Логирование является важной составной частью профессиональной отладки программ. Написав небольшой логгер и изучая, как он работает, можно лучше разобраться, как работает многозадачность в операционной системе.

Из всех возможных вариантов вывода текста самым удобным, с моей точки зрения, является 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, в котором три задачи с одинаковым приоритетом, цикле выводят сообщения.

  1. Активируем Serial Wire Debug:

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

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

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

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

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

  7. Подключаем отладочную плату. К пинам, которые используются UART, необходимо подключить или USB-UART адаптер или программатор, который поддерживает виртуальный COM-порт.

  8. Запускаем терминал

        sudo picocom --imap lfcrlf -b 115200 /dev/ttyACM0
    

    где ttyACM0 имя виртуального COM-порта

  9. Собираем и запускаем проект.

На выходе мы видим кашу.

Связано это с тем, что функция printf() из библиотеки newlib не является потокобезопасной. Когда она вызывается одновременно из нескольких потоков, рушатся её внутренние структуры.

Реализация корректного вывода при помощи мьютекса

Многие изучали операционные системы и имеют представление о вытесняющей (preemptive) и кооперативной (cooperative) многозадачности и как работает планировщик.

Также слышали и имеют базовое представление о синхронизирующих объектах в операционной системе, таких как мьютексы, семафоры и др.

Но если по отдельности всё понятно, то как работает планировщик совместно с примитивами — всё гораздо сложнее.

Вы можете написать неправильно программу, использующую многозадачность, и программа будет даже работать. Но при изменении настроек многозадачности или при другой частоте вывода или другом размере сообщений, программа у вас загадочным образом перестанет работать.

Вы знаете, что защитить совместный ресурс можно при помощи синхронизирующих конструкций, например, мьютекса.

Добавляем мьютекс:

Мьютекс гарантирует взаимное исключение: только та задача, которая захватила мьютекс, получает доступ к коду внутри мьютека, в то время как остальные задачи будут переведены в состояние ожидания (блокировки) при попытке взять этот же мьютекс.

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

Запускаем. И что мы видим? Один из потоков монополизировал вывод, а остальные просто ничего вывести не могут.

Почему такое происходит? Мы же выбрали вытесняющую многозадачность, потоки имеют одинаковый приоритет, и даже включен Time Slicing!

Дело в том, что во FreeRTOS используется механизм «несправедливых» мьютексов. Когда поток освобождает мьютекс, поток всё ещё продолжает выполняться. И если в цикле он сразу же пытается захватить мьютекс снова, он делает это быстрее, чем планировщик успевает переключить контекст на другую задачу (даже при одинаковых приоритетах).

Захват уже захваченного мьютекса переводит задачу в состояние blocked. Задачи в состоянии blocked не участвуют в планировании. То есть может возникнуть ситуация, когда задача захватила мьютекс и выводит по UART. Вторая задача пытается захватить мьютекс, но он уже захвачен, поэтому задача переходит в состояние blocked.

Или более подробно:

  1. Поток А освобождает мьютекс. В этот момент поток А всё ещё находится в состоянии Running (исполняется) и его квант времени ещё не истёк.

  2. Поток А снова вызывает safe_printf и пытается захватить мьютекс.

  3. Так как планировщик ещё не переключил контекст на потоки Б или В, поток А мгновенно забирает мьютекс обратно.

Добавим в конце тела цикла 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);
        }
    }
}

Ограничение созданного прототипа логгера

Несмотря на то, что вы можете использовать разработанный логгер в своих проектах, я всё же считаю логгер прототипом, так для лучшего понимания я сделал ряд упрощений.

  1. Я использую блокирующие вызовы UART, пересылкой данных по UART занимается процессор, что не есть хорошо.

  2. Я использую очередь FreeRTOS, а не кольцевой буфер.

  3. Длина сообщения жёстко определена, и не рационально используется оперативная память.

Заключение

В статье я рассказал лишь малую часть обширной темы многопоточности вообще и многопоточности (многозадачности) во FreeRTOS.

Полученных знаний должно хватить на понимание основ использования FreeRTOS для микроконтроллера STM32.

Небольшую библиотеку логгера можно использовать в своих проектах, где необходим вывод отладочной информации.

И хотя FreeRTOS освещена в статье довольно поверхностно, читать документацию по FreeRTOS будет легче. А вы можете поставить себе галочку, что имели опыт работы с FreeRTOS (хоть и со словарём :)).

© 2026 ООО «МТ ФИНАНС»

Комментарии (0)