wandres.dev
SEMIDIÓS · Undefined Behavior

Convivir con el UB

Que garantiza cada compilador por encima de la norma, que hacen realmente banderas como fwrapv o fno strict aliasing, y una disciplina de siete reglas para no invocar comportamiento indefinido en codigo nuevo.

⏱ 18 min

Erradicar el comportamiento indefinido no es una aspiración moral: es una tarea de ingeniería con herramientas concretas, costes medibles y decisiones que hay que tomar por escrito. Esta lección cierra el nivel con lo operativo: qué te promete tu compilador más allá de la norma, qué compras realmente con cada bandera defensiva, y una disciplina de siete reglas que hace que el problema deje de aparecer en código nuevo.

🎯 Al terminar esta lección sabrás
  • Distinguir las garantías de la norma de las extensiones documentadas de cada compilador.
  • Saber qué define exactamente -fwrapv, -fno-strict-aliasing y sus parientes, y qué cuestan.
  • Combinar sanitizers, avisos, análisis estático y fuzzing en una tubería coherente.
  • Adoptar una disciplina de siete reglas aplicable desde la primera línea.

Lo que tu compilador promete por encima de la norma

La norma es el mínimo común. Cada implementación define más de lo que la norma exige, lo documenta en su manual y se compromete a mantenerlo. Esa documentación es un contrato real, y usarla conscientemente es legítimo; usarla sin saber que la estás usando es lo que produce código que muere al cambiar de compilador.

📘

GCC y Clang

Definen la conversión de entero fuera de rango como truncamiento en complemento a dos, el desplazamiento a la derecha de negativos como aritmético, y char con signo en x86 y sin signo en ARM.

🧱

MSVC

Define el desbordamiento con signo como envolvente en la práctica, pero no ofrece equivalente a -fwrapv ni garantiza el aliasing permisivo.

🐧

El kernel de Linux

Compila con -fno-strict-aliasing, -fno-delete-null-pointer-checks y -fno-strict-overflow: se ha construido su propio dialecto de C, documentado y estable.

Dos precisiones que decidan bien el uso de estas garantías. La primera: una extensión documentada no convierte tu programa en conforme, lo hace correcto para esa implementación; si algún día compilas con otra, el contrato desaparece sin aviso. La segunda: nada de esto aplica a las construcciones verdaderamente indefinidas como el acceso fuera de rango o el uso de memoria liberada, para las que ningún compilador ofrece garantía alguna, porque no hay comportamiento razonable que prometer.

La conclusión práctica es que las extensiones sirven para dos cosas y solo dos: sostener bases de código heredadas que no puedes reauditar, y construir un dialecto explícito para un proyecto que controla su propia cadena de compilación. Nunca para justificar código nuevo.

Merece la pena mirar el caso del kernel con atención, porque es el único ejemplo a gran escala de la segunda vía hecha bien. No eligió sus tres banderas por comodidad ni por no querer arreglar el código: las eligió porque su modelo de programación —estructuras reinterpretadas a través de tipos distintos, punteros derivados por aritmética sobre desplazamientos, la página cero deliberadamente no mapeable— hace que ciertas hipótesis del optimizador sean falsas por diseño. Y las escribió en su sistema de compilación, no en un correo. Ese es el estándar al que hay que llegar antes de permitirse una bandera defensiva: saber exactamente qué hipótesis retiras, por qué es falsa en tu dominio, y dejarlo por escrito donde el siguiente lo encuentre.

Las banderas que domestican, y su precio

Cada bandera defensiva hace una cosa concreta: retira una hipótesis al optimizador. Eso define el comportamiento y, en la misma medida, cuesta rendimiento. Ninguna arregla nada; todas compran previsibilidad con velocidad, y saber cuál hipótesis retira cada una es la diferencia entre una decisión de ingeniería y un amuleto copiado de un foro.

# define el desbordamiento con signo como envolvente en complemento a dos
gcc -fwrapv prog.c

# como fwrapv pero solo desactiva las optimizaciones que lo asumen
gcc -fno-strict-overflow prog.c

# retira la regla de aliasing estricto: cualquier puntero puede alias de otro
gcc -fno-strict-aliasing prog.c

# no elimina comprobaciones de nulidad por dereferencias previas
gcc -fno-delete-null-pointer-checks prog.c

# los bucles sin efectos observables ya no se asumen terminantes
gcc -fno-finite-loops prog.c

# char sin signo en todas las plataformas, y aritmetica de punteros acotada
gcc -funsigned-char -fno-wrapv-pointer prog.c
flowchart TD
A[Que hago con este comportamiento indefinido] --> B[Es codigo nuevo que yo escribo]
A --> C[Es codigo heredado que no puedo reauditar]
B --> D[Corregir el origen: aritmetica comprobada, memcpy, validar antes]
C --> E[Bandera defensiva y anotar la deuda tecnica]
D --> F[Verificar con UBSan en la bateria de pruebas]
E --> F
F --> G[Fijar la bandera en el sistema de compilacion, no en la memoria]
style D fill:#a6e3a1,color:#11111b
style E fill:#f9e2af,color:#11111b
style G fill:#89b4fa,color:#11111b

