martedì 19 aprile 2022

Arduino LCD con sprintf

Uso di sprintf

Figura 1: 101 sul display
Spesso con gli LCD si presenta la necessità di visualizzare il valore di una variabile, questa nel tempo assume valori differenti, ma il display mostra sempre lo stesso valore. Prendiamo ad esempio il valore 101, vediamo il codice:

    lcd.setCursor(0, 0);
    lcd.print(101);

Bene, adesso supponiamo che al posto della costante numerica 101 ci sia la nostra variabile di nome numero, alla quale ho assegnato il valore 101, vediamo il codice:

    uint32_t numero = 101;
    lcd.setCursor(0, 0);
    lcd.print(numero);

Otterrò lo stesso risultato di Figura 1, non è cambiato nulla ok, ma adesso la mia variabile numero assume il valore 10 e vorrei visualizzarlo sul display nella stessa posizione, proviamo a modificare il codice in questo modo:

    uint32_t numero = 101;
    lcd.setCursor(0, 0);
    lcd.print(numero);
    numero = 10;            // modificato il valore di numero
    lcd.setCursor(0, 0);
    lcd.print(numero);

Ciò che ottengo è uguale a Figura 1, ma io mi aspettavo di vedere 10 al posto di 101. In verità il valore di numero viene correttamente scritto nella memoria del display, ma il 3° carattere scritto in precedenza resta nella memoria del display. Così ottengo comunque un risultato non desiderato, allora penso di usare il metodo clear() in questo modo:

    uint32_t numero = 101;
    lcd.setCursor(0, 0);
    lcd.print(numero);
    delay(500);             // aggiungo un delay di 500 ms
    numero = 10;            // modificato il valore di numero
    lcd.clear();
    lcd.setCursor(0, 0);
    lcd.print(numero);    

Ok, adesso in effetti compare 101 che resta li per 500 millesimi di secondo (1/2 secondo) e poi compare il valore 10. Questo va bene raramente, poiché spesso sul display vogliamo visualizzare altre informazioni, alcune di queste non variano nel tempo, ma altre si. La soluzione di clear() ci costringe a dovere scrivere nuovamente anche quello che non varia nel tempo. Se poi vogliamo pure aggiornare (rinfrescare) il display frequentemente otterremo un effetto sfarfallio.

Stessa cosa ma meno evidente se prima di scrivere la variabile ci mettiamo una lcd.print(" "); che pulisce 101 e poi visualizza 10. Vediamo il codice con la print in questione:

    uint32_t numero = 101;
    lcd.setCursor(0, 0);
    lcd.print(numero);
    delay(500);             // aggiungo un delay di 500 ms
    numero = 10;            // modificato il valore di numero
    lcd.setCursor(0, 0);
    lcd.print("   ");       // la print in questione
    lcd.setCursor(0, 0);
    lcd.print(numero);

Già è qualcosa, ma nella pratica si rivela non essere la soluzione priva di sfarfallio. L’ideale sarebbe di scrivere sul display 10 seguito da uno spazio così che il numero 1 residuo venga sovrascritto dallo spazio. Però se al posto di 10 la variabile assumesse valore 1 dovrei farla seguire da due spazi, diventa complicato scrivere codice per gestire più variabili.

Per fortuna c’è già una funzione che permette di ottenere ciò e questa si chiama sprintf, funzione che non è così semplice da comprendere, specie per i principianti che ancora non hanno sperimentato l’uso degli array.

La funzione in effetti richiede un array di caratteri da usare come buffer, una stringa di formattazione e per finire una o più variabili da formattare in base alla stringa di formattazione specificata. Prima di scendere nei dettagli vediamola in azione con il seguente codice:

    numero = 1000;
    while (true) {
        delay(2);
        // %-4 allineamento a sinistra
        sprintf(sprintBuff, "%-4u", numero);
        lcd.setCursor(0, 0);
        lcd.print(sprintBuff);
        if (numero == 0)
            break;
        numero--;
    }

