Отладочный вывод на микроконтроллерах: как Concepts и Ranges отправили мой printf на покой

МЕНЮ


Главная страница
Поиск
Регистрация на сайте
Помощь проекту
Архив новостей

ТЕМЫ


Новости ИИРазработка ИИВнедрение ИИРабота разума и сознаниеМодель мозгаРобототехника, БПЛАТрансгуманизмОбработка текстаТеория эволюцииДополненная реальностьЖелезоКиберугрозыНаучный мирИТ индустрияРазработка ПОТеория информацииМатематикаЦифровая экономика

Авторизация



RSS


RSS новости


Здравствуйте! Меня зовут Александр, и я работаю программистом микроконтроллеров.

Начиная на работе новый проект, я привычно набрасывал в project tree исходники всяческих полезных утилит. И на хедере app_debug.h несколько подзавис.

Мы опубликовали и перевели эту статью с разрешения правообладателя. Автор статьи – Александр Сажин (Ник - Saalur, email - a.y.sazhin@gmail.com). Оригинал опубликован на сайте Habr.

Дело в том, что в декабре прошлого года у GNU Arm Embedded Toolchain вышел релиз 10-2020-q4-major, включающий все GCC 10.2 features, а значит и поддержку Concepts, Ranges, Coroutines вкупе с другими, менее "громкими" новинками С++20.

Воодушевленное новым стандартом воображение рисовало мой будущий С++ код ультрасовременным и лаконично-поэтичным. И старый, добрый printf("Debug message ") в это благостное видение не очень-то вписывался.

Хотелось бескомпромиссной плюсовой функциональности и стандартных удобств!

float raw[] = {3.1416, 2.7183, 1.618}; array<int, 3> arr{123, 456, 789};  cout << int{2021}       << ' '      << float{9.806}    << ' '      << raw             << ' '      << arr             << ' '      << "Hello, Habr!"  << ' '      << ("esreveR me!" | views::take(7) | views::reverse ) << ' ';

Ну а если хочется хорошего, зачем же себе отказывать?

Реализуем на С++20 интерфейс потока для отладочного вывода МК, поддерживающий любой подходящий протокол, предусмотренный вендром камня. Легковесный и быстрый, без бойлерплейта. Поддерживающий как блокирующий посимвольный вывод - для нечувствительных к времени выполнения участков кода, так и неблокирующий, для быстрых функций.

Зададим для комфортного чтения кода несколько удобных алиасов:

using base_t = std::uint32_t; using fast_t = std::uint_fast32_t; using index_t = std::size_t;

Как известно, в микроконтроллерах неблокирующие алгоритмы передачи данных реализуются на прерываниях и DMA. Для идентификации режимов вывода заведем enum:

enum class BusMode{   BLOCKING,   IT,   DMA, };

Опишем базовый класс, реализующий логику протоколов, ответственных за отладочный вывод:

[НАЧАЛО БЛОКА SPOILER]

class BusInterface

template<typename T> class BusInterface{  public:    using derived_ptr = T*;        static constexpr BusMode mode = T::mode;    void send (const char arr[], index_t num) noexcept {      if constexpr (BusMode::BLOCKING == mode){        derived()->send_block(arr, num);      } else if (BusMode::IT == mode){        derived()->send_it(arr, num);      } else if (BusMode::DMA == mode){        derived()->send_dma(arr, num);     }   }  private:    derived_ptr derived(void) noexcept{     return static_cast<derived_ptr>(this);   }    void send_block (const char arr[], const index_t num) noexcept {}    void send_it (const char arr[], const index_t num) noexcept {}    void send_dma (const char arr[], const index_t num) noexcept {} };

[КОНЕЦ БЛОКА SPOILER]

Класс реализован по паттерну CRTP, что дает нам преимущества полиморфизма времени компиляции. Класс содержит единственный публичный метод send(), в котором на этапе компиляции, в зависимости от режима вывода, выбирается нужный метод. В качестве аргументов метод принимает указатель на буфер с данными и его полезный размер. На моей практике это самый распространенный формат аргументов в HAL-функциях вендоров МК.

И тогда, например, класс Uart, наследуемый от данного базового класса, будет выглядеть примерно так:

[НАЧАЛО БЛОКА SPOILER]

class Uart