-fwrapv es la más útil y la más malentendida. No hace que tu programa sea correcto: hace que el desbordamiento tenga un resultado definido y, sobre todo, impide que el optimizador razone a partir de su ausencia, que es donde estaba el peligro real. El coste es medible en bucles con contadores con signo, porque el compilador ya no puede ensanchar el índice a la palabra nativa ni vectorizar con la misma libertad; en código de propósito general es ruido y en núcleos numéricos puede ser un factor.

La diferencia con -fno-strict-overflow es sutil y conviene tenerla clara: la primera define el resultado como envolvente, de modo que puedes escribir código que dependa de ello; la segunda se limita a desactivar las optimizaciones que asumen la ausencia de desbordamiento, sin prometerte un valor. Si solo quieres protegerte del optimizador, la segunda basta y cuesta algo menos. Si además vas a apoyarte en el envolvimiento, necesitas la primera y estás escribiendo un dialecto, no C conforme.

-fno-strict-aliasing es la más cara de todas, y por una razón que se olvida: no se paga en las funciones problemáticas sino en todo el módulo. Al retirar la hipótesis, cada escritura a través de un puntero obliga al compilador a descartar lo que tuviera cacheado en registros sobre cualquier otro puntero, lo que bloquea el movimiento de código y la vectorización de bucles que no tenían nada que ver con el problema. Actívala si administras código heredado que reinterpreta memoria de forma sistemática; para código nuevo la respuesta siempre es memcpy.

💡
Ponlas en el sistema de compilación, nunca en la línea de órdenes

Una bandera defensiva que vive en la memoria de una persona no existe. Si tu proyecto depende de -fwrapv, escríbelo en el meson.build o en el Makefile, junto a un comentario que explique qué código lo necesita y con un static_assert o una prueba que falle si alguien lo quita. Una dependencia semántica invisible es peor que el comportamiento indefinido original, porque el próximo que compile el proyecto no tendrá forma de saber que existe.

Las herramientas, en orden de rentabilidad

Ninguna herramienta encuentra todo el comportamiento indefinido, y ninguna es prescindible. Se complementan porque atacan el problema desde ángulos distintos: unas razonan sobre el texto, otras observan la ejecución, y una tercera genera las entradas que las demás nunca probaron. La razón de fondo es que detectar comportamiento indefinido en general es indecidible, así que toda herramienta elige entre callar de más o avisar de más, y solo el conjunto cubre el hueco.

# 1. avisos: coste cero, cazan lo trivial y lo estatico
gcc -std=c23 -Wall -Wextra -Wpedantic -Wshadow -Wconversion \
    -Wstrict-aliasing=2 -Wnull-dereference -Warray-bounds=2 -c prog.c

# 2. sanitizers: la red principal, en toda la bateria de pruebas
gcc -std=c23 -O1 -g -fsanitize=address,undefined \
    -fno-sanitize-recover=all -fno-omit-frame-pointer prog.c -o prog

# 3. analisis estatico: razona sobre caminos que las pruebas no recorren
clang --analyze prog.c
gcc -fanalyzer -c prog.c

# 4. fuzzing: genera las entradas que nadie escribio a mano
clang -std=c23 -g -fsanitize=fuzzer,address,undefined objetivo.c -o objetivo

Hay un hueco que ninguna de las cuatro cubre y conviene nombrarlo para no vivir con una falsa sensación de seguridad: las violaciones de aliasing estricto y los incumplimientos de restrict. La primera se detecta solo parcialmente y de forma heurística mediante avisos; la segunda no la detecta absolutamente nada, porque la promesa se hace en la definición de la función y se rompe en el punto de llamada, muchas veces en otra unidad de traducción. Para esas dos, la única defensa es la disciplina de la sección siguiente.

El orden importa. Los avisos son gratis y deben estar en cero desde el primer día, con -Werror en integración continua. Los sanitizers son la red principal, pero solo ven lo que la ejecución alcanza: sin una batería de pruebas que cubra los caminos interesantes, UBSan no encuentra nada. El análisis estático cubre justamente el hueco contrario —caminos no ejecutados— a cambio de falsos positivos que hay que triar. Y el fuzzing cierra el círculo generando las entradas patológicas que ningún humano escribe, razón por la cual la combinación de fuzzing con sanitizers es, con diferencia, la que más comportamiento indefinido ha encontrado en la historia reciente del lenguaje.

ℹ️
El modo trap para producción

-fsanitize=undefined -fsanitize-trap=undefined emite una instrucción ilegal en lugar de un mensaje, sin biblioteca de apoyo ni tablas de metadatos. Comprobadores como bounds, object-size o signed-integer-overflow quedan tan baratos que pueden dejarse activos en un binario desplegado: el programa muere limpiamente con una señal en vez de continuar en un estado indefinido explotable. Es lo que hace el kernel con CONFIG_UBSAN_TRAP y lo que Android compila en sus componentes de red y multimedia.

