wandres.dev
MAESTRO · ABI y calling conventions

La convención System V AMD64

El algoritmo que reparte los argumentos entre registros y pila, dónde aterriza el valor de retorno, y el contrato de registros volátiles y preservados que permite que dos objetos compilados por separado encajen sin conocerse.

⏱ 17 min

Casi todo el mundo recuerda la lista: rdi, rsi, rdx, rcx, r8, r9. Pero esa lista no es la convención, es su consecuencia más visible. Debajo hay un algoritmo de clasificación que descompone cada argumento en bloques de ocho bytes, asigna una clase a cada bloque y solo entonces decide si viaja por registro, por la pila o por una copia oculta. Aprender el algoritmo, y no la lista, es lo que te permite predecir dónde acaba una estructura, por qué devolverla por valor unas veces es gratis y otras cuesta una copia completa, y qué registros puedes pisar sin traicionar a quien te llamó.

🎯 Al terminar esta lección sabrás
  • Aplicar el algoritmo de clasificación por bloques de ocho bytes a un tipo cualquiera.
  • Predecir en qué registro o posición de la pila viaja cada argumento.
  • Localizar el valor de retorno según su clase, incluido el puntero oculto.
  • Distinguir registros volátiles de preservados y razonar quién debe salvarlos.

La clasificación: un algoritmo, no una lista

El documento de System V no dice “los enteros van en rdi”. Dice algo bastante más general: cada argumento se descompone en fragmentos de ocho bytes —los eightbytes de la especificación— y cada fragmento recibe una clase. Tres clases cubren el noventa y nueve por ciento de tu código: INTEGER para enteros, punteros y booleanos; SSE para float y double; y MEMORY para todo lo que no cabe o no encaja.

La regla de corte es de tamaño. Un agregado de más de 16 bytes se clasifica como MEMORY sin discusión y viaja copiado en la pila. Uno de 16 bytes o menos se clasifica fragmento a fragmento, y si cada fragmento obtiene su registro, el agregado entero viaja por registros aunque sea una estructura con nombre.

struct Par     { long a; long b; };        // 16 B: INTEGER + INTEGER
struct Punto   { double x; double y; };    // 16 B: SSE + SSE
struct Mixto   { long id; double peso; };  // 16 B: INTEGER + SSE
struct Grande  { long v[4]; };             // 32 B: clase MEMORY

Par ocupa dos registros enteros; Punto, dos registros vectoriales; Mixto, uno de cada. Grande no ocupa ninguno: el llamador reserva 32 bytes en su propia área de argumentos, copia la estructura ahí y la función la lee desde la pila. Esa asimetría explica una regla de estilo que hasta ahora quizá seguías por costumbre: pasar agregados grandes por puntero no es una manía de programadores viejos, es evitar una copia por llamada que el compilador no siempre puede eliminar.

🔗

Cola de enteros

Seis registros en orden fijo: rdi, rsi, rdx, rcx, r8, r9. Se consumen uno a uno según aparecen los fragmentos de clase INTEGER.

🔗

Cola vectorial

Ocho registros xmm0 a xmm7 para los fragmentos de clase SSE. Es una cola independiente: no compite con la de enteros.

🔗

Desbordamiento

Cuando una cola se agota, ese argumento y todos los siguientes de su clase pasan a la pila, en orden inverso al de apilado.

🔗

Casos degenerados

Un campo long double fuerza la clase X87 y con ella la pila. Los agregados desalineados o empaquetados también degradan a MEMORY.

Que las dos colas sean independientes tiene una consecuencia que sorprende la primera vez que la lees en el ensamblador: el orden de los parámetros en la firma no determina el orden de los registros dentro de cada clase, solo dentro de ella.

void f(int a, double x, int b, double y, int c);
/* a -> edi   b -> esi   c -> edx
   x -> xmm0  y -> xmm1                       */

Queda un detalle que la especificación deja deliberadamente abierto y que produce bugs memorables: cuando un argumento es más estrecho que 64 bits, los bits altos del registro no están definidos. Pasar un int significa escribir 32 bits en edi; qué hay en la mitad superior de rdi es asunto de nadie. Durante años el código generado por gcc extendía esos bits por costumbre y algunos compiladores empezaron a asumir que estaban limpios, con el resultado de fallos que solo aparecían al mezclar objetos de dos orígenes. La regla defensiva es simple: si escribes ensamblador a mano, no leas nunca la parte alta de un registro que recibió un valor estrecho, y trunca explícitamente antes de usarlo como índice.

Dónde vuelve el resultado

El retorno reutiliza la misma clasificación, con otras colas: rax y rdx para los fragmentos INTEGER, xmm0 y xmm1 para los SSE. Un int vuelve en eax; un long long en rax; un __int128 en la pareja rax y rdx; un double en xmm0; una estructura de dos double en xmm0 y xmm1.

El caso interesante es el retorno de clase MEMORY. Cuando el resultado no cabe en dos registros, la convención introduce un puntero oculto: el llamador reserva espacio para el objeto de retorno, pasa su dirección como argumento invisible en rdi —desplazando todos los argumentos reales una posición— y la función escribe el resultado ahí. Por cortesía, la función devuelve además esa misma dirección en rax.

flowchart TD
A[Argumento o retorno] --> B[Medir el tamano del tipo]
B --> M[Mas de 16 bytes: clase MEMORY, pila o puntero oculto]
B --> C[16 bytes o menos: clasificar cada bloque de ocho]
C --> D[Bloque entero o puntero: cola INTEGER]
C --> E[Bloque float o double: cola SSE]
D --> F[Cola agotada: pasa a la pila]
E --> F
style M fill:#f38ba8,color:#11111b
style F fill:#f9e2af,color:#11111b

Sabiendo esto puedes leer sin ayuda una firma que devuelve una estructura grande y explicar por qué el primer argumento aparece en rsi en el ensamblador y no en rdi. No es un error del compilador: es el hueco que ocupa el puntero oculto.

Este mecanismo tiene una consecuencia de diseño que merece pensarse. Devolver por valor un tipo de 16 bytes o menos es literalmente gratis: dos registros que ya estaban ahí. Devolver uno de 24 bytes cuesta una reserva en el marco del llamador más una escritura completa del objeto. Esa frontera explica por qué las bibliotecas maduras diseñan sus tipos de retorno compactos —un puntero más un código de error, dos enteros, un par de double— y por qué struct timespec, con sus dos campos de ocho bytes, vuelve en rax y rdx mientras que una estructura con un array pequeño en su interior ya no.

struct Res { void *p; int err; };   /* 16 B: vuelve en rax y rdx, sin copia */
struct Res abrir(const char *ruta);

Los tipos escalares tienen sus propias sutilezas. Un _Bool devuelto ocupa al con solo dos valores válidos, cero y uno, y el llamador tiene derecho a asumirlo. Un long double vuelve en la pila del coprocesador x87, en st0, que es la razón de que sea el tipo peor tratado por todas las herramientas. Y una función declarada void no promete nada sobre rax, así que leerlo tras la llamada es leer basura.

Volátiles y preservados

La segunda mitad del contrato reparte responsabilidad sobre el estado de la máquina. Seis registros son preservados por la función llamadacallee-saved—: rbx, rbp, r12, r13, r14 y r15, más rsp, que debe volver exactamente a su valor. Todo lo demás es volátil o caller-saved: rax, rcx, rdx, rsi, rdi, r8 a r11 y la totalidad del banco xmm. Si tienes un valor vivo en un registro volátil y vas a llamar a alguien, guárdalo tú.

💡
El reparto no es arbitrario: es un equilibrio económico

Podría haberse elegido que todos los registros fueran preservados, o ninguno. Ambos extremos son malos. Si todos son preservados, cada función paga un prólogo largo aunque no use casi ninguno. Si ninguno lo es, cada llamada obliga al llamador a volcar todo su estado vivo a la pila. La partición de System V intenta acertar el punto medio para código real: los registros de argumentos son volátiles porque su contenido ya está consumido tras la llamada, y quedan seis preservados para que una función con un bucle largo pueda mantener sus variables calientes en registro atravesando llamadas. Cuando leas asm optimizado verás la firma de esta decisión: el compilador coloca en r12 a r15 justo aquello que sobrevive a una llamada.

La forma de honrar la mitad que te toca es visible en cualquier prólogo no trivial: si una función necesita un registro preservado, lo apila al entrar y lo restaura al salir.

procesar:
    push rbx            ; voy a usar rbx: es mio prestado, lo devuelvo intacto
    push r12
    sub  rsp, 8         ; relleno para conservar la alineacion de 16
    ...
    add  rsp, 8
    pop  r12
    pop  rbx            ; el llamador no percibe que existieron
    ret

Fíjate en el sub rsp, 8 aparentemente inútil: dos push desplazan la pila 16 bytes y la dejan alineada, pero el número de push no siempre es par, y el compilador inserta ese relleno para que la siguiente llamada cumpla la regla. Es la clase de detalle que un humano olvida y una máquina no.

