El stack en detalle: alineación, zona roja y backtrace
La regla de los 16 bytes y por qué violarla cuesta un fallo de segmentación, los 128 bytes que una función hoja puede usar sin pedir permiso, el papel real del puntero de marco y cómo se reconstruye una traza de llamadas.
La pila parece el rincón simple de la ABI: se resta al puntero, se apila la dirección de retorno, se vuelve. Pero cada uno de esos gestos está gobernado por invariantes que nadie comprueba en tiempo de ejecución y que, al romperse, producen fallos absurdos: un printf que revienta dentro de la libc, un depurador que muestra una traza imposible, una función que corrompe datos que jamás tocó. Este capítulo desmonta cuatro de esas invariantes y te deja capaz de auditar cualquier marco de pila que veas.
- Enunciar con precisión la regla de alineación de 16 bytes y saber en qué punto se mide.
- Explicar qué es la zona roja, quién puede usarla y en qué contextos está prohibida.
- Distinguir el puntero de marco de los metadatos de desenrollado como fuentes de traza.
- Reconstruir a mano una cadena de marcos para obtener un backtrace.
La regla de los 16 bytes
El enunciado exacto importa más que el número. System V AMD64 exige que, en el instante inmediatamente anterior a ejecutar call, el puntero de pila sea múltiplo de 16. Como call apila ocho bytes de dirección de retorno, la consecuencia es que al entrar en cualquier función el puntero de pila vale un múltiplo de 16 más ocho. Esa asimetría es la fuente de la mitad de los errores de alineación escritos a mano.
mi_funcion:
push rbp ; rsp pasa de 16k+8 a 16k: ya alineado
mov rbp, rsp
sub rsp, 32 ; multiplo de 16: la alineacion se conserva
call otra ; correcto: rsp es multiplo de 16 aqui
Si en ese prólogo escribes sub rsp, 24 en lugar de 32, el código sigue compilando, sigue ejecutándose y sigue produciendo resultados correctos… hasta que llames a una función que use instrucciones vectoriales alineadas. movaps sobre una dirección no alineada a 16 no da un resultado raro: lanza una excepción de protección general que el sistema traduce a SIGSEGV. Y el fallo aparece dentro de memcpy o de printf, es decir, en código que no escribiste ni compilaste tú.
Un desalineamiento introducido en tu función se manifiesta varias llamadas más abajo, en la primera rutina optimizada que asuma la garantía. Cuando un depurador te muestre un fallo en la libc con argumentos aparentemente válidos, comprueba rsp módulo 16 en cada marco antes de sospechar de la biblioteca. La otra fuente clásica es un push impar en ensamblador en línea, o un manejador de señales instalado sin SA_ONSTACK sobre una pila que ya venía torcida.
El número 16 no salió de un sorteo. Cuando se redactó la ABI, las instrucciones vectoriales de 128 bits eran la novedad que había que aprovechar, y sus variantes rápidas exigían operandos alineados a su tamaño. Garantizar 16 bytes en el punto de llamada permite que cualquier función coloque un vector en su marco sin realinear nada. La decisión envejeció bien y mal a la vez: hoy hay registros de 256 y 512 bits cuyas cargas alineadas necesitan 32 o 64 bytes, y el compilador tiene que generar código explícito de realineación cuando los usa. Se paga en cada función que toca AVX el precio de una garantía que se fijó una vez y ya no puede subirse sin romper todo lo compilado.
Conviene separar esta regla de otra distinta que suele confundirse con ella: la alineación de los objetos. El requisito de 16 bytes en rsp es una propiedad del protocolo de llamada; que un double deba estar en dirección múltiplo de 8 o que un _Alignas(64) reserve una línea de caché completa son propiedades del modelo de objetos. La primera la impone la ABI al compilador; las segundas las impone el tipo, y alignof te las cuenta.
La zona roja
Los 128 bytes situados por debajo del puntero de pila forman la red zone, un territorio que la ABI declara intocable: ninguna señal, ningún manejador y ningún evento asíncrono puede escribir ahí. Una función hoja —una que no llama a nadie— puede usarlos como almacén de locales sin ejecutar prólogo ni epílogo, porque nadie va a pisárselos.
long doble(long x)
{
long tmp = x * 2; // cabe en la zona roja o directamente en registro
return tmp;
}
doble:
lea rax, [rdi+rdi] ; ni push, ni sub rsp: marco puramente virtual
ret
El ahorro es real: dos instrucciones menos por llamada en las funciones más frecuentes de cualquier programa. Pero la garantía descansa en una suposición que no se cumple siempre, y de ahí salen tres prohibiciones que debes reconocer.
Código de kernel
Las interrupciones se apilan sobre la pila actual sin respetar la zona roja. Todo kernel x86-64 se compila con -mno-red-zone.
Funciones no hoja
En cuanto una función ejecuta call, el callee usa esas direcciones como marco propio. La zona roja deja de existir para ella.
Otras plataformas
Windows x64 no tiene zona roja, y en AArch64 el equivalente son 16 bytes. Es una cláusula de esta ABI concreta, no una ley del hardware.
Ensamblador a mano
Si escribes asm en línea y tocas memoria bajo rsp en una función no hoja, corrompes el marco del callee de forma indetectable.
La cláusula que protege la zona roja frente a las señales tiene un pariente que conviene conocer, porque resuelve el problema opuesto. Cuando la pila se desborda, el sistema entrega SIGSEGV, pero el manejador necesita pila para ejecutarse y justo eso es lo que falta: el proceso muere sin poder informar. La solución es registrar una pila alternativa de señal, un bloque independiente donde el manejador se ejecuta con seguridad.
static char pila_senal[SIGSTKSZ];
stack_t s = { .ss_sp = pila_senal, .ss_size = sizeof pila_senal, .ss_flags = 0 };
sigaltstack(&s, NULL);
/* y el manejador se instala con SA_ONSTACK */
Es exactamente el mecanismo que usan los detectores de desbordamiento y los sanitizadores para imprimir una traza en lugar de morir en silencio.
El puntero de marco y el desenrollado
Durante décadas, rbp fue la columna vertebral de la pila: cada función guardaba el rbp de su llamador y establecía el suyo, formando una lista enlazada de marcos que cualquiera podía recorrer. Con optimizaciones activadas, -fomit-frame-pointer elimina esa cadena y libera un registro de uso general, lo que mejora el código pero destruye la forma barata de reconstruir la traza.
La respuesta moderna son los metadatos de desenrollado: tablas en las secciones .eh_frame y .eh_frame_hdr que describen, para cada dirección de instrucción, dónde está la dirección de retorno y cómo restaurar los registros preservados. Son las mismas tablas que usan las excepciones de C++ y las que consumen libunwind y los depuradores. Son exactas y funcionan sin puntero de marco, pero interpretarlas cuesta órdenes de magnitud más que seguir punteros.
flowchart TD A[Necesito una traza de llamadas] --> B[Hay puntero de marco: seguir la cadena de rbp] A --> C[No hay puntero de marco: interpretar eh frame] B --> D[Rapido y valido en un perfilador de muestreo] C --> E[Exacto pero costoso: depuradores y excepciones] D --> F[Traza de llamadas reconstruida] E --> F style F fill:#a6e3a1,color:#11111b style C fill:#f9e2af,color:#11111b
De esa tensión sale una decisión de ingeniería que muchas distribuciones han tomado en los últimos años: recompilar el sistema entero con -fno-omit-frame-pointer. Se paga alrededor de un uno por ciento de rendimiento a cambio de que perf pueda muestrear trazas continuamente en producción sin coste prohibitivo. Es un intercambio explícito entre velocidad de ejecución y observabilidad, y saber que existe te permite participar en la discusión en vez de sufrirla.
Recorrer una pila a mano
Con puntero de marco, un backtrace es un bucle de seis líneas. En cada marco, rbp apunta al rbp guardado del llamador, y ocho bytes por encima está la dirección de retorno.
void traza(void)
{
void **marco = __builtin_frame_address(0);
for (int i = 0; i < 32 && marco; i++) {
void *retorno = marco[1]; // direccion de retorno del marco actual
if (!retorno) break;
printf("#%d %p\n", i, retorno);
marco = marco[0]; // saltar al marco del llamador
}
}
Tres precauciones lo separan de un juguete. La primera es el límite de iteraciones: si la cadena está corrompida —y si estás depurando un fallo, probablemente lo esté— el bucle se vuelve infinito o salta a memoria inválida. La segunda es que solo funciona para los marcos compilados con puntero de marco; en cuanto atraviesas una función optimizada de la libc, el enlace se pierde y la traza se corta o inventa. La tercera es que este código no es seguro dentro de un manejador de señales si printf participa, así que en un manejador real se acumulan direcciones y se imprimen con write.
Ese bucle es, en esencia, lo que hace backtrace de glibc cuando la cadena existe. Para que los símbolos aparezcan en backtrace_symbols hace falta enlazar con -rdynamic, porque de lo contrario los nombres de las funciones no llegan a la tabla dinámica. En producción es más habitual guardar solo las direcciones y resolverlas después con addr2line -e binario -f -C, evitando trabajo costoso dentro del proceso que quizá está muriendo.
gcc -std=c23 -O2 -g -fno-omit-frame-pointer -rdynamic prog.c -o prog
perf record --call-graph fp ./prog && perf report --stdio
addr2line -e prog -f -C 0x401136
Aquí está la idea que reordena todo lo anterior. No existe ningún objeto “pila” en el procesador. Existe un registro, rsp, y un acuerdo colectivo sobre qué significa lo que hay alrededor de él. La alineación de 16 bytes es un acuerdo. La zona roja es un acuerdo. La cadena de rbp es un acuerdo tan opcional que basta una bandera del compilador para disolverla. Y sin embargo, sobre esos acuerdos se sostienen cosas que damos por naturales: que el depurador sepa quién te llamó, que un perfilador atribuya el tiempo a la función correcta, que una excepción de C++ encuentre su manejador, que un manejador de señales no destruya el trabajo de una función hoja. Cada una de esas capacidades es un derecho que alguien pactó por escrito, no una propiedad del silicio. La lección práctica es doble. Primero, que cuando una de esas capacidades falla —una traza absurda, un fallo dentro de la libc— la hipótesis correcta no es “el hardware hizo algo raro” sino “alguien rompió un acuerdo, y probablemente fui yo”. Y segundo, que puedes negociar los acuerdos: -fno-omit-frame-pointer compra observabilidad, -mno-red-zone compra corrección en contextos de interrupción, una pila alternativa de señal compra supervivencia ante el desbordamiento. Un ingeniero de sistemas no obedece la ABI: sabe qué cláusulas puede ajustar y qué precio tiene cada una.
- Escribe una función en ensamblador en línea que reste 24 a
rspy luego llame aprintfcon undouble; observa el fallo y explícalo midiendorspmódulo 16 en el depurador. - Compila una función hoja con
-O2y confirma en el asm que no tocarsp; recompílala con-mno-red-zoney compara la diferencia. - Implementa el bucle de recorrido de marcos de arriba y compara su salida con la de
backtracede glibc y conbtdegdb. - Compila el mismo programa con y sin
-fno-omit-frame-pointery comprueba cuál de las dos trazas sobrevive aperf record --call-graph fp. - Inspecciona las tablas de desenrollado con
readelf --debug-dump=framesy localiza la regla que describe dónde está la dirección de retorno.