UndefinedBehaviorSanitizer: el catálogo completo
UBSan no es un comprobador sino dos docenas: qué trae activado, cómo cazar el desbordamiento de enteros con signo y las desalineaciones, y los tres modos de reacción que van del informe verboso al trap silencioso en producción.
ASan responde a una pregunta física: ¿esta dirección es válida? UBSan responde a una jurídica: ¿esta operación está permitida por la norma? Es una diferencia enorme, porque el comportamiento indefinido no corrompe nada de inmediato — simplemente le concede al optimizador una licencia que este ejercerá sin piedad tres funciones más allá. UBSan es el único instrumento que convierte esa licencia silenciosa en un mensaje con número de línea.
- Conocer el catálogo de comprobadores y cuáles trae activados el grupo por defecto.
- Cazar desbordamientos de enteros con signo y sustituirlos por aritmética comprobada de C23.
- Detectar accesos desalineados y entender por qué importan fuera de x86.
- Elegir entre los modos de informe, aborto y trap según el destino del binario.
El catálogo, no el interruptor
-fsanitize=undefined no es un comprobador: es un grupo que activa unos veinte. Los que más trabajo hacen en código real:
signed-integer-overflow— desbordamiento en aritmética con signo.shift-baseyshift-exponent— desplazar más bits de los que tiene el tipo, o desplazar un negativo.integer-divide-by-zero— división entera por cero.nullynonnull-attribute— dereferencia de nulo y violación de contratos declarados.alignment— acceso a través de un puntero mal alineado para su tipo.object-sizeybounds— acceso fuera del objeto cuando el compilador conoce su tamaño.vla-bound— longitud no positiva en un array de longitud variable.returnyunreachable— caer por el final de una función novoid, o alcanzar código declarado inalcanzable.bool,enum,float-cast-overflow— valores que no pertenecen al dominio del tipo.pointer-overflow— aritmética de punteros que desborda o se sale del objeto.
Y los que no entran en el grupo y casi siempre quieres añadir a mano:
# el grupo por defecto, abortando al primer hallazgo
gcc -std=c23 -O1 -g -fsanitize=undefined -fno-sanitize-recover=all prog.c -o prog
# clang, ampliado con lo que el grupo deja fuera a proposito
clang -std=c23 -O1 -g \
-fsanitize=undefined,unsigned-integer-overflow,implicit-conversion,local-bounds \
-fno-sanitize-recover=all prog.c -o prog
El desbordamiento sin signo está definido por la norma —envuelve módulo dos elevado a N— así que no es comportamiento indefinido y por eso no está en el grupo. Pero es un bug con la misma frecuencia: la resta a - b con size_t cuando b es mayor produce un número astronómico, no un negativo. Actívalo cuando la aritmética de tamaños sea crítica y prepárate para revisar los falsos positivos legítimos, como los hashes y los generadores pseudoaleatorios, que desbordan a propósito.
El desbordamiento con signo y la aritmética comprobada
Este es el comportamiento indefinido que más código rompe al subir de -O0 a -O2, porque el optimizador razona a partir de la premisa de que jamás ocurre:
int f(int i) {
return i + 1 > i; /* el optimizador lo pliega a 1, siempre */
}
void g(int n) {
for (int i = 0; i <= n; i++) /* asume que i nunca envuelve:
el bucle termina, luego puede
convertirlo en aritmetica de 64 bits */
trabajo(i);
}
int h(int x) { return abs(x); } /* UB si x es INT_MIN */
Ninguna de las tres líneas produce un aviso del compilador y las tres son indefinidas. Bajo -fsanitize=signed-integer-overflow la primera denuncia en el instante en que i vale INT_MAX, con tipo, valores y línea.
La respuesta no es solo detectar, sino escribir aritmética que no pueda desbordar. C23 estandarizó por fin lo que cada proyecto serio tenía como macro propia, en la cabecera stdckdint.h:
#include <stdckdint.h>
#include <stdio.h>
int main(void) {
int a = 2000000000, b = 2000000000, r;
if (ckd_add(&r, a, b)) /* devuelve true si desbordo */
return fputs("desbordamiento detectado\n", stderr), 1;
printf("%d\n", r);
return 0;
}
ckd_add, ckd_sub y ckd_mul son macros genéricas: calculan en precisión infinita, guardan el resultado envuelto en el tipo del primer argumento y devuelven si hubo desbordamiento. Sin comportamiento indefinido, sin comparaciones frágiles previas, sin dependencia de las extensiones __builtin_add_overflow de cada compilador. Es la forma correcta de validar un tamaño antes de pasárselo a malloc.
Alineación y el resto del zoo
El acceso desalineado es el comportamiento indefinido que más engaña, porque en x86 funciona:
char buf[16];
int *p = (int *)(buf + 1); /* direccion no multiplo de 4 */
*p = 42; /* UBSan: store of misaligned address */
En x86-64 esto se ejecuta sin incidentes y el programa parece correcto. En ARM con acceso estricto, en algunas instrucciones vectoriales y en muchos microcontroladores, la misma línea produce una excepción de bus. Y aunque no falle, el optimizador tiene derecho a vectorizar el bucle que lo contiene asumiendo la alineación que el tipo promete, con resultados arbitrarios. La forma correcta de leer una estructura desde un búfer de bytes es siempre la misma:
int v;
memcpy(&v, buf + 1, sizeof v); /* correcto, alineado y sin aliasing ilegal */
Cualquier compilador decente compila ese memcpy a la misma instrucción de carga que el cast, sin coste. La diferencia es que uno es legal y el otro no.
UBSan y ASan comparten runtime y se combinan sin conflicto, y la mayoría de los comprobadores de UBSan cuestan una comparación y un salto no tomado. La configuración de desarrollo por defecto de cualquier proyecto C moderno debería ser -fsanitize=address,undefined -fno-sanitize-recover=all. No hay excusa de rendimiento para no tenerla: el coste marginal de añadir UBSan sobre un binario que ya lleva ASan es ruido estadístico.
Tres modos de reaccionar
UBSan no tiene un comportamiento, tiene tres, y elegir el correcto según el destino del binario es la decisión de ingeniería de esta lección.
flowchart TD A[Donde va a correr este binario] --> B[Desarrollo local] A --> C[Integracion continua] A --> D[Produccion o firmware] B --> E[Informar y continuar mas print stacktrace] C --> F[fno sanitize recover: abortar al primer fallo] D --> G[fsanitize trap: instruccion ilegal, sin runtime] style E fill:#89b4fa,color:#11111b style F fill:#f9e2af,color:#11111b style G fill:#a6e3a1,color:#11111b
# 1. informar y seguir: ves TODOS los problemas de una pasada
UBSAN_OPTIONS=print_stacktrace=1 ./prog
# 2. abortar al primero: lo que quieres en CI, para que el fallo sea rojo
gcc -fsanitize=undefined -fno-sanitize-recover=all prog.c -o prog
# 3. trap: emite una instruccion ilegal, sin biblioteca de runtime ni mensaje
gcc -fsanitize=undefined -fsanitize-trap=undefined prog.c -o prog
El tercer modo es el que hace de UBSan algo más que una herramienta de desarrollo. Sin runtime, sin mensajes y sin tablas de metadatos, comprobadores como bounds, object-size o signed-integer-overflow cuestan tan poco que pueden dejarse activos en producción: el programa muere limpiamente con SIGILL en lugar de continuar en un estado indefinido explotable. Es exactamente lo que hace el kernel de Linux con CONFIG_UBSAN_TRAP y lo que Android compila en sus componentes de red y multimedia.
Durante décadas, el comportamiento indefinido fue para el programador de C una amenaza teológica: sabías que existía, sabías que era grave, y no tenías absolutamente ninguna forma de saber si tu código lo contenía. Los avisos del compilador solo cubren los casos triviales y estáticos; el resto depende de valores que solo existen en ejecución. El resultado fue una cultura entera de folklore —“no hagas esto”, “cuidado con aquello”— transmitida oralmente y obedecida a medias. UBSan liquida esa era. Convierte una propiedad semántica del programa, dictada por un documento normativo de mil páginas, en una proposición verificable en ejecución: corres tus tests y el binario te dice, con archivo y línea, en qué punto exacto tu código dejó de tener significado definido. Y hay algo más profundo: cada informe de UBSan es un lugar donde el optimizador tenía licencia para hacer cualquier cosa y probablemente ya la estaba ejerciendo, silenciosamente, en ese binario de producción que llevas dos años ejecutando. Los bugs que UBSan encuentra no son bugs que fueras a tener; son bugs que ya tienes, esperando a que la próxima versión del compilador decida cobrárselos. Esa es la razón de que sea la herramienta que más código antiguo ha arreglado en la historia reciente de C.
- Compila los tres ejemplos de la sección de enteros con
-O2y sin sanitizers, y comprueba conobjdumpque el optimizador realmente plegó la comparación a una constante. - Actívales
-fsanitize=signed-integer-overflowy lee los informes. - Reescribe una validación de tamaño del tipo
n * sizeof(T)antes de unmallocusandockd_muly demuestra que rechaza el caso que antes desbordaba. - Provoca un acceso desalineado, cázalo con
-fsanitize=alignmenty corrígelo conmemcpy. Compara el ensamblador generado por ambas versiones: deberían ser idénticos. - Compila el mismo programa en los tres modos —informar, abortar y trap— y compara tamaño del binario, dependencias con
lddy comportamiento al fallar.