La mecánica de la pila: stack frames y el riesgo de desbordamiento

La pila de llamadas es la estructura de control fundamental que permite la ejecución de funciones anidadas y la recursión. Funciona mediante una política LIFO (Last-In, First-Out) gestionada por el procesador, donde cada llamada a una función reserva un bloque de memoria llamado stack frame (marco de pila). Este bloque contiene el contexto necesario para la ejecución actual: variables locales, argumentos que no caben en registros, la dirección de retorno (el punto exacto en el código al que el CPU debe volver tras el ret) y el estado de los registros previos.

Esta estructura es esencial porque permite que cada invocación de una función tenga su propio espacio de memoria independiente, incluso si la misma función se llama a sí misma (recursión). En arquitecturas x86-64, el stack pointer (rsp) apunta al tope de la pila, que crece hacia direcciones de memoria más bajas, mientras que el frame pointer (rbp) se utiliza habitualmente para establecer una referencia base estable dentro del marco, facilitando el acceso a variables locales mediante desplazamientos (offsets).

Cuando trabajas con el ABI (Application Binary Interface) de System V AMD64 (el estándar en Linux), la eficiencia es la prioridad: los primeros seis argumentos de una función se pasan a través de registros específicos (rdi, rsi, rdx, rcx, r8, r9) para evitar el acceso a la memoria. Si una función tiene más de seis argumentos, el resto se apila. Los valores de retorno se devuelven típicamente en el registro rax.

Debes usar el stack para variables de vida corta y estructuras de control de flujo. Sin embargo, si la profundidad de la recursión es excesiva o intentas alojar arrays masivos de forma local, agotarás el segmento de pila asignado por el sistema operativo. Cuando esto sucede, el procesador intentará acceder a una dirección de memoria fuera de los límites permitidos y el kernel enviará una señal SIGSEGV (Segmentation Fault), terminando tu proceso abruptamente.

#include <stdio.h>
#include <stdlib.h>
#include <alloca.h> // Requerido para alloca [POSIX]

/* 
 * Una función recursiva clásica. Cada llamada incrementa 
 * la profundidad de la pila.
 */
long factorial(int n) {
    // Esta variable local reside dentro del stack frame de cada llamada.
    int dummy_buffer[4]; 
    (void)dummy_buffer; // Evita el warning por variable no utilizada

    if (n <= 1) {
        return 1;
    }
    // La llamada a factorial(n-1) empuja un nuevo frame al stack.
    return (long)n * factorial(n - 1);
}

/* 
 * Demostración de uso de alloca [POSIX].
 * Reserva memoria directamente en el stack, moviendo el rsp.
 */
void demo_alloca(int n) {
    // CUIDADO: No se puede verificar si alloca falló. 
    // Si n es muy grande, causará un stack overflow inmediato.
    int *ptr = alloca(n * sizeof(int));
    
    if (ptr != NULL) {
        ptr[0] = 42; // Escribimos en la memoria recién asignada
        printf("Valor en alloca[0]: %d\n", ptr[0]);
    }
}

int main(void) {
    int profundidad = 10;
    int tamaño_aloca = 5;

    printf("Calculando factorial de %d...\n", profundidad);
    printf("Resultado: %ld\n", factorial(profundidad));

    printf("Probando asignación en stack con alloca...\n");
    demo_alloca(tamaño_aloca);

    return 0;
}

Análisis del funcionamiento

Fíjate en cómo factorial(n) gestiona su ejecución. En la arquitectura x86-64, cuando factorial es llamada, el argumento n se encuentra inicialmente en el registro rdi según el ABI. Antes de que la función empiece a ejecutar su cuerpo, se realiza un call, lo que empuja la dirección de retorno a la pila. Dentro de la función, el compilador reserva espacio para dummy_buffer[4]. Esto desplaza el rsp hacia abajo, aumentando el tamaño del stack frame. En cada paso de la recursión, se crea un nuevo marco con su propio dummy_buffer y su propia copia de n, acumulándose en la memoria hasta que se alcanza el caso base.

En demo_alloca, estamos haciendo algo distinto a malloc. Mientras que malloc solicita memoria en el heap (montículo) mediante llamadas al sistema, alloca simplemente ajusta el valor del rsp hacia abajo. Es extremadamente rápido porque no hay gestión de metadatos de memoria, pero la memoria se libera automáticamente cuando la función retorna, simplemente moviendo el rsp de vuelta a su posición original.

El error frecuente

Un error clásico es confundir el stack con el heap cuando manejas datos de tamaño variable o grandes. Intentar declarar un array masivo dentro de una función es una bomba de tiempo:

void funcion_peligrosa() {
    // Si este array es demasiado grande, desbordará el stack
    // antes de que siquiera se pueda ejecutar la primera línea.
    double datos_masivos[10000000]; 
    datos_masivos[0] = 1.0;
    printf("%f", datos_masivos[0]);
}

Si intentas compilar y ejecutar esto, lo más probable es que recibas un Segmentation fault. El tamaño de la pila suele estar limitado (por ejemplo, 8 MB en muchas distribuciones de Linux, consultable con ulimit -s). Un array de 10 millones de double requiere unos 80 MB, superando con creces el límite. herramientas como AddressSanitizer detectarán desbordamientos de memoria si el acceso ocurre dentro de los límites del segmento, pero un stack overflow por tamaño de frame suele ser detectado directamente por la unidad de gestión de memoria (MMU) del hardware mediante una señal de protección de memoria.

Si necesitas memoria de gran tamaño, usa siempre malloc en el heap.

126

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio