В этой статье пойдет речь о немаловажных особенностях разработки встраиваемого программного обеспечения (далее ВПО) для микроконтроллеров (далее МК) с низким уровнем абстракции аппаратного обеспечения.
Это не полноценный гайд по написанию кода ВПО, которых на просторах интернета сегодня тьма, а лишь нюансы, которые помогут писать хороший код.
Особенности этой огромной отрасли, embedded‑разработки, лежат на поверхности и вытекают из ответа на вопрос: а какая вообще стоит задача? Если обобщить, то прозвучит это так: закодировать алгоритм работы, как правило, многозадачной однопоточной системы с низким уровнем абстракции аппаратного обеспечения.
Многозадачная однопоточная система
Стоит сразу разобраться с этими терминами. Поток (Thread) представляет собой физическую (наименьшую) единицу выполнения кода, а задача (Task) — логическую. Поток имеет железный стек и программный счетчик (указатель команд). Исходя из этого можно сказать, что поток = процессорное ядро.
Параллельно, за единицу времени, могут выполняться только потоки, задачи выполняются последовательно внутри потока. Иными словами, система выполняет поток команд (инструкций процессора) для различных задач, а кол‑во потоков зависит от кол‑ва ядер процессора.
Когда на однопоточных (одноядерных) системах начинают обзывать задачи потоками и говорить про параллельную работу, при чем опытные разработчики, немного корежит…
Низкий уровень абстракции аппаратного обеспечения
Уже должно быть не секретом, что embedded‑разработчик работает с регистрами напрямую, но здесь идет речь не только о портах ввода‑вывода (GPIO) или последовательных интерфейсах передачи данных (UART и так далее), а про всю архитектуру целевого МК.
Ключевым моментом здесь является память, как оперативная (ОЗУ), так и постоянная (ПЗУ). Низкий уровень абстракции взаимодействия с памятью заключается в отсутствии аппаратного блока управления памятью (далее MMU), который необходим для запуска операционных систем общего назначения, таких как Linux или Windows.
Главная задача MMU заключается в предоставлении и обеспечении безопасности виртуального адресного пространства, тем самым изолируя потоки и задачи друг от друга. Разработчик работает с виртуальными, а не с физическими адресами памяти, а взаимодействие с железом происходит по средствам системных вызовов.
В системах с низким уровнем абстракции блок MMU отсутствует, разработчик работает с физическими адресами памяти напрямую со всеми вытекающими. Базой для работы таких систем является, так называемый, bare‑metal (работа с железом напрямую), а также «железные» операционные системы — операционные системы реального времени (далее ОСРВ), например, FreeRTOS.
В серьезных процессорных ядрах для МК, таких как ARM CM7, есть блок защиты памяти (MPU), предотвращающий несанкционированный доступ к памяти, не более.
Безусловно, стоит сказать, что системы с несколькими потоками (2 и более ядер) имеют место быть, даже чаще чем вы думаете. Например, достаточно популярный МК ESP32 или какие‑то специализированные решения могут иметь 2 и более ядер. Однако, целью второго потока не является распараллелить выполняемую работу, а вынести какую‑то определенную тяжелую задачу(и) из потока выполнения задач основного алгоритма работы системы. Например, вынос стека технологий передачи/обработки данных или графику на второе ядро — обыденная вещь для ESP32. Но смысл и подход к разработке ВПО не меняется — у разработчика в распоряжении все еще один поток (одно ядро).
Исходя из вышесказанного, разработчик должен грамотно реализовать многозадачность на, относительно десктопных процессоров, достаточно посредственном одноядерном процессоре с тактовой частотой ядра, в лучшем случае, не более пары сотен МГц.
Когда идет речь об, всеми горячо любимой, очередной метеостанции на каком‑нибудь сетапе ESP32 + BME/DHT (или что там сейчас модно), то уже сегодня эту статью можно и не читать, ИИ прекрасно справляется с такой задачей, даже печатную плату разведет и в корпус затолкает, еще и работать будет. Но когда встает вопрос «реального времени», детерминизма, производительности за доллар и прочего, стоит учитывать описанные в данной статье особенности.
Многозадачность
Как правило, кодируемый разработчиком алгоритм работы системы представляет собой какое‑то кол‑во задач различной сложности: опрос устройств ввода и вывод данных в различных формах и представлениях, математическая обработка и др. Нужно четко понимать, что все эти задачи будут выполняться силами одного потока, поэтому следует уделить время архитектуре кода и выбрать подход к реализации этой многозадачности — планировщику.
Глобально, существуют 2 подхода к реализации планирования многозадачности: кооперативное и вытесняющее. Разницу можно продемонстрировать на диаграммах:


