
«Во всех самолётах есть черный ящик. A UART‑логирование это черный ящик вашей прошивки.»
Допустим вы решили делать в своей прошивке printf- отладку. Или даже забабахать UART-CLI (Shell). Многие про нее слышали и некоторые ей пользуются. Или у ваc есть какое -то внешнее устройство конфигурируемое по UART. Например микросхема U-Blox со своим UBX протоколом. Или LTE модуль с AT-командами. Первое с чем Вы столкнетесь - это настроить UART-трансивер. Как же реализовать алгоритм работы с UART периферией?
С приёмом все просто. Настраиваем прерывание по приему байта и принятый байт кладем в ту или иную программную очередь RxFIFO. Для CLI, Для NMEA для ModBus, AT-команд и т. п. Если вы гарантированно знаете сколько должны принять за раз, то можно даже настроить прием по DMA сразу, условно, 512 байт.
Куда интереснее с тем, как быть при отправке массивов. UART это тот редкий случай интерфейса, когда отправка сложнее, чем прием. Сейчас объясню почему. Для начала вспомним немного определений.
Если Вы в теме, то читайте сразу главу уровень 3.
Теория
UART - двухпроводной полнодуплексный последовательный способ передачи байт между микроконтроллерами.
FIFO - абстрактная структура данных, которая позволяет добавлять и извлекать элементы по принципу первый пришел первый ушел.
Прерывание (Interrupt Trap) — событие в микропроцессоре, которое провоцирует замену значений регистров процессора и вызов отдельной функции ISR. После исполнения функции ISR управление возвращается обратно в прерванную функцию main со старыми значениями регистров процессора. Как правило, прерывания используются для работы с внешними периферийными устройствами SoCa: Timer, UART, DMA и пр
Суперцикл — тот код, который бесконечно снова и снова исполняется внутри while(1) в функции main()
Race Condition (состояние гонки ) - ошибка проектирования программы с прерываниями , при которой работа приложения зависит от того, в каком порядке выполняются части кода. Это когда прерывание и основной поток берут и пишут одну и ту же переменную в RAM памяти. Результат - непредсказуемое значение.
Критическая секция — участок кода, выполнение которого не должно быть прервано прерываниями процессора.
Вот пожалуй и все определения, которые сегодня потребуются.
В чем проблема?
Неясно куда писать логи, когда UART трансивер еще не проинициализирован. Надо же как-то узнать причину ошибки при инициализации подсистемы тактирования или во время инициализации SysTick, Timer, VectorTable, GPIO. Вообще говоря, до настройки UART в прошивке происходит целая куча всяческих действий про результат отработки которых хорошо бы получить строчку в логе загрузке прошивки. Это настройка Fpu, CORE, ISRTable, Hal, Log, Time, Writer, Int, SysTick, Clk, GPIO, Flash. Хотелось бы про каждый программный компонент получить строчку со статусом выполнения в логе загрузки прошивки. Это же очевидно.
Если вы будете отправлять локальный массив по UART (из стека), то отправляемые данные исказятся при выходе из функции и заходе в следующую. Ибо трансивер работает сам по себе, а код тоже исполняется сам по себе.
Если выделять динамическую память для того, чтобы положить туда данные для UART отправки , тo это тоже не вариант, так как можно забыть освободить эту память. Да и использование malloc запрещается правилами MISRA.
Если выделить статическую память фиксированного размера (например 64 байт), то непременно появится запрос отправить массив большей длинны (120 байт) и его некуда будет положить чтобы оттуда отправить.
Как быть, если логи появляются быстрее, чем битовая скорость UART успевает их отправлять?
Как же распетлить весь этот ворох проблем?
Уровень1 (отправка не отходя от кассы)
Самое простое дать команду отправить массив и тут же ждать окончания отправки в бесконечном цикле сразу после функции HAL_UART_Transmit_IT.
bool UART_Send(UartHandle_t* const node,
const uint8_t* const data,
uint32_t len) {
bool res = false;
// make sure that global ISR enabled
// We send mainly from Stack. We need wait the end of transfer.
if(node) {
if(node->init_done && len && data) {
uint32_t start_us = TIME_GetUs();
HAL_StatusTypeDef stat = HAL_UART_Transmit_IT(&node->uart_h,
data,
len);
if(HAL_OK == stat) {
res = true;
/* We send from Stack. We need wait the end of tx.
Otherwise tx data will not be currupted */
while(false == node->tx_done) {
uint32_t dutation_us = TIME_GetUs() - start_us;
if(9999999 < dutation_us) {
res = false;
Node->tx_time_out_cnt++;
break;
}
if(HAL_UART_STATE_READY == node->uart_h.gState) {
res = true;
break;
}
}
}
}
}
return res;
}
Плюсы:
+1 Это очень просто реализовать. Дали отмашку на отправку массива и сидим ждем окончания отправки. По сути это беспонтовый режим polling-а.
+2 Не нужно дополнительной памяти. Всё работает на RAM стеке.
+3 Можно отправлять в UART из стековой RAM памяти
+4 Отправляемые данные не теряются в принципе. Всё, что вы хотите отправить контролируемым образом честно отправляется прямо перед нами. Мы не уйдем из функции uart_send(...) пока всё окончательно не отправится.
+5 Если зависнем, то в PuTTY хоть останется лог с целеуказанием того места, где мы зависли.
Минусы:
-1) Процессор работает в холостую пока ждет окончания отправки массива. Чем больше логирования, тем больше холостой работы процессора. Тем ниже производительность. Если скорость 9600 бит в сек, то это совсем не здорово. Если скорость 460800 бит/c то 1 бит отправляется за 2.1 us. Байт отправляется 26 us. 80 байт уйдут за 2ms. CPU занимается обогревом атмосферы.
-2) Долго происходит печать. Долго отрабатывает функция инициализации.
-3) Вы не можете пользоваться логированием до того, как проинициализируете UART трансивер.
-4) Недостаток отправки с poll-инном в том, что нельзя писать в прерывании.
Если вы сделали такую реализацию отправки, то в большой прошивке вы заметите, что прошивка ощутимо долго стартует. Обычно много логов генерирует именно функция system init. У меня на 460800 суперцикл стартует только после 1.041 cекунд с момента подачи электропитания питания. Это долго. Заметный затуп прошивки, который со временем сильно раздражает разработчика.
Уровень 2 (отправка в супер цикле)
Идея в том, чтобы при отправке в UART не отправлять байты физически, а просто складировать байты в Tx FIFO и идти дальше делать свои дела.
bool UART_Send(const uint8_t num,
const uint8_t* const data,
uint32_t size) {
bool res = false;
if(size) {
if(data) {
UartHandle_t* Node = UART_GetNode(num);
if(Node) {
res = FIFO_PushArray(&Node->TxFifo,
data,
size);
if(false == res) {
Node->tx_error_cnt++;
}
}
}
}
return res;
}
А саму отправку делать в суперцикле в отдельной задаче. Выгребать из очереди байт и отправлять их. Можно даже по DMA сразу массив взять и отправить.
Плюсы
+Вы можете пользоваться логированием до инициализации UART трансивера. Данные просто будут лежать в FIFO и ждать своего момента отправки в очереди на отправку.
Минусы
-Если зависнем в init функции до запуска суперцикла с задачей отправки, то программист так и не узнает почему и где зависла прошивка. Придется только включать пошаговый отладчик.
-Очередь может переполниться и вы потеряете ценнейшие логи, которые так никогда и не увидят белый свет.
Уровень 3: ( Oтправка в UART прерывании )
При вызове uart_send байты по-прежнему складировать в очередь TxFIFO. Отправлять байт в прерывании по окончанию отправки байта. По сути отправка происходит в прерывании.
bool UART_TxNext(const uint8_t num) {
bool res = false;
UartHandle_t* node = UartGetNode(num);
if(node) {
if(HAL_UART_STATE_READY == node->uart_h.gState) {
uint32_t outLen = 0;
res = FIFO_PullArray(&node->TxFifo,
node->txBlock,
sizeof(Node->txBlock),
&outLen);
if(res) {
HAL_StatusTypeDef ret = HAL_UART_Transmit_IT(&node->uart_h,
node->txBlock,
outLen);
res = HALretToRes(ret);
if(res) {
node->tx_start_ms = time_get_ms();
node->tx_done = false;
}
}
}
}
return res;
}
void HAL_UART_TxCpltCallback(UART_HandleTypeDef* pHandle) {
int8_t num = 0;
num = get_uart_index(pHandle->Instance);
UartHandle_t* node = UartGetNode(num);
if(Node) {
node->tx_done = true;
node->tx_cnt++;
UART_TxNext(num);
}
}
Но это даже не отправка, а отмашка на отправку. Пока байт отправляется процессор преспокойно себе возвращается в main() и делает какую-то по-настоящему нужную работу.
Плюсы
+1 Вы можете пользоваться логированием до инициализации UART трансивера. Байты просто будут лежать в очереди и ждать своего момента на отправку. Произойдет это тотчас же как освободится UART трансивер.
+2 Очередь постоянно и непрерывно освобождается. UART трансимвер пожирает TxFIFO непрерывно.
+3 Достоинство печати в очередь в том, что вы можете делать логирования даже в прерываниях. Ведь логирование просто сводится к заполнению очереди на отправку.
Минусы
1) Частые прерывания. В пределе UART прерывания происходят после отправки каждого байта. Ситуация осложняется тем, что в микроконтроллере могут быть настроены и включены все 18 UARTов.
2) Иной раз железо подводит. Ты отправляешь байт, а прерывание по окончании отправки не происходит. Приходится вводить time-out по которому надо сбрасывать флаг tx_in_progress=0. Можно запоминать uptime последней отправки (поставить тайм штамп). Если отправка не закончилась и прошел тайм-аут от тайм штампа, то надо брать и пере инициализировать uart-трансивер.
3) При единоразовом вваливании в лог циклопического куска (например вызов shell команды help) можно разом переполнить FIFO. Поэтому надо тут же добавить ожидание отправки, если в программном буфере TxFIFO не достаточно места, чтобы добавить туда ещё одно сообщение. В коде выглядит это так.
/*Wait until a free space appears in the Tx Queue*/
bool UART_WaitFifoSpace_LL(UartHandle_t* node, uint32_t size) {
bool res = false;
if(node->init_done) {
uint32_t cnt = 0;
uint32_t up_time_start = TIME_GetMs();
while(1) {
cnt++;
uint32_t spare = FIFO_GetSpare(&node->TxFifo);
if(size <= spare) {
res = true;
break;
}
uint32_t up_time = TIME_GetMs();
uint32_t diff = up_time - up_time_start;
if(200 < diff) {
res = false;
break;
}
}
} else {
res = false;
}
return res;
}
4) На время заполнения uart tx fifo следует отключить прерывания глобально (interrupt_control_all()). Создать критическую секцию. Иначе одновременная запись очереди где-то в main и чтение этой же очереди из прерывания по окончанию отправки повредят переменные и данные очереди. На выходе вы увидите испорченный лог.
bool UART_Send(const uint8_t num,
const uint8_t* const data,
uint32_t size) {
bool res = false;
if(size) {
if(data) {
UartHandle_t* Node = UART_GetNode(num);
if(Node) {
uart_wait_fifo_space_ll(Node, size);
res = INTERRUPT_СontrolAll(false);
res = FIFO_PushArray(&Node->TxFifo,
data,
size);
if(false == res) {
Node->tx_error_cnt++;
}
res = INTERRUPT_СontrolAll(true);
}
} else {
res = false;
}
} else {
res = false;
}
return res;
}
Еще критической секцией надо сделать функцию UART_Init(). Это на тот случай, если вы захотите пере инициализировать UART глубоко в runtime. Без этого возникнет race condition и ваша программа заклинит в обработчике прерываний по UART. .
Уровень 4 (отправка по DMA в прерывании)
Как снизить частоту прерываний? Классическое решение это активировать DMA. Если за время отправки байта очередь на отправку сильно разрослась и стала чрезмерно большая, то можно перекопировать её в отдельный глобальный массив и отправить по DMA. Это повысить производительность процессора.
UART не может отправлять чаще своей битовой скорости.
Чудес не бывает. Если очередь TxFIFO переполняется и байты вываливаются на пол, значит надо работать в этих направлениях:
1-увеличивать битовую скорость UART трансивера
2-увеличивать размер TxFIFO.
3-уменьшать количество бессмысленных логов из Вашей программы (прошивки)
Итоги
Итак, подытожим. Чтобы просто нормально и эффективно работать с UART Вам надо предварительно наворотить в своей прошивке целую кучу программных компонентов.
№ |
Программный компонент |
1 |
Активированный FPU |
2 |
Драйвер CLK тактирования, PLL |
3 |
Драйвер GPIO |
4 |
Драйвер DMA |
5 |
Драйвер аппаратного таймера (или SysTick) для получения тайм штампов |
6 |
Драйвер прерываний NVIC |
7 |
Драйвер UART |
8 |
Реализация FIFO |
Нормально так, да? Как видите сами, чтобы просто по-человечески отправлять текст в UART надо написать мегатонны кода для CLOCK, GPIO, DMA, NVIC и забабахать безотказную FIFO-ху и разрулить race condition. Вот такое оно программирование микроконтроллеров. Казалось бы однопоточная прошивка, какой-то костный UART, а оказывается, внезапно, нужна синхронизация и критические секции как во взрослых RTOS-ах. Зато с нормальным логированием Вы будете редко вспоминать про эту медленную пошаговую GDB отладку.
Добавляйте логирование в свои прошивки!
Словарь
Сокращение |
Расшифровка |
DMA |
Direct memory access |
GPIO |
general-purpose input/output |
NVIC |
Nested Vectored Interrupt Controller |
FIFO |
first in, first out |
UART |
Universal Asynchronous Receiver/Transmitter |
Источники
@Katbert Как наш shell похорошел
@Helius консоль в микроконтроллере с micro readline
@aabzel Почему Нам Нужен UART-Shell?
@Corviniol Command line interpreter на микроконтроллере своими руками
Реализация очереди
Комментарии (27)

igorek_tm
26.12.2025 20:06Рискну посоветовать две вещи:
Первая (очевидная, да, кэп!) - не использовать один и тот же UART для логов и для связи с чем-то другим. Все-таки stdin, stdout и stderr это разные файлы, даром, что они почти всегда прицеплены к консоли.Вторая: попробуйте пользоваться вызовами RTOS. По крайней мере FreeRTOS на серии ESPxx сама расставит мьютексы при инициализации драйвера и две задачи не попытаются писать в один UART каждая свое. Другими словами, многое из ПО системного уровня уже написано до нас ))

danil_12345 Автор
26.12.2025 20:06Мне на работе запрещено использовать FreeRTOS так как это "не доверенный код".

BobovorTheCommentBeast
26.12.2025 20:06Все конечно же ради безопасности и надежности. В прочем, ничего нового.

DanilinS
26.12.2025 20:06А Eclipse ThreadX (ThreadX) относится к доверенному коду?

danil_12345 Автор
26.12.2025 20:06Хороший вопрос. Пока у нас разрешена только SafeRTOS

DanilinS
26.12.2025 20:06Если что - Eclipse ThreadX (ThreadX) сертифицирована для критически важных приложений безопасности.
The following standards have been used for certification:
IEC 61508-3:2010; clause 7.4.2.12, route 3S
IEC 62304:2015
ISO 26262-8:2018; clause 12
EN 50128:2011; clause 7.3.4.7

