wandres.dev
NOVATO · Por qué C hoy

Una historia con consecuencias: de B a C23

Medio siglo de revisiones del estándar: qué problema resolvía cada una, qué se ganó a cambio y qué decisiones tomadas sobre un PDP-11 seguimos pagando en 2026.

⏱ 14 min

Ningún lenguaje se diseña en el vacío. C nació bajo restricciones brutales — una máquina con memoria contada y un compilador que debía caber dentro de ella — y muchos atajos de aquel momento se congelaron como semántica permanente. Reconstruir esa genealogía no es erudición histórica: es la única forma de entender por qué C hace hoy cosas que nadie diseñaría desde cero.

🎯 Al terminar esta lección sabrás
  • Reconstruir la línea BCPL, B, K&R C, C89, C99, C11 y C23.
  • Identificar qué problema concreto resolvía cada revisión.
  • Separar las decisiones deliberadas de los accidentes históricos.
  • Reconocer la herencia de los años setenta que sigue viva en tu código.

De BCPL a K&R: el pecado original

BCPL (Martin Richards, 1967) y su descendiente B (Ken Thompson, 1969) eran lenguajes sin tipos: la memoria era un vector uniforme de palabras y el programador decidía por convención si una palabra contenía un número, caracteres empaquetados o una dirección. Aquello funcionaba porque el PDP-7 direccionaba palabras.

El PDP-11, en 1970, rompió el modelo: direccionaba bytes y distinguía operaciones de 8 y de 16 bits. Un lenguaje sin tipos ya no podía generar código correcto, porque el compilador necesitaba saber cuántos bytes mover. Dennis Ritchie añadió tipos a B, y de ese parche nació C hacia 1972.

Conviene subrayarlo, porque explica media semántica del lenguaje: los tipos entraron en C por necesidad de generación de código, no por seguridad. Un tipo en C responde a “cuántos bytes ocupa y cómo se interpretan”, no a “qué operaciones son legítimas sobre este valor”.

De esa transición vienen tres decisiones que ya no se pueden deshacer:

  • Los arrays se degradan a punteros. En B, declarar un array reservaba una celda que contenía un puntero a los datos. Ritchie eliminó esa celda pero conservó la equivalencia entre a[i] y *(a + i). Resultado: el nombre del array se evalúa como una dirección y el tamaño se pierde al cruzar una llamada a función.
  • Las cadenas terminan en cero. Un prefijo de longitud costaba una palabra por cadena en una máquina donde cada palabra importaba; el terminador era gratis. El precio lo pagamos desde entonces: strlen es lineal, no existe un límite implícito y buena parte del historial de vulnerabilidades de la industria nace ahí.
  • Promociones enteras y declaraciones laxas. El int implícito, la ausencia de prototipos y la promoción automática a int respondían a un compilador de una sola pasada que tenía que caber en la máquina.

En 1978, la primera edición de The C Programming Language de Kernighan y Ritchie fijó el lenguaje de facto. No había prototipos, ni void, ni const: había un libro y una implementación de referencia. Así se declaraba entonces una función, y así se declara ahora:

/* Estilo K&R, 1978: los tipos de los parametros van fuera de la lista */
int suma(a, b)
int a;
int b;
{ return a + b; }

/* Estilo con prototipo, desde C89: el compilador comprueba la llamada */
int suma(int a, int b) { return a + b; }

La diferencia parece cosmética y no lo es en absoluto. Sin prototipo, el compilador no conocía los tipos de los argumentos en el punto de llamada: aplicaba las promociones por defecto y confiaba en que coincidieran. Llamar a suma con un double compilaba sin queja alguna y producía basura en tiempo de ejecución. Toda una generación de errores de portabilidad nació de ese hueco, y C23 lo ha cerrado por fin retirando la forma antigua del lenguaje.

flowchart LR
A[BCPL 1967 sin tipos] --> B[B 1969 palabras]
B --> C[C 1972 tipos por el PDP 11]
C --> D[K and R 1978 estandar de facto]
D --> E[C89 ANSI y C90 ISO]
E --> F[C99 numerico y moderno]
F --> G[C11 concurrencia]
G --> H[C17 correcciones]
H --> I[C23 limpieza y ergonomia]
style C fill:#89b4fa,color:#11111b
style E fill:#94e2d5,color:#11111b
style F fill:#f9e2af,color:#11111b
style G fill:#fab387,color:#11111b
style I fill:#a6e3a1,color:#11111b

