martedì 5 luglio 2022

Arduino sketch multi-file - dividere lo sketch

Come dividere lo sketch di arduino

Quando il numero di linee supera una certa soglia si inizia a sentire la necessità di dividere lo sketch in più file. Molto spesso si opta per più file .ino e/o un solo file .ino, più un header file .h, Purtroppo sono entrambe scelte infelici che anziché aiutare complicano lo sviluppo. Chi conosce bene sia il linguaggio C++ e la particolare fase di build usata da arduino IDE conosce come organizzare bene l'albero dei sorgenti.

Una buona suddivisione permette di sfruttare meglio la potenza delle compile unit del C++. Un modulo .cpp è per il compilatore una compile unit indipendente dalle altre. La compile unit viene compilata dal compilatore il quale genera un file oggetto .o.

Più compile unit (quindi più file .cpp) riceveranno lo stesso trattamento, ognuna per essere compilata non necessità dell'altra. Nota che il file .ino è una compile unit anch'esso. Ad esempio da tre file .cpp più il file .ino, il compilatore genera quattro file .o, questi vengono analizzati dal linker (ld) nella fase finale di collegamento e trasformati in un unico file con estensione .elf. Ma attenzione da tre file .ino si ottiene un solo file oggetto e quindi non sono tre compile unit.

Creare più compile unit

L'ide arduino permette di creare più file appartenenti allo stesso progetto tramite il pulsante a destra sotto la lente di ingrandimento. Premendolo compare un menu dove il primo elemento è "Nuova Scheda", selezionandola viene richiesto il nome del nuovo file che desideriamo creare.

Se vogliamo che il nuovo file si chiami pippo lo scriviamo e confermiamo con il pulsante OK, di conseguenza compare una tab vuota di nome pippo. Se premiamo il pulsante "Salva" di default viene creato un file di nome pippo.ino e questo non ci serve poiché vogliamo creare una compile unit .cpp. Per ottenere ciò oltre al nome del file dobbiamo specificare anche l'estensione, cioè così: pippo.cpp. Possiamo anche specificare come estensione .c, .h e .S. Il modulo .c può contenere solo codice sorgente compatibile con il linguaggio C (non C++) e il modulo .S può contenere solo codice assembly.

Vediamo di seguito un possibile albero dei sorgenti:

sketch.ino
moduloa.cpp
modulob.cpp
moduloc.cpp
config.h
extern.h

 

Variabili e funzioni

Tutte le variabili usate solo da un modulo vengono dichiarate nel modulo stesso anteponendo la parola chiave static alla dichiarazione. Stessa cosa per le funzioni. Il modulo funge da contenitore in modo simile ad una classe, similmente ciò che è static di modulo sono membri privati visibili solo al modulo in cui sono state dichiarate/definite.

