Menu

Comportamento indefinito in C: cosa significa e perché fa male

Il comportamento indefinito è codice su cui lo standard C non impone alcun requisito, quindi può succedere di tutto, anche che l'ottimizzatore cancelli i tuoi controlli. Ecco cosa lo causa, perché "sul mio computer funziona" non dimostra niente e quali flag lo intercettano.

Questa pagina include editor eseguibili: modifica, esegui e vedi subito l'output.

"Comportamento indefinito" sembra un modo tecnico per dire "imprevedibile". È qualcosa di più forte. Quando lo standard C dice che un costrutto ha comportamento indefinito, significa che lo standard non impone alcun requisito su ciò che fa il programma: né sul valore, né sull'istruzione, né sul programma.

È questa la parte che sorprende: il comportamento indefinito non resta confinato alla riga incriminata. Il compilatore può presumere che non accada mai e riscrivere il codice circostante sulla base di questa ipotesi. Il risultato può essere un programma in cui un controllo che hai scritto chiaramente non esiste nel binario.

Il modello del contratto

Pensa allo standard come a un contratto tra te e il compilatore. Tu prometti di non fare certe cose; in cambio, il compilatore promette che il tuo programma significa ciò che dice.

Non indicizzare fuori da un array. Non mandare in overflow un intero con segno. Non leggere un valore non inizializzato. Non usare un puntatore dopo averlo liberato. Non modificare lo stesso oggetto due volte nella stessa espressione senza un punto di sequenza in mezzo.

Violi una clausola e l'accordo salta, per l'intero programma e non solo per quella riga. Non esiste un "ripiego ragionevole" né l'obbligo di andare in crash.

Vale la pena distinguere tre termini collegati:

  • Comportamento indefinito: può succedere di tutto. Accesso fuori dai limiti, overflow con segno, use-after-free.
  • Comportamento non specificato: uno tra diversi risultati validi, e il compilatore non è tenuto a dirti quale. L'ordine in cui vengono valutati gli argomenti di una funzione, per esempio.
  • Comportamento definito dall'implementazione: sceglie l'implementazione, e deve documentare la sua scelta. Se char ha segno, quanto è grande un int.

Solo il primo è pericoloso nel senso di "l'ottimizzatore mi ha cancellato il codice".

Le fonti principali

Overflow di interi con segno

L'aritmetica senza segno ricircola, e lo standard lo dice esplicitamente. L'aritmetica con segno no: uscire dall'intervallo è indefinito.

Il controllo if (a > INT_MAX - b) avviene interamente dentro l'intervallo valido, ed è questo a renderlo un test di overflow corretto. Scrivere if (a + b < 0) esegue l'overflow prima e poi si interroga sul risultato, e il compilatore, autorizzato a presumere che l'overflow non sia mai avvenuto, può eliminare il controllo.

Accesso fuori dai limiti

Leggere o scrivere fuori da un array è indefinito, che vada in crash o no:

int arr[5] = {1, 2, 3, 4, 5};
int x = arr[5];        /* UB: l'indice 5 non esiste */
arr[-1] = 0;           /* UB */
int *p = arr + 10;     /* UB anche solo calcolare questo puntatore */

Nota l'ultima riga: formare un puntatore oltre la posizione successiva alla fine è indefinito anche se non lo dereferenzi mai. Lo standard permette arr + 5 (uno oltre la fine, per terminare i cicli) ma non arr + 6.

Gli sforamenti piccoli sono quelli pericolosi. Di solito non causano un segfault; sovrascrivono in silenzio una variabile vicina, e il risultato sbagliato salta fuori da qualche parte che non c'entra niente.

Letture non inizializzate

int x;
printf("%d\n", x);     /* UB: lettura di un valore indeterminato */

int *p;
*p = 42;               /* UB: dereferenziazione di un puntatore indeterminato */

Si è tentati di pensare "contiene solo spazzatura", ma non è quello che dice lo standard, e i compilatori sfruttano la differenza. È noto che GCC abbia concluso che una variabile letta prima dell'assegnazione può contenere qualunque valore gli faccia comodo, compreso quello che fa sparire un ramo.

Puntatori pendenti

int *p = malloc(sizeof *p);
free(p);
*p = 42;               /* UB: use-after-free */
free(p);               /* UB: doppio free */

int *q;
{
    int local = 10;
    q = &local;
}
printf("%d\n", *q);    /* UB: il tempo di vita dell'oggetto è finito */

