wandres.dev
SEMIDIÓS · Undefined Behavior

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).

⏱ 16 min

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.

🎯 Al terminar esta lección sabrás
  • 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"
🛑
El caso real que casi nadie olvida

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.

Programar en C es cumplir un contrato

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.

⚔️ Enfréntate al UB
  1. Provoca un desbordamiento de entero con signo y cázalo con -fsanitize=undefined.
  2. Escribe el patrón “dereferencia y luego comprueba nulo” y observa (con -O2) cómo el compilador puede eliminar la comprobación.
  3. Clasifica tres comportamientos en UB / unspecified / implementation-defined.
  4. Compila con -fwrapv y comprueba cómo cambia el desbordamiento con signo.