Изучаем многозадачность FreeRTOS на примере создания логгера для STM32


Логирование является важной составной частью профессиональной отладки программ. Написав небольшой логгер и изучая, как он работает, можно лучше разобраться, как работает многозадачность в операционной системе.
Из всех возможных вариантов вывода текста самым удобным, с моей точки зрения, является UART, хотя это не самый быстрый вариант.
Если у вас не отладочная плата Nucleo или Discovery, необходимо обзавестись UART-USB адаптером или программатором, который содержит в себе USB-UART адаптер. Оригинальные адаптеры содержат в себе этот интерфейс, но из китайских клонов можно использовать или Weact ST-Link или J-Link.
Исходный код логгера для микроконтроллера STM32H750 приведён в моём репозитории на GitHub.
Скучные примеры из книжек по FreeRTOS можно сделать гораздо интереснее, если найти прикладную задачу, в нашем случае — логгер. Я хочу убить двух зайцев: рассказать о том, как работать с операционной системой FreeRTOS на микроконтроллерах STM32 и как написать простейший логгер.
Подключаем FreeRTOS
FreeRTOS — это операционная система реального времени, которая портирована на множество различных аппаратных платформ. В основном это микроконтроллеры с ограниченными ресурсами.
Когда вы пишете программы, для операционных систем Windows или Linux, операционная система является внешней по отношению к программе. В прошивке для микроконтроллера, работающей под операционной системой FreeRTOS, операционная система является частью прошивки.
FreeRTOS с точки зрения программиста — это несколько .c и .h файлов, в исходном коде проекта.
В FreeRTOS вся программа работает как один большой «процесс», а задачи — это потоки выполнения внутри него. У каждой задачи свой стек, свой контекст (регистры), но общая память. Поэтому ошибка в одной задаче (например, выход за границы массива) легко может «уронить» всю систему. В Linux/Windows процессы полностью изолированы друг от друга благодаря MMU (Memory Management Unit). Потоки внутри одного процесса уже разделяют память — и именно они ближе всего по смыслу к задачам FreeRTOS.
Если вы добавляете поддержку FreeRTOS в свой проект STM32 через STM32СubeMX, то в коде генерируются не вызовы функций FreeRTOS, а обёртки, которые являются функциями из CMSIS-RTOS.
Вместо прямых нативных вызовов FreeRTOS (например, xTaskCreate или vTaskDelay) STM32CubeMX генерирует функции-обёртки от ARM (например, osThreadNew или osDelay).
В проекте создаются файлы cmsis_os2.c / cmsis_os2.h (для CMSIS-RTOS V2). Внутри этих функций-обёрток как раз и вызываются нативные команды FreeRTOS.
Зачем STMicroelectronics это делает?
Портативность: вы можете легко заменить FreeRTOS на другую RTOS, поддерживающую стандарт CMSIS-RTOS, не меняя при этом код самого приложения.
Стандартизация: ARM продвигает CMSIS как единый стандарт для всех микроконтроллеров Cortex-M.
CMSIS чем-то напоминает стандарт POSIX для операционных систем.
Главная цель — обеспечить переносимость кода и избавить разработчиков от привязки к конкретной операционной системе.
Эксперимент с printf()
В одной из статей, я уже рассказывал, как сделать вывод текста функцией printf(), используя UART на микроконтроллерах STM32. В этой же статье вы можете посмотреть, как создавать проекты для STM32.
Вывод при помощи printf() годится для простейших случаев, когда мы пишем bare metal прошивку без операционной системы. Если, например, в операционной системе FreeRTOS мы будем вызывать printf() из нескольких задач, то результат будет непредсказуемым. Далее мы рассмотрим, почему так происходит.
При помощи Stm32CubeMX создадим проект с FreeRTOS, в котором три задачи с одинаковым приоритетом, цикле выводят сообщения.
Активируем Serial Wire Debug:

Активируем UART:

Добавление UART может сбить настройки клоков. Поэтому разрешаем STM32CubeMX решить это за нас:

Добавляем поддержку FreeRTOS. И выбираем интерфейс CMSIS_V2:

Для корректной работы FreeRTOS нужно не забыть выбрать Timebase Source:

Создаём и редактируем определение задач FreeRTOS:


