El catalogo del terror
Los cinco generos de comportamiento indefinido que aparecen en codigo real: desbordamiento con signo, dereferencia de nulo, aliasing estricto, accesos fuera de rango y orden de evaluacion. Que los provoca, por que no avisan y como se reconocen a simple vista.
La norma enumera más de doscientas situaciones de comportamiento indefinido en su apéndice, pero el código real las visita por unas pocas puertas. Cinco géneros explican la abrumadora mayoría de los defectos que sobreviven a la revisión, pasan las pruebas y explotan en producción. Conocerlos de memoria no es erudición: es la única forma de verlos mientras escribes, porque ninguno produce un aviso fiable del compilador.
- Reconocer el desbordamiento con signo y sus primos: desplazamientos, división y conversiones.
- Entender por qué la dereferencia de nulo es una promesa retroactiva, no un fallo local.
- Identificar violaciones de aliasing estricto y del tipo efectivo en código que parece inocente.
- Distinguir el acceso fuera de rango del puntero uno-más-allá, y el orden de evaluación de la precedencia.
Aritmética: cuando el resultado no cabe en el tipo
El desbordamiento en aritmética con signo es indefinido; el desbordamiento sin signo está definido y envuelve módulo dos elevado al número de bits. Esta asimetría, que parece un capricho histórico, es el motor de más miscompilaciones que ningún otro punto de la norma, porque le permite al optimizador tratar int como si fuera un entero matemático sin cota.
#include <limits.h>
int a(int x) { return x + 1 > x; } /* UB si x es INT_MAX: se pliega a 1 */
int b(int x) { return -x; } /* UB si x es INT_MIN */
int c(int x) { return x / -1; } /* UB si x es INT_MIN */
int d(int x) { return x % 0; } /* UB siempre: modulo por cero */
int e(int x) { return 1 << x; } /* UB si x es negativo o pasa del ancho */
int f(int x) { return x << 31; } /* UB si el bit de signo resulta afectado */
long g(float v) { return (long)v; } /* UB si v no cabe en long tras truncar */
Ninguna de esas siete líneas produce un aviso con -Wall -Wextra, y las siete son indefinidas para algún valor de entrada perfectamente alcanzable. Las tres primeras comparten el mismo origen: el complemento a dos es asimétrico y INT_MIN no tiene opuesto representable. La quinta y la sexta son la trampa de los desplazamientos, donde además el desplazamiento por un número igual al ancho del tipo —el clásico 1 << 32 con int de treinta y dos bits— es indefinido, no cero.
La defensa no es comprobar después, que ya sería tarde, sino calcular en un tipo donde no pueda desbordar o usar la aritmética comprobada que C23 estandarizó:
#include <stdckdint.h>
/* correcto: detecta el desbordamiento sin provocarlo jamas */
int suma_segura(int x, int y, int *out) {
return ckd_add(out, x, y) ? -1 : 0;
}
/* incorrecto: la comprobacion ya ejecuto la operacion indefinida */
int suma_ingenua(int x, int y, int *out) {
*out = x + y;
return (*out < x) ? -1 : 0;
}
Punteros: nulo, colgante y fuera de rango
Dereferenciar un puntero nulo es indefinido. Lo que casi nadie interioriza es la consecuencia lógica: a partir del punto en que dereferencias un puntero, el compilador puede asumir para siempre que ese puntero no era nulo, y eliminar cualquier comprobación posterior. La dereferencia no es solo peligrosa; es una afirmación sobre el valor que el optimizador toma como cierta.
Nulo
Dereferenciar NULL es indefinido incluso si no lees el valor. Tras la dereferencia, toda comprobación posterior de nulidad es material muerto para el optimizador.
Colgante
Usar un puntero tras free o a una variable local cuyo bloque terminó. Basta con leer su valor, sin dereferenciar, para que el comportamiento sea indefinido.
Fuera de rango
Acceder más allá del final del objeto, o incluso formar una dirección que no esté dentro del objeto ni justo uno más allá del último elemento.
La regla del uno-más-allá es la que más sorprende. La norma permite construir y comparar el puntero que apunta justo después del último elemento de un array —de ahí que los bucles con for sobre iteradores sean legales—, pero no permite dereferenciarlo, y no permite formar el puntero anterior al primer elemento ni siquiera para no usarlo.
int v[10];
int *p = v + 10; /* legal: uno mas alla del ultimo */
int *q = v - 1; /* INDEFINIDO: formar el puntero ya lo es */
int z = *p; /* INDEFINIDO: dereferenciar el uno mas alla */
/* el bucle descendente ingenuo es indefinido en su ultima vuelta */
for (int *r = v + 9; r >= v; r--) { /* r-- produce v-1 al final */ }
Y el caso del puntero colgante es más estricto de lo que parece: la norma dice que el valor de un puntero se vuelve indeterminado cuando el objeto al que apunta termina su vida, y que usar un valor indeterminado es indefinido. Copiarlo, imprimirlo con %p o compararlo con NULL después de un free ya es comportamiento indefinido, aunque nunca lo dereferencies.
Objetos y tipos: aliasing estricto y valores fuera de dominio
El tercer género no habla de direcciones sino de tipos. La regla de aliasing estricto establece que un objeto solo puede accederse a través de un lvalue cuyo tipo sea compatible con el tipo efectivo del objeto, con excepciones tasadas: tipos que difieren en calificadores o en signo, y siempre los tipos carácter.
#include <string.h>
#include <stdint.h>
/* INDEFINIDO: se lee como uint32_t lo que tiene tipo efectivo float */
uint32_t bits_mal(float f) { return *(uint32_t *)&f; }
/* CORRECTO: memcpy no viola nada y compila a la misma instruccion */
uint32_t bits_bien(float f) {
uint32_t u;
memcpy(&u, &f, sizeof u);
return u;
}
A la misma familia pertenecen los valores que no pertenecen al dominio de su tipo. Un bool cuya representación no sea cero ni uno, un enum con un valor fuera del rango de sus enumeradores, o cualquier lectura de una variable automática no inicializada cuya dirección nunca se tomó: los tres son indefinidos, y los tres se propagan porque el optimizador deduce el conjunto de valores posibles a partir del tipo.
Los tres géneros anteriores tienen la decencia de fallar a veces. El aliasing estricto no: en -O0 funciona exactamente como esperabas, porque sin optimización el compilador no aprovecha la hipótesis. Compilas con -O2 para el despliegue y la función devuelve el valor de la iteración anterior. Actívate -Wstrict-aliasing=2 y no escribas nunca un cast entre tipos de puntero incompatibles para mirar lo que ya había en memoria.
Secuenciación: el orden de evaluación y los efectos colaterales
El quinto género es el que más se confunde con otra cosa. La precedencia decide cómo se agrupan los operandos; la secuenciación decide cuándo ocurren los efectos colaterales. Son independientes, y solo la segunda produce comportamiento indefinido.
flowchart TD A[Dos efectos colaterales sobre el mismo objeto] --> B[Hay un punto de secuencia entre ellos] B -->|Si| C[Definido y portable] B -->|No| D[Comportamiento indefinido] A --> E[Un efecto colateral y una lectura del mismo objeto] E --> F[La lectura sirve para calcular el valor a escribir] F -->|Si| G[Definido] F -->|No| D style C fill:#a6e3a1,color:#11111b style G fill:#a6e3a1,color:#11111b style D fill:#f38ba8,color:#11111b
C11 sustituyó el vocabulario de los puntos de secuencia por el de las relaciones de secuenciación, más preciso pero con la misma consecuencia práctica: si dos efectos colaterales sobre el mismo objeto escalar no están secuenciados uno respecto del otro, el comportamiento es indefinido.
int i = 0, v[4] = {0};
i = i++ + 1; /* INDEFINIDO: dos escrituras a i sin secuenciar */
v[i] = i++; /* INDEFINIDO: escritura y lectura sin secuenciar */
f(i++, i++); /* INDEFINIDO: dos modificaciones de i en la llamada */
i++; i = i + 1; /* correcto: el punto y coma secuencia */
int j = (i++, i); /* correcto: el operador coma secuencia */
if (p && *p) { } /* correcto: el and logico secuencia y cortocircuita */
Ojo con no mezclarlo con lo no especificado. Que g() + h() no diga cuál de las dos funciones se ejecuta primero es no especificado: hay dos órdenes posibles y ambos legales. Que i++ + i++ no tenga significado es indefinido: no hay conjunto de resultados que enumerar. Una llamada con dos funciones que imprimen es un programa correcto de salida impredecible; una expresión con dos modificaciones del mismo objeto no es un programa.
Existe la tentación de tratar esta lista como un examen que se aprueba una vez. No funciona así, y la razón es estructural: el comportamiento indefinido no es visible en el punto donde lo escribes. No hay una forma sintáctica que lo delate, no hay un color distinto en el editor, y el compilador guarda silencio precisamente en los casos peores, porque para avisarte tendría que demostrar que el valor problemático es alcanzable, y eso es indecidible en general. Lo único que funciona es convertir los cinco géneros en preguntas automáticas que te haces al escribir cada línea: esta resta de enteros con signo, ¿puede desbordar con las entradas reales del sistema? Este puntero, ¿de dónde viene y quién garantiza que no es nulo ni está liberado? Este cast, ¿está mirando bits que otro tipo escribió? Este índice, ¿puede llegar al final del array? Esta expresión, ¿toca dos veces la misma variable? Cinco preguntas, no doscientas. La diferencia entre un programador de C competente y uno peligroso no está en la cantidad de norma memorizada, sino en si esas cinco preguntas ya se hacen solas mientras teclea. Cuando lo hacen, el código deja de contener comportamiento indefinido por accidente, y las herramientas —UBSan, el análisis estático, el fuzzing— pasan de ser una red de emergencia a ser una confirmación.
- Compila las siete funciones aritméticas con
-Wall -Wextra -O2y comprueba cuántas producen aviso. Después vuelve a compilarlas con-fsanitize=undefinedy provoca cada una con su valor crítico. - Escribe el bucle descendente indefinido y razona por qué es indefinido incluso en la vuelta en que ya no entra al cuerpo. Reescríbelo de forma legal sin cambiar su semántica.
- Imprime con
%pel valor de un puntero después defree. Explica por qué es comportamiento indefinido aunque no lo dereferencies y aunque el programa imprima algo razonable. - Implementa
bits_malybits_bien, compara su ensamblador a-O2y comprueba que son idénticos pero solo una es legal. - Clasifica cada línea del bloque de secuenciación en indefinida, no especificada o correcta, y justifica la diferencia entre
f(i++, i++)yg() + h().