マイクロコントローラーでのデバッグ出力:Concepts and Ranges で printf を停止する方法

こんにちは!私の名前はアレクサンダーです。マイクロコントローラーの開発者として働いています。

仕事で新しいプロジェクトを開始するとき、私は習慣的にあらゆる種類の便利なユーティリティのソース ファイルをプロジェクト ツリーに追加しました。ヘッダーの app_debug.h 少し固まった。

著作権者の許可を得て、この記事を公開および翻訳しました。著者は Alexander Sazhin (ニックネーム - Saalur、メール - [email protected]) です。この記事はもともと Habr に掲載されたものです。

ご覧のとおり、昨年 12 月に GNU Arm Embedded Toolchain がリリースされた 10-2020-q4-major には、GCC 10.2 のすべての機能が含まれていたため、コンセプト、範囲、コルーチン、およびその他のあまり目立たない C++20 の新機能がサポートされていました。

新しい標準に触発された私の想像力は、私の将来の C++ コードを超近代的で簡潔で詩的なものとして描きました。古き良き printf("Debug message\n") この楽しい計画には本当に合いませんでした.

妥協のない C++ 機能と標準の使いやすさの組み合わせが必要でした!

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

ええと、何か良いものが欲しいのなら、なぜ自分を否定するのでしょうか?

マイクロコントローラのベンダーが提供する適切なプロトコルをサポートする MCU で出力をデバッグするために、C++20 でストリームのインターフェイスを実装しましょう。定型コードなしで、軽量で高速でなければなりません。このようなスレッド インターフェイスは、時間に依存しないコード セクションのブロック文字出力と、高速関数の非ブロックの両方もサポートする必要があります。

コードを読みやすくするために、いくつかの便利なエイリアスを設定しましょう:

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

知られているように、マイクロコントローラでは、ノンブロッキング データ転送アルゴリズムが割り込みと DMA によって実装されます。出力モードを識別するために、列挙型を作成しましょう:

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

デバッグ出力を担当するプロトコルのロジックを実装する基本クラスについて説明しましょう:

[スポイラーブロック開始]

クラス バス インターフェイス

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 パターンで実装されているため、コンパイル時のポリモーフィズムの利点が得られます。このクラスには、単一の public send() が含まれています 方法。この方法では、コンパイル段階で、出力モードに応じて必要な方法が選択されます。このメソッドは引数として、データ バッファーへのポインターとその有効なサイズを受け取ります。私の実践では、これは MCU ベンダーの HAL 関数で最も一般的な引数形式です。

そして、たとえばUart この基本クラスから継承されたクラスは次のようになります:

[スポイラーブロック開始]

クラス 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() 内の対応する HAL 関数を置き換えるだけです , send_it() およびsend_dma() メソッド。データ転送プロトコルがすべてのモードをサポートしていない場合、対応するメソッドは単に定義されていません。

記事のこの部分を締めくくるために、最終的な Uart クラスの短いエイリアスを作成しましょう:

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

それでは、出力スレッド クラスを作成しましょう:

[スポイラーブロック開始]

クラス 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;
};

[スポイラーブロック終了]

その重要な部分を詳しく見てみましょう。

クラス テンプレートは、プロトコル クラス (char の Delim の値) によってパラメータ化されます。 タイプ。このクラス テンプレートは StreamStorage から継承されます クラス。後者の唯一のタスクは、char へのアクセスを提供することです。 出力文字列がノンブロッキング モードで形成される配列。ここでは実装を示しません。目前のトピックとはあまり関係ありません。それはあなた次第です。記事の最後にある私の例をチェックしてください。この配列 (例ではストレージ) を便利かつ安全に操作するために、2 つのプライベート クラス メンバーを作成しましょう:

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

Delim は、配列/コンテナーの内容を表示するときの数値の値の間の区切り文字です。

クラスの public メソッドは 4 つの operator<< です。 過負荷。そのうちの 3 つは、インターフェイスが動作する基本的な型を示しています (charフロート、 および積分 タイプ )。 4 番目のものは、配列と標準コンテナーの内容を表示します。

ここから最もエキサイティングな部分が始まります。

各出力演算子のオーバーロードは、指定された概念の要件によってテンプレート パラメーターが制限されるテンプレート関数です。自分の 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 .

基本的な型の概念により、あいまいなオーバーロードから保護され、範囲の概念と組み合わせることで、標準のコンテナーと配列に対して単一の出力アルゴリズムを実装できます。

各基本型出力演算子内のロジックは単純です。出力モード (ブロッキング/ノンブロッキング) に応じて、すぐに文字を送信して印刷するか、スレッド バッファーに文字列を形成します。関数を終了すると、スレッドのオブジェクトは破棄されます。デストラクタが呼び出され、プライベート flush() メソッドは準備された文字列を送信して、IT または DMA モードで印刷します。

数値を chars の配列に変換するとき、snprintf() でよく知られているイディオムをあきらめました neiver の [RU] プログラム ソリューションを支持します。著者は、彼の出版物で、バイナリのサイズと変換速度の両方で、数値を文字列に変換するために提案されたアルゴリズムの顕著な優位性を示しています。彼からコードを借りて、NumConvert にカプセル化しました。 to_string_integer() を含むクラス そして to_string_float() メソッド。

配列/コンテナ データ出力演算子のオーバーロードでは、標準の std::ranges::for_each() を使用します アルゴリズムと範囲の内容を調べます。要素が char_type を満たす場合 コンセプトでは、空白なしで文字列を出力します。要素が num_type を満たす場合 または std::floating_point 概念に基づいて、指定された Delim の値で値を区切ります。

ここでは、これらすべてのテンプレート、概念、およびその他の C++ の「重い」ものを使用して、すべてを非常に複雑にしています。では、出力でアセンブラからテキストの壁を取得しますか? 2 つの例を見てみましょう:

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}

そして 2 番目の例では:

.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

結果、なかなか良いと思います。通常の C++ スレッド インターフェイス、数値の便利な出力、コンテナー/配列を取得しました。また、出力シグネチャで範囲処理を直接取得しました。そして、これらすべてを事実上ゼロのオーバーヘッドで実現しました。

もちろん、数値の出力時には数値を文字列に変換する別のコードが追加されます。

ここでオンラインでテストできます (わかりやすくするために、ハードウェアに依存するコードを putchar() に置き換えました) ).

ここからプロジェクトの作業コードを確認/借用できます。記事の最初の例がそこに実装されています。

これは最初のコード バリアントです。自信を持って使用するには、いくつかの改善とテストが必要です。たとえば、ノンブロッキング出力の同期メカニズムを提供する必要があります。前の関数のデータ出力がまだ完了しておらず、次の関数内で、既に新しい情報でバッファーを上書きしているとします。また、std::views を注意深く試す必要があります アルゴリズム。たとえば、std::views::drop() を適用すると、 文字列リテラルまたは文字の配列に対して、「距離と境界の方向が一致しません」というエラーがスローされます。標準は新しいものです。時間をかけて習得していきます。

ここでそれがどのように機能するかを見ることができます。このプロジェクトでは、デュアルコア STM32H745 マイクロコントローラーを使用しました。 1 つのコア (480MHz) から、出力は SWO デバッグ インターフェイスを介してブロック モードになります。例のコードは 9.2 マイクロ秒で実行され、2 番目のコア (240MHz) から DMA モードの Uart を介して約 20 マイクロ秒で実行されます。

ご清聴ありがとうございました。フィードバックやコメント、およびこの混乱を改善する方法のアイデアや例をいただければ幸いです。


No