Перейти к содержанию

Логирование и трассировка в прошивке

Отладочная печать — самый простой способ узнать, что делает прошивка, и самый простой способ её сломать: блокирующий printf на 115 200 бод тратит миллисекунды на строку и меняет поведение программы. Разбираем каналы вывода — UART с перенаправлением printf в avr-libc и newlib, ITM и вывод SWO в ядрах Cortex-M3 и M4, SEGGER RTT через память, — их скорость и цену, уровни журнала с отсечением при компиляции, метки времени по счётчику тактов DWT и правила логирования в обработчиках прерываний.

Обновлено Редакция GAW

Отладчик с точками останова хорош, пока программу можно остановить. Но обмен по шине, управление двигателем, обработка потока с АЦП в остановленном виде не существуют: остановка ломает тайминг, а с ним и то, что хотели посмотреть. Поэтому в прошивках всегда есть второй инструмент — журнал: программа сама сообщает, что делает. Вопрос в том, через какой канал и какой ценой.

Коротко

  • UART + printf: работает везде, нужен только преобразователь USB-UART; на 115 200 бод строка из 40 символов передаётся 3,5 мс.
  • ITM + SWO (Cortex-M3/M4/M7): запись в порт ITM — одно слово, вывод SWO на скоростях в мегабиты; нужен отладчик с SWO; у Cortex-M0 ITM нет.
  • SEGGER RTT: кольцевые буферы в ОЗУ, которые отладчик читает на ходу; без лишних выводов, порядка микросекунды на строку.
  • Уровни журнала отсекают при компиляции макросами, чтобы отладочные строки не занимали флеш-память в рабочей сборке.
  • В обработчиках прерываний не форматируют строки: кладут короткое событие в буфер, а печатают в основном цикле.

Каналы вывода

КаналВыводыСкоростьЦена записи для процессораБез отладчика
UART, блокирующий printfTX (+ GND)11 520 байт/с на 115 200 бодвремя передачи всей строкиработает
UART через кольцевой буферTXта жекопирование в буфер, затем прерывание на байтработает
ITM → SWOSWO (+ SWD)мегабиты в секундузапись слова в порт, если он свободенвывод не идёт, запись почти бесплатна
SEGGER RTTтолько SWD или JTAGдо ~3,5 МБ/с в фоновом режиме (данные SEGGER)копирование в буфер в ОЗУданные отбрасываются или ждут — по режиму канала

Блокирующий вывод через UART — самый медленный вариант: на 115 200 бод кадр 8N1 занимает 86,8 мкс, и строка из 40 символов идёт 40 · 86,8 мкс ≈ 3,5 мс. Всё это время процессор крутится в ожидании флага «регистр данных пуст». В программе с циклом управления на 1 кГц одна такая строка съедает три с половиной периода. Посчитать время кадра для других скоростей и форматов помогает инструмент «Кадр UART».

printf через UART

AVR и avr-libc

В avr-libc printf пишет в поток stdout, а поток связывают с функцией вывода символа макросом FDEV_SETUP_STREAM — без выделения памяти через malloc:

#include <stdio.h>
#include <avr/io.h>

static int uart_putchar(char c, FILE *stream)
{
    if (c == '\n')
        uart_putchar('\r', stream);          /* terminals expect CR LF */
    loop_until_bit_is_set(UCSR0A, UDRE0);
    UDR0 = (uint8_t)c;
    return 0;
}

static FILE uart_out = FDEV_SETUP_STREAM(uart_putchar, NULL, _FDEV_SETUP_WRITE);

int main(void)
{
    uart_init();
    stdout = &uart_out;
    printf("boot, MCUSR=%02x\n", mcusr_mirror);
    for (;;) { }
}

По умолчанию компонуется сокращённая версия vfprintf без чисел с плавающей точкой; полную подключают ключами -Wl,-u,vfprintf -lprintf_flt -lm, минимальную — -Wl,-u,vfprintf -lprintf_min. Разница во флеш-памяти — килобайты, поэтому чаще печатают целые: милливольты вместо вольт. Подробно настройка UART и printf для ATmega328P разобрана в уроке «UART и printf», а принимать вывод на компьютере удобно в последовательном терминале.

