ОСРВ для многих - новый инструмент и, как при знакомстве с любым новым инструментом, у программиста возникает вопрос: с чего начать? В данном пособии приведены несколько примеров, позволяющих упростить понимание организации ОСРВ и принципов ее работы и самое главное - научиться ее использовать для проектирования своих программ.
Во "Введении" будут рассмотрены общие вопросы применения OSA. Здесь будет рассказано о том, как реализуется многозадачность на одном контроллере, показана структура взаимодействия программы и ядра ОС, а также будут приведены особенности программ, использующих OSA.
Для начала дадим несколько определений.
- совокупность подпрограмм и системных переменных, обеспечивающих взаимодействие задач и разделение ресурсов контроллера между ними. Ядро скрыто от программиста, взаимодействие с ним обеспечивается специальными сервисами ОС.
- самостоятельные подпрограммы, имеющие возможность работать параллельно друг с другом.
- количественная характеристика задачи, отражающая ее важность по отношению к другим задачам. Приоритет рассматривается планировщиком при наличии нескольких готовых к выполнению задач как основной критерий для выбора, какой из них передавать управление.
- подпрограмма в составе ядра, занимающаяся поиском готовых к выполнению задач, выделением самой приоритетной из них и передачей ей управления.
- набор подпрограмм и макросов, предназначенных для доступа к функциям ядра. В основном применяются для управления состояниями задач и обмена информацией между задачами.
- аппаратные возможности микроконтроллера, а также внешние модули, подключенные к нему, которыми может пользоваться задача во время своего выполнения.
Ресурсами являются:
Термин многозадачность означает, что система способна обеспечить параллельное выполнение нескольких процессов (задач), разделяя между ними ресурсы контроллера.
Не вдаваясь в подробности, рассмотрим, как выглядит многозадачность на практике. Рассмотрим для простоты параллельное выполнение двух процессов (задач): Task1 (голубой график) и Task2 (зеленый график).
На рисунке рис.1а изображено то, как многозадачность воспринимается пользователем: обе задачи выполняются одновременно, каждая выполняет какие-то свои действия (например, одна задача управляет двигателем стиральной машины, а другая выводит на индикатор текущее состояние: режим работы, время до завершения стирки, температуру стирки и т.д.). Пользователю кажется, что обе задачи выполняются одновременно. Но на самом деле это не совсем так.
Т.к. контроллер в один момент времени может выполнять только одну команду (или одну последовательность команд), то для выполнения двух задач он должен переключаться с выполнения одной последовательности команд на другую и обратно. Поэтому на уровне программы многозадачность будет выглядеть несколько иначе, чем на уровне пользователя (рис.1б): задачи по очереди сменяют друг друга, захватывая на какое-то время ресурсы контроллера. Одна задача, отработав какое-то время, приостанавливается и начинает выполняться другая. Пользователю же это будет незаметно, т.к. смена задач происходит достаточно быстро.
Контроллер должен знать, когда и какую последовательность команд выполнять, когда и как переключаться между ними. Для этого и служит операционная система. И на уровне операционной системы многозадачность будет выглядеть так, как показано на рис.1в. Обратим внимание, что здесь добавлен еще один график - желтый - ядро операционной системы. Прерываясь, задача Task1 передает управление не задаче Task2, а планировщику операционной системы (в кооперативных ОСРВ именно задача передает управление планировщику, в отличие от вытесняющих, где сам планировщик либо более приоритетная задача отнимают управление у менее приоритетной), который в свою очередь принимает решение о том, какой задаче отдать управление. В нашем случае он отдает управление задаче Task2. Когда Task2 отдает управление планировщику, у Task1 появляется шанс получить управление вновь.
Наконец, для полноты картины добавим еще один график (рис.1г) - прерывания. Как видно из рисунка прерывание может прервать как задачу, так и планировщик.
Здесь, наверное, может возникнуть вопрос: для чего нужны сложности с планировщиком, если можно в вечном цикле (часто называют суперциклом или суперлупом) по очереди вызывать функции, содержащие код для Task1 и Task2? Штука в том, что при вызове функции мы каждый раз попадаем в ее начало, в то время как ОС позволяет нам прервать функцию в любом месте, позволив выполниться другим функциям, а затем продолжить ее выполнение с того места, где она была приостановлена. (Это основное отличие от суперцикла. На остальных отличиях пока не будем заострять внимание.) Благодаря этой особенности, каждая задача может быть написана как отдельная независимая программа (она должна быть написана с учетом некоторых требований, но об этом чуть ниже).
На рис.2 схематически изображено взаимодействие программы пользователя с ядром операционной системы. Все, что находится внутри желтого прямоугольника, - это ядро ОС, скрытое от программиста. Взаимодействие с ним производится только через сервисы ОС.
Логически программа получается разбита на 5 частей:
Сердцем ядра является планировщик, который оперирует с массивом задач, выбирая из него самую приоритетную из готовых и передавая ей управление. Массив задач - это массив дескрипторов, содержащих для каждой активной задачи данные, необходимые для работы с ней: текущий адрес или точка возврата в задачу, состояние задачи (готова, ждет событие, пауза, задержка), таймер задачи (у каждой - свой) и некоторые дополнительные данные в зависимости от используемой платформы (например, для MCC18 - значение регистра FSR1, т.е. указателя стека; для MCC30 - регистры общего назначения W8..W15).
Красными линиями показаны пути передачи управления внутри программы. Причем светло-красные обозначают вызов подпрограммы (т.е. подразумевает возврат из нее), а темно-красные - передачу управления безусловным переходом (без возврата). Обратим внимание на три момента:
После ресета мы попадаем в функцию main(), где производим инициализацию системы OS_Init(), создаем задачи OS_Task_Create() и передаем управление планировщику OS_Run(). Планировщик живет сам по себе, циклически проверяя все задачи в своем внутреннем списке на предмет готовности. Когда появляются готовые к выполнению задачи, планировщик выбирает из них ту, приоритет которой самый высокий, и передает ей управление. Обратите внимание, что передача управления задаче отмечена на рисунке темно-красной линией: это значит, что задача должна будет сама вернуть управление ядру, иначе остальные задачи никогда не получат управление. Для возврата в планировщик есть специальные сервисы: безусловный возврат, перевод в режим ожидания события или задержки, приостановка задачи и удаление задачи.
Во время своего выполнения задача может вызывать другие функции (не являющиеся задачами).
Голубыми линии показано направление обмена данными. Рассмотрим особенности:
Программа, написанная с использованием ОСРВ OSA, должна быть написана с учетом некоторых требований:
Ниже в двух словах все эти пункты расшифровываются. Здесь их расшифровка приводится только для информации; более подробно они будут рассмотрены по ходу уроков.
Система OSA является довольно гибкой в настройке, что позволяет сконфигурировать ее под конкретный проект так, чтобы минимизировать ресурсозатраты. Все указания по конфигурации OSA для конкретного проекта задаются в файле osacfg.h (поэтому и важно, чтобы файл находился именно в папке проекта). В самом простом случае этот файл может быть пустым, тогда система будет сконфигурирована по умолчанию. Однако в большинстве случаев может понадобиться внести коррекцию в настройки системы с тем, чтобы повысить эффективность использования ОСРВ.
Этот файл можно создавать вручную, задавая нужные значения констант конфигурации, описанные здесь. Но гораздо проще, удобнее, быстрее и нагляднее пользоваться специальной утилитой OSAcfg_Tool, которую можно скачать здесь. Эта программа представляет собой диалоговое окно, в котором программист выбирает параметры системы для своего проекта: количество задач, банки памяти для размещения системных переменных, размерности переменных и т.д. Далее в уроках мы будем создавать этот файл с использованием этой утилиты. Те, кто не захотят ей пользоваться, а захотят создавать файл конфигурации вручную, смогут один раз заглянуть в сгенерированный утилитой файл, чтобы понять, как правильно его создавать.
Этот файл содержит определения всех системных переменных и функций. Было решено его оставить именно в виде Си-файла, не компилируя в библиотеку, с тем, чтобы он был максимально гибким в настройке. Т.к. PIC-контроллеры младшего семейства имеют дефицит ресурсов, то бывает так, что каждый байт на счету. Максимальной оптимизации получится добиться только использованием условных директив в Си-файле. В противном случае пришлось бы создавать целую кучу вариантов библиотечных файлов под разные контроллеры и под различные конфигурации, что усложнило бы процесс создания проекта и процесс изменения конфигурации в ходе работы над проектом.
Этот файл содержит прототипы системных функций, предопределения системных переменных, а также включает в себя файлы с определением макросов всех системных сервисов. Именно этому файлу требуется наличие файла конфигурации osacfg.h, т.к., опираясь на настроечные константы из него, будет принято решение о том, какие функции и переменные включать в программу.
Здесь все просто: компилятор должен знать, где искать подключаемые файлы, а именно - osa.h (и, следовательно, все файлы включаемые в него из папок osa\port и osa\service) и файл osacfg.h, чтобы он был включен в osa.h.
Наличие вызова этих двух сервисов обязательно. Причем OS_Init должен быть вызван в самом начале программы до вызова какого-либо другого сервиса ОС, а OS_Run - в самом конце (после OS_Run ничего не будет выполняться, т.к. этот сервис переводит программу в вечный цикл).
OS_Init - процедура инициализации системы. В ней подготавливаются все системные переменные, обнуляются списки задач, таймеров, семафоров. Этот сервис должен вызываться один раз за все выполнение программы (если, конечно, самой программой не предусмотрен программный ресет).
OS_Run - этот сервис представляет собой вечный цикл, в котором крутится планировщик - подпрограмма, отвечающая за проверку готовности задач, сравнение приоритетов у готовых задач и передачу управления наиболее приоритетной из готовых задаче. Т.к. сервис содержит внутри себя вечный цикл, то все, что в программе идет за вызовом этого сервиса, будет либо проигнорировано на стадии компиляции, либо просто никогда не получит управление.
void main (void) { ... // Инициализация периферии, переменных программы и пр. OS_Init(); ... // Создание задач, подготовка сервисов OS_Run(); }
Те функции, которые предполагается выполнять параллельно, т.е. сами задачи, описываются как обычные Си-функции с параметром void, но должны быть оформлены по определенным правилам:
Из функции-задачи можно вызывать другие функции (не являющиеся задачами). Локальные переменные всех функций задач занимают одну и ту же область памяти, и после передачи задачей управления планировщику значения локальных переменных будут утеряны. Если есть переменные, значения которых важно сохранить до следующего получения управления, должны быть описаны как static.
Вот типичный пример функции-задачи:
void Task_1 (void) { static unsigned char s_ucCounter; // Определение глобальных переменных задачи unsigned char i, j; // Определение локальных переменных задачи s_ucCounter = 0; // Код подготовки (если нужен) for (;;) { OS_Yield(); } }
Осталось только определиться с тем, как операционная система узнает, какие функции являются задачами, а какие - нет. Мы сами ей об этом сообщим через системный сервис OS_Task_Create, добавив, таким образом, указатель на функцию во внутренний список системы. Сервис OS_Run в своем цикле просматривает этот список и передает управление по занесенным в него указателям.
void main (void) { OS_Init(); OS_Task_Create(0, Task_1); // Теперь система знает, что Task_1 - это задача ОС OS_Run(); }
Во всех уроках мы будем придерживаться единого стиля именования идентификаторов, чтобы не возникало путаницы. Вот они:
Такая система имен может показаться запутанной и ненужной, однако моя практика показала, что она позволяет, во-первых, предотвращать ошибки на стадии написания программы, а во-вторых, сильно упрощает чтение чужой программы (или своей спустя год-два). Возможно, у кого-то есть своя система имен, я никому мою не навязываю, но в данных уроках мы будем пользоваться приведенной выше системой. И встретив в программе переменную с именем, например, s_ucCounter, вы сразу поймете, что это статическая переменная внутри задачи (ее значение будет сохраняться после передачи управления планировщику) типа unsigned char.
Я о локальных переменных в задачах уже упоминал, но остановлюсь на этом вопросе немного подробнее. Дело в том, что ОСРВ OSA является некоей надстройкой над компилятором, и сам компилятор не учитывает ее особенностей. В частности компилятор не знает о том, что в программе есть такие функции, выполнение которых может прерываться в произвольном месте (мы сами точно знаем, что функции прерываются в местах, где происходят вызовы сервисов передающих управление планировщику, но компилятору это неведомо). Поэтому он считает, что локальные переменные, описанные внутри такой функции, являются собственностью этих функций на протяжении всего времени их выполнения. На этапе компиляции строится граф вызовов, опираясь на который, линкер распределяет память под локальные переменные так, чтобы между ними не было пересечений на разных уровнях графа, при этом локальные переменные функций одного уровня могут располагаться в одной и той же области памяти, т.к. предполагается, что эти функции не будут прерывать друг друга.
Однако в случае с ОСРВ дело обстоит иначе, а именно - все функции-задачи, находящиеся на одном уровне в графе вызовов (первый уровень после main), выполняются параллельно, постоянно сменяя друг друга в ходе выполнения. И т.к. у них под локальные переменные задействована одна и та же область памяти, задачи при переключении между собой будут пользоваться этой областью каждая под свои нужды, затирая данные, записанные туда другими задачами. Поэтому, если важно, чтобы значение переменной сохранилось после передачи управления планировщику (а следовательно - и другим задачам), то ее нужно определять с квалификатором static. Тогда эта переменная будет размещена в другой области памяти и будет закреплена исключительно за той функцией, в которой она определена. По ходу рассмотрения уроков мы будем возвращаться к этой теме.
Как описано в документации в параграфе "Состояния задач", задача может находиться в одном из пяти состояний: не создана (удалена), в ожидании, в готовности, в работе и приостановлена. Ниже схематически изображен граф изменения состояний:
Примечания:
Отладка программы, написанной под ОСРВ, немного отличается от отладки обычной программы. Это связано, главным образом, с тем, что тело основной функции программы (планировщика задач) скрыто от программиста (сам планировщик оформлен в виде макроса, что позволяет запускать его тело исключительно в окне дизассемблера).
Сейчас этот параграф можно пропустить, т.к. в нем используются термины и куски программ, которые будут рассмотрены в уроках. По ходу уроков мы будем ссылаться на этот параграф для объяснения некоторых моментов, связанных с отладкой.
Два слова о том, как работает пошаговый отладчик (это относится и к симулятору, и к внутрисхемным отладчикам). При выполнении команды Step Over (F8) отладчик делает следующее:
Зачем такие хитрости с точками останова? Дело в том, что одна строка Си-кода может быть транслирована в несколько (иногда в сотни) инструкций ассемблера. Кроме того, текущая строка может просто-напросто содержать в себе вызов подпрограммы. Чтобы при выполнении каждой инструкции не проверять, дошла ли программа до требуемого адреса, ставится точка останова. К слову сказать, симулятору все равно, как это делать: через точку останова или прямым сравнением текущего адреса с адресом следующей строки; но внутрисхемному отладчику это важно, т.к. проверка адреса после выполнения каждой инструкции очень сильно замедлила бы его работу.
Рассмотрим на конкретном примере. Предположим, программный счетчик в данный момент указывает на строку программы, содержащую вызов функции:
Когда пользователь нажимает F8, запоминается программный адрес следующей за вызовом функции delay(10) строки: "cCounter = 25;". Далее программа начинает выполняться. Т.е. заходит внутрь подпрограммы delay(), выполняет внутри нее какие-то действия, возможно даже вызов других подпрограмм. Но как только будет произведен возврат из подпрограммы delay(), и программный счетчик станет равным запомненному в начале адресу (или, если мы работаем с внутрисхемным отладчиком, как только программа дойдет до точки останова), отладчик приостановит выполнение программы (удалив при этом точку останова), и мы увидим, что зеленая стрелка теперь указывает на строку присваивания "cCounter = 25;".
Примечание. Если в теле функции delay() или в теле одной из функций, которые вызываются из функции delay(), стоит пользовательская точка останова, то отладчик прервет выполнение программы, как только программный счетчик станет равным адресу точки останова.
Рассмотрим простой пример синхронизации двух задач с помощью бинарного семафора:
void Task1 (void) { for (;;) { OS_Bsem_Set(0); // Позволяем выполниться задаче 2 OS_Delay(10); } } //-------------------------- void Task2 (void) { for (;;) { OS_Bsem_Wait(0); // Ждем события от задачи 1 PIN_LED ^= 1; // Мигаем светодиодом } }
Примечание: PIN_LED - вывод контроллера, управляющий светодиодом.
Эта программа включает/выключает светодиод на выводе PIN_LED с интервалом в 10 системных тиков. Логика работы программы такова: задача Task1 с интервалом в 10 системных тиков устанавливает бинарный семафор. Задача Task2 находится в режиме ожидания семафора. Как только она видит, что семафор установлен, она становится готовой к выполнению и при первой же возможности получает его (становится в режим работы), при этом семафор сразу сбрасывается (это делается автоматически сервисом OS_Bsem_Wait()). После этого состояние светодиода меняется на противоположное (зажжен/погашен), и задача вновь становится в режим ожидания двоичного семафора (ведь он был сброшен сразу же при входе в задачу). Задача Task1 все это время находится в режимы ожидания задержки. Через 10 системных тиков она станет готовой к выполнению и после его получения вновь установит семафор.
Схематически программа будет выглядеть так:
Как видно из рисунка, обе задачи выполняются по одному разу, после чего система бездействует (планировщик крутится впустую, т.к. нет готовых задач: одна ждет завершения задержки, другая - установки семафора). В это время у нас происходят прерывания, в коде обработчика которых вызывается сервис OS_Timer, который следит за отсчетом задержки задачи Task1. Как только время задержки истекает, планировщик переводит задачу Task1 в готовность, а так как она единственная готовая к выполнению задача, то она сразу получает управление. Получив его, она вновь устанавливает семафор, делая готовой задачу Task2, а сама опять встает в режим ожидания задержки. И так по кругу.
Рассмотрим, что произойдет, если мы попытаемся пройти эту программу в симуляторе по шагам (F8).
Первым делом мы попадаем в начало функции main(). Пройдя по шагам вызовы всех подпрограмм инициализации (периферии и системы) и создание задач, мы подойдем к вызову сервиса OS_Run, который запускает планировщик в работу. Здесь мы столкнемся с первой неприятностью. Дело в том, что при попытке выполнить OS_Run командой Step Over (F8) программа начнет выполняться и никогда не остановится (если мы ее сами не остановим). Произойдет это потому, что отладчик запомнит адрес следующей за OS_Run строки, но на него программа никогда не попадет, т.к. OS_Run содержит в себе вечный цикл.
Итак, попробуем выполнить задачу Task1 по шагам, для чего сперва установим точку останова на вызове сервиса:
Далее хоть по шагам (F8), хоть сразу (F9) - дойдем до этой точки останова. Теперь рассмотрим, что получится, когда мы попытаемся выполнить пошаговую отладку. Если бы программа была написана по принципу суперцикла, то поведение отладчика было бы предсказуемым: после установки семафора мы бы через несколько шагов очутились бы в задаче Task2 на строке, следующей за ожиданием семафора. Однако с программой, написанной под ОСРВ, отладчик поведет себя немного иначе. Нажмем F8, выполним тем самым вызов сервиса OS_Bsem_Set(), т.е. установив двоичный семафор. Отладчик сделает шаг и остановится перед вызовом системного сервиса OS_Delay(). Сервис OS_Bsem_Set не содержит внутри себя передачи управления планировщику, поэтому с ним трудностей не возникло. Но вот сервис OS_Delay при выполнении передаст управление планировщику.
Что при этом произойдет. Отладчик запомнит адрес следующей строки за OS_Delay, т.е. адрес конца (или начала, как удобнее) цикла, и запустит программу на выполнение. Программа будет выполняться до тех пор, пока программный счетчик не станет равным запомненному адресу. Т.е. за один шаг F8 произойдет следующее:
Другими словами вся работа Task2 выполнится незаметно для нас. Мы нажмем F8, находясь перед вызовом OS_Delay() и имея установленный семафор, и сразу же окажемся в начале цикла, перед вызовом сервиса OS_Bsem_Set(). К этому моменту мы обнаружим, что светодиод уже поменял свое состояние, а семафор уже сброшен. Схематически это можно изобразить так:
Все, что в заштрихованной области, включая все тело Task2, будет выполнено за один шаг.
Точно так же отладчик поведет себя, если мы попытаемся выполнить по шагам задачу Task2. Дойдя до вызова сервиса OS_Bsem_Wait, который передает управление планировщику, мы будем знать, что при выполнении команды отладчика Step Over (F8) мы за один шаг пропустим весь цикл выполнения задачи Task1, вместе с задержкой в 10 системных тиков.
Все, что в заштрихованной области, включая все тело Task1, будет выполнено за один шаг.
При пошаговой отладке задачи Task2 мы будет крутиться в цикле, по очереди выполняя две операции: ожидание семафора, изменении состояния светодиода. Все остальные задачи, которые активны на данный момент, будут выполняться в фоновом режиме незаметно для нас.
К чему все это рассказывается? Вот к чему:
В нашем конкретном примере нужно поставить две точки останова: за сервисом OS_Delay (а именно - на вызове сервиса OS_Bsem_Set) для задачи Task1 и за сервисом OS_Bsem_Wait (а именно - на операции PIN_LED ^= 1) для задачи Task2.
Все выше сказанное относилось к работе с компиляторами PICC18, MCC18 и MCC30. При работе с компилятором PICC нельзя точно предсказать поведение отладчика при попытке выполнить сервис, переключающий контекст, в пошаговом режиме. Поэтому, когда будете производить отладку программы, написанной под PICC, выполняйте сервисы, переключающие контекст, командой отладчика Run (F9), предварительно установив точки останова в интересующих местах. Иногда это бывает сложно (особенно при работе с внутрисхемным отладчиком, который имеет возможность поставить всего одну точку останова), т.к. не всегда удается предсказать, какую задачу планировщик выберет для выполнения. Но другого способа отладки пока нет.
Работая с сервисами, управляющими пользовательскими данными (сообщения, очереди, флаги, семафоры, таймеры), иногда необходимо следить за их состояниями через окно Watch. С переменными, создаваемыми программистом явно, все понятно: каждая такая переменная имеет свой идентификатор и свою область видимости. Например:
OST_SMSG smsg_Sound; OST_DTIMER dt_Alarm;
Эти две переменные типов "короткое сообщение" и "динамический таймер" могут быть добавлены в окно Watch, в котором за ними можно будет наблюдать. Таким же образом можно наблюдать за переменными следующих типов:
Но OSA имеет два вида пользовательских переменных, которые создаются на этапе компиляции и контролируются самой системой:
Т.е. двоичные семафоры и статические таймеры не имеют собственных идентификаторов. Они сгруппированны в массивы, что позволяет сократить код обработки этих данных и сэкономить оперативную память. Однако такая организация затрудняет отслеживание значений этих переменных при отладке.
Как их наблюдать?
Все двоичные семафоры хранятся в массиве:
unsigned char OS_BSem[];
размерность которого зависит от количества используемых в системе семафоров (задается константой OS_BSEMS в файле конфигурации osacfg.h) из расчета: один байт на каждую неполную восьмерку. (Примечание. Для MCC30 элементы массива имею тип unsigned int.)
Младший бит в нулевом элементе массива - это нулевой семафор. Байт с маской 0x02 - первый, 0x04 - второй и т.д. Таким образом, чтобы отследить нужный нам семафор при отладке, нам надо следить за состоянием соответствующего бита в массиве OS_BSem. Это довольно неудобно, но такова плата за экономию оперативной памяти.
Все статические таймеры хранятся в массиве:
_OST_STIMER OS_STimers[];
Тип элементов массива и размерность массива задается в файле osacfg.h константами OS_STIMER_SIZE и OS_STIMERS, соответственно. Каждый элемент массива - отдельный таймер. Самый старший бит каждого таймера (7-ой, 15-ый или 31-ый, в зависимости от типа) - бит активности. Если он установлен, значит, таймер считает. Если сброшен, - таймер переполнился и остановлен. Статические таймеры в окне Watch удобно отображать в виде знаковых целых чисел. Тогда будет видно, сколько времени осталось до окончания счета. Например:
Здесь видно, что статический таймер 0 считает (т.к. число отрицательное), и ему осталось 2000 системных тиков до завершения счета. Статический таймер 1 - не считает, т.к. старший бит сброшен (число не отрицательное).