template<BusMode Mode> class Uart final : public BusInterface<Uart<Mode>> {  private:    static constexpr BusMode mode = Mode;    void send_block (const char arr[], const index_t num) noexcept{      HAL_UART_Transmit(         &huart,         bit_cast<std::uint8_t*>(arr),         std::uint16_t(num),         base_t{5000}     );   }      void send_it (const char arr[], const index_t num) noexcept {      HAL_UART_Transmit_IT(           &huart,           bit_cast<std::uint8_t*>(arr),           std::uint16_t(num)     );   }    void send_dma (const char arr[], const index_t num) noexcept {      HAL_UART_Transmit_DMA(           &huart,           bit_cast<std::uint8_t*>(arr),           std::uint16_t(num)     );   }    friend class BusInterface<Uart<BusMode::BLOCKING>>;   friend class BusInterface<Uart<BusMode::IT>>;   friend class BusInterface<Uart<BusMode::DMA>>; };

[КОНЕЦ БЛОКА SPOILER]

По аналогии можно реализовать классы и других протоколов, поддерживаемых микроконтроллером, заменив в методах send_block(), send_it() и send_dma() соответствующие функции HAL. Если протокол передачи данных поддерживает не все режимы, тогда соответствующий метод просто не определяем.

И в завершении этой части заведем короткие алиасы итогового класса Uart:

using UartBlocking = BusInterface<Uart<BusMode::BLOCKING>>; using UartIt = BusInterface<Uart<BusMode::IT>>; using UartDma = BusInterface<Uart<BusMode::DMA>>;

Отлично, теперь разработаем класс потока вывода:

[НАЧАЛО БЛОКА SPOILER]

class StreamBase

template <class Bus, char Delim> class StreamBase final: public StreamStorage {  public:    using bus_t = Bus;   using stream_t = StreamBase<Bus, Delim>;    static constexpr BusMode mode = bus_t::mode;    StreamBase() = default;   ~StreamBase(){ if constexpr (BusMode::BLOCKING != mode) flush(); }   StreamBase(const StreamBase&) = delete;   StreamBase& operator= (const StreamBase&) = delete;    stream_t& operator << (const char_type auto c){      if constexpr (BusMode::BLOCKING == mode){        bus.send(&c, 1);      } else {        *it = c;       it = std::next(it);     }     return *this;   }    stream_t& operator << (const std::floating_point auto f){      if constexpr (BusMode::BLOCKING == mode){        auto [ptr, cnt] = NumConvert::to_string_float(f, buffer.data());        bus.send(ptr, cnt);      } else {        auto [ptr, cnt] = NumConvert::to_string_float(         f, buffer.data() + std::distance(buffer.begin(), it));        it = std::next(it, cnt);     }     return *this;   }    stream_t& operator << (const num_type auto n){      auto [ptr, cnt] = NumConvert::to_string_integer( n, &buffer.back() );      if constexpr (BusMode::BLOCKING == mode){        bus.send(ptr, cnt);      } else {        auto src = std::prev(buffer.end(), cnt + 1);        it = std::copy(src, buffer.end(), it);     }     return *this;   }    stream_t& operator << (const std::ranges::range auto& r){          std::ranges::for_each(r, [this](const auto val) {                          if constexpr (char_type<decltype(val)>){                              *this << val;              } else if (num_type<decltype(val)>        || std::floating_point<decltype(val)>){                  *this << val << Delim;             }         });     return *this;   }  private:    void flush (void) {      bus.send(buffer.data(),              std::distance(buffer.begin(), it));      it = buffer.begin();   }    std::span<char> buffer{storage};   std::span<char>::iterator it{buffer.begin()};    bus_t bus; };

[КОНЕЦ БЛОКА SPOILER]

Рассмотрим подробнее его значимые части.

Шаблон класса параметризуется классом протокола, значением Delim типа char и наследуется от класса StreamStorage. Единственная задача последнего - предоставить доступ к массиву char, в котором будут формироваться строки вывода в неблокирующем режиме. Имплементацию здесь не привожу, она вторична к рассматриваемой теме; оставляю на ваше усмотрение или утяните из моего примера в конце статьи. Для удобной и безопасной работы с этим массивом (в примере - storage) мы заведем два приватных члена класса:

