
Необходимость выполнять на одном контроллере несколько алгоритмов одновременно у нас возникла довольно давно, но до сих пор нам удавалось обойтись простыми решениями. Теперь рассмотрим несколько основных способов организации многозадачности.
Часть 8. Привилегии и защита памяти
15 Введение
С простейшими примерами многозадачности вы уже наверняка познакомились, если экспериментировали с контроллерами.
Как минимум, вам знаком ввод-вывод отладочной информации по прерываниям UART. Напомню один из возможных вариантов реализации: создается кольцевой буфер, куда основной код пишет теми блоками, которые ему удобны, а модуль UART по прерываниям забирает оттуда байт за байтом. И в обратную сторону: UART по прерываниям кладет в буфер по одному байту, а основной код может оттуда вычитать сразу строку. Таким образом, в контроллере как будто одновременно работают три процесса: основной код, обработчик UART на прием и обработчик UART на передачу. Переключение между ними осуществляется по внешнему событию (байт отправлен или байт пришел).
Второй пример — матрица светодиодов. Ее нельзя обновить целиком за один раз, за раз обновляется только одна строка. Поэтому, чтобы картинка была видна человеку, функцию обновления приходится вызывать постоянно с некоторым интервалом. Опять же получается как будто второй процесс, работающий параллельно основному. Но тут он вызывается из основного кода явно.
Нужды в более сложных методах организации многозадачности не возникало в основном из-за простоты кода. Собственно, я и старался сделать его максимально простым, чтобы не отвлекаться от темы рассказа. Но в реальных проектах практически всегда надо и датчики опрашивать (включая фильтрацию и прочую обработку сигналов), и с пользователем взаимодействовать, и логи вести, и ошибки генерировать отслеживать. При дальнейшем усложнении кода может возникнуть необходимость более хитрого взаимодействия процессов друг с другом или, наоборот, ограничения этого взаимодействия. Чтобы один процесс, например, не смог влезть в память другого или начать что-то писать в периферию, которой тот уже пользуется. Неплохо бы выделить наиболее критичные процессы, которые бы вызывались чаще других. И наоборот, менее важные, которым процессорное время отводится по остаточному принципу.
Но стоп. Специфика маломощных контроллеров все же не настолько разнообразна. Так например, практически всегда весь набор возможных процессов известен на этапе компиляции, и каждый из них запускается ровно в единственном экземпляре. Ну действительно, если уж мы написали процесс для работы с экраном, мы же не будем ожидать, что экран внезапно куда-то исчезнет. Не пользователь же с кусачками придет. Или с паяльником, новый прикручивать. Поэтому с динамическим размещением переменных да и вообще с динамической памятью нам возиться не придется, что уже сильно упрощает задачу.
Иллюстрировать различные способы организации многозадачности я буду при помощи простого алгоритма, состоящего из двух процессов. Первый будет переключать светодиоды, а второй — бесконечно отправлять по UART'у какой-то текст.
15.1 Конечный автомат
Внимание! «Конечный автомат» — это не общеупотребимый термин одного из вариантов многозадачности. На самом деле это специфичная вариация следующего пункта — кооперативной многозадачности. Правильнее сказать, что «конечный автомат» — специфический способ записи алгоритма. Достаточно простой, чтобы даже не задумываться, что это ведь тоже многозадачность. Именно он использовался в описанных выше примерах.
При данном подходе каждый процесс представляет собой функцию, которая явным образом вызывается из основного кода, быстро выполняет свои дела и тут же завершается. Ключевое слово здесь «быстро». В функции не должно быть задержек. Если нужное событие не наступило (не прошло достаточно времени, не взвелся флаг в периферии), она просто завершается в надежде, что событие наступит при следующем вызове.
Зачастую задержки в функции все-таки нужны, да еще не одна, а несколько в разных местах. В таком случае функция как бы делится на несколько блоков от задержки до задержки, а выбор блока осуществляется обычным switch по специальной, явно описанной переменной. Эта переменная называется состоянием конечного автомата. Помимо реализации линейного алгоритма с задержками она позволяет организовывать ветвление в зависимости от внешних событий.
Для примера приведу диаграммы описанного ранее алгоритма:


