Здесь я собрал ответы на самые распространенные вопросы по OSA.
Можно ли вызывать сервисы ожидания из функций, вызываемых задачами?
Ожидание события в цикле
Можно ли создать задачу по указателю на функцию?
Что будет, если отсылать/принимать сообщения из неинициализированной очереди?
Изменение типов сообщений
Если две задачи ожидают одного и того же события
Модификация тела сообщения до того, как оно будет принято
Использование таймеров вне задач
Почему виснет OS_Delay?
Ответ: нет.
Этот вопрос, пожалуй, задают чаще остальных. Такой подход, действительно, выглядит очень заманчивым, когда, например, в задачах часто вызывается сервис OS_Delay с одним и тем же параметром:
// В этом примере просто напрашивается вынос OS_Delay(10) в отдельную функцию. // Сам вызов сервиса занимает около 10 слов ROM, и его, конечно, хочется // заменить одним вызовом. ... OS_Delay(10); ... if (...) OS_Delay(10); ... do { ... OS_Delay(10); ... } while (...); ...
или когда есть несколько задач, ожидающих одно и то же событие:
... // Ждем, когда освободится доступ в EEPROM OS_Bsem_Wait(BS_EEPROM_FREE); ...
К сожалению в ОСРВ OSA такое недопустимо. Дело в том, что при таком подходе произойдет путаница с адресами возврата в стеке. Рассмотрим на примере:
//----------------------------------------------- void Delay10 (void) { OS_Delay(10); } //----------------------------------------------- void Bsem_Wait (void) { OS_Bsem_Wait(BS_BINSEM); } //----------------------------------------------- void TaskA (void) { for (;;) { Delay10(); /*...*/ Delay10(); /*...*/ Delay10(); /*...*/ } } //----------------------------------------------- void TaskB (void) { for (;;) { Bsem_Wait(); /*...*/ Bsem_Wait(); /*...*/ Bsem_Wait(); /*...*/ } } //-----------------------------------------------
Итак, у нас есть две задачи: TaskA и TaskB. Обе вызывают разные функции, в каждой из которых есть сервис, содержащий код возврата в планировщик (в перечне сервисов все такие сервисы в примечании отмечены буквой "T"). Для простоты рассмотрим работу этого примера на PIC16. (Примечание: планировщик в этих контроллерах передает управление задачам присвоением адреса задачи паре регистров PCLATH:PCL, а задачи возвращают управление планировщику, совершая переход на него по GOTO. Таким образом экономится стек, т.к. ни задачи, ни планировщик не вызываются через CALL). При выполнении программы возможна такая последовательность:
. | Действие | Передача управления | Стек |
---|---|---|---|
1 | Планировщик запускает задачу TaskA | PCLATH:PCL=TaskA | - |
2 | Задача TaskA вызывает функцию Delay10() | CALL Delay10. | ret_addr_A - |
3 | Функция Delay10 вызывает системный сервис OS_Delay, который, инициализировав задержку, передает управление планировщику | GOTO sched | ret_addr_A - |
4 | Планировщик запускает задачу TaskB | PCLATH:PCL=TaskB | ret_addr_A - |
5 | Задача TaskB вызывает функцию Bsem_Wait() | CALL Bsem_Wait | ret_addr_B ret_addr_A - |
6 | Функция Bsem_Wait вызывает системный сервис OS_Bsem_Wait, который передает управление планировщику | GOTO sched | ret_addr_B ret_addr_A - |
7 | Планировщик крутится вхолостую, пока идет задержка, запущенная в задаче TaskA и пока не установлен семафор, которого ожидает задача TaskB | ret_addr_B ret_addr_A - |
|
8 | Задержка закончилась, плнировщик передает управление задаче TaskA в то же место, откуда был возврат в планировщик, а именно - в середину функиции Delay10 | PCLATH:PCL=Delay10 | ret_addr_B ret_addr_A - |
9 | И теперь - кульминация: функция делает возврат, при котором из стека берется последний положенный туда адрес, а именно - ret_addr_B | RETURN | ret_addr_A - |
Как видно, после 9-ой операции мы из функции Delay10 вернемся в функцию-задачу TaskB, хотя должны были вернуться в TaskA.
Примечание. В принципе, такой подход допускается, если у программиста есть уверенность в том, что в один момент времени только одна задача производит вызов такой функции. Однако, здесь надо быть крайне осторожным и хорошо понимать, что он делает. Поэтому, если Вы не уверены, что уследите за вызовами при дальнейшем росте программы, - не применяйте такой прием!
Некоторые из присланных мне программ содержали однотипную ошибку, которую я бы хотел здесь обрисовать. Иногда бывает так, что в ходе ожидания какого-либо события требуется выполнять какое-то действие. Поэтому код этого ожидания некоторые писали без использования сервисов OS_xxx_Wait, заменяя их циклом do {…} while. Рассмотрим отвлеченный пример: пока ожидаем установки какого-то двоичного семафора, нам нужно сравнивать напряжения на двух входах АЦП и, в зависимости от результата сравнения, зажигать либо красный либо зеленый светодиод.
Код такого ожидания выглядел так:
do { if (ADC_Read(0) > ADC_Read(1)) // Сравниваем напряжения на двух аналоговых входах { GREEN_LED = 1; RED_LED = 0; } else { GREEN_LED = 0; RED_LED = 1; } OS_Yield(); // Возврат в планировщик } while (!OS_Bsem_Check(BS_START));
Ошибка такого подхода заключается в том, что задача, крутясь в таком цикле, является всегда готовой к выполнению. И если в программе есть задачи с более низким приоритетом, то они не смогут получить управление до тех пор, пока эта задача не дождется семафора. А если ожидаемый ей семафор должна установить как раз задача с более низким приоритетом, то программа просто зависнет в вечном ожидании.
Как быть в таких случаях? Здесь есть несколько вариантов решения этой коллизии.
С точки зрения концепции ОСРВ такой способ самый правильный. В данном конкретном примере сравнение напряжений на входах АЦП и ожидание семафора - функционально разные действия и нет никакого смысла выполнять их одновременно.
OST_TASK_POINTER tp; /******************************************************************************/ // Отдельная задача для работы с АЦП и светодиодами /******************************************************************************/ void Task_ADC_Leds (void) { tp = OS_GetCurTask(); for (;;) { /*...*/ /* Здесь сравниваем напряжения */ /*...*/ OS_Yield(); // Возврат в планировщик } } /******************************************************************************/ // Наша задача /******************************************************************************/ void Task (void) { for (;;) { /*...*/ // Перед ожиданием создаем задачу сравнения напряжений OS_Task_Create(7, Task_ADC_Leds); // Ждем наш семафор OS_Bsem_Wait(BS_START); // Удаляем задачу сравнения напряжений OS_Task_Delete(tp); /*...*/ } }
Но при своей правильности этот подход не всегда оправдан, т.к. требует наличие свободного дескриптора на момент создания новой задачи, дополнительной глобальной переменной и времени на создание/удаление задачи.
Понизив приоритет до минимального, мы исключаем, что какая-либо задача окажется блокированной. С точки зрения ресурсов контроллера я бы назвал такой подход оптимальным.
static char prio; /*...*/ prio = OS_Task_GetPriority(this_task); // Запоминаем текущий приоритет задачи OS_Task_SetPriority(this_task, 7); // Понижаем приоритет до минимального do { /*...*/ /* Здесь сравниваем напряжения */ /*...*/ OS_Yield(); } while (!OS_Bsem_Check(BS_START)); OS_Task_SetPriority(this_task, prio); // После цикла восстанавливаем сохраненный // приоритет
Примечание: рекомендуется понижать приоритет не до самого низкого (7-го), а до предпоследнего (6-го), т.к.низший приоритет удобно использовать для задачи SLEEP'а.
Заменив OS_Yield() на OS_Delay(1), мы гарантировано на время одного системного тика ставим задачу в режим ожидания (время задержки можно увеличить, если одного тика мало):
do { /*...*/ /* Здесь сравниваем напряжения */ /*...*/ OS_Delay(1); } while (!OS_Bsem_Check(BS_START));
Недостатком такого способа будет увеличение периода сравнения напряжений. Возможно, в данном примере это не страшно, но в другом случае, если операции внутри цикла критичны ко времени, это может отрицательно сказаться на логике работы устройства.
Ответ: да
В OSA есть два недокументированных сервиса, которые позволяют это сделать. Я их не стал описывать в общей документации, чтобы не вносить путаницы в логику работы задач. Тем не менее, такой способ создания задач может оказаться очень удобным в пользовательских приложениях, где адрес функции-задачи привязан, например, к пункту меню. Предположим, у нас определен тип структуры, содержащей название пункта меню и адрес задачи, которую нужно будет выполнять по этому пункту:
typedef struct { const char* strMenu; void (*Func)(void); } TMenuItem;
Далее в программе определен массив этих структур:
const TMenuItem UserMenu[] = { {"Load", Task_Load}, {"Save", Task_Save}, {"View", Task_View} {"Edit", Task_Edit} };
Все эти задачи описываются как обычно, например:
void Task_Load (void) { for (;;) { /*...*/ } }
Далее - одна тонкость. Чтобы компилятор правильно строил дерево вызовов подпрограмм, ему нужно указать, что функции, которые мы перечислили в массиве, являются задачами. Для этого в main() нужно для каждой такой функции вызвать сервис OS_Task_Reserve:
void main (void) { /*...*/ OS_Task_Reserve(Task_Load); OS_Task_Reserve(Task_Save); OS_Task_Reserve(Task_View); OS_Task_Reserve(Task_Edit); /*...*/ }
После этого компилятор будет знать, что эти функции косвенно вызываются из main, и правильно распределит локальные переменные этих функций.
Теперь в произвольном месте программы можно создавать задачи по указателю на функцию, пользуясь специальным сервисом OS_Task_CreateP:
LCD_Out(UserMenu[i].strMenu); // Выводим на экран название функции OS_Task_CreateP(0, UserMenu[i].Func); // Создаем задачу с высшим приоритетом
Ответ: ничего хорошего
Это относится не только к неинициализированной очереди, но и к неинициализированным: счетным семафорам, коротким сообщениям, указателям на сообщения. Неизвестно, что содержат в себе эти переменные на момент обращения к ним. Поэтому нужно всегда следить за тем, чтобы эти объекты ОС инициализировались до первого обращения к ним.
Рекомендую проанализировать работу следующего примера:
OST_QUEUE q; OST_MSG smsg; OST_MSG rmsg; void Task1 (void) { OS_Queue_Create(q); // Создаем очередь (инициализируем) for (;;) { OS_Queue_Send(q, smsg); // Отсылаем сообщение в очередь } } void Task2 (void) { for (;;) { OS_Queue_Wait(q, rmsg); // Ожидаем сообщение из очереди } } void main (void) { OS_Init(); OS_Task_Create(1, Task1); OS_Task_Create(0, Task2); OS_Run(); }
Обратите внимание на расстановку приоритетов: приоритет задачи Task2 выше, чем Task1. Это означает, что первой выполнится именно задача Task2, т.е. та, которая ожидает сообщение из очереди, а задача, инициализирующая очередь, запустится второй (если OS_Queue_Wait не вызовет сбой программы). При всей очевидности выхода из ситуации (а именно - правильной расстановке приоритетов), допустить такую ошибку очень просто. Для неприоритетного режима - вообще неизвестно, какая задача запустится первой. Поэтому очереди (и все остальные объекты, требующие инициализации) следует инициализировать так, чтобы на момент обращения к ним они гарантировано были инициализированны. Тривиальный способ - создавать их в функции main() до вызова сервиса OS_Run().
OSA имеет два вида сообщений: указатель на сообщение и короткое однобайтовое сообщение. Различаются они тем, что с помощью первого можно передавать любой объем информации, т.к. фактически передается только указатель на нее, а с помощью второго - только одно значение (по умолчанию это значения от 1 до 255). Учитывая архитектурные особенности PIC-контроллеров, программистам оставлена возможность изменять типы этих сообщений.
Сначала поговорим о типе указателя на сообщение. По умолчанию указатель на сообщение имеет тип void* , т.е. указатель на область RAM-памяти. Учитывая, что ядро PIC-контроллера построены по гарвардской архитектуре (раздельные шины адреса для памяти данных и программы), указатели на данные в ОЗУ и указатели на константы, хранящиеся в программной памяти, - это разные вещи. Поэтому ОСРВ OSA предоставляет программисту возможность на этапе написания программы выбрать тип указателей на сообщения. Этот тип нужно указать в файл конфигурации osacfg.h:
#define OS_MSG_TYPE const char *
в этом примере мы заменяем тип указателя на сообщение так, что сможем в программе обмениваться строковыми константами.
OST_MSG_CB msg_cb; // Дескриптор сообщения const char * MenuStrings[] = {"Load", "Save", "Save as...", "Exit"}; void Task_Menu (void) { for (;;) { for (i = 0; i < 4; i++) OS_Msg_Send(msg_cb, MenuString[i]); /*...*/ } }
Примечание: в программе может быть применен только один тип для указателей на сообщения, и он не может меняться в ходе выполнения программы. (Исключение составляют указатели в HT-PICC18, когда указан ключ компиляции -CP24.)
Теперь об одной ошибке, вернее, о некоторой некорректности использования возможности замены типа указателя на сообщения. Несколько раз в программах видел такое:
typedef struct { char* name; int age; int weight; } TMyStruct; #define OS_MSG_TYPE TMyStruct *
Т.е. программист создает некую свою структуру и тип сообщения заменяет указателем на нее. Ошибки здесь, конечно, нет. Но концептуально такой подход довольно спорный. За год программист может написать одну программу, а может и 10, и 20. Каждая программа может быть индивидуальна, и данные, которыми будут обмениваться задачи, - тоже. Если в каждой программе подменять тип указателя на сообщение каким-то специфичным указателем вместо void*, то возникнет некоторая неразбериха, да и проблемы с переносом модулей. Если предполагается работать с указателями на эти структуры, расположенные в RAM-области памяти, то лучше оставить тип void*. Это же замечание касается указателей на структуры, расположенные в ROM-области памяти - их лучше определять как const void* .
Теперь два слова о изменении типа короткого сообщения. Для чего оно вообще сделано? Две причины: экономия RAM и повышение скорости. Довольно часто между программами нужно обмениваться незначительными объемами информации: "нажата кнопка 5", "переключиться в режим 3", "зажечь светодиод 12". Преимущества перед указателями на сообщения:
По умолчанию короткое сообщение имеет тип unsigned char. Этот тип может быть заменен на любой перечислимый тип (int, long, float, bit) заданием константы в файле osacfg.h:
#define OS_SMSG_TYPE unsigned long
Такое, хоть и редко, но бывает нужно.
У короткого сообщения есть две особенности, которые нужно учитывать при проектировании программы:
При написании программы с использованием ОСРВ OSA нужно учитывать одну особенность ее планировщика. При поиске лучшей готовой задачи для выполнения планировщик в цикле пробегается по всем дескрипторам, проверяет готовность задач и сравнивает их приоритеты. Управление получит готовая задача с высшим приоритетом. Если есть несколько задач с одинаковым приоритетом, то управление получит та, которая была рассмотрена планировщиком раньше. Дескрипторы задач хранятся в массиве, который планировщиком рассматривается как кольцевой. Каждый раз он начинает поиск готовой задачи со следующей после последней выполненной. Например, у нас 5 задач. Последней задачей выполнялась 3-я. Тогда порядок проверки задач при следующей работе планировщика будет таким: 4, 5, 1, 2, 3.
Проблема в том, что если две задачи ждут одного и того же события, например, семафора, который устанавливается третьей задачей, то одна из задач никогда не получит управление. Рассмотрим пример:
/******************************************************************************/ // Задача, устанавливающая семафор /******************************************************************************/ void Task1 (void) { for (;;) { OS_Bsem_Set(bsem); OS_Delay(10); } } /******************************************************************************/ // Первая задача, ожидающая семафор /******************************************************************************/ void Task2 (void) { for (;;) { OS_Bsem_Wait(bsem); /*...*/ } } /******************************************************************************/ // Вторая задача, ожидающая семафор /******************************************************************************/ void Task3 (void) { for (;;) { OS_Bsem_Wait(bsem); /*...*/ } } /******************************************************************************/ // /******************************************************************************/ void main (void) { OS_Init(); OS_Task_Create(1, Task1); // Все задачи с равными приоритетами OS_Task_Create(1, Task2); OS_Task_Create(1, Task3); OS_Run(); }
Последовательность просмотра задач планировщиком будет такова: Task1, Task2, Task3, Task1, Task2, Task3, … . Проблема в том, что Task2 и Task3 не получают управления до тех пор, пока не будет установлен двоичный семафор, а устанавливается он только в Task1. Поэтому всегда будет происходить одна и та же последовательность:
Как тут быть? Единого способа решения нет. Можно ретранслировать семафор дальше, т.е. дождавшись его в Task2, сразу же установить его, чтобы и Task3 смогла получить управление. Но это не лучший вариант, т.к. неизвестно, сколько задач в цепочке, и на какой нужно останавливать установку семафора. Можно использовать счетный семафор, но опять же нужно знать, сколько задач его ожидают, чтобы установить в нем правильное число. На мой взгляд, самым удобным в таком случае будет использование флагов. В задаче-отправителе устанавливать все биты флага:
/*...*/ OS_Flag_Set_1(flag, 0xFF); /*...*/
а в задачах-приемниках ожидать только своего.
/* В задаче Task2 */ OS_Flag_Wait(flag, 0x01); OS_Flag_Set_0(flag, 0x01); /* В задаче Task3 */ OS_Flag_Wait(flag, 0x02); OS_Flag_Set_0(flag, 0x02);
В общем, способы решения есть, а какой применять, - на усмотрение программиста. Главное - надо помнить об этой особенности планировщика.
В одной присланной мне программе была допущена такая ошибка: задача формировала тело сообщения, отправляла другой задаче указатель на него, а потом сразу же модифицировала для отправки следующего. Выглядело это так:
OST_MSG_CB msg_cb; void Task (void) { static char buf[3]; for (;;) { // Формируем первое сообщение buf[0] = '1'; buf[1] = '2'; buf[2] = '3'; OS_Msg_Send(msg_cb, buf); OS_Yield(); // Формируем второе сообщение buf[0] = '5'; buf[1] = '6'; buf[2] = '7'; OS_Msg_Send(msg_cb, buf); OS_Yield(); /*...*/ } }
Не смотря на то, что после отправки сообщения выполняется OS_Yield, чтобы дать возможность адресату получить сообщение, оно может и не доставиться с первого раза (по любой причине: задача-приемник чем-то занята, или имеет низкий приоритет, или, наконец, находится в режиме паузы). Сервис OS_Msg_Send устроен так, что он не сможет отправить сообщение до тех пор, пока предыдущее не получено адресатом. Тем не менее, следует помнить, что этот сервис следит только за дескриптором сообщения, а не за областью памяти, где фактически расположено тело.
Что произойдет в нашем примере, если после первого OS_Yield задача-адресат не успеет принять отправленное ей сообщение? Фактически ей отправляется только адрес области памяти, где располагается массив buf, в котором на момент отправки лежат значения '1', '2' и '3'. Итак, после выполнения OS_Yield сообщение не было принято. Планировщик возвращает управление задаче отправителю, и она продолжает свое выполнение с того места, откуда вышла, т.е. со следующей строки после OS_Yield. Здесь у нас происходит замена элементов буфера buf на '5', '6' и '7'. После этого задача Task пытается отправить очередное сообщение, но так как предыдущее еще не получено адресатом, то эта задача становится в ожидание, когда дескриптор освободится.
По прошествии какого-то времени адресат получит сообщение и освободит дескриптор, но в теле сообщения будут уже новые значения (не "123", а "567"). Задача отправитель же, дождавшись освобождения дескриптора, отсылает следующее сообщение; фактически - это тот же указатель на тот же участок памяти. И задача-адресат второй раз подряд получит "567".
Как этого избежать? Всего-навсего перед формированием нового сообщения в том же буфере нужно проверять, было ли предыдущее сообщение доставлено.
// Формируем первое сообщение buf[0] = '1'; buf[1] = '2'; buf[2] = '3'; OS_Msg_Send(msg_cb, buf); OS_Cond_Wait(!OS_Msg_Check(msg_cb)); // Ставим задачу в режим ожидания до тех пор, // пока сообщение не будет принято // Формируем второе сообщение buf[0] = '5'; buf[1] = '6'; buf[2] = '7'; OS_Msg_Send(msg_cb, buf); OS_Yield();
Как статические, так и динамические таймеры могут использоваться в любом месте программы (за исключением сервисов Wait и Delay). Это позволяет:
1. В любом месте программы ожидать какого-то условия с выходом по таймауту.
char MyFunc1 (void) { /*...*/ OS_Stimer_Run(0, 10); // Запускаем статический таймер 0 // на отсчет 10 системных тиков while (!RB0 && !OS_Stimer_Check(0)) // Ожидаем установки RB0 continue; /*...*/ }
2. Формировать задержку внутри фоновых функций (не задач).
void MyFunc2 (void) { /*...*/ OS_Stimer_Run(0, 10); // Запускаем статический таймер 0 // на отсчет 10 системных тиков while (!OS_Stimer_Check(0)) continue; // Ожидаем конца счета /*...*/ }
3. Выделять квант времени для работы какой-либо функции.
char buffer[10]; void Task (void) { static OST_DTIMER dt; OS_Dtimer_Create(dt); // Инициализируем таймер for (;;) { OS_Dtimer_Run(dt, 50); // Выделяем для функции Receive квант // времени в 50 тиков if (Receive(&dt) == 1) { /*...*/ } /*...*/ } } char Receive (OST_DTIMER *dt) { static bit b; do { b = GetBit(); // Принимаем бит данных ShiftBit(b); // Вдвигаем его в буфер if (CheckSumOK()) return 1; // Проверяем контрольную сумму } while (!OS_Dtimer_Check(*dt)); // Висим в цикле, пока таймер не досчитает // Попали сюда, значит ничего не приняли за отведенное время return 0; }