wandres.dev
INICIADO · El proceso de compilación

Compilación separada

Por qué C se compila archivo a archivo: qué gana en velocidad, qué pierde en optimización y qué complica en coherencia. El modelo que explica la existencia de Make, Ninja y Meson.

⏱ 15 min

C no compila proyectos: compila unidades de traducción, una a una, sin que ninguna sepa de las demás. Esa decisión, tomada cuando las máquinas no tenían memoria para más, resultó ser una de las más duraderas de la historia del software. Te regala reconstrucciones incrementales y paralelismo casi gratis, y a cambio te cobra en coherencia: nadie garantiza que todas las piezas se compilaran con las mismas suposiciones. Todo sistema de construcción que has usado existe para administrar ese intercambio.

🎯 Al terminar esta lección sabrás
  • Explicar por qué C se compila archivo a archivo y qué implica para el diseño.
  • Medir la ganancia de la reconstrucción incremental y del paralelismo.
  • Reconocer los tres fallos clásicos de coherencia entre unidades.
  • Ver el grafo de dependencias como la abstracción central de todo build.

Por qué archivo a archivo

El flujo canónico de un proyecto en C separa la compilación del enlazado:

gcc -std=c23 -c main.c   -o main.o
gcc -std=c23 -c parser.c -o parser.o
gcc -std=c23 -c util.c   -o util.o
gcc main.o parser.o util.o -o prog

Cada -c es un proceso independiente que lee una unidad de traducción y escribe un objeto. Ninguno consulta a los otros; la coordinación llega al final, cuando el enlazador resuelve los símbolos.

El origen fue material: en 1972 no cabía un programa entero en memoria, así que el compilador procesaba un archivo cada vez. Pero el modelo sobrevivió a esa restricción porque tenía dos propiedades que resultaron valiosísimas por sí mismas.

La primera es la independencia: si util.c no ha cambiado, util.o sigue siendo válido y no hay que rehacerlo. La segunda es la paralelizabilidad: como los procesos no se comunican, puedes lanzar tantos como núcleos tengas.

flowchart LR
M[main.c] --> MO[main.o]
P[parser.c] --> PO[parser.o]
U[util.c] --> UO[util.o]
H[parser.h] -.dependencia.-> MO
H -.dependencia.-> PO
MO --> L[enlazado]
PO --> L
UO --> L
L --> E[prog]
style H fill:#f9e2af,color:#11111b
style E fill:#a6e3a1,color:#11111b

Lo que acelera

Toca una sola línea de util.c en un proyecto de trescientos archivos y solo hay que recompilar uno: el resto de objetos siguen siendo válidos. El coste pasa de proporcional al tamaño del proyecto a proporcional al tamaño del cambio, y esa diferencia es la que hace viable trabajar en bases de código grandes.

El paralelismo es el segundo multiplicador. Como cada compilación es independiente, escala casi linealmente con los núcleos:

time make -j1          # secuencial
time make -j"$(nproc)" # una tarea por núcleo

Y una tercera capa, el cacheado, aprovecha que el proceso es determinista: si la misma entrada con las mismas banderas ya produjo un objeto, se puede reutilizar sin recompilar.

ccache gcc -std=c23 -c parser.c -o parser.o
ccache -s     # estadísticas de aciertos y fallos de caché

Las tres técnicas descansan sobre la misma propiedad: las unidades no se hablan entre sí.

Lo que complica

La independencia tiene un precio, y se paga en tres monedas.

⚙️

Dependencias ocultas

El build no sabe qué headers leyó cada unidad salvo que el compilador se lo diga con -MMD.

⚙️

Banderas divergentes

Dos unidades compiladas con macros o alineaciones distintas producen un binario incoherente que nadie detecta.

⚙️

Optimización truncada

El compilador no puede integrar código que no ve; recuperarlo exige optimización en tiempo de enlace.

Primera: las dependencias de headers son invisibles. El sistema de construcción ve que main.o depende de main.c, pero no sabe que main.c incluye parser.h salvo que se lo digas. Si cambias el header y nadie lo detecta, te quedas con objetos obsoletos que enlazan sin quejarse y fallan de formas absurdas. La solución estándar es pedirle al propio compilador que declare lo que leyó:

gcc -std=c23 -MMD -MP -c main.c -o main.o
cat main.d     # main.o: main.c parser.h util.h

-MMD genera ese archivo de dependencias durante la compilación real y -MP añade reglas vacías para que borrar un header no rompa el build. Todo sistema serio hace exactamente esto por debajo.

