wandres.dev
INICIADO · El proceso de compilación

Del C al ensamblador

Leer el asm de una función simple: el prólogo que monta el marco de pila, el cuerpo que hace el trabajo, el epílogo que lo desmonta, y cómo -O2 borra casi todo lo anterior.

⏱ 16 min

No necesitas escribir ensamblador para beneficiarte de leerlo. Lo que ganas al abrir un .s es una respuesta directa a la pregunta que persigue a todo programador de C: qué hace de verdad mi código. Una función de tres líneas se convierte en una secuencia de instrucciones donde ves el marco de pila montarse, los argumentos llegar por registros y el resultado salir por uno concreto. Y cuando enciendes -O2, ves cómo la mitad de eso desaparece porque nunca fue esencial.

🎯 Al terminar esta lección sabrás
  • Leer el ensamblador de una función simple y localizar sus tres partes.
  • Identificar el prólogo y el epílogo, y entender qué es el marco de pila.
  • Conocer el convenio de llamada: qué registros llevan argumentos y resultado.
  • Comparar la misma función con -O0 y con -O2 y explicar la diferencia.

Una función y su traducción

Partimos de algo deliberadamente aburrido:

int suma(int a, int b)
{
    int r = a + b;
    return r;
}

Genera el ensamblador sin optimizar, que es el más literal y por tanto el más didáctico:

gcc -std=c23 -S -O0 -fverbose-asm suma.c -o suma.s

En x86-64 con sintaxis AT&T obtendrás algo muy parecido a esto:

suma:
	pushq	%rbp             # prologo: guarda el marco anterior
	movq	%rsp, %rbp       # prologo: rbp pasa a marcar este marco
	movl	%edi, -20(%rbp)  # guarda el argumento a en la pila
	movl	%esi, -24(%rbp)  # guarda el argumento b en la pila
	movl	-20(%rbp), %edx
	movl	-24(%rbp), %eax
	addl	%edx, %eax       # el trabajo real: una suma
	movl	%eax, -4(%rbp)   # guarda r
	movl	-4(%rbp), %eax   # r pasa a eax: el valor de retorno
	popq	%rbp             # epilogo: restaura el marco anterior
	ret                      # epilogo: vuelve al llamante

Nueve de las once instrucciones son contabilidad. Solo addl hace el trabajo que pediste. Esa proporción es normal en -O0, donde el compilador traduce cada sentencia de forma independiente y guarda cada variable en memoria para que el depurador la encuentre siempre donde toca.

El prólogo, el epílogo y el marco de pila

El marco de pila (stack frame) es la región de la pila que pertenece a una invocación concreta de una función: sus variables locales, los argumentos que no cupieron en registros y la dirección de retorno. El prólogo lo crea y el epílogo lo destruye.

flowchart TD
A[Llamada: call empuja la direccion de retorno] --> B[Prologo: push rbp y mov rsp a rbp]
B --> C[Reserva de locales: sub a rsp]
C --> D[Cuerpo: el trabajo real]
D --> E[Epilogo: leave o pop rbp]
E --> F[ret: salta a la direccion de retorno]
style D fill:#a6e3a1,color:#11111b
style F fill:#89b4fa,color:#11111b

El registro %rbp actúa como puntero de marco: una referencia fija desde la que se accede a locales con desplazamientos negativos y a argumentos apilados con desplazamientos positivos. %rsp sí se mueve, porque marca la cima de la pila. Tener %rbp fijo simplifica el desensamblado y el recorrido de la pila en un depurador.

El convenio que rige quién pone qué dónde es la ABI System V AMD64, que estudiarás a fondo en el nivel 21. Por ahora bastan cuatro reglas:

  • Los primeros seis argumentos enteros viajan en rdi, rsi, rdx, rcx, r8 y r9, en ese orden.
  • El valor de retorno entero sale en rax.
  • rbx, rbp y r12 a r15 son callee-saved: si los usas, los restauras.
  • Los 128 bytes por debajo de rsp forman la red zone, utilizable sin ajustar la pila en funciones hoja.

