Fab-O-Matic: Sviluppo embedded con C++ moderno

Continuiamo la serie di articoli a scopo didattico sulla realizzazione di un progetto embedded all’interno del nostro FabLab. In questo articolo, spiegheremo le scelte di sviluppo e gli aspetti positivi del C++ moderno.

Puoi consultare le istruzioni per l’uso di Fab-O-Matic qua.


Requisiti dell’applicativo embedded

E’ utile avere in mente cosa deve fare il sistema che progetti, per fare scelte tecnologiche azzeccate. Le funzioni della board Fab-O-Matic sono le seguenti:

  • • Autenticare gli utenti via RFID (con interrogazione del backend) ;
  • • Aprire o chiudere il relay di autorizzazione macchina ;
  • • Gestire un eventuale dispositivo Shelly via MQTT, oltre al relay della schedina (ad es. compressore asservito alla macchina) ;
  • • Indicare agli utenti lo stato della macchina e la durata di utilizzo ;
  • • Evidenziare eventuali operazioni di manutenzione richieste ;
  • • Scambiare dati macchina e eventi utente con il protocollo MQTT con il back-end ;
  • • Funzionare anche senza rete WiFi (con cache delle tags RFID, buffering messaggi in Flash) ;
  • • Consentire la prima configurazione rete WiFi/macchina via portale web dedicato.

E’ molto importante che il sistema sia sempre funzionante per evitare di bloccare l’operatività del FabLab e vedremo, in un articolo successivo, come sono stati realizzati test automatici per evitare problemi.

Quale framework scegliere?

Il microprocessore ESP32S3 è fornito dal produttore Espressif con due framework di programmazione : ESP-IDF (C/C++) e Arduino Core (C/C++). La larga diffusione di questi processori ESP32 ha portato altre organizzazioni a supportare questi microcontrollori, con linguaggi come Micropython. C’è dunque da fare una scelta prima di partire!

Per Fab-O-Matic, è stato scelto il framework Arduino Core che fornisce le solite funzioni del mondo Arduino1, che forse conosci già : digitalWrite(...), void setup(), void main()… queste funzioni sono tutte implementate sopra l’API nativa, ESP-IDF.

L’enorme vantaggio dell’Arduino Core è che le migliaia di librerie del mondo Arduino sono così disponibili per il nostro progetto, e non è necessario riscrivere tutto da zero per il microcontroller scelto. Ad esempio, la libreria Arduino WifiManager gestisce molto bene la prima configurazione e l’associazione all’access point.


L’ambiente di sviluppo

Una volta scelto il framework, occorre trovare l’ambiente di sviluppo (IDE). Da molti anni, la prima scelta per sviluppare progetti embedded semplici è stata ArduinoIDE ; tuttavia, a questo IDE mancano molte features presenti nei moderni ambienti di programmazione, e la loro mancanza si fa sentire con progetti più complessi come Fab-O-Matic.

L’ambiente VSCode con l’estensione PlatformIO fornisce invece un supporto multi-files (per evitare di avere un file unico gigantesco poco mantenibile), suggerimenti automatici durante la scrittura del codice, numerose estensioni e un aspetto visivo più personalizzabile.

Perché PlatformIO ?

PlatformIO semplifica un problema molto diffuso quando si lavora con i microcontrollori : la necessità di installare e configurare le toolchains (compilatore per il microcontrollore specifico, librerie di sistema, linker, debugger…) e la gestione delle librerie aggiuntive al progetto. L’abbiamo usato in questo progetto con i microprocessori ESP32 ma supporta molti altri produttori tramite altre piattaforme.

PlatformIO è un progetto nato in Ucraina e open-source. Non è solo un’estensione VSCode : è un vero build system installabile e usabile da riga di comando con vari sistemi operativi. Questo build system di PlatformIO è un elemento chiave per riuscire a creare rapidamente firmware per configurazioni diverse (lingua, microcontrollore, versioni hardware PCB…) e automatizzare le operazioni di test, come vedremo in un successivo articolo.

Per rendere l’idea, ecco com’è semplice generare due firmware per due microcontrollori, dagli stessi sorgenti:

pio run -e esp32
pio run -e esp32s3

