[Из песочницы] Немного о многозадачности в микроконтроллерах
Немного о многозадачности
Каждый, кто день за днем или от случая к случаю, занимается программированием микроконтроллеров, рано или поздно столкнется с вопросом:, а не использовать ли многозадачную операционную систему? Их в сети предлагается довольно много, при этом немало — бесплатных (или почти бесплатных). Только выбирай.
Подобные сомнения появляются, когда попадается проект, в котором микроконтроллер должен одновременно выполнять несколько различных действий. Некоторые из них не связаны с другими, а остальные, наоборот, не могут друг без друга. К тому же, и тех и других может оказаться слишком много. Что такое «слишком много» зависит от того, кто будет оценивать или от того, кто будет выполнять разработку. Хорошо, если это один и тот же человек.
Это, скорее, не вопрос количества, а вопрос качественного различия задач по отношению к скорости выполнения, или еще каким-то требованиям. Такие мысли могут возникнуть, например, когда в проекте требуется регулярно отслеживать напряжение питания (не пропало ли?), довольно часто считывать и сохранять значения входных величин (покоя не дают), изредка следить за температурой и управлять вентилятором (дышать нечем), сверять свои часы с тем, кому доверяешь (вам хорошо там командовать), поддерживать связь с оператором (стараться не нервировать его), проверять контрольную сумму постоянной памяти программ на предмет деменции (при включении, или раз в неделю, или с утра).
Подобные разнородные задачи можно вполне осмысленно и успешно запрограммировать, опираясь на одну фоновую задачу и на прерывания от таймера. В обработчике этих прерываний каждый раз выполняется один из «кусочков» очередной задачи. В зависимости от важности, срочности или подобных соображений, эти вызовы для одних задач повторяются часто, а для других — редко. И еще, надо заботиться о том, чтобы каждая задача делала короткую по времени часть работы, потом готовилась к следующей небольшой порции работы и так далее. Такой подход, если привыкнуть, не кажется слишком сложным. Неудобства возникают, когда требуется нарастить проект. Или, например, вдруг передать другому. Надо заметить, что второе частенько оказывается труднее и без всякой псевдо- много-задачности.
А что если использовать готовую операционную систему для микроконтроллеров? Несомненно, многие так делают. Это хороший вариант. Но автора этих строк, до сих пор, останавливала и продолжает останавливать мысль о том, что в этом надо будет разбираться, потратив немало времени, выбрать из того, что удалось добыть и из этого использовать только то, что реально требуется. И все это делать, заметьте, копаясь в чужом коде! И нет уверенности, что через полгода это не придется повторить, ибо забудется.
Другими словами, зачем Вам полный гараж инструментов и приспособлений, если там хранится и используется велосипед?
Поэтому возникло желание сделать простую «переключалку» задач только под Cortex-M4 (ну, может, еще под M3 и M7). Но и старое, доброе стремление не сильно напрягаться никуда не пропало.
Итак, делаем самое простое. Небольшое число задач делят время выполнения поровну. Как на рисунке 1 ниже это делают четыре задачи. Нулевая из них пусть будет main, поскольку трудно представить другое.
Работая так, они гарантированно получают свой слот или отрезок времени (тик) и совсем не обязаны знать о существовании других задач. Каждая задача ровно через 3 тика снова получит возможность чем-то заняться.
Но, с другой стороны, если какой-то из задач потребуется ждать внешнего события, например, нажатия кнопки, то она тупо будет тратить драгоценное время нашего с Вами микроконтроллера. Мы не можем с этим согласиться. И наша жаба (совесть) — тоже. Надо что-то делать.
А пусть задача, если ей пока нечего делать, отдаст оставшееся от тика время своим товарищам, которые, наверняка, пашут изо всех сил.
Другими словами, делиться надо. Пусть задача 2 так и делает, как на рисунке 2.
А почему бы и нашей фоновой задаче main тоже не позволить отдавать остатки времени, если все равно приходится ждать? Давайте позволим. Как это показано на рисунке 3.
А если известно, что какой-то из задач не скоро потребуется что-то снова проверить или просто поработать? И она могла бы позволить себе немного поспать, а вместо этого будет бесцельно тратить время и путаться под ногами. Не порядок, это надо поправить. Пусть задача 3 будет пропускать один кусочек своего времени (или тысячу). Как показано на рисунке 4.
Ну вот, как видим, у нас наметилось справедливое сосуществование задач или что-то в этом роде. Надо теперь заставить наши отдельные задачи вести себя так, как им предписано. И если мы стараемся ценить время, то стоит вспомнить о языке низкого уровня (не побоюсь этого слова — ассемблере) и не доверяться полностью компилятору с какого-нибудь языка, высокого уровня или очень высокого. Мы ведь, в глубине души, решительно против всякой зависимости. К тому же, упрощает жизнь то обстоятельство, что нам нужен не любой ассемблер, а только от Cortex-M4.
Для стека выделим одну общую область оперативной памяти, которая будет заполняться вниз, то есть, в сторону уменьшения адресов памяти. Почему? Просто потому, что оно по-другому не работает. Эту важную область поделим мысленно на равные участки по числу заявленного максимального количества наших задач. На рисунке 5 это показано для четырех задач.
Далее выделим место, где мы будем хранить копии указателей стеков для каждой задачи. Теперь, по прерыванию от таймера, который мы примем за системный, сохраняем все регистры текущей задачи в ее стековой области (туда указывает сейчас регистр SP), затем сохраняем ее указатель стека в специальном месте (сохраняем его значение), достаем указатель стека следующей задачи (записываем новое значение в регистр SP) из нашего специального места и восстанавливаем все ее регистры. На их копии сейчас указывает регистр SP нашей следующей задачи. Ну, и выходим из прерывания, конечно. При этом в регистрах оказывается весь контекст следующей по списку задачи.
Наверное, лишним будет сказать о том, что следующей после task3 на очереди будет main. А не лишним, конечно, будет напомнить, что в Cortex-M4 уже предусмотрен SysTick таймер и специальное прерывание от него, и многие производители микроконтроллеров об этом знают. Его и это прерывание мы и употребим по назначению.
Для того, чтобы запустить этот системный таймер, а также сделать все необходимые приготовления и проверки, надо воспользоваться предназначенной для этого процедурой.
U8 main_start_task_switcher(void);
Эта подпрограмма возвращает 0, если все проверки пройдены или код ошибки, если что-то пошло не так. Проверяется, в основном, правильно ли выровнен стек и достаточно ли места для него, а также заполняются начальными значениями все наши специальные места. Короче, скука.
Если кто-то захочет взглянуть на текст программы, то в конце повествования он легко сможет это сделать, например, через личную почту.
Да, совсем забыл, когда мы будем забирать из хранения регистры следующей задачи в первый в ее жизни раз, надо чтобы они получили осмысленные первородные значения. И поскольку, забирать она их будет из своего участка стека, надо их заранее туда положить и ее указатель стека сдвинуть так, чтобы удобно было брать. Для этого нам понадобится процедура
U8 task_run_and_return_task_number(U32 taskAddress);
Этой подпрограмме мы сообщаем 32-ух битный адрес начала нашей задачи, которую мы хотим запустить. А она (подпрограмма) сообщает нам номер задачи, который получился в специальной общей таблице, или 0, если места в таблице не оказалось. Затем мы можем запустить еще одну задачу, потом еще одну и так далее, хоть все три в дополнение к нашей никогда не выключаемой задаче main. Она свой нулевой номер не отдаст никому и никогда.
Пару слов о приоритетах. Главным приоритетом было и остается не грузить читателя излишними подробностями.
А, если серьезно, то надо вспомнить, что существуют прерывания от последовательных портов, от нескольких SPI соединений, от аналого-цифрового преобразователя, от другого таймера, в конце концов. И что будет если мы соберемся переключиться на другую задачу (переключить контекст), когда находимся в обработчике какого-нибудь прерывания. Ведь это будет не легитимная задача, а временное помутнение хода программы. А мы сохраним этот странный контекст как какую-то задачу. Случится конфуз: воротничок не застегивается, пилотка не налезает. Стоп, нет, это из другой истории.
В нашем случае этого просто нельзя допустить. Нельзя допустить, чтобы мы стали переключать контекст во время обработки не запланированного нами прерывания. Вот для этого и придуманы приоритеты. Мы просто должны немного подождать, а уж затем, когда эта невиданная наглость закончится, спокойно переключиться на другую задачу. Короче приоритет прерывания нашего переключателя задач должен быть слабее приоритета любого из остальных используемых прерываний. Это, кстати, тоже делается в нашей процедуре старта, именно там он и устанавливается, самым не приоритетным из всех возможных.
Не хотелось говорить, но придется. В нашем процессоре предусмотрено два режима работы: привилегированный и не привилегированный. А также два регистра для указателя стека:
главный SP и SP процесса. Так вот, мы не будем размениваться на мелочи, мы будем использовать только привилегированный режим и только главный указатель стека. Тем более, что все это уже дано при старте контроллера. Так что, просто не будем усложнять себе жизнь.
Осталось вспомнить, что каждая задача, наверняка, хотела бы иметь возможность бросить все к черту и как следует отдохнуть. И это может произойти в любой момент в течение рабочего дня, то есть во время нашего тика. В Cortex-M4 для подобных случаев предусмотрена специальная ассемблерная команда SVC, которую мы приспособим для своей ситуации. Она приводит к прерыванию, которое приведет нас к цели. И мы позволим задаче не только уйти с рабочего места после обеда, но и назавтра не приходить. Да чего уж там, пусть приходит после праздников. А если надо, то пусть приходит, когда закончит ремонт или не приходит совсем. Для этого есть процедура, которую задача сама может вызвать.
void release_me_and_set_sleep_period(U32 ticks);
Этой подпрограмме надо только указать сколько тиков планируется отдыхать. Если 0, то значит можно отдохнуть только остаток текущего тика. Если 0xFFFFFFFF, то задача будет «спать» до тех пор, пока кто-то не разбудит. Все остальные числа означают то количество тиков, в течение которых задача будет в состоянии сна.
Чтобы задачу кто-то еще смог разбудить со стороны или заставить спать, пришлось добавить такие процедуры.
void task_wake_up_action(U8 taskNumber);
void set_task_sleep_period(U8 taskNumber, U32 ticks);
И, на всякий случай, даже такую подпрограмму.
void task_remove_action(U8 taskNumber);
Она, грубо говоря, вычеркивает задачу из списка работающих. Честно, пока не знаю, зачем я ее написал. Вдруг пригодится?
Пришло время показать, как выглядит то место, где одна задача сменяется другой, то есть сам переключатель.
На всякий случай, давайте вспомним, что некоторая часть регистров, при входе в прерывание, сохраняется в стеке без нашего участия, автоматически (так принято в Cortex-M4). Поэтому нам надо только сохранить оставшиеся. Ниже это можно увидеть. Не пугайтесь увиденного, это инструкции ассемблера от Cortex-M4(M3, M7), в изложении IAR Embedded Workbench.
Те, кто пока еще не сталкивался с инструкциями ассемблера, просто поверьте, они действительно так выглядят. Это молекулы, из которых состоит любая программа под ARM Cortex-M4.
SysTick_Handler
STMDB SP!,{R4-R11} ;Сохраняем остальные регистры
LDR R0,=timersTable ;Указатель на таблицу таймеров
LDR R1,=stacksTable ;Указатель на таблицу стеков
LDR R2,[R0] ;R2 Номер текущей (активной) задачи
STR SP,[R1,R2,LSL #2] ;Сохраняем копию текущего SP (R2 * 4)
__st_next_check
ADD R2,R2,#1 ;Номер следующей задачи
CMP R2,#TASKS_LIMIT ;R2-TASKS_LIMIT сравниваем
BLO __st_no_border_yet ;Еще не граница
MOV R2,#0 ;Теперь с начального номера (main)
LDR R3,[R1] ;Возьмем main SP
MOV SP,R3
B __st_timer_ok
__st_no_border_yet
;; LDR SP,[R1,R2,LSL #2] ;Так не надо делать (errata Cortex M4)
;; CMP SP,#0 ;
LDR R3,[R1,R2,LSL #2] ;Возьмем следующий SP и сравним его с нулем
CMP R3,#0 ;Если =0 значит такая задача отсутствует
BEQ __st_next_check
MOV SP,R3
LDR R3,[R0,R2,LSL #2] ;Проверяем соответствующий suspend timer
CBZ R3,__st_timer_ok ;Если 0 то задача не подвешена, ее берем
;
CMP R3,#0xFFFFFFFF ;Задача выключена, но существует
BEQ __st_next_check
SUB R3,R3,#1 ;Уменьшаем на 1
STR R3,[R0,R2,LSL #2] ;Сохраняем соответствующий suspend timer
B __st_next_check
__st_timer_ok
STR R2,[R0] ;Ставим номер следующей активной задачи
LDMIA SP!,{R4-R11} ;Восстанавливаем регистры R4-R11
BX LR
Обработка прерывания, заказанного самой задачей, когда она отдает остаток тика, выглядит похожим образом. С той лишь разницей, что надо еще похлопотать о том, чтобы поспать потом немного (или уснуть капитально). Там есть одна тонкость. Надо сделать два действия, записать в таймер сна желаемое число и вызвать прерывание SVC. То, что эти два действия происходят не атомарно (то есть не оба одновременно), меня немного беспокоит. Представим на миллисекунду, что мы только взвели таймер и в это время пришла пора поработать другой задаче. Другая начала тратить свой тик, при этом наша задача будет спать следующие свои тики, как и положено (ведь ее таймер не нулевой). Затем, когда придет ее время, наша задача получит свой тик и тут же его отдаст по прерыванию SVC, поскольку из двух действий это одно осталось не сделано. Ничего страшного, на мой взгляд, не произойдет, но осадок останется. Поэтому сделаем так. Будущий таймер сна кладется в предварительное место. Его оттуда берет сама процедура обработки прерывания от SVC. Атомарность, как бы, достигнута. Это показано ниже.
SVC_Handler
LDR R0,__sysTickAddr ;Адрес SysTick таймера
MOV R1,#6 ;Надо записать в CSR регистр, чтобы остановить
STR R1,[R0] ;Stop SysTimer
MOV R1,#7 ;А теперь, чтобы стартовать
STR R1,[R0] ;Start SysTimer
;
STMDB SP!,{R4-R11} ;Сохраняем остальные регистры
LDR R0,=timersTable ;Указатель на таблицу таймеров
LDR R1,=stacksTable ;Указатель на таблицу стеков
LDR R2,[R0] ;R2 Номер текущей (активной) задачи
STR SP,[R1,R2,LSL #2] ;Сохраняем копию текущего SP (R2 * 4)
LDR R3,=tmpTimersTable ;Указатель на таблицу tmpTimers
LDR R3,[R3,R2,LSL #2] ;tmpTimer для этой задачи
STR R3,[R0,R2,LSL #2] ;Обновляем timer задачи
__svc_next_check
ADD R2,R2,#1 ;Номер следующей задачи
CMP R2,#TASKS_LIMIT ;R2-TASKS_LIMIT сравниваем
BLO __svc_no_border_yet ;Еще не граница
MOV R2,#0 ;Теперь с начального номера (main)
LDR R3,[R1] ;Возьмем main SP
MOV SP,R3
B __svc_timer_ok
__svc_no_border_yet
;; LDR SP,[R1,R2,LSL #2] ;Restore SP does not work (errata Cortex M4)
;; CMP SP,#0 ;
LDR R3,[R1,R2,LSL #2] ;Возьмем следующий SP и сравним его с нулем
CMP R3,#0 ;Если =0 значит такая задача отсутствует
BEQ __svc_next_check
MOV SP,R3
LDR R3,[R0,R2,LSL #2] ;Проверяем соответствующий suspend timer
CBZ R3,__svc_timer_ok ;Если 0 то задача не подвешена, ее берем
B __svc_next_check
__svc_timer_ok
STR R2,[R0] ;Ставим номер следующей активной задачи
LDMIA SP!,{R4-R11} ;Восстанавливаем R4-R11
BX LR
Надо напомнить, что все эти подпрограммы и обработчики прерываний обращаются к некоторой области данных, которая выглядит в исполнении автора как показано на рисунке 7.
DATA
SECTION .taskSwitcher:CODE:ROOT(2)
__topStack
DCD sfe(CSTACK)
__botStack
DCD sfb(CSTACK)
__dimStack
DCD sizeof(CSTACK)
__sysAIRCRaddr
DCD 0xE000ED0C
__sysTickAddr
DCD 0xE000E010
__sysSHPRaddr
DCD 0xE000ED18
__sysTickReload
DCD RELOAD
;*******************************************************************************
; Task table for concurrent tasks (main is number 0).
;*******************************************************************************
SECTION TABLE:DATA:ROOT(2)
DS32 1 ;stack shift due to FPU
mainCopyCONTROL
DS32 1 ;Needed to determine if FPU is used
mainPSRvalue
DS32 1 ;Copy from main
;*******************************************************************************
Чтобы убедиться, что во всем вышесказанном есть здравый смысл, автору пришлось написать небольшой проект под IAR Embedded Workbench, где все удалось детально рассмотреть и потрогать руками. Все проверялось на контроллере STM32F303VCT6 (ARM Cortex-M4). А точнее, с использованием платы STM32F3DISCOVERY. Там достаточно светодиодов, чтобы дать каждой задаче вдоволь помигать своим светодиодом отдельно.
Есть еще несколько функций, которые мне показались полезными. Например, подпрограмма, которая подсчитывает в каждой стековой области количество незатронутых слов, то есть остающихся равными нулю. Это может пригодиться при отладке, когда надо проверить, не слишком ли близко к предельному уровню заполнение стека той или иной задачей.
U32 get_task_stack_empty_space(U8 taskNum);
Еще одну функцию хотелось бы упомянуть. Это возможность для самой задачи узнать свой номер в списке. Можно его потом кому-нибудь сообщить.
;*******************************************************************************
; Example: U8 get_my_number(void);
; Возвращает номер вызывающей задачи (текущей). Т.е. задача узнает свой номер.
;*******************************************************************************
get_my_number
LDR R0,=timersTable ;Начало таблицы таймеров задач (currentTaskNumber)
LDR R0,[R0] ;Номер задачи
BX LR
;==============================================================
Вот, пожалуй, и все.