En AArch64 el esquema es análogo pero distinto en la letra: argumentos en x0 a x7, retorno en x0, y un prólogo típico stp x29, x30, [sp, #-16]! que guarda de golpe el puntero de marco y la dirección de retorno.

Junto a las instrucciones verás directivas del ensamblador: .text abre la sección de código, .globl suma exporta el símbolo, .type y .size describen el símbolo para el enlazador, y las .cfi_* describen cómo desenrollar la pila. No son instrucciones de la CPU: son metadatos.

Qué cambia con -O2

Recompila la misma función con optimización y compara:

gcc -std=c23 -S -O2 suma.c -o suma-o2.s
suma:
	leal	(%rdi,%rsi), %eax   # suma y coloca el resultado en eax
	ret

Dos instrucciones. Han desaparecido el prólogo, el epílogo, la variable r y todos los viajes a memoria. Tres decisiones del optimizador explican el cambio:

  1. Asignación de registros. r nunca necesitó existir en memoria; vive en un registro y se disuelve.
  2. Omisión del puntero de marco. A partir de -O1, GCC activa -fomit-frame-pointer: si nadie necesita %rbp, no se monta el marco. Puedes forzarlo con -fno-omit-frame-pointer, lo que suelen hacer los perfiladores.
  3. Selección de instrucciones. leal calcula una dirección efectiva, pero aquí se usa como una suma de tres operandos que además evita tocar banderas. El compilador elige la instrucción más barata, no la más literal.

La lección incómoda es esta: el código que se ejecuta no es el código que escribiste. El estándar solo obliga a preservar el comportamiento observable, y dentro de esa libertad el optimizador reordena, fusiona y elimina. Por eso un bucle vacío puede desaparecer entero y por eso el comportamiento indefinido es tan destructivo: si el compilador asume que nunca ocurre, elimina el código que solo tendría sentido si ocurriera.

Leer asm sin sufrir

Un .s real está lleno de ruido que no aporta nada a tu lectura: directivas de depuración, información de desenrollado, alineaciones. Tres tácticas lo vuelven manejable.

⚙️

Quita el ruido

Compila con -fno-asynchronous-unwind-tables y sin -g para eliminar las directivas .cfi y la mayor parte de los metadatos.

⚙️

Aísla una función

Con objdump -d --disassemble=suma prog.o desensamblas solo el símbolo que te interesa, ya con las direcciones resueltas.

⚙️

Anota el origen

Compila con -g y usa objdump -S: cada bloque de instrucciones aparece precedido por la línea de C que lo generó.

# asm limpio, sin metadatos de desenrollado
gcc -std=c23 -S -O2 -fno-asynchronous-unwind-tables suma.c -o limpio.s

# asm intercalado con el fuente original
gcc -std=c23 -g -O2 -c suma.c -o suma.o && objdump -S suma.o

La cuarta táctica no es una bandera sino un hábito: empieza siempre por el símbolo, no por el principio del archivo. Busca la etiqueta con el nombre de tu función y lee hacia abajo hasta el ret. Todo lo que hay antes y después pertenece a otras funciones o al ensamblador, y leerlo en orden lineal es la forma más rápida de perderse.

El asm es el árbitro final de toda discusión

Cuando alguien afirma que una construcción de C es más rápida que otra, hay exactamente una forma honesta de resolverlo: generar el .s de ambas y comparar. No hay opiniones que sobrevivan a diff. Esta costumbre te dará tres cosas a lo largo de tu carrera. Primero, humildad: descubrirás que muchas micro-optimizaciones que la gente defiende con vehemencia producen ensamblador idéntico, porque el compilador ya las hacía. Segundo, precisión: cuando una diferencia sí exista, la verás instrucción a instrucción en vez de intuirla. Y tercero, y más importante, un modelo mental correcto del comportamiento indefinido, porque verás con tus ojos código desaparecer cuando el optimizador deduce que una condición no puede darse. Los ingenieros que confían de verdad en el compilador no lo hacen por fe: lo hacen porque han leído su salida lo suficiente como para saber qué esperar de él.

⚔️ Lee el asm de tus propias funciones
  1. Escribe una función que sume un array de int en un bucle y genera su .s con -O0. Localiza el prólogo, el cuerpo del bucle y el epílogo.
  2. Recompila con -O2 y comprueba si el bucle sigue siendo un bucle o el compilador lo vectorizó.
  3. Compila con -O2 -fno-omit-frame-pointer y confirma que el prólogo vuelve a aparecer.
  4. Escribe una función que reciba ocho argumentos enteros y observa dónde acaban el séptimo y el octavo.
  5. Genera el mismo código con -masm=intel y decide con cuál de las dos sintaxis lees más cómodo.