wandres.dev
INICIADO · El proceso de compilación

Ver cada fase con tus ojos

Las banderas -E, -S y -c son el botón de pausa del compilador. Detenerlo en cada fase y leer el artefacto que deja convierte un proceso opaco en cuatro objetos concretos que puedes abrir, medir y comparar.

⏱ 15 min

Saber que existen cuatro fases es teoría. Verlas es otra cosa. El controlador de GCC acepta tres banderas que funcionan como botón de pausa —-E, -S y -c— y cada una deja sobre el disco un artefacto real que puedes abrir con Neovim. Quien ha leído un .i, ha recorrido un .s y ha desensamblado un .o deja de tratar al compilador como una caja negra y empieza a tratarlo como lo que es: una tubería de programas encadenados, cada uno con su entrada y su salida inspeccionables.

🎯 Al terminar esta lección sabrás
  • Distinguir el controlador gcc de los programas que realmente invoca.
  • Detener la compilación en cada fase con -E, -S y -c.
  • Leer la salida del preprocesador sin ahogarte en miles de líneas.
  • Conservar y comparar todos los intermedios con -save-temps.

gcc no es el compilador

Primera corrección de modelo mental: gcc no compila nada. Es un controlador (driver): un programa que decide qué herramientas ejecutar, en qué orden y con qué argumentos, según la extensión de los archivos y las banderas que le des. El compilador propiamente dicho es cc1; el ensamblador es as; el enlazador es ld, invocado a través de collect2.

flowchart LR
D[Driver gcc] --> P[cc1 en modo -E]
P --> C[cc1 compilador]
C --> A[as ensamblador]
A --> L[collect2 mas ld]
L --> X[ejecutable]
style D fill:#cba6f7,color:#11111b
style X fill:#a6e3a1,color:#11111b

Puedes verlo tú mismo. La bandera -v muestra cada subproceso con su línea de órdenes completa, y -### hace lo mismo pero sin ejecutar nada:

gcc -std=c23 -v prog.c -o prog     # ejecuta y narra cada paso
gcc -std=c23 -### prog.c -o prog   # solo muestra qué ejecutaría

En esa salida aparecen las rutas reales de cc1, los directorios de búsqueda de headers y la línea de collect2 con los objetos de arranque de la libc. Es información densa, pero es la verdad literal de lo que ocurre.

Fase 1 y 2: el texto antes del binario

-E detiene el proceso justo después del preprocesado. La salida es C puro, sin una sola directiva.

gcc -std=c23 -E prog.c -o prog.i
wc -l prog.c prog.i    # compara: decenas de líneas frente a miles

Esa explosión de tamaño no es ruido: es todo el árbol de headers pegado en tu unidad de traducción. Dentro verás marcadores de línea como # 42 "/usr/include/stdio.h" 3 4, que le dicen al compilador de qué archivo y qué línea procede cada fragmento para que los mensajes de error apunten al sitio correcto. Los dígitos finales son banderas: 1 entra en un archivo, 2 regresa de él, 3 marca cabecera de sistema y 4 implica tratamiento como C externo.

Tres opciones hacen la lectura soportable:

gcc -std=c23 -E -P prog.c        # sin marcadores de línea: solo el código
gcc -std=c23 -dM -E prog.c       # solo las macros predefinidas del compilador
gcc -std=c23 -E -fdirectives-only prog.c   # expande includes, no macros

-dM -E sobre un archivo vacío es un ejercicio revelador: te lista __STDC_VERSION__, __x86_64__, __GNUC__ y varios cientos más. Ahí está el contexto invisible en el que se compila todo tu código.

-S continúa un paso más y para tras generar ensamblador. Con -fverbose-asm, GCC anota cada instrucción con los nombres de las variables de origen:

gcc -std=c23 -S -fverbose-asm prog.c -o prog.s
gcc -std=c23 -S -masm=intel prog.c -o prog-intel.s   # sintaxis Intel