danil_12345 Автор
26.12.2025 20:06сама расставит мьютексы при инициализации драйвера и две задачи не попытаются писать в один UART каждая свое.
У меня NoRTOS прошивка.

danil_12345 Автор
26.12.2025 20:06Первая (очевидная, да, кэп!) - не использовать один и тот же UART для логов и для связи с чем-то другим.
Как же тогда один кабель Ethernet используется для сотен протоколов внутри трафика?

igorek_tm
26.12.2025 20:06Обычно это делается при помощи драйверов разного уровня. L2, канальный уровень, выше него L3 и т.д., все эти stdout и stderr - они значительно выше. Я бы самостоятельно писать TCP/IP стэк на уровне "отправь блок в контроллер eth - получи блок из контроллера eth" не стал, долго это и трудоемко, если качественно делать, а самое главное, что оно уже готовое есть, написано. Разобраться как написано и адаптировать при необходимости - надо, а вот с нуля писать ...

BobovorTheCommentBeast
26.12.2025 20:06Уже сделано это не про эмбеддед, кмк. Вообще культура переиспользования не развита почти никак, ввиду очень сильной зарегулированности и традиционной ригидности.
Единственное, где оно развито в А-word,. Его не то, что бы любят, но, что я видел в либах, оно там часто реально лучше написано, чем стандартный код стандартного встраиваемого деда.

