"Comportamiento indefinido" suena a jerga para decir "impredecible". Es más fuerte que eso. Cuando el estándar de C dice que una construcción tiene comportamiento indefinido, significa que el estándar no impone ningún requisito en absoluto sobre lo que hace el programa: ni sobre el valor, ni sobre la sentencia, ni sobre el programa.
Esa es la parte que sorprende: el comportamiento indefinido no se limita a la línea culpable. Al compilador se le permite suponer que nunca ocurre, y reescribir el código de alrededor bajo esa suposición. El resultado puede ser un programa en el que una comprobación que escribiste claramente no existe en el binario.
El modelo del contrato
Piensa en el estándar como un contrato entre tú y el compilador. Tú prometes no hacer ciertas cosas; a cambio, el compilador promete que tu programa significa lo que dice.
No indexes fuera de un array. No desbordes un entero con signo. No leas un valor sin inicializar. No uses un puntero después de liberarlo. No modifiques el mismo objeto dos veces en una expresión sin un punto de secuencia entre medias.
Rompe una cláusula y el trato se cancela, para el programa entero y no solo para esa línea. No hay ningún "respaldo razonable" ni obligación de reventar.
Vale la pena separar tres términos emparentados:
- Comportamiento indefinido: puede pasar cualquier cosa. Acceso fuera de límites, desbordamiento con signo, uso tras liberación.
- Comportamiento no especificado: uno de varios resultados válidos, y el compilador no tiene que decirte cuál. El orden en que se evalúan los argumentos de una función, por ejemplo.
- Comportamiento definido por la implementación: la implementación elige y debe documentar su elección. Si
chartiene signo, cuán grande es unint.
Solo el primero es peligroso en el sentido de "el optimizador borró mi código".
Las grandes fuentes
Desbordamiento de enteros con signo
La aritmética sin signo da la vuelta, y el estándar lo dice. La aritmética con signo no: exceder el rango es indefinido.
La comprobación if (a > INT_MAX - b) se ejecuta enteramente dentro del rango válido, que es lo que la convierte en una prueba de desbordamiento correcta. Escribir if (a + b < 0) realiza el desbordamiento primero y luego pregunta por el resultado, y el compilador, con derecho a suponer que el desbordamiento nunca ocurrió, puede eliminar la comprobación.
Acceso fuera de límites
Leer o escribir fuera de un array es indefinido, reviente o no:
int arr[5] = {1, 2, 3, 4, 5};
int x = arr[5]; /* UB: el indice 5 no existe */
arr[-1] = 0; /* UB */
int *p = arr + 10; /* UB incluso solo por calcular este puntero */
Fíjate en esa última línea: formar un puntero más de una posición pasado el final es indefinido aunque nunca lo desreferencies. El estándar permite arr + 5 (uno más allá del final, para terminar bucles) pero no arr + 6.
Los desbordamientos pequeños son los peligrosos. Normalmente no dan segfault; sobrescriben en silencio una variable vecina, y la respuesta equivocada aparece en algún sitio sin relación.
Lecturas sin inicializar
int x;
printf("%d\n", x); /* UB: leer un valor indeterminado */
int *p;
*p = 42; /* UB: desreferenciar un puntero indeterminado */
Es tentador pensar "simplemente contiene basura", pero eso no es lo que dice el estándar, y los compiladores explotan la diferencia. Se sabe que GCC ha concluido que una variable leída antes de asignarla puede contener cualquier valor que le convenga, incluido el que haga desaparecer una rama.
Punteros colgantes
int *p = malloc(sizeof *p);
free(p);
*p = 42; /* UB: uso tras liberacion */
free(p); /* UB: doble liberacion */
int *q;
{
int local = 10;
q = &local;
}
printf("%d\n", *q); /* UB: el tiempo de vida del objeto termino */
Las consecuencias en tiempo de ejecución se cubren en fallo de segmentación; el punto aquí es que reventar es el desenlace afortunado.
El especificador de printf equivocado
printf("%d\n", 3.14); /* UB: %d con un double */
printf("%s\n", 42); /* UB: %s con un int, normalmente revienta */
printf("%d %d\n", 1); /* UB: menos argumentos que especificadores */
long n = 5;
printf("%d\n", n); /* UB en sistemas donde long es mas ancho que int */
printf es variádica: lee los argumentos según la cadena de formato y no puede comprobarlos. Un desajuste hace que lea la cantidad equivocada de bytes del lugar equivocado. Compila con -Wall y el compilador comprueba la cadena de formato por ti: este es uno de los avisos de mayor valor de todo el conjunto.
Modificar un objeto dos veces en una expresión
int i = 0;
i = i++ + ++i; /* UB */
arr[i] = i++; /* UB */
printf("%d %d\n", i++, i); /* UB */
Estos son indefinidos, no meramente "dependientes del compilador". Los acertijos de manual que preguntan a cuánto se evalúa i = i++ + ++i no tienen respuesta correcta.
Aliasing estricto
Acceder a un objeto a través de un puntero de un tipo incompatible es indefinido, y este sorprende a programadores con experiencia:
float f = 1.0f;
int *p = (int *)&f;
printf("%d\n", *p); /* UB: leer un float a traves de un int * */
El compilador supone que un int * y un float * nunca apuntan a la misma memoria, y reordena en consecuencia. La forma definida de reinterpretar bytes es memcpy (que se optimiza a las mismas instrucciones) o una unión:
Un char * es la excepción: siempre puedes inspeccionar los bytes de cualquier objeto a través de un unsigned char *.
Por qué "funciona en mi máquina" no demuestra nada
El comportamiento indefinido a menudo parece funcionar, y eso es lo que lo hace peligroso. El programa se ejecuta correctamente durante el desarrollo y las pruebas, y luego se rompe cuando cambia algo completamente sin relación:
- Una versión nueva del compilador con un optimizador más listo.
- Pasar de
-O0a-O2para la versión de lanzamiento. - Agregar una función no relacionada, que desplaza la disposición de la pila de modo que un desbordamiento ahora aterriza sobre algo que importa.
- Otra máquina, otra libc, otro sistema operativo.
Que parezca funcionar no es prueba de corrección, porque el estándar nunca prometió nada. Es un error en estado latente, y el disparador suele ser la compilación de lanzamiento.
Cómo lo explota el optimizador
Aquí está el ejemplo que normalmente zanja la discusión. Alguien escribe una comprobación de nulo:
void process(int *p) {
int value = *p; /* desreferencia */
if (p == NULL) { /* y luego comprueba si es nulo */
return;
}
printf("%d\n", value * 2);
}
El orden está mal —la comprobación viene después de la desreferencia— pero seguro que la comprobación se ejecuta igual, ¿no?
No tiene por qué. El compilador razona: *p se desreferenció, por tanto p no puede ser NULL (desreferenciar NULL es indefinido, así que en cualquier programa con comportamiento definido no es NULL), por tanto p == NULL siempre es falso, por tanto el cuerpo entero del if es código muerto y se puede eliminar.
La función compilada no tiene ninguna comprobación de nulo. Una instancia real de este patrón en el núcleo de Linux se convirtió en CVE-2009-1897, donde GCC eliminó exactamente esa comprobación y convirtió un error de orden de aspecto inocente en una vulnerabilidad explotable.
Un segundo ejemplo, más pequeño:
/* Una comprobacion de desbordamiento que no funciona */
int safe_add(int a, int b) {
int sum = a + b;
if (sum < a) { /* "dio la vuelta?" */
return -1;
}
return sum;
}
Para los tipos con signo el compilador puede suponer que a + b no se desbordó, en cuyo caso sum < a solo es posible cuando b < 0. Con esa suposición la comprobación prueba algo distinto de lo que quería quien la escribió, y con b >= 0 puede optimizarse hasta desaparecer. La versión que funciona comprueba de antemano:
Ambas pruebas se quedan dentro del rango representable, así que nunca ocurre ningún desbordamiento y no hay nada que el optimizador pueda dar por supuesto. (GCC y clang también proveen __builtin_add_overflow, que hace esto en una sola instrucción.)
Cómo detectarlo
Primero los avisos estáticos, que son gratis:
gcc -Wall -Wextra -Wpedantic program.c -o program
Eso atrapa desajustes en las cadenas de formato, algunas lecturas sin inicializar, comparaciones sospechosas y código inalcanzable.
Luego los sanitizadores, que instrumentan el programa y reportan en el momento de la violación:
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'
Combínalos con AddressSanitizer para la mitad de la memoria:
gcc -g -Wall -Wextra -fsanitize=address,undefined program.c -o program
Juntos atrapan accesos fuera de límites, uso tras liberación, dobles liberaciones, desbordamiento con signo, desplazamientos inválidos, punteros mal alineados y desreferencias nulas, cada uno con un archivo, una línea y una traza de pila. Alrededor del doble de lento, lo que no es nada durante el desarrollo.
valgrind ./program no necesita recompilar y atrapa lecturas sin inicializar y errores de memoria, aunque no el comportamiento indefinido aritmético. clang --analyze y gcc -fanalyzer encuentran parte de ello sin siquiera ejecutar el programa.
La regla práctica: ejecuta tus pruebas bajo los sanitizadores en tu integración continua. El comportamiento indefinido que parece funcionar en local es exactamente lo que existen para exponer.
Convivir con él
No puedes evitar el comportamiento indefinido siendo cuidadoso: todo el mundo acaba escribiéndolo. Lo que funciona es hacerlo ruidoso:
- Compila con
-Wall -Wextradesde el primer día y arregla todos los avisos. - Ejecuta las pruebas bajo
-fsanitize=address,undefined. - Inicializa cada variable en su declaración, y cada puntero a
NULL. - Comprueba los índices de los arrays contra la longitud, e itera con
i < n. - Comprueba el desbordamiento antes de la aritmética, usando
<limits.h>. - Pon un puntero a
NULLdespués de liberarlo. - Usa tipos
unsigneddonde la vuelta a la carga sea el comportamiento buscado: ahí está definida. - Prefiere
memcpya las conversiones de punteros cuando reinterpretes bytes.
La velocidad de C viene de que al compilador se le permite suponer que cumpliste el contrato. Ese es un intercambio genuino, no un fallo de diseño, y las herramientas de arriba te devuelven casi toda la seguridad por un costo nulo en tiempo de desarrollo.
Para la cara en tiempo de ejecución de estas reglas, mira fallo de segmentación; para los errores de compilación que vienen antes, errores comunes.
Preguntas frecuentes
¿Qué es el comportamiento indefinido en C?
Código para el que el estándar de C no impone ningún requisito en absoluto. El compilador es libre de producir cualquier cosa: un fallo, una respuesta equivocada, código que parece funcionar o código con la rama problemática eliminada por completo. No es "definido por la implementación" ni "aleatorio": es un contrato que rompiste, y después no se promete nada.
¿Por qué C tiene comportamiento indefinido en lugar de definirlo todo?
Velocidad y portabilidad. Exigir una comprobación de límites en cada acceso a un array costaría un rendimiento que C fue diseñado para no pagar; definir el desbordamiento con signo como una vuelta a la carga obligaría a instrucciones extra en hardware que en cambio lanza una excepción. Dejar esos casos indefinidos permite al compilador suponer que nunca ocurren y optimizar en consecuencia.
¿El desbordamiento de enteros con signo es comportamiento indefinido en C?
Sí. INT_MAX + 1 es indefinido: no está garantizado que dé la vuelta hasta INT_MIN. El desbordamiento sin signo es distinto: está completamente definido y da la vuelta módulo 2^N. Es por eso que los compiladores pueden suponer que x + 1 > x siempre es verdadero para un x con signo, y borrar una comprobación de desbordamiento escrita así.
¿Cómo detecto comportamiento indefinido en mi programa de C?
Compila con -Wall -Wextra para atrapar lo que el compilador puede ver estáticamente, y luego ejecuta tus pruebas con -fsanitize=address,undefined, que reporta accesos fuera de límites, uso tras liberación, desbordamiento con signo y más en el momento en que ocurren, nombrando el archivo y la línea. valgrind atrapa un conjunto parecido sin recompilar.