std::span<char> buffer{storage}; std::span<char>::iterator it{buffer.begin()};

Delim - разделитель между значениями чисел при выводе содержимого массивов/контейнеров.

Публичные методы класса - это четыре перегрузки operator<<. Три из них - для вывода базовых типов, с которыми наш интерфейс будет работать (char, float и integral type), а четвертая - для вывода содержимого массивов и стандартных контейнеров.

Вот здесь начинается самая вкуснота.

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

template <typename T> concept char_type = std::same_as<T, char>;  template <typename T> concept num_type = std::integral<T> && !char_type<T>;

... и концепты из стандартной библиотеки - std::floating_point и std::ranges::range.

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

Логика внутри каждого оператора вывода базового типа проста. В зависимости от режима вывода (блокирующий / не блокирующий) мы или сразу отправляем символ на печать, либо формируем в буфере потока строку. И в момент выхода из функции объект нашего потока разрушается, вызывается деструктор, где приватный метод flush() отправляет заготовленную строку на печать в режиме IT или DMA.

При конвертации числового значения в массив char-ов я отказался от известной идиомы с snprintf() в пользу наработок neiver. Автор в своих публикациях показывает заметное превосходство предложенных им алгоритмов конвертации чисел в строку как в размере бинарника, так и в скорости преобразования. Позаимствованный у него код я инкапсулировал в классе NumConvert, содержащем методы to_string_integer() и to_string_float().

В перегрузке оператора вывода данных массива/контейнера мы с помощью стандартного алгоритма std::ranges::for_each() пробегаемся по содержимому рэйнджа и если элемент удовлетворяет концепту char_type, выводим строку слитно. Если же удовлетворяет концептам num_type или std::floating_point, разделяем значения с помощью заданного значения Delim.

Ну хорошо, мы тут наворотили шаблонов, концептов и прочей плюсовой тяжелой артиллерии. Это ж какой длины мы получим ассемблерную портянку на выходе? Посмотрим два примера:

int main() {      using StreamUartBlocking = StreamBase<UartBlocking, ' '>;      StreamUartBlocking cout;      cout << 'A'; // 1   cout << ("esreveR me!" | std::views::take(7) | std::views::reverse); // 2      return 0; }

Выставим флаги компилятора: -std=gnu++20 -Os -fno-exceptions -fno-rtti. Тогда на первом примере мы получим следующий ассемблерный листинг:

main:         push    {r3, lr}         movs    r0, #65         bl      putchar         movs    r0, #0         pop     {r3, pc}

На втором:

.LC0:         .ascii  "esreveR me!00" main:         push    {r3, r4, r5, lr}         ldr     r5, .L4         movs    r4, #5 .L3:         subs    r4, r4, #1         bcc     .L2         ldrb    r0, [r5, r4]    @ zero_extendqisi2         bl      putchar         b       .L3 .L2:         movs    r0, #0         pop     {r3, r4, r5, pc} .L4:         .word   .LC0

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

Конечно же, при выводе числовых значений, добавится еще код конвертации числа в строку.

Потестировать онлайн можно здесь (hardware dependent код заменил для наглядности на putchar() ).

Рабочий код проекта смотрите/забирайте отсюда. Там реализован пример из начала статьи.

Это стартовый вариант, для уверенного использования еще требуются некоторые доработки и тесты. Например, нужно предусмотреть механизм синхронизации при неблокирующем выводе - когда, скажем, вывод данных предыдущей функции еще не завершен, а мы в следующей функции уже переписываем буфер новой информацией. Также нужно еще внимательно поэкспериментровать с алгоритмами std::views. Например std::views::drop() при применении ее к строковому литералу или массиву char-ов, взрывается ошибкой "inconsistent directions for distance and bound". Ну что ж, стандарт новый, со временем освоим.

Как это работает можно посмотреть здесь. Проект поднят на двухядерном STM32H745; с одного ядра (480МГц) вывод идет в блокирующем режиме через отладочный интерфейс SWO, код примера выстреливается за 9,2 мкс, со второго(240МГц) - через Uart в режиме DMA, примерно за 20 мкс.

Как-то так.

Спасибо за внимание, буду рад отзывам и замечаниям, а также идеям и примерам, как это безобразие можно улучшить.


Источник: pvs-studio.com

Комментарии: