mercoledì 20 gennaio 2021

Arduino: Array e array di caratteri (C string)

Array o vettore

Array o vettore è una porzione di memoria a cui attribuiamo un nome e una dimensione. L'accesso in lettura e scrittura avvengono tramite operatore sottoscrizione [], al cui interno si specifica l'indice numerico. Vediamo prima la dichiarazione senza inizializzazione e poi l'accesso.

Dichiarazione

byte unoArray[5];

byte è il tipo di dato usato per ogni elemento. Altri tipi predefiniti sono: int, word, unsigned long ecc. unoArray è il nome che abbiamo attribuito alla porzione di memoria. 5 è la dimensione del vettore espressa in numero di elementi di tipo byte in questo caso.

Accesso agli elementi di array

Il primo elemento di unoArray è accessibile a partire dall'indice zero (0), l'ultimo elemento è allora 5 - 1 = 4, o in altri termini n elementi meno 1. Attenzione che questo crea confusione ed è facile cadere in errore accedendo ad un elemento non lecito.

unoArray[0] = 1;
unoArray[1] = 2;
unoArray[2] = 3;
unoArray[3] = 4;
unoArray[4] = 5;

Di seguito invece l'errore di accedere ad un elemento non esistente:

unoArray[5] = 6;

Attenzione che l'errore non viene segnalato dal compilatore, spetta al programmatore fare attenzione a questo errore che ha conseguenze non prevedibili. In ogni caso questo errore compromette l'integrità dei dati presenti in memoria. Un modo sicuro per usare indici validi e di verificare che l'indice non superi il numero di elementi meno 1.

Occupazione di memoria

L'occupazione di memoria lo esprimiamo in byte e nel primo esempio il numero di elementi coincide con l'occupazione di memoria, cioè 5 byte. Se al posto del tipo byte, usiamo il tipo int l'occupazione in memoria ammonta a 2 x 5 = 10 byte, ma sempre di 5 elementi sarà composto il vettore. Segue un esempio con il tipo longche occupa 4 x 5 = 20 byte.

Dichiarazione e inizializzazione

Possiamo assegnare un valore ad ogni elemento durante l'esecuzione del programma (detto run-time) oppure inizializzare il vettore durante la compilazione (detto compile-time). Assegnare un valore a run-time lo abbiamo già visto, di seguito inizializziamo il vettore a compile-time.

byte byteArray[] = { 1, 2, 3, 4, 5 };

Come si vede manca il numero 5 tra le parentesi quadre, ma il compilatore lavora correttamente ricavando la dimensione dall'elenco specificato tra le parentesi graffe. Poiché l'inizializzazione avviene a compile-time, nell'elenco possono comparire solo costanti. Se si specifica la dimensione del vettore questa deve corrispondere al numero di elementi specificati tra le graffe, diversamente ad esempio, nel caso specifico, se minore di 5, il compilatore emette un errore, se maggiore di 5, gli altri elementi sono inizializzati a zero (0).

Array di char (C string)

Ciò che è conosciuto come C string è in effetti un vettore contenente codice ascii in cui l'ultimo elemento è il codice 0, usato come terminatore di elementi utili. In altri termini, poiché non è efficiente salvare un simbolo grafico all'interno di un elemento, si usa un numero nel range 0÷255 che occupa un solo byte e ad ogni numero è associato un simbolo rappresentabile o meno, per approfondire circa la tabella ascii segui il link.

Dichiarazione di C string

La dichiarazione è simile a quanto già visto, sostituendo a byte il tipo predefinito char, esempio:

char str[7];

Dove str è una C string valida poiché se dichiarata globale tutti gli elementi sono automaticamente inizializzati a zero, nonostante occupi 7 byte, il numero di caratteri utili è nullo. In questo caso str viene detto genericamente buffer. Per usare str nel modo appropriato dobbiamo fare attenzione a non modificare il valore dell'ultimo elemento che vale 0 come tutti gli altri, solo così preserviamo la C string, vediamo un esempio:

for (byte i = 0; i < 6; i++) {
str[i] = 65 + i;
}

Se stampiamo str con Serial.println(str); otteniamo il seguente output sul monitor seriale:

ABCDEF

Se stampiamo ogni singolo elemento in str otteniamo:

65, 66, 67, 68, 69, 70, 0

L'ultimo elemento vale 0 e quindi str è una C string valida. Essa è composta da 6 caratteri rappresentabili e l'elemento all'indice 6 è il terminatore. Molte funzioni della libreria standard string.h fanno affidamento sulla presenza del terminatore C string e in mancanza il loro comportamento è imprevedibile, ad esempio la funzione strlen() restituisce la lunghezza di str contando tutti i caratteri presenti tranne lo 0.

Quindi ad esempio Serial.println(strlen(str)); stamperà:

6

Errori insidiosi

Per comprendere quanto la cattiva gestione degli indici sia disastrosa ho voluto scrivere un programma errato che casualmente si comporta correttamente. Nel programma sovrascrivo il terminatore di stringa ma la funzione strlen() restituisce un valore plausibile ma errato.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
void setup() {
    Serial.begin(9600);
    char str[7] = "";
    char str1[2] = "";
    for (byte i = 0; i < 7; i++) {
        str[i] = 65 + i;
    }
    Serial.println(str);
    Serial.print("strlen(str) = ");
    Serial.println(strlen(str));
    delay(1000);
}

void loop() { 
    // empty loop
}

La riga 8 stampa:

ABCDEFG

Le righe 9÷10 stampa:

strlen(str) = 7

Il programma lavora apparentemente corretto e non si presentano comportamenti anomali casuali, tuttavia str non è una C string valida in quanto priva di terminatore '\0'. La lunghezza di str vale 7, ma se modifichiamo la riga 4 in questo modo:

char str1[] = "Abbbbb";

Eseguendo il programma adesso la funzione strlen(str) restituisce 13, e guarda caso 7 + 6 = 13. dove 6 è la lunghezza della C string str1, ma la cosa divertente è che riga 8 stampa str + str1, cioè: ABCDEFGAbbbb.

Il motivo di ciò è dovuto al terminatore presente solo in str1 e Serial.print() si ferma di stampare solo quando trova l'unico terminatore di C string . Ora proviamo immaginare che Serial.print() non trovi un terminatore e non si fermi di stampare, questo sarebbe davvero un bel problema che potrebbe portare il programma a comportarsi in modo casuale fino a bloccarsi del tutto.

L'uso delle C string mette in difficoltà anche i programmatori professionisti, figuriamoci come se la possa cavare un principiante. Tuttavia i vettori e le C string sono troppo efficienti per non usarle e cercare un loro sostituto è spesso tempo perso. Un suo possibile sostituto è la classe String() di arduino che tuttavia alla lunga produce effetti disastrosi, perdendo al contempo di efficienza, questo è vero con arduino UNO, meno con la MEGA che dispone di maggiore RAM. Discorso diverso su piattaforma ESP32, dove l'uso moderato e coscienzioso raramente produce disastri.

Uso ricorrente

Con arduino spesso si ricorre ai vettori all'interno del quale specificare quali pin configurare ad esempio come uscita. Ciò è particolarmente conveniente quando molti pin devono essere configurati nella stessa modalità, inoltre ciò risulta pure efficiente se il vettore viene dichiarato all'interno di una funzione anziché essere globale. Il codice banalmente è composto da un ciclo for al cui interno chiamare la funzione pinMode(), anche se banale vediamo come fare ciò all'interno della funzione setup():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
void setup() {
    Serial.begin(9600);
    byte pinsAsOutput[] = {4, 5, 7, 8, 10, 11, 13};
    char str1[2] = "";
    for (byte i = 0; i < sizeof(pinAsOutput); i++) {
        pinMode(pinsAsOutput[i], OUTPUT);
    }
    
    delay(1000);
}

void loop() { 
    // empty loop
}

Certamente si evita di scrivere 7 pinMode(... nel setup, bene tanto di guadagnato, tuttavia si può migliorare aggiungendo un commento alla riga 3. Inoltre ciò ci da la possibilità di mostrare l'uso della istruzione sizeof() grazie a ciò non rischiamo di commettere un errore contando il numero di elementi presenti nel vettore, vantaggio non da poco, inoltre possiamo modificare il numero di elementi senza preoccuparci di altro. Sempre con scopo didattico possiamo anche creare una funzione da chiamare alla quale passiamo 3 argomenti, cioè il vettore, la dimensione e la modalità (INPUT, OUTPUT ecc). Vediamola subito a seguire.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
void setPins(const byte *arr, size_t size, uint8_t mode) {
    for (byte i = 0; i < size; i>++) {
        pinMode(arr[i], mode);
    }
}

void setup() {
    Serial.begin(9600);
    byte pinsAsOutput[] = { 4, 5, 7, 8, 10, 11, 13 };
    
    setPins(pinsAsOutput, sizeof(pinsAsOutput), OUTPUT);
    delay(1000);
}
void loop() { 
    // empty loop
}

L'operatore sizeof non restituisce il numero di elementi di cui è composto il vettore ma la dimensione in byte occupata, nel caso specifico pinAsOutput occupa 7 byte ed ha 7 elementi e il problema non si pone ma se ogni elemento fosse grande 2 byte, sizeof restituirebbe 14.

L'operatore sizeof

L'operatore sizeof è risolto a tempo di compilazione e questo ci permette di usarlo per creare array la cui dimensione è ricavata da altri array, segue un esempio:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
const byte message[] = { 0x95, 0x01, 0x00, 0x20
                       , 0x00, 0x00, 0x00, 0x00
                       , 0x00, 0x00, 0x00, 0x00
                       , 0x00, 0x00, 0x00, 0x00
                       , 0x00, 0x33 };

byte dest[sizeof message];

void setup() {
    Serial.begin(115200);
    Serial.println(sizeof message);
    Serial.println(sizeof dest);
}

void loop() {
    // empty loop
}

La dimensione del vettore message viene ricavata in automatico dal compilatore, mentre il vettore dest viene dimensionato ricavando la dimensione del vettore message attraverso l'operatore sizeof.

Alla riga 11 e 12 stampiamo la dimensione espressa in byte degli array message e dest ed entrambe hanno lo stessa dimensione (18 byte).

Quanto mostrato è valido solo nel caso in cui gli elementi del vettore sono di tipo byte, char o uint8_t, grandi 8-bit cioè 1 byte. Nel caso seguente vediamo come ricavare la dimensione in elementi di tipo più grande di 1 byte, ad esempio con un vettore di tipo long. Il programma è sempre lo stesso ma alla riga 1 e 7 al posto di byte ci scriviamo long. Il programma stampa due numeri differenti:

Dimensione in byte di message :     72
Dimensione in byte di dest :    288

Possiamo giustificare questi valori poiché sappiamo che il tipo long è grande 4 byte, per cui 72 / 4 = 18 elementi di tipo long. Il vettore dest è composto da 288 / 4 = 72 elementi di tipo long. L'operatore sizeof usato così sizeof(long) restituisce la dimensione in byte del tipo specificato tra parentesi e nel caso specifico resituisce il valore 4. Allora se volessimo stampare di quanti elementi è composto il vettore dest potremmo benissimo scrivere:

Serial.println(sizeof dest / sizeof(long));

Oppure ancora meglio:

Serial.println(sizeof dest / sizeof dest[0]);

Risulta migliore perché grazie alla sintassi sizeof dest[0] è il compilatore che ricava la dimensione del tipo di dato usato per dichiarare il vettore dest . Mentre nel caso di sizeof(long) è nostra responsabilità specificare il tipo di dato giusto. Quanto mostrato adesso dovrebbe suggerirci qualcosa, poiché siamo pigri e meno scriviamo meglio è per noi, proviamo a creare una macro del preprocessore di nome ITEMOF , così digitiamo qualche carattere in meno, ok vediamola:

#define ITEMOF(a) (sizeof a / sizeof a[0])

Proviamo ad usarla per dichiarare il vettore dest e per stampare il numero di elementi di cui è composto il vettore:

long dest[ITEMOF(message)];

Per stampare usiamo le due righe seguenti all'interno della funzione setup():

Serial.println(ITEMOF(message));
Serial.println(ITEMOF(dest));


Questo articolo ha subito modifiche a seguito di questo post, nel quale sono intervenuti nid69ita, Standardoil e gpb01 che ringranzio per avere argomentato.

Licenza Creative Commons
Quest' opera è distribuita con licenza Creative Commons Attribuzione - Non commerciale - Non opere derivate 3.0 Unported.