Come funziona il linguaggio C: memoria, puntatori e compilazione spiegati davvero
Dal sorgente all'eseguibile, da stack e heap all'undefined behavior: cosa succede sotto il cofano quando scrivi in C, e perché conviene saperlo anche se programmi in altri linguaggi.
8 min di letturaStoria dei linguaggi
Luigi Iadicola, Programmatore - Napoli
In questo articolo
Capire come funziona il linguaggio C significa capire cosa fa davvero un computer quando esegue un programma. In la storia di C abbiamo visto da dove arriva questo linguaggio; qui scendiamo di un livello: come un file sorgente diventa un eseguibile, dove finiscono le variabili in memoria, cosa sono davvero i puntatori e perché in C un errore piccolo può diventare un problema enorme.
Dal sorgente all'eseguibile: le quattro fasi
Quando lanci gcc main.c -o main sembra che succeda tutto in un colpo. In realtà la toolchain attraversa quattro fasi distinte, e ognuna si può osservare separatamente:
gcc -E main.c -o main.i # 1. preprocessore
gcc -S main.c -o main.s # 2. compilazione in assembly
gcc -c main.c -o main.o # 3. assemblaggio in codice oggetto
gcc main.o -o main # 4. linking
Il preprocessore lavora sul testo: espande le direttive #include copiando fisicamente il contenuto degli header, sostituisce le macro #define e valuta le compilazioni condizionali #ifdef. Non conosce i tipi né la sintassi di C: fa sostituzioni testuali, ed è per questo che le macro scritte male producono bug sorprendenti.
Il compilatore vero e proprio trasforma il C in assembly per l'architettura di destinazione, ed è qui che avvengono le ottimizzazioni. L'assembler traduce l'assembly in un file oggetto con codice macchina e una tabella di simboli. Infine il linker unisce i file oggetto e le librerie, risolvendo i riferimenti: se chiami printf, è il linker a collegarla alla libreria standard.
Questa separazione spiega anche due classici messaggi d'errore: "undeclared identifier" arriva dal compilatore, che non ha visto la dichiarazione; "undefined reference" arriva dal linker, che ha visto la dichiarazione ma non trova la definizione.
I tipi: meno garantiti di quanto sembri
In C la dimensione di un int non è fissata dallo standard: è garantito un intervallo minimo, ma la dimensione reale dipende dalla piattaforma. Oggi su quasi tutti i sistemi desktop un int occupa 4 byte, ma su molti microcontrollori ne occupa 2. Anche il fatto che char sia con o senza segno dipende dall'implementazione.
Per questo il codice che deve essere portabile usa i tipi di <stdint.h>, introdotti con C99:
#include <stdint.h>
uint8_t byte; // esattamente 8 bit, senza segno
int32_t valore; // esattamente 32 bit, con segno
uint64_t contatore;
C'è poi il padding. Il compilatore può inserire byte vuoti dentro una struct per allineare i campi agli indirizzi che il processore legge più efficientemente:
struct esempio {
char c; // 1 byte
int n; // 4 byte, di solito allineati a 4
};
// sizeof(struct esempio) è tipicamente 8, non 5
È un dettaglio che diventa importante quando si scrivono strutture su file, si parla con l'hardware o si scambiano dati in rete.
Dove vivono i dati: stack, heap e memoria statica
Un programma C in esecuzione organizza la memoria in zone con regole diverse.
Il codice del programma e le costanti stanno in aree di sola lettura. Le variabili globali e statiche vivono per tutta la durata del programma: quelle inizializzate in una sezione, quelle non inizializzate (che partono da zero) in un'altra.
Lo stack ospita le variabili locali e i dati di ogni chiamata di funzione. È velocissimo, perché allocare significa solo spostare un puntatore, ma è limitato e automatico: quando la funzione ritorna, le sue variabili smettono di esistere. Ecco perché questo codice è sbagliato:
int *crea_numero(void) {
int n = 42;
return &n; // errore: n non esiste più dopo il return
}
L'heap è la memoria che chiedi esplicitamente con malloc e che resta valida finché non la liberi con free. È flessibile, ma la responsabilità è interamente tua.
I puntatori: un indirizzo con un tipo
Un puntatore è una variabile che contiene un indirizzo di memoria, insieme all'informazione sul tipo di dato che si trova a quell'indirizzo.
int x = 42;
int *p = &x; // p contiene l'indirizzo di x
*p = 7; // scrive attraverso p: ora x vale 7
Il tipo conta soprattutto nell'aritmetica dei puntatori. Se p punta a un int da 4 byte, p + 1 non avanza di un byte ma di un intero intero: di 4 byte.
Qui entra il rapporto, spesso frainteso, tra array e puntatori. Un array non è un puntatore, ma nella maggior parte delle espressioni il suo nome "decade" nel puntatore al primo elemento:
int v[4] = {10, 20, 30, 40};
int *q = v; // equivale a &v[0]
printf("%d\n", v[2]); // 30
printf("%d\n", *(q + 2)); // 30: v[i] è definito come *(v + i)
La conseguenza pratica più insidiosa: quando passi un array a una funzione, la funzione riceve solo un puntatore. La dimensione dell'array si perde, e sizeof dentro la funzione restituisce la dimensione del puntatore, non dell'array. Per questo le API C passano quasi sempre la lunghezza come parametro separato.
Le stringhe non esistono (quasi)
C non ha un vero tipo stringa. Una stringa è un array di char terminato dal carattere nullo '\0'. Tutte le funzioni della libreria standard, da strlen a strcpy, scorrono la memoria finché non trovano quel terminatore.
È un modello semplice, ma fragile. Nessuno controlla che il buffer di destinazione sia abbastanza grande:
char nome[8];
strcpy(nome, input); // se input supera 7 caratteri: buffer overflow
// alternativa più sicura: scrive al massimo sizeof nome byte,
// terminatore compreso
snprintf(nome, sizeof nome, "%s", input);
Il buffer overflow è stato per decenni una delle vulnerabilità più sfruttate: scrivere oltre la fine di un array significa sovrascrivere altri dati, e nei casi peggiori l'indirizzo di ritorno di una funzione.
malloc e free: la memoria come responsabilità
La gestione manuale della memoria segue uno schema preciso: chiedi, controlli, usi, liberi.
int *buf = malloc(n * sizeof *buf);
if (buf == NULL) {
// allocazione fallita: va gestita
return -1;
}
/* ... uso di buf ... */
free(buf);
buf = NULL; // evita di riusare per sbaglio un puntatore non più valido
Gli errori tipici hanno nomi precisi. Il memory leak è memoria allocata e mai liberata. L'use-after-free è l'accesso a memoria già restituita. Il double free è la liberazione ripetuta dello stesso blocco. Gli ultimi due non sono solo bug: sono spesso vulnerabilità sfruttabili.
Il problema di fondo è la proprietà della memoria: chi l'ha allocata, chi la deve liberare, per quanto resta valida. C non lo esprime nel linguaggio; bisogna documentarlo e rispettarlo a mano. Linguaggi come C++ con RAII o Rust con il suo sistema di ownership nascono in buona parte per rendere esplicito proprio questo.
Undefined behavior: quando il compilatore ti prende in parola
Lo standard C definisce alcune operazioni come undefined behavior: accedere fuori dai limiti di un array, dereferenziare un puntatore nullo, leggere una variabile non inizializzata, far andare in overflow un intero con segno. In questi casi lo standard non impone nessun risultato.
La parte controintuitiva è che il compilatore, ottimizzando, può assumere che l'undefined behavior non accada mai. Un esempio classico:
int sta_per_traboccare(int x) {
return x + 1 < x;
}
L'intento è controllare l'overflow. Ma siccome l'overflow di un int con segno è undefined behavior, il compilatore può ragionare così: "x + 1 non può mai essere minore di x", e ridurre l'intera funzione a return 0. Il controllo sparisce. Il modo corretto è verificare prima di fare l'operazione, per esempio con x == INT_MAX.
Ecco perché in C "sulla mia macchina funziona" dice pochissimo: lo stesso codice può comportarsi diversamente cambiando compilatore, livello di ottimizzazione o piattaforma.
Gli strumenti che rendono C più sicuro
Scrivere C oggi senza strumenti di supporto è una scelta che non conviene fare. I più utili:
gcc -Wall -Wextra -g -fsanitize=address,undefined main.c -o main
I warning -Wall -Wextra segnalano molti errori già in compilazione. AddressSanitizer intercetta a runtime overflow, use-after-free e double free, indicando la riga esatta. UndefinedBehaviorSanitizer segnala overflow con segno, shift non validi e altri casi di undefined behavior. Valgrind è utile soprattutto per trovare memory leak e letture di memoria non inizializzata. A questi si aggiungono gli analizzatori statici, che esaminano il codice senza eseguirlo.
Nessuno di questi strumenti rende C un linguaggio memory safe, ma trasformano bug silenziosi in errori visibili durante lo sviluppo e i test.
C e la sicurezza della memoria oggi
Negli ultimi anni la sicurezza della memoria è diventata un tema centrale. Aziende come Microsoft e Google hanno riportato che una larga parte delle vulnerabilità gravi nei loro prodotti deriva proprio da errori di gestione della memoria, tipici di C e C++. Da qui la spinta verso linguaggi memory safe per il codice nuovo, con Rust entrato anche nel kernel Linux accanto al C.
Questo non rende C obsoleto. Rende però più chiaro dove ha senso usarlo: dove servono controllo totale, dipendenze minime e prevedibilità, e dove si è disposti a pagare quel controllo con disciplina, revisioni e strumenti.
Perché studiarlo anche se scrivi PHP, Java o Python
Il motore che esegue PHP, lo Zend Engine, è scritto in C. Lo stesso vale per l'interprete di riferimento di Python e per una quantità enorme di runtime, database e librerie. Capire come funziona il linguaggio C aiuta a leggere meglio messaggi di errore di basso livello, a capire perché certe strutture dati sono lente o veloci, e a ragionare su memoria, copie e riferimenti anche nei linguaggi che li nascondono.
Non serve diventare programmatori C per trarne vantaggio. Basta scrivere qualche programma, sbagliare un puntatore, far scattare AddressSanitizer e leggere l'assembly generato: è uno dei modi più rapidi per vedere cosa c'è sotto le astrazioni che usiamo ogni giorno.
Hai lo stesso problema in azienda?
Raccontami cosa non va e da quando. Guardo la situazione con te prima di proporre qualsiasi intervento.
Ricevi il prossimo articolo
Una mail per ogni articolo nuovo, nient'altro. Ti disiscrivi con un clic dal link in fondo a ogni email.