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

  • Tutorial

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

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

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

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

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

float raw[] = {3.1416, 2.7183, 1.618};
array<int, 3> arr{123, 456, 789};

cout << int{2021}       << '\n'
     << float{9.806}    << '\n'
     << raw             << '\n'
     << arr             << '\n'
     << "Hello, Habr!"  << '\n'
     << ("esreveR me!" | views::take(7) | views::reverse ) << '\n';

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

Реализуем на С++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,
};

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

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 {}
};

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

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

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>>;
};

По аналогии можно реализовать классы и других протоколов, поддерживаемых микроконтроллером, заменив в методах 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>>;

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

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;
}; 

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

Шаблон класса параметризуется классом протокола, значением 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!\000"
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 мкс.

Как-то так.

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

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

    +4

    Неужели cout удобно использовать для отладочного вывода?

      0
      Я давно перешел на fmt, которая поддерживай цветной вывод в консоль из коробки, есть еще spdlog, основанный на fmt
        0
        Да, как gnu-arm начнет его поддерживать, как часть 20-го стандарта, обязательно попробую.
        0
        Доброе утро! А разве пример в самом начале статьи не очень убедительно это показывает? Когда единственный оператор вывода из двух печатных знаков работает со всеми форматами чисел, строковыми литералами и контейнерами, а также трансформациями над ними? Или я не совсем понял ваше сомнение? Тогда попрошу пример удобного на Ваш взгляд варианта. Спасибо за отзыв!
          +1

          Доброе.

          Неубедительно. У cout есть ровно одно преимущество – type safety. Но ведь type-safe аналогам printf сто лет в обед.

          А конкретно для логгинга ещё есть всякие удобные макросы, позволяющие из вызова LOG(a,b) сделать печать "a=..., b=...".

        +1
        код примера выстреливается за 9,2 мкс

        Время работы физики определяется выбранным интерфейсом, и не является качественным показателем. Так-же как и режим блокирования основной программы, просто это нужно делать иначе — использовать внешний буфер для дма при работе с физикой, копировать туда данные для передачи, и обновлять задание для дма. Само дма полностью на прерываниях, связь через задания.
        моё github.com/AVI-crak/Rtos_cortex/blob/master/sPrint.h
        физики там нет, время преобразования в строку для одинарной или двойной точности 2us,
        F7 на частоте 216MHz.
        А с кодом вы что-то намудрили H7 умеет прекрасно умножать и делить, и не только целые числа. При этом печать массивов — самая большая глупость что можно придумать, такие данные оформляются в сырые пакеты, и отдаются как есть.
          +1
          AVI-crak, доброе утро! Признателен за ваше внимание к моей статье. Когда около 3-х лет назад я решил сменить профессию и засел за изучение МК и С/С++, неоднократно встречал ваши заметки в разных сообществах. Нередко они помогали разобраться в тех или иных вопросах.
          Теперь по существу вашего отзыва:
          … нужно делать иначе — использовать внешний буфер для дма при работе с физикой...
          Так у меня ровно так и реализовано.
          H7 умеет прекрасно умножать и делить, и не только целые числа.
          Бесспорно. Вот только основной посыл моей статьи был о том, как заменить именно интерфейс printf-а, используя возможности самого последнего, 20-го плюсового стандарта. И честно указал, что позаимствовал алгоритмы и код конвертации в строку. Можно ли переписать и улучшить заимствованный код с учетом эволюции камней? Весьма вероятно, просто не это основная тема конкретно этой статьи. Ссылку на вашую имплементацию обязательно посмотрю, поэкспериментирую.
          При этом печать массивов — самая большая глупость что можно придумать...
          Резковато, конечно, но почему? Разве не удобно, напечатав два символа <<, быстро вывести в терминал фрагмент массива с «сырыми» данными любого числового типа? Или, использовав функционал views, прямо в операторе вывода сразу как-то отфильтровать их, выделить поддиапазон etc.?
          … время преобразования в строку для одинарной или двойной точности 2us...
          У меня за 9 мкс выводится не одно значение, а пример суммарно с 8 значениями разных типов, 2 строковых литерала с операциями выделения поддиапазона и реверса. Вы воспроизвели эти же условия?
          Ну и еще раз подчеркну, цель у меня была понять, что могут дать современные плюсы с 20-м стандартом в задаче замены интерфейса printf-a. Насколько универсальным, лаконичным, эффективным и эффектным может выглядеть решение. На мой взгляд, чуть менее 180 строк кода, чтобы получить вполне функциональный аналог стандартного stream-а для embbed-a, это неплохо.
          0

          Как бы ещё прикрутить c++20 к Eclipse...

            0
            Не знаю, как в eclipse, но в VSCode+PlatformIO я просто руками заменил папку тулчейна и все заработало. Мб и у вас таким образом получится.
              0
              Очень легко.
              «Eclipse Embedded CDT is an open source project that includes a family of Eclipse plug-ins and tools for multi-platform embedded cross (Arm and RISC-V) development, based on GNU toolchains»

              eclipse-embed-cdt.github.io
              xpack.github.io/arm-none-eabi-gcc
              0
              Вопрос! SWO к DMA никак не прикрутить?
                0

                del, не заметил ответа ниже

                0
                Ну, фактически отправка символа по SWO это запись в регистр ITM->PORT. А ДМА в режиме MEM_TO_PERIPH для этого и предназначено. Другой вопрос, дотянется ли он до адреса 0xE0000000 (ITM). Не задумывался над этим… Сомневаюсь, но можно будет полюбопытствовать в RM.
                  0

                  а триггер что из ячейки данные утекли в ПК? ITM вряд ли умеет пинать DMA на предмет " я все, давай ещё"

                  там скорости не слишком то и большие были. Как в данном случае проверить, что DMA не перетрет данные?

                  0
                  Saalur
                  Подмена интерфейса выхлопа cout — замечательно. Но меня не радует способ реализации. Использовать наработки урезанных по функционалу мк в виде базовых — очень плохая идея.
                  Хуже может быть только переключение типа ядра в готовой собранной библиотеке. Есть такие товарищи, и вы с ними обязательно столкнётесь.
                  Для совместной печати в ITM интерфейс — можно принудительно назначить номер порта. Это поможет избежать каши, но не уберёт взаимную блокировку. Кстати, это достаточно удобно.
                  Совместная печать через физику без деления на каналы — всегда головная боль. Нормального решения нет.

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

                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                  Самое читаемое