⏱️ Lectura: 14 min
Thomas Habets lleva treinta años escribiendo C y C++ casi a diario. Trabaja en Google, lee propuestas del comité ISO, escucha podcasts del lenguaje y disfruta del oficio. Su conclusión publicada esta semana en su blog personal es contundente: nadie puede escribir código C correcto. Y no lo dice contra los principiantes — lo dice contra sí mismo.
📑 En este artículo
El ensayo recorre los rincones del comportamiento indefinido que la mayoría de desarrolladores ignora y lo conecta con la migración masiva de infraestructura crítica hacia Rust que vimos durante 2026. La tesis es directa: el entorno de 1972 ya no es el entorno de hoy.
TL;DR
- Thomas Habets, ingeniero con 30 años en C/C++, sostiene que nadie escribe código no trivial libre de comportamiento indefinido.
- El UB no aparece solo con optimizaciones agresivas: significa que el compilador asume que tu código nunca lo invoca, en ningún backend.
- Crear un puntero mal alineado ya es UB en C23 §6.3.2.3 — no hace falta dereferenciarlo, el cast solo basta.
- isxdigit() recibe un int; pasarle un char con signo negativo es UB. La mayoría del código C en producción no castea a unsigned char.
- El desbordamiento de enteros con signo sigue siendo UB en 2026, aunque todos los CPU reales usan complemento a dos.
- Microsoft reportó que el 70% de los CVE críticos en Windows entre 2006 y 2018 fueron bugs de memoria prevenibles en lenguajes safe.
- Bun, Astral, Cloudflare y módulos del kernel de Linux migraron infraestructura crítica a Rust en los últimos 12 meses.
Qué pasó
Habets publicó en blog.habets.se un ensayo titulado Everything in C is undefined behavior. El texto sostiene que todo programa no trivial escrito en C o C++ contiene comportamiento indefinido (UB, por undefined behavior), y que la industria sigue tropezando con los mismos errores década tras década.
El argumento en sí mismo no es nuevo. Lo distinto es de quién viene y cuándo llega. Habets no es un converso de Rust evangelizando contra el lenguaje viejo — es un practicante veterano de C++ que admite que no puede dominarlo y que argumenta, con casos concretos, que nadie puede. La pieza aterriza además en un momento particular: en los últimos doce meses, la infraestructura más cargada del internet hispano y global — el runtime de Bun, las herramientas Python de Astral, el edge de Cloudflare, módulos críticos del kernel de Linux — migró parcial o totalmente a Rust. Habets conecta esta migración con la imposibilidad práctica de escribir C seguro a escala.
Contexto e historia
El comportamiento indefinido es una categoría del estándar C que cubre situaciones donde el lenguaje no especifica qué debe ocurrir. La especificación C23 lista decenas de casos: doble free, uso después de liberación, acceso fuera de los límites de un objeto, lectura de memoria sin inicializar, división por cero con enteros, desbordamiento de enteros con signo, modificación de cadenas literales, y un largo etcétera.
La intuición común entre programadores intermedios es que el UB solo aparece cuando se activa la optimización del compilador. Como si el compilador, al detectar UB, dijera deliberadamente AHA, puedo hacer lo que quiera aquí — y al desactivar las optimizaciones, ese impulso desapareciera. Habets enfatiza que esta intuición es incorrecta.
UB no significa que el compilador aproveche tu descuido. UB significa que el compilador puede asumir que tu código nunca invoca UB. La intención que es obvia para un humano leyendo el código no tiene siquiera una manera de ser expresada entre las fases del compilador o entre módulos. El compilador, y el hardware abajo, juegan un teléfono descompuesto con tu intención: puede que termine haciendo lo que querías, pero no hay garantías para hoy ni para versiones futuras.
⚠️ Ojo: el compilador no tiene que implementar casos especiales en su generación de código para situaciones que “no pueden pasar”. Si tu código las dispara, el binario resultante puede tener cualquier comportamiento — incluso uno que pase tests hoy y falle en producción cuando se actualice GCC o Clang.
Casos sutiles que pocos conocen
Habets dedica buena parte del ensayo a casos donde el UB es invisible para un lector experimentado. Tres ejemplos resumen el argumento.
Acceso a un objeto mal alineado
Considerá esta función trivial:
int foo(const int* p) {
return *p;
}
Si esta función se llama con un puntero no alineado correctamente — probablemente significando una dirección múltiplo de sizeof(int), aunque la definición exacta depende de la implementación — es UB según C23 §6.3.2.3.
En Linux Alpha, en algunos casos esto atrapaba al kernel, que emulaba en software lo que querías hacer. En otros casos crasheaba con SIGBUS. En SPARC, SIGBUS directo. En x86 y amd64 es probable que funcione, e incluso podría ser una lectura atómica — x86 es famosamente permisivo con la coherencia de caché. ¿Qué pasa en ARM, RISC-V o futuras arquitecturas? Habets imagina un futuro CPU con registros especiales para punteros enteros que no use los bits bajos, porque tales punteros no pueden existir desde la perspectiva del lenguaje.
Y acá viene el giro: el problema no está en dereferenciar el puntero. Está en crearlo. Este código ya es UB antes incluso de pasar por foo():
bool parse_packet(const uint8_t* bytes) {
const int* magic_intp = (const int*)bytes; // UB aquí
int magic_raw = foo(magic_intp);
int magic = ntohl(magic_raw);
// ...
}
El cast es el problema, no la función foo(). Es perfectamente válido para el compilador asignar significado específico — tags de garbage collection, bits de seguridad, identificadores de cache line — a los bits bajos de un int*. Si lo asume y vos pasaste bits arbitrarios, tu programa entró en territorio indefinido sin que ninguna línea de código se vea sospechosa al ojo humano.
isxdigit() con char con signo
La función estándar isxdigit() recibe un int, no un char. Acepta valores de unsigned char y el valor especial EOF. Si pasás un char con signo cuyo valor sea negativo — algo perfectamente posible cuando leés bytes de un archivo UTF-8, de la red o de cualquier fuente binaria — el resultado es UB.
bool bar(char ch) {
return isxdigit(ch); // UB si ch es negativo
}
La forma correcta requiere casteo explícito a unsigned char antes de pasar el valor:
bool bar(char ch) {
return isxdigit((unsigned char)ch);
}
¿Cuántas líneas de código C activo en producción en LATAM cumplen esta regla? Probablemente menos del 5%. Y este es uno de los UB más documentados del lenguaje: aparece en la página de cppreference desde hace dos décadas. Si los profesionales que escriben C cada día no lo recuerdan, ¿qué esperanza tienen los demás?
Desbordamiento de enteros con signo
En 2026, todos los procesadores que importan usan representación de complemento a dos. Sumar INT_MAX + 1 en una arquitectura real produce INT_MIN. Pero el estándar de C sigue declarando este caso como UB. El compilador puede asumir que un programador nunca dispara desbordamiento con signo, y optimizar bucles bajo esa premisa.
El ejemplo clásico es un loop con condición i <= INT_MAX: si la variable i es int, el compilador puede asumir que la condición eventualmente se vuelve falsa, porque desbordar es UB y “no puede pasar”. Si vos escribiste el código contando con el wraparound para salir del loop, podés terminar con un loop infinito en release y un loop finito en debug. Y nada en el código fuente revela la diferencia.
El flujo del compilador con UB
Para visualizar por qué desactivar optimizaciones no salva del problema, sirve mirar el flujo conceptual:
graph LR
A["Codigo C con UB latente"] --> B["Frontend del compilador"]
B --> C["IR asume codigo valido"]
C --> D["Optimizador elimina ramas imposibles"]
C --> E["Backend genera asm"]
D --> F["Binario impredecible"]
E --> F
El IR (representación intermedia) carga la asunción de que el código no invoca UB desde la primera traducción. No es algo que se active con -O2. Es algo que está horneado en la semántica del lenguaje desde C89.
Datos y cifras del problema
El ensayo de Habets no sale del vacío. Aterriza en una conversación más amplia con datos concretos:
- Microsoft reportó en 2019 que el 70% de los CVE críticos parcheados en sus productos entre 2006 y 2018 fueron bugs de memoria — todos prevenibles en un lenguaje memory-safe.
- Google publicó cifras similares para Chromium: aproximadamente el 70% de las vulnerabilidades de severidad alta eran bugs de memoria nativa.
- Android reportó que las introducciones nuevas de Rust en su base de código tuvieron cero vulnerabilidades de memoria en 2024, mientras que el código C/C++ equivalente habría producido decenas según las estadísticas históricas.
- Bun exploró un puerto de Zig a Rust usando agentes de Claude Code en 2026.
- Astral, la empresa detrás de uv y ruff, escribió toda su pila de herramientas Python en Rust.
- Cloudflare reescribió componentes críticos de su edge en Rust.
- El kernel de Linux aceptó Rust como segundo lenguaje oficial en 2024 y desde entonces aceptó drivers y módulos críticos.
Estas decisiones no son moda. Son una respuesta racional al argumento de Habets: si nadie puede escribir C libre de UB, y los compiladores cada año son más agresivos asumiendo que el código no invoca UB, entonces la única forma de tener garantías reales es cambiar de herramienta.
Impacto y análisis
La pieza ya generó respuestas en Hacker News, Lobsters y en el subreddit r/cpp. Los críticos hacen tres puntos válidos.
Primero, existen herramientas de análisis estático y dinámico. UBSan (UndefinedBehaviorSanitizer), AddressSanitizer, ThreadSanitizer y herramientas comerciales como Coverity o PVS-Studio reducen el costo de mantener C seguro. Habets responde que estas herramientas son cinta adhesiva sobre un problema de diseño: detectan UB cuando se ejecuta, pero no garantizan su ausencia. Ningún proyecto C grande logra zero-UB verificado en producción.
Segundo, C tiene un ecosistema y un perfil de rendimiento difícil de replicar. Reescribir todo en Rust es caro y arriesgado. Habets coincide y aclara que no propone tirar C a la basura — propone reconocer la realidad y dejar de escribir código nuevo en C cuando hay alternativa razonable. La diferencia entre mantener libcurl en C y empezar un parser nuevo en C es enorme.
💭 Clave: el argumento no es contra C como artefacto histórico. Es contra C como elección por defecto para código nuevo en 2026, cuando existen Rust, Zig y otras alternativas con sistemas de tipos modernos.
Tercero, el comité C avanza. C23 fijó varios UB históricos: la representación de complemento a dos para enteros con signo ya es obligatoria, por ejemplo, aunque el desbordamiento sigue siendo UB. C2y y futuros estándares prometen más fixes. Habets reconoce el progreso pero apunta que el ritmo es de décadas, no de releases. Para un proyecto greenfield iniciado hoy, esperar a que el comité elimine el UB no es un plan viable.
Qué hacer hoy si trabajás con C
Para desarrolladores en LATAM trabajando con C/C++ activo, el ensayo sugiere acciones concretas que se pueden implementar esta semana:
- Compilar siempre con
-Wall -Wextra -Wpedanticy al menos un sanitizer activo en CI. - Activar UBSan en tests, no solo ASan. UBSan captura los casos sutiles de cast, alignment y overflow que ASan no ve.
- Usar
-fno-strict-aliasingy-fwrapvcomo red de seguridad mientras se migra código heredado. No son una cura, pero reducen la superficie. - Evaluar Rust o Zig para módulos nuevos donde el rendimiento importe pero la corrección sea crítica. Un parser de protocolos, un encoder de media, un componente de red — todos candidatos.
- Documentar cada cast de puntero que cruce tipos, porque casi siempre es UB y necesita auditoría manual.
- Adoptar
std::spanystd::string_viewen C++20 en lugar de punteros crudos cuando sea posible. Reducen oportunidades de error pero no eliminan UB.
Qué sigue
El debate no se cerrará con un post de blog. C va a seguir existiendo y se va a seguir escribiendo. Linux, glibc, OpenSSL, SQLite, Postgres, Redis, FFmpeg, el kernel de cada sistema operativo serio: todo eso es C o C++ y va a seguir siéndolo por décadas.
Lo que cambia es la dirección del flujo de código nuevo. Cuando una startup de Silicon Valley o de São Paulo elige un lenguaje para su backend en 2026, la decisión rara vez es C++. Cuando Microsoft anuncia que va a portar partes del kernel de Windows a Rust, el mensaje no es para los que mantienen NTFS. Es para los que van a escribir el próximo subsistema.
La industria se mueve y los lenguajes nuevos absorben las lecciones de cuatro décadas. Que un ingeniero de Google con tres décadas de C++ ratifique públicamente esta lección no cambia el rumbo, pero le da peso a una conversación que en LATAM apenas empieza.
📖 Resumen en Telegram: Ver resumen
Preguntas frecuentes
¿Qué es exactamente el comportamiento indefinido en C?
Es una categoría del estándar de C que describe operaciones para las cuales el lenguaje no especifica qué debe ocurrir. El compilador puede asumir que tu código nunca las invoca, y optimizar bajo esa premisa. El resultado puede ser un binario que funciona, uno que crashea o uno que produce resultados arbitrarios — todo es válido según el estándar.
¿Desactivar optimizaciones evita el comportamiento indefinido?
No. UB es una propiedad semántica del lenguaje, no una consecuencia de optimizar. Incluso con -O0, el compilador puede generar código que rompe con UB, especialmente en arquitecturas exóticas o cuando el backend traduce la representación intermedia a assembly.
¿Sirve UBSan para encontrar todos los casos?
UBSan encuentra muchos casos de UB pero no todos, y solo los detecta cuando se ejecutan en runtime con la instrumentación activa. Es una herramienta excelente en CI y en QA, pero no garantiza ausencia de UB en código que no se ejercitó durante los tests.
¿Rust elimina completamente el problema?
Rust elimina los UB de seguridad de memoria en código safe, que es la mayoría del código que escribís. El código marcado unsafe puede tener UB y requiere auditoría manual. La diferencia es que en Rust el código unsafe es una porción pequeña y localizada, mientras que en C todo el código es implícitamente unsafe.
¿Debería reescribir mi proyecto C en Rust?
Probablemente no. Reescribir es caro y arriesgado. Lo que sí tiene sentido es no empezar proyectos nuevos en C cuando hay alternativa razonable, y considerar Rust para módulos nuevos dentro de un proyecto existente. Bun, por ejemplo, mantiene Zig para el core y agrega Rust en módulos nuevos.
¿C23 resolvió los problemas históricos?
Parcialmente. C23 mandató representación de complemento a dos para enteros con signo, agregó atributos de seguridad y limpió casos puntuales. Pero el grueso del UB sigue presente: alignment de punteros, casts entre tipos incompatibles, desbordamiento con signo, accesos fuera de objeto y muchos más.
Referencias
- blog.habets.se — Ensayo original de Thomas Habets sobre UB en C.
- cppreference.com — Documentación de comportamiento indefinido en el estándar C.
- github.com/google/sanitizers — Repositorio de UBSan, AddressSanitizer y otras herramientas de detección.
- open-std.org/jtc1/sc22/wg14 — Comité ISO C y documentos de C23.
📱 ¿Te gusta este contenido? Únete a nuestro canal de Telegram @programacion donde publicamos a diario lo más relevante de tecnología, IA y desarrollo. Resúmenes rápidos, contenido fresco todos los días.
0 Comentarios