Функция выше ждёт освобождения передатчика. Чтобы printf не тормозил программу, uart_putchar кладёт символ в кольцевой буфер передачи и включает прерывание по пустому регистру данных — как это сделать, показано в статье о кольцевом буфере для UART. Тогда цена вывода — время форматирования и копирования, а передача идёт в фоне. Если буфер переполнен, приходится выбирать: ждать (и тормозить) или терять строки; для журнала обычно лучше терять и считать потери.

STM32 и newlib

Библиотека newlib, которая идёт с arm-none-eabi-gcc, выводит в любой файл, включая stdout, через системный вызов write (документация newlib, System Calls). В проектах STM32CubeIDE файл syscalls.c уже содержит слабую функцию _write, которая для каждого символа вызывает __io_putchar(). Остаётся определить её:

#include <stdio.h>
#include "stm32f1xx.h"

int __io_putchar(int ch)
{
    while (!(USART1->SR & USART_SR_TXE)) { }   /* blocking: simplest, slowest */
    USART1->DR = (uint8_t)ch;
    return ch;
}

void log_init(void)
{
    setvbuf(stdout, NULL, _IONBF, 0);          /* no buffering: characters go out at once */
}

Вызов setvbuf(stdout, NULL, _IONBF, 0) (C11, 7.21.5.6) отключает буферизацию stdout. Без него символы могут копиться в буфере библиотеки и выйти пачкой позже — или не выйти вовсе перед зависанием, ради которого печать и добавляли.

ITM и SWO

У ядер Cortex-M3, M4 и M7 есть модуль ITM (Instrumentation Trace Macrocell): набор портов-стимулов — до 256, их число кратно восьми (ARMv7-M ARM, C1.7). Запись в порт превращается в пакет трассировки, который блок TPIU выводит наружу — в недорогих отладочных схемах через однопроводной вывод SWO (Serial Wire Output). Отладчик принимает пакеты и показывает их как текст. В руководстве по Cortex-M0 (DUI 0497A) ITM не упоминается вовсе — для таких ядер остаются UART и RTT.

Со стороны прошивки всё сводится к функции CMSIS ITM_SendChar():

/* CMSIS 6, core_cm3.h (abridged) */
__STATIC_INLINE uint32_t ITM_SendChar(uint32_t ch)
{
    if (((ITM->TCR & ITM_TCR_ITMENA_Msk) != 0UL) &&   /* ITM enabled */
        ((ITM->TER & 1UL) != 0UL))                    /* port 0 enabled */
    {
        while (ITM->PORT[0U].u32 == 0UL) { __NOP(); } /* wait until the port is ready */
        ITM->PORT[0U].u8 = (uint8_t)ch;
    }
    return ch;
}

Если ITM не включён — отладчик не подключён или не настроил трассировку, — функция сразу возвращается, и вывод ничего не стоит. Архитектура требует, чтобы запись в порт выключенного или отсутствующего ITM не вызывала ошибку шины (ARMv7-M ARM, C1.1). Поэтому вызовы ITM можно оставлять и в сборке, которая работает без отладчика. Перенаправить на ITM printf из newlib — значит переопределить _write:

int _write(int file, char *ptr, int len)
{
    (void)file;
    for (int i = 0; i < len; i++)
        ITM_SendChar((uint32_t)(uint8_t)ptr[i]);
    return len;
}

Настройка SWO