C’è un solo argomento e quindi la stringa di formattazione contiene un solo marcatore “%” seguito da codici di formattazione, che nello specifico, allineano a sinistra (-) un campo di 4 caratteri (4) e una conversione di tipo (u) unsigned. Non specificando il meno ( - ) l’allineamento è a destra.

La stringa di formattazione può includere del testo, ad esempio:

    sprintf(sprintBuff, "numero = %-4u", numero);

Ma attenzione che la dimensione del buffer adesso deve essere di 9+4 = 13 elementi, questo perché 'numero = ' conta 9 caratteri e anche questi vengono copiati nel buffer.

Poiché il display si prende circa 500us per ogni carattere, abbiamo 9 + 4 = 13 che moltiplicati per 0.5ms = ~6.5ms. Se si vuole risparmiare tempo CPU sarebbe bene creare una maschera, stamparla sul display solo una volta e poi visualizzare il valore delle variabili.

Se volessimo aggiornare il display ad ogni ciclo di loop, per 32 caratteri avremmo un ritardo di 32 x 0.5 = 16ms e questo vuole dire non potere temporizzare con millis azioni temporizzate inferiori a 16ms. Alcune librerie per il debounce dei pulsanti falliscono con loop che durano più di 5ms, ma ciò dovrebbe essere ben documentato in ogni libreria.

La dichiarazione del buffer sprintBuff è globale e il numero di elementi di cui è composto deve essere “almeno” n + 1, dove n indica il numero di caratteri da visualizzare. “almeno” vuole dire che minimo deve essere n + 1, ma potrebbe essere anche più grande e contare anche 10 o più elementi. Quindi ad esempio per 4 caratteri il buffer dovrà essere composto minimo da 5 elementi, qui di seguito come appare la dichiarazione:

    char sprintBuff[5];

Nota che il buffer potrebbe anche essere dichiarato all’interno della funzione che contiene la lcd.print() con il vantaggio di risparmiare memoria, ma non sempre è possibile, ma spesso è desiderabile. Il buffer può essere usato con diverse sprintf(), cioè riciclato.

Vediamo come al solito una applicazione dimostrativa, sempre scritta passo passo.

Applicazione demo

L’applicazione è fisicamente composta da:

  1. Arduino UNO, va bene qualunque board basata sulla MCU ATmega328
  2. Display LCD HD44780 2 o 4 righe.
  3. 4 potenziometri 10kohm

I 4 potenziometri sono connessi agli ingressi analogici A0÷A3. Il valore letto con analogRead() verrà visualizzato sul display, quindi avremo 4 valori da visualizzare.

Una maschera verrà visualizzata solo una volta per risparmiare il prezioso tempo CPU. In Figura 2 compare la maschera e i 4 valori letti da analogRead().

Figura 2: screenshoot della applicazione demo

Per il collegamento dei potenziometri fate riferimento alla Figura 3, la quale mostra un tipo di potenziometri a slitta, se invece avete i potenziometri rotativi, ricordate che il centrale (pin2), va al pin di arduino, il pin 1 a GND e il 3 al +5V.

Figura 3: screenshoot del collegamento dei potenziometri

Il software

Il programma è molto semplice e non richiede infrastrutture software, ricordiamolo, si tratta di una demo per mostrare l’uso di sprintf. In una applicazione reale e concreta potremmo usare uno o più potenziometri per tarare dei parametri funzionali, in effetti in passato, molte schede di gestione cancelli automatici, usavano trimer potenziometrici per tarare i tempi. In questo caso avremmo bisogno di un menu che ci permetta di accedere alla taratura e che mostri la schermata sul display, una volta tarati, premeremo un pulsante per salvare i valori ecc. Non è esclusa una macchina a stati simile a quella descritta in questo articolo.

Per adesso ci concentriamo sulla demo che, possiamo descrivere sempre tramite il nostro pseudo linguaggio.

SETUP {
    LCDPRINT MASCHERA
}