Per le funzioni definite in un modulo, ma che vogliamo rendere visibili (accesso pubblico) in altre compile unit, abbiamo due strade:

  • Un header file per modulo contenente i prototipi delle funzioni che vogliamo rendere pubbliche.
  • Un header file (extern.h contenete tutte le funzioni e variabili che vogliamo rendere pubbliche, anteponendo alla dichiarazione/prototipo la parola chiave extern.

Se il programma è molto complesso e diviso in numerose compile unit si preferisce avere un header file per modulo .cpp.

Ogni file .h avrà lo stesso nome del file .cpp, ad esempio il modulo motorController.cpp ha un file header di nome motorController.h che contiene le dichiarazioni dei prototipi di funzione che vogliamo rendere pubblici. Nessuno dei file .h dovrà contenere dichiarazioni di variabili, queste saranno sempre all'interno dei moduli .cpp. Ricordiamo che dichiarare una variabile comporta allocazione di memoria ram da parte del compilatore.

Iniziamo con solo due file, un .ino e un .cpp, io ho dato questi due nomi: sketch.ino e modulea.cpp. Il nostro primo obbiettivo è dichiarare una variabile nel .ino, che sia visibile anche nel file modulea.cpp.

// file sketch.ino
 
int ininofile = 255;
extern void printInInoFile();
 
void setup() {
Serial.begin(115200);

} // end void setup()
void loop() {

}

Nel file .cpp invece definisco una funzione che stampa il valore di ininofile, funzione che deve essere visibile dal file .ino. Quella riga che inizia con extern ha il compito di importare la visibilità della funzione definita nel file .cpp. Vediamo adesso il .cpp:

// Se il modulo usa le api di arduino serve includere Arduino.h
#include <Arduino.h>

extern int ininofile;
 
void printInInoFile() {
Serial.println(ininofile);
}

Stessa cosa, la riga che inizia con externimporta la visibilità della variabile ininofile dichiarata nel file .ino. Nota l'inclusione di Arduino.h, essa è necessaria solo nel caso in cui nel modulo facciamo ricorso alle API arduino, cioè pinMode(), digitalWrite(), Serial ecc.

Ciò che ho fatto è solo un esempio di uso della parola chiave extern, pertanto l'incrocio tra funzione e variabile non è da prendere come buono esempio di programmazione, capito ciò ora sappiamo come rendere visibile una funzione o variabile ad un modulo diverso da quello in cui è definita o dichiarata la variabile.

Quando il processo di compilazione non va a buon fine per una variabile o funzione mancante, il compilatore (o meglio il linker ld) emette l'errore seguente:

/sketch/modulea.cpp:39: undefined reference to `ininofile'
/sketch/modulea.cpp:39: undefined reference to `ininofile'
collect2: error: ld returned 1 exit status Error during build: exit status 1

Il compilatore cerca la dichiarazione della variabile ininofile e non la trova (l'ho commentata io per mostrare l'errore), stessa cosa quando non trova la definizione di una funzione. Tradotto sarebbe: "riferimento non definito per ininofile".

La penultima riga recita "collect2: ecc", sappiamo che ld è il linker e quindi visti i messaggi precedenti, concludiamo che nella fase di collegamento ad opera del linker qualcosa non è andato per il verso giusto e ld termina con errore 1.

Quando si usa un header file (i file .h) è bene non dichiarare variabili al loro interno. Quindi dichiarare le variabili o nel file .ino o nel modulo .cpp.

Subito ci si chiede, perché non dovrei se è consentito?

Anche se è consentito, due moduli che includono lo stesso header file contenente una dichiarazione di variabile verrebbe vista dal compilatore come due dichiarazioni di variabile con lo stesso nome, e il compilatore termina con errore.

In un header file ci vanno solo prototipi di funzione e macro del preprocessore, come ad esempio #define.

Un header file deve sempre avere una protezione per evitare che il file venga incluso due volte nella stessa compile unit. Conosco due modi per fare ciò: usare la direttiva #ifndef o la direttiva #pragma once. Di seguito il file config.h:

#ifndef config_h
#define config_h

// non è permesso dichiarare qui variabili.
#define HAS_RTC true
#define HAS NTC true

#endif

Se questo file viene incluso in più moduli (incluso .ino) questi avranno accesso alle due macro HAS_RTC e HAS_NTC.

Come al solito il link al progetto su wokwi, ma stavolta solo alcune raccomandazioni: i nomi di funzioni e variabili sono specificamente pensati per aiutare a seguire il codice. Le funzioni non fanno nulla di apparentemente utile e si limitano a mostrare che extern funziona come spiegato.

Riassumendo

  1. Non dichiarare variabili in un header file.
  2. Usa static di modulo quando la variabile o funzione non deve essere visibile in altri moduli, incluso il file .ino,
  3. Usa extern saggiamente, preferisci sempre un header file con la lista dei prototipi di funzione.
  4. Proteggi sempre un header file dalla inclusione multipla accidentale.
  5. Per accedere ai membri privati usa i metodi get e set.
  6. Includi nel modulo .cpp Arduino.h solo se necessario.

Importare una libreria

Grazie alla possibilità di creare più compile unit possiamo importare una libreria in locale al fine di adattarla e configurarla come meglio ci necessità. Oppure all'inverso creiamo in locale una libreria che poi esportiamo. Importare ed esportare in questo caso deve essere fatto manualmente, copiando il file.

I motivi per modificare una libreria sono diversi, uno di questi potrebbe essere che temiamo che la libreria in questione in futuro venga modificata in modo tale da non essere più compatibile con il nostro sketch. Questo è già un buon motivo, in ogni caso l'obbiettivo qui è mostrare come importare in locale una libreria e come svilupparne una ed esportarla e per farlo dobbiamo scegliere una libreria da importare.

Scegliamo la libreria LiquidCrystal standard già presente nella cartella:

arduino-1.8.19/libraries/LiquidCrystal/src

Da questa cartella copiamo i due file: LiquidCrystal.h e LiquidCrystal.cpp

Ci spostiamo nel percorso:

arduino-1.8.19/portable/sketchbook

dove creiamo una cartella di nome: importLiquidCrystal

All'interno di questa cartella incolliamo i file copiati prima. Sempre in questa cartella creiamo con un editor esterno un file vuoto di nome:

importLiquidCrystal.ino

Se abbiamo fatto tutto correttamente ci ritroviamo come appare qui sotto.

portable/sketchbook/importLiquidCrystal:
.  ..  
importLiquidCrystal.ino
LiquidCrystal.cpp
LiquidCrystal.h

Adesso con Arduino IDE apriamo il file importLiquidCrystal.ino e magicamente si apriranno due tab contenenti gli altri due file. Ora possiamo apportare modifiche senza compromettere la libreria LiquidCrystal standard che è sempre disponibile. Apportiamo un modifica alla funzione setCursor() in modo che come primo argomento prenda la riga (row) e secondo argomento la colonna (col). La modifica è semplice, basta invertire gli argomenti in entrambe i file LiquidCrystal.h e LiquidCrystal.cpp.

La modifica al file LiquidCrystal.cpp:

1
void LiquidCrystal::setCursor(uint8_t row, uint8_t col)

La modifica al file LiquidCrystal.h:

1
void setCursor(uint8_t row, uint8_t col);

Adesso possiamo testare la nostra modifica alla LiquidCrystal. Spostiamoci nel primo tab che mostra il file importLiquidCrystal.ino vuoto e ci scriviamo ciò che compare di seguito:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#include "LiquidCrystal.h"

LiquidCrystal lcd(12, 11, 10, 9, 8, 7);

void setup() {
  Serial.begin(115200);
  lcd.begin(16, 2);
  lcd.setCursor(0, 2);
  lcd.print("Hello!");
}

void loop() {
  
}

La scritta Hello! dovrà comparire alla riga 0 e colonna 2, come nella figura sotto. Notate che alla riga 1, al posto di < e > ci sono gli apici, questa sintassi è da usare per i file locali e il file LiquidCrystal.h lo è. Adesso siamo in grado di apportare tutte le modifiche che desideriamo, ad esempio io invertirei anche i primi due argomenti della funzione LiquidCrystal::begin().

Dopo le modifiche potrebbe venirci voglia di trasformare la nuova LiquidCrystal in libreria, qui sorgono i primi problemi, poiché esiste già una libreria con questo nome, ma per adesso facciamo finta che la libreria l'abbiamo sviluppata noi e che quindi avrà nomi di file e di classi da non potere andare in conflitto con una libreria già installata. Una libreria arduino per essere conforme e compatibile con arduino IDE deve avere una specifica struttura che possiamo dedurre da altre librerie, ma l'argomento richiede un articolo a parte.

Per adesso il nostro obbiettivo è di organizzare meglio l'albero dei sorgenti. Per progetti complessi potremmo desiderare la suddivisione dell'albero dei sorgenti in cartelle separate, ma purtroppo non è possibile seguendo l'intuito che ci suggerisce il seguente albero dei sorgenti:

importLiquidCrystal
LiquidCrystal
LiquidCrystal.h
LiquidCrystal.cpp
MTRelay
MTRelay.h
MTRelay.cpp
AnotherLib
AnotherLib.h
AnotherLib.cpp
importLiquidCrystal.ino

La compilazione termina con un errore che indica la mancata compilazione dei moduli .cpp, ma basta un piccola modifica e tutto fila liscio. L'albero dei sorgenti seguente infatti compila correttamente, come corretta è l'esecuzione sulla scheda:

importLiquidCrystal
src
LiquidCrystal
LiquidCrystal.h
LiquidCrystal.cpp
MTRelay
MTRelay.h
MTRelay.cpp
AnotherLib
AnotherLib.h
AnotherLib.cpp
importLiquidCrystal.ino

L'aggiunta della cartella src è determinante e funziona anche copiandovi una o più librerie prese dalla cartella: arduino-1.8.19/libraries. Funziona anche con librerie prelevate da github, questo ci da la possibilità di testare librerie senza doverle per forza installare. Purtroppo non è possibile aprire un tab per potere editare uno dei file sotto le cartelle, per editarlo siamo costretti ad usare un editor esterno. Per includere un header file dobbiamo specificare il percorso relativo, ad esempio il file .ino conterrà: #include "src/MTRelay/MTRelay.h".

Conclusione

Quanto descritto in questo articolo ha l'obbiettivo di semplificare la fase di sviluppo mantenendo l'albero dei sorgenti facile da creare e estendere. Certamente non ho detto tutto quello che c'è da sapere in merito, ma spero di non avere tralasciato nulla di così determinante da rendere nullo l'intento.

Licenza Creative Commons
Quest'opera è distribuita con Licenza Creative Commons Attribuzione - Condividi allo stesso modo 4.0 Internazionale

Nessun commento:

Posta un commento