Блоки ITM и TPIU обычно настраивает отладчик — STM32CubeIDE, OpenOCD или J-Link — по заданной в нём частоте ядра и скорости SWO. Прошивке достаточно не мешать. Но знать, что настраивается, полезно, чтобы понять, почему вывод не работает:

  • Разрешение трассировки: бит TRCENA (бит 24) регистра DEMCR включает блоки DWT и ITM (ARMv7-M ARM, C1.6.5). В CMSIS 6 это DCB->DEMCR |= DCB_DEMCR_TRCENA_Msk; старое имя CoreDebug->DEMCR с CoreDebug_DEMCR_TRCENA_Msk оставлено как синоним.
  • Скорость: частота SWO = TRACECLKIN / (PRESCALER + 1), где PRESCALER — значение регистра TPIU_ACPR по адресу 0xE0040010 (Cortex-M3 TRM, 11.3.1). У STM32F1 TRACECLKIN подключён к HCLK (RM0008, 31.17.8). При HCLK = 72 МГц и PRESCALER = 35 получаем 72 / 36 = 2 Мбит/с.
  • Протокол: регистр TPIU_SPPR выбирает асинхронный SWO с кодом NRZ (значение 2) или с манчестерским кодом (1) (ARMv7-M ARM, C1.10.5).
  • Включение ITM и порта: бит ITMENA регистра ITM_TCR разрешает ITM в целом, а бит 0 регистра ITM_TER — порт 0, в который пишет ITM_SendChar(); без них функция CMSIS молча ничего не выводит (ARMv7-M ARM, C1.7.4 и C1.7.6).
  • Форматтер TPIU: в режиме SWO бит EnFCont (бит 1) регистра TPIU_FFCR по адресу 0xE0040304 включает форматтер, а сброшенный — пропускает его, и на выход идут только пакеты ITM и DWT; значение после сброса — 0x102, бит установлен. Когда по SWO нужны только ITM и DWT, форматтер обходят, сбрасывая этот бит; включение и выключение форматтера на ходу на мгновение портит данные (Cortex-M3 TRM, 11.3.3).
  • Вывод: у STM32F103 SWO — это PB3, общий с JTDO. Асинхронная трасса работает только с отладочным портом SW-DP, то есть с отладкой по SWD, а не по JTAG (RM0008, 31.3). В регистре DBGMCU_CR биты TRACE_IOEN = 1 и TRACE_MODE = 00 назначают вывод под асинхронную трассу.

Из того, что TRACECLKIN равен HCLK, следует частая проблема: прошивка переключает тактирование с HSI на PLL, а отладчик настроил SWO под одну частоту — и после переключения в окне вывода мусор. Частоту ядра в настройках отладчика задают ту, на которой программа работает после инициализации, а вывод до переключения тактирования не используют. RM0008 отдельно предупреждает об этом в 31.17.8. Подробнее о выводе — в глоссарии: SWO.

SEGGER RTT

RTT (Real Time Transfer) — механизм SEGGER для обмена с отладчиком J-Link через память. В ОЗУ микроконтроллера лежит управляющий блок: он начинается с идентификатора, по которому отладчик находит его в памяти, и содержит описатели кольцевых буферов. Буферы «вверх» передают данные от прошивки к компьютеру, «вниз» — обратно. У буфера вверх указатель записи меняет только прошивка, а указатель чтения — только отладчик, у буфера вниз — наоборот. Это ровно схема «один писатель, один читатель» из статьи о кольцевом буфере, где второй стороной выступает отладчик, читающий память по SWD, пока процессор работает.

#include "SEGGER_RTT.h"

void report(int32_t temp_mc)
{
    SEGGER_RTT_WriteString(0, "boot\n");
    SEGGER_RTT_printf(0, "temp=%d mC\n", temp_mc);
}

Что важно знать (по документации SEGGER):

  • Цена: около 500 байт кода, 24 байта идентификатора и по 24 байта на канал в ОЗУ плюс сами буферы. Строка выводится примерно за микросекунду — по сути, это копирование в память.
  • Канал 0 создаётся при компиляции, его размер задаёт BUFFER_SIZE_UP; остальные каналы настраивают функциями SEGGER_RTT_ConfigUpBuffer() и SEGGER_RTT_ConfigDownBuffer().
  • Режим при заполненном буфере: блокирующий (ждать, пока отладчик заберёт данные — без отладчика программа встанет), без блокировки с пропуском всей записи или с обрезкой. Для журнала, который остаётся в рабочей прошивке, выбирают неблокирующий режим.
  • Поиск блока: отладчик ищет идентификатор в заданном диапазоне ОЗУ; при большом ОЗУ поиск занимает время, и адрес блока можно указать явно.

Ограничение RTT — отладчик: нужен J-Link или совместимое с RTT средство. Зато не нужны ни свободный UART, ни вывод SWO: SEGGER указывает, что фоновое чтение памяти работает на ядрах Cortex-M, где к памяти всегда есть доступ через AHB-AP, а значит, и на ядрах без ITM.

Уровни журнала

Отладочные строки занимают флеш-память — каждая строка формата хранится целиком — и время. Поэтому сообщения делят по уровням, а лишние уровни отсекают при компиляции: тогда в рабочей сборке их нет ни в коде, ни во флеш-памяти.

/* log.h */
#ifndef LOG_H
#define LOG_H

#include <stdint.h>
#include <stdio.h>