SUPER_LOOP {
    IS_TIMER_ELAPSED(50ms) {
        pot0 = ANALOG_READ(A0)
        pot1 = ANALOG_READ(A0)
        pot2 = ANALOG_READ(A0)
        pot3 = ANALOG_READ(A0)
        LCD_UPDATE
    }
}

Riassumendo a parole, nel setup() visualizziamo la maschera sul display. Passiamo alla funzione loop(), dove un timer scade ogni 50ms, quando scade eseguiamo la lettura di ogni ingresso analogico (A0÷A3) e le salviamo nelle variabili pot0÷pot3, chiamiamo una funzione per aggiornare il display e fine, la nostra applicazione è completa.

Quindi dichiariamo globali le 4 variabili pot0÷pot3:

#include <LiquidCrystal.h>

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

uint16_t pot0;
uint16_t pot1;
uint16_t pot2;
uint16_t pot3;

uint32_t saveMillis;

La variabile saveMillis ci serve per il temporizzatore da 50ms che impiegheremo nella funzione loop(). Vediamo la funzione setup() nella quale c’è poco da commentare:

void setup() {
    Serial.begin(115200);
    lcd.begin(16, 2);
    lcdPrintMaschera();
}      

Passiamo subito alla funzione lcdPrintMaschera() qui di seguito:

void lcdPrintMaschera() {
  lcd.setCursor(0, 0);
  lcd.print("P0:");

  lcd.setCursor(9, 0);
  lcd.print("P1:");

  lcd.setCursor(0, 1);
  lcd.print("P2:");

  lcd.setCursor(9, 1);
  lcd.print("P3:");
}

Anche qui nulla da commentare e passiamo alla funzione loop():

void loop() {
    uint32_t dt = millis() - saveMillis;
    if (dt > 50) {
        saveMillis = millis();
        pot0 = analogRead(A0);
        pot1 = analogRead(A1);
        pot2 = analogRead(A2);
        pot3 = analogRead(A3);
        lcdUpdate();
    }
}