А вот как это выглядит в коде. Полный код примера можно посмотреть на GitHub
void task1(){ //LEDs
static uint8_t state = 0;
static int32_t t_av = 0;
const int32_t t_delay_cyc = 200 * (F_SYS / 1000);
int32_t t_cur = read_mcycle();
switch(state){
case 0:
if( (t_cur - t_av) > 0){
t_av = t_cur + t_delay_cyc;
GPO_OFF(GLED);
state = 1;
}
break;
case 1:
if( (t_cur - t_av) > 0){
t_av = t_cur + t_delay_cyc;
GPO_ON(RLED);
state = 2;
}
break;
case 2:
if( (t_cur - t_av) > 0){
t_av = t_cur + t_delay_cyc;
GPO_OFF(RLED);
state = 3;
}
break;
case 3:
if( (t_cur - t_av) > 0){
t_av = t_cur + t_delay_cyc;
GPO_ON(GLED);
state = 0;
}
break;
default: state = 0;
}
}
void task2(){ //UART
static uint16_t idx = 0;
if( ! (USART_STAT(USART0) & USART_STAT_TBE) )return;
USART_DATA(USART0) = uart_data[idx];
idx++;
if(idx > sizeof(uart_data))idx = 0;
}
...
int main(){
...
while(1){
task1();
task2();
}
}
Еще раз обращаю внимание: никаких «тупых» задержек. Если нужна задержка по времени или ожиданию флага периферии, она выполняется однократным опросом и, если событие не наступило, возвратом из функции. Иначе «тупая» задержка будет тормозить вообще все.
Такой способ организации многозадачности самый простой и быстрый (меньше всего накладных расходов). Но при этом код процессов получается непривычным и сложным для понимания. Ну и, разумеется, нет никакой «защиты от дурака».
Стоит еще раз обратить внимание, что именно такой подход сплошь и рядом используется при работе с периферией. Причем с обеих сторон: и когда контроллер ей управляет, и когда сам ей является. Первые примеры, которые мне вспомнились, это организация JTAG и логика работы с USB-флешками.
Полный код примера на Github: GD32VF103 CH32V303
15.2 Кооперативная (совместная) многозадачность
Этот способ реализации многозадачности является логичным продолжением предыдущего. Мы ведь уже изучали ассемблер и знаем, где там хранится состояние программы в общем виде: в регистре pc
(ProgramCounter), в переменных и на стеке. Следовательно, можно написать функцию, которая бы это состояние где-то сохраняла, потом восстанавливала состояние другого процесса и возвращала управление уже ему. Вообще-то, в RISC-V напрямую читать pc
нельзя, но мы ведь пишем специальную функцию по переключению процессов, и вот в нее он приедет под именем регистра ra.
Кстати, использование для переключения задач специальной функции дает еще одно преимущество. Мы ведь помним про соглашение о сохранении регистров. Иначе говоря, в функции мы не обязаны сохранять t*
, что уже неплохая экономия.
Для хранения состояния процессов логично выделить каждому из них персональный стек. Ну а все стеки собрать в массив и переключаться между ними. Я просто выделил в общей памяти массив из 10 стеков по килобайту каждый. Заодно там же, на стеке процесса, можно хранить и регистры между переключениями.
.equ task_size, 1024
.equ task_count_max, 10
.data
task_count: .byte 0
task_idx: .byte 0
.bss
.align 2
local_stack: .space (task_size*task_count_max)
local_stack_en: .space 1
...
.global yield
.text
yield:
push ra, gp, tp, s0, s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11
//save current stack
la s0, task_count
lb s1, 1(s0)
li s2, task_size
mul s3, s2, s1
la s4, local_stack_en
sub s5, s4, s3
sw sp, 0(s5)
//next task
addi s1, s1, 1
lb s6, 0(s0)
blt s1, s6, yield_skip_wrap
li s1, 0
yield_skip_wrap:
sb s1, 1(s0)
//load next stack
mul s3, s2, s1
sub s5, s4, s3
lw sp, 0(s5)
pop ra, gp, tp, s0, s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11
ret
void task1(){ //LEDs
static int32_t t_av = 0;
const int32_t t_delay_cyc = 200 * (F_SYS / 1000);
int32_t t_cur = systick_read32();
GPO_OFF(GLED); GPO_OFF(RLED);
while(1){
GPO_ON(GLED);
t_av = systick_read32() + t_delay_cyc;
while( (systick_read32() - t_av) < 0 )yield();
GPO_OFF(GLED);
t_av = systick_read32() + t_delay_cyc;
while( (systick_read32() - t_av) < 0 )yield();
GPO_ON(RLED);
t_av = systick_read32() + t_delay_cyc;
while( (systick_read32() - t_av) < 0 )yield();
GPO_OFF(RLED);
t_av = systick_read32() + t_delay_cyc;
while( (systick_read32() - t_av) < 0 )yield();
}
}
void task2(){ //UART
uint16_t idx = 0;
while(1){
while( !(USART1->STATR & USART_STATR_TXE) )yield();
USART1->DATAR = uart_data[idx];
yield();
idx++;
if(idx > sizeof(uart_data))idx = 0;
}
}
...
task_create(task1);
task_create(task2);
os_start();
while(1){
}
Весь код я здесь приводить не буду, но его можно посмотреть на Github: GD32VF103 CH32V303
Вызывать функцию переключения процессов руками не всегда удобно (главным образом потому, что велик риск забыть), поэтому ее часто встраивают в некоторые системные функции. В первую очередь — в функции задержки.
Как обычно, подобные алгоритмы подвержены атакам. Например, я слышал историю, как на древнем терминальном сервере, устроенном именно по такому принципу, нашелся маньяк, который написал свою программу так, что она вообще не использовала системных функций. Соответственно, не происходило переключения на другие процессы, и все процессорное время досталась только ему. Коллеги были несколько недовольны.
15.3 Вытесняющая многозадачность
Сам собой напрашивается следующий шаг: не доверять процессу и переключать его принудительно в прерывании таймера. При этом, разумеется, появляются новые проблемы. Самое банальное — надо сохранять не только сохраняемые регистры (s0 — s11) и не только те, что используются в самом прерывании, а вообще все. Включая CSR вроде mepc. Впрочем, как раз это не проблема, сохранять регистры мы умеем. В качестве таймера для переключалки процессов отлично подойдет рассмотренный в прошлый раз mtime — все равно он ущербный и ни на что другое не пригоден. Здесь код получился еще больше (150 строк), но на GitHub пока помещается. В CH32 регистра mtime нет, поэтому вместо него будем использовать таймер Systick.
Причем в случае CH32 нас поджидают довольно неочевидые грабли. По умолчанию в startup-файле от WCH включается поддержка «быстрых» прерываний с аппаратным сохранением половины регистров на теневом стеке и восстановлением их оттуда при завершении прерывания. А проблема в том, что нам-то нужно сохранить на одном стеке, а восстанавливать из другого. И я это делал, вручную. А потом приходило ядро контроллера и затирало мои регистры значениями из теневого стека. Долго я пытался понять почему же оно не работает...
Простейшим решением будет влезть в startup-код и отключить interrupt-fast (кстати, в нашем случае, с ручным сохранением регистров, это даст даже выигрыш в два такта). Но можно поступить чуть хитрее и прочитать описание CSR-регистра, отвечающего за это. Он называется INTSYSCR
и имеет адрес 0x804
. Его пятый бит, GIHWSTKNEN
, как раз и предназначен для временного отключения теневого стека.
void task1(){ //LEDs
GPO_OFF(GLED); GPO_OFF(RLED);
const int32_t t_delay_cyc = 200 * (F_SYS / 1000);
while(1){
GPO_ON(GLED);
delay_ticks(t_delay_cyc);
GPO_OFF(GLED);
delay_ticks(t_delay_cyc);
}
}
void task2(){ //UART
uint16_t idx = 0;
while(1){
while( !(USART1->STATR & USART_STATR_TXE) ){};
USART1->DATAR = uart_data[idx];
idx++;
if(idx > sizeof(uart_data))idx = 0;
}
}
...
task_create(task1);
task_create(task2);
os_start();
while(1){
}
Здесь task1
чуть попроще, она не мигает красным светодиодом. Это сделано для наглядности: красным светодиодом мигает переключалка задач. Ее интервал выставлен в одну секунду чтобы можно было видеть как происходит процесс. Теперь задачи уже выглядят как самостоятельные программы. В них может вообще не быть специальных функций переключения контекстов, они даже могут не подозревать, что делят среду выполнения с кем-то еще.
При использовании прерываний для переключения задач возникают новые особенности. Прерывание ведь может возникнуть в произвольный момент, между любыми инструкциями. В том числе, например, между записями двух половин 64-битного числа. Если это число используется только самим процессом, проблемы нет, но если его захочет прочитать другой, то получит какую-нибудь ерунду. Не то, что было до начала записи и не то, что должно было получиться в конце, а их смесь. Это называется «неатомарный доступ»: обращение к переменной происходит не единым неделимым процессом, а может быть прервано посередине. Можно было бы запрещать на время записи прерывания, но, во-первых, юзерскому коду это, по-хорошему, должно быть запрещено, а во-вторых — слишком велик риск забыть. В «полноценных» операционных системах для этого используют специальные средства межпроцессного взаимодействия — семафоры, очереди и тому подобное.
Еще с одной схожей проблемой мы уже знакомы: оптимизация. Компилятор имеет право хранить переменную не в отведенной для нее области памяти, а просто на регистрах. Код из другого процесса (в том числе просто из прерывания) к ней получить доступ не сможет. И способ решения нам знаком — модификатор volatile
. Правда, как было сказано выше, с неатомарными данными он не поможет. Для более-менее сложного взаимодействия применяют уже другие методы.
Но появляются и новые возможности! Поскольку распределением процессорного времени теперь занимаются не сами процессы, а специально обученный планировщик (то есть часть ядра операционной системы), можно накрутить еще более интересную логику переключения. Скажем, назначить каждому процессу приоритет: этот у нас следит за наличием внешнего питания — его будем вызывать почаще, тот общается с пользователем — его пореже, а вот этот следит за зарядом резервной батарейки — его можно совсем редко запускать. И реакция на прерывания тоже упрощается: можно прямо из прерывания «переключить процесс» на нужный, а может, если не так спешно, просто поднять ему приоритет.
Это, кстати, важный момент. В контроллерах сплошь и рядом встречается необходимость быстрой реакции на внешние события, независимо от того, что кто-то там решил посчитать число Пи до миллионного знака. Если возникло прерывание — срочно (но без фанатизма, сбой алгоритма нам тоже не нужен) откладываем остальные дела и обрабатываем событие. Операционные системы, заточенные на это, называются операционными системами реального времени (ОСРВ, real-time operating system, RTOS). В компьютерных ОС такого требования нет, там большинство задач вычислительные, и операционная система имеет полное право тупить, сбрасывать память в подкачку на диске и дожидаться других долгих операций, на время которых даже интерфейс перестает отвечать.
Ну и раз уж мы отобрали у процесса возможность переключать контексты, логично отобрать и доступ к произвольной области памяти (а вдруг в чужой стек упадет случайно?), периферии (а вдруг ее уже кто-то другой использует?) и регистрам ядра. Впрочем, это мы уже обсуждали.
Решением этой проблемы, кстати, является появление драйверов устройств, зачастую даже иерархических. Представим ситуацию, что на одном SPI у нас висит и дисплей, и microSD-карта. Разрешить их драйверам доступ напрямую к регистрам нельзя, поэтому надо написать специальный драйвер SPI, который, если что, скажет «я сейчас общаюсь с дисплеем, подожди». А уже к этому драйверу будут обращаться драйверы дисплея, флешки и всего остального.
15.4 RTOS
Вот мы и добрались до самого сложного варианта многозадачности — полноценной операционной системы. Сложность здесь в основном в том, что уж больно много от нее ожидается. Это и переключение контекстов, и защита памяти, и организация обмена, и задержки, и прерывания, и много чего другого.
Сразу скажу, что с RTOS я не работал, поэтому подробностей не ждите. Но без упоминания о них статья была бы неполной. В качестве примера я взял freeRTOS как самую, пожалуй, известную, и ее доработку под CH32-контроллеры. Правда, не самую новую, 2022 года, но, думаю, для первого знакомства сойдет. Подключение началось с множества доработок: нужно прописать кучу *.h и *.c файлов, выпилить из них упоминание stdlib.h. Его не всегда удобно подключать к проекту, да и используется оттуда только memset — проще уж руками его написать. В .ld-файле нужно добавить __freertos_irq_stack_top = .;
. Отдельно для CH32 версии надо убрать wch-interrupt-fast
(ну не поддерживается он стандартным компилятором из репозитория!) и исправить работу с Systick. По умолчанию там применен режим со сбросом счетчика, но у меня Systick активно используется по прямому назначению, для глобального счета времени, и для задержек, поэтому по прерыванию пусть просто меняет значение регистра сравнения Systick->CMP
.
void task1_task(void *pvParameters){
GPO_OFF(GLED); GPO_OFF(RLED);
while(1){
GPO_ON(GLED);
vTaskDelay(200); //Задержка 200 тиков планировщика
GPO_OFF(GLED);
vTaskDelay(200);
GPO_ON(RLED);
vTaskDelay(200);
GPO_OFF(RLED);
vTaskDelay(200);
}
}
void task2_task(void *pvParameters){
uint16_t idx = 0;
while(1){
while( !(USART1->STATR & USART_STATR_TXE) ){} //Заметьте, здесь вообще нет задержек. Так делать нельзя, но это иллюстрация, что FreeRTOS умеет переключать задачи принудительно
USART1->DATAR = uart_data[idx];
idx++;
if(idx > sizeof(uart_data))idx = 0;
}
}
...
xTaskCreate((TaskFunction_t)task2_task,
(const char* )"task2",
(uint16_t )256, //stack size
(void* )NULL,
(UBaseType_t )5, //priority
(TaskHandle_t* )&Task2Task_Handler);
xTaskCreate((TaskFunction_t)task1_task,
(const char* )"task1",
(uint16_t )256, //stack size
(void* )NULL,
(UBaseType_t )5, //priority
(TaskHandle_t* )&Task1Task_Handler);
vTaskStartScheduler();
while(1){}
Этот код очень похож на предыдущий, но внимание обратить стоит на vTaskDelay
. Это системная функция задержки, способная переключить контекст. В принципе, FreeRTOS умеет переключать задачи и по таймеру, на примере task2_task
это видно. Но для этого нужно немножко влезть в настройки (файл FreeRTOSConfig.h
, настройка #define configUSE_PREEMPTION
). Без этого FreeRTOS работает в кооперативном режиме. Да и вообще со стороны задачи как-то некрасиво тратить общее процессорное время на тупое ожидание флага. Лучше пусть этим временем воспользуется кто-то еще. А может, контроллеру даже поспать удастся (не уверен, но похоже, FreeRTOS умеет и это).
Полагаю, для иллюстрации полезности ОСРВ лучше подошла бы задача, где надо с одной стороны осуществлять вывод, скажем, на семисегментник, с другой — регулярно опрашивать медленные датчики, а с третьей — реализовывать какой-нибудь сложный алгоритм. И тут сложность именно в алгоритме, который ни в какой точке естественным образом не рвется. Любые yield будут смотреться противоестественно, да и поддерживать их при модификации кода будет непросто. А вот ОС позволяет игнорировать мнение процессов, когда они там хотят прерваться. Квант истек — отправится ждать следующего кванта, а пока поработают другие. Как видите, мне такую задачу подобрать не удалось.
Основным недостатком ОСРВ является «страшность». Много файлов, много кода, много неочевидного поведения. Вот и думает программист: зачем мне ее изучать, ведь мои задачи прекрасно решаются и старыми средствами. И для простых устройств, подобных тем, что делали в прошлом тысячелетии на контроллерах x51, или на тех, что сейчас делают на Arduino на кружках робототехники, это действительно так. Но мощность контроллеров растет, и со временем объем кода перестает умещаться в голове. При ручной организации многозадачности полезут странные ошибки, связанные, скажем, с невнимательностью. В общем, с некоторого этапа изучить ОСРВ все-таки полезно.
Изредка можно услышать возражение про объем прошивки: даже мой пример занимает целых 15 кБ в противовес 3 кБ на ручном переключении. Но этот объем — статический, с усложнением алгоритма он расти практически не будет.
Заключение
Вот мы и познакомились с основными способами организации многозадачности в контроллерах. Надеюсь, теперь читателю будет проще выбрать, чем воспользоваться в своем проекте, понять, чем воспользовался автор «кривульки», которую он ковыряет, и для чего годится тот или иной подход.
Ну и, надеюсь, стали понятнее принципы функционирования и обычных, компьютерных ОС — по сути ведь там тоже самое, пусть на несколько порядков более сложное.
Как обычно, исходные коды доступны на GitHub:
Кооперативная многозадачность, GD32, CH32
Вытесняющая многозадачность, GD32, CH32
FreeRTOS, CH32
Исходный код доступен на Github.