enum { LOG_LVL_NONE, LOG_LVL_ERROR, LOG_LVL_WARN, LOG_LVL_INFO, LOG_LVL_DEBUG };

#ifndef LOG_LEVEL
#define LOG_LEVEL LOG_LVL_INFO               /* override with -DLOG_LEVEL=... */
#endif

uint32_t log_timestamp(void);                /* milliseconds or cycles */

#define LOG_AT(lvl, tag, ...)                                           \
    do {                                                                \
        if ((lvl) <= LOG_LEVEL) {                                       \
            printf("%lu %s %s:%d: ", (unsigned long)log_timestamp(),    \
                   (tag), __FILE__, __LINE__);                          \
            printf(__VA_ARGS__);                                        \
            putchar('\n');                                              \
        }                                                               \
    } while (0)

#define LOG_E(...) LOG_AT(LOG_LVL_ERROR, "E", __VA_ARGS__)
#define LOG_W(...) LOG_AT(LOG_LVL_WARN,  "W", __VA_ARGS__)
#define LOG_I(...) LOG_AT(LOG_LVL_INFO,  "I", __VA_ARGS__)
#define LOG_D(...) LOG_AT(LOG_LVL_DEBUG, "D", __VA_ARGS__)

#endif

Условие (lvl) <= LOG_LEVEL — константное выражение, и при LOG_LEVEL = LOG_LVL_INFO компилятор выбросит вызовы LOG_D вместе со строками форматов. Макрос принимает формат и аргументы через __VA_ARGS__ (C11, 6.10.3), поэтому работает и с одной строкой без аргументов: LOG_I("boot"). Обёртка do { … } while (0) делает макрос одним оператором, и он безопасен в if без фигурных скобок. __FILE__ даёт полный путь к файлу, который тоже занимает флеш-память; в больших проектах вместо него передают короткий тег модуля.

Хорошие правила для текста сообщений: одно событие — одна строка; числа, а не слова («adc=1873», а не «значение нормальное»); ключи и единицы в строке («t=23500 mC»), чтобы журнал можно было разбирать скриптом.

Метки времени: счётчик тактов DWT

Без времени журнал отвечает на вопрос «что», но не «когда» и «как долго». Миллисекунды от SysTick подходят для событий, а для измерения длительности функций нужен счётчик тактов DWT — 32-битный регистр DWT->CYCCNT, который увеличивается каждый такт ядра:

#include "stm32f4xx.h"

void cycles_init(void)
{
    CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;   /* DCB->DEMCR in CMSIS 6 */
    DWT->CYCCNT = 0;
    DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;
}

static inline uint32_t cycles_now(void)
{
    return DWT->CYCCNT;
}

void measure_process_frame(void)       /* usage: time one call */
{
    uint32_t t0 = cycles_now();
    process_frame();
    uint32_t dt = cycles_now() - t0;   /* unsigned difference survives a wrap */
    LOG_D("process_frame: %lu cycles", (unsigned long)dt);
}

На 72 МГц счётчик переполняется через 2^32 / 72 000 000 ≈ 59,7 с, и беззнаковая разность остаётся правильной, пока измеряемый интервал короче. Счётчик тактов есть не во всех реализациях: архитектура допускает упрощённый DWT без него (ARMv7-M ARM, C1.1), а в руководстве по Cortex-M0 (DUI 0497A) блока DWT нет вовсе. Подробнее — в глоссарии: счётчик тактов DWT. Тем же способом стоит измерить и сам вывод: сколько тактов занимает LOG_I с форматированием — часто это сюрприз.

Логирование и прерывания

Форматированная печать в обработчике прерывания — плохая идея по трём причинам. Она долгая: форматирование числа занимает сотни и тысячи тактов, а блокирующий вывод — миллисекунды. Она нереентерабельна: если основной цикл был внутри printf, когда пришло прерывание, второй вызов испортит общее состояние библиотеки. И она меняет тайминг ровно там, где он важнее всего. Подробно о нереентерабельных функциях — в статье о прерываниях и общих данных.

Вместо этого обработчик кладёт в очередь короткую запись — код события, время, одно-два числа, — а основной цикл форматирует и выводит:

#include <stdint.h>

typedef struct {
    uint32_t t;                  /* cycles or ms */
    uint16_t code;               /* event id */
    uint16_t arg;
} log_event_t;