Le conseguenze a runtime sono trattate in segmentation fault; il punto qui è che il crash è l'esito fortunato.

Lo specificatore di printf sbagliato

printf("%d\n", 3.14);        /* UB: %d con un double */
printf("%s\n", 42);          /* UB: %s con un int, di solito va in crash */
printf("%d %d\n", 1);        /* UB: meno argomenti che specificatori */
long n = 5;
printf("%d\n", n);           /* UB sui sistemi dove long è più largo di int */

printf è variadica: legge gli argomenti secondo la stringa di formato e non può verificarli. Una discrepanza le fa leggere il numero sbagliato di byte dal posto sbagliato. Compila con -Wall e il compilatore controlla la stringa di formato al posto tuo: è uno degli avvisi più utili dell'intero insieme.

Modificare un oggetto due volte nella stessa espressione

int i = 0;
i = i++ + ++i;             /* UB */
arr[i] = i++;              /* UB */
printf("%d %d\n", i++, i); /* UB */

Questi sono indefiniti, non semplicemente "dipendenti dal compilatore". Gli indovinelli da manuale che chiedono quanto vale i = i++ + ++i non hanno una risposta corretta.

Strict aliasing

Accedere a un oggetto tramite un puntatore di tipo incompatibile è indefinito, e questo sorprende anche programmatori esperti:

float f = 1.0f;
int *p = (int *)&f;
printf("%d\n", *p);        /* UB: leggere un float tramite un int * */

Il compilatore presume che un int * e un float * non puntino mai alla stessa memoria, e riordina di conseguenza. Il modo definito per reinterpretare dei byte è memcpy (che viene ottimizzato nelle stesse istruzioni) oppure una union:

Un char * è l'eccezione: puoi sempre ispezionare i byte di qualsiasi oggetto tramite un unsigned char *.

Perché "sul mio computer funziona" non dimostra niente

Il comportamento indefinito spesso sembra funzionare, ed è questo a renderlo pericoloso. Il programma gira correttamente durante lo sviluppo e i test, poi si rompe quando cambia qualcosa di completamente scollegato:

  • Una nuova versione del compilatore con un ottimizzatore più furbo.
  • Il passaggio da -O0 a -O2 per la build di rilascio.
  • L'aggiunta di una funzione non correlata, che sposta il layout dello stack così che uno sforamento ora finisce su qualcosa che conta.
  • Un'altra macchina, un'altra libc, un altro sistema operativo.

Il fatto che sembri funzionare non è una prova di correttezza, perché lo standard non ha mai promesso niente. È un bug in stato dormiente, e di solito a risvegliarlo è la build di rilascio.

Come lo sfrutta l'ottimizzatore

Ecco l'esempio che di solito chiude la discussione. Qualcuno scrive un controllo sul puntatore nullo:

void process(int *p) {
    int value = *p;              /* dereferenziazione */
    if (p == NULL) {             /* poi il controllo del nullo */
        return;
    }
    printf("%d\n", value * 2);
}

L'ordine è sbagliato, perché il controllo arriva dopo la dereferenziazione, ma di sicuro il controllo viene eseguito comunque, no?

Non è detto. Il compilatore ragiona così: *p è stato dereferenziato, quindi p non può essere NULL (dereferenziare NULL è indefinito, quindi in qualunque programma con comportamento definito non è NULL), quindi p == NULL è sempre falso, quindi l'intero corpo dell'if è codice morto e può essere cancellato.

La funzione compilata non contiene alcun controllo sul nullo. Un caso reale di questo schema nel kernel Linux è diventato la CVE-2009-1897, in cui GCC ha rimosso esattamente un controllo del genere e ha trasformato un errore di ordinamento dall'aria innocua in una vulnerabilità sfruttabile.

Un secondo esempio, più piccolo:

/* Un controllo di overflow che non funziona */
int safe_add(int a, int b) {
    int sum = a + b;
    if (sum < a) {          /* "ha ricircolato?" */
        return -1;
    }
    return sum;
}

Per i tipi con segno il compilatore può presumere che a + b non sia andato in overflow, e in tal caso sum < a è possibile solo quando b < 0. Con questa ipotesi il controllo verifica qualcosa di diverso da ciò che intendeva chi lo ha scritto, e con b >= 0 può essere eliminato del tutto dall'ottimizzazione. La versione che funziona controlla in anticipo:

Entrambi i test restano dentro l'intervallo rappresentabile, quindi non avviene mai alcun overflow e l'ottimizzatore non ha niente da dare per scontato. (GCC e clang forniscono anche __builtin_add_overflow, che fa la stessa cosa in un'unica istruzione.)

Come rilevarlo

Prima gli avvisi statici, che sono gratis:

gcc -Wall -Wextra -Wpedantic program.c -o program

Questo intercetta discrepanze nelle stringhe di formato, alcune letture non inizializzate, confronti sospetti e codice irraggiungibile.

Poi i sanitizer, che strumentano il programma e segnalano il problema nel momento della violazione:

gcc -g -fsanitize=undefined program.c -o program
./program
program.c:8:15: runtime error: signed integer overflow:
2147483647 + 1 cannot be represented in type 'int'

Combinalo con AddressSanitizer per la parte sulla memoria:

gcc -g -Wall -Wextra -fsanitize=address,undefined program.c -o program

Insieme intercettano accessi fuori dai limiti, use-after-free, doppi free, overflow con segno, shift non validi, puntatori non allineati e dereferenziazioni di NULL, ciascuno con file, riga e stack trace. Circa due volte più lento, il che non è niente durante lo sviluppo.

valgrind ./program non richiede di ricompilare e intercetta letture non inizializzate ed errori di memoria, ma non il comportamento indefinito aritmetico. clang --analyze e gcc -fanalyzer ne trovano una parte senza nemmeno eseguire il programma.

La regola pratica: esegui i test sotto i sanitizer nella CI. Il comportamento indefinito che sembra funzionare in locale è esattamente ciò che esistono per smascherare.

Conviverci

Non puoi evitare il comportamento indefinito solo stando attento: prima o poi capita a tutti di scriverne. Quello che funziona è renderlo rumoroso:

  • Compila con -Wall -Wextra fin dal primo giorno e correggi ogni avviso.
  • Esegui i test sotto -fsanitize=address,undefined.
  • Inizializza ogni variabile nella dichiarazione, e ogni puntatore a NULL.
  • Confronta gli indici degli array con la lunghezza, e cicla con i < n.
  • Controlla l'overflow prima dell'operazione aritmetica, usando <limits.h>.
  • Imposta un puntatore a NULL dopo averlo liberato.
  • Usa tipi unsigned dove il ricircolo è il comportamento voluto: lì è definito.
  • Preferisci memcpy ai cast tra puntatori quando reinterpreti dei byte.

La velocità di C deriva dal fatto che il compilatore può presumere che tu abbia rispettato il contratto. È un compromesso vero, non un difetto di progettazione, e gli strumenti qui sopra ti restituiscono gran parte della sicurezza a un costo praticamente nullo in fase di sviluppo.

Per il lato a runtime di queste regole, vedi segmentation fault; per gli errori in fase di compilazione che vengono prima, gli errori comuni.

Domande frequenti

Cos'è il comportamento indefinito in C?

Codice per cui lo standard C non impone alcun requisito. Il compilatore è libero di produrre qualsiasi cosa: un crash, un risultato sbagliato, codice che sembra funzionare o codice in cui il ramo incriminato è stato rimosso del tutto. Non è "definito dall'implementazione" né "casuale": è un contratto che hai violato, e da quel momento non ti viene promesso più niente.

Perché C ha il comportamento indefinito invece di definire tutto?

Velocità e portabilità. Imporre un controllo dei limiti su ogni accesso a un array costerebbe prestazioni che C è stato progettato per non pagare; definire l'overflow con segno come ricircolo costringerebbe a istruzioni extra su hardware che invece genera una trap. Lasciare quei casi indefiniti permette al compilatore di presumere che non accadano mai e di ottimizzare di conseguenza.

L'overflow di un intero con segno è comportamento indefinito in C?

Sì. INT_MAX + 1 è indefinito: non è garantito che ricominci da INT_MIN. L'overflow senza segno è diverso: è completamente definito e ricircola modulo 2^N. Per questo i compilatori possono presumere che x + 1 > x sia sempre vero per un x con segno, e cancellare un controllo di overflow scritto in quel modo.

Come rilevo il comportamento indefinito nel mio programma C?

Compila con -Wall -Wextra per intercettare ciò che il compilatore vede staticamente, poi esegui i tuoi test con -fsanitize=address,undefined, che segnala accessi fuori dai limiti, use-after-free, overflow con segno e altro nel momento in cui accadono, indicando file e riga. valgrind intercetta un insieme simile senza ricompilare.

Illustrazione dei linguaggi di programmazione di Coddy

Impara a programmare con Coddy

INIZIA