Для начала рассмотрим самый простой пример - параллельную работу двух пустых задач. Практически он ничего не будет делать, но для нас может оказаться полезным для понимания механизма работы ОС. Данную программу можно будет погонять в симуляторе, наставив брейкпоинтов. Цель урока - исследование работы сервиса безусловного переключения контекста OS_Yield().
Для начала создадим проект в оболочке MPLab по шагам, описанным в документации.
Проект создан, можно переходить ко второму шагу.
Создаем файл конфигурации для нашего проекта, для чего воспользуемся утилитой OSAcfg_Tool.exe. После запуска программы перед нами диалоговое окно, позволяющее нам в интерактивном режиме сделать все настройки ОС для конкретного проекта. Для начала укажем программе, где проект будет располагаться, для этого кнопкой "Browse…" открываем диалоговое окно выбора файла и в нем выбираем рабочую папку "c:\tutor\t1", где будет располагаться наш файл конфигурации. Нажимаем "OK". Программа сообщит нам, что файл еще не существует и будет создан при сохранении. Жмем "OK".
Пока не будем вдаваться в подробности, касающиеся назначения различных секций в диалоговом окне, а ограничимся минимальным набором, который будет необходим нам для запуска первого проекта. Единственное, что нам нужно сделать, это установить количество задач в секции "System" равное двум (т.к. у нас будет две задачи). После этого жмем кнопку "Save" в нижней части экрана, читаем сообщение, что файл успешно сохранен, давим "OK" и выходим из программы конфигуратора по кнопке "Exit".
Убеждаемся, что файл OSAcfg.h создан в папке "c:\tutor\t1". Для интереса можете заглянуть в его содержимое. Там будет только одна значащая строчка (помимо сопровождающих комментариев):
#define OS_TASKS 2
Эта строчка скажет компилятору при сборке, что нужно зарезервировать память под два дескриптора задач. Сейчас для нас это главное.
Включаем в tutor1.c используемые заголовочные файлы: pic.h и osa.h.
#include <pic.h> #include <osa.h>
Через меню MPLab "Project\Build options…\Project" открываем параметры проекта и во вкладке "Directories" добавляем два пути в "Include Search Path":
В функцию main() добавляем вызов двух обязательных системных сервисов: OS_Init() и OS_Run().
#include <pic.h> #include <osa.h> void main (void) { OS_Init(); OS_Run(); }
Как уже было описано во "Введении", OS_Init() инициализирует все внутренние переменные системы, обнуляет список дескрипторов задач. OS_Run() организует вечный цикл, в котором просматривает список задач на предмет готовности, и когда обнаруживает готовую задачу, передает ей управление.
Как уже было сказано, задача в OSA - это обычная функция Си, которая содержит внутри себя вечный цикл, внутри которого вызывается хотя бы один сервис ОС, переключающий контекст. Для нашего примера мы воспользуемся самым простым сервисом переключения контекста - OS_Yield(), который просто передает управление обратно планировщику, оставляя задачу в состоянии готовности. Для наглядности мы нагрузим каждую задачу какой-нибудь простой операцией. В большинстве случаев в качестве такой операции приводят увеличение какой-нибудь переменной на 1. Мы не будем мудрить и воспользуемся общим подходом: для каждой задачи будет заведена своя переменная-счетчик, значение которой мы и будем отслеживать в процессе отладки программы. Итак, у нас будут две одинаковые задачи следующего вида:
char m_cCounter1; char m_cCounter2; void Task_T1 (void) { m_cCounter1 = 0; for (;;) { OS_Yield(); // Передача управления операционной системе m_cCounter1++; } } void Task_T2 (void) { m_cCounter2 = 0; for (;;) { OS_Yield(); // Передача управления операционной системе m_cCounter2++; } }
Как видно, обе функции: Task_T1 и Task_T2 - соответствуют требованиям, предъявляемым к функциям-задачам, а именно:
Все, что нам осталось сделать, - это дописать в функцию main() вызовы сервисов OS_Task_Create, которые сообщат операционной системе, что функции Task_T1 и Task_T2 являются задачами ОС. Эти сервисы должны вызываться после OS_Init (т.к. сам OS_Init, помимо всего прочего, обнуляет список задач) и перед OS_Run (т.к., как уже писалось ранее, этот сервис содержит вечный цикл, и код, стоящий после вызова этого сервиса выполнен не будет).
void main (void) { OS_Init(); OS_Task_Create(3, Task_T1); OS_Task_Create(3, Task_T2); OS_Run(); }
Первым параметром в сервисе OS_Task_Create указывается приоритет создаваемой задачи от 0 (высший) до 7 (низший). Сейчас задачам установлен одинаковый приоритет (позже будет объяснено, почему).
Итак, полный текст нашей программы теперь будет выглядеть так:
#include <osa.h> //****************************************************************************** // Глобальные переменные //****************************************************************************** char m_cCounter1; char m_cCounter2; //****************************************************************************** // Функции-задачи //****************************************************************************** void Task_T1 (void) { m_cCounter1 = 0; for (;;) { OS_Yield(); // Передача управления операционной системе m_cCounter1++; } } //------------------------------------------------------------------------------ void Task_T2 (void) { m_cCounter2 = 0; for (;;) { OS_Yield(); // Передача управления операционной системе m_cCounter2++; } } //****************************************************************************** // main //****************************************************************************** void main (void) { OS_Init(); // Инициализация переменных системы OS_Task_Create(3, Task_T1); // Добавление задач в список OS_Task_Create(3, Task_T2); OS_Run(); // Запуск планировщика } //****************************************************************************** // end of file //******************************************************************************
Как будет работать наша программа? После обработки сервиса OS_Init список задач пуст. Два последовательно вызванных сервиса OS_Task_Create записывают в свободные ячейки списка задачи Task_T1 и Task_2. Обе задачи после создания находятся в состояния готовности. Далее мы запускаем в работу планировщик сервисом OS_Run. Он будет перебирать все активные (созданные) задачи в списке задач, проверять их готовность, сравнивать приоритеты и запускать самую приоритетную из готовых. Если к выполнению готовы несколько задач с одинаковым приоритетом, то они будут выполняться по очереди.
Итак, планировщик по очереди просмотрит обе задачи. Они обе готовы и обе имеют одинаковый приоритет, поэтому планировщик передаст управление сперва той, которая была создана первой, т.е. Task_T1. Задача переводится из состояния готовности в состояние "в работе". При первом запуске задача начинает работать с самого начала функции, в нашем случае первым выполнится присваивание переменной m_cCoutner1 значения "0". Далее мы попадаем в вечный цикл, в котором первым шагом производится передача управления планировщику сервисом OS_Yield. Т.е. OS_Yield() является своего рода эквивалентом return'а (но это не одно и то же). При выполнении сервиса OS_Yield() мы покинем функцию-задачу Task_T1 и вернемся в планировщик, задача при это переводится из состояния "в работе" в состояние "готовности" (при этом во внутреннюю переменную системы будет сохранен адрес выхода из задачи, и при следующем входе в нее выполнение продолжится с того места, откуда она была покинута, т.е. со следующей за OS_Yield строки).
Далее в работе опять планировщик. Он снова проверяет задачи на готовность и сравнивает приоритеты. Т.к. обе задачи опять являются готовыми и приоритеты у них равны, то он берет следующую по очереди задачу, т.е. Task_T2. Теперь Task_T2 переводится из состояния "готовности" в состояние "в работе", и с ней повторяется все то, что происходило с Task_T1. После обнуления счетчика m_cCounter2 и вызова сервиса OS_Yield управление опять получит планировщик, сохранив при этом адрес возврата в задачу и переведя ее из состояния "в работе" в состояние "готовности".
Следующей планировщик запустит задачу Task_T1. Но теперь уже она будет выполняться не с начала, а с того места, откуда мы ее покинули, т.е. со следующей строчки после OS_Yield. Поэтому мы сразу попадаем на увеличение счетчика m_cCounter1. После увеличения счетчика программа переходит на начало цикла. Т.е. вновь выполняется OS_Yield, который передает управление планировщику, сохраняя адрес возврата и переводя задачу в состояние "готовности".
Далее все то же самое повторяется с задачей Task_T2. Задачи будут выполняться по очереди в течение всего времени работы программы, увеличивая каждая свой счетчик. Графически это можно изобразить так:
Заметим важную деталь: в данном примере на работу планировщика затрачивается больше времени, чем на работу задач. Это не удивительно, ведь планировщик проверяет все задачи в списке, смотрит, какая из них в каком состоянии, выполняет сравнение приоритетов, вычисляет, какую задачу запустить. А сами задачи делают всего одну команду - увеличение счетчика. Потому и получается так, что большую часть времени контроллер тратит на работу планировщика.
Здесь мы проведем несколько экспериментов для исследования работы сервиса OS_Yield. Сперва просто прогоним программу в симуляторе и убедимся, что она работает именно так, как было описано выше.
Включим симулятор через меню "Debugger\Select Tool\MPLAB SIM". Соберем проект по Ctrl+F10. Установим точки останова так, как показано на рисунке ниже:
Теперь, нажимая кнопку F9 (Run), проследим за тем, в какой последовательности будут получать управление задачи. Первым делом мы попадаем на начало Task_T1, как раз ту строчку, где производится обнуление переменной m_cCounter1.
Итак, мы находимся на первой точке останова в задаче Task_T1. Если мы сейчас нажмем F9, то произойдет следующее:
Другими словами, если сейчас нажмем F9, то выйдем из задачи Task_T1 и войдем в Task_T2. Нажимаем F9 и убеждаемся, что так оно и произошло.
При следующем нажатии F9 произойдет все то же самое, что и в предыдущем абзаце, с той только разницей, что планировщик передаст управление задаче Task_T1 и не в начало, а на следующую за OS_Yield() строчку. Нажимаем F9 и видим, что мы вновь находимся в задаче Task_T1, причем уже на второй точке останова, где производится увеличение счетчика Task_T1.
При дальнейших нажатиях F9 мы увидим, что программа переключается между двумя задачами, каждая из которых увеличивает свой счетчик (значение счетчиков можно наблюдать в окне Watch). Также можем обратить внимание на то, что ни одна из задач больше не начинается сначала, т.е. они обе крутятся в вечном цикле. При этом значения счетчиков растут строго синхронно, т.к. задачи запускаются по очереди, а увеличение счетчика производится при каждом запуске задачи.
Так мы написали простейшую программу с использованием RTOS OSA. Теперь, когда мы разобрались с тем, как передается управление, можно немного поэкспериментировать.
Попробуем добавить в задачу Task_T2 еще один дополнительный вызов сервиса OS_Yield():
void Task_T2 (void) { m_cCounter2 = 0; for (;;) { OS_Yield(); OS_Yield(); m_cCounter2++; } }
Пересоберем программу (Ctrl+F10). Теперь, нажимая F9 и наблюдая при этом за поведением счетчиков m_cCounter1 и m_cCounter2, мы увидим, что счетчик во второй задаче растет в два раза медленнее, чем в первой. Почему так происходит?
Дело в том, что хоть при равных приоритетах готовые задачи запускаются по очереди, но задача Task_T2 переключает контекст в двух местах. Т.е. задача Task_T1, отработав первый раз (после обнуления счетчика), передает управление Task_T2 (на самом деле, конечно, она передает управление планировщику, а уже планировщик передает его задаче Task_T2, но для краткости будем считать, что это Task_T1 передает управление Task_T2). Task_T2, обнулив счетчик, так же по OS_Yield() передает управление Task_T1, которая продолжается с того места, откуда была покинута, т.е. попадаем на строчку m_cCounter1++. После этого Task_T1 снова передает управление задаче Task_T2. А задача Task_T2 продолжается с того места, откуда была покинута, т.е. со следующей за первым OS_Yield строки, т.е. со второго OS_Yield. Таким образом, не добравшись еще до увеличения счетчика m_cCounter2, задача Task_T2 снова передает управление задаче Taks_T1, которая снова увеличивает счетчик. И только после третьего получения управления задача Task_T2 доберется до увеличения своего счетчика.
Схематически поведение программы можно изобразить так (для простоты не показан планировщик):
Как видно, из-за одного добавленного вызова сервиса OS_Yield в задаче Task_T2 увеличение счетчика происходит в два раза медленнее.
Следующим экспериментом мы проверим требование N2 к функции-задаче: внутри бесконечного цикла должен быть хотя бы один вызов сервиса, переключающего контекст (передающего управление планировщику). Теперь мы из задачи Task_T2 вообще удалим все сервисы OS_Yield, оставив только операцию увеличения счетчика.
void Task_T2 (void) { m_cCounter2 = 0; for (;;) { m_cCounter2++; } }
Пересоберем проект (Ctrl+F10). Теперь посмотрим, что будет, если мы начнем выполнять программу. После первого нажатия F9, мы, как обычно, попадаем на начало задачи Task_T1. После обнуления счетчика управление передается задаче Task_T2. Задача Task_T2 обнуляет счетчик и попадает в вечный цикл, в котором будет увеличиваться счетчик m_cCounter2. Вот тут-то мы и прочувствуем, отчего есть требование к задачам иметь в себе хотя бы один вызов, переключающий контекст. Дело в том, что после попадания в задачу Task_T2 мы из нее уже никогда не выберемся, а будем вечно крутиться в цикле. В планировщик мы никогда не вернемся, следовательно, никакие другие задачи управление получить уже не смогут.
Теперь проделаем такой опыт: изменим приоритет одной из задач. Пусть задача Task_T2 теперь будет с более низким приоритетом, чем Task_T1. Для этого заменим аргумент в вызове сервиса создания задачи (чем значение больше, тем приоритет ниже):
OS_Task_Create(4, Task_T2);
Пересоберем проект (Ctrl+F10). Теперь, нажимая F9, мы увидим, что задача Task_T2 вообще не получает управления, а программа постоянно выполняет задачу Task_T1. Разберемся, почему это произошло. Обе задачи при начале работы планировщика являются готовыми к выполнению. Управление получит та, приоритет которой выше (или они будут получать управление по очереди, если их приоритеты равны). Очевидно, что управление получит задача Task_T1. Нажимаем F9 и убеждаемся, что так оно и есть. Рассмотрим, что произойдет, если мы нажмем F9 еще раз. После обнуления счетчика m_cCounter1 мы доходим до вызова сервиса OS_Yield, который передает управление планировщику, оставляя задачу в состоянии готовности. Далее планировщик пробегается по списку задач, находит все готовые (это опять будут задачи Task_T1 и Task_T2), а потом выбирает из них самую высокоприоритетную, и ей снова окажется задача Task_T1. И так будет при каждом входе в планировщик, т.е. каждый раз самой приоритетной из готовых будет оказываться задача Task_T1, поэтому задача Task_T2 управление никогда не получит.
В данном уроке мы рассмотрели работу простейшей программы, написанной с использованием RTOS OSA, просмотрели в симуляторе порядок работы программы и порядок смены задач. В "Эксперименте 2" мы убедились в необходимости соблюдать требования к функциям-задачам, а именно наличие в них вызова сервисов, переключающих контекст. Кроме того, мы исследовали работу сервиса безусловного переключения контекста OS_Yield и узнали его свойства: