«Во всех самолётах есть черный ящик. 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 отладку. Плюс UART всегда сохраняет link, если пере загрузить по питанию микроконтроллер, что не скажешь про SWD.

Добавляйте логирование в свои прошивки!

Словарь

Сокращение

Расшифровка

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 на микроконтроллере своими руками
Реализация очереди

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Вы работали с UART?
97.67%да42
2.33%нет1
Проголосовали 43 пользователя. Воздержался 1 пользователь.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Как вы отправляете данные в UART?
21.05%Отправка и ожидание окончания отправки. (polling)8
7.89%Запись в очередь и отправка в супер цикле из очереди3
36.84%Запись в очередь и отправка из прерывания по окончанию отправки по UART14
26.32%Запись в очередь и отправка по DMA из прерывания по окончанию отправки по DMA10
7.89%Отправляю в UART из определенного глобально массива фиксированной длинны3
Проголосовали 38 пользователей. Воздержались 4 пользователя.