
L’allocazione dinamica della memoria, ovvero l’uso di funzioni come malloc()
, calloc()
, realloc()
e free()
, è ampiamente sconsigliata nei sistemi embedded. Questa pratica, comune nella programmazione per desktop e server, introduce numerosi problemi quando applicata a microcontrollori e dispositivi con risorse limitate.
Le linee guida di sicurezza, come lo standard MISRA C, vietano esplicitamente l’uso della memoria dinamica, e questa raccomandazione non si applica solo ai sistemi critici, ma a qualsiasi applicazione embedded. Ma perché l’allocazione dinamica è così pericolosa? Questo articolo analizza nel dettaglio le problematiche associate, confrontando i vantaggi dell’allocazione statica rispetto a quella dinamica e offrendo alternative per una gestione efficiente della memoria.
I problemi dell’allocazione dinamica nei sistemi embedded
L’allocazione dinamica introduce molteplici problemi, tra cui:
-
Indeterminatezza nell’allocazione a runtime
-
Possibile indisponibilità della memoria
-
Spreco di spazio
-
Frammentazione della memoria heap
-
Overhead di esecuzione
-
Frammentazione dei dati
-
Imprevedibilità della quantità di memoria allocata
-
Memory leaks e dangling pointers
-
Problemi con le interfacce delle funzioni di allocazione
- Il problema del double free
- Se si usa malloc() e realloc() correttamente, i problemi spariscono?
- Rust: la soluzione definitiva alla gestione della memoria?
Vediamo nel dettaglio ognuno di questi problemi.
Allocazione non deterministica a runtime
Nei sistemi embedded, la prevedibilità è fondamentale. La memoria disponibile è limitata e deve essere gestita in modo estremamente preciso.
Quando si usa l’allocazione dinamica, il programma non può sapere in anticipo quanta memoria sarà richiesta e quando sarà rilasciata. Questo è particolarmente critico nei sistemi real-time, dove ogni operazione deve rispettare vincoli temporali stringenti. L’allocazione dinamica introduce ritardi non prevedibili, rendendo impossibile garantire il rispetto di queste tempistiche.
Inoltre, mentre l’uso dello stack può essere stimato e controllato, la memoria heap cresce e si riduce in maniera imprevedibile, aumentando il rischio di errori catastrofici.
Possibile esaurimento della memoria
Un altro problema chiave è che la memoria potrebbe non essere disponibile quando necessaria. Se un’allocazione fallisce (ad esempio, perché l’heap è pieno), il programma potrebbe non essere in grado di recuperare dallo stato di errore.
Nei sistemi desktop, un programma può gestire la mancanza di memoria mostrando un messaggio di errore o terminando in modo controllato. Nei sistemi embedded, invece, non esiste un sistema operativo avanzato che possa gestire la situazione: in molti casi, l’unica opzione è un reset del microcontrollore, con possibili conseguenze disastrose.
Un errore comune è non verificare se malloc()
restituisce NULL
. Se il programmatore dimentica di controllare il valore di ritorno, il programma potrebbe tentare di accedere a una memoria non allocata, causando un comportamento imprevedibile.
Spreco di spazio
L’allocazione dinamica non utilizza la memoria in modo efficiente. Anche se una variabile viene deallocata, la memoria occupata dalla heap non viene automaticamente restituita ad altre parti del programma.
Inoltre, la memoria riservata per l’heap è definita a link-time e rimane fissa, indipendentemente dall’effettivo utilizzo. Questo significa che una porzione di RAM potrebbe restare inutilizzata, senza poter essere assegnata ad altre risorse critiche.
Esempio pratico
Immaginiamo un sistema con 8 KB di RAM:
-
Se allocassimo 2 KB per lo stack e 6 KB per l’heap, quei 6 KB resterebbero occupati, anche se ne usiamo solo 1 KB.
-
Con un’allocazione statica, invece, quei 6 KB potrebbero essere sfruttati al meglio per buffer o altre strutture dati.
Frammentazione della memoria heap
Ogni volta che viene allocata e rilasciata memoria dinamicamente, si creano piccoli buchi all’interno dell’heap. Col passare del tempo, lo spazio disponibile diventa sempre più frammentato, riducendo la capacità di allocare blocchi di memoria di dimensioni maggiori.
Nei sistemi con risorse limitate, questo fenomeno può portare a situazioni in cui la memoria totale è sufficiente, ma non esiste un blocco contiguo abbastanza grande per soddisfare una richiesta di allocazione.
Soluzione: utilizzare la memoria statica e suddividere lo spazio in buffer preallocati.
Overhead nell’esecuzione
Le funzioni di allocazione dinamica introducono un costo computazionale elevato. Ogni chiamata a malloc()
o free()
comporta un’operazione di ricerca all’interno della struttura dati della heap, con un impatto significativo sulle prestazioni.
Nei sistemi embedded, l’efficienza del codice è cruciale, quindi ogni millisecondo perso in overhead non necessario deve essere eliminato.
Frammentazione dei dati e cache inefficiente
Nei microcontrollori avanzati, come ARM Cortex-M4 o PowerPC e200, l’accesso ai dati avviene tramite cache. Se i dati sono frammentati nella memoria heap, il processore potrebbe impiegare più tempo per caricare le informazioni, peggiorando le prestazioni.
Soluzione: allocare i dati in aree contigue della RAM per sfruttare al meglio le cache del processore.
Imprevedibilità della memoria allocata
L’allocazione dinamica non garantisce un uso prevedibile della memoria: ogni chiamata a malloc()
può includere overhead per metadati e padding, riducendo ulteriormente l’efficienza dell’heap.
Esempio:
Il programmatore pensa di aver allocato 10 byte, ma il sistema potrebbe aver riservato 16 o più byte per gestire il blocco di memoria.
Memory leaks e dangling pointers
Un memory leak si verifica quando un blocco di memoria allocato non viene mai rilasciato, causando un progressivo esaurimento della memoria disponibile.
Un dangling pointer, invece, è un puntatore che fa riferimento a una memoria che è stata deallocata. L’accesso a un dangling pointer può portare a comportamenti imprevedibili e crash.
Per evitare questi problemi, il codice dovrebbe essere scritto in modo tale da non dipendere mai da malloc()
e free()
.
Interfacce problematiche delle funzioni di allocazione
Le funzioni malloc()
, realloc()
e free()
hanno interfacce poco sicure:
-
malloc()
non specifica la quantità effettiva di memoria allocata. -
realloc()
può restituire un nuovo puntatore, invalidando quello originale. -
free()
non azzera il puntatore, causando possibili dangling pointers.
Nei sistemi embedded, questa mancanza di sicurezza è inaccettabile.
Il problema del double free
Uno degli errori più pericolosi nell’uso dell’allocazione dinamica è il double free, ovvero la deallocazione della stessa area di memoria più di una volta.
Quando una porzione di memoria viene liberata con free()
, il sistema la segna come disponibile. Se il programmatore tenta di chiamare free()
di nuovo sullo stesso puntatore senza averlo riassegnato o azzerato, il comportamento è indefinito.
Esempio di double free in C
Nel migliore dei casi, il programma crasha immediatamente. Nel peggiore, potrebbe continuare a funzionare con un comportamento imprevedibile, perché l’area di memoria precedentemente liberata potrebbe essere stata riutilizzata da altre parti del programma.
Come evitare il double free
-
Dopo aver chiamato
free()
, assegnare il puntatore a NULL: -
Usare strumenti come AddressSanitizer, Valgrind o debug malloc per individuare errori di memoria.
-
Implementare strategie di gestione della memoria sicura, come tabelle di allocazione o wrapper per
malloc()
efree()
.
Se si usa malloc() e realloc() correttamente, i problemi spariscono?
C’è un’idea diffusa che se si usa correttamente l’allocazione dinamica, tutti i problemi possano essere evitati. In parte è vero: un programmatore esperto può scrivere codice robusto, evitando molti degli errori comuni.
Tuttavia, anche con la massima attenzione:
-
Il comportamento del sistema operativo o del runtime può influenzare le allocazioni. La gestione della memoria dipende dall’implementazione della libc o del firmware.
-
Heap fragmentation è un problema intrinseco. Anche se si usa
malloc()
correttamente, la frammentazione della memoria non può essere completamente evitata senza una gestione avanzata. -
Ogni chiamata a malloc() introduce overhead. Anche se
malloc()
viene usato con precisione chirurgica, il costo di gestione della memoria dinamica rimane un problema nei sistemi embedded.
Anche nei sistemi desktop e server, dove le risorse sono abbondanti, gli errori di gestione della memoria sono tra le cause più comuni di crash e vulnerabilità. Per questo motivo, i linguaggi moderni stanno sviluppando soluzioni per evitare questi problemi alla radice.
Rust: la soluzione definitiva alla gestione della memoria?
Il linguaggio Rust offre un approccio completamente diverso alla gestione della memoria, eliminando alla radice molti dei problemi discussi finora.
Come Rust evita i problemi della memoria dinamica
-
Ownership e Borrowing
-
In Rust, ogni variabile ha un proprietario e può avere riferimenti mutabili o immutabili, ma non entrambi contemporaneamente.
-
Questo evita dangling pointers, use-after-free e double free.
Esempio:
-
-
No malloc(), no free()
-
In Rust, la memoria è automaticamente rilasciata quando il valore esce dal suo scope, senza bisogno di
free()
. -
Questo impedisce i memory leaks.
-
-
No heap fragmentation
-
La gestione della memoria in Rust è basata su allocatori efficienti e su stack-first allocation.
-
-
Sicurezza senza Garbage Collector
-
Diversamente da linguaggi come Java o Python, Rust evita gli errori di memoria senza bisogno di un garbage collector.
-
Esempio pratico: allocazione sicura in Rust
Grazie a queste caratteristiche, Rust è ideale per lo sviluppo embedded e di sistemi critici, dove la sicurezza della memoria è fondamentale.
Conclusione
Kit consigliati:
Link utili