Una disciplina de siete reglas

El objetivo no es cazar comportamiento indefinido: es que no llegue a escribirse. Las herramientas son la segunda línea, y una segunda línea que trabaja mucho es señal de que la primera no existe. Siete hábitos cubren la práctica totalidad de los casos reales, y todos son gratis en tiempo de ejecución porque actúan sobre cómo escribes, no sobre lo que se genera.

  1. Valida antes de dereferenciar. Nunca leas a través de un puntero antes de haber comprobado su procedencia, ni siquiera para inicializar una variable que aún no usas.
  2. No provoques la operación para inspeccionar su resultado. Comprueba antes con ckd_add, ckd_sub y ckd_mul, o calcula en un tipo más ancho.
  3. Usa tipos sin signo para tamaños y desplazamientos, con signo para aritmética que resta. El envolvimiento sin signo está definido; la resta de tamaños que se vuelve astronómica es un bug igual de real.
  4. Reinterpreta bits solo con memcpy o con una unión. Un cast entre punteros de tipos incompatibles para leer lo que otro tipo escribió es siempre una violación.
  5. Un efecto colateral por expresión. Si una variable se modifica en una expresión, que no aparezca en ninguna otra parte de esa misma expresión.
  6. Anula el puntero tras liberar y no reutilices nombres. El valor de un puntero liberado es indeterminado incluso sin dereferenciarlo.
  7. Documenta y verifica cada dependencia de la implementación con static_assert, para que la compilación falle el día que la plataforma cambie en lugar de fallar el programa.

Ninguna de las siete pide heroísmo ni ralentiza el desarrollo. Cuatro de ellas son puramente sintácticas y se aplican sin pensar en cuanto se convierten en costumbre; las otras tres exigen preguntarse de dónde viene un valor, que es la pregunta que de todos modos hay que hacerse para escribir código correcto. El rendimiento tampoco sufre: la aritmética comprobada de stdckdint.h compila a la instrucción de suma que ya usabas más un salto sobre la bandera de acarreo, y memcpy con tamaño constante no genera llamada alguna.

Estas siete no cubren la concurrencia, que merece su propio tratado: la carrera de datos entre hilos sin sincronizar es comportamiento indefinido con las mismas consecuencias que todo lo anterior, y la única herramienta que la caza de forma sistemática es -fsanitize=thread, incompatible con el sanitizer de direcciones y por tanto con una ejecución de pruebas propia. Añádela como octava regla en cuanto tu programa tenga más de un hilo: toda variable compartida es atómica, o está protegida por un mutex, o no es compartida.

La disciplina es la única optimización que nunca caduca

Después de recorrer las tres categorías, el catálogo y las autopsias, queda la pregunta que importa: ¿cómo se escribe C durante veinte años sin acumular una deuda de comportamiento indefinido que acabe cobrándose sola? La respuesta no es una herramienta, porque las herramientas cambian y ninguna es completa. Es un desplazamiento en la pregunta que te haces mientras escribes. El programador que aún no ha llegado se pregunta si esto funciona; comprueba, ve la salida correcta y sigue. El que ha llegado se pregunta si esto es verdad: si todo lo que este código afirma implícitamente —que el índice está dentro, que el puntero es válido, que la suma cabe, que estos tipos no se solapan, que el bucle termina— es cierto para toda entrada alcanzable, y no solo para la que acaba de probar. Es la misma diferencia que separa probar un teorema de comprobar un caso. Y tiene una propiedad que ninguna bandera ni ningún sanitizer poseen: no depende de la versión del compilador, no cuesta rendimiento, no caduca al subir de -O1 a -O3 ni al cruzar a otra arquitectura, y no se vuelve obsoleta con la próxima revisión de la norma. Las banderas defensivas son andamios para lo ya construido; los sanitizers son la inspección que confirma lo que ya creías. Lo único que escala durante dos décadas es escribir programas cuyas premisas sean verdaderas. Ese es el final del camino en C, y el punto en que el lenguaje deja de ser peligroso: no porque hayas aprendido a esquivar sus trampas, sino porque has dejado de necesitarlas.

⚔️ Monta tu red y estrénala
  1. Configura un proyecto con dos perfiles en meson o make: uno de desarrollo con -Og -g -fsanitize=address,undefined -fno-sanitize-recover=all y uno de despliegue con -O2 -fsanitize-trap=undefined. Compara tamaño del binario, dependencias y comportamiento al fallar.
  2. Toma un módulo tuyo de al menos quinientas líneas y compílalo con la lista completa de avisos de la lección. Corrige hasta llegar a cero y activa -Werror.
  3. Escribe un objetivo de fuzzing para tu función de análisis de entrada más compleja, ejecútalo diez minutos con sanitizers y clasifica cada hallazgo por género del catálogo.
  4. Mide el coste real de -fwrapv en un bucle numérico intensivo y en tu programa completo. Decide con datos si te lo puedes permitir y anota la decisión.
  5. Audita ese mismo módulo contra las siete reglas y anota cada incumplimiento con la regla que viola y la corrección aplicada.