Come scrivere codice ottimizzato per Arduino, senza utilizzare le funzioni setup() e loop(), per vedere di nascosto l’effetto che fa.
Quando scriviamo i nostri programmi per Arduino, usiamo generalmente la best practice di suddividere il codice in due parti: la funzione setup(), che viene lanciata una sola volta e si occupa di definire e instanziare il nostro ambiente di lavoro, e la funzione loop() che viene ripetuta continuamente in un ciclo infinito.
Quanti di voi sanno che questo non è l’unico modo di scrivere un programma per Arduino?
Liberiamo la memoria inutile…
Conosciamo tutti il programma “Blink”, che ci permette di far lampeggiare il nostro LED sull’uscita digitale 13 ad intervalli di mezzo secondo.
Il programma utilizza 924 byte dei 32K disponibili nella memoria flash, e 9 byte dei 2K di SRAM. È evidente che Arduino utilizzi parte della memoria per le proprie attività di gestione interna. Possiamo sostituire alcune funzioni e scrivere codice ottimizzato per Arduino. Proviamo a farci carico di una parte del lavoro, indirizzando le informazioni direttamente senza farci carico di funzioni di servizio. Troveremo ad esempio le informazioni necessarie ad indirizzare le funzioni pinMode() e digitalWrite() attraverso le informazioni presenti sulla pagina del sito di Arduino intitolata PortManipulation.
L’istruzione
1 |
DDRB = 0xFF; |
seleziona tutti i pin di uscita, da 8 a 13, come OUTPUT.
L’istruzione
1 |
PORTB = 0xFF; |
definisce tutti i pin da 8 a 13 ad HIGH, mentre l’istruzione
1 |
PORTB = 0x0; |
mette i pin da 8 a 13 nello stato LOW.
Questo codice non è strettamente equivalente all’esempio precedente, in quanto seleziona tutti i pin dal numero 8 al numero 13 contemporaneamente. Ma se proviamo ad eseguirlo, vedremo che funziona allo stesso modo. E c’è di più. Il programma è “dimagrito” del 30%, utilizzando solo 646 byte di memoria. Evidentemente occuparci direttamente della definizione dei pin ci ha permesso di liberare codice interno. Ma possiamo fare di meglio.
Eliminiamo le infrastrutture inutili.
Se analizziamo lo scheletro interno di un programma per Arduino come mostrato di seguto,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
#include <Arduino.h> // Declared weak in Arduino.h to allow user redefinitions. int atexit(void (* /*func*/ )()) { return 0; } // Weak empty variant initialization function. // May be redefined by variant files. void initVariant() __attribute__((weak)); void initVariant() { } void setupUSB() __attribute__((weak)); void setupUSB() { } int main(void) { init(); initVariant(); #if defined(USBCON) USBDevice.attach(); #endif setup(); for (;;) { loop(); if (serialEventRun) serialEventRun(); } return 0; } |
noteremo subito al suo interno le chiamate alla funzione setup() ed alla funzione loop(), oltre ad altre funzioni che si occupano dell’inizializzazione della scheda. A questo punto molti tra voi lettori si saranno già posti la domanda fatidica: “Ma allora potremmo eliminare anche le chiamate alle funzioni setup() e loop(), e gestire il nostro programma come un file in C?“
La risposta è: certamente! Scrivere codice ottimizzato per Arduino comporta proprio questa attività. Sostituiamo la chiamata a setup() con una chiamata a main(), e spostiamo all’interno di main() tutto ciò che è contenuto nella funzione loop(), racchiudendolo in un loop infinito.
Il codice così modificato si è ulteriormente ridotto, passando a soli 498 byte! Peccato che… non funzioni: il LED, infatti, non lampeggia più.
La ragione è abbastanza semplice: dal momento che abbiamo scritto il nostro main(), Arduino non ha trovato una sezione necessaria per il setup della scheda. Ora, la funzione delay() fa affidamento su di un timer, e tale timer viene di solito inizializzato a cura del sistema interno, che nel nostro caso non è stato attivato. Ma cos’è mai un timer, se non un sistema per controllare il trascorrere del tempo? Sostituiamolo con un ciclo for() sufficientemente lungo, ed eliminiamo la chiamata alla funzione delay().
La luce emessa dal nostro LED ora è molto più debole, e ancora non vediamo il lampeggìo, ma date un’occhiata all’occupazione di memoria: 142 byte contro i 924 iniziali, ed abbiamo eliminato anche i 9 byte in SRAM! L’ottimizzazione è dovuta al fatto che delay() e millis() fanno parte della standard library, che nel nostro caso non è stata compilata perché non necessaria.
E sempre a proposito di compilatore, il nostro è talmente intelligente da essersi accorto che all’interno del ciclo for() non abbiamo inserito alcuna istruzione (la riga di commento non è una istruzione). Pertanto ha pensato bene di “eliminare i cicli” dal codice eseguibile. Questo porta il programma ad eseguire l’accensione e lo spegnimento del LED in modo talmente veloce da non permettere al LED stesso di accendersi completamente prima della richiesta dello spegnimento.
Forzare il compilatore
A questo punto dobbiamo “spiegare” al nostro compilatore che quei cicli for() noi vogliamo eseguirli sul serio. Inseriamo quindi all’interno di ciascun ciclo la direttiva asm(” “) che in pratica ci consente di “iniettare” codice assembler all’interno del nostro sorgente in C. La richiesta (direttiva) di inserire codice assembler (anche se in realtà non immettiamo alcuna istruzione) fa capire al nostro compilatore che i cicli for() non devono essere eliminati dall’eseguibile.
L’aggiunta del ciclo for(), eliminato nel caso precedente, ha richiesto 34 bytes di codice, ma ora il programma funziona perfettamente. O quasi.
I più attenti tra voi si saranno accorti che il LED non lampeggia con la stessa frequenza di prima.
È possibile risolvere anche quest’ultimo problema in modo semplice: vediamo come.
Gestire il timing via codice.
La scheda Arduino adotta un clock di 14 MHz. Questo significa che è possibile elaborare in teoria approssimativamente 14 milioni di operazioni singole al secondo. In pratica le cose non vanno in questo modo: le singole operazioni del nostro codice vengono “interlacciate” (interleaved) con chiamate al firmware di gestione della scheda. Il firmware, infatti consente al nostro programma di lavorare sino a quando il clock raggiunge un determinato valore: in quel momento il nostro progamma viene “interrotto” (interrupt) per eseguire la routine di gestione e controllo dell’hardware.
Quanto tempo richiede tale routine è presto detto. Come abbiamo accennato in precedenza, Arduino lavora a 14 milioni di cicli al secondo, quindi ogni istruzione singola potrebbe durare esattamente 1/14.000.000 secondi (circa 0,00000007 secondi, o 70 nanosecondi). Con qualche tentativo scopriamo che ponendo un valore pari a 2.100.000 come contatore del nostro ciclo for(), otteniamo un intervallo molto simile a quello che avevamo trovato con la funzione delay(500), ovvero mezzo scondo di accensione seguito da mezzo secondo di spegnimento.
Quindi ricapitolando, in un secondo il nostro Arduino esegue circa 4.200.000 istruzioni “nulle”, occupando 12.600.000 cicli di clock (una istruzione nulla, una istruzione di incremento del contatore ed una istruzione di controllo per il fine-ciclo). Il tempo rimanente (14.000.000 – 12.600.000 = 1.400.000 cicli di clock, o un decimo di secondo ogni secondo) viene demandato alle funzioni di interrupt per la gestione dell’hardware.
Conclusioni
E sì, per quanto pericoloso, sarebbe possibile scrivere codice che non risponda agli interrupt, e quindi più veloce, ma richiederebbe nozioni di assembler che esulano da questa serie di articoli.
Se vi interessa imparare a scrivere codice ottimizzato per Arduino, potremo realizzare un nuovo articolo (o magari una intera serie) che tratti esplicitamente della gestione degli interrupt di Arduino.
Se l’argomento vi interessa, scriveteci!