Отладочный вывод на микроконтроллерах: как Concepts и Ranges отправили мой printf на покой
Здравствуйте! Меня зовут Александр и я работаю программистом микроконтроллеров.
Начиная на работе новый проект, я привычно набрасывал в 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 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 BusInterfacetemplate
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(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 Uarttemplate
class Uart final : public BusInterface> {
private:
static constexpr BusMode mode = Mode;
void send_block (const char arr[], const index_t num) noexcept{
HAL_UART_Transmit(
&huart,
bit_cast(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(arr),
std::uint16_t(num)
);
}
void send_dma (const char arr[], const index_t num) noexcept {
HAL_UART_Transmit_DMA(
&huart,
bit_cast(arr),
std::uint16_t(num)
);
}
friend class BusInterface>;
friend class BusInterface>;
friend class BusInterface>;
};
По аналогии можно реализовать классs и других протоколов, поддерживаемых микроконтроллером, заменив в методах send_block (), send_it () и send_dma () соответствующие функции HAL. Если протокол передачи данных поддерживает не все режимы, тогда соответствующий метод просто не определяем.
И в завершении этой части заведем короткие алиасы итогового класса Uart:
using UartBlocking = BusInterface>;
using UartIt = BusInterface>;
using UartDma = BusInterface>;
Отлично, теперь разработаем класс потока вывода:
class StreamBasetemplate
class StreamBase final: public StreamStorage
{
public:
using bus_t = Bus;
using stream_t = StreamBase;
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){
*this << val;
} else if (num_type || std::floating_point){
*this << val << Delim;
}
});
return *this;
}
private:
void flush (void) {
bus.send(buffer.data(), std::distance(buffer.begin(), it));
it = buffer.begin();
}
std::span buffer{storage};
std::span::iterator it{buffer.begin()};
bus_t bus;
};
Рассмотрим подробнее его значимые части.
Шаблон класса параметризуется классом протокола, значением Delim типа char и наследуется от класса StreamStorage. Единственная задача последнего — предоставить доступ к массиву char, в котором будут формироваться строки вывода в неблокирующем режиме. Имплементацию здесь не привожу, она вторична к рассматриваемой теме; оставляю на ваше усмотрение или утяните из моего примера в конце статьи. Для удобной и безопасной работы с этим массивом (в примере — storage) мы заведем два приватных члена класса:
std::span buffer{storage};
std::span::iterator it{buffer.begin()};
Delim — разделитель между значениями чисел при выводе содержимого массивов/контейнеров.
Публичные методы класса — это четыре перегрузки operator<<. Три из них — для вывода базовых типов, с которыми наш интерфейс будет работать (char, float и integral type), а четвертая — для вывода содержимого массивов и стандартных контейнеров.
Вот здесь начинается самая вкуснота.
Каждая перегрузка оператора вывода — фактически шаблонная функция, в которой шаблонный параметр ограничен требованиями указанного концепта. Я использую собственные концепты char_type, num_type…
template
concept char_type = std::same_as;
template
concept num_type = std::integral && !char_type;
… и концепты из стандартной библиотеки — 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;
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 мкс.
Как-то так.
Спасибо за внимание, буду рад отзывам и замечаниям, а также идеям и примерам, как это безобразие можно улучшить.