Неправильный старт с 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, ¶ms, 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при разработке
Что дальше
- АЦП + DMA на STM32 — подача данных в задачу АЦП из DMA-конвейера
- Lock-free очереди — когда очереди FreeRTOS слишком тяжелы
- Паттерн «активный объект» — инкапсуляция задачи и очереди в один C++ класс