C89 y C99: estandarizar, después modernizar

C89 (ANSI X3.159-1989, adoptado como ISO/IEC 9899:1990) resolvía un problema industrial, no técnico: cada fabricante había extendido C a su manera y el código dejó de ser portable. El comité aportó prototipos de función tomados de C++, los calificadores const y volatile, una biblioteca estándar por fin definida y las localizaciones.

Su contribución más profunda, sin embargo, fue taxonómica: C89 formalizó las categorías de comportamiento —definido, definido por la implementación, no especificado e indefinido— y la regla as-if, que autoriza al compilador a transformar el programa como quiera siempre que el comportamiento observable se conserve. Esas dos páginas son el fundamento legal de todo optimizador agresivo posterior.

C99 atacó otro frente: el cómputo numérico y la llegada de los 64 bits. Trajo long long, los tipos de anchura exacta de stdint.h, los números complejos, restrict, inline, el tipo _Bool, los literales compuestos, los inicializadores designados, las declaraciones en cualquier punto del bloque, los comentarios de línea, los miembros de array flexibles y la vinculación con IEEE-754.

Buena parte de lo que hoy consideras C idiomático es en realidad vocabulario de C99:

struct config { uint32_t timeout_ms; uint16_t reintentos; _Bool verbose; };

/* Inicializadores designados: los campos no citados quedan a cero */
struct config c = { .timeout_ms = 500, .verbose = 1 };

/* Literal compuesto: un objeto anonimo con duracion automatica */
aplicar((struct config){ .timeout_ms = 100, .reintentos = 3 });

for (int i = 0; i < 3; i++)   /* declaracion dentro del for */
    aplicar(c);

También trajo el mayor error de cálculo del comité: los arrays de longitud variable obligatorios. La idea era elegante, pero implica asignación dinámica en la pila sin forma de detectar el fallo, complica enormemente la generación de código y abre una vía de agotamiento de pila explotable desde datos de entrada. C11 los degradó a característica opcional y el núcleo de Linux los eliminó de su base de código en 2018.

📝
Una revisión se juzga por lo que se adopta

C99 tardó más de una década en tener soporte universal, en buena parte porque un compilador dominante de la época se negó a implementarlo. La lección es válida en 2026: una característica del estándar solo existe de verdad cuando los tres o cuatro compiladores relevantes la implementan y los proyectos aceptan exigir esa versión mínima.

C11 y C23: consolidar y limpiar

C11 (ISO/IEC 9899:2011) resolvió una carencia que se había vuelto insostenible: hasta entonces el estándar no sabía que existían los hilos. Los programas multihilo se apoyaban en bibliotecas como pthreads, pero desde el punto de vista del lenguaje el compilador podía reordenar accesos e incluso inventar escrituras especulativas sobre variables compartidas, porque nada en la norma se lo prohibía.

C11 introdujo un modelo de memoria formal —alineado con el de C++11—, el calificador _Atomic con su biblioteca de operaciones atómicas y órdenes de sincronización, un API de hilos opcional, _Static_assert, la selección genérica _Generic, las estructuras y uniones anónimas y aligned_alloc. También añadió el Anexo K de funciones con comprobación de límites, que fracasó: apenas se implementó fuera de una plataforma y su propia revisión técnica recomendó retirarlo. C17 no añadió nada: fue una pasada de corrección de defectos.

C23 (ISO/IEC 9899:2024) ataca la deuda ergonómica acumulada: décadas de idioms resueltos con macros peligrosas y extensiones no portables. Aporta nullptr con tipo propio, constexpr, typeof, auto, _BitInt(N), la directiva #embed, los atributos con sintaxis [[nodiscard]] unificada con C++, enumeraciones con tipo subyacente fijo, literales binarios, separadores de dígitos y la cabecera de manipulación de bits stdbit.h.

Y, por primera vez en mucho tiempo, quita cosas: desaparecen las definiciones de función al estilo K&R, el int implícito y los trigrafos. Además fija por norma la representación en complemento a dos para los enteros con signo, cerrando una ambigüedad que llevaba abierta cincuenta años. Que ese cambio haya tardado tanto es revelador: la norma describía tres representaciones posibles porque en los años ochenta existían máquinas reales con las otras dos.

El patrón de C23 se ve mejor comparando el antes y el después de un mismo idiom:

/* Antes: macro sin tipo, evaluada por el preprocesador */
#define TAM_BUF 4096
#define NULO ((void *)0)

