Output di debug sui microcontrollori:come Concepts and Ranges mette a riposo il mio printf

Output di debug sui microcontrollori:come Concepts and Ranges mette a riposo il mio printf

Ciao! Mi chiamo Alexander e lavoro come sviluppatore di microcontrollori.

Quando iniziavo un nuovo progetto al lavoro, aggiungevo abitualmente i file sorgente di tutti i tipi di utili utilità all'albero del progetto. E nell'intestazione, app_debug.h si è bloccato per un po'.

Abbiamo pubblicato e tradotto questo articolo con il permesso del titolare del copyright. L'autore è Alexander Sazhin (soprannome - Saalur, e-mail - [email protected]). L'articolo è stato originariamente pubblicato su Habr.

Vedete, lo scorso dicembre, GNU Arm Embedded Toolchain ha rilasciato 10-2020-q4-major, che includeva tutte le funzionalità di GCC 10.2 e quindi supportava concetti, intervalli, coroutine e altre novità C++20 meno importanti.

Ispirata dal nuovo standard, la mia immaginazione ha rappresentato il mio futuro codice C++ come ultramoderno, conciso e poetico. E il buon vecchio printf("Messaggio di debug\n") non rientrava davvero in questo gioioso piano.

Volevo la combinazione di funzionalità C++ senza compromessi e usabilità dello standard!

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

Bene, se vuoi qualcosa di buono, perché rinnegare te stesso?

Implementiamo un'interfaccia del flusso in C++ 20 per il debug dell'output su MCU che supporti qualsiasi protocollo adatto fornito dal fornitore del microcontrollore. Dovrebbe essere leggero e veloce, senza codice standard. Tale interfaccia thread dovrebbe anche supportare sia l'output dei caratteri di blocco per sezioni di codice che non richiedono tempo, sia il non blocco, per funzioni veloci.

Impostiamo diversi alias convenienti per rendere il codice comodo da leggere:

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

Come è noto, nei microcontrollori, gli algoritmi di trasferimento dati non bloccanti sono implementati da interrupt e DMA. Per identificare le modalità di output, creiamo enum:

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

Descriviamo una classe base che implementa la logica dei protocolli responsabili dell'output di debug:

[INIZIO BLOCCO SPOILER]

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

[BLOCCO SPOILER TERMINA]

La classe è implementata con il pattern CRTP, che ci offre i vantaggi del polimorfismo in fase di compilazione. La classe contiene un singolo send() pubblico metodo. In questo metodo, in fase di compilazione, a seconda della modalità di output, viene selezionato il metodo necessario. Come argomenti, il metodo prende un puntatore al buffer di dati e alla sua dimensione utile. Nella mia pratica, questo è il formato di argomento più comune nelle funzioni HAL dei fornitori di MCU.

E poi, ad esempio, l'Uart la classe ereditata da questa classe base avrà un aspetto simile a questo:

[INIZIO BLOCCO SPOILER]

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

[BLOCCO SPOILER TERMINA]

Per analogia, si possono implementare classi di altri protocolli supportati dal microcontrollore. Basta sostituire le corrispondenti funzioni HAL in send_block() , send_it() e send_dma() metodi. Se il protocollo di trasferimento dati non supporta tutte le modalità, il metodo corrispondente semplicemente non è definito.

E per concludere questa parte dell'articolo, creiamo brevi alias della classe Uart finale:

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

Ottimo, ora sviluppiamo la classe del thread di output:

[INIZIO BLOCCO SPOILER]

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

[BLOCCO SPOILER TERMINA]

Diamo un'occhiata più da vicino alle sue parti significative.

