
Логирование является важной составной частью профессиональной отладки программ. Написав небольшой логгер и изучая, как он работает, можно лучше разобраться, как работает многозадачность в операционной системе.
Из всех возможных вариантов вывода текста самым удобным, с моей точки зрения, является 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 (хоть и со словарём :)).
© 2026 ООО «МТ ФИНАНС»