/* C23: constantes con tipo y un puntero nulo que el compilador entiende */
constexpr size_t tam_buf = 4096;
char *p = nullptr;

/* C23: enumeracion con tipo subyacente fijo y anchura garantizada */
enum estado : uint8_t { INACTIVO, LISTO, ERROR };

El hilo conductor es constante: sustituir trucos del preprocesador por construcciones que el compilador puede razonar y comprobar. No se añade poder expresivo nuevo; se traslada trabajo desde una fase textual y ciega hacia una fase con información de tipos.

Las decisiones que nos siguen persiguiendo

📉

La degradación de arrays

Un parámetro declarado como array es en realidad un puntero. El tamaño no viaja con el dato, así que toda comprobación de límites es responsabilidad manual del programador.

🧵

Cadenas sin longitud

El terminador nulo obliga a recorrer para medir, impide subcadenas baratas y convierte cualquier olvido en lectura fuera de rango.

🔢

Conversiones implícitas

Las promociones enteras y las conversiones aritméticas usuales convierten comparaciones aparentemente triviales entre signed y unsigned en fuentes clásicas de error.

💥

El comportamiento indefinido

Nació como margen de maniobra para implementaciones diversas y se convirtió en licencia de optimización agresiva. Es la deuda histórica más cara del lenguaje.

La tercera merece una demostración, porque es la que más gente experimentada sigue sufriendo:

int contiene(const char *s, char c)
{
    /* strlen devuelve size_t, que no tiene signo */
    for (int i = 0; i < strlen(s); i++)   /* comparacion signed contra unsigned */
        if (s[i] == c) return 1;
    return 0;
}

La comparación de la condición del bucle convierte i a size_t por las conversiones aritméticas usuales. Si i llegase a ser negativo, se convertiría en un número enorme y el bucle terminaría de forma sorprendente. La regla histórica que lo provoca —promover hacia el tipo sin signo— se tomó para que el generador de código del PDP-11 produjera una única secuencia de comparación. Cincuenta años después, sigue produciendo errores en revisiones de código.

⚠️
Ninguna de estas herencias se puede arreglar

Cambiar la degradación de arrays, el terminador nulo o las conversiones aritméticas rompería incompatiblemente miles de millones de líneas en producción. Por eso el estándar nunca lo hará, y por eso el trabajo recae en ti: elegir interfaces que lleven la longitud explícita, comparar siempre entre tipos del mismo signo y apoyarte en los diagnósticos del compilador como primera línea de defensa.

La compatibilidad hacia atrás no es un defecto del comité: es su función

Cada vez que el comité de C intentó romper el pacto con el código existente, fracasó. Los arrays de longitud variable obligatorios se volvieron opcionales; el Anexo K quedó en papel mojado; los intentos de reformar la aritmética de punteros nunca prosperaron. En cambio, todo lo que añadió sin romper —prototipos, stdint.h, atómicos, nullptr— se adoptó de manera irreversible. Esto no es conservadurismo gremial: es la propiedad que hace valioso a C. Un fuente escrito en 1990 sigue compilando hoy, y por eso C pudo convertirse en la frontera binaria estable sobre la que se apoya todo el resto del ecosistema (nivel 1.3). El precio, y hay que asumirlo con los ojos abiertos, es que los errores de diseño de C son permanentes: no se corrigen, se rodean con herramientas —sanitizers, análisis estático, tipos más expresivos— y con disciplina. Cuando en C23 escribas constexpr o nullptr, recuerda que estás usando parches ergonómicos sobre un núcleo semántico que se fijó cuando la memoria se medía en kilobytes. Entender ese núcleo, y no solo los parches, es lo que separa a quien escribe C de quien lo domina.

⚔️ Arqueología del estándar
  1. Compila un mismo fuente con -std=c89, -std=c99, -std=c11 y -std=c23 y anota qué diagnósticos aparecen y desaparecen.
  2. Escribe una definición de función al estilo K&R y comprueba que un compilador en modo C23 la rechaza.
  3. Declara un array local y pásalo a una función: imprime el resultado de sizeof dentro y fuera. Explica la diferencia con lo que leíste sobre B.
  4. Busca en la lista de defect reports del comité un caso real y sigue el razonamiento: qué se preguntó, qué se respondió y por qué.
  5. Elige una característica de C23 y averigua desde qué versión de GCC y de Clang está disponible. Esa es la fecha real en que existe.