Il modello di classe è parametrizzato dalla classe del protocollo, il valore di Delim del char genere. Questo modello di classe è ereditato da StreamStorage classe. L'unico compito di quest'ultimo è fornire l'accesso al char array, in cui le stringhe di output sono formate in modalità non bloccante. Non sto fornendo l'implementazione qui, non è abbastanza rilevante per l'argomento in questione. Sta a te controllare il mio esempio alla fine dell'articolo. Per un funzionamento comodo e sicuro con questo array (nell'esempio - storage), creiamo due membri della classe privata:

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

Delim è un delimitatore tra i valori dei numeri durante la visualizzazione del contenuto di array/contenitori.

I metodi pubblici della classe sono quattro operator<< sovraccarichi. Tre di essi mostrano i tipi di base con cui la nostra interfaccia funzionerà (char , galleggiante, e integrale digitare ). Il quarto mostra il contenuto di array e contenitori standard.

Ed è qui che inizia la parte più emozionante.

Ogni sovraccarico dell'operatore di output è una funzione del modello in cui il parametro del modello è limitato dai requisiti del concetto specificato. Uso il mio char_type , tipo_num concetti...

template <typename T>
concept char_type = std::same_as<T, char>;

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

... e concetti dalla libreria standard - std::floating_point e std::ranges::range .

I concetti di tipo di base ci proteggono da sovraccarichi ambigui e, in combinazione con il concetto di intervallo, ci consentono di implementare un unico algoritmo di output per qualsiasi contenitore e array standard.

La logica all'interno di ciascun operatore di output di tipo base è semplice. A seconda della modalità di output (blocco/non blocco), inviamo immediatamente il carattere da stampare o formiamo una stringa nel buffer del thread. Quando esci dalla funzione, l'oggetto del nostro thread viene distrutto. Viene chiamato un distruttore, dove il privato flush() invia la stringa preparata per la stampa in modalità IT o DMA.

Quando ho convertito un valore numerico nell'array chars, ho rinunciato al noto idioma con snprintf() a favore delle soluzioni del programma di Neiver [RU]. L'autore nelle sue pubblicazioni mostra una notevole superiorità degli algoritmi proposti per convertire i numeri in una stringa sia nella dimensione del binario che nella velocità di conversione. Ho preso in prestito il codice da lui e l'ho incapsulato in NumConvert classe, che contiene to_string_integer() e a_string_float() metodi.

Nell'overloading dell'operatore di output dei dati di array/container, utilizziamo lo standard std::ranges::for_each() algoritmo e scorrere i contenuti dell'intervallo. Se l'elemento soddisfa il char_type concept, emettiamo la stringa senza spazi bianchi. Se l'elemento soddisfa il num_type o std::floating_point concetti, separiamo i valori con il valore di Delim specificato.

Bene, abbiamo reso tutto così complicato con tutti questi modelli, concetti e altre cose "pesanti" di C++ qui. Quindi, otterremo il muro di testo dall'assemblatore all'output? Diamo un'occhiata a due esempi:

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

Segnaliamo i flag del compilatore:-std=gnu++20 -Os -fno-exception -fno-rtti . Quindi nel primo esempio otteniamo il seguente elenco di assembler:

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

E nel secondo esempio:

.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

Penso che il risultato sia abbastanza buono. Abbiamo la consueta interfaccia thread C++, il comodo output di valori numerici, contenitori/array. Abbiamo anche ottenuto l'elaborazione degli intervalli direttamente nella firma di output. E abbiamo ottenuto tutto questo praticamente senza spese generali.

Naturalmente, durante l'output dei valori numerici, verrà aggiunto un altro codice per convertire il numero in una stringa.

Puoi testarlo online qui (per chiarezza, ho sostituito il codice dipendente dall'hardware con putchar() ).

Puoi controllare/prendere in prestito il codice di lavoro del progetto da qui. Un esempio dall'inizio dell'articolo è implementato lì.

Questa è la variante del codice iniziale. Alcuni miglioramenti e test sono ancora necessari per usarlo con sicurezza. Ad esempio, è necessario fornire un meccanismo di sincronizzazione per l'output non bloccante. Diciamo che quando l'output dei dati della funzione precedente non è stato ancora completato e, all'interno della funzione successiva, stiamo già sovrascrivendo il buffer con nuove informazioni. Inoltre ho bisogno di sperimentare attentamente con std::views algoritmi. Ad esempio, quando applichiamo std::views::drop() in una stringa letterale o in una matrice di caratteri, viene generato l'errore "direzioni incoerenti per distanza e limite". Bene, lo standard è nuovo, lo padroneggeremo nel tempo.

Puoi vedere come funziona qui. Per il progetto ho utilizzato il microcontrollore dual-core STM32H745. Da un core (480 MHz), l'uscita passa in modalità di blocco tramite l'interfaccia di debug SWO. Il codice dell'esempio viene eseguito in 9,2 microsecondi, dal secondo core (240MHz) tramite Uart in modalità DMA - in circa 20 microsecondi.

Qualcosa del genere.

Grazie per l'attenzione. Sarei felice di ricevere feedback e commenti, nonché idee ed esempi su come posso migliorare questo pasticcio.


No