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.
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.
- Distinguir el controlador
gccde los programas que realmente invoca. - Detener la compilación en cada fase con
-E,-Sy-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
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.
- Compila
demo.ccon-save-tempsy abre los cuatro artefactos en Neovim, uno por buffer. - Ejecuta
gcc -dM -E - < /dev/nully localiza__STDC_VERSION__. Confirma que corresponde a C23. - Genera el
.scon-O0y con-O2y usadiffpara ver qué desapareció. - Ejecuta
gcc -### demo.c -o demoe identifica en la salida los tres programas reales que se invocan. - Aplica
readelf -hal.oy al ejecutable final y compara el campo de tipo y el punto de entrada.