La configurazione di Platformio si basa su un file INI da modificare per ogni configurazione. Vedremo più avanti un esempio con le librerie.

Toolchains moderne!

Per garantire una massima durata di vita al progetto software embedded, abbiamo usato le ultime toolchains e librerie disponibili nel 2024 (GCC 13.2 per C++23, Arduino Core 3.0 nella branch dedicata). Se vuoi compilare e modificare il progetto, puoi fare il checkout dal nostro Github e seguire le istruzioni del README. Se non sei familiare con gli strumenti di version control, sappi che teniamo corsi su Git!


Le librerie usate

Implementare un protocollo SPI con uno chip dedicato come MRFC522 non è per niente veloce o semplice. Se puoi usare una librerie, già collaudata da migliaia di utenti, risparmi tempo e fatica. L’abbiamo fatto anche noi, usando le seguenti librerie Arduino per Fab-O-Matic:

Libreria utilizzataFunzionalità
LiquidCrystalGestione del display LCD 1602 con interfaccia parallela a 4 fili.
ArduinoJSONGenerazione e parsing di JSON, usato per salvare parametri e messaggi in memoria Flash.
256dpi/MQTTClient MQTT, usato per collegarsi al broker Mosquitto sul server
MFRC522v2Gestione del modulo RFID via bus SPI
Adafruit NeoPixelGestione del NeoPixel (segnale dati a 400 kHz)
WiFiManagerPortale di configurazione per setup rete WiFi e ID Macchina
sMQTTBrokerBroker MQTT usato per testare la board senza il server operativo
ArduinoOTAPer consentire gli aggiornamenti firmware via rete senza cavo USB (“Over the air”)

Per usare una libreria in Platformio, è sufficiente aggiungere un riferimento nel file platformio.ini nella sezione lib_deps:

lib_deps = https://github.com/bblanchon/ArduinoJson.git#v7.0.4
	256dpi/MQTT
	https://github.com/OSSLibraries/Arduino_MFRC522v2.git#2.0.4
	...

💡Contribuire in ritorno è un modo di ringraziare gli autori di queste librerie per il loro lavoro ! Durante questo progetto, sono state mandate Pull Request ai progetti LiquidCrystal, WiFiManager, sMQTTBroker, MFRC522v2, principalmente per ridurre warning di compilazione con versioni più recenti del C++.


Utilità del C++ in ambito embedded

Il linguaggio C (o il C++ usato in stile C classico) è sicuramente la scelta più diffusa in ambito embedded. Fab-O-Matic invece usa in modo deciso il linguaggio C++23, cercando di applicare seguire le guidelines del C++ moderno. Abbiamo scelto alcuni aspetti interessanti del linguaggio C++ usati in Fab-O-Matic per illustrare la potenza del linguaggio.

  1. Constexpr
  2. Libreria standard
  3. Allocare memoria
  4. Templates
  5. Formattazione di stringhe 2.0
  6. Namespaces

Constexpr all the things!

Un meccanismo veramente affascinante del C++ è la sua capacità di eseguire codice al momento della compilazione e inserire “il risultato” dentro il codice generato. E’ possibile in questo modo “precalcolare” al momento della compilazione gli elementi noti al momento della compilazione.

In Fab-O-Matic, questo meccanismo è stato usato per validare la configurazione dei pins al momento della compilazione senza generare nessun codice nel firmware. Basta infatti menzionare static_assert(no_duplicates(pins)) e il compilatore lavorerà per noi.

Ecco una funzione constexpr che verifica l’assenza di pin duplicati:

// @brief Check at compile time that there are no duplicate pin definitions
  constexpr bool no_duplicates(pins_config pins)
  {
    std::array pin_nums{
        pins.mfrc522.sda_pin,
        pins.mfrc522.mosi_pin,
        pins.mfrc522.miso_pin,
        pins.mfrc522.sck_pin,
        pins.mfrc522.reset_pin,
        pins.lcd.rs_pin,
        pins.lcd.en_pin,
        pins.lcd.d0_pin,
        pins.lcd.d1_pin,
        pins.lcd.d2_pin,
        pins.lcd.d3_pin,
        pins.lcd.bl_pin,
        pins.relay.ch1_pin,
        pins.buzzer.pin,
        pins.led.pin,
        pins.led.green_pin,
        pins.led.blue_pin,
        pins.buttons.factory_defaults_pin};

    // No constexpr std::sort available
    for (auto i = 0; i < pin_nums.size(); ++i)
    {
      if (pin_nums[i] == NO_PIN)
        continue;

      for (auto j = i + 1; j < pin_nums.size(); ++j)
      {
        if (pin_nums[i] == pin_nums[j])
          return false;
      }
      // Check pins numbers are convertible to gpio_num_t
      static_cast<gpio_num_t>(pin_nums[i]);
    }
    return true;
  }

