Available Languages?:

OSA : Учебник. Урок 1 - OS_Yield

Тема

Для начала рассмотрим самый простой пример - параллельную работу двух пустых задач. Практически он ничего не будет делать, но для нас может оказаться полезным для понимания механизма работы ОС. Данную программу можно будет погонять в симуляторе, наставив брейкпоинтов. Цель урока - исследование работы сервиса безусловного переключения контекста OS_Yield().

Проект

Для начала создадим проект в оболочке MPLab по шагам, описанным в документации.

1. Создание проекта

  1. создаем рабочую папку "c:\tutor\t1";
  2. в ней создаем пустой файл "tutor1.c";
  3. запускаем MPLab (если в ней открыт проект, то закрываем его через меню "File\Close Workspace";
  4. через меню "Project\Project Wizard…" создаем новый проект:
    1. в качестве процессора можно пока использовать любой (например, 16F887, т.к. он установлен на демоплате из набора PicKit2);
    2. выбираем инструмент (toolsuite): HI-TECH PICC Toolsuite;
    3. создаем файл проекта с именем tutor1.mcp в папке "c:\tutor\t1";
    4. добавляем в проект созданный нами пустой файл tutor1.c и файл системы "c:\osa\osa.c";
    5. нажимаем кнопку "Готово".

Проект создан, можно переходить ко второму шагу.

2. Конфигурирование проекта (osacfg.h)

Создаем файл конфигурации для нашего проекта, для чего воспользуемся утилитой OSAcfg_Tool.exe. После запуска программы перед нами диалоговое окно, позволяющее нам в интерактивном режиме сделать все настройки ОС для конкретного проекта. Для начала укажем программе, где проект будет располагаться, для этого кнопкой "Browse…" открываем диалоговое окно выбора файла и в нем выбираем рабочую папку "c:\tutor\t1", где будет располагаться наш файл конфигурации. Нажимаем "OK". Программа сообщит нам, что файл еще не существует и будет создан при сохранении. Жмем "OK".

Пока не будем вдаваться в подробности, касающиеся назначения различных секций в диалоговом окне, а ограничимся минимальным набором, который будет необходим нам для запуска первого проекта. Единственное, что нам нужно сделать, это установить количество задач в секции "System" равное двум (т.к. у нас будет две задачи). После этого жмем кнопку "Save" в нижней части экрана, читаем сообщение, что файл успешно сохранен, давим "OK" и выходим из программы конфигуратора по кнопке "Exit".

Убеждаемся, что файл OSAcfg.h создан в папке "c:\tutor\t1". Для интереса можете заглянуть в его содержимое. Там будет только одна значащая строчка (помимо сопровождающих комментариев):

#define OS_TASKS          2

Эта строчка скажет компилятору при сборке, что нужно зарезервировать память под два дескриптора задач. Сейчас для нас это главное.

3. #include <osa.h>

Включаем в tutor1.c используемые заголовочные файлы: pic.h и osa.h.

#include <pic.h>
#include <osa.h>

4. Указание путей

Через меню MPLab "Project\Build options…\Project" открываем параметры проекта и во вкладке "Directories" добавляем два пути в "Include Search Path":

  • "c:\osa" - путь к файлам операционной системы;
  • "c:\tutor\t1" - путь к файлам проекта.

5. Функция main()

В функцию 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.

Далее можно попробовать пройти по шагам (F8), но, как было описано во "Введении", это может привести к непредсказуемому поведению симулятора. Тем не менее, если симулятор где-то и "застрянет", у нас всегда есть возможность нажать F9, чтобы он автоматически дошел до ближайшей точки останова.

Итак, мы находимся на первой точке останова в задаче Task_T1. Если мы сейчас нажмем F9, то произойдет следующее:

  • программа войдет в цикл for(;;) и дойдет до вызова сервиса OS_Yield();
  • При выполнении OS_Yield() произойдет выход из задачи Task_T1 (с сохранением точки выхода) и передача управления планировщику;
  • планировщик решит, что следующей задачей нужно запустить Task_T2, и передаст ей управление (в самое начало функции Task_T2);
  • в самом начале Taks_T2 у нас стоит точка останова, где симулятор и остановится, ожидая дальнейших указаний.

Другими словами, если сейчас нажмем F9, то выйдем из задачи Task_T1 и войдем в Task_T2. Нажимаем F9 и убеждаемся, что так оно и произошло.

При следующем нажатии F9 произойдет все то же самое, что и в предыдущем абзаце, с той только разницей, что планировщик передаст управление задаче Task_T1 и не в начало, а на следующую за OS_Yield() строчку. Нажимаем F9 и видим, что мы вновь находимся в задаче Task_T1, причем уже на второй точке останова, где производится увеличение счетчика Task_T1.

При дальнейших нажатиях F9 мы увидим, что программа переключается между двумя задачами, каждая из которых увеличивает свой счетчик (значение счетчиков можно наблюдать в окне Watch). Также можем обратить внимание на то, что ни одна из задач больше не начинается сначала, т.е. они обе крутятся в вечном цикле. При этом значения счетчиков растут строго синхронно, т.к. задачи запускаются по очереди, а увеличение счетчика производится при каждом запуске задачи.

Так мы написали простейшую программу с использованием RTOS OSA. Теперь, когда мы разобрались с тем, как передается управление, можно немного поэкспериментировать.

Эксперимент 1

Попробуем добавить в задачу 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 увеличение счетчика происходит в два раза медленнее.

Эксперимент 2

Следующим экспериментом мы проверим требование 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 мы из нее уже никогда не выберемся, а будем вечно крутиться в цикле. В планировщик мы никогда не вернемся, следовательно, никакие другие задачи управление получить уже не смогут.

Первое требование к функциям-задачам (должна содержать внутри себя вечный цикл) мы не проверяем по той причине, что при отсутствии вечного цикла мы обязательно дойдем до конца функции, выход из которой будет совершен по команде return, в результате чего произойдет порча стека.

Эксперимент 3

Теперь проделаем такой опыт: изменим приоритет одной из задач. Пусть задача 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 и узнали его свойства:

  • он передает управление планировщику, оставляя задачу готовой к выполнению;
  • при передаче управления планировщику он сперва сохраняет адрес возврата в задачу.
 
osa/tutorial/tutor1.txt · Последние изменения: 19.01.2011 11:26 От osa_chief
 
Creative Commons License Powered by PHP Valid XHTML 1.0 Valid CSS Driven by DokuWiki