Это черновая версия, финальная опубликована на хабре
“Вас много, а я одна!” – классическая фраза продавщицы, которую затерроризировали покупатели с вопросами “А есть …?”. Вот и в микроконтроллерах случаются полностью аналогичные ситуации, когда несколько потоков требуют внимания от какой-либо медленной штуки, которая просто физически не способна обслужить всех разом.
Возьмем наиболее яркий и богатый проблемами пример, на котором “валятся” большинство неопытных программистов. Есть мощный и достаточно быстрый микроконтроллер. К нему подключен с одной стороны адаптер com-порта, через который пользователь подает команды и получает результаты, а с другой – шаговый двигатель, который согласно этим командам поворачивается на какой-то угол. И конечно же, прикольная кнопочка, которая тоже что-то этакое значит для пользователя. Где можно наловить проблем?
Пойдем со стороны пользователя. Com-порт, или USART (universal asynchronous receiver/transmitter) – штука очень нежная и капризная. Основной каприз заключается в том, что ее нельзя оставлять без внимания. По одной простой причине – для экономии выводов в 99% случаев выводят только сигналы приема и передачи, оставляя сигналы разрешения приема и передачи за бортом. И стоит микропроцессору хоть чуть-чуть замешкаться, как символ будет потерян. Судите сами: магические цифры 9600/8N1 говорят нам, что в секунду по линии летит 9600 бод, а один символ кодируется 9 импульсами. В итоге максимальная скорость передачи составляет 9600/9 = 1066 байт в секунду. Или чуть меньше одной миллисекунды на байт. А если по ТЗ скорость передачи 115200? Там может прилететь 128 байт за миллисекунду. А ведь микроконтроллеру надо еще обработать эти данные. Кажется, что все плохо, но в реальности куча устройств работает и не доставляет проблем. Какие же решения применяются?
Cамым распространенным (и правильным) решением является перекладка всей работы по приему или передаче символов на плечи микроконтроллера. Делать аппаратный usart порт научились сейчас все, поэтому типовое решение в большинстве случаев выглядит вот так:
void URARTInterrupt()
{
a=GetCharFromUSART();
if(a!=0x13)
buffer[count++]=a;
else
startProcessing();
}
Где проблема? Во-первых, проблема в вызове startProcessing. Стоит ей хоть чуть-чуть задержаться с работой, как очередной символ будет потерян. STM32L1 на минимальной частоте успевает за 1мс обработать 84 команды. В итоге при более-менее развесистой логике полученная конструкция будет терять символы.
После дружеского похлопывания по голове у нас в студии программист переписывает код так, что бы вместо вызова startProcessing поднимался семафор, который и запускал бы обработку полученных данных. И тут же получил следующую проблему в полный рост: пока startProcessing что-то там перемалывает из буфера, обработчик прерывания по получению нового символа начинает планомерно затирать еще не обработанный буфер. После очередного разорванного свитера и скуренной книжки по буферам программист реализует кольцевой буфер с указателями, чем закапывает проблему еще глубже.
Обычно где-то на этом месте я замечаю удивленные глаза народа и возмущенные выкрики в духе “ну такие алгоритмы же работают в куче проектов и ничего, никаких проблем”. Да, работают. Но в каких проектах? Только в тех, где можно все общение с контроллером перевести на синхронную логику. Вот пример диалога пользователя (П) и контроллера (К)
П: ATZ (Контроллер, ты живой?)
К: ОК (Ага, я тут)
П: М340,1 (Сделай чего-то)
(тут может быть пауза, иногда очень большая)
К: ОК (Сделал типа)
Где проблемы? Во-первых, нельзя послать несколько команд подряд. Значит либо надо будет менять логику или делать сложные команды с несколькими параметрами. Во-вторых, между запросом и ответом не может быть никаких других запросов и ответов. А вдруг пользователь в процессе движения нажмет кнопку? Что делать? Выход только один – использовать родную природу порта, а именно его асинхронность. В результате диалог между пользователем и контроллером выглядит примерно так
П: ATZ (жив?)
К: ОК (ага)
П: M340,1
К: К1=1 (кнопку нажали)
П: Е333,25,2
(тишина)
К: Е=1 (задачу Е выполнил)
К: М=1 (задачу М выполнил)
Конечно, логика обработки подобного потока немного усложняется, зато благодаря такой асинхронности мы сразу получаем множество преимуществ перед “классической школой”.
Во-первых, резко повышается отзывчивость интерфейса с пользователем. Ну работает там где-то моторчик или считается что-то, но это же не повод “замирать” или “тормозить”. А когда пользователь понимает, что величина тормозов практически не зависит от мощности контроллера, у него возникают резонные вопросы …
Во-вторых, легко реализовать совместное управление. Скажем, у меня на плате 8 выводов. Легко можно сделать так, что бы первые 3 управлялись через USART1, вторые 3 через USB-CDC, а последние два – совместно. И все это на одном контроллере.
И наконец, очень легко отвязать логику от аппаратного интерфейса. Нам все равно, откуда приходят команды – от соседнего процесса, от пользователя или вообще от другого контроллера.
А теперь со всеми этими задумками взглянем на противоположную сторону – на сторону исполнителя. Он достаточно медлителен, что бы попросту не успеть обработать команды по порядку. И время запуска-остановки моторчика очень большое, поэтому хорошо бы сделать примитивную оптимизацию в духе “если поступило две команды на поворот в одну сторону, то поверни за раз”.
Что делает обычный программист? Так как он прочитал предыдущие статьи и кучку книжек, то он рисует логику расставляя семафоры по необходимости, а для блокирования одновременного доступа к моторчику использует мутексы. Все хорошо, но код получается громоздкий и тяжело читаемый. Что делать? У нас в studiovsemoe.com мы используем рецепт Шарикова: “В очередь, сукины дети! В очередь!”
В данном примере можно просто создать три очереди. Первая это команды, полученные от пользователя. В нее засовывается все (ну или после минимальной проверки), что принято со всех входных портов. Вторая это те данные, которые необходимо выдать пользователю. Состояние кнопок, результаты расчетов и так далее. И наконец, третья очередь служит для заданий моторчику/считалке.
Все, всего три очереди, а дикая куча проблем решена. Во-первых, нет даже потенциальной проблемы потери или переписи буфера принятых символов. Правда результатом станет чуть больший расход памяти, но это допустимая цена. Во-вторых, нет проблем с выводом. Все процессы просто пишут в одну очередь, а как выводить, в каком формате и прочее – это уже не их забота. А значит упрощается код и понижается стоимость разработки и поддержки. И наконец благодаря такому подходу задачи очень легко разделить на более мелкие и раскидать их по потокам/ядрам микропроцессора.
Что бы не отходить от практики мигания светодиодами, сделаю следующую демонстрацию: создам очередь, из которой раз в секунду будет забираться элемент (имитация обсчета). По нажатию кнопки два раза в секунду в очередь будет помещаться элемент (управляющие команды). Ну а светодиодики будут показывать загруженность очереди. Опять же, предупреждаю что код для повышения читаемости написан без обработки ошибок, поэтому аккуратней.
Где-то в начале кода определим очередь
xQueueHandle q;
Код светодиодов поменяем по принципу “в очереди больше Н элементов? зажигаем, если нет, то нет”
if(uxQueueMessagesWaiting(q)>1)
HAL_GPIO_WritePin(GPIOE,GPIO_PIN_9,GPIO_PIN_SET);
else
НAL_GPIO_WritePin(GPIOE,GPIO_PIN_9,GPIO_PIN_RESET);
osDelay(100);
Перед запуском планировщика проинициализируем очередь так, что бы она могла хранить 8 байт.
q = xQueueCreate( 8, sizeof( unsigned char ) );
Ну и код кнопки для помещения символов в очередь
if(HAL_GPIO_ReadPin(GPIOA,GPIO_PIN_0)==GPIO_PIN_SET)
{
unsigned char toSend;
xQueueSend( q, ( void * ) &toSend, portMAX_DELAY );
}
osDelay(500);
Чего не хватает? Воркера, который забирает из очереди задания. Пишем.
static void WorkThread(void const * argument)
{
for(;;)
{
unsigned char rec;
xQueueReceive( q, &( rec ), portMAX_DELAY );
osDelay(1000);
}
}
Как видите, все параметры аналогичны тем, что используются в семафорах. Результат проще показать.
Как обычно, использующийся код доступен по адресу http://kaloshin.ru/stm32/freertos/stage3.rar