Se vuoi approfondire, ti consigliamo questo video che mostra come è possibile spingere questo meccanismo di “elaborazione al momento della compilazione” in modo sorprendente, ad esempio per precalcolare le slide di una presentazione per un processore Amiga con Jason Turner.

stdlib : la parte migliore del C++ ?

La libreria standard del C++ è ricchissima di funzioni che eliminano molto codice ripetitivo. Essendo altamente ottimizzata, il codice generato sarà, almeno in linea generale, molto più efficiente. Alcuni ciclo for comuni possono scomparire completamente per lasciare spazio ad uno stile più funzionale : trovare un elemento con std::find, o copiare un array su un altro con std::copy.

Esempio di codice “stile C” per ricercare un elemento in un array:

  for (auto i = 0; i &lt; this->whitelist.size(); i++)
  {
    if (this->whitelist[i] == uid)
    {
      return true;
    }
  }
  return false;

Stessa funzionalità usando std::find :

return std::find(whitelist.cbegin(), whitelist.cend(), uid) != whitelist.end();

Ecco come ordinare in ordine crescente un vettore secondo una proprietà (getNextRun()) crescente:

std::sort(mutableTasks.begin(), mutableTasks.end(), [](const Task &amp;a, const Task &amp;b)<br>              { return a.getNextRun() &lt; b.getNextRun(); });

Sembra quasi usare un linguaggio di più alto livello, no ?

Usare la libreria standard ti obbliga anche ad aderire alla visione degli autori e dei progettisti del C++, tutti esperti di fama mondiale. Se impari ad usare la libreria standard, acquisirai familiarità con alcuni concetti (ad es. lambda function sopra) e ti aiuterà a usarli con giudizio nel tuo codice.

Le strutture di dati comnui sono disponibili nella libreria standard e le principali usate in questo progetto sono std::array (per eliminare gli array C-style con parentesi quadre), std::tuple, std::deque (lista concatenata testa-coda).

std::chrono

La gestione degli intervalli, timers, durate trascorse fra eventi, è un aspetto fondamentale nella applicazioni embedded. Possiamo usare questo tema per illustrare come l’approccio specifico del C++ sia vantaggioso.

Anzitutto, è possibile usare suffisi agli interi per esplicitare le unità usando std::chrono::literals. Le operazioni aritmetiche su queste operazioni tengono conto delle unità ; scompaiono le varie costanti magiche di conversione e possibilità di errore.

Possiamo confrontare codice “tipico” per eseguire attività ogni (periodo_ms) millisecondi:

static const unsigned long PERIODO_MS= 2000;

void run()
{
  static unsigned long start_ms = millis();
  // Non usare questo pattern, https://www.norwegiancreations.com/2018/10/arduino-tutorial-avoiding-the-overflow-issue-when-using-millis-and-micros/
  if (millis() > start_ms + PERIODO_MS) { 
   // Do something
   start_ms = millis();
  }
}

Versione con std::chrono

using std::chrono::literals;

static constexpr auto PERIODO = 2s;

void run()
{
  static auto start = std::chrono::steady_clock::now();
  if (std::chrono::steady_clock::now() > PERIODO + start)
  {
    // Do something
    start = std::chrono::steady_clock::now();
  }
}

Sono così scomparse le possibilità di errori di conversione, la necessità di mettere nel nome della variabile l’unità di misura, e il bug di overflow presente in (millis() > start_ms + PERIODO_MS) e altri potenziali problemi dovuti a definizioni diverse di millis() fra microprocessori diversi.

Allocare memoria con smart pointers