Al primo ciclo saveMillis = 0 per cui millis() - 0 è sicuramente maggiore di 50 e quindi si entra nel corpo della if (dt > 50) {, salviamo il valore restituito da millis() nella variabile saveMillis in questo modo ricarichiamo il temporizzatore. Leggiamo con analogRead() e per finire chiamiamo la funzione lcdUpdate() che aggiorna il display, ed è qui che usiamo sprintf , vediamola subito:

void lcdUpdate() {
    static uint16_t p0 = 1024;
    static uint16_t p1 = 1024;
    static uint16_t p2 = 1024;
    static uint16_t p3 = 1024;
    char lcdBuffer[5];      // il nostro buffer da usare nella sprintf 

    if (p0 != pot0) {
        p0 = pot0;
        
        sprintf(lcdBuffer, "%4u", pot0);
        lcd.setCursor(3, 0);
        lcd.print(lcdBuffer);
    }

    if (p1 != pot1) {
        p1 = pot1;
        
        sprintf(lcdBuffer, "%4u", pot1);
        lcd.setCursor(12, 0);
        lcd.print(lcdBuffer);
    }

    if (p2 != pot2) {
        p2 = pot2;
        
        sprintf(lcdBuffer, "%4u", pot2);
        lcd.setCursor(3, 1);
        lcd.print(lcdBuffer);
    }

    if (p3 != pot3) {
        p3 = pot3;
        
        sprintf(lcdBuffer, "%4u", pot3);
        lcd.setCursor(12, 1);
        lcd.print(lcdBuffer);
    }
}

Ciò che facciamo con p0 e pot0 viene ripetuto con p1, p2 e p3, quindi descriviamo solo la prima if (p0 != pot0) {. La funzione analogRead() ci restituisce un valore che non supera 1023, dato dalla risoluzione del convertitore ADC a 10-bit. Su altre schede simil arduino potremmo avere un convertitore ADC a 12-bit, allora la analogRead() ci restituirà un valore non superiore a 4095, in questo caso dovremmo modificare il valore assegnato alle variabili dichiarate static e anziché 1024 ci assegniamo 4096

Assegniamo 1024 di modo che la condizione logica 1024 != pot0 sia vera, nuovamente pot0 non assumerà mai un valore maggiore di 1023, pertanto 1024 è diverso da 0÷1023?, la risposta è si, per cui si entra nel corpo della if e la prima cosa che facciamo è di rendere uguali le due variabili, cioè a p0 gli assegniamo il valore di pot0 in questo modo possiamo proseguire a stampare sul display il valore di pot0 solo se le due variabili sono diverse.

Notiamo dopo le variabili dichiarate static la dichiarazione del buffer di nome lcdBuffer composto da 5 elementi di tipo char. Ci servono infatti 5 elementi per poterne formattare 4, questo per fare spazio al terminatore di C string \0. Vi ricordate di avere letto che n è il numero di caratteri da visualizzare e che il buffer deve essere di n + 1 elementi, appunto 4 + 1 fa 5. Ora possiamo usare la sprintf per formattare il buffer e poi impostare il cursore alla posizione desiderata e per finire scrivere sul display con lcd.print().

Se riusciamo a digerire la ripetizione di codice presente nelle 4 if possiamo considerare il programma completato, diversamente potremmo pensare di creare la seguente funzione:

void lcdPrintPot(uint8_t r, uint8_t c, uint16_t n) {
    char lcdBuffer[5];
    sprintf(lcdBuffer, "%4u", n);
    lcd.setCursor(c, r);
    lcd.print(lcdBuffer);
}

Rivolgiamo la nostra attenzione al buffer lcdBuffer e quindi il buffer nella funzione lcdUpdate() non ci serve più. La funzione prende come argomenti 3 valori: r la riga, c la colonna e n il valore da visualizzare sul display. Non ci serve altro che chiamare questa funzione da dentro la funzione lcdUpdate(), vediamo come appare la nuova lcdUpdate():

void lcdUpdate() {
    static uint16_t p0 = 1024;
    static uint16_t p1 = 1024;
    static uint16_t p2 = 1024;
    static uint16_t p3 = 1024;
    
    if (p0 != pot0) {
        p0 = pot0;
        lcdPrintPot(0, 3, pot0);
    }

    if (p1 != pot1) {
        p1 = pot1;
        lcdPrintPot(0, 12, pot1);
    }

    if (p2 != pot2) {
        p2 = pot2;
        lcdPrintPot(1, 3, pot2);
    }

    if (p3 != pot3) {
        p3 = pot3;
        lcdPrintPot(1, 12, pot3);
    }
}

Grazie alla funzione lcdPrintPot() abbiamo risparmiato 2 istruzioni per ogni if. Al codice mancano i commenti in testa ad ogni funzione e questo non è buono. Scrivere i commenti richiede parecchio tempo, poiché serve curare la qualità dei commenti che diventano inutili se descrivono ciò che appare ovvio. Non esiste una guida da seguire per scrivere commenti utili e sensati, l’unico modo e dedicargli tempo.

Avrete notato come non sia necessario mettere mano alla funzione loop, che non ha subito modifiche. Se la nostra demo viene usata come punto di partenza per derivare una applicazione più complessa, allora potremmo pensare di creare una funzione di supporto di nome readPot() che contiene le 4 analogRead(), vediamola:

void readPot() {
    pot0 = analogRead(A0);
    pot1 = analogRead(A1);
    pot2 = analogRead(A2);
    pot3 = analogRead(A3);
}

Allora modifichiamo la funzione loop() come di seguito:

void loop() {
    uint32_t dt = millis() - saveMillis;
    if (dt > 50) {
        saveMillis = millis();
        readPot();
        lcdUpdate();
    }
}

Noi aggiorniamo il display con i valori appena letti alla frequenza di 1/0.05s = 20Hz, detto in altri termini vuole dire che eseguiamo 20 aggiornamenti al secondo.
Qualora la frequenza dovesse sembrarvi troppo alta, potrete sperimentare con un progetto reale e verificare quale frequenza sia più adatta, ma non dimenticate che, maggiore sarà la frequenza, minore sarà il tempo CPU che ci resta per fare altre cose, quindi occorre trovare un compromesso.

La stringa di formato

La stringa di formato ci permette di configurare:

  • Il numero di variabili da formattare
  • Il tipo di formattazione da eseguire su ogni variabile

Per ogni variabile da formattare, ci deve essere nella stringa di formato la specifica di formattazione. Ogni specifica di formattazione inizia con il carattere %, seguito da almeno un carattere di conversione.
Ad esempio per formattare due variabili di tipo unsigned int la stringa di formato sarà simile a: “%u %u”. Quindi due specifiche di formattazione per due variabili, tre specifiche per tre variabili e così via.

Caratteri di conversione

  • s richiede che la variabile associata sia di tipo puntatore a C string.
  • c richiede che la variabile associata sia di tipo char.
  • u richiede che la variabile associata sia di tipo unsigned int. Se la variabile è di tipo int o long sarà convertita nel tipo unsigned int, ad esempio int ivar = -100, visualizza 65436. Mentre con long lvar = -10000 visualizza 55536.
  • d richiede che la variabile associata sia di tipo int. Se la variabile è di tipo unsigned long verrà convertita in int, ad esempio unsigned long lu = 10000000, visualizzerà -27008.
  • l é il modificatore di lunghezza long che posto prima di u, così: lu, è equivalente a long unsigned e la variabile associata dovrà essere di tipo unsigned long. Usato con d sarebbe ld long int, la variabile associata dovrà essere di tipo long.
  • h è il modificatore di lunghezza short. Il tipo di dato short in avr8-bit equivale a int. Vista l’equivalenza si può benissimo fare a meno di h, ma se il programma dovrà essere portabile verso altre MCU (piattaforme) nella quale il tipo int è grande 32-bit allora il tipo short potrebbe essere grande 16-bit.
  • f sta per float ed è pensato per convertire dati in singola precisione. Ciò ci permette di stampare valori come 0.125, 1.25, 12.5 ecc. Tuttavia con arduino IDE con core AVR f stampa un punto interrogativo al posto della cifra e ciò è dovuto al fatto che questo tipo di conversione impegna molta memoria flash e pertanto si è deciso di disabilitare f, stessa sorte per e, E, g, G. Con core ARM ad esempio pi-pico 2040 la conversione è abilitata.
  • g Vedi f.
  • e Vedi f.
  • x converte la variabile intera preferibilmente senza segno in rappresentazione esadecimale. Ad esempio se xv = 255, %x stamperà ff. Mentre %X stamperà FF.
  • o converte la variabile intera preferibilmente senza segno in rappresentazione ottale. Se il sistema di numerazione esadecimale impiega 16 simboli, il sistema ottale nel impiega 8 (0÷7).

flags modificatori

Un flag viene inserito alla destra del simbolo %, il comportamento è specifico per ogni carattere di conversione. Ad esempio %#o visualizza uno 0 davanti al valore ottale, mentre %#x visualizza 0x davanti al valore esadecimale.

  • - allinea a sinistra all’interno del campo dati. Di default l’allineamento è a destra.
  • + visualizza il segno + o - davanti al numero.
  • spazio uno spazio viene visualizzato solo davanti i numeri positivi (non deve essere presente il +).
  • 0 riempie un campo di 0, ad esempio %04d mostra 0000, 0001 ecc.
  • #
    • con ottale, %#o visualizza uno 0 davanti al valore ottale.
    • con esadecimale, %#x visualizza 0x davanti al valore esadecimale.
    • con float, %#.0f forza la visualizzazione del punto decimale anche se la precisione (.0) indica zero decimali. Ad esempio float fvar = 2.0; stamperà ’2.` senza lo zero.

Larghezza campo e precisione

Prima del carattere di conversione è possibile specificare la larghezza di un campo e la precisione. L’esempio %04d specifica larghezza campo di 4 caratteri e stamperà 0001, 0002 ecc. Mentre %4d sostituisce con spazio gli zeri. Dopo la larghezza di campo possiamo opzionalmente specificare la precisione con .2, es %4.2f dove 4 è la larghezza di campo e .2 la precisione, ciò vuole dire che il valore non deve superare 9.99 se voglio che si rispetti la larghezza di campo, infatti 10.00 richiede una larghezza di campo di 5 caratteri. Se non specifico la precisione (.2) questa di default sarà .6 cioè 6 decimali. La precisione con %s riduce il numero di caratteri stampati, ad esempio %.4s stamperà Hell anziché Hello.

Virgola mobile su pi-pico

Su questa piattaforma possiamo usare i caratteri di conversione e (E), f, g (G). f è già stato descritto sopra, ma poiché è il più usato più avanti ci sono degli esempi. g ed e richiedono una descrizione più approfondita anche se non mi è capitato di doverli usare per visualizzare valori sul display.

e o E visualizza il valore in notazione esponenziale equivalente alla notazione scientifica, cioè 1234.12 in notazione scientifica sarebbe 1.23412 x 103. In notazione esponenziali verrà visualizzato 1.23412e+03, ciò indica che 1.23412 è moltiplicato per 10 elevato alla 3°, cioè per 1000. Questo vuole dire che ci sarà sempre e solo un numero prima del punto. Se al posto di e usiamo E sarà visualizzato 1.23412E+03.

g o G arrotonda e converte il valore usando f o e (E) in base alla grandezza del valore. Alcuni esempio con g (G):

  • sprintf(sprintBuff, "%#g", 123.120); usa f e stampa: 123.120
  • sprintf(sprintBuff, "%g", 123.120); stampa: 123.12
  • sprintf(sprintBuff, "%g", 123.124580); stampa 123.125.
    Arrotonda e visualizza come f.
  • sprintf(sprintBuff, "%g", 1234567.12); stampa 123457e+06.
    Arrotonda e visualizza in notazione esponenziale.
  • sprintf(sprintBuff, "%.9g", 1234567.12); stampa 1234567.12.
    La precisione .9 non si riferisce più ai decimali dopo la virgola ma al numero di digit presenti nel valore, escluso il punto decimale. In questo caso ci sono 9 digit.

Esempi con float su pi-pico 2040

Contatore da 0.0 a 1.0 con incrementi di 0.01.

  • Larghezza di campo 4 incluso il punto decimale.
  • precisione .2, due cifre decimali dopo il punto.
    float fnum = 0.0;
    while (true) {
        sprintf(sprintBuff, "%4.2f", fnum);
        lcd.setCursor(0, 0);
        lcd.print(sprintBuff);
        fnum += 0.01;
        if (fnum > 1.0)
            break;
        delay(10);
    }

Il codice qui sopra è adatto a stampare un valore in virgola mobile nel range 0.00÷9.99. Se il valore supera 9.99, cioè 10.00, non verrà allineato correttamente, stessa cosa se fnum = -1.0.

Numeri reali negativi e positivi allineati

Quando non specifichiamo il flag + i valori positivi sono privi del segno +. Quando il numero è negativo viene visualizzato il segno - e questo è un carattere che deve essere previsto nella larghezza di campo.

  • Larghezza di campo 5 incluso punto decimale e segno.
  • precisione .2, due cifre decimali dopo il punto.
  float fnum = -1.0;
  sprintf(sprintBuff, "%+5.2f", fnum);
  lcd.setCursor(0, 0);
  lcd.print(sprintBuff);

  fnum = 1.0;
  sprintf(sprintBuff, "%+5.2f", fnum);
  lcd.setCursor(0, 1);
  lcd.print(sprintBuff);

Come sempre qui il progetto su wokwi con il quale verificare e sperimentare online con sprintf.