RTOS: задачи, планировщик, синхронизация, FreeRTOS на практике
Программисту «больших» машин слово «операционная система» обещает многое: изоляцию процессов, виртуальную память, файлы, драйверы, планировщик, который сам разберётся. Ядро реального времени не даёт почти ничего из этого списка. FreeRTOS — это 6–10 КБ кода, умеющего ровно три вещи: переключать контекст между стеками, вести списки готовых и заблокированных задач, отсчитывать время. Ни памяти, ни защиты, ни драйверов. И тем не менее это самая полезная библиотека в арсенале встраиваемого разработчика, потому что она решает проблему, которую суперцикл решить не может: как уложиться в несколько разных дедлайнов одновременно. Дальше — про то, как ядро устроено внутри, что оно вам должно, а что нет, и какие ошибки оно превращает из редких в систематические; механика ISR, приоритетов прерываний и гонок берётся как данность из статьи про прерывания и реальное время.
Откуда берётся потребность: жизнь и смерть суперцикла
Начнём с того, что RTOS не нужна. Подавляющее большинство прошивок начинается с суперцикла, и это правильно.
int main(void)
{
hal_init();
for (;;) {
read_sensor(); // 0,4 мс
update_control(); // 0,2 мс
drive_display(); // 1,2 мс
handle_uart(); // 0,1 мс
} // весь цикл 1,9 мс — быстрее любого дедлайна в системе
}
Пока сумма времён меньше самого жёсткого периода, всё честно: цикл проходит за 1,9 мс, регулятор считается достаточно часто, ядро не нужно. Суперцикл детерминирован, отлаживается пошагово, не тратит ни байта ОЗУ на стеки задач и не имеет ни одной гонки, кроме заведённых через прерывания. Ломается он в одном конкретном месте: когда появляется операция дольше, чем позволяет самый короткий дедлайн системы. Запись страницы во flash — 5 мс. Ответ радиомодуля — 200 мс. Пересчёт FFT — 8 мс. Любая такая операция в суперцикле съедает дедлайн всех остальных.
жёсткого дедлайна?"} Q1 -->|да| SL["Суперцикл и прерывания.
Это правильный ответ,
не усложняйте"] Q1 -->|нет| Q2{"Долгих операций одна-две,
и они делятся на шаги?"} Q2 -->|да| FSM["Кооперативные автоматы:
каждая долгая операция —
машина состояний в цикле"] Q2 -->|нет| Q3{"Нужны файлы, TCP/IP,
GUI, сторонние приложения?"} Q3 -->|нет| RT["RTOS с вытеснением:
FreeRTOS, Zephyr, ThreadX"] Q3 -->|да| Q4{"Есть дедлайн порядка
сотен микросекунд?"} Q4 -->|нет| LIN["Linux на MPU: Pi, i.MX"] Q4 -->|да| MIX["Гибрид: Linux и отдельный MCU"] FSM -.->|"состояний больше пяти"| RT
Средний вариант недооценивают. Длинная операция превращается в конечный автомат, делающий на каждой итерации суперцикла один шаг: flash_start_erase() вместо ожидания, состояние FL_WAIT_ERASE, проверка flash_busy() с таймаутом, переход дальше. Это работает и стоит ноль байт ОЗУ. Но у подхода есть предел: логика выворачивается наизнанку — всё, что в линейном коде было бы write(); wait(); read();, превращается в набор состояний и явно сохранённых локальных переменных. Когда таких автоматов пять и они ждут друг друга, читаемость исчезает. Главная мысль: RTOS покупается не за скорость, а за право писать блокирующий код, соблюдая несколько дедлайнов сразу. Платите вы оперативной памятью (стек на каждую задачу) и целым классом новых ошибок — гонок, дедлоков и инверсий приоритетов.
Что означает «реального времени» и чего RTOS не делает
Самое частое заблуждение: real-time — это про быстроту. Нет. Real-time — это про верхнюю границу, которая доказуема. Ядро с переключением контекста за 3 мкс всегда лучше для реального времени, чем ядро с типичным временем 0,5 мкс и редкими выбросами до 400 мкс. Linux с планировщиком CFS оптимизирует пропускную способность и справедливость (см. процессы и планирование); RTOS оптимизирует худший случай и отказывается от справедливости полностью — низкоприоритетная задача может не выполняться никогда, и это штатное поведение, а не баг.
| RTOS делает | RTOS не делает |
|---|---|
| Переключает контекст между стеками за ограниченное число тактов | Не выделяет память задачам и не защищает её |
| Выбирает самую приоритетную готовую задачу за O(1) | Не изолирует задачи: любая испортит чужой стек указателем |
| Будит задачи по таймауту, отсчитывает системное время | Не даёт файловой системы, сети, драйверов — это отдельные библиотеки |
| Даёт очереди, семафоры, мьютексы с наследованием приоритетов, даёт ядру спать в Idle | Не спасает от дедлока, инверсии и переполнения стека, не делает код быстрее |
Отдельно: RTOS не является ядром ОС в смысле привилегий. На Cortex-M без MPU весь код — и ядро, и задачи — выполняется с полным доступом ко всей памяти и периферии, разделения на kernel space и user space нет. Отличие от системных вызовов принципиальное: xQueueSend — обычный вызов функции, а не переход в привилегированный режим.
Задача: стек плюс структура плюс функция, которая не возвращается
Задача (task, thread) — это тройка: функция вида void task(void *arg) с бесконечным циклом; собственный стек — блок ОЗУ под её локальные переменные и сохранённый контекст; TCB (Task Control Block) — структура ядра с указателем на вершину стека, приоритетом, узлами списков и полем уведомлений, около 60–100 байт. Функция задачи не должна возвращаться: дойдя до закрывающей скобки, она уйдёт по мусорному адресу и даст HardFault в месте, никак не связанном с причиной. Правильный выход — vTaskDelete(NULL).
#define IMU_STACK_WORDS 256u // 256 СЛОВ по 4 байта = 1024 байта стека
static StaticTask_t imu_tcb; // TCB и стек в .bss, а не в куче
static StackType_t imu_stack[IMU_STACK_WORDS];
static void imu_task(void *arg)
{
(void)arg;
TickType_t last_wake = xTaskGetTickCount();
for (;;) {
imu_sample_t s;
imu_read_burst(&s); // обмен по I2C, ~300 мкс
xQueueSend(q_samples, &s, 0); // не ждём: очередь полна — теряем кадр осознанно
// Отсчёт от прошлого пробуждения, а не от «сейчас»:
// именно это не даёт джиттеру накапливаться от цикла к циклу.
xTaskDelayUntil(&last_wake, pdMS_TO_TICKS(10));
}
}
// Создаём до старта планировщика: приоритет 3, стек и TCB — свои, из .bss
TaskHandle_t h = xTaskCreateStatic(imu_task, "imu", IMU_STACK_WORDS, NULL, 3, imu_stack, &imu_tcb);
configASSERT(h != NULL);
Три детали, за которые платят кровью. Размер стека задаётся в словах, а не байтах (во FreeRTOS; в Zephyr и CMSIS-RTOS2 — в байтах, и это регулярно даёт четырёхкратную ошибку). xTaskDelayUntil против vTaskDelay: последний означает «поспать 10 тиков начиная отсюда», поэтому период равен 10 мс плюс тело цикла плюс всё, на что вас вытеснили, — ошибка накапливается; первый держит период стабильным, что для любого регулятора обязательно, так как формулы ПИД предполагают равномерный шаг. Статическое размещение против динамического: xTaskCreate берёт стек и TCB из кучи ядра, возвращая всё то, за что мы выгнали malloc (см. C для встраиваемых систем). Продакшн-правило: все задачи, очереди и семафоры создаются статически до старта планировщика и никогда не удаляются — тогда полный расход ОЗУ виден в отчёте компоновщика ещё до прошивки, чего фактически и требуют MISRA и IEC 61508.
Считать байты надо заранее, потому что сумма стеков — обычно крупнейшая статья расхода ОЗУ в прошивке с RTOS. Бюджет такой: TCB 60–100 Б; стек задачи от 256 Б до 4 КБ (минимум 64 слова уходят только на контекст и вызовы ядра); кадр контекста 64 Б, а с FPU 136 Б; очередь 40 Б плюс глубина на размер элемента, потому что данные копируются целиком; мьютекс или семафор около 80 Б — это та же структура очереди без данных; само ядро 200–400 Б в .bss под списки готовых задач. Практическое следствие: на STM32F030 с 4 КБ ОЗУ ядро почти бессмысленно — четыре задачи съедят всё. На STM32F411 со 128 КБ или ESP32 с 320 КБ можно позволить десяток задач и не думать. Это одна из осей выбора платы — подробнее в статье про железо.
Жизненный цикл задачи
Различие, которое надо усвоить намертво: Blocked не потребляет процессор, а ожидание в Running потребляет. Задача в Blocked вообще не рассматривается планировщиком — её нет в списке готовых. Задача, «ждущая» в цикле опроса флага, находится в Running и сжигает такты, которых не хватит кому-то другому.
while (!data_ready) { } // ПЛОХО: жжёт процессор, блокирует всех, кто ниже
while (!data_ready) { vTaskDelay(1); } // ТОЖЕ ПЛОХО: 1000 бесполезных пробуждений в секунду
// ХОРОШО: задача в Blocked, ядро может уснуть, пробуждение ровно по событию.
// Таймаут конечный: portMAX_DELAY — это обещание, что событие точно придёт.
if (xSemaphoreTake(sem_data, pdMS_TO_TICKS(50)) != pdTRUE) { handle_timeout(); }
Правило, которое стоит принять как аксиому: в правильно написанной прошивке почти всё время все задачи находятся в Blocked, а процессор — в Idle. Idle-задача выполняет WFI, ядро спит, потребление падает на два порядка. Профиль загрузки, показывающий 100 % занятости, означает опрос вместо блокировки. Как это превращается в часы работы от батарейки — в следующей статье трека.
Планировщик: фиксированные приоритеты и вытеснение
Алгоритм ядра FreeRTOS формулируется одной фразой: всегда выполняется готовая задача с наивысшим приоритетом; среди равных — по кругу. Это fixed-priority preemptive scheduling, отраслевой стандарт для встраиваемых систем.
функция выбрать_следующую():
# ready_bitmap — маска: бит p установлен, если в списке приоритета p кто-то есть
p ← 31 - количество_ведущих_нулей(ready_bitmap) # одна инструкция CLZ на Cortex-M3+
список ← ready_lists[p]
список.курсор ← список.курсор.следующий # круговой обход равных приоритетов
вернуть список.курсор.владелец
Сложность: O(1) по времени независимо от числа задач и приоритетов — при configUSE_PORT_OPTIMISED_TASK_SELECTION = 1 это ровно __CLZ(ready_bitmap) плюс два разыменования в vTaskSwitchContext. Память: O(P + N), где P — число уровней приоритета (каждый стоит пустого списка, ~20 байт), N — число задач. Пригодным для реального времени планировщик делает именно постоянство времени, а не его малость: в анализе худшего случая переключение контекста — константа, которую можно прибавить. Без оптимизации порта (Cortex-M0 не имеет CLZ) используется цикл по приоритетам — O(P), отсюда совет не заводить 32 уровня там, где хватает пяти.
Тик, кванты и tickless idle
Системный тик — периодическое прерывание (обычно SysTick, 1 кГц по умолчанию). На каждом тике ядро увеличивает счётчик, проверяет, кого пора разбудить, и при включённом configUSE_TIME_SLICING передаёт квант следующей задаче того же приоритета. Отсюда следствия, удивляющие пришедших с сервера. Разрешение таймаутов равно периоду тика. vTaskDelay(pdMS_TO_TICKS(1)) при тике 1 кГц означает «от 0 до 1 мс», потому что неизвестно, в какой момент внутри тика вы попросили. Для задержек короче тика берите аппаратный таймер или busy-wait на счётчике циклов DWT.
Тик — это накладные расходы. 1000 прерываний в секунду по 2–5 мкс — 0,2–0,5 % процессора и, что важнее для батарейных устройств, тысяча пробуждений из сна в секунду. Отсюда configUSE_TICKLESS_IDLE: когда все задачи заблокированы, ядро вычисляет момент ближайшего пробуждения, программирует на него таймер, уходит в глубокий сон и по возвращении доначисляет пропущенные тики. Time slicing бывает вреден: две задачи равного приоритета переключаются каждый тик, даже если каждая работает по 50 мкс, — 950 бесполезных переключений в секунду. В продакшн-конфигурациях его часто выключают и делают все приоритеты уникальными, чтобы поведение системы стало полностью детерминированным.
Как назначать приоритеты: по срочности, а не по важности
Самая частая ошибка проектирования — раздать приоритеты по субъективной важности: «логирование нужно для расследования аварий, дадим ему высокий приоритет» — и запись во flash на 5 мс срывает регулятор. Корректный подход называется Rate Monotonic: чем короче период задачи, тем выше приоритет. Для периодических задач с дедлайном, равным периоду, это доказанно оптимальное назначение среди всех статических (Liu & Layland, 1973). Достаточное условие: суммарная загрузка U = Σ(C_i / T_i) не превышает n·(2^(1/n) − 1) — 0,828 для двух задач, 0,779 для трёх, 0,693 в пределе. Условие пессимистично; точный ответ даёт анализ времени отклика: худшее время отклика задачи i есть неподвижная точка уравнения R = C_i + Σ_{j выше по приоритету} ceil(R / T_j) · C_j.
import math
def response_time_analysis(tasks, max_iter=200):
"""Худшее время отклика при вытеснении с фиксированными приоритетами.
tasks: список (имя, C, T, D) — время выполнения, период, дедлайн в мс,
упорядоченный по УБЫВАНИЮ приоритета.
Сложность: O(n^2 * K) по времени (K — итераций до неподвижной точки,
на практике меньше 20) и O(n) по памяти.
"""
out = []
for i, (name, c_i, _t, d_i) in enumerate(tasks):
r = c_i # начальное приближение
for _ in range(max_iter):
# помехи от всех более приоритетных задач за время r
interference = sum(math.ceil(r / t_j) * c_j for (_, c_j, t_j, _) in tasks[:i])
if c_i + interference == r:
break # неподвижная точка найдена
r = c_i + interference
if r > d_i:
break # дедлайн уже сорван, дальше считать незачем
out.append((name, r, d_i, r <= d_i))
return out
# Контур мотора, чтение IMU, телеметрия: (имя, C, T, D) в миллисекундах
system = [("motor_pid", 0.3, 1.0, 1.0), ("imu_read", 0.45, 5.0, 5.0), ("telemetry", 3.0, 50.0, 50.0)]
print(f"Загрузка: {sum(c / t for _, c, t, _ in system):.1%}") # Загрузка: 81.0%
print(response_time_analysis(system))
# [('motor_pid', 0.3, 1.0, True), ('imu_read', 0.75, 5.0, True), ('telemetry', 19.05, 50.0, True)]
Загрузка 81 % превышает границу Liu & Layland для трёх задач, но система укладывается: достаточное условие не является необходимым — потому RTA и считают всерьёз. Главная поправка к теории: C_i — это худшее время выполнения (WCET), а не среднее, и мерить его надо на реальном железе, при худшем варианте ветвлений, включая время вклинившихся прерываний. Эвристика для продакшна: держать загрузку ниже 70 %, оставляя запас на всё неучтённое.
Переключение контекста: что физически происходит
Механика на Cortex-M сделана изящно, и из неё прямо следуют правила отладки.
- Задачи выполняются в Thread mode со своим указателем стека PSP, прерывания и ядро — в Handler mode с общим MSP. Поэтому ISR не расходуют стек задачи, кроме аппаратного кадра, укладываемого до входа.
- При входе в исключение ядро само сохраняет 8 регистров (R0–R3, R12, LR, PC, xPSR) — соглашение AAPCS, благодаря которому обработчик пишется на обычном C без ассемблерных прологов.
- Переключение выполняется в обработчике PendSV с самым низким приоритетом прерываний: если переключить задачу понадобилось во время работы высокоприоритетного ISR, это случится только после того, как отработают все прерывания, — смена контекста никогда не задерживает обслуживание железа.
- PendSV сохраняет оставшиеся R4–R11 на стек уходящей задачи, кладёт PSP в её TCB, вызывает
vTaskSwitchContext, забирает PSP новой задачи из её TCB, восстанавливает R4–R11 и делаетbx lrсо специальным EXC_RETURN — дальше ядро само распаковывает аппаратный кадр.
Выводы. Цена переключения — 100–200 тактов (1,5–3 мкс на 72 МГц); задача, переключающаяся 10 000 раз в секунду, тратит 2–3 % процессора только на это. FPU удорожает контекст: S16–S31 — ещё 72 байта, и хотя lazy stacking откладывает копирование, место на стеке резервируется всегда, поэтому при использовании float закладывайте всем задачам +128 байт. Стек-трейс по умолчанию показывает только текущую задачу — чтобы видеть все, нужен плагин осведомлённости об ОС: в OpenOCD это $_TARGETNAME configure -rtos FreeRTOS, в SEGGER Ozone и STM32CubeIDE — галочка. Без него отладка многозадачной прошивки превращается в гадание.
Синхронизация: очередь как основной инструмент
В многозадачной прошивке появляется весь набор проблем конкурентности (общий разбор — в Конкурентность и параллелизм), но выбор примитивов иной, чем на сервере. Главный совет: обменивайтесь данными через очередь, а не через разделяемую переменную под мьютексом. Очередь во FreeRTOS копирует значение целиком, поэтому у получателя нет ссылки на чужие данные, нет вопроса о времени жизни и нет надобности в блокировке. Тот же принцип, что «не общайтесь разделением памяти, разделяйте память общением» в Go и модель акторов в Elixir — только на 200 байтах ОЗУ.
typedef struct { uint32_t t_us; int16_t ax, ay, az; } imu_sample_t;
static uint8_t q_buf[8 * sizeof(imu_sample_t)]; static StaticQueue_t q_ctrl;
static QueueHandle_t q_samples; // = xQueueCreateStatic(8, sizeof(imu_sample_t), q_buf, &q_ctrl)
void DMA1_Stream0_IRQHandler(void) // производитель: прерывание завершения DMA
{
BaseType_t higher_woken = pdFALSE;
imu_sample_t s = { .t_us = dwt_us(), .ax = raw[0], .ay = raw[1], .az = raw[2] };
dma_clear_flags();
// Версия FromISR никогда не блокирует; вернёт pdFALSE, если очередь полна
(void)xQueueSendFromISR(q_samples, &s, &higher_woken);
// Разбуженная задача приоритетнее прерванной — переключиться сразу при выходе из ISR.
// Пропуск этой строки — классическая причина «задача просыпается с задержкой до 1 мс».
portYIELD_FROM_ISR(higher_woken);
}
static void ctrl_task(void *arg) // потребитель: задача обработки
{
(void)arg;
imu_sample_t s;
for (;;) {
if (xQueueReceive(q_samples, &s, pdMS_TO_TICKS(20)) == pdTRUE) {
pid_update(&s);
} else {
fault_set(FAULT_IMU_SILENT); // 20 мс без данных — это отказ датчика
}
}
}
Нюансы, каждый из которых стоил кому-то недели. Суффикс FromISR обязателен: обычный xQueueSend умеет блокировать, а блокировка внутри прерывания гарантированно разрушает систему; ни одна функция без FromISR не может вызываться из ISR — правило без исключений. portYIELD_FROM_ISR не украшение: без него разбуженная задача ждёт ближайшего тика, латентность вырастает с 3 мкс до случайной величины в пределах 1 мс, и задержка эта плавающая. Очередь конечна, и переполнение — штатное событие: решение «что делать при полной очереди» принимается при проектировании (для сенсоров обычно «терять новые данные»), а молчаливое игнорирование результата xQueueSendFromISR — источник самых неприятных багов, где данные пропадают под нагрузкой. portMAX_DELAY допустим только там, где событие гарантировано: иначе отказавший датчик просто тихо усыпит задачу навсегда вместо диагностируемой ошибки.
| Примитив | Задача | Стоимость | Когда брать |
|---|---|---|---|
| Очередь | передать данные | 40 Б + N × размер | по умолчанию для всего обмена |
| Уведомление задачи | сигнал «событие произошло» | 0 Б, поле в TCB | самый быстрый вариант, если получатель один |
| Семафор, двоичный или счётный | сигнал от ISR, учёт ресурсов | ~80 Б | получателей несколько, нужен таймаут, пул из N буферов |
| Мьютекс | защита разделяемого ресурса | ~80 Б | шина I2C, буфер, которые нельзя копировать |
| Группа событий | ждать комбинацию флагов | ~30 Б | «Wi-Fi поднялся И время синхронизировано» |
| Stream / message buffer | поток байтов или сообщений | настраиваемая | UART и аудио: один писатель, один читатель |
| Критическая секция | атомарность на 3–5 инструкций | 0 Б | доступ к общей переменной, без вызовов ядра внутри |
Уведомления задач стоит выделить: они в разы быстрее и на порядок дешевле по памяти, поскольку представляют собой 32-битное слово прямо в TCB.
void EXTI0_IRQHandler(void) // разбудить задачу без семафора вообще
{
BaseType_t woken = pdFALSE;
EXTI->PR = EXTI_PR_PR0;
vTaskNotifyGiveFromISR(motion_task_handle, &woken);
portYIELD_FROM_ISR(woken);
}
static void motion_task(void *arg)
{
(void)arg;
for (;;) {
// pdTRUE = сбросить счётчик; events — сколько раз сработало, пока мы не смотрели
uint32_t events = ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(1000));
if (events == 0u) { watchdog_report_stall(); continue; } // тишина секунду — отказ
process_motion(events);
}
}
Ограничение честное: получатель у уведомления ровно один, и данных оно не переносит (кроме 32 бит). Как только адресатов двое — возвращайтесь к семафору или очереди. Критическая секция (taskENTER_CRITICAL, на Cortex-M поднимающая BASEPRI) увеличивает латентность всей системы, поэтому мерится в тактах, а не в строчках: не больше десятка инструкций, никаких вызовов API ядра, никаких циклов с переменным числом итераций. Всё, что длиннее, — это мьютекс.
Инверсия приоритетов: главная ловушка
Июль 1997 года, марсоход Sojourner периодически перезагружался на поверхности Марса. Причина — классическая инверсия приоритетов в VxWorks; лечение выкатили патчем, включив наследование приоритетов на мьютексе информационной шины (разбор в RISKS Digest 19.49).
(высокий, дедлайн 10 мс) participant M as Задача M
(средний, счёт 40 мс) participant L as Задача L
(низкий) participant X as Мьютекс шины Note over L: t=0 — H и M заблокированы, работает L L->>X: xSemaphoreTake — успешно H-->>H: t=2 мс, пришло прерывание, H готова Note over H,L: H вытесняет L, но L остаётся владельцем мьютекса H->>X: xSemaphoreTake X--)H: занято, H уходит в Blocked Note over M: t=3 мс, M готова — и она приоритетнее L M-->>M: считает 40 мс, L не выполняется вовсе, H ждёт L L->>X: t=43 мс, M закончила, L отдала мьютекс X--)H: опоздание 41 мс, сторожевой таймер перезагружает систему
Лечение — наследование приоритетов: когда высокоприоритетная задача блокируется на мьютексе, ядро временно поднимает приоритет владельца до уровня ожидающего. В сценарии выше L мгновенно получила бы приоритет H, вытеснила M, за миллисекунду закончила работу с шиной и вернула мьютекс. Во FreeRTOS это встроено в xSemaphoreCreateMutex и работает автоматически. Отсюда правило, которое надо запомнить как таблицу умножения:
Для защиты ресурса используйте мьютекс, а не двоичный семафор. Внешне они делают одно и то же, но у семафора нет владельца, поэтому наследование приоритетов для него невозможно в принципе.
Двоичный семафор — для сигнализации (ISR разбудил задачу), мьютекс — для взаимного исключения; смешение этих ролей и есть источник инверсии. Остальные грабли никуда не делись: дедлок (два мьютекса в разном порядке — лечится глобальным порядком захвата и таймаутами вместо portMAX_DELAY), голодание низкоприоритетных задач (штатно для RTOS, но следите, чтобы голодающая задача не держала ничего нужного), неограниченная инверсия через вложенные мьютексы (лечится тем, что вложенных мьютексов не бывает — пересматривайте архитектуру).
FreeRTOS: конфигурация, которая имеет значение
Весь характер ядра задаётся файлом FreeRTOSConfig.h.
/* ---------- FreeRTOSConfig.h: планировщик ---------- */
#define configUSE_PREEMPTION 1 /* вытеснение; 0 = кооперативка */
#define configUSE_TIME_SLICING 0 /* выкл: переключаемся только по событию */
#define configMAX_PRIORITIES 6 /* больше уровней = больше ОЗУ впустую */
#define configTICK_RATE_HZ 1000 /* 1 мс; для батарейных устройств — 100 */
#define configUSE_PORT_OPTIMISED_TASK_SELECTION 1 /* CLZ, выбор задачи за O(1) */
#define configUSE_TICKLESS_IDLE 1 /* глубокий сон в Idle */
/* ---------- Память ---------- */
#define configSUPPORT_STATIC_ALLOCATION 1 /* всё создаём статически */
#define configSUPPORT_DYNAMIC_ALLOCATION 0 /* куча ядра выключена совсем */
/* ---------- Диагностика: на этапе разработки включать безусловно ---------- */
#define configCHECK_FOR_STACK_OVERFLOW 2 /* метод 2: шаблон 0xA5 по всему стеку */
#define configASSERT(x) if ((x) == 0) { taskDISABLE_INTERRUPTS(); __BKPT(0); for(;;); }
#define configUSE_TRACE_FACILITY 1 /* нужен SystemView и Tracealyzer */
/* ---------- Прерывания: источник половины «мистических» зависаний ---------- */
#define configPRIO_BITS 4 /* СТРОГО как в вашем чипе! STM32 = 4 */
#define configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 5
Последний блок требует объяснения, потому что ошибка в нём даёт зависания, не диагностируемые наугад. На Cortex-M меньшее число означает более высокий приоритет прерывания. configMAX_SYSCALL_INTERRUPT_PRIORITY проводит границу: прерывания с приоритетом численно меньше порога (то есть более срочные) ядру неподконтрольны — они не маскируются критическими секциями, но из них категорически нельзя вызывать никакие функции FreeRTOS, даже с суффиксом FromISR. Прерывания ниже порога API FromISR пользоваться могут. Вторая мина — число значащих бит приоритета: у STM32 их 4 (16 уровней), у некоторых Cortex-M0 — 2, и биты эти старшие, поэтому NVIC_SetPriority(IRQ, 5) и прямая запись 5 в регистр дают разные результаты, а неверный configPRIO_BITS тихо ломает всю схему маскирования. configASSERT проверяет корректность приоритета при каждом вызове FromISR — одна из причин никогда не выключать его в отладочной сборке.
// Вызывается ядром при обнаружении переполнения стека. К этому моменту память
// уже испорчена: задача хука — зафиксировать факт и виновника, а не «починить».
void vApplicationStackOverflowHook(TaskHandle_t task, char *name)
{
(void)task;
fault_log_write(FAULT_STACK_OVERFLOW, name); // в RAM, не очищаемую при сбросе
NVIC_SystemReset(); // перезагрузка честнее работы на испорченных данных
}
// Периодически в задаче здоровья: минимум свободного места за жизнь задачи, в СЛОВАХ
UBaseType_t words_free = uxTaskGetStackHighWaterMark(task_handles[i]);
configASSERT(words_free > 32u); // меньше 128 байт запаса — это уже отказ
Подбор размеров стеков делается ровно так: заложить с большим запасом, погонять прошивку по всем сценариям (включая обработку ошибок и самые глубокие пути), снять high water mark, урезать до измеренного минимума плюс 30–50 % и плюс запас на FPU. Никакой теории — только измерение на железе, потому что глубина стека зависит от уровня оптимизации компилятора.
Отладка RTOS без единого printf
В многозадачной прошивке ключевой навык встраиваемой разработки ощущается острее всего: printf меняет тайминги настолько, что искомый баг исчезает. Вывод строки на UART 115200 — около 900 мкс на десять символов; за это время в системе с тиком 1 кГц успевает произойти всё, что вы пытались поймать. Плюс printf нереентерабелен, требует килобайта стека и часто тащит malloc. SWD и осведомлённость об ОС. Двухпроводный отладочный интерфейс ARM даёт полный доступ к памяти и регистрам; с плагином RTOS awareness отладчик показывает все задачи, их состояния, приоритеты, стек-трейсы и заполненность стеков. В arm-none-eabi-gdb с OpenOCD: set target-rtos FreeRTOS, затем info threads. SEGGER RTT — отладочные строки через кольцевой буфер в ОЗУ, который отладчик вычитывает по SWD параллельно работе ядра: единицы микросекунд на запись вместо сотен, без остановки процессора. Тот случай, когда «принты» допустимы, потому что почти не влияют на тайминги (описание технологии). ITM/SWO — аппаратный трассировочный порт Cortex-M3 и выше: один пин, до нескольких мегабит, поток событий планировщика без участия процессора.
Трассировщики задач — SEGGER SystemView и Percepio Tracealyzer подключаются к хукам FreeRTOS и рисуют ту самую временную диаграмму, что была выше, но по реальным данным устройства: каждое переключение контекста, каждое ожидание на семафоре, каждый ISR; инверсия приоритетов, которую невозможно найти чтением кода, видна на такой диаграмме за десять секунд. GPIO плюс логический анализатор — приём, работающий всегда и на любом железе, включая самое дешёвое.
// Поднимаем «свой» пин в начале задачи, опускаем в конце: 2 такта на запись
// в регистр, влияние на тайминги практически нулевое.
#define TRACE_HI(pin) (GPIOB->BSRR = (1u << (pin)))
#define TRACE_LO(pin) (GPIOB->BSRR = (1u << ((pin) + 16u)))
for (;;) {
xQueueReceive(q_samples, &s, portMAX_DELAY);
TRACE_HI(3); pid_update(&s); TRACE_LO(3); // канал 3 = «выполняется ctrl_task»
}
Четырёхканальный анализатор за 10 USD плюс PulseView — и вы физически видите трассу планировщика: длительность каждой задачи, джиттер периода, моменты вытеснения. Осциллограф даёт то же с точностью до наносекунд и вдобавок показывает аналоговую сторону — просадку питания в момент, когда просыпаются все задачи разом. Полный разбор инструментария — в статье про отладку и производство. Счётчик тактов DWT измеряет WCET прямо в прошивке, без внешних приборов:
uint32_t t0 = DWT->CYCCNT; // один раз при старте: DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk
pid_update(&s);
uint32_t dt = DWT->CYCCNT - t0; // переполнение 32 бит корректно гасится вычитанием
if (dt > wcet_pid) wcet_pid = dt; // копим максимум за всё время работы
Максимум, накопленный за неделю прогона, — это и есть тот C_i, который подставляется в анализ времени отклика. Принципы измерений те же, что в статье Измерение производительности, меняются только приборы.
Железо: где какая RTOS и нужна ли она вообще
| Платформа | ОЗУ | Что запускают | Практика |
|---|---|---|---|
| Arduino Uno (ATmega328P) | 2 КБ | суперцикл; FreeRTOS формально портирован | RTOS съест половину ОЗУ на два стека — не стоит того |
| STM32F0/G0 (Cortex-M0+) | 4–32 КБ | FreeRTOS при 16 КБ и выше | нет CLZ → выбор задачи O(P); держите мало приоритетов |
| STM32F4/F7/H7 (M4/M7, FPU) | 128 КБ – 1 МБ | FreeRTOS, Zephyr, ThreadX | рабочая лошадка; помните про +72 Б контекста на FPU |
| ESP32 / ESP32-S3 | 320–512 КБ | FreeRTOS в ESP-IDF, двухъядерный SMP | стеки Wi-Fi и BLE сами живут в задачах, ядро 0 занято радио |
| RP2040 / RP2350 | 264–520 КБ | FreeRTOS SMP или два ядра вручную в pico-SDK | PIO снимает часть реального времени с процессора вовсе |
| Raspberry Pi (Cortex-A, MMU) | 512 МБ+ | Linux, при необходимости PREEMPT_RT | это не RTOS: латентность в лучшем случае десятки микросекунд |
Два случая стоит развернуть. ESP32 и SMP: здесь FreeRTOS работает в двухъядерном режиме, и модель меняется — две задачи выполняются физически одновременно, поэтому критическая секция, которая на одноядерном Cortex-M просто запрещала прерывания, обязана быть спинлоком. Wi-Fi и Bluetooth-стеки — тоже задачи, конкурирующие с вашими, и Wi-Fi по умолчанию закреплён за ядром 0.
// Контур управления закрепляем за ядром 1, чтобы Wi-Fi на ядре 0 ему не мешал
xTaskCreatePinnedToCore(control_task, "ctrl", 4096, NULL,
configMAX_PRIORITIES - 2, &ctrl_handle, 1 /* APP_CPU */);
static portMUX_TYPE state_mux = portMUX_INITIALIZER_UNLOCKED;
portENTER_CRITICAL(&state_mux); // спинлок плюс запрет прерываний на своём ядре
shared_state = v; // на одноядерном Cortex-M хватило бы taskENTER_CRITICAL
portEXIT_CRITICAL(&state_mux);
Raspberry Pi: Linux даже с PREEMPT_RT не даёт гарантий на уровне единиц микросекунд — латентность прерывания измеряется десятками микросекунд, а хвост распределения зависит от драйверов, SD-карты и теплового троттлинга. Поэтому в реальных роботах Pi отвечает за планирование, зрение и связь, а рядом стоит микроконтроллер, держащий контур управления с жёстким периодом. Это не костыль, а стандартная архитектура — ровно та, что описана в обзоре трека.
- FreeRTOS — де-факто стандарт: MIT-лицензия, порты подо всё, минимальный объём, сопровождение AWS, сертифицированная ветка SafeRTOS для IEC 61508 и ISO 26262.
- Zephyr — не просто ядро, а целая ОС: device tree, Kconfig, драйверы, сетевой стек, BLE, обновления через MCUboot; порог входа выше, отдача больше на сложных проектах (docs.zephyrproject.org). ThreadX — коммерческое ядро с сертификацией под авиацию и медицину, теперь открытое; очень предсказуемое. RIOT OS заточен под IoT и сети малой мощности. Embassy для Rust — другой путь: вместо задач со стеками используется
async/await, конкурентность разворачивается компилятором в конечные автоматы, и расход ОЗУ падает в разы. По сути автоматизированная версия того кооперативного подхода, с которого мы начали (The Embedded Rust Book).
RTOS в робототехнике: где проходит граница
Робот почти всегда двухуровневый, и граница проходит ровно по требованию к детерминизму.
латентность < 10 мкс"] --> P["Задача PID, приоритет 5
период 1 мс, джиттер < 50 мкс"] S["Опрос датчиков
приоритет 4, период 5 мс"] --> P P --> M["Драйверы моторов, ШИМ"] P --> B["Мост, приоритет 2
micro-ROS, UART или CAN"] SF["Безопасность, приоритет 6
концевики, ток, watchdog"] --> M end subgraph LINUX["Linux-машина — мягкое реальное время"] B <-->|"CAN, UART, Ethernet"| N["Узел ROS 2: драйвер базы"] N --> O["Одометрия и локализация"] O --> NAV["Планировщик маршрута"] NAV --> N V["Камера и лидар"] --> O end
На микроконтроллере под RTOS — всё, где просрочка означает физическое повреждение: контур тока и скорости мотора, обработка энкодеров, аварийный останов по концевикам, слежение за током и температурой. Периоды здесь — сотни микросекунд и единицы миллисекунд, а задача безопасности получает наивысший приоритет и не зависит ни от чего внешнего. На Linux — построение карты, локализация, планирование маршрута, зрение, интерфейс: опоздание на 100 мс означает менее плавную траекторию, а не сгоревшую обмотку. Связывает их либо простой бинарный протокол поверх UART/CAN, либо micro-ROS — реализация ROS 2 поверх FreeRTOS или Zephyr прямо на микроконтроллере (micro.ros.org), и тогда MCU становится полноценным узлом графа ROS 2 и публикует топики, а не отдаёт байты.
# Узел ROS 2 на Linux-стороне: прототипируется быстро, гарантий времени не даёт.
# Задержка планировщика ядра, сборка мусора Python, сокеты — ни один из этих
# источников джиттера не ограничен сверху, поэтому контур скорости мотора живёт
# НЕ здесь, а в задаче FreeRTOS с периодом 1 мс.
class BaseController(Node):
def __init__(self):
super().__init__("base_controller")
self.create_subscription(Twist, "cmd_vel", self.on_cmd, 10)
self.odom_pub = self.create_publisher(Odometry, "odom", 10)
self.create_timer(0.02, self.poll_mcu) # 50 Гц телеметрии с контроллера
def on_cmd(self, msg: Twist) -> None:
# Уставка в мм/с и мрад/с: MCU сам разложит её на колёса и удержит регулятором
self.link.send_setpoint(int(msg.linear.x * 1000), int(msg.angular.z * 1000))
def poll_mcu(self) -> None:
state = self.link.read_state() # тики энкодеров, накопленные на MCU
if state is not None:
self.odom_pub.publish(self.to_odometry(state))
Кинематика, одометрия и регуляторы разбираются в статье про робототехнику. Архитектурная сторона вопроса — как раскладывать функции робота по узлам и почему это по сути распределённая система — исследована в научных работах портала: Определение роботизированных систем и их классификация, Теоретические обоснования микросервисной архитектуры для роботизированных систем и Реализация сервисов. Они хорошо продолжают тему: то, что внутри контроллера решается приоритетами задач, на уровне робота целиком решается разделением на узлы и контрактами между ними.
Типичные ошибки
- Взять RTOS там, где хватает суперцикла. Вы добавили гонки, дедлоки и 8 КБ расхода ОЗУ ради задачи, решавшейся конечным автоматом.
- Раздать приоритеты по важности, а не по срочности. Логирование во flash с высоким приоритетом гарантированно сорвёт контур управления.
- Защищать ресурс двоичным семафором вместо мьютекса. Наследования приоритетов не будет, инверсия приедет — вопрос лишь в том, на столе или у заказчика.
- Забыть
portYIELD_FROM_ISRили вызвать функцию безFromISRиз прерывания, или задать неверныйconfigPRIO_BITS: плавающий отклик и редкие зависания без внятного стек-трейса. - Ждать с
portMAX_DELAYтам, где событие не гарантировано. Отказ датчика превращается в тихо спящую навсегда задачу вместо диагностируемой ошибки. - Подбирать размер стека «на глаз». Особенно опасен путь обработки ошибок: тестируется реже всего, а по вложенности вызовов часто самый глубокий.
- Отлаживать тайминги через
printf. Наблюдатель меняет систему сильнее изучаемого эффекта. RTT, ITM или GPIO — всегда. - Считать, что RTOS защищает память. Указатель за границей массива спокойно испортит стек соседней задачи; защиту даёт только MPU, и настраивать его надо руками. И следите за высокой отметкой стека в проде: стека, которого хватало год, перестанет хватать после одной добавленной библиотеки, а узнаете вы об этом по HardFault в поле.
Мини-итог
- RTOS покупается за право писать линейный блокирующий код и соблюдать несколько дедлайнов сразу; платите ОЗУ и новым классом ошибок конкурентности.
- «Реального времени» означает доказуемую верхнюю границу, а не малое среднее: выбор задачи за O(1), константное переключение контекста, ограниченные критические секции.
- Задача — это стек плюс TCB: считайте байты заранее, размещайте всё статически, следите за высокой отметкой стека. Приоритеты назначаются по срочности (Rate Monotonic), проверяются анализом времени отклика, а
C_iберётся из измерений на железе. - Данные передаются очередями и уведомлениями; мьютекс — только для взаимного исключения, и только он даёт наследование приоритетов. Отлаживается всё это через SWD с осведомлённостью об ОС, RTT/ITM, трассировщики задач и пару GPIO на логическом анализаторе.
- В робототехнике RTOS держит нижний контур на микроконтроллере, а Linux с ROS 2 — верхний; граница проходит там, где заканчивается требование к детерминизму.
Источники
- FreeRTOS. «Mastering the FreeRTOS Real Time Kernel» и справочник по API — https://www.freertos.org/Documentation/RTOS_book.html
- Liu C. L., Layland J. W. «Scheduling Algorithms for Multiprogramming in a Hard-Real-Time Environment», JACM, 1973 — https://dl.acm.org/doi/10.1145/321738.321743
- Joseph M., Pandya P. «Finding Response Times in a Real-Time System», Computer Journal, 1986 — https://academic.oup.com/comjnl/article/29/5/390/427193
- Burns A., Wellings A. «Real-Time Systems and Programming Languages» — стандартный учебник по анализу планируемости
- ARM. Cortex-M Generic User Guide: модель исключений, PendSV, режимы стека — https://developer.arm.com/documentation/dui0553/latest/ ; Zephyr, документация планировщика — https://docs.zephyrproject.org/latest/kernel/services/scheduling/index.html
- Инверсия приоритетов в Mars Pathfinder, RISKS Digest 19.49 — https://catless.ncl.ac.uk/Risks/19.49.html
- SEGGER SystemView — https://www.segger.com/products/development-tools/systemview/ , Percepio Tracealyzer — https://percepio.com/tracealyzer/ , FreeRTOS SMP на ESP32 — https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/freertos_idf.html
- micro-ROS — https://micro.ros.org/ , Embassy для встраиваемого Rust — https://embassy.dev/
Что дальше
Питание и ограничения: режимы сна, бюджет энергии, память и флеш — почему tickless idle из этой статьи превращается в годы работы от батарейки, как считать бюджет энергии до написания кода, чем различаются режимы сна на STM32 и ESP32 и как ужимать прошивку, когда до конца flash осталось два килобайта.