Виктор Тимофеев, osa@pic24.ru (февраль, 2009)
(ОСРВ - операционная система реального времени)
Два слова о том, что побудило написать эту статью. Хоть мне и не часто приходят письма с вопросами по ОСРВ OSA (отписываются не более 10 человек за месяц), тем не менее, этого достаточно, чтобы набрать статистику по часто совершаемым ошибкам. Я уже несколько раз отвечал на одни и те же вопросы и давал одни и те же советы, поэтому я решил все это дело обобщить в одной статье и выложить на всеобщее обозрение. Полагаю, что статья окажется полезной не только тех, кто только начинают писать программы с использованием ОСРВ, но и для тех, кто уже имеют некоторый опыт.
Статья разделена на несколько частей:
Статья посвящена использованию ОСРВ в малоресурсных контроллерах. Однако, большинство рекомендаций (которые, конечно же, не бесспорны) могут быть приняты на вооружение применительно к остальным контроллерам.
Так зачем же нужна операционная система в контроллерах? Я бы назвал несколько причин.
Во-первых, ОСРВ предоставляет программисту готовый планировщик - некую подпрограмму, следящую за состоянием выполняемых задач, обеспечивающую поиск наиболее важной на данный момент и передачу ей управления. Поскольку часто на контроллер возлагается выполнение сразу нескольких задач (например, чтение клавиатуры, вывод данных на дисплей и пр.), то программист, создавая очередную программу, так или иначе вынужден каждый раз с нуля писать некий свой планировщик (в простейшем случае - это суперлуп, где все задачи-подпрограммы вызываются по очереди в бесконечном цикле). При этом в большинстве случаев не учитывается важность (приоритет) подпрограмм, вызываемых таким планировщиком, и подпрограмма, требующая больше внимания к себе со стороны планировщика, будет, тем не менее, получать его поровну со всеми остальными. Так вот, ОСРВ обеспечивает программиста уже готовым планировщиком, способным не только самостоятельно запускать все подпрограммы, но еще и определять, нужно ли какую-то конкретную подпрограмму запускать в данный момент, или нет, а также при обнаружении нескольких готовых к выполнению подпрограмм выбирать из них наиболее важную и передавать управление именно ей. (Примечание: в ОСРВ такие подпрограммы называются задачами.)
Во-вторых, ОСРВ обеспечивает параллельное выполнение всех задач. В программе, написанной без применения ОСРВ, функция не может прерваться так, чтобы при повторном ее вызове продолжить свое выполнение с того места, где она была прервана. Разумеется, можно прибегнуть ко всякого рода хитростям, вроде таблицы переходов в начале функции или явного сохранения адреса возврата перед выходом, но зачем все это делать вручную, если ОСРВ уже имеет такую возможность? Кроме того, прибегая к таким сложным и нестандартным приемам программирования, как сохранение адреса, можно напортачить и налепить серьезных ошибок, незаметных на первый взгляд. Да и таблицы переходов в начале функции требуют постоянного "ухода" за ними: появляется новая точка выхода из функции - нужно добавить еще один переход в таблицу; убирается точка выхода - нужно убрать переход. Итак, ОСРВ предоставляет нам инструмент, позволяющий покинуть функцию-задачу в любом месте и оставляющий нам уверенность в том, что при следующем входе в задачу мы продолжим ее выполнение с того же места, откуда мы ее покинули.
(Примечание: здесь речь, конечно, идет о псевдопараллельности, т.е. когда все задачи выполянются по очереди, быстро сменяя друг друга; порядок очереди определяется приоритетами и готовностью задач к выполнению.)
В-третьих, ОСРВ берет на себя отсчет временных задержек. Часто в программе нужно выдержать паузу (например при записи в EEPROM мы ждем 5-10 мс). Выполняя задержку явно, т.е., зацикливая программу до тех пор, пока какой-нибудь счетчик не достигнет нужного значения, мы блокируем работу всей программы на время выполнения этой задержки. Не страшно, если нам нужно подождать 10 мс после записи одного байта в EEPROM. А если нам нужно записать 100 байт? Придется "вешать" программу на секунду? ОСРВ же дает возможность организовать программу так, чтобы во время выполнения задержки одной задачей могли выполняться остальные задачи. Т.е. отсутствует время пустого простоя системы. Кроме того, часто в программах требуется ожидать некоего события, но так, чтобы была возможность прервать ожидание, если событие не происходит в течение какого-то времени. Простейший пример - набор номера в GSM-модеме и ожидание ответа. Мы не можем ждать вечно, поэтому дополняем программу неким механизмом, который по истечении заданного времени прервет ожидание. Для этого мы заводим отдельный таймер (тактируемый, например, в прерывании) и следим за его значением. В этом есть некоторые неудобства. Во-первых, на время ожидания все остальные задачи блокированы; во-вторых, нужна отдельная глобальная переменная нужной размерности, имеющая осмысленное имя (например, ModemAnswerTimer); в-третьих, нужно не забыть все такие переменные инкрементировать в обработчике прерываний; есть еще в-четвертых и в-пятых, но это уже мелочи. ОСРВ же может обеспечить ожидание события с выходом по таймауту так, что оно будет лишено перечисленных недостатков, особенно - главного из них: система не будет простаивать впустую во время ожидания.
В-четвертых. В большинстве случаев подпрограммы, отвечающие за разные функциональные узлы, должны обмениваться между собой информацией. Например, подпрограмма, производящая опрос клавиатуры, должна сообщать остальным подпрограммам, какая кнопка была нажата/отпущена; а подпрограмма, выводящая информацию на экран, должна эту информацию откуда-то получать. Т.е. программа должна иметь какой-то механизм обмена информацией между внутренними функциями. Тут есть несколько вариантов: через аргументы функций, через глобальные переменные, наконец, через сервисы операционной системы (семафоры, сообщения, флаги). А как быть, когда одна задача должна получать данные от нескольких задач сразу, или когда какая-то задача отправляет данные быстрее, чем задача-получатель успевает их обрабатывать? Вот тут на помощь приходит очень полезный механизм ОСРВ - очереди сообщений. Они позволяют задаче-отправителю отправлять следующее сообщение (какую-то полезную информацию), если предыдущее еще не успело обработаться задачей-получателем. Все это, конечно же, можно реализовать и без ОСРВ, но зачем выполнять уже выполненную работу, к тому же уже проверенную и отлаженную.
В-пятых, использование ОСРВ улучшает читабельность и наглядность программного кода. То, что реализуется без ОСРВ в 10-15 строках кода, с ОСРВ может быть записано одной строкой. Здесь я не буду приводить конкретных примеров, пока поверьте мне на слово, а когда дойдет дело до написания программы, сами убедитесь в том, что это так.
Наконец, в-шестых, последний аргумент в пользу применения ОСРВ - удобство написания программы, когда над ней трудятся несколько человек. Применительно к PIC-контроллерам, конечно, это редкость, но тем не менее - это большой плюс.
За все выше перечисленные полезности придется платить.
Платим мы в первую очередь памятью контроллера. Сама ОСРВ требует под себя некоторые ресурсы, пусть небольшие, но все-таки. Чем больше параллельных задач, тем больше RAM потребуется для хранения их текущих состояний, адресов возврата и внутренних таймеров. Кроме того, система оперирует еще и своими внутренними переменными, такими как указатель на текущую задачу, лучший приоритет и пр. Ну, естественно, сами системные сервисы и планировщик требуют под себя программную память.
Во вторую очередь мы платим системным временем. Поиск самой важной их готовых к выполнению задач не происходит мгновенно. На это требуется время. Чем больше задач, тем дольше будет выполняться поиск. Это следует учитывать при проектировании программы.
В третью очередь, как ни печально, мы платим непереносимостью программного кода. Т.е. переносимость программы с одной платформы на другую возможна только при том условии, что другая платформа так же поддерживается выбранной ОСРВ. Здесь конкретно для OSA выбор пока ограничен только PIC-контроллерами серий 10, 12, 14, 16, 18, 24, 30 и 33. Расширение планируется, но не сегодня и не завтра. Эта задача на перспективу. (Примечание: с версии 100311 OSA поддерживает 8-битные контроллеры AVR фирмы Atmel)
Наконец, опасениями, что "код разработан не мной, а каким-то дядей", и кто знает, что у этого дяди было на уме. Я сам работал с чужими библиотеками и помню, что когда натыкался на какую-то ошибку, всегда в первую очередь грешил на библиотеки. Некоторые идут дальше: не в библиотеке - так в компиляторе, не в компиляторе - так в программаторе, не в программаторе - так в контроллере! И вот уже строчат письмо на support.microchip.com: "Я лажу нашел! В ваших контроллерах RA4 неправильно работает!" Однако, 99% всех ошибок - на совести программиста. Тем не менее, не исключены и ошибки библиотек, компиляторов, программаторов, наконец, самих контроллеров (не случайно ведь появляются документы ERRATA). И ОСРВ здесь - не исключение. Те, кто отписываются мне по OSA, бывает, обнаруживают неправильное поведение системы, помогают отлавливать баги. И чем больше народа отписывается, тем более безглючной становится "ось". После отлова очередного бага (а они все хитрее и все трудноуловимее) всегда кажется, что вот теперь-то багов точно нет. Тем не менее, нет-нет, да обнаружится еще один (еще более хитрый). Поэтому вряд ли я когда-нибудь скажу: "Все, пользуйтесь. Багов нет!" (Да и ни один производитель ПО такого не скажет). Поэтому сидеть и ждать, когда система станет абсолютно безглючной, - это так никогда и не приступить к работе с ней. А попробовать стоит хотя бы из любопытства. Если и решите: "нет, это не для меня", - то хотя бы, я уверен в этом, почерпнете для себя что-то новое.
Ответ однозначный: да, можно. При проектировании программы программист должен предварительно взвесить все выше перечисленные аргументы, свои аргументы, свои предпочтения, элементную базу и пр. и принять решение, что ему будет быстрее/удобнее/дешевле.
Некоторые присылают мне свои проекты, написанные с использованием OSA, с просьбой помочь разобраться, почему программа виснет или ведет себя не так, как хотелось бы, и пр. При разборе этих проектов я заметил, что многие при написании программы совершают две основные ошибки:
Эти два момента будут рассмотрены в первую очередь, а далее будут даны еще несколько рекомендаций по использованию ОСРВ.
Не используйте ОСРВ там, где это не нужно
Не злоупотребляйте использованием сервисов ОСРВ
Не забывайте о прерываниях
О глобальных переменных
Как разбить программу на задачи
Как расставлять приоритеты
Далеко не все задачи удобно решать с помощью ОСРВ (а в некоторых случаях ОСРВ даже будет мешать). Кроме того, следует помнить, что микроконтроллеры (в особенности те, на которые в первую очередь была ориентирована OSA, т.е. PIC10, PIC12, PIC16) имеют дефицит ресурсов. Некоторые программисты после написания одной-двух программ с использованием ОСРВ так привязываются к ней, что, формулируя для себя очередную задачу еще в проблемно-ориентированных терминах, уже сразу пытаются подогнать эту формулировку под принципы ОСРВ. Т.е., фактически, при проектировании программы пропускается важный этап - выбор инструмента. Это не совсем правильно; вернее - совсем неправильно.
Например, простая задача для PIC10: "пока на входе контроллера есть меандр 1 КГц, горит зеленый светодиод; если частота меандра выходит за пределы допуска +/- 10% - гасим зеленый светодиод и зажигаем красный". Здесь использование ОСРВ совсем не оправдано, более того, просто вредно, поскольку работа планировщика отнимет скоростные ресурсы контроллера и не позволит обеспечить требуемую точность. Тем не менее, некоторые (не буду говорить, кто) создадут 3 независимых задачи: одна следит за меандром, вторая обрабатывает светодиоды, третья следит за первыми двумя - и будут усердно пытаться всю остальную программу оформить и утрамбовать так, чтобы эти три задачи справлялись со своими функциями, причем - в ущерб наглядности текста, прозрачности алгоритма и, наконец, надежности самой программы.
Другой пример - какую-нибудь простую задачу затолкают в малоресурсный контроллер вместе с ОСРВ, на этапе компиляции обнаружат, что памяти (или ROM, или RAM, или и той и другой) не хватает, и начинают вытворять какие-то немыслимые фокусы по оптимизации вплоть до использования машинных кодов в inline-ассемблере (есть такая возможность в HT-PICC), чтобы программу вообще хоть как-то запустить (Михаил, без обид :) ), при этом даже не рассмотрев вариант написания программы без использования ОСРВ.
В ответ на мои рассуждения о ОСРВ и малоресурсных контроллерах мне приводят в пример мою же программу led3, мол, она-то с использованием ОСРВ написана; зачем тогда такой пример, если Вы сами не рекомендуете так делать? Здесь ответ простой. Представьте, что Вы пришли на базар, чтобы купить топор для колки дров. Продавец, демонстрируя свои топоры, одним из их разрубает стальной трос. На топоре при этом - ни зазубрины. Это не значит, что все стальные тросы нужно непременно рубить этими топорами, забыв при этом о специальных для таких целей инструментах, но это кое-что говорит о возможностях этих топоров в работе по дереву. Так же и с этим примером для PIC10. Он приведен не для того, чтобы показать, что любая программа для любого контроллера может и должна быть написана под операционной системой; он приведен для того, чтобы показать, насколько конкретная ОСРВ нетребовательна к ресурсам (в частности, ни jacOS ни Salvo с такой задачей не справятся). Другими словами, этот пример показывает, что само ядро операционной системы будет почти незаметно для программы при использовании контроллера помощнее, например, PIC16F628 (не говоря уже о PIC18 и выше).
Резюме такое: разумно выбирайте инструмент для решения задачи. Программу, которая может получиться, следует оценивать не только по основным параметрам:
Важно учитывать также второстепенные:
Есть религиозные войны "Си & асм". Изучение перепалок на эту тему позволяет сделать вывод, что самые ярые "вояки" - это те, которые с противоположной стороной вопроса не очень знакомы. Возможно, на подходе религиозные войны "RTOS & !RTOS". Я предлагаю не участвовать на передовой, ударяя себя пяткой в грудь и крича, что RTOS - сила, а осваивать и ОСРВ, и суперлуп, и прерывания (есть и такой вариант), и выбирать оптимальный для решения конкретной задачи.
В присланных мне проектах я также замечал маниакальное использование сервисов ОСРВ везде: где нужно и где не нужно. Складывается такое ощущение, что некоторые программисты рассматривают ОСРВ как язык программирования, просто еще более высокого уровня, чем Си. И как при программировании на Си избегают использования встроенного ассемблера, так и при программировании под ОСРВ избегают использования самого Си. В результате такого подхода исходный текст программы сильно пестрит изобилием сервисных вызовов, которое просто затрудняет чтение программы. Применительно к OSA, возможно, есть и моя вина в том, что некоторые так пишут программы. OSA предоставляет изобилие сервисов для, в общем-то, простых операций, не требующих каких-то сложных пассажей с передачей управления ядру (достаточно посмотреть на сервисы установки/сброса флагов). По существу же, большое количество сервисов - это просто формализация простых операций в терминах конкретной ОС. Применительно к флагам эта формализация звучит так: есть сервисы ожидания флагов, значит должны быть и сервисы установки/сброса флагов (хотя по сути - это простые побитовые операции and и or) и их проверки ( == и != ). Т.е. есть некий объект ОС (флаг), следовательно, ОС должна обеспечить программиста сервисами по всевозможным операциям с объектом. На деле же получается довольно трудночитаемый текст.
Однако, это не единственная причина такого яростного пичкания программы системными сервисами. Тут какой-никакой здравый смысл присутствует. Это вроде как стандартизация, все-таки флаг - объект ОС, и неизвестно, как там нутро этой ОС устроено и как оно его обрабатывает (не все же программисты заглядывают в исходники ОС, даже если они открыты). Есть еще одна причина: мышление в контексте ОСРВ заставляет людей местами необдуманно использовать сервисы ОС там, где это совсем не к месту.
В качестве примера можно привести использование счетного семафора в качестве счетчика цикла. Он, конечно, может применяться в этих целях, принципами ОСРВ это не возбраняется, но здравый смысл должен говорить за неэффективность такого подхода. Сравните два приведенных ниже фрагмента кода:
OS_Csem_SetValue(csem, 10); do { /*...*/ OS_Csem_Accept(csem); } while (OS_Csem_Check(scem));
i = 10; do { /*...*/ } while (--i);
Здесь очевидно злоупотребление сервисами ОС. Возможно, этот пример кажется утрированным, тем не менее, это фрагмент реальной программы.
Еще могу привести такой пример. В одной из присланных мне программ была подпрограмма приема данных по последовательному интерфейсу (не аппаратному). Там была заведена переменная mask, которая имела только один установленный бит и циклически сдвигалась после приема каждого импульса. А принимаемый байт записывался в системную переменную типа OST_FLAG. В результате код имел несколько комичный вид:
// CLC - ножка CLOCK // DIO - ножка DATA // (прим. - В.Т.) OS_Cond_Wait(CLC); if (DIO) OS_Flag_Set_1(data, mask); else OS_Flag_Set_0(data, mask); mask <<= 1; if (!mask) ...
Налицо совершенно бессмысленное использование сервиса OS_Flag_Set_x, которое в данном случае не только не наглядно, но еще и сбивает с толку. Куда нагляднее выглядел бы код:
OS_Cond_Wait(CLC); if (DIO) data |= mask; else data &= ~mask; mask <<= 1; if (!mask) ...
Еще в одной присланной мне программе человек так увлекся бинарными семафорами, что на них построил всю логику работы программы. У него было под 40 этих семафоров, и в результате он в них запутался, т.к. его программа выглядела уже не как последовательность действий, а как какая-то МДНФ на ПЛМ, которая хоть и выполняет свою функцию, но без пристальнейшего вглядывания имеет совсем непонятную логику работы. И модификация такой программы становится весьма затруднительной (Семен, это я о тебе :) ).
Я могу привести еще несколько подобных примеров, но полагаю, что читающий и так понял, к чему я призываю. Самыми полезными сервисами ОСРВ являются сервисы ожидания и переключения контекста. Все остальное - формализованное дополнение.
Поэтому я подведу итог:
Также распространенной ошибкой при проектировании является отказ от использования кода в прерываниях. Многие программисты пытаются всю программу представить непременно в виде задач ОС, полагая при этом, что прерывание не оформить как отдельную задачу, и, следовательно, внутри него ничего делать нельзя. В большинстве программ, присланных мне, код прерыввания выглядел примерно одинаково:
void interrupt isr (void) { OS_EnterInt(); if (T0IF) { T0IF = 0; OS_Timer(); } OS_LeaveInt(); }
Разница была только в используемом таймере. Все же остальные обработчики прерываний были вынесены в отдельные задачи и выглядели примерно так:
void Task_Iinterrupt_INTF (void) { for (;;) { OS_Cond_Wait(INTF); INTF = 0; /* Далее следует ко добработки прерывания */ } }
Неэффективность такого подхода очевидна:
В дополнение напомню, что ОСРВ OSA имеет целый набор сервисов для работы внутри прерываний, позволяя вести обмен информацией с остальной программой в терминах ОСРВ.
Вопрос использования глобальных переменных довольно спорный. Одни утверждают, что глобальные переменные - это зло, т.к. их наличие усложняет переносимость отдельных функций, а также не исключает возможные коллизии из-за модификаций глобальной переменной в разных модулях и т.д. Другие говорят, что использование глобальных переменных сокращает ресурсозатраты на обмен информацией. Точно сказать, кто прав в этом вопросе, я думаю, нельзя. И те и другие по-своему правы. Нужно рассматривать каждый конкретный случай в отдельности, при этом следует принимать во внимание не только задачу, под которую пишется программа, но еще и специфику используемого контроллера, и особенности используемого компилятора, и сколько программистов трудятся над задачей и т.д. и т.п.
В идеале программа не должна содержать глобальных переменных. В реальности же нам приходится искать компромисс между структурностью программы и наличием свободных ресурсов (памяти и скорости). Создавая программу с помощью ОСРВ, можно, конечно, все обмены данными между задачами делать с помощью объектов ОС: семафоров, сообщений, очередей и пр. (все эти объекты, вообще-то, сами по себе являются глобальными переменными, но они имеют четкую формализацию в терминах используемой ОС, а потому сильно упрощают перенос функций в другие программы и практически исключают коллизии с модификацией переменных разными модулями). Тем не менее, такой подход не всегда оправдан по нескольким причинам:
Поэтому в большинстве случаев, особенно, когда дело касается малоресурсных контроллеров, имеет смысл некоторые переменные делать глобальными. Тут еще раз напомню, что сервисы ОС лучше использовать только там, где без них никак, а не там, где их можно втиснуть. Хоть это утверждение и спорно в общем случае, но применительно к контроллерам я считаю такой подход правильным.
Эта задача не совсем тривиальна. Даже в каждом конкретном случае может быть несколько решений. В присланных мне проектах четко прослеживались две основные ошибки, совершаемые программистами при разбиении программы на задачи. Вернее сказать, не ошибки, а, как бы выразиться, две крайности, в которые бросаются программисты.
Первая - программист пытается разбить программу на задачи так, чтобы каждая задача выполняла одно свое действие и не мешала работать другим. Хоть в теории такое решение и выглядит красиво, но на практике получается совсем не так. Иногда дело доходит до того, что на каждое реле, управляющее каким-то силовым выходом, заводится отдельная задача. Выглядит она довольно красиво:
void Task_Relay1 (void) { for (;;) { OS_Bsem_Wait(BS_RELAY1); RELAY1 = 1; OS_Delay(100); RELAY1 = 0; } }
Но что у нас получается на практике? Если программа управляет десятком таких силовых выходов, то у нас заводится десять таких задач. С виду - все красиво и аккуратно. Но не следует забывать, что каждая активная задача в ОСРВ требует несколько байт оперативной памяти, где хранится ее текущее состояние, адрес возврата, ее таймер (все эти параметры хранятся в так называемом дескрипторе задачи). Например, для PIC16 размер дескриптора задачи 5 байт (он может быть и меньше, но мы рассматриваем общий случай), и для десяти задач потребуется 50 байт ОЗУ, что для PIC16 - просто расточительство. Кроме того, еще одна проблема, возникающая при большом количестве задач, - увеличивается время работы планировщика, т.е. то время, за которое планировщик успеет перебрать все задачи, определить, какие из них готовы к выполнению, и из этих готовых найти самую важную (т.е. имеющую высший приоритет).
Так же к недостаткам такого подхода можно отнести и то, что усложняется управление этими задачами. Чем больше задач требуют каких-то данных для своей работы (в нашем примере это всего лишь бинарные семафоры, но ведь некоторые задачи могут требовать и сообщения, и очереди сообщений), тем остальным задачам сложнее будет справляться с управлением такими потоками данных.
Можно привести еще пример (тоже из реальной жизни): на экран нужно было выводить информацию разного характера. Для этого были созданы 4 задачи, каждая из которых выводила на экран свой параметр. Каждая задача ожидала от головной задачи свое сообщение. В результате, помимо того, что для 4-х задач функции вывода на экран являлись разделяемым ресурсом, так еще и головная задача была озадачена головной болью по работе с четырьмя сообщениями. Кроме того, такой подход накладывает ограничение на содержание выводимых на экран данных, ибо для вывода 5-го параметра потребовалась бы 5-я задача (и 5-е сообщение), для 6-го - 6-я и т.д.
Вторая крайность - это когда программист пытается все, что не является обработкой кнопок, затолкать в одну задачу. Она занимается и приемом даных по USART, и записью данных в EEPROM, и … да чем только не занимается! Зато информацию о кнопках получает извне. Зато есть многозадачность! Бессмысленность такого подхода очевидна (тем не менее, он часто встречается): нет преимущества использования ОСРВ. В чем преимущество параллельности выполнения задач, если задача всего одна? В чем преимущество очередей сообщений, если задача сообщения отсылает сама себе? Какова будет реакция на событие, если задача занимается сразу всем? Про читабельность кода, написанного с таким подходом, я вообще молчу. Единственное, что полезного можно ухватить от ОСРВ в таком случае, - это удобство формирования задержек (OS_Delay - и все!).
Итак, как же разбивать программу на задачи? Как я уже писал выше, единой рекомендации по этому вопросу нет. Тут я могу дать только несколько советов, исходя из опыта и здравого смысла.
Для начала приведу очевидный пример: сначала программа была написана под конкретный LCD-индикатор. По прошествии какого-то времени после запуска ее в серию в продаже появился новый менее дорогой и более функциональный индикатор, и руководство фирмы приняло решение следующую версию устройства делать на нем. Очевидно, что если при написании программы все, относящееся к функциям вывода на экран, было вынесено в отдельный модуль (отдельную задачу), то и модификация программы будет минимальной. Т.е. меняем только функции работы с LCD и задачу вывода информации на экран (единственную задачу, которая работает с функциями LCD). Для остальной программы такая замена будет незаметной: она как отсылала свои сообщения в очередь, так и отсылает, а что там вытворяет с ними задача вывода на экран, - это уже их не касается.
Рассмотрим теперь такой пример. Допустим, программист работает в фирме, которая разрабатывает, скажем, системы доступа: домофоны, кодовые замки, пульты дистанционного управления и пр.
Почти в каждом из разрабатываемых фирмой устройств есть кнопки. И чтобы не писать функции обработки кнопок для каждого такого устройства, есть смысл вынести обработку кнопок в отельную задачу, оформить ее в виде отдельного файла и использовать во всех проектах (изменяя только количество кнопок, полярность и пр.).
Не нужно объединять в одной задаче функционально различные модули. Например объединять вывод звука с чтением данных по UART. Пускай даже и удастся написать задачу так, что эти функции не будут мешать и задерживать друг друга, но концептуально это неправильно, т.к. программа теряет наглядность. Т.е. лучше делать задачу максимально наглядной.
При вынесении каких-то функций в отдельную задачу нужно предварительно прикинуть, сможет ли задача при такой организации обеспечить реакцию на событие в течение определенного времени. Это время может быть различным в зависимости от события. Например, если пользователь нажимает на кнопку, то он хочет увидеть реакцию устройства сразу же, и задержка всего в секунду уже недопустима. Если событие - это превышение температуры в комнате на 2 градуса выше какой-то нормы, то тут допустима задержка реакции в 5 секунд или в минуту - разницы особой нет. Т.е. задача должна быть спроектирована таким образом, чтобы ее выполнение не мешало ей же реагировать на события. Например, у нас есть задача, управляющая несколькими силовыми приборами: свет, насосы, обогреватель. Получая сообщение о том, что нужно на 10 секунд включить насос, она в течение этих 10 секунд может оказаться не в состоянии обработать другие приходящие ей сообщения. Если логика работы такой задачей проста, то эту проблему можно обойти, добавив в программу несколько таймеров и булевых переменных. Однако же, если логика посложнее, то тогда, наверное, есть смысл разбить эту задачу на две или более.
В одном из присланных мне проектов был реализован интересный подход, который тоже можно взять на вооружение. В программе было описано около 20 функций-задач. Но в один момент времени могли работать только 4. 3 из них работали постоянно, а 4-я в зависимости от текущего режима все время пересоздавалась. Преимущество такого подхода - скорость работы планировщика (чем меньше активных задач, тем планировщик OSA работает быстрее). Недостаток - нужно очень внимательно следить за создаваемыми/удаляемыми задачами, а так же за тем, чтобы при удалении они не забывали освобождать занимаемые ресурсы.
Попробую подытожить рекомендации. Написанная программа должна обладать следующими свойствами:
Напомню, что все выше перечисленное носит исключительно рекомендательный характер. Возможно, кто-то руководствуется другими правилами. Тем не менее, часто приходится видеть программы людей, которые каким-либо правилам следуют условно (т.е. не следуют им совсем), поэтому я надеюсь, что эти рекомендации кому-то окажутся полезными.
Применительно к кооперативной ОСРВ, приоритет задачи - понятие весьма условное. Оно больше подходит к вытесняющим ОСРВ, где планировщик может приостановить задачу в любой момент, чтобы передать управление готовой более приоритетной задаче, что обеспечивает выполнение важного условия - детерминированного времени реакции на событие. В кооперативной ОСРВ вытеснение произойти не может, т.е. планировщик не может отнять у задачи управление, а только сама задача может решить, отдавать управление планировщику или нет. Поэтому приоритетность в кооперативных ОСРВ не совсем уместна.
Кроме того, включение механизма приоритетов несколько замедляет работу планировщика, поскольку, помимо перебора всех задач в поиске готовой, он будет должен еще заниматься сравниванием их приоритетов и поиском максимального. Поэтому, как это ни парадоксально, работая в приоритетном режиме, задача с высоким приоритетом может получить управление с большей задержкой, чем если бы приоритеты были отключены.
Тем не менее, в программах под кооперативными ОСРВ бывают случаи, когда приоритетность выручает.
Например, при сильной загрузке контроллера. Если контроллер сильно загружен какими-то вычислениями, или общением с внешними устройствами, чтением датчиков и т.д., то возникают ситуации, когда он не в состоянии, выполняя все задачи по очереди, обеспечить реакцию на некоторые события в приемлемое время. Включение приоритетного режима позволит быстрым высокоприоритетным задачам выполняться чаще остальных, позволяя сократить время реакции.
Другой пример: при происшествии какого-то события нам нужно выполнить несколько задач в определенной последовательности. Все задачи находятся в ожидании установки какого-то бита (пусть это будет флаг прерывания INTF). Тогда расстановка приоритетов задачам позволит нам однозначно определить порядок выполнения задач при установке INTF. Последняя задача, отработавшая по этому событию (т.е. самая низкоприоритетная), этот флаг сбросит.
В большинстве же случаев в программах под кооперативной ОСРВ приоритеты задачам не нужны. Однако, если все же решили использовать приоритетный режим, то как тогда правильно расставить приоритеты?
Для того, чтобы ответить на этот вопрос, нужно посмотреть на задачи не как программист, например, "хочу, чтобы опрос кнопок производился с интервалом 20 мс", а как пользователь, т.е. "хочу, чтобы при нажатии на кнопку, сразу происходило действие". При такой постановке вопроса становится ясно, что не столь важно опрашивать кнопку именно каждые 20 мс, сколь успеть ее обработать сразу при нажатии. При этом уже очевидно, что интервал может быть и 20 мс и 40 мс, т.е. не так критичен, а отсюда становится понятно, что приоритет этой задачи нет смысла делать высоким.
Высокоприоритетными задачами нужно делать те, которые действительно критичные ко времени реакции. Представьте себе устройство управления автоматическими воротами. Задачами этого устройство являются: прием по радиоканалу команд от ПДУ на открытие/закрытие, управление двигателями дверей, а также - аварийный реверс двигателей, если на пути створок дверей оказалось препятствие. Последняя задача является критичной ко времени. Например, оператор нажал копку на ПДУ - ворота открылись, машина поехала, а оператор нажал кнопку закрытия ворот раньше времени. В результате створки ворот упрутся в кузов автомобиля. Чем быстрее устройство сообразит, что надо дать дверям обратный ход, тем дешевле будут последствия.
Не следует давать высокий приоритет задачам, которые долго выполняются. Например, в программе одновременно оказались готовыми к выполнению задача включения реле и задача преобразования Фурье. Очевидно, что вторая заберет ресурсы контроллера на долгое время, поэтому есть смысл пропустить вперед быструю задачу включения реле, после чего уже можно отдавать управление вычислителю.
Ко всему сказанному можно добавить, что большинство ОСРВ (OSA - не исключение) позволяют менять приоритет задач в ходе выполнения программы, что может в некоторых случаях оказаться полезным. Например, нет смысла давать высокий приоритет задаче записи на flash-карту в фотоаппарате, если сам фотоаппарат находится в режиме просмотра снимков, и запись в данный момент ему нужна только для сохранения каких-то единичных параметров, вроде текущей яркости экрана, или номера последнего просмотренного снимка. И наоборот, задачу записи во flash-память лучше сделать самой приоритетной во время сохранения снимка, пожертвовав при этом скоростью вывода на экран, скоростью обработки кнопок и т.д.
Вот, собственно, основные рекомендации по расстановке приоритетов. В целом эта задача так же не тривиальна, как и разбиение программы на задачи, и в каждом случае нужно решать ее отдельно. Но рекомендации, приведенные выше, помогут упростить этот процесс.
Здесь я собрал ответы на самые распространенные вопросы по 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_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; }
"Почему после вызова OS_Delay() задача подвисает, значение таймеров не изменяется, хотя в osacfg.h определена константа OS_ENABLE_TTIMERS?". Как ни странно, вопрос довольно частный.
Ответ: счет таймеров выполняет сервис OS_Timer, который должен периодически вызываться.
Здесь будут описаны правила, советы и рекомендации по конфигурированию OSA, а также по применению сервисов OSA с целью увеличения эффективности использования ресурсов микроконтроллера. Есть три ресурса, которые хотелось бы сэкономить: RAM, ROM и время. Сначала я предполагал разбить этот раздел на три части, посвятив каждую из них своему параметру. Однако, получилось так, что приемы и советы по экономии RAM и ROM практически идентичны, поэтому им будет посвящена одна часть.
Оптимизация памяти в основном производится за счет правильного конфигурирования, но есть и приемы, относящиеся к использованию сервисов. Начальная конфигурация по умолчанию (когда файл osacfg.h пустой) подобрана так, чтобы обеспечить программиста всем необходимым, не давая ничего лишнего. Но каждая программа индивидуальна и есть смысл для каждой подбирать оптимальную конфигурацию.
(К списку советов по оптимизации)
Во многих приложениях отключение приоритетности - очень полезная вещь. Как было описано выше (в разделе Как расставлять приоритеты), в большинстве приложений под кооперативной ОСРВ приоритетность не нужна. Когда мы отключаем приоритетность, код планировщика сильно упрощается и сокращается, т.к. он больше не содержит цикла предварительного перебора всех задач в поиске готовых и кода сравнения приоритетов. Кроме того, из прораммы выкидываются системные переменные, содержащие максимальный приоритет, адрес задачи с максимальным приоритетом и адрес последней выполненной задачи. Т.е., отключив приоритеты, мы экономим и RAM, и ROM, и время работы планировщика (см. ниже).
По умолчанию приоритетность включена. Отключить ее можно, задав в файле osacfg.h константу:
#define OS_DISABLE_PRIORITY
(К списку советов по оптимизации)
Таймеры в OSA - очень гибкий в настройке инструмент. Помимо того, что есть три типа таймеров: таймеры задач, пользовательские статические и пользовательские динамические таймеры, - каждый из которых обладает своими преимуществами, есть еще возможность выбирать размерность каждого типа таймера в зависимости от возложенных на него задач.
(Примечание. Есть еще четвертый тип таймеров, исторически сложилось так, что называется он "статические таймеры старого типа", но т.к. эти таймеры были довольно сложны в настройке, от их дальнейшего сопровождения пришлось отказаться, хотя в системе они остались и функционируют. См. приложение к документации)
По умолчанию размерность таймера равна двум байтам. Это покрывает большую часть потребностей в организации задержек в программах. Такие таймеры могут отсчитывать до 65535 системных тиков (периодов вызова OS_Timer). Больше обычно не нужно. Но зато часто встречаются случаи, когда все задержки (и ожидания событий с таймаутами) не длиннее 255 тиков. Например, OS_Timer вызывается в обработчике прерывания по TMR2, настроенного так, чтобы его период был 10 мс. В этом случае, используя отсчет таймера в 255 тиков, мы получаем задержку в 2.5 секунды. Для многих приложений этого более чем достаточно.
Поэтому во многих случаях есть смысл таймеры задач сделать однобайтовыми. Делается это заданием размерности таймера в файле osacfg.h:
#define OS_TTIMER_SIZE 1
Что мы выигрываем, уменьшив размерность таймеров задач?
Примечание 1. Размерность можно также менять и у статических, и у динамических таймеров.
Примечание 2. Для 16-разрядных контроллеров (PIC24 и dsPIC) уменьшение размерности таймера до одного байта не приведет к сокращению кода или экономии RAM.
(К списку советов по оптимизации)
Иногда при дефиците ресурсов может оказаться полезным воспользоваться статическими таймерами, вместо таймеров задач. При включении таймеров задач (включаются определением константы OS_ENABLE_TTIMERS) появляется возможность использовать в задачах задержки (OS_Delay) и ожидание событий с выходом по таймауту (OS_xxx_Wait_TO). За каждым дескриптором задачи при этом закрепляется свой таймер (так называемый таймер задач), и их будет столько, сколько дескрипторов зарезервировано в osacfg.h (константа OS_TASKS). Получается, что если у нас 10 задач, а таймеры используются, например, только в трех, то мы имеем в памяти 7 переменных, которые не используются, но занимают место.
В таких случаях имеет смысл отключить таймеры задач и воспользоваться статическими таймерами. Для нашего примера мы определяем константу в osacfg.h:
#define OS_STIMERS 3
И теперь у нас только 3 ячейки памяти занято таймерами, а мы сэкономили 7 ячеек. При двухбайтовых таймерах это 14 байт оперативной памяти. Теперь задержки в задачах будут выглядеть так:
OS_Stimer_Delay(ST_ID, 100);
где ST_ID - номер статического таймера (от 0 до 2 в нашем примере).
Как заменить сервисы ожидания с выходом по таймауту, если все сервисы OS_xxx_Wait_TO требуют наличия таймеров задач? Очень просто, воспользовавшись сервисом OS_Cond_Wait, который позволяет ожидать в задаче любое условие. Например, мы ждем сообщение из очереди:
// С таймерами задач OS_Queue_Wait_TO(queue, msg, 100); if (!OS_IsTimeout) { /* обрабатываем сообщение */ } // Без таймеров задач, но со статическими таймерами OS_Stimer_Run(0, 100); OS_Cond_Wait(OS_Queue_Check(queue) && !OS_Stimer_Check(0)); if (!OS_Stimer_Check(0)) { OS_Queue_Accept(queue, msg); /* обрабатываем сообщение */ }
При использовании статического таймера запись оказалась более громоздкой, тем не менее сгенерированный код будет примерно одинаковым по размеру.
Итак, что мы выигрываем от замены таймеров задач статическими таймерами?
Теряем мы удобство и наглядность.
Еще одно применение статических таймеров можно рассмотреть в контексте предыдущего параграфа (Размерность таймеров). Там мы указали, что часто бывает так, что задержки в задачах не превышают 255 системных тиков. Но бывает и так, что в 9-ти задачах не превышают, а 10-ой, хоть тресни, нужен 32-разрядный таймер. Понятно, что из-за одной задачи не хотелось бы всем приделывать 4-байтовые таймеры. Поэтому удобно организовать один 4-байтовый статический таймер для этой задачи, а всем остальным определить 1-байтовые. Вот фрагмент файла osacfg.h:
#define OS_ENABLE_TTIMERS // Разрешаем использование таймеров задач #define OS_TTIMER_SIZE 1 // Размерность таймеров задач #define OS_STIMERS 1 // Определяем один статический таймер #define OS_STIMER_SIZE 4 // Размерность статического таймера
(К списку советов по оптимизации)
Сервис OS_Timer вызывает системную функции _OS_Timer(). Учитывая, что чаще всего вызов этого сервиса производят по переполнению какого-либо таймера (TMR0, TMR1, TMR2), обычно он располагается внутри функции-прерывания. Компиляторы от HTSoft в таком случае ведут себя следующим образом: т.к. сама функция _OS_Timer() относительно функции прерывания находится во внешнем другом модуле, то функция прерывания ничего не знает о ресурсах, которые использует _OS_Timer(), поэтому при входе в прерывание сохраняются все критичные регистры, которые, по предположению компилятора, может изменить внешняя функция. К ним относятся: все FSR, пара PRODH:PROLH, 15 регистров btemp, и тройка регистров TABLAT, TBLPTRH:TBLPTRL. Возможно в самом прерывании они и не используются, а все прерывание выглядит так:
void interrupt myisr (void) { OS_EnterInt(); if (TMR2IF) { TMR2IF = 0; OS_Timer(); } OS_LeaveInt(); }
Но на этапе компиляции файла, содержащего код прерывания, компилятор не знает, что творится внутри функции, вызываемой сервисом OS_Timer. Сохранение/восстановление при входе/выходе из прерывания занимает более 100 слов памяти ROM и, соответственно, более 100 машинных циклов. Т.е. если у нас таймер запрограммирован так, чтобы прерывание происходило каждые 256 циклов, то более 40% всего процессорного времени будет тратиться только на вход и выход из прерывания.
В ОСРВ OSA есть возможность вместо вызова функции _OS_Timer подставлять ее тело напрямую. Для этого нужно определить константу в файле osacfg.h:
#define OS_USE_INLINE_TIMER
Ограничение: если эта константа определена, то в программе OS_Timer() может вызываться в единственном месте. Возможно, в дальнейшем это ограничение будет снято.
(К списку советов по оптимизации)
(Этот параграф имеет отношение не столько к OSA, сколько к PIC18)
Для PIC18 некоторый выигрыш по программной памяти даст размещение переменных в ACCESS-банке (первые 128 или 96 байт RAM-памяти). Обращение к таким переменным производится без предварительной установки регистра BSR, следовательно, при частом обращении к ним можно сэкономить на всех инструкциях movlb. Свои переменные можно размещать в этом банке вручную. Например, для HT-PICC18:
near char a; near int i;
для MCC18:
#pragma udata access my_vars near char a; near int i;
В ОСРВ OSA также есть возможность размещать все внутренние переменные в разных банках: дескрипторы задач, системные переменные, статический таймеры, бинарные семафоры. Для каждого типа этих данных можно указать свой банк (0 - access, 1 - остальная память):
#define OS_BANK_OS 0 // Размещение системных переменных #define OS_BANK_TASKS 1 // Размещение дескрипторов задач #define OS_BANK_BSEMS 0 // Размещение двоичных семафоров #define OS_BANK_OS 1 // Размещение статических таймеров
(К списку советов по оптимизации)
Обычно сообщения отправляются сервисом OS_Msg_Send. Как этот сервис работает? Предварительно он проверяет, занят дескриптор сообщения или свободен (т.е. обработано ли предыдущее сообщение или еще нет). Если дескриптор занят, то задача становится в режим ожидания и передает управление планировщику. Иногда бывает так, что не принципиально, обработалось предыдущее сообщение или нет. Например, задача измеряет напряжение бортовой сети автомобиля и сообщением отправляет его головной задаче. Головной задаче не важна последовательность изменений напряжения, ей нужно текущее значение. Поэтому иногда нет смысла выполнять лишние операции по проверки занятости дескриптора, по формированию адреса возврата, да еще задержки на неопределенное время. Для отправки сообщения, не дожидаясь освобождения дескриптора (т.е. фактически для перетирания старого тела сообщения новым), можно пользоваться сервисом:
OS_Msg_Send_Now(msg_cb, msg);
Например, для PIC16 этот сервис занимает 7 слов программной памяти, в то время как OS_Msg_Send требует 17 слов.
Примечание. Это же относится к отправке коротких сообщений, а также к отправке сообщений и коротких сообщений в очередь.
(К списку советов по оптимизации)
OSA предоставляет возможность программисту использовать два типа очередей в своей программе, причем их можно использовать одновременно, т.к. они независимы друг от друга. Однако, их независимость порождает небольшую проблему: в программе присутствуют отдельные функции по добавлению/извлечению сообщения для обоих типов очередей. Т.е. в программной памяти два экземпляра функции _OS_Queue_Send и два экземпляра функции _OS_Queue_Accept. Это позволяет программисту использовать в своей программе сообщения разных типов и размерностей. Но если в программе используются сообщения одинаковых размерностей? Т.е. размерность указателя на сообщения такая же, как размерность короткого сообщения (для PIC16, например, указатель на RAM однобайтовый). В этом случае использование двух экземпляров функций неоправданно.
В таких случаях программисту предоставляется возможность объединить эти функции, т.е., вернее, использовать одни и те же функции для сообщений разных типов. Для этого нужно в файле osacfg.h определить константу:
#define OS_QUEUE_SQUEUE_IDENTICAL
Так мы сэкономим около 100 слов программной памяти. Однако после применения такого приема следует помнить, что был сделан допуск относительно размерности сообщений. И если вдруг нужно будет поменять тип одного из сообщений (указателя на сообщение или короткого сообщения), то следует пересмотреть возможность использования одной функции для работы с очередями этих сообщений.
(К списку советов по оптимизации)
Давайте еще раз рассмотрим принцип работы планировщика в ОСРВ OSA. Планировщик по очереди перебирает все активные (созданные) задачи, проверяя их готовность. Одновременно он сравнивает приоритеты у всех готовых задач, для чего лучший из приоритетов, а также адрес задачи с лучшим приоритетам сохраняет в своих внутренних переменных. После прохода всех задач управление передается той, чей адрес сохранен во внутренней переменной, т.е. чей приоритет был высшим из всех готовых задач.
Если во время проверки встречаются несколько готовых задач с одинаковым приоритетом, то управление получит та из них, которая была проверена первой. Получается так, что если планировщик натыкается на готовую задачу с высшим приоритетом (0), то дальнейший поиск делать бессмысленно, поскольку, даже если и найдутся еще готовые задачи с высшим приоритетом, планировщик все равно запустит ту, которая была найдена первой. Планировщик OSA пользуется этим моментом и, находя готовую задачу с высшим приоритетом, сразу же прерывает поиск и передает управление найденной задаче.
Учитывая, что полное время проверки готовности одной задачи (с выбором адреса задачи, с передачей ей управления для проверки готовности, с самой проверкой и с возвратом в планировщик) длится около 70 тактов. И если у нас 10 задач, то общий поиск будет длиться около 700 тактов. Если же присутствует задача с нулевым приоритетом, то в случае ее готовности время работы планировщика сократится в среднем вдвое (это время будет колебаться от 70 до 700 тактов в зависимости от того, с какой задачи планировщик начинает просмотр).
Итак, рекомендация такова: если используется приоритетный режим, то желательно самым приоритетным задачам устанавливать именно высший (а не просто самый высокий) приоритет, т.к. это ускорит работу планировщика.
(К списку советов по оптимизации)
Об этом уже разговор был. Постольку, поскольку при отключении приоритетности убирается код, занимающийся предварительным перебором задач и сравнением приоритетов, то работа планировщика ускорится в OS_TASKS раз.
(К списку советов по оптимизации)
Два слова о том, как проверяется условие при ожидании. Например, задача ожидает установку двоичного семафора. Что при этом происходит:
Т.е. при ожидании события планировщик всегда заглядывает внутрь задачи (передает ей управление, чтобы она сама проверила свое условие). Это требует времени. Если семафор устанавливается довольно редко, а скорость реакции на него некритична (например, в будильнике, по которому мы просыпаемся на работу не так важно, зазвенит он в 07:00:00 или в 07:00:05), то очень много тактов процессора тратится впустую.
Теперь рассмотрим поведение планировщика, когда какая-либо задача находится в ожидании задержки OS_Delay. Что происходит, когда задача выполняет OS_Delay(100):
(Примечание. Бит bDelay сбрасывается обработчиком сервиса OS_Timer).
Т.е. при выполнении задачей задержки OS_Delay планировщик не передает управление задаче до конца задержки. Т.е. не теряет 50 тактов на заход внутрь задачи и проверку условия.
Таким образом, если есть некритичные к реакции события, можно ускорить работу планировщика, делая замены подобные этой:
// Обычное ожидание события OS_Bsem_Wait(0); // Ожидание, которое ускорит работу планировщика do { OS_Delay(10); // Проверяем условие с интервалом в 10 системных тиков } while (!OS_Check_Bsem(0));
Примечание. Эффективной будет только задержка с использованием таймеров задач. Статический и динамические таймеры здесь не подойдут.(К списку советов по оптимизации)
Вернемся к описанию работы планировщика при ожидании события в какой-либо задаче, описанному в предыдущем параграфе. Как видно, вне зависимости от того, произошло событие на момент проверки или нет, мы по-любому передаем управление планировщику. Объяснение такому поведению уже приводилось: среди задач может оказаться более приоритетная, ожидающая этого же события, и управление должно быть сперва передано ей. Но допустим, что мы на 100% уверены, что второй задачи, ожидающей того же события нет. А нам жалко терять время на выход из задачи и на ожидание того, когда она станет самой приоритетной. Поэтому в таком случае можно прибегнуть к следующему приему:
if (!OS_Bsem_Check(0)) OS_Bsem_Wait(0);
Т.е. мы сперва проверяем, не установлен ли уже семафор, и, только убедившись, что еще нет, возвращаемся в планировщик.
Итак, в статье были рассмотрены рекомендации по принятию решения, использовать ли ОСРВ в своих проектах или нет, рассмотрены основные рекомендации и советы по написанию более эффективных программ с использованием ОСРВ OSA, приведены ответы на часто задаваемые вопросы с объяснениями и примерами, а также произведен некий "взгляд изнутри" операционной системы OSA для более глубокого понимания функционирования планировщика и сервисов.
Возможно, в этой статье я не коснулся каких-то важных моментов, которые нужно было рассмотреть, или Вы, читая статью, не нашли ответ на свой вопрос, или из-за местами непонятного изложения появился какой-то новый вопрос. Может, есть какие-то замечания или предложения, - не стесняйтесь, пишите на мейл osa@pic24.ru или testerplus@mail.ru.
Все личные имена, встречающиеся в статье, приведены с разрешения личностей, эти имена носящих.
На сайте www.pic24.ru статья размещена с разрешения Alex'а B.
Виктор Тимофеев, февраль 2009
osa@pic24.ru