wandres.dev
NOVATO · Por qué C hoy

La filosofía: confianza en el programador

C no te protege por diseño. Qué gana el lenguaje con esa decisión, qué pierde, y cuál es el contrato implícito que firmas —y debes demostrar— cada vez que compilas.

⏱ 14 min

El principio rector de C está escrito con todas las letras en el documento de fundamentos del estándar: confiar en el programador. Suena a permiso, y esa lectura ha costado miles de vulnerabilidades. En realidad es lo contrario: no es una licencia que se te concede, es una obligación de prueba que asumes. Entender esa inversión es el salto conceptual más importante del nivel uno.

🎯 Al terminar esta lección sabrás
  • Enunciar los principios de diseño que el comité llama el espíritu de C.
  • Argumentar qué capacidades reales gana el lenguaje al no protegerte.
  • Ver cómo el comportamiento indefinido convierte tus promesas en teoremas.
  • Formular el contrato implícito y cómo se demuestra en 2026.

El espíritu de C: cinco principios incómodos

El documento de fundamentos que acompaña al estándar recoge un puñado de máximas que el comité usa para decidir qué entra y qué no. Vale la pena leerlas literalmente, porque explican decisiones que de otro modo parecen arbitrarias:

  • Confía en el programador. El lenguaje no impone comprobaciones que el programador podría no necesitar.
  • No impidas al programador hacer lo que hay que hacer. Si algo es necesario a bajo nivel, debe ser expresable.
  • Mantén el lenguaje pequeño y simple. La complejidad se paga en la implementación, y la implementación debe ser portable.
  • Ofrece una única forma de hacer cada operación. Se ha erosionado con el tiempo, pero sigue orientando.
  • Hazlo rápido, aunque no se garantice portable. El rendimiento pesa más que la uniformidad entre plataformas.

Los tres primeros forman una unidad lógica. Si el lenguaje debe ser pequeño y simple, el compilador puede ser pequeño y simple; si el compilador es simple, existe para cualquier arquitectura nueva; si existe para cualquier arquitectura, C se convierte en el sustrato universal que viste en el nivel 1.3. La ausencia de protecciones no es un olvido: es el precio pagado deliberadamente por la ubicuidad.

Qué se gana: el modelo de ejecución transparente

La contrapartida es un modelo mental que casi ningún otro lenguaje de uso general ofrece con esta nitidez:

🪞

Sin runtime oculto

No hay recolector de basura, ni máquina virtual, ni desenrollado de pila implícito. Lo que se ejecuta es lo que escribiste, más lo que el optimizador dedujo.

🗺️

Layout predecible

Un struct es un bloque de bytes con desplazamientos calculables. Eso permite mapear registros de hardware, formatos binarios y protocolos de red directamente sobre tipos.

⏱️

Coste visible

Ninguna asignación de memoria ocurre a tus espaldas. En sistemas de tiempo real, la ausencia de pausas impredecibles no es una comodidad: es un requisito.

🔓

Escape deliberado

Puedes reinterpretar bytes, escribir tu propio asignador o hablar con el hardware. Los lenguajes seguros necesitan una vía de escape explícita para lo mismo.

Estas cuatro propiedades no son independientes: todas derivan de la misma renuncia. El lenguaje no puede protegerte precisamente porque no se reserva ninguna capa intermedia donde colocar la protección.

Qué se pierde: el comportamiento indefinido como mecanismo

Aquí está la parte que casi nadie explica bien. El estándar define el significado de un programa solo si el programa es correcto. Si invocas comportamiento indefinido, la norma no dice que el resultado sea impredecible: dice que la norma no impone ningún requisito, y el compilador puede asumir con total legitimidad que eso jamás ocurre.

Esa asunción no es pasiva. El optimizador la usa como premisa para razonar:

#include <limits.h>

/* Comprobación rota: se apoya en el desbordamiento con signo, que es UB */
int suma_saturada(int a, int b)
{
    int r = a + b;
    if (a > 0 && b > 0 && r < 0)   /* el compilador puede eliminar esta rama */
        return INT_MAX;
    return r;
}

El razonamiento del compilador es impecable. Como el desbordamiento con signo es comportamiento indefinido, en todo programa válido a + b no desborda; si a y b son positivos, entonces r es positivo; por tanto la condición es siempre falsa y la rama es código muerto. Tu comprobación desaparece, y desaparece más agresivamente cuanto más optimices.

La versión correcta no pregunta después, sino antes, y no usa aritmética que pueda desbordar:

#include <limits.h>

int suma_saturada_ok(int a, int b)
{
    if (b > 0 && a > INT_MAX - b) return INT_MAX;
    if (b < 0 && a < INT_MIN - b) return INT_MIN;
    return a + b;
}

El patrón general es el mismo en todo C: nunca compruebes una condición después de haber ejecutado la operación que la haría indefinida. Una vez cruzada esa línea, no hay estado del programa sobre el que razonar.

El mismo mecanismo aparece, con consecuencias mucho peores, cuando la premisa es la validez de un puntero:

int leer_campo(struct nodo *n)
{
    int v = n->valor;      /* desreferencia: el compilador deduce que n no es nulo */
    if (n == NULL)         /* comprobacion ahora demostrablemente inutil */
        return -1;
    return v;
}