El contrato incluye además dos cláusulas que se olvidan y muerden a quien escribe ensamblador a mano. La bandera de dirección DF debe estar a cero al entrar y al salir de cualquier función: si la pones a uno con std, tienes que restaurarla con cld. Y la pila del coprocesador x87 debe quedar vacía salvo que estés devolviendo un long double.

Variádicas y el registro al

Una función variádica no sabe cuántos argumentos vectoriales recibió, y va_arg tiene que poder recuperarlos. La convención resuelve el problema con un canal lateral: antes de llamar a una variádica, el llamador deja en al el número de registros vectoriales usados, entre cero y ocho.

    mov   edi, OFFSET fmt   ; primer argumento fijo
    movsd xmm0, [valor]     ; un argumento variadico de tipo double
    mov   eax, 1            ; al = 1: se ha usado un registro xmm
    call  printf

El prólogo de la variádica lee al, y si es cero se salta el volcado de los ocho registros xmm al register save area, la zona del marco donde va_start deja copiados todos los registros de argumentos para que va_arg pueda recorrerlos como si siempre hubieran estado en memoria. Esa zona mide 176 bytes: 48 para los seis registros enteros y 128 para los ocho vectoriales.

De ahí sale la forma real del tipo va_list en esta ABI, que no es un puntero sino una pequeña estructura con dos contadores y dos punteros: cuántos registros enteros y vectoriales quedan por consumir, dónde está el área de guardado y dónde empiezan los argumentos que llegaron por la pila. va_arg decide en cada paso de cuál de las dos fuentes leer, y por eso copiar un va_list con una asignación es incorrecto y existe va_copy.

typedef struct {
    unsigned gp_offset;      /* bytes ya consumidos de los registros enteros */
    unsigned fp_offset;      /* idem de los vectoriales */
    void    *overflow_arg_area;  /* argumentos que llegaron por la pila */
    void    *reg_save_area;      /* los 176 bytes volcados por va_start */
} va_list_impl;

De aquí sale también el detalle práctico más importante: llamar a printf con un prototipo visible es correcto, pero llamarla sin prototipo o a través de un puntero mal tipado deja al con basura y el prólogo copia registros que nadie escribió.

Una convención de llamada es un protocolo distribuido sin coordinador

Detente en lo que acaba de ocurrir. Dos funciones que jamás se vieron —compiladas por compiladores distintos, en décadas distintas, quizá una en C y otra en Rust— ejecutan las dos mitades complementarias de un protocolo y aciertan. No hay negociación en tiempo de ejecución, no hay descubrimiento, no hay verificación: hay un documento y la disciplina de respetarlo. Todo lo que has visto aquí son las cláusulas de ese documento, y cada una compra algo concreto. El paso por registros compra velocidad, porque una llamada normal no toca memoria para sus argumentos. La clasificación por bloques compra generalidad, porque el mismo algoritmo sirve para un int y para una estructura que aún no se ha inventado. La partición entre volátiles y preservados compra composición, porque permite que el optimizador razone localmente sobre qué sobrevive a una llamada sin mirar dentro de ella. Y el puntero oculto compra uniformidad, porque hace que devolver un objeto de cualquier tamaño siga funcionando sin casos especiales en el lenguaje. Cuando escribes C no ves nada de esto, y esa invisibilidad es exactamente la medida de su éxito: la abstracción sostiene porque todo el mundo, debajo, obedece. La otra cara es que quien viole el protocolo una sola vez —un ensamblador a mano que olvida restaurar rbx, un puntero a función con la firma equivocada— produce un fallo que aparece a kilómetros del culpable, porque la corrupción es silenciosa hasta que alguien lee el registro que ya no vale lo que creía.

⚔️ Verifica el algoritmo con tus manos
  1. Escribe cinco funciones que reciban, respectivamente, struct Par, struct Punto, struct Mixto, struct Grande y un puntero a struct Grande. Compila con -O2 -S -masm=intel y anota en qué registro o desplazamiento de pila llega cada una.
  2. Añade una función que devuelva struct Grande por valor y localiza el puntero oculto en rdi; comprueba que el primer argumento real se ha desplazado a rsi.
  3. Escribe una función con cuatro enteros y cuatro double intercalados y confirma que las dos colas de registros avanzan de forma independiente.
  4. Llama a una variádica con y sin argumentos de coma flotante y localiza en el asm la instrucción que carga al; explica por qué vale cero en un caso.
  5. Escribe una función que use rbx sin guardarlo, invócala desde otra que tenga un valor vivo en rbx, y observa la corrupción.