Синие стрелочки отображают переключение контекста по завершению роботы задачи (кооперация), красные — по требованию (вытеснение).
Кооперативное планирование. Суперцикл
Кооперативное планирование считается самым простым планированием. При таком планировании все задачи делят процессорное время между собой на столько, на сколько необходимо каждой конкретной задаче. Задачи работают последовательно, не могут друг друга прервать (вытеснить) и выполняют свою работу до конца (до точки выхода из функции задачи, return).
Классическим вариантом кооперативного планирования является суперцикл — расставленный, по определенному разработчиком порядку, набор функций задач в цикле for/while. Здесь можно выделить первый недостаток — планировщиком является разработчик, а само планирование статическое (определено на этапе компиляции).
void main( void ) { while( true ) { connection_task( ); // Задача коммуникации с ПК sensors_task( ); // Задача опроса датчиков keypad_task( ); // Задача опроса нажатия кнопок display_task( ); // Задача вывода данных на дисплей } }
Вторым недостатком такого подхода является зависимость кол‑ва задач, их сложности и порядка вызова на отзывчивость системы в целом — чем задач больше, и они сложнее, тем отзывчивость системы хуже.
В представленном выше примере кода, например, нажатия кнопок будут обработаны задачей keypad_task( ) только в момент ее вызова.
Суперцикл не лишен положительных качеств и позволяет быстро разрабатывать прототипы, отлаживать какие‑то определенные механизмы. Хороший вариант для систем, от которых не ожидается детерминизм, а само решение нужно было уже вчера.
Здесь стоит отметить, что такой подход не всегда является плохим, а применение продвинутых механизмов планирования может быть нецелесообразным.
Кооперативное планирование. Планирование по времени
Алгоритм работы данного планировщика основан на детерминированных (предсказуемых, постоянных) интервалах времени, которые задают частоту вызовов каждой конкретной задачи.
Функции всех задач также находятся внутри суперцикла, но вызов этих задач происходит в определенный момент времени — с определенной частотой (период). Частота вызовов, как правило, является статической и выбирается исходя из приоритета и требуемого кол‑во времени для каждой конкретной задачи.
Детерминированные интервалы времени, а, в результате частоту, генерирует аппаратный таймер, например, системный таймер ядра (Systick) или периферийный таймер общего назначения (TIM/TMR и др. аббревиатуры). Поток, в рамках которого работает такая многозадачность, может синхронизироваться с аппаратным таймером по средствам прерываний или простого чтения регистра счетчика этого таймера.
#define TIME_STATE_SENSORS_TASK_PERIOD_MS 1000U #define TIME_STATE_DISPLAY_TASK_PERIOD_MS 250U #define TIME_STATE_CONNECTION_TASK_PERIOD_MS 100U typedef enum { E_TIME_STATE_KEYPAD_TASK = 0, E_TIME_STATE_CONNECTION_TASK, E_TIME_STATE_DISPLAY_TASK, E_TIME_STATE_SENSORS_TASK } e_time_state_t; typedef struct { uint32_t timeCurr; volatile bool isPolling; } s_time_state_x_task_t; struct { s_time_state_x_task_t sensors; s_time_state_x_task_t display; s_time_state_x_task_t connection; e_time_state_t state; } s_time_state_dcb; void time_state_processing1( void ) { { // Задача опроса датчиков if( s_time_state_dcb.sensors.timeCurr < TIME_STATE_SENSORS_TASK_PERIOD_MS ) { s_time_state_dcb.sensors.timeCurr++; } else { s_time_state_dcb.sensors.timeCurr = 0U; s_time_state_dcb.sensors.isPolling = true; } } { // Задача вывода данных на дисплей if( s_time_state_dcb.display.timeCurr < TIME_STATE_DISPLAY_TASK_PERIOD_MS ) { s_time_state_dcb.display.timeCurr++; } else { s_time_state_dcb.display.timeCurr = 0U; s_time_state_dcb.display.isPolling = true; } } { // Задача коммуникации с ПК if( s_time_state_dcb.connection.timeCurr < TIME_STATE_CONNECTION_TASK_PERIOD_MS ) { s_time_state_dcb.connection.timeCurr++; } else { s_time_state_dcb.connection.timeCurr = 0U; s_time_state_dcb.connection.isPolling = true; } } } e_time_state_t time_state_processing2( void ) { { // Выбор задачи для вызова if( s_time_state_dcb.connection.isPolling ) { s_time_state_dcb.connection.isPolling = false; s_time_state_dcb.state = E_TIME_STATE_CONNECTION_TASK; } else if( s_time_state_dcb.display.isPolling ) { s_time_state_dcb.display.isPolling = false; s_time_state_dcb.state = E_TIME_STATE_DISPLAY_TASK; } else if( s_time_state_dcb.sensors.isPolling ) { s_time_state_dcb.sensors.isPolling = false; s_time_state_dcb.state = E_TIME_STATE_SENSORS_TASK; } else { s_time_state_dcb.state = E_TIME_STATE_KEYPAD_TASK; } } return s_time_state_dcb.state; } void systick_isr( void ) // Вызывается прерыванием (IRQ) 1 раз в миллисекунду { // ... time_state_processing1( ); } void main( void ) { while( true ) { __disable_irq( ); const e_time_state_t time_state = time_state_processing2( ); __enable_irq( ); switch( time_state ) { case E_TIME_STATE_SENSORS_TASK : { sensors_task( ); // Задача опроса датчиков break; } case E_TIME_STATE_DISPLAY_TASK : { display_task( ); // Задача вывода данных на дисплей break; } case E_TIME_STATE_CONNECTION_TASK : { connection_task( ); // Задача коммуникации с ПК break; } case E_TIME_STATE_KEYPAD_TASK : default : { keypad_task( ); // Задача опроса нажатия кнопок break; } } } }
Таким образом получаем: опрос сенсоров 1 раз в секунду, обновление дисплея 4 раза в секунду, коммуникация с ПК 10 раз в секунду, а все свободное процессорное время — обработка нажатий на кнопки.
Из‑за перекрытия вызовов задач, когда к вызову готовы 2 и более задачи, процессорного времени может не хватить для вызова следующей задачи по плану, что порождает недетерминированные реакции системы.
Любой совместный ресурс прерывания и основного потока необходимо защитить, например, отключением этого прерывания на время доступа основного потока к этому ресурсу. В данном случае защищается s_time_state_dcb.sensors/display/connection.isPolling. Сам ресурс необходимо модифицировать как volatile — запретить кэширование ресурса в регистрах процессора.
Если прерывание попало в момент выполнения процессорным ядром инструкции, то, в большинстве случаев, инструкция будет выполнена до конца. Исключением являются синхронные прерывания (ошибки во время выполнения инструкции, например, MemFault) и, так называемые, длинные инструкции (множественное или двойное чтение/запись, операции с плавающей точкой и др). Для атомарного доступа, вместо отключения прерываний, в процессорных ядрах ARM Cortex M есть специализированное решение — инструкции эксклюзивного доступа LDREX/STREX. Этот механизм защищает конкретный адрес памяти от несанкционированного доступа — доступ из прерывания. Если совместный ресурс представляет собой одну целочисленную переменную не шире разрядности системы, а доступ к ней атомарный (неделимая инструкция процессорного ядра), отключение прерываний на время доступа необязательно.
Планирование по времени не лишено недостатков суперцикла, однако, в некоторых случаях, позволяет получить оптимальную отзывчивость системы.
Кооперативное планирование. ОСРВ
Описанные выше механизмы кооперативного планирования можно сколько угодно усложнять: добавлять динамику вызовов задач в процессе работы, оптимизировать потребление процессорного времени по средствам сложных автоматов (FSM). В данном случае правильным решением будет применение ОСРВ.
Несмотря на то, что ОСРВ изначально задумывались под вытесняющее планирование, что в большинстве и применяется на практике, ОСРВ также может работать в режиме кооперативного планирования.
В данном случае разработчик сосредоточен на написании бизнес‑логики, а не на разработке какого‑то самопального планировщика и сложных автоматов задач.
Ключевой особенностью здесь можно выделить то, что кооперативный планировщик ОСРВ сохраняет контекст задачи — задачи имеют свой стек. Иными словами, ОСРВ под капотом сам разруливает вопросы корректного возобновления работы задачи — писать сложный автомат для, например, возможности неблокирующего ожидания не нужно. А еще, теперь задачи имеют приоритет.
Пример реализации задачи на чистом bare‑metal:
void sensors_task( void ) { switch( fsm ) { case E_SENSORS_TASK_FSM_RUN : // Состояние работы { // ... break; } case E_SENSORS_TASK_FSM_WAIT : // Состояние неблокирующего ожидания { // ... break; } case E_SENSORS_TASK_FSM_IDLE : // Состояние простоя default : { // ... break; } } }
Здесь мы видим, что в саму задачу интегрируется логика неблокирующего ожидания. Теперь посмотрим на пример этой же задачи под управлением ОСРВ:
void sensors_rtos_task( void* prm ) { // ... while( true ) { // ... // Полезная работа задачи vTaskDelay( N ); // Неблокирующее ожидания } }
Код задачи теперь содержит только бизнес‑логику, за исключением vTaskDelay( N ) — сообщение к планировщику ОСРВ о том, что задачу можно заблокировать (не выполнять) на время N. Также, планировщику ОСРВ можно сообщить что данную задачу и вовсе можно исключить из планирования на запуск и не тратить процессорное время на проверку ее готовности. Эту логику можно реализовать, например, в задаче простоя.
Данный подход не лишен недостатков, заплатить придется памятью. Интегрирование ОСРВ требует определенных накладных расходов, тот самый «капот» может завесить на пару десяток, а то и сотен килобайт.
В целом, можно сказать, что кооперативное планирование не подходит для задач, требующих детерминизм и реального времени.
Вытесняющее планирование. Прерывания
void exti0_isr( void ) // Вызывается прерыванием (IRQ) при обнаружении определенного фрона сигнала на ножке (0)МК { keypad_task( ); // Задача опроса нажатия кнопок } void tim1_period_elapsed_isr( void ) // Вызывается прерыванием (IRQ) при завершении периода счета аппатарного таймера (1) { sensors_task( ); // Задача опроса датчиков } void uart4_isr( void ) // Вызывается прерыванием (IRQ) при событиях UART (4) { connection_task( ); // Задача коммуникации с ПК } void tim2_period_elapsed_isr( void ) // Вызывается прерыванием (IRQ) при завершении периода счета аппатарного таймера (2) { display_task( ); // Задача вывода данных на дисплей } void main( void ) { while( true ) { } }
В данном примере все задачи выполняются по требованию (прерыванию), что увеличивает отзывчивость системы. Для используемых прерываний устанавливается соответствующий приоритет — приоритет задачи. Сложность данного подхода заключается в логике работы ВПО при срабатывании нескольких прерываний одновременно.
Некоторые разработчики скептически относятся к такому подходу — «код на прерываниях», однако данная реализация вытеснения без накладных расходов ОСРВ имеет самую лучшую отзывчивость. Даже в самых оптимистичных случаях, механизм переключения контекста в ОСРВ может занимать единицы или десятки микросекунд, что в рамках систем реального времени может быть недопустимо.
Порой, задачи реального времени могут потребовать процессорного времени меньше, чем переключение контекста в ОСРВ.
Упомянутый выше скептицизм в отношении такого подхода обоснован лишь в отношении процессорных ядер для МК, которые не умеют обрабатывать вложенные прерывания, например, Atmega8 или PIC16.
Прерывание должно быть быстрым — поднятие флага, не более…
На таких МК действительно критически важно быстро обработать прерывание, так как любое другое возникшее прерывание в момент обработки текущего будет проигнорировано.
Современные МК лишены этой проблемы за счет интеграции отдельного контроллера вложенных прерываний (NVIC/CLIC) и данный подход вполне заслуживает места под солнцем.
Вытесняющее планирование. ОСРВ
Безусловно, появление асинхронной природы в коде подтягивает за собой новые проблемы: потокобезопасность, состояние конки, сложность отладки, инверсия приоритетов (низкоприоритетная задача захватывает ресурс для высокоприоритетной задачи) и др.
И это нормально, код алгоритма работы какой‑нибудь сложной системы автоматического управления никогда не будет простым.
В данном случае не стоит изобретать велосипед. Если разработчик понимает, что ему необходима отзывчивая многозадачность с уклоном в реальное время, стоит подумать о выборе ОСРВ в ее классическом режиме работы.
Как и в случае с кооперативным планированием, ОСРВ предоставляет все необходимые механизмы для реализации вытесняющей многозадачности (мьютексты, семафоры, нотификация).
void exti0_isr( void ) // Вызывается прерыванием (IRQ) при обнаружении определенного фрона сигнала на ножке(0)МК { rtos_task_notify_from_isr__keypad_task( ); // Разблокирование задачи keypad_task( ) rtos_yield( ); // Запрос на немедленное переключение контекста } void tim1_period_elapsed_isr( void ) // Вызывается прерыванием (IRQ) при завершении периода счета аппатарного таймера (1) { rtos_task_notify_from_isr__sensors_task( ); // Разблокирование задачи sensors_task( ) rtos_yield( ); // Запрос на немедленное переключение контекста } void uart4_isr( void ) // Вызывается прерыванием (IRQ) при событиях UART (4) { rtos_task_notify_from_isr__connection_task( ); // Разблокирование задачи connection_task( ) rtos_yield( ); // Запрос на немедленное переключение контекста } void tim2_period_elapsed_isr( void ) // Вызывается прерыванием (IRQ) при завершении периода счета аппатарного таймера (2) { rtos_task_notify_from_isr__display_task( ); // Разблокирование задачи display_task( ) rtos_yield( ); // Запрос на немедленное переключение контекста } void main( void ) { keypad_task_init( ); // Инициализация задачи опроса нажатия кнопок sensors_task_init( ); // Инициализация задачи опроса датчиков connection_task_init( ); // Инициализация задачи коммуникации с ПК display_task_init( ); // Инициализация задачи вывода данных на дисплей rtos_scheduler_start( ); // Запуск планировщика while( true ) { } // never }
Здесь стоит обратить внимание на особенности переключения контекста. Как говорилось выше, некоторые задачи могут требовать очень малого кол‑ва процессорного времени, однако скорость реакции, вызова задачи реального времени, полностью зависит от скорости работы механизма переключения контекста в ОСРВ.
Когда речь заходит про реальное время, одного лишь детерминизма не хватает, нужна скорость реакции, скорость обработки данных. Здесь можно сформулировать определенное правило: если ОСРВ способна переключить контекст, за время не более 5% от времени выполнения интересующей критической задачи, то вызов такой задачи можно поручить ОСРВ. Иными словами, нужно оценивать целесообразность применения ОСРВ.
Вытесняющее планирование. Гибридный режим
Когда применение ОСРВ целесообразно, но есть задача(и), которые требует быстрой и детерминированной реакции — задачи реального времени, возникает вопрос: а можно ли соединить код на прерываниях и ОСРВ? Да, со своими особенностями, но можно, а в некоторых случая просто необходимо.
В таком подходе часть критического кода, задачи реального времени, выносится из‑под управления ОСРВ. Здесь нет ничего запредельно сложного, главная задача заключается в том, чтобы код задач реального времени был изолирован от задач и самого планировщика ОСРВ.
А самым главным моментом является то, что приоритет прерываний, которые вызывают функции задач реального времени, за пределами ОСРВ должен быть больше наивысшего приоритета прерываний, используемых в пределах ОСРВ. Это обусловлено тем, что внутренняя кухня, включая планировщик, ОСРВ активно взаимодействует с масками прерываний, разрешая или запрещая определенные приоритеты прерываний в процессе работы — ОСРВ не должен нарушать работу прерываний за своими пределами. Этого правила вполне достаточно чтобы подружить задачи реального времени и ОСРВ.
void exti0_isr( void ) // Вызывается прерыванием (IRQ) при обнаружении определенного фрона сигнала на ножке(0)МК { rtos_task_notify_from_isr__keypad_task( ); // Разблокирование задачи keypad_task( ) rtos_yield( ); // Запрос на немедленное переключение контекста } void tim1_period_elapsed_isr( void ) // Вызывается прерыванием (IRQ) при завершении периода счета аппатарного таймера (1) { rtos_task_notify_from_isr__sensors_task( ); // Разблокирование задачи sensors_task( ) rtos_yield( ); // Запрос на немедленное переключение контекста } void uart4_isr( void ) // Вызывается прерыванием (IRQ) при событиях UART (4) { connection_task( ); // Задача реального времени коммуникации с ПК } void tim2_period_elapsed_isr( void ) // Вызывается прерыванием (IRQ) при завершении периода счета аппатарного таймера (2) { rtos_task_notify_from_isr__display_task( ); // Разблокирование задачи display_task( ) rtos_yield( ); // Запрос на немедленное переключение контекста } void main( void ) { keypad_task_init( ); // Инициализация задачи опроса нажатия кнопок sensors_task_init( ); // Инициализация задачи опроса датчиков display_task_init( ); // Инициализация задачи вывода данных на дисплей rtos_scheduler_start( ); // Запуск планировщика while( true ) { } // never }
В данном примере задача коммуникации с ПК connection_task( ) имеет самый высокий приоритет в системе и способна прерывать задачи ОСРВ, что позволяет реализовать реальное время в отношении коммуникации.
Дабы не отпугивать читателей большим кол‑вом текста, я разобью свои мысли на несколько частей. В следующей части я расскажу про блокирующий и неблокирующий подходы к написанию задач, а также о связанных с этим аппаратных особенностях МК.
Спасибо за внимание, скоро услышимся!
Комментарии (2)

SIISII
01.10.2026 17:59Когда на однопоточных (одноядерных) системах начинают обзывать задачи потоками и говорить про параллельную работу, при чем опытные разработчики, немного корежит…
"при чём" в таком контексте пишется слитно, но это придирки. А вот термин "многозадачность" (multitasking) появился очень давно, и подразумевал он именно что выполнение программ, переключаемых планировщиком (диспетчером) системы -- то есть, по сути, применялся именно к потокам и/или к "процессопотокам", когда в некоторой ОС не было отдельных концепций, аналогичных процессу и потоку винды.
Скажем, в большинстве вариантов OS/360 (а это вторая половина 1960-х), кроме самых примитивных, были и процессы, и потоки, только то, что в винде включается в термин "процесс", размазывалось между разделом памяти и задачей пункта задания, а "потоками" были эта самая задача пункта задания (головной поток) и порождаемые ей подзадачи.
В RSX-11M -- "бабке" Винды (середина 1970-х) -- были лишь "процессопотоки", называемые, внезапно, задачами, и даже выполняемые файлы ("экзешники") имели расширение .TSK. И да, они выполнялись параллельно в режиме вытесняющей многозадачности.В общем, простите, задачи -- исторически это и есть параллельная работа.
Для атомарного доступа, вместо отключения прерываний, в процессорных ядрах ARM Cortex M есть специализированное решение – инструкции эксклюзивного доступа LDREX/STREX. Этот механизм защищает конкретный адрес памяти от несанкционированного доступа – доступ из прерывания.
С этим тоже есть проблемы, причём, по меньшей мере, две:
есть ядра Cortex-M0 (архитектура ARMv6-M), у которых этих команд нет вообще;
есть двухъядерные микроконтроллеры STM32H745 и иже с ним, где у обоих ядер (Cortex-M7 и Cortex-M4) эти команды есть, но вот глобальный монитор монопольного доступа разработчик МК почему-то не реализовал, из-за чего эта парочка команд может использоваться весьма ограниченно -- только если она должна обеспечить атомарность при доступе строго для одного ядра; если к чему-то нужно атомарно обращаться кодом, выполняющимся на разных ядрах, -- всё, приходится делать что-то другое, например, использовать аппаратные семафоры (блок HSEM), неализованные на этом семействе МК.
На таких МК действительно критически важно быстро обработать прерывание, т.к. любое другое возникшее прерывание в момент обработки текущего будет проигнорировано.
Не будет оно проигнорировано -- оно будет сохранено в ожидании обработки, и его обработчик будет вызван, как только станет возможным. Точно то же самое имеет место и на ARMах с NVICом: если низкоприоритетное прерывание запрашивается в момент, когда выполняется обработчик высокоприоритетного прерывания, низкоприоритетное запоминается и ждёт.
lamerok
Про вытеснение. Вы описали задачи со своим стеком, довольно долгим переключение и постоянным, возможно с холостым запуском, планировщиком.
. Ещё есть RunToComplition РТОС, з с вытеснение. Там задачи без собственного стека,, просто функции ззапускающиеся на общем стеке и только по источнику события.
Т. е пришло событие, планировщик пнули, он запустил задачу, если надо вытеснил низкоприоритетну задачу - все. Если его пинают, значит обязательно есть задача для запуска.
Прямо затраты по ресрусам копеечные + быстрое переключение.