#define LOG_Q_SIZE 32u           /* power of two */
static log_event_t log_q[LOG_Q_SIZE];
static volatile uint8_t log_head, log_tail;
static volatile uint16_t log_lost;

void log_event_isr(uint16_t code, uint16_t arg)      /* the only producer: one ISR priority */
{
    uint8_t head = log_head;
    if ((uint8_t)(head - log_tail) >= LOG_Q_SIZE) {
        log_lost++;
        return;
    }
    log_q[head & (LOG_Q_SIZE - 1u)] = (log_event_t){ cycles_now(), code, arg };
    __asm__ __volatile__ ("" ::: "memory");          /* entry before the index */
    log_head = (uint8_t)(head + 1u);
}

Основной цикл забирает записи тем же способом, что и байты из кольцевого буфера, и печатает их не спеша. Если писать в очередь могут обработчики разных приоритетов, они вытесняют друг друга, и запись выполняют при запрещённых прерываниях. Развитие этой идеи — двоичный журнал: в прошивке хранится только номер сообщения и аргументы, а строки форматов остаются на компьютере, который и собирает из них текст. Такой журнал на порядок экономнее и по флеш-памяти, и по времени.

Журнал в рабочей прошивке

Журнал полезен не только при разработке. В рабочей сборке оставляют уровни ERROR и WARN и счётчики событий — потерянные байты UART, ошибки CRC, перезапуски по сторожевому таймеру. Последние записи перед сбоем стоит держать в кольцевом буфере в секции .noinit: после сброса они сохранятся, и программа отправит их при следующем подключении. Так же хранят регистры HardFault — пример в статье о переполнении стека и ошибках памяти.

Типичные ошибки

  • Блокирующий printf в цикле управления — тайминг меняется, ошибка «пропадает» при включении печати и возвращается без неё.
  • printf в обработчике прерывания — долгий обработчик и порча состояния библиотеки при вложенном вызове.
  • Буферизация stdout — последние строки перед зависанием не выводятся; setvbuf(stdout, NULL, _IONBF, 0) или явный fflush.
  • Частота ядра в настройках SWO не та — мусор вместо текста после переключения тактирования.
  • SWO при отладке по JTAG — у STM32F1 асинхронная трасса работает только с SWD.
  • RTT в блокирующем режиме без отладчика — программа останавливается на первой заполненной записи.
  • float в printf на AVR без -lprintf_flt — вместо числа печатается знак вопроса.
  • Отладочные строки в рабочей сборке — флеш-память занята форматами, которые никто не прочитает; уровни отсекают при компиляции.

Частые вопросы

Как вывести printf в UART на STM32?

printf из newlib выводит символы через системный вызов _write. В проектах STM32CubeIDE файл syscalls.c уже содержит слабую _write, которая вызывает __io_putchar() для каждого символа, — достаточно написать __io_putchar, отправляющую байт в USART, а лучше кладущую его в кольцевой буфер передачи. Чтобы символы не задерживались в буфере stdout, вызывают setvbuf(stdout, NULL, _IONBF, 0).

Что такое SWO и чем он лучше UART?

SWO — вывод трассировки ядер Cortex-M3, M4 и M7, по которому модуль ITM передаёт данные отладчику. Он не занимает UART, работает на скоростях в единицы мегабит, а запись в порт ITM — одна команда, если буфер свободен. Нужен отладчик с поддержкой SWO и свободный вывод: у STM32F103 это PB3, и он работает только с SWD, не с JTAG. У Cortex-M0 ITM и SWO нет.

Что такое SEGGER RTT?

Способ обмена через ОЗУ: в памяти микроконтроллера лежат кольцевые буферы, прошивка пишет в них, а отладчик J-Link читает их по SWD или JTAG, не останавливая процессор. Дополнительных выводов не нужно, запись — практически копирование в память, по данным SEGGER строка выводится примерно за микросекунду. Без подключённого отладчика в неблокирующем режиме данные просто отбрасываются.

Почему программа работает с отладочной печатью и ломается без неё?

Печать меняет тайминг и раскладку памяти. Блокирующий printf добавляет задержки в миллисекунды, которые маскируют гонки и слишком быстрые обращения к периферии, а его кадр на стеке и буферы сдвигают переменные — иначе проявляется порча памяти. Это признак скрытой ошибки синхронизации или выхода за границы, а не повод оставить печать.