Al desreferenciar n en la primera línea, el programa afirma implícitamente que n no es nulo, porque desreferenciar un puntero nulo es indefinido. El optimizador propaga esa afirmación hacia abajo y elimina la comprobación. Este patrón exacto ha producido vulnerabilidades reales en núcleos de sistemas operativos: una comprobación defensiva que el autor escribió, que el revisor leyó y que el binario no contiene.

⚠️
Indefinido no significa impredecible

Es tentador traducir comportamiento indefinido por resultado aleatorio. No lo es. Significa que el compilador razonó suponiendo que nunca ocurre y transformó tu programa a partir de esa suposición. Por eso el síntoma casi nunca aparece donde está la causa, por eso cambia al subir el nivel de optimización y por eso reproducirlo en depuración puede ser imposible.

flowchart TD
A[Escribes una operacion con comportamiento indefinido] --> B[La norma no impone ningun requisito]
B --> C[El compilador asume que jamas ocurre]
C --> D[Convierte la asuncion en premisa del analisis]
D --> E[Elimina ramas y reordena accesos]
E --> F[El binario no contiene tu comprobacion]
F --> G[El fallo aparece lejos de la causa]
style B fill:#f9e2af,color:#11111b
style D fill:#fab387,color:#11111b
style G fill:#f38ba8,color:#11111b

El contrato que firmas al compilar

Formulado sin adornos, cada compilación es un intercambio con dos cláusulas asimétricas:

  • Tú te comprometes a que, para toda entrada posible, tu programa no invoca comportamiento indefinido: sin accesos fuera de rango, sin uso después de liberar, sin desbordamiento con signo, sin violaciones de alias estricto, sin carreras de datos, sin desreferenciar punteros nulos.
  • El compilador se compromete a generar el código más rápido posible suponiendo que cumpliste. Nada más. No te avisa si fallas, no está obligado a diagnosticarlo y no hay un modo de fallo definido.

La consecuencia metodológica es que el contrato debe escribirse, no suponerse. Una interfaz profesional en C declara sus precondiciones y su régimen de propiedad de forma explícita, porque el lenguaje no tiene dónde expresarlas:

/*
 * Copia como maximo cap-1 bytes de src en dst y termina en cero.
 * Precondiciones: dst apunta a al menos cap bytes validos; cap es mayor que 0;
 *                 src es una cadena valida terminada en cero; no se solapan.
 * Devuelve: longitud que habria necesitado, para detectar truncamiento.
 * Propiedad: el llamante posee dst; la funcion no retiene ningun puntero.
 */
size_t copiar_acotado(char *dst, size_t cap, const char *src);

Ese bloque no es documentación decorativa: es la enumeración exacta de lo que el compilador da por cierto y no puede verificar. Si una sola de esas líneas se incumple en alguna ruta de ejecución, el programa deja de tener significado definido.

La asimetría es total: tu parte es una obligación universal sobre todas las ejecuciones posibles, y la suya está condicionada a que la tuya se cumpla. Por eso conviene pensarlo en términos de demostración y no de buenas intenciones. La práctica profesional en 2026 consiste en tener un procedimiento explícito para descargar esa obligación: avisos del compilador tratados como errores, sanitizers de memoria y de comportamiento indefinido en las pruebas, análisis estático en la integración continua y fuzzing sobre cualquier entrada no confiable. Todo eso lo verás a fondo en los niveles 24 a 26.

La confianza no es un permiso: es una obligación de prueba

Casi todo el mundo lee la máxima como una concesión —C me deja hacer lo que quiera— cuando es exactamente lo contrario. El estándar no te concede libertad: te transfiere una carga de la prueba que en otros lenguajes asume el compilador o el sistema de tipos. Y hay un detalle que convierte esa carga en algo mucho más severo de lo que parece: el optimizador no solo tolera tus promesas, las utiliza como axiomas. Cada construcción cuyo comportamiento es indefinido se traduce en un teorema que el compilador da por cierto y propaga hacia atrás y hacia adelante por el flujo de control, borrando ramas, reordenando accesos y estrechando rangos de valores. De ahí que un fallo no se manifieste donde lo cometiste, sino a cincuenta líneas de distancia y solo con optimizaciones activadas. La consecuencia madura no es tenerle miedo a C ni desactivar optimizaciones: es dejar de tratar la corrección como una intuición y empezar a tratarla como una propiedad que se demuestra con herramientas. Cuando escribas C profesionalmente, tu valor no estará en escribir código que funciona en tu máquina, sino en poder argumentar por qué no puede invocar comportamiento indefinido para ninguna entrada. Ese argumento es, literalmente, tu firma en el contrato.

⚔️ Firma el contrato con los ojos abiertos
  1. Compila suma_saturada sin optimizaciones y con optimización agresiva. Compara el código generado y localiza la rama desaparecida.
  2. Repite el experimento activando el sanitizer de comportamiento indefinido y observa qué informa en tiempo de ejecución.
  3. Vuelve a compilar forzando semántica de envoltura en el desbordamiento con signo y explica por qué la rama reaparece.
  4. Escribe una función que copie una cadena a un búfer fijo y enumera todas las precondiciones que el llamante debe cumplir. Esa lista es tu parte del contrato.
  5. Redacta en un comentario, en dos frases, la garantía que ofrece tu función y la que exige. Adopta esa costumbre desde hoy.