igorek_tm
26.12.2025 20:06Спасибо за развернутый ответ, я "в теме", я сам эбмеддед. Но культура - не мы ли сами ее такой делаем?
Позволю себе провести параллель: проблема использования неподходящих инструментов — как в известной истории Левши, где ружья кирпичом чистили, — уходит корнями не в технику, а в традицию. Но традиции меняются, когда им предлагают конкретную альтернативу.
Из собственного опыта: когда я был еще только кодером я сталкивался с подобными ограничениями. Ключевым оказался не спор, а бизнес-аргумент. Я приходил к руководству с двумя вариантами: "Мы можем получить надежный продукт через N месяцев, используя проверенные открытые решения, или потратить почти в два раза больше времени, создавая и отлаживая свой аналог с теми же рисками". Когда риски и сроки становятся измеримы, здравый смысл часто побеждает. И это неважно, руководство гражданское или военное, любому руководству важен результат вовремя.
Возможно, стоит подготовить подобный сравнительный анализ (трудоемкость, переносимость на будущие платформы, стоимость поддержки, надежность) для случая с драйвером? Иногда борьба за разумное — это не конфронтация, а просвещение.
Sun-ami
Есть много других вариантов организации отправки логов. Можно ждать отправки не всегда, а только в случае, если в программном буфере TxFIFO не достаточно места, чтобы добавить туда ещё одно сообщение, если позволяет логика программы, и с таймаутом. Можно вообще хранить сообщения об ошибках не в конечном виде (в данном случае текстовом, хотя возможны и много других вариантов), а в виде FIFO из специальных объектов с кодом ошибки, параметрами, и таймштампом, и преобразовывать их в выходной формат в одной из задач в суперцикле - это позволяет сильно экономить память на мелких микроконтроллерах. При этом число различных вариантов ошибок обычно ограничено, а если они повторяются быстрее отправки - сообщения о них можно объединять для экономии памяти. Ну а отправка по DMA вообще мало связана со способом добавления сообщений в TxFIFO - её можно использовать во множестве случаев, это больше зависит от используемого микроконтрроллера и скорости передачи данных. Разумеется, буфер TxFIFO должен быть выделен статически.
danil_12345 Автор
Гениально!
danil_12345 Автор
Да. У меня это выглядит так
rukhi7
зачем вообще это делать на микроконтроллерах? Логи наверно на большой ПК приходят, наверно там попроще будет
А из
можно при переполнении TxFIFO отправлять предопределенное сообщение о переполнении TxFIFO, и по факту предпринимать меры чтобы трафик логов не превышал возможности последовательного порта. Отладка инструмента отладки средствами этого инструмента отладки, у меня очень хорошо тоже работала.Иногда без этого прям не обойтись, потому что если сделать задержку при переполнении, то она обычно тайминги непредсказуемо ломает и понять что происходит бывает не возможно. Если тайминги сломались - это тоже ошибка и очень серьезная! Ее логировать надо в первую очередь.
Sun-ami
В моих проектах в основном так и сделано - преобразование в человекочитаемый формат выполняется на ПК. Но многим удобнее, когда для просмотра логов не нужно использовать специальный софт. В любом случае нужно сформировать пакеты, в пакетах всегда больше данных, чем нужно для внутреннего представления сообщения.
karenic
Да. Бинарные протоколы компактнее текстовых. Это факт.