Segunda: nadie comprueba la coherencia entre unidades. Si compilas parser.c con -DBUFFER=1024 y main.c sin esa definición, cada uno verá un struct de tamaño distinto y el programa corromperá memoria en tiempo de ejecución sin un solo aviso. Lo mismo ocurre si una función se declara con una firma en el header y se define con otra en el .c: los tipos de C no viajan en la tabla de símbolos, así que el enlazador acepta el emparejamiento y el desastre se pospone.

Tercera: se pierde la optimización global. El compilador no puede integrar una función definida en otra unidad porque literalmente no la ve. La respuesta moderna es la optimización en tiempo de enlace: con -flto, el objeto guarda además una representación intermedia y el optimizador vuelve a actuar cuando ya tiene todas las piezas delante.

gcc -std=c23 -O2 -flto -c main.c   -o main.o
gcc -std=c23 -O2 -flto -c parser.c -o parser.o
gcc -O2 -flto main.o parser.o -o prog

El coste es un enlazado mucho más lento, así que suele reservarse para las compilaciones de publicación y no para el ciclo diario de desarrollo.

La base de todo sistema de build

Junta las dos mitades y aparece la abstracción: un grafo dirigido acíclico donde los nodos son artefactos, las aristas son dependencias y cada nodo tiene una regla que sabe reconstruirlo. Un sistema de construcción es un programa que recorre ese grafo, compara marcas de tiempo o hashes, y ejecuta solo las reglas cuyas entradas cambiaron, en paralelo cuando puede.

Make expresa el grafo a mano; Ninja lo ejecuta muy rápido pero no está pensado para escribirlo tú; Meson y CMake lo generan a partir de una descripción de más alto nivel. Los tres resuelven el mismo problema, y todos consumen los archivos .d que acabas de ver.

Existe también el movimiento contrario, la compilación unificada: incluir todos los .c en uno solo para volver a tener una única unidad de traducción, ganar optimización global sin LTO y evitar la coordinación. Funciona bien en proyectos pequeños y se derrumba en cuanto quieres reconstrucciones incrementales, que es justo lo que la compilación separada regalaba.

Puedes emular el criterio de invalidación a mano para verlo con claridad. Este bucle recompila un objeto solo si su fuente es más reciente:

for src in main.c parser.c util.c; do
    obj="${src%.c}.o"
    if [ ! -f "$obj" ] || [ "$src" -nt "$obj" ]; then
        echo "recompilando $src"
        gcc -std=c23 -MMD -MP -c "$src" -o "$obj"
    fi
done
gcc main.o parser.o util.o -o prog

Eso es, en esencia, lo que hace Make: comparar marcas de tiempo y ejecutar una regla cuando la entrada es más nueva que la salida. Lo que le falta a este bucle es precisamente lo que hace difícil el problema —leer los .d para conocer los headers, paralelizar sin condiciones de carrera, detectar cambios en las propias banderas de compilación— y ahí es donde las herramientas reales ganan su sueldo.

El grafo de dependencias es la idea, no la herramienta

Es tentador estudiar Make, luego Meson, luego Bazel, y sentir que cada uno es un mundo nuevo. No lo es. Los tres son implementaciones del mismo concepto: un grafo acíclico de artefactos con reglas de reconstrucción y un criterio de invalidación. Cuando entiendes eso, la pregunta relevante deja de ser qué sintaxis usa cada herramienta y pasa a ser cómo decide cada una que algo está obsoleto —marcas de tiempo en Make, hashes de contenido en Bazel, una base de datos de comandos en Ninja— porque ahí está el origen de casi todos los builds rotos que verás en tu vida: un objeto que nadie recompiló porque el sistema no sabía que dependía de ese header. Y la misma idea reaparece muy lejos de la compilación: en los grafos de tareas de un orquestador de datos, en las capas cacheadas de una imagen de contenedor, en el árbol de módulos de un empaquetador de JavaScript. La compilación separada de C, nacida de una limitación de memoria de hace cincuenta años, terminó enseñando a la industria entera a pensar en dependencias.

⚔️ Construye el grafo a mano
  1. Divide un programa en tres archivos con sus headers y compílalo con -c archivo a archivo, enlazando al final.
  2. Modifica solo un .c y recompila únicamente ese objeto. Verifica que el ejecutable resultante es correcto.
  3. Ahora cambia un header sin recompilar los objetos que lo usan y observa qué ocurre.
  4. Genera los archivos de dependencias con -MMD -MP y examina el contenido de un .d.
  5. Compila el proyecto con -flto y compara el tiempo de enlazado y el tamaño del binario con la versión sin LTO.