«Во всех самолётах есть черный ящик. 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 памяти. Результат - непредсказуемое значение.

Критическая секция — участок кода, выполнение которого не должно быть прервано прерываниями процессора.

Вот пожалуй и все определения, которые сегодня потребуются.

В чем проблема?

  1. Неясно куда писать логи, когда UART трансивер еще не проинициализирован. Надо же как-то узнать причину ошибки при инициализации подсистемы тактирования или во время инициализации SysTick, Timer, VectorTable, GPIO. Вообще говоря, до настройки UART в прошивке происходит целая куча всяческих действий про результат отработки которых хорошо бы получить строчку в логе загрузке прошивки. Это настройка Fpu, CORE, ISRTable, Hal, Log, Time, Writer, Int, SysTick, Clk, GPIO, Flash. Хотелось бы про каждый программный компонент получить строчку со статусом выполнения в логе загрузки прошивки. Это же очевидно.

  2. Если вы будете отправлять локальный массив по UART (из стека), то отправляемые данные исказятся при выходе из функции и заходе в следующую. Ибо трансивер работает сам по себе, а код тоже исполняется сам по себе.

  3. Если выделять динамическую память для того, чтобы положить туда данные для UART отправки , тo это тоже не вариант, так как можно забыть освободить эту память. Да и использование malloc запрещается правилами MISRA.

  4. Если выделить статическую память фиксированного размера (например 64 байт), то непременно появится запрос отправить массив большей длинны (120 байт) и его некуда будет положить чтобы оттуда отправить.

  5. Как быть, если логи появляются быстрее, чем битовая скорость 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)


  1. Sun-ami
    26.12.2025 20:06

    Есть много других вариантов организации отправки логов. Можно ждать отправки не всегда, а только в случае, если в программном буфере TxFIFO не достаточно места, чтобы добавить туда ещё одно сообщение, если позволяет логика программы, и с таймаутом. Можно вообще хранить сообщения об ошибках не в конечном виде (в данном случае текстовом, хотя возможны и много других вариантов), а в виде FIFO из специальных объектов с кодом ошибки, параметрами, и таймштампом, и преобразовывать их в выходной формат в одной из задач в суперцикле - это позволяет сильно экономить память на мелких микроконтроллерах. При этом число различных вариантов ошибок обычно ограничено, а если они повторяются быстрее отправки - сообщения о них можно объединять для экономии памяти. Ну а отправка по DMA вообще мало связана со способом добавления сообщений в TxFIFO - её можно использовать во множестве случаев, это больше зависит от используемого микроконтрроллера и скорости передачи данных. Разумеется, буфер TxFIFO должен быть выделен статически.


    1. danil_12345 Автор
      26.12.2025 20:06

       При этом число различных вариантов ошибок обычно ограничено, а если они повторяются быстрее отправки - сообщения о них можно объединять для экономии памяти. 

      Гениально!


    1. danil_12345 Автор
      26.12.2025 20:06

      Можно ждать отправки не всегда, а только в случае, если в программном буфере 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;
      }


    1. rukhi7
      26.12.2025 20:06

      и преобразовывать их в выходной формат в одной из задач в суперцикле - это позволяет сильно экономить память на мелких микроконтроллерах.

      зачем вообще это делать на микроконтроллерах? Логи наверно на большой ПК приходят, наверно там попроще будет

      преобразовывать их в выходной формат /!

      А из

      много других вариантов

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


      1. Sun-ami
        26.12.2025 20:06

        зачем вообще это делать на микроконтроллерах? Логи наверно на большой ПК приходят, наверно там попроще будет

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


        1. karenic
          26.12.2025 20:06

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

          Да. Бинарные протоколы компактнее текстовых. Это факт.


  1. igorek_tm
    26.12.2025 20:06

    Рискну посоветовать две вещи:
    Первая (очевидная, да, кэп!) - не использовать один и тот же UART для логов и для связи с чем-то другим. Все-таки stdin, stdout и stderr это разные файлы, даром, что они почти всегда прицеплены к консоли.

    Вторая: попробуйте пользоваться вызовами RTOS. По крайней мере FreeRTOS на серии ESPxx сама расставит мьютексы при инициализации драйвера и две задачи не попытаются писать в один UART каждая свое. Другими словами, многое из ПО системного уровня уже написано до нас ))


    1. danil_12345 Автор
      26.12.2025 20:06

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


      1. BobovorTheCommentBeast
        26.12.2025 20:06

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


      1. DanilinS
        26.12.2025 20:06

        А Eclipse ThreadX (ThreadX) относится к доверенному коду?


        1. danil_12345 Автор
          26.12.2025 20:06

          Хороший вопрос. Пока у нас разрешена только SafeRTOS


          1. 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


            1. danil_12345 Автор
              26.12.2025 20:06

              Спасибо.


      1. skyazimuth
        26.12.2025 20:06

        А HAL, значит, "доверенный код?


        1. aabzel
          26.12.2025 20:06

          Да. HAL от ST это кладезь багов.


    1. danil_12345 Автор
      26.12.2025 20:06

      сама расставит мьютексы при инициализации драйвера и две задачи не попытаются писать в один UART каждая свое. 

      У меня NoRTOS прошивка.


    1. danil_12345 Автор
      26.12.2025 20:06

      Первая (очевидная, да, кэп!) - не использовать один и тот же UART для логов и для связи с чем-то другим.

      Как же тогда один кабель Ethernet используется для сотен протоколов внутри трафика?


      1. igorek_tm
        26.12.2025 20:06

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


        1. BobovorTheCommentBeast
          26.12.2025 20:06

          Уже сделано это не про эмбеддед, кмк. Вообще культура переиспользования не развита почти никак, ввиду очень сильной зарегулированности и традиционной ригидности.

          Единственное, где оно развито в А-word,. Его не то, что бы любят, но, что я видел в либах, оно там часто реально лучше написано, чем стандартный код стандартного встраиваемого деда.


          1. igorek_tm
            26.12.2025 20:06

            Спасибо за развернутый ответ, я "в теме", я сам эбмеддед. Но культура - не мы ли сами ее такой делаем?

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

            Из собственного опыта: когда я был еще только кодером я сталкивался с подобными ограничениями. Ключевым оказался не спор, а бизнес-аргумент. Я приходил к руководству с двумя вариантами: "Мы можем получить надежный продукт через N месяцев, используя проверенные открытые решения, или потратить почти в два раза больше времени, создавая и отлаживая свой аналог с теми же рисками". Когда риски и сроки становятся измеримы, здравый смысл часто побеждает. И это неважно, руководство гражданское или военное, любому руководству важен результат вовремя.

            Возможно, стоит подготовить подобный сравнительный анализ (трудоемкость, переносимость на будущие платформы, стоимость поддержки, надежность) для случая с драйвером? Иногда борьба за разумное — это не конфронтация, а просвещение.