Fase 3: cuando el texto se vuelve binario

-c cierra el ensamblado y produce el objeto. Aquí ocurre la transición cualitativa del curso: dejas el terreno del texto y entras en el de los formatos binarios.

gcc -std=c23 -c prog.c -o prog.o
file prog.o          # ELF 64-bit LSB relocatable
readelf -h prog.o    # cabecera ELF: tipo REL, sin punto de entrada
objdump -d prog.o    # desensamblado de las secciones de código

Fíjate en dos detalles del readelf -h. El tipo es REL (reubicable), no EXEC ni DYN: este archivo no se puede ejecutar. Y el punto de entrada es 0x0, porque las direcciones definitivas todavía no existen; las decidirá el enlazador.

Para no repetir órdenes una a una, -save-temps conserva todos los intermedios de una sola pasada:

gcc -std=c23 -save-temps -O2 prog.c -o prog
ls prog.i prog.s prog.o prog

Y una comparación que enseña más que cualquier explicación: genera el mismo .s con dos niveles de optimización y mide la diferencia.

gcc -std=c23 -S -O0 prog.c -o o0.s
gcc -std=c23 -S -O2 prog.c -o o2.s
wc -l o0.s o2.s
diff -u o0.s o2.s | head -60
Cada bandera de pausa es un punto de observación

Lo importante de -E, -S y -c no es que existan, sino que convierten un proceso en una serie de estados observables. Esa es exactamente la diferencia entre depurar por intuición y depurar por evidencia. ¿Una macro se comporta raro? No la mires: mírala expandida en el .i, donde ya no hay ambigüedad posible. ¿Sospechas que el compilador eliminó tu bucle? No lo supongas: lee el .s. ¿El enlazador se queja de un símbolo? Míralo con nm en el .o antes de acusar a nadie. La disciplina que estás construyendo aquí es la misma que aplicarás luego a un kernel que no arranca o a un binario que se comporta distinto en producción: no razonar sobre lo que debería pasar, sino inspeccionar el artefacto intermedio donde la respuesta ya está escrita. El compilador siempre te deja mirar; casi nadie mira.

⚙️

-E preprocesado

Produce un .i: C puro con headers expandidos y marcadores de línea. Usa -P para leerlo limpio.

⚙️

-S compilación

Produce un .s: ensamblador textual. Con -fverbose-asm cada instrucción lleva su variable anotada.

⚙️

-c ensamblado

Produce un .o: ELF reubicable, con secciones, símbolos y relocalizaciones pendientes.

Un experimento reproducible

Crea un archivo mínimo y recorre las cuatro fases midiendo cada salto:

#include <stdio.h>

#define DOBLE(x) ((x) * 2)

int main(void)
{
    printf("%d\n", DOBLE(21));
    return 0;
}
gcc -std=c23 -E demo.c -o demo.i   && wc -l demo.i
gcc -std=c23 -S demo.i -o demo.s   && wc -l demo.s
gcc -std=c23 -c demo.s -o demo.o   && ls -l demo.o
gcc demo.o -o demo                 && ./demo

Busca en demo.i la línea del printf: la macro ya está sustituida por ((21) * 2), y quizá ni siquiera veas la multiplicación en el .s porque el compilador la resolvió en tiempo de compilación. Esa cadena de observaciones, hecha una sola vez a conciencia, vale más que diez lecturas de la documentación.

⚔️ Cuatro artefactos, cuatro lecturas
  1. Compila demo.c con -save-temps y abre los cuatro artefactos en Neovim, uno por buffer.
  2. Ejecuta gcc -dM -E - < /dev/null y localiza __STDC_VERSION__. Confirma que corresponde a C23.
  3. Genera el .s con -O0 y con -O2 y usa diff para ver qué desapareció.
  4. Ejecuta gcc -### demo.c -o demo e identifica en la salida los tres programas reales que se invocan.
  5. Aplica readelf -h al .o y al ejecutable final y compara el campo de tipo y el punto de entrada.