Подключаем отладочную плату. К пинам, которые используются UART, необходимо подключить или USB-UART адаптер или программатор, который поддерживает виртуальный COM-порт.
Запускаем терминал
sudo picocom --imap lfcrlf -b 115200 /dev/ttyACM0где
ttyACM0имя виртуального COM-портаСобираем и запускаем проект.
На выходе мы видим кашу.

Связано это с тем, что функция printf() из библиотеки newlib не является потокобезопасной. Когда она вызывается одновременно из нескольких потоков, рушатся её внутренние структуры.
Реализация корректного вывода при помощи мьютекса
Многие изучали операционные системы и имеют представление о вытесняющей (preemptive) и кооперативной (cooperative) многозадачности и как работает планировщик.
Также слышали и имеют базовое представление о синхронизирующих объектах в операционной системе, таких как мьютексы, семафоры и др.
Но если по отдельности всё понятно, то как работает планировщик совместно с примитивами — всё гораздо сложнее.
Вы можете написать неправильно программу, использующую многозадачность, и программа будет даже работать. Но при изменении настроек многозадачности или при другой частоте вывода или другом размере сообщений, программа у вас загадочным образом перестанет работать.
Вы знаете, что защитить совместный ресурс можно при помощи синхронизирующих конструкций, например, мьютекса.
Добавляем мьютекс:

Мьютекс гарантирует взаимное исключение: только та задача, которая захватила мьютекс, получает доступ к коду внутри мьютека, в то время как остальные задачи будут переведены в состояние ожидания (блокировки) при попытке взять этот же мьютекс.
Поэтому, используя мьютекс, можно потокобезопасно работать с функцией printf(). Создадим функцию safe_printf(), где перед вызовом printf() мы захватываем мьютекс, а после — освобождаем.
Запускаем. И что мы видим? Один из потоков монополизировал вывод, а остальные просто ничего вывести не могут.

