El proceso de compilación en 4 fases
De .c a ejecutable no es un solo paso mágico, son cuatro: preprocesado, compilación, ensamblado y enlazado. Ver cada uno por dentro desmitifica todo lo demás.
Cuando escribes gcc prog.c -o prog, ocurren cuatro transformaciones distintas, en orden. Entenderlas es una de las cosas que más te separa de quien solo “compila y reza”: cuando algo falle —un símbolo no definido, un header que no aparece— sabrás exactamente en qué fase y por qué.
- Las cuatro fases: preprocesado, compilación, ensamblado, enlazado.
- Qué produce exactamente cada una.
- Detener el compilador en cada fase para verlo.
- Qué es un archivo objeto y un símbolo.
Las cuatro fases
flowchart LR C[prog.c] -->|preprocesado| I[prog.i] I -->|compilacion| S[prog.s] S -->|ensamblado| O[prog.o] O -->|enlazado| E[ejecutable] style C fill:#89b4fa,color:#11111b style I fill:#94e2d5,color:#11111b style S fill:#f9e2af,color:#11111b style O fill:#fab387,color:#11111b style E fill:#a6e3a1,color:#11111b
1. Preprocesado
El preprocesador manipula texto: expande #include (pega el contenido de los headers), sustituye macros (#define), y resuelve la compilación condicional (#ifdef). Produce una unidad de traducción pura, sin directivas.
gcc -std=c23 -E prog.c -o prog.i # -E: para tras preprocesar
Abre prog.i y verás tu código con todo el stdio.h pegado encima. El preprocesador no entiende C: solo mueve texto.
2. Compilación
Ahora sí: el compilador toma la unidad de traducción, la analiza (¿es C válido?), y la traduce a ensamblador para tu arquitectura.
gcc -std=c23 -S prog.i -o prog.s # -S: para tras compilar
prog.s es texto en lenguaje ensamblador: las instrucciones de la CPU, aún legibles.
3. Ensamblado
El ensamblador traduce ese texto a código máquina binario, empaquetado en un archivo objeto (.o).
gcc -std=c23 -c prog.s -o prog.o # -c: para tras ensamblar
prog.o ya es binario: instrucciones de la máquina, más una tabla de símbolos (los nombres de tus funciones y variables) y relocalizaciones pendientes.
4. Enlazado (linking)
El linker toma uno o más .o (y las bibliotecas), resuelve los símbolos —conecta cada llamada con la función real, esté en tu código o en la libc— y produce el ejecutable final.
gcc prog.o -o prog # enlaza y produce el ejecutable
Verlo todo de golpe
# el flujo completo, guardando cada intermedio
gcc -std=c23 -save-temps prog.c -o prog
ls prog.* # prog.i prog.s prog.o prog
Este modelo mental resuelve una confusión enorme. Un error de “header no encontrado” es de preprocesado. Un error de sintaxis o de tipos es de compilación. Y el temido “undefined reference to …” no es un error de compilación: es del linker — tu código compiló bien, pero el linker no encuentra la definición de un símbolo que usaste (te falta un .o o una -l de biblioteca). Quien no distingue las fases pierde horas buscando en el sitio equivocado. Tú, en cuanto leas el error, sabrás en qué fase mirar. Este es de los conocimientos más rentables de todo el track.
Archivos objeto y símbolos
Un .o no es ejecutable por sí solo: le faltan las conexiones. Contiene:
- Código y datos de tus funciones y variables globales.
- Una tabla de símbolos: qué define (
Ten el listado) y qué necesita de fuera (U, undefined). - Relocalizaciones: huecos que el linker rellenará con direcciones finales.
Inspecciónalo:
nm prog.o # lista los símbolos: T=definido, U=indefinido
objdump -d prog.o # desensambla el código máquina
file prog # te dice que es un ELF ejecutable
Los símbolos, las relocalizaciones y el formato de estos archivos son la base del linker (nivel 20, ELF) y de la ABI (nivel 21). Por ahora quédate con la idea: compilar produce piezas con símbolos, y enlazar es conectar esas piezas. Todo el tooling de build (Meson, Make, bibliotecas) existe para orquestar estas cuatro fases sobre muchos archivos.
- Compila un programa con
-save-tempsy abre el.i, el.sy mira el.o. - Usa
gcc -Esobre un archivo con un#definey observa la sustitución. - Llama a una función que no definas (sin implementarla) y provoca a propósito un “undefined reference”: confirma que es un error de linker, no de compilación.
- Usa
nmsobre un.opara ver sus símbolos definidos (T) e indefinidos (U).