E’ molto facile eliminare le allocazioni dinamiche (malloc, new) usando std::make_unique e std::unique_ptr. Il tipo std::string sostituisce le allocazioni dinamiche di char*.

In generale, usare classi che allocano nel costruttore e de-allocano nel distruttore consente di usare il determinismo di queste chiamate nello standard C++ per eliminare possibili errori. Uno degli strani acronimi del C++ per questa tecnica è R.A.I.I. (resource allocation is initialization).

Facciamo un esempio con una classe che alloca memoria alla costruzione.

  Mrfc522Driver::Mrfc522Driver()
  {
    rfid_simple_driver = std::make_unique<mfrc522driverpinsimple>(pins.mfrc522.sda_pin);
    spi_rfid_driver = std::make_unique<mfrc522driverspi>(*rfid_simple_driver, SPI);
    mfrc522 = std::make_unique<mfrc522>(*spi_rfid_driver);
  };

Quando il distruttore della classe Mrfc522Driver() girerà, verranno chiamati i distruttori delle variabili membro, fra cui mfrc522 , spi_rfid_driver, rfid_simple_driver. Anche l’ordine di distruzione è definito dallo standard, come inverso rispetto alla loro costruzione:

  class Mrfc522Driver
  {
  private:
    std::unique_ptr<mfrc522driverpinsimple> rfid_simple_driver;
    std::unique_ptr<mfrc522driverspi> spi_rfid_driver;
    std::unique_ptr<mfrc522> mfrc522;
    auto hardReset() -> void;
...

Finalemente, questi membri sono di tipo std::unique_ptr e il distruttore dello smart pointer libererà la memoria allocata da std::make_unique. La conclusione è che non serve scrivere codice come un distruttore sulla classe Mrfc522Driver, perché i meccanismi del linguaggio garantiscono la liberazione della memoria in modo deterministico.

Templates – lascia il compilatore lavorare per te!

Un’altra caratteristica poco “comune” del linguaggio C++ è la capacità di definire “una ricetta” di generazione del codice che verrà poi applicata automaticamente in base ai parametri forniti : un template. Un caso di utilizzo in Fab-O-Matic è l’invio messaggi MQTT, ciascuno rappresentato da una classe specifica. In molti altri linguaggi, creare una simile funzione implica di usare un argomento con una classe base comune ai vari messaggi da mandare.

Esempio della funzione :

/**
* @brief Sends a query without reply by backend
*
* @tparam QueryT The type of the query.
* @tparam ...Args The arguments to be passed to the query constructor.
* @return true if the query was processed successfully, false otherwise.
*/
template <typename queryt,="" typename...="" queryargs="">
bool FabBackend::processQuery(QueryArgs &amp;&amp;...args)
{
	static_assert(std::is_base_of<servermqtt::query, queryt="">::value, "QueryT must inherit from Query");
	QueryT query{args...};

	if (publish(query) == PublishResult::PublishedWithoutAnswer)
	{
		return true;
	}
	else
	{
		ESP_LOGW(TAG, "Failed to publish query %s", query.payload().data());
		this->disconnect();
	}
	return false;
}

Punto di chiamata della funzione templatizzata:

bool FabBackend::alive()
  {
    return processQuery<servermqtt::alivequery>();
  }

La bellezza del meccanismo è che il compilatore genererà al punto di chiamata una funzione processQuery specifica al tipo AliveQuery. Dato che il compilatore dispone del tipo specifico durante la compilazione della funzione, e non della classe base, può, fra altre cose, evitare la chiamata indiretta ai membri della classe derivata (v-table lookup) tipica delle gerarchie di classi.

Il guadagno di prestazioni è un aspetto insignificante in questa applicazione, ma il bello del C++ è che ti rende possibile questo tipo di ottimizzazione e ti fa pensare a vari livelli di astrazione.

Formattazione di stringhe v2.0

E’ possibile evitare le format strings con i format specifiers usando std::stringstream . Questo evita gli errori comuni sull’ordine dei parametri e rende le successive modifiche ai messaggi più semplici.

auto AliveQuery::payload() const -> const std::string
  {
    std::stringstream ss{};

    // Get MAC address
    const auto serial = esp32::esp_serial();

    ss << "{\"action\":\"alive\","
       << "\"version\":\"" << FABOMATIC_BUILD << "," << GIT_VERSION << "\","
       << "\"ip\":\"" << WiFi.localIP().toString() << "\","
       << "\"serial\":\"" << serial << "\","
       << "\"heap\":\"" << esp32::getFreeHeap() << "\""
       << "}";
    return ss.str();
  }

Questo approccio è anche più portabile ; la revisione Arduino Core 3.0 redefinisce uint32_t da int a long int, causando warnings con i format specifier %d, %u (%l o %lu richiesto), anche se i tipi sono sinonimi sulla piattaforma ESP32.

Namespaces – niente conflitti !

Quando si usano molte librerie, o il numero di file della soluzione cresce, diventa probabile incontrare conflitti di nomi. I namespace in C++ consentono di nascondere parte del programma ad altre parte del programma. E se non ci sono conflitti oggi, domani uno degli autori delle librerie incluse potrebbe aggiungere una funzione che collide con una delle nostre, e fare fallire la compilazione – purtroppo, molti autori di libreria non usano namespace.

E’ anche possibile importare namespace dentro altri namespace. In Fab-O-Matic questo è stato usato per le traduzioni : il namespace fabomatic::strings è quello usato dalle varie funzioni di display per l’utente ; i namespace fabomatic::strings::it_IT o fabomatic::strings::en_US che contengono le traduzioni vere e proprie sono semplicemente importati condizionalmente dentro il namespace padre.

// Include Italian language
#ifdef FABOMATIC_LANG_IT_IT
#include "language/it-IT.hpp"
namespace fabomatic::strings
{
  using namespace fabomatic::strings::it_IT;
}
#endif

// Include English language
#ifdef FABOMATIC_LANG_EN_US
#include "language/en-US.hpp"
namespace fabomatic::strings
{
  using namespace fabomatic::strings::en_US;
}
#endif

Nell’esempio di sopra la lingua di Fab-O-Matic è controllata dalle costanti di compilazione FABOMATIC_LANG_IT_IT o FABOMATIC_LANG_EN_US. Usare direttive del preprocessore come #ifdef non è molto C++, ma non ho trovato un altro meccanismo compatibile con PlatformIO per generare i firmware localizzati.

Documentazione del codice

Per garantire una migliore manutenibilità del codice e facilitare la comprensione da parte di nuovi sviluppatori, è essenziale avere una documentazione completa e ben strutturata. A questo scopo, l’uso di strumenti come Doxygen può risultare estremamente utile. Doxygen permette di generare una documentazione dettagliata del codice direttamente dai commenti inseriti nel codice sorgente.

Per consultare la documentazione generata del progetto Fab-O-Matic, visita Fab-O-matic Documentation.

Documentare il codice non solo aiuta nella manutenzione e nello sviluppo futuro, ma migliora anche la qualità complessiva del progetto, rendendolo più accessibile e comprensibile per tutti i contributori e gli utenti finali.

Conclusioni

In questo articolo abbiamo esplorato le scelte di sviluppo e gli aspetti positivi del C++ moderno nell’implementazione del progetto embedded Fab-O-Matic. Abbiamo visto come l’uso di funzionalità avanzate del linguaggio C++23, come constexpr, la libreria standard, l’allocazione di memoria con smart pointers, i templates, e la formattazione avanzata delle stringhe, possa migliorare la qualità, la manutenibilità e l’efficienza del codice. L’ambiente di sviluppo con PlatformIO ha ulteriormente semplificato il processo, permettendo una gestione semplice delle librerie necessarie per il progetto.

Concludiamo questo articolo con l’auspicio che queste tecniche e strumenti possano essere utili per i vostri progetti embedded, aiutandovi a sfruttare al meglio le potenzialità del C++ moderno e dei tool di sviluppo disponibili. Buon coding!


Riferimenti

  1. Language Reference | Arduino Documentation ↩︎

Utilizzando il sito, accetti l'utilizzo dei cookie da parte nostra. Per maggiori informazioni.

Questo sito utilizza i cookie per fornire la migliore esperienza di navigazione possibile. Continuando a utilizzare questo sito senza modificare le impostazioni dei cookie o clicchi su "Accetta" permetti al loro utilizzo. Privacy Policy di FABLAB BERGAMO

Chiudi