Почему такое происходит? Мы же выбрали вытесняющую многозадачность, потоки имеют одинаковый приоритет, и даже включен Time Slicing!
Дело в том, что во FreeRTOS используется механизм «несправедливых» мьютексов. Когда поток освобождает мьютекс, поток всё ещё продолжает выполняться. И если в цикле он сразу же пытается захватить мьютекс снова, он делает это быстрее, чем планировщик успевает переключить контекст на другую задачу (даже при одинаковых приоритетах).
Захват уже захваченного мьютекса переводит задачу в состояние blocked. Задачи в состоянии blocked не участвуют в планировании. То есть может возникнуть ситуация, когда задача захватила мьютекс и выводит по UART. Вторая задача пытается захватить мьютекс, но он уже захвачен, поэтому задача переходит в состояние blocked.
Или более подробно:
Поток А освобождает мьютекс. В этот момент поток А всё ещё находится в состоянии Running (исполняется) и его квант времени ещё не истёк.
Поток А снова вызывает safe_printf и пытается захватить мьютекс.
Так как планировщик ещё не переключил контекст на потоки Б или В, поток А мгновенно забирает мьютекс обратно.
Добавим в конце тела цикла vTaskDelay(), и всё заработает как надо. Функция заблокирует текущий поток на время, дав возможность остальным задачам захватить освободившийся мьютекс.
Пишем логгер
Выводить информацию можно и прямо из нескольких задач, однако в многозадачных системах логгеры обычно устроены иначе. Отдельная задача-логгер постоянно читает сообщения из очереди и выводит их по UART, а все остальные задачи только кладут сообщения в эту очередь.
Помещение сообщения в очередь (когда она не заполнена) — очень быстрая операция по сравнению с передачей данных по UART. Благодаря этому логирование почти не влияет на тайминги основного кода приложения.
Рассмотрим создание такого логгера.
Логгер состоит из трёх основных функций:
Logger_Init()— инициализация логгера.Logger_Log()— помещение сообщения в очередь.vLoggerTask()— задача, которая читает сообщения из очереди и выводит их.
Чтобы логгер выводил время в микросекундах (момент, когда сообщение было помещено в очередь), я написал функцию Logger_GetMicroseconds().
Для получения времени в микросекундах нужно взять значение счётчика тактов процессора (DWT->CYCCNT), умножить значение на 1 000 000 и разделить на текущую тактовую частоту ядра.
Для работы со счётчиком необходимо выполнить корректную низкоуровневую инициализацию:
// 1. Включаем трассировку в CoreDebug
if (!(CoreDebug->DEMCR & CoreDebug_DEMCR_TRCENA_Msk)) {
CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;
}
// 2. Снимаем защиту с регистров DWT (обязательно для Cortex-M7 / STM32H7)
DWT->LAR = 0xC5ACCE55;
// 3. Сбрасываем и включаем счётчик тактов (CYCCNT)
DWT->CYCCNT = 0;
DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;
Для простоты я не использовал функции CMSIS-RTOS в логгере.
Функция Logger_Init()
Назначение функции Logger_Init() создать задачу, в которой будет выполняться логгер и создать очередь сообщений для логгера.
void Logger_Init(void) {
// 0. На всякий случай обновляем значение частоты ядра
SystemCoreClockUpdate();
// 1. Включаем трассировку в CoreDebug
if (!(CoreDebug->DEMCR & CoreDebug_DEMCR_TRCENA_Msk)) {
CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;
}
// 2. Снимаем защиту с регистров DWT (ОБЯЗАТЕЛЬНО для Cortex-M7 / STM32H7)
DWT->LAR = 0xC5ACCE55;
// 3. Сбрасываем и включаем счётчик тактов (CYCCNT)
DWT->CYCCNT = 0;
DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;
// 4. Создаём очередь для хранения строк
xLogQueue = xQueueCreate(LOG_QUEUE_LEN, sizeof(log_item_t));
if (xLogQueue != NULL) {
// 5. Создаём задачу логгера с низким приоритетом
xTaskCreate(vLoggerTask, "LogTask", configMINIMAL_STACK_SIZE + 1024, NULL, osPriorityHigh, NULL);
}
}
Функция Logger_Log()
Задача функции Logger_Log — получить сообщение и параметры от вызывающего потока, отформатировать сообщение и поместить в очередь сообщений.
Чтобы логгер оказывал меньшее влияние на тайминги остальных задач операционной системы, более правильно разместить код по форматированию сообщений в задачу логгера, но я этого делать не стал, чтобы не усложнять код.
void Logger_Log(log_level_t level, const char *tag, const char *format, ...) {
if (xLogQueue == NULL) return;
log_item_t item;
int offset = 0;
// Получаем время в микросекундах
uint32_t timestamp = Logger_GetMicroseconds();
// 1. Форматируем префикс: Время (мкс), Уровень, Тэг (ширина поля увеличена до 10 символов)
offset = snprintf(item.msg, LOG_MAX_STR_LEN, "[%10lu us] %s [%s]: ", timestamp, level_strings[level], tag);
if (offset < 0 || offset >= LOG_MAX_STR_LEN) return;
// 2. Добавляем пользовательское сообщение
va_list args;
va_start(args, format);
int body_len = vsnprintf(item.msg + offset, LOG_MAX_STR_LEN - offset - 2, format, args);
va_end(args);
if (body_len > 0) {
offset += body_len;
}
// 3. Добавляем перенос строки
strcpy(item.msg + offset, "\r\n");
// 4. Отправляем в очередь
xQueueSend(xLogQueue, &item, 5);
}
Функция vLoggerTask()
Реализация функции vLoggerTask() простая, нужно в бесконечном цикле пытаться получить из очереди сообщений (xLogQueue) сообщение и вывести последнее на UART.
static void vLoggerTask(void *pvParameters) {
log_item_t item;
for (;;) {
if (xQueueReceive(xLogQueue, &item, portMAX_DELAY) == pdTRUE) {
Logger_Output(item.msg);
}
}
}
Ограничение созданного прототипа логгера
Несмотря на то, что вы можете использовать разработанный логгер в своих проектах, я всё же считаю логгер прототипом, так для лучшего понимания я сделал ряд упрощений.
Я использую блокирующие вызовы UART, пересылкой данных по UART занимается процессор, что не есть хорошо.
Я использую очередь FreeRTOS, а не кольцевой буфер.
Длина сообщения жёстко определена, и не рационально используется оперативная память.
Заключение
В статье я рассказал лишь малую часть обширной темы многопоточности вообще и многопоточности (многозадачности) во FreeRTOS.
Полученных знаний должно хватить на понимание основ использования FreeRTOS для микроконтроллера STM32.
Небольшую библиотеку логгера можно использовать в своих проектах, где необходим вывод отладочной информации.
И хотя FreeRTOS освещена в статье довольно поверхностно, читать документацию по FreeRTOS будет легче. А вы можете поставить себе галочку, что имели опыт работы с FreeRTOS (хоть и со словарём :)).
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.