Неправильный старт с FreeRTOS

Самая распространённая ошибка при добавлении FreeRTOS в проект — воспроизвести структуру bare-metal кода с помощью задач:

 1// Задача 1 — читает сенсор
 2void sensorTask(void*) {
 3    while (true) {
 4        g_sensorValue = readAdc();    // пишет в глобальную переменную
 5        vTaskDelay(pdMS_TO_TICKS(10));
 6    }
 7}
 8
 9// Задача 2 — обрабатывает данные
10void processTask(void*) {
11    while (true) {
12        float v = g_sensorValue;      // читает глобальную — гонка данных
13        process(v);
14        vTaskDelay(pdMS_TO_TICKS(10));
15    }
16}

Здесь гонка данных на g_sensorValue. На Cortex-M это работает, потому что 32-битные выровненные чтение/запись атомарны на ARMv7-M — но это неопределённое поведение в C++, не работает для многобайтовых типов и не переносимо между семействами МК.

Правильная модель: задачи общаются через очереди, а не разделяемые переменные.


Очередь как канал связи

Очередь FreeRTOS — это потокобезопасный блокирующий FIFO. Одна задача отправляет, другая получает. Никаких разделяемых переменных, никаких мьютексов для самой передачи данных.

 1// Тип сообщения
 2struct SensorReading {
 3    uint32_t timestamp_ms;
 4    float    value;
 5    uint8_t  channel;
 6};
 7
 8// Создать очередь (вмещает до 8 измерений)
 9static QueueHandle_t sensorQueue = xQueueCreate(8, sizeof(SensorReading));
10
11// Задача-отправитель
12void sensorTask(void*) {
13    while (true) {
14        SensorReading reading {
15            .timestamp_ms = HAL_GetTick(),
16            .value        = readAdc(),
17            .channel      = 0
18        };
19        // Отправить — не блокирует, если в очереди есть место; сбрасывает при заполнении (timeout = 0)
20        xQueueSend(sensorQueue, &reading, 0);
21        vTaskDelay(pdMS_TO_TICKS(10));
22    }
23}
24
25// Задача-получатель
26void processTask(void*) {
27    SensorReading reading;
28    while (true) {
29        // Ждать бесконечно, пока не придёт измерение
30        if (xQueueReceive(sensorQueue, &reading, portMAX_DELAY) == pdTRUE) {
31            process(reading.value);
32        }
33    }
34}

Получатель блокируется на xQueueReceive — он не потребляет CPU пока ждёт. Планировщик переключается на другую задачу. Когда отправитель публикует сообщение, получатель разблокируется немедленно, если его приоритет выше; иначе — на следующем тике планировщика.


Отправка из ISR

К очередям можно обращаться из ISR через варианты FromISR. Они не блокируют и используют механизм пробуждения с повышенным приоритетом:

 1void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) {
 2    SensorReading reading {
 3        .timestamp_ms = HAL_GetTick(),
 4        .value        = HAL_ADC_GetValue(hadc) * (3.3f / 4096.0f),
 5        .channel      = 0
 6    };
 7
 8    BaseType_t higherPriorityTaskWoken = pdFALSE;
 9    xQueueSendFromISR(sensorQueue, &reading, &higherPriorityTaskWoken);
10
11    // Если разблокирована задача с более высоким приоритетом — запросить переключение контекста
12    portYIELD_FROM_ISR(higherPriorityTaskWoken);
13}

portYIELD_FROM_ISR гарантирует, что разблокированная задача запустится сразу после возврата из ISR, не ожидая следующего тика. Это минимизирует задержку для обработки в реальном времени.

Никогда не вызывайте не-FromISR функции FreeRTOS из ISR. xQueueSend из ISR повредит состояние ядра.


Структура задачи

Каждая задача следует одному паттерну: инициализация, затем бесконечный цикл обработки сообщений.

 1void processingTask(void* pvParameters) {
 2    // 1. Инициализация (выполняется один раз, в контексте задачи)
 3    Filter filter(8);
 4    Display* display = static_cast<Display*>(pvParameters);
 5
 6    SensorReading reading;
 7
 8    // 2. Бесконечный цикл
 9    for (;;) {
10        if (xQueueReceive(inputQueue, &reading, portMAX_DELAY) == pdTRUE) {
11            float filtered = filter.process(reading.value);
12            display->show(filtered);
13        }
14    }
15    // Сюда никогда не доходим — задачи в FreeRTOS не возвращаются
16    // vTaskDelete(nullptr);  // удалить себя, если задача может завершиться
17}

Передавайте зависимости через pvParameters — приводите к нужному типу внутри задачи. Для нескольких параметров оберните их в структуру:

1struct ProcessingTaskParams {
2    Display*    display;
3    IAlarmSink* alarm;
4    float       threshold;
5};
6
7// В main/init:
8static ProcessingTaskParams params { &tftDisplay, &buzzer, 25.0f };
9xTaskCreate(processingTask, "Process", 512, &params, 3, nullptr);

Размер стека

Размер стека FreeRTOS задаётся в словах (4 байта на Cortex-M), не в байтах.

1// 512 слов = 2048 байт
2xTaskCreate(sensorTask, "Sensor", 512, nullptr, 2, nullptr);

