El stack frame: anatomía de una llamada
Qué ocurre de verdad cuando una función llama a otra: la dirección de retorno, el prólogo, el marco donde viven parámetros y locales, el epílogo, y por qué la recursión choca contra un techo físico.
Una llamada parece un salto: el control se va, hace su trabajo y vuelve con un valor. Debajo hay un protocolo minucioso que apila una dirección de retorno, reserva memoria, guarda registros y lo deshace todo al salir. Ese bloque efímero es el marco de pila, y entenderlo vuelve obvias tres cosas que hasta ahora eran magia: por qué una local muere al salir, por qué devolver su dirección es un desastre, y por qué la recursión tiene un techo que no puedes negociar.
- Reconstruir el protocolo completo de una llamada: prólogo, cuerpo y epílogo.
- Situar parámetros, locales y dirección de retorno dentro del marco.
- Distinguir duración automática de duración estática y dinámica.
- Explicar el desbordamiento de pila y el límite real de la recursión.
El protocolo de una llamada
Cuando escribes int t = sumar(a, b);, el compilador no emite “un salto”. Emite la mitad de un protocolo cuya otra mitad vive dentro de sumar. El llamador coloca los argumentos donde manda la ABI —en System V x86-64, los seis primeros enteros en rdi, rsi, rdx, rcx, r8, r9— y ejecuta call, una instrucción que hace dos cosas a la vez: apila la dirección de la instrucción siguiente y salta. Esa dirección apilada es el hilo de Ariadna que permitirá volver.
La función llamada abre entonces su prólogo: guarda el puntero de marco del llamador, establece el suyo y baja el puntero de pila tantos bytes como necesiten sus locales. Ese bloque recién reservado es su marco.
int sumar(int a, int b) {
int total = a + b;
return total;
}
sumar:
push rbp ; guarda el marco del llamador
mov rbp, rsp ; rbp pasa a ser la base de MI marco
sub rsp, 16 ; reserva sitio para las locales
... ; cuerpo: total vive en [rbp-4]
mov eax, [rbp-4] ; el retorno viaja en eax
leave ; epilogo: mov rsp,rbp + pop rbp
ret ; salta a la direccion que apilo call
El epílogo es el prólogo al revés: descarta las locales moviendo el puntero de pila de vuelta, restaura el marco del llamador y ejecuta ret, que desapila la dirección de retorno y salta a ella. Nadie borra los bytes del marco: simplemente dejan de pertenecer a nadie. Por eso una local “muere” al volver sin que ocurra nada visible, y por eso leerla después devuelve a veces el valor antiguo y a veces basura.
Con optimizaciones, gcc y clang activan -fomit-frame-pointer y prescinden de rbp: si el compilador sabe cuánto mide el marco, puede direccionar todo respecto a rsp y liberar un registro. Por eso al mirar código optimizado no siempre verás el push rbp de manual. Los depuradores y los perfiladores lo compensan con metadatos de desenrollado, y por eso compilar con -fno-omit-frame-pointer sigue siendo habitual en producción: las trazas de perf salen mucho mejor.
Dónde vive cada cosa
El marco no es un saco: tiene inquilinos con reglas distintas.
Parámetros
Llegan por registro según la ABI. Si el compilador necesita su dirección, los vuelca al marco: por eso puedes hacer un puntero a un parámetro.
Locales automáticas
Nacen al entrar y mueren al salir. Sin inicializar contienen basura, no ceros. Su dirección deja de ser válida al volver.
Dirección de retorno
La apila call. Es el dato más sensible del marco: pisarla es tomar el control del programa.
Lo que NO está aquí
Las variables static viven en el segmento de datos y las de malloc en el heap. Ninguna muere al volver.
Hay una excepción elegante a todo esto que conviene conocer: la zona roja. La ABI de System V reserva 128 bytes por debajo del puntero de pila que ningún manejador de señales puede pisar, de modo que una función hoja —una que no llama a nadie— puede usarlos para sus locales sin ejecutar prólogo ni epílogo. Compila una función pequeña con -O2 y verás que no toca rsp en absoluto: su marco es puramente virtual.
Cada llamada anidada empuja un marco nuevo sobre el anterior, y la pila crece hacia direcciones bajas:
flowchart TD A[Marco de main] --> B[Marco de procesar] B --> C[Marco de auxiliar] C --> D[Cima actual de la pila] D --> E[Zona libre que se agota hacia direcciones bajas] style D fill:#a6e3a1,color:#11111b style E fill:#f9e2af,color:#11111b
De esta disciplina LIFO se deduce el error clásico de C, que ahora deberías poder explicar sin dudar:
int *mal(void) {
int x = 42;
return &x; // x deja de existir al volver: puntero colgante
}
int *bien(void) {
static int x = 42; // duracion estatica: sobrevive a la llamada
return &x;
}
Devolver &x no falla al compilar por accidente: gcc -Wall avisa con -Wreturn-local-addr, y aun así el programa suele “funcionar” un rato, porque el marco no se borra hasta que otra llamada lo pisa. Ese es exactamente el perfil de un bug caro: comportamiento indefinido silencioso que explota en producción y no en tu portátil.
El techo de la recursión
Cada llamada recursiva es un marco más. La pila de un hilo tiene un tamaño fijo, decidido al crearlo: en Linux suelen ser 8 MiB para el hilo principal y 8 MiB o menos para los hilos de pthread. La profundidad máxima no es una constante del lenguaje, es una división:
size_t profundidad(size_t n) {
char relleno[1024]; // 1 KiB de local: engorda el marco
relleno[0] = (char)n;
return n == 0 ? 0 : 1 + profundidad(n - 1);
}
Con un marco de 1 KiB y una pila de 8 MiB, el techo ronda las 8000 llamadas. Reduce la local y sube a decenas de miles; mete un array de 64 KiB y no pasarás de 128. La recursión no falla “cuando es muy profunda”: falla cuando el producto de la profundidad por el tamaño del marco supera la pila.
ulimit -s # KiB de pila: 8192 = 8 MiB
gcc -std=c23 -O2 -fstack-usage prog.c # genera .su con el tamano de cada marco
El tamaño tampoco es inamovible: ulimit -s gobierna el hilo principal y pthread_attr_setstacksize fija el de cada hilo que crees, algo que importa mucho en servidores con miles de hilos, donde reservar 8 MiB por hilo agota el espacio de direcciones. Ahí está el compromiso: pilas pequeñas escalan en número de hilos pero acortan el techo de recursión de cada uno.
Al desbordar no hay excepción ni mensaje amable: el programa pisa la guard page que el sistema coloca bajo la pila y recibe un SIGSEGV. Indistinguible, a simple vista, de desreferenciar un puntero nulo. Un matiz decisivo: con -O2 el compilador puede convertir una recursión de cola en un bucle y reutilizar el mismo marco, de modo que el programa que desbordaba con -O0 corre infinito con -O2. C no garantiza esa transformación en ningún sitio: es una optimización, no una promesa. Nunca escribas un algoritmo cuya corrección dependa de que ocurra.
Marcos que rompen el molde
Un marco normal se dimensiona en compilación: el sub rsp, N del prólogo lleva una constante que el compilador calculó sumando las locales. Tres construcciones rompen esa propiedad y conviene reconocerlas, porque cada una tiene su patología.
Los arrays de longitud variable, incorporados en C99 y de soporte opcional desde C11, reservan dentro del marco un tamaño que solo se conoce al entrar:
void procesar(size_t n) {
int buf[n]; // VLA: el marco crece con n en tiempo de ejecucion
...
}
Si n procede de una entrada externa, acabas de ceder al usuario el control sobre cuánto baja el puntero de pila. Con un valor suficientemente grande, el puntero salta por encima de la guard page sin tocarla y aterriza en otra región válida del mapa de memoria: eso es el ataque de stack clash. Por ese motivo el kernel de Linux erradicó los VLA de su código, y por eso existen -Wvla y -fstack-clash-protection. alloca comparte el defecto sin ninguna ventaja: no es estándar, no puede fallar de forma detectable y su memoria vive hasta que retorna la función, no hasta el final del bloque.
La tercera es más profunda. setjmp y longjmp saltan por encima de la disciplina LIFO: restauran el puntero de pila a un marco anterior y descartan de golpe todos los marcos intermedios. Cualquier recurso adquirido en ellos —memoria de malloc, ficheros abiertos, locks tomados— se pierde sin que nadie lo libere, porque en C no existe nada análogo a un destructor que se ejecute al desenrollar. Ahí tienes la razón técnica de que el manejo de errores idiomático en C sea devolver códigos y propagarlos a mano, y no saltar.
Da un paso atrás y mira cuántas reglas aparentemente arbitrarias caen de una sola decisión de diseño: el almacenamiento de las llamadas es una pila LIFO gestionada moviendo un registro. Como reservar es restar a rsp, asignar memoria local cuesta una instrucción, y por eso C es rápido sin recolector de basura. Como liberar es sumar, nadie tiene que rastrear nada. Pero un LIFO impone que lo último en entrar sea lo primero en salir, y de ahí sale la duración automática: una local no puede sobrevivir a su llamada porque su espacio se lo queda la siguiente. De ahí sale el puntero colgante, de ahí sale que malloc exista (necesitas un almacenamiento cuya vida no siga el orden de las llamadas), y de ahí sale el límite de la recursión, que no es una restricción del lenguaje sino la aritmética de un buffer finito. La misma decisión explica la seguridad: si la dirección de retorno vive dentro del mismo buffer donde escribes tus arrays, un strcpy sin control puede sobrescribirla y dictar a dónde salta ret. Eso es el desbordamiento de pila clásico, y por eso existen los canarios, -fstack-protector, las páginas no ejecutables y el ROP como respuesta a ellas. Cuando entiendes el marco de pila dejas de memorizar reglas: las deduces.
- Compila
sumarcongcc -std=c23 -S -O0 -masm=intely localizapush rbp,sub rspyret; repite con-O2y explica qué desapareció. - Escribe la función que devuelve la dirección de una local, compílala con
-Wally lee el aviso-Wreturn-local-addr; luego imprime el valor tras otra llamada y observa cómo se corrompe. - Mide tu profundidad máxima real de recursión con un relleno local de 1 KiB, y vuelve a medirla tras
ulimit -s 1024. - Compila la recursión de cola con
-O0y con-O2y comprueba cuál desborda; usa-fstack-usagepara ver el tamaño de marco en cada caso.