Встраиваемые системы и робототехника RTOS: задачи, планировщик, синхронизация, FreeRTOS на практике
0%

RTOS: задачи, планировщик, синхронизация, FreeRTOS на практике

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 мс. Любая такая операция в суперцикле съедает дедлайн всех остальных.

Средний вариант недооценивают. Длинная операция превращается в конечный автомат, делающий на каждой итерации суперцикла один шаг: 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

Тик, кванты и 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: раскладка стека задачи, TCB и работа PendSV

Механика на Cortex-M сделана изящно, и из неё прямо следуют правила отладки.

  1. Задачи выполняются в Thread mode со своим указателем стека PSP, прерывания и ядро — в Handler mode с общим MSP. Поэтому ISR не расходуют стек задачи, кроме аппаратного кадра, укладываемого до входа.
  2. При входе в исключение ядро само сохраняет 8 регистров (R0–R3, R12, LR, PC, xPSR) — соглашение AAPCS, благодаря которому обработчик пишется на обычном C без ассемблерных прологов.
  3. Переключение выполняется в обработчике PendSV с самым низким приоритетом прерываний: если переключить задачу понадобилось во время работы высокоприоритетного ISR, это случится только после того, как отработают все прерывания, — смена контекста никогда не задерживает обслуживание железа.
  4. 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).

Лечение — наследование приоритетов: когда высокоприоритетная задача блокируется на мьютексе, ядро временно поднимает приоритет владельца до уровня ожидающего. В сценарии выше 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 в робототехнике: где проходит граница

Робот почти всегда двухуровневый, и граница проходит ровно по требованию к детерминизму.

На микроконтроллере под 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))

Кинематика, одометрия и регуляторы разбираются в статье про робототехнику. Архитектурная сторона вопроса — как раскладывать функции робота по узлам и почему это по сути распределённая система — исследована в научных работах портала: Определение роботизированных систем и их классификация, Теоретические обоснования микросервисной архитектуры для роботизированных систем и Реализация сервисов. Они хорошо продолжают тему: то, что внутри контроллера решается приоритетами задач, на уровне робота целиком решается разделением на узлы и контрактами между ними.

Типичные ошибки

  1. Взять RTOS там, где хватает суперцикла. Вы добавили гонки, дедлоки и 8 КБ расхода ОЗУ ради задачи, решавшейся конечным автоматом.
  2. Раздать приоритеты по важности, а не по срочности. Логирование во flash с высоким приоритетом гарантированно сорвёт контур управления.
  3. Защищать ресурс двоичным семафором вместо мьютекса. Наследования приоритетов не будет, инверсия приедет — вопрос лишь в том, на столе или у заказчика.
  4. Забыть portYIELD_FROM_ISR или вызвать функцию без FromISR из прерывания, или задать неверный configPRIO_BITS: плавающий отклик и редкие зависания без внятного стек-трейса.
  5. Ждать с portMAX_DELAY там, где событие не гарантировано. Отказ датчика превращается в тихо спящую навсегда задачу вместо диагностируемой ошибки.
  6. Подбирать размер стека «на глаз». Особенно опасен путь обработки ошибок: тестируется реже всего, а по вложенности вызовов часто самый глубокий.
  7. Отлаживать тайминги через printf. Наблюдатель меняет систему сильнее изучаемого эффекта. RTT, ITM или GPIO — всегда.
  8. Считать, что RTOS защищает память. Указатель за границей массива спокойно испортит стек соседней задачи; защиту даёт только MPU, и настраивать его надо руками. И следите за высокой отметкой стека в проде: стека, которого хватало год, перестанет хватать после одной добавленной библиотеки, а узнаете вы об этом по HardFault в поле.

Мини-итог

  • RTOS покупается за право писать линейный блокирующий код и соблюдать несколько дедлайнов сразу; платите ОЗУ и новым классом ошибок конкурентности.
  • «Реального времени» означает доказуемую верхнюю границу, а не малое среднее: выбор задачи за O(1), константное переключение контекста, ограниченные критические секции.
  • Задача — это стек плюс TCB: считайте байты заранее, размещайте всё статически, следите за высокой отметкой стека. Приоритеты назначаются по срочности (Rate Monotonic), проверяются анализом времени отклика, а C_i берётся из измерений на железе.
  • Данные передаются очередями и уведомлениями; мьютекс — только для взаимного исключения, и только он даёт наследование приоритетов. Отлаживается всё это через SWD с осведомлённостью об ОС, RTT/ITM, трассировщики задач и пару GPIO на логическом анализаторе.
  • В робототехнике RTOS держит нижний контур на микроконтроллере, а Linux с ROS 2 — верхний; граница проходит там, где заканчивается требование к детерминизму.

Источники

Что дальше

Питание и ограничения: режимы сна, бюджет энергии, память и флеш — почему tickless idle из этой статьи превращается в годы работы от батарейки, как считать бюджет энергии до написания кода, чем различаются режимы сна на STM32 и ESP32 и как ужимать прошивку, когда до конца flash осталось два килобайта.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов