lunedì 30 maggio 2022

Arduino memset()

 

Per cosa si usa la funzione memset

La funzione torna comoda per inizializzare a run-time una porzione di memoria allocata staticamente o dinamicamente (es: tramite malloc()). La vedremo in azione con array allocati staticamente, con array locali (che risiedono nello stack) e con l’allocazione dinamica della memoria. Vediamo subito un esempio banale con un array di byte:

    uint8_t arrayOfByte[10];
    memset(arrayOfByte, 65, 10);

A tutti i Dieci elementi di arrayOfByte viene assegnato il valore 65.

void *memset(void *, int, size_t);

La funzione memset prende 3 argomenti:

  1. Un puntatore a qualunque tipo (void *).
  2. Un byte o int.
  3. Un size_t.

Restituisce un puntatore generico (void *) che punta al primo elemento di arrayOfByte.

I tre argomenti possono essere variabili o costanti manifeste, cioè come avviene nell’esempio precedente dove 65 e 10 sono le costanti. L’array ovviamente non deve essere const, ma il puntatore può esserlo.

** ATTENZIONE **

La responsabilità è tutta del programmatore, se hai allocato un array di 10 elementi e per sbaglio scrivi:

    memset(arrayOfByte, 65, 11);

sovrascrivi e corrompi il contenuto della memoria. Le conseguenze sono disastrose, in particolare quando l’array è dichiarato dentro una funzione, cioè locale, poiché l’array si trova nello stack, corromperlo comporta conseguenze imprevedibile e non recuperabili. Stessa considerazione nel caso in cui si sia allocata l’array dinamicamente (memoria heap), tramite la chiamata alla funzione malloc() o con l’operatore new. Corrompere lo heap ha ripercussioni sulla chiamata a free(ptr) per liberare la memoria. Se mi riesce voglio mostrare cosa accade quando si corrompe la memoria, il compito è arduo poiché richiede degli articoli che spiegano lo stack e lo heap, io invece ci vorrei riuscire senza scrivere questi articoli impegnativi per me.

memset alle prese con array di caratteri dichiarato globale.

Ricordiamo che le variabili di qualunque tipo, quando dichiarate globali vengono inizializzate al loro valore di default durante la compilazione. Cioè il seguente array:

    uint8_t array[10];

Se lo stampassimo con Serial.print(), vedremmo quanto segue nel serial monitor:

    0 0 0 0 0 0 0 0 0 0

Contateli se non vi fidate, sono 10 elementi di array ai quali è stato assegnato il valore zero (0). Questo assegnamento a run-time non è costato neanche un ciclo cpu. Supponiamo che a seguire la dichiarazione di array, ci sia la dichiarazione e inizializzazione di una string literal, così:

    char name[] = "Razzi";

Ora immaginiamo di stampare il contenuto della memoria ram a partire dal primo elemento di array per finire all’ultimo elemento di name.

    0 0 0 0 0 0 0 0 0 0 82 97 122 122 105 0

Dove, 82 97... sono i codici ASCII associati ai simboli R a z z i. Lo zero finale viene aggiunto dal compilatore, poiché una C string per essere valida richiede che l’ultimo elemento sia zero.

Ora immaginiamo di sbagliare a scrivere la memset e al posto di 10 ci mettiamo 11, cioè:

    memset(array, 67, 11);

Allora memset modifica il contenuto della memoria che sarà la seguente:

    67 67 67 67 67 67 67 67 67 67 67 97 122 122 105 0

Il codice ASCII 67 corrisponde alla lettera maiuscola C. Stiamo sovrascrivendo la variabile name in particolare la lettere R (codice ASCII 82) diventa C. Non è un disastro, ma se il nostro programma lo dovesse usare qualcuno a noi estraneo, forse lo troverebbe anche divertente, oppure offensivo, di certo non ci faremo una grande figura. Questo è il minimo che può accadere, il disastro invece è un programma dal comportamento casuale e non prevedibile.

memeset con array locale

Particolare attenzione la dobbiamo avere con le variabili dichiarate dentro una funzione. Per queste dichiarazioni il compilatore alloca spazio in una porzione di memoria RAM detta stack. Il valore di queste variabili è casuale se non specificamente inizializzato. Per dichiarare e inizializzare un array locale possiamo usare la seguente sintassi:

    char str[10] = {0};

Sfruttiamo la funzione setup, per verificare quanto detto.

    void setup() {
        char str[10];    // str è array locale non inizializzato
        // stampiamo nel serial monitor i 10 elementi di str
        for (byte i=0; i<10; i++) {
            Serial.print((uint8_t)str[i]);
            Serial.print(" ");
        }
    }

Nel mio caso ha stampato questo:

    0 0 0 0 2 55 1 214 2 55

Mentre dichiarando e inizializzando l’array così:

    void setup() {
        char str[10] = {0};    // str è array locale inizializzato
        // stampiamo nel serial monitor i 10 elementi di str
        for (byte i=0; i<10; i++) {
            Serial.print((uint8_t)str[i]);
            Serial.print(" ");
        }
    }

stamperà 10 zeri, poiché dentro le graffe abbiamo scritto zero. Ciò è molto importante con gli array di caratteri null termined (o C string), infatti dobbiamo stare attenti a preservare il valore dell’ultimo elemento di str. Ad esempio se avessimo necessità di avere una stringa composta da 9 spazi potremmo in sicurezza usare memset così:

    void setup() {
        char str[10] = {0};    // str è array locale inizializzato
        memset(str, ' ', 9);    // 9 spazi
        // stampiamo nel serial monitor i 10 elementi di str
        for (byte i=0; i<10; i++) {
            Serial.print((uint8_t)str[i]);
            Serial.print(" ");
        }
    }

La stampa sarà la seguente:

    32 32 32 32 32 32 32 32 32 0

Il codice ASCII 32 corrisponde allo spazio ’ ’. Per compromettere la validità della C string str ci basta commettere l’errore di scrivere 10 al posto di 9 nella memset, sovrascrivendo di fatto lo zero finale che indica la fine della stringa. Tutte le funzioni C che operano sulle stringhe si aspettano lo zero finale, se manca il risultato è imprevedibile.

memset con malloc

La funzione malloc alloca al tempo di esecuzione (run-time), il numero di byte specificato come argomento. Se malloc non trova spazio libero, restituisce un puntatore nullo, altrimenti restituisce un puntatore al primo byte allocato. La funzione non azzera la memoria allocata, la quale dovrebbe comunque essere stata inizializzata a zero da codice generato dal compilatore.

Dopo avere allocato memoria la usiamo e ci scriviamo quello che vogliamo, quando non ci serve più, chiamiamo la funzione free(ptr) dove ptr è il puntatore restituito da malloc. Bene free libera la memoria e la azzera anche, ciò dovrebbe garantire che ogni byte di memoria de-allocata, abbia valore zero.

Anche in questo caso dobbiamo fare la massima attenzione al numero di ripetizioni specificate in memset e valgono le stesse considerazioni fatte prima, tuttavia se sbagliamo, potrebbe pure non accadere nulla di non atteso, tutto dipende dalla sequenza di operazioni che eseguiamo. Per spiegare come mai può o meno sorgere un problema devo mostrarvi del codice e relativo dump di memoria ram. Anche nel caso in cui non accade nulla di anomalo, il bug rimane latente e basta una piccola e apparentemente innocua modifica al codice per compromettere il funzionamento del programma.

Per prima cosa vi mostro il codice per allocare 10 byte in memoria heap.

    void setup() {
        char *charPtr = (char*)malloc(10);      // (1)
        for (byte i=0; i<10; i++) {             // (2)
            Serial.print((uint8_t)charPtr[i]);
            Serial.print(" ");
        }
        Serial.println();
        Serial.println((uint16_t)charPtr, HEX); // (3) 
        free(charPtr); // obbligatorio poichè charPtr è una variabile locale
    }

La riga (1) alloca 10 byte e charPtr punta al primo byte allocato. La seconda riga (2) è il ciclo for visto prima che stampa in questo caso 10 zeri. La riga 3 stampa l’indirizzo di memoria a cui punta charPtr, mi serve questo dato per potere avviare il dump della memoria che vi mostro di seguito:

    1FC è l'indirizzo a cui punta `charPtr` 
                                                *
    ADDR    00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
    1F0     00 02 00 40 10 50 06 02 00 00 0a 00 00 00 00 00 
    200     00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 

Ciò che è di nostro interesse sono gli indirizzi 1FA e 1FB. In 1FA c’è il valore 0a che in decimale vale 10. Questi due byte precedenti l’indirizzo 0x1FC sono usati da malloc e free, free usa questi due byte per azzerare 0x0a (10) byte a partire dall’indirizzo 0x1FC. Appare evidente che se per errore al posto di 0x0a ci scrivo 0xFF free libererà 255 byte anziché 10. Sarebbe logico pensarlo, ma stranamente malloc usa altre porzioni di memoria dove salva altre info e magicamente free() lavora anche se modifichiamo il valore 0a all’indirizzo 1FA.

Ho provato ad indagare come avviene questa magia studiando la funzione malloc(), che tuttavia anche se breve, risulta davvero complessa, per cui l’obbiettivo di mostrare cosa accade se per errore sovrascriviamo lo heap è rimandato a momenti di maggiore lucidità mentale.

Quanto detto è in generale applicabile a qualunque codice che direttamente o indirettamente scrive in ram. In pratica tutte le funzioni della libreria standard C, se usate male possono compromettere il funzionamento del programma, ma questo non è da considerare un difetto.