Стек задачи должен вмещать:

  • Все локальные переменные по всему дереву вызовов
  • Контекст задачи, сохраняемый планировщиком (обычно 17 регистров = 68 байт на Cortex-M)
  • Фреймы прерываний (задача может быть вытеснена в любой момент)

Типичная ошибка: недооценка стека, потому что printf/sprintf добавляет ~400–800 байт локальных переменных для форматирующих буферов.

Проверяйте использование стека в рантайме с помощью вотермаркинга FreeRTOS:

1// В диагностической задаче:
2UBaseType_t hwm = uxTaskGetStackHighWaterMark(sensorTaskHandle);
3// hwm = минимальное количество свободных слов за всё время; если 0 — стек переполнился

Запускайте это при стресс-тестировании. Устанавливайте размеры стеков так, чтобы hwm оставался выше ~50–100 слов.


Расстановка приоритетов

FreeRTOS использует приоритеты от 0 (простой) до configMAX_PRIORITIES - 1 (наивысший). Большее число = более высокий приоритет.

Монотонное планирование по частоте — проверенный подход для периодических задач: назначайте более высокий приоритет задачам с меньшим периодом.

Период 1 мс   → Приоритет 5  (например, обработка АЦП)
Период 10 мс  → Приоритет 4  (например, слияние данных сенсоров)
Период 50 мс  → Приоритет 3  (например, обновление дисплея)
Период 100 мс → Приоритет 2  (например, логирование UART)
Фоновая логика → Приоритет 1

Избегайте одинаковых приоритетов для нескольких задач, если им не нужна round-robin планировка — FreeRTOS будет нарезать их по времени, что может вызвать непредсказуемые задержки.

Инверсия приоритетов: если задача с высоким приоритетом ждёт мьютекс, удерживаемый задачей с низким приоритетом, а задача со средним приоритетом вытесняет задачу с низким, задача с высоким приоритетом фактически заблокирована средней. Используйте xSemaphoreCreateMutex (который реализует наследование приоритетов) вместо бинарных семафоров для разделяемых ресурсов.


Уведомления вместо семафора

Для простых паттернов «разбуди эту задачу», уведомления задач быстрее и занимают меньше RAM, чем бинарные семафоры:

 1static TaskHandle_t processingTaskHandle;
 2
 3// В ISR: разбудить задачу обработки
 4void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef*) {
 5    BaseType_t woken = pdFALSE;
 6    vTaskNotifyGiveFromISR(processingTaskHandle, &woken);
 7    portYIELD_FROM_ISR(woken);
 8}
 9
10// В задаче обработки:
11void processingTask(void*) {
12    for (;;) {
13        ulTaskNotifyTake(pdTRUE, portMAX_DELAY);  // блокировать до уведомления
14        processLatestAdcBuffer();
15    }
16}

Используйте уведомления, когда:

  • Один производитель сигнализирует одному потребителю
  • Данные не передаются (данные — в разделяемом буфере под управлением вызывающего)
  • Требуются минимальные накладные расходы

Используйте очереди, когда:

  • Данные нужно передать вместе с сигналом
  • Одновременно может ожидать несколько элементов
  • Есть несколько производителей или потребителей

Типичная топология задач

[ISR АЦП] ──уведомление──▶ [Задача АЦП] ──очередь──▶ [Задача фильтра] ──очередь──▶ [Задача дисплея]
                                                              │
                                                              └──очередь──▶ [Задача логгера] ──UART──▶ serial
  • Каждая стрелка — очередь или уведомление; никаких разделяемых переменных
  • Каждая задача имеет один вход и один или несколько выходов
  • Задачи можно тестировать независимо, подменяя их очереди

Типичные ловушки

Блокировка в ISR: вызов xQueueSend (не FromISR) из ISR вызовет assert в debug-сборке или тихо повредит ядро в release.

Молчаливая потеря данных при timeout=0: xQueueSend(queue, &msg, 0) вернёт errQUEUE_FULL без шума, если очередь заполнена. Либо используйте timeout, либо увеличьте глубину очереди, либо обрабатывайте возвращаемое значение.

Переполнение стека: configCHECK_FOR_STACK_OVERFLOW в FreeRTOSConfig.h должен быть установлен в 2 при разработке. Это включает проверку паттерна вотермарки при каждом переключении контекста — небольшие накладные расходы, перехватывает переполнения до повреждения памяти.

Забыть вызвать vTaskStartScheduler: задачи FreeRTOS создаются до запуска планировщика. Ни одна из них не выполняется, пока не вызван vTaskStartScheduler() в main(). После этого вызова main() никогда не возвращается.


Итоги

  • Общайтесь между задачами через очереди, а не глобальные переменные
  • Используйте варианты FromISR в обработчиках прерываний; вызывайте portYIELD_FROM_ISR
  • Размеры стеков — с вотермаркингом; проверяйте при стресс-тестировании
  • Расставляйте приоритеты по периоду (монотонно) — меньший период = более высокий приоритет
  • Предпочитайте уведомления задач семафорам для паттернов с одним производителем/потребителем
  • Включайте configCHECK_FOR_STACK_OVERFLOW = 2 при разработке

Что дальше