Источники

  1. Arm. ARMv7-M Architecture Reference Manual, DDI 0403E.e, 2021 — C1.6.5 — DEMCR.TRCENA, бит 24, — общее разрешение блоков DWT и ITM; C1.1 — уровни реализации отладки: запись в порты ITM не должна вызывать BusFault, если ITM выключен или отсутствует, базовый уровень допускает DWT без счётчика тактов; C1.7 — ITM: до 256 портов-стимулов, число кратно восьми; C1.7.4 — ITM_TER0–7, разрешение портов; C1.7.6 — ITM_TCR, бит ITMENA; C1.10.4 — TPIU_ACPR: частота SWO = опорная частота / (SWOSCALER + 1); C1.10.5 — TPIU_SPPR: 01 — SWO с манчестерским кодом, 10 — SWO с кодом NRZ
  2. Arm. Cortex-M3 Processor Technical Reference Manual, revision r2p1 (100165); Cortex-M0 Devices Generic User Guide, DUI 0497A — 11.3.1 — TPIU_ACPR по адресу 0xE0040010, PRESCALER[12:0], делитель TRACECLKIN = PRESCALER + 1; 11.3.3 — TPIU_FFCR по адресу 0xE0040304, значение после сброса 0x102, бит 1 EnFCont, в режимах SWO при сброшенном бите форматтер обходится и проходят только данные ITM и DWT; в руководстве Cortex-M0 нет ни ITM, ни SWO, ни DWT
  3. Arm. CMSIS 6.4.0: CMSIS/Core/Include/core_cm3.h — ITM_SendChar(): пишет в порт 0, только если установлены ITM_TCR.ITMENA и бит 0 ITM_TER, ждёт готовности порта; ITM->PORT[0].u8; DWT->CTRL, DWT->CYCCNT, DWT_CTRL_CYCCNTENA_Msk; DCB->DEMCR и DCB_DEMCR_TRCENA_Msk (бит 24); CoreDebug и CoreDebug_DEMCR_TRCENA_Msk — устаревшие синонимы для совместимости с CMSIS 5
  4. STMicroelectronics. RM0008 Reference Manual STM32F101/102/103/105/107, Rev 21, 2021 — 31.3 — асинхронная трасса TRACESWO мультиплексирована с TDO и доступна только с SW-DP; табл. выводов — JTDO/TRACESWO на PB3; 31.16.3 — DBGMCU_CR: TRACE_IOEN и TRACE_MODE (00 — асинхронный режим); 31.17.8 — TRACECLKIN подключён к HCLK, частота SWO меняется вместе с тактированием
  5. SEGGER Knowledge Base: RTT (kb.segger.com/RTT), страница по состоянию на октябрь 2026 — управляющий блок в ОЗУ начинается с идентификатора, за ним дескрипторы буферов; у буферов вверх указатель записи меняет только цель, указатель чтения — только отладчик; режимы канала: блокирующий, без блокировки с пропуском и с обрезкой; канал 0 настраивается при компиляции (BUFFER_SIZE_UP); около 500 байт кода, 24 байта идентификатора и 24 байта на канал; строка выводится примерно за микросекунду; фоновый режим — до ~3,5 МБ/с; у Cortex-M к памяти всегда есть доступ через AHB-AP; SEGGER_RTT_Write, SEGGER_RTT_WriteString, SEGGER_RTT_printf(unsigned BufferIndex, const char *sFormat, ...)
  6. AVR-LibC 2.3.2: <stdio.h> — FDEV_SETUP_STREAM(put, get, rwflag) и _FDEV_SETUP_WRITE для потока без malloc; функция вывода int put(char, FILE *); stdout = &поток; варианты vfprintf: -Wl,-u,vfprintf -lprintf_min и -lprintf_flt
  7. Red Hat newlib C Library documentation: System Calls (write, sbrk); STMicroelectronics STM32CubeF1, коммит c7656f1: syscalls.c — подпрограммы libc выводят во все файлы, включая stdout, через системный вызов write, его минимальная реализация отправляет байты в порт; в syscalls.c ST слабая функция _write вызывает __io_putchar() для каждого символа
  8. ISO/IEC 9899:201x (C11), Committee Draft N1570, April 2011 — 6.10.3 — макросы с переменным числом аргументов и __VA_ARGS__; 6.10.8.1 — __FILE__ и __LINE__; 7.21.5.6 — setvbuf и _IONBF

Нашли ошибку?