Undefined Behavior a fondo
El concepto que separa a quien cree saber C de quien lo entiende. Qué es el UB, por qué el estándar lo permite, y cómo el compilador lo explota para optimizar (y romperte el código).
El Undefined Behavior es la característica de C más malentendida y más importante. No es “un bug que a veces funciona”: es un contrato entre tú y el compilador que, cuando lo rompes, le da permiso para hacer literalmente cualquier cosa. Entenderlo de verdad es alcanzar la comprensión que casi nadie tiene.
- UB, comportamiento no especificado y definido por implementación.
- Los casos clásicos de UB.
- Cómo el compilador explota el UB para optimizar.
- Cómo defenderte.
Tres tipos de “no está garantizado”
El estándar C distingue con precisión:
Undefined Behavior
Todo vale. El compilador puede hacer cualquier cosa: crashear, dar cualquier resultado, o —peor— optimizar asumiendo que nunca ocurre. Ej: desbordamiento de entero con signo.
Unspecified
El compilador elige entre varias opciones válidas, sin decir cuál. Ej: el orden de evaluación de los argumentos de una función.
Implementation-defined
Depende de la plataforma, pero está documentado y es consistente. Ej: el tamaño de int, si char tiene signo.
El peligroso es el primero. Los casos clásicos de UB:
int x = INT_MAX + 1; // desbordamiento de entero CON signo → UB
arr[10]; // acceso fuera de límites → UB
*p; // dereferenciar puntero nulo/colgante → UB
int y; use(y); // leer variable no inicializada → UB
1 << 40; // desplazar más que el ancho del tipo → UB
a / 0; // división por cero → UB
// data race entre hilos sin sincronizar → UB
El compilador EXPLOTA el UB
Aquí está la parte que asombra y aterra a partes iguales. El compilador no solo “no define qué pasa”: asume que el UB nunca ocurre y optimiza basándose en esa suposición. Esto puede hacer desaparecer tu código:
int f(int x) {
if (x + 1 < x) // desbordamiento con signo es UB...
return manejar_overflow(); // ...así que el compilador asume
return normal(x); // que x+1 < x NUNCA es cierto
}
// el compilador ELIMINA la comprobación entera: "eso no puede pasar"
Un ejemplo famoso del kernel de Linux: un código dereferenciaba un puntero y después comprobaba si era nulo. El compilador razonó así: “dereferenciaron el puntero; si fuera nulo, eso sería UB; como el UB no puede ocurrir, el puntero no puede ser nulo; luego la comprobación posterior es siempre falsa” — y eliminó la comprobación de nulo, abriendo un agujero de seguridad explotable. El código “funcionaba” hasta que un compilador más agresivo lo optimizó. Esta es la lección brutal del UB: no es que “a veces dé mal resultado”. Es que el compilador tiene permiso lógico para asumir que tu UB nunca pasa y reescribir el programa alrededor de esa suposición, borrando comprobaciones de seguridad que creías tener. El UB no es un bug local: puede transformar código lejano.
Por qué el estándar permite el UB
No es maldad: es rendimiento. Si el estándar obligara a comprobar cada acceso a array, cada desbordamiento, cada puntero, C sería tan lento como los lenguajes con recolector de basura y comprobaciones en runtime. El UB es el precio de la velocidad: el estándar dice “prometemos comportamiento correcto solo si tú prometes no hacer estas cosas”. El compilador confía en esa promesa para generar código óptimo. Rompe la promesa, y el trato se cancela.
Esta es la comprensión que corona tu dominio del lenguaje. C no es “ensamblador portable donde todo vale”: es un lenguaje con un contrato preciso, el estándar ISO, que define exactamente qué está garantizado. El UB marca las fronteras del contrato. Un programador de C novato ve un desbordamiento que “casualmente funciona en mi máquina” y sigue. Uno experto sabe que eso es UB, que el compilador puede explotarlo mañana, con otro -O, en otra plataforma, de formas imposibles de predecir — y lo evita. La diferencia entre saber la sintaxis de C y entender C es exactamente esta: respetar el contrato, saber dónde están sus bordes, y no cruzarlos nunca “porque parece que funciona”. El estándar es la fuente de verdad; tenlo cerca.
Cómo defenderte
UBSan
-fsanitize=undefined caza el UB en el acto durante los tests. Tu mejor red (nivel 24).
Flags defensivos
-fwrapv (desbordamiento con signo definido), -fno-strict-aliasing (aliasing permisivo): domestican ciertos UB si los necesitas.
Conoce los casos
Aprende la lista de UB clásicos y evítalos por diseño. La mayoría son evitables con hábitos.
-Wall -Wextra
Muchos UB probables los avisa el compilador si se lo permites.
- Provoca un desbordamiento de entero con signo y cázalo con
-fsanitize=undefined. - Escribe el patrón “dereferencia y luego comprueba nulo” y observa (con
-O2) cómo el compilador puede eliminar la comprobación. - Clasifica tres comportamientos en UB / unspecified / implementation-defined.
- Compila con
-fwrapvy comprueba cómo cambia el desbordamiento con signo.