← Gestión de Memoria Bloque 1 · El espacio de direcciones · Tema 5 de 52

Tema 5

Explorar el espacio de direcciones en C

En este tema tomarás el control: imprimirás las direcciones de funciones, variables globales, locales, estáticas y bloques dinámicos, las compararás con /proc/self/maps, verás cómo cambian con ASLR y analizarás el binario con nm, readelf y size. Es un tema práctico: al terminar podrás leer el espacio de direcciones de cualquier programa como si fuera un mapa.

1. Qué vamos a explorar

El objetivo es tomar un programa en C y ver por dentro cómo se distribuye en su espacio de direcciones. Para ello imprimiremos las direcciones de distintos objetos y las contrastaremos con el mapa del proceso. Esta técnica es la base de la depuración, el análisis de rendimiento y la seguridad.

Vamos a imprimir direcciones de: funciones, variables globales inicializadas, variables globales no inicializadas (BSS), variables estáticas de función, variables locales (automáticas), bloques de malloc, bloques de calloc, memoria anónima con mmap y memoria mapeada de archivo.

2. Las categorías de variables y dónde viven

CategoríaDeclaración típicaRegiónVida
Funciónvoid f(void) {}Código (text)Todo el programa
Global inicializadaint g = 7;DataTodo el programa
Global no inicializadaint g;BSSTodo el programa
Estática de funciónstatic int s = 0;Data / BSSTodo el programa
Local (automática)int x;Pila (stack)Mientras la función se ejecuta
Bloque dinámicomalloc(n)HeapHasta free
Mapeo anónimommap(...ANONYMOUS)mmapHasta munmap

La región la decide el compilador y el enlazador según el tipo de objeto, no el programador. El programador solo elige el tipo de almacenamiento (automático, estático, dinámico).

3. Un programa que imprime todo

explorar.c
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>

static void funcion_a(void) { }
static void funcion_b(void) { }

int global_init = 42;          /* data */
int global_bss;                /* BSS */
static int estatica_init = 7;  /* data */
static int estatica_bss;      /* BSS */

int main(void)
{
    static int estatica_local = 9;   /* data (estática) */
    int local1 = 1;
    int local2 = 2;
    void *h1 = malloc(16);
    void *h2 = malloc(4096);
    void *m1 = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
                    MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);

    printf("función A        : %p\n", (void *)&funcion_a);
    printf("función B        : %p\n", (void *)&funcion_b);
    printf("global_init      : %p\n", (void *)&global_init);
    printf("global_bss       : %p\n", (void *)&global_bss);
    printf("estática_init    : %p\n", (void *)&estatica_init);
    printf("estática_bss     : %p\n", (void *)&estatica_bss);
    printf("estática_local   : %p\n", (void *)&estatica_local);
    printf("local1           : %p\n", (void *)&local1);
    printf("local2           : %p\n", (void *)&local2);
    printf("malloc 16        : %p\n", h1);
    printf("malloc 4096      : %p\n", h2);
    printf("mmap anónimo     : %p\n", m1);
    printf("main             : %p\n", (void *)&main);
    printf("printf (libc)    : %p\n", (void *)&printf);

    munmap(m1, 4096);
    free(h2); free(h1);
    return 0;
}

4. Interpretar las direcciones

Al ejecutar el programa se obtienen las direcciones. Lo importante es la agrupación, no los valores exactos:

terminal · direcciones agrupadas por región
$ gcc -std=c17 -Wall -Wextra -o explorar explorar.c
$ ./explorar
función A        : 0x55a1b3c01149
función B        : 0x55a1b3c01151
global_init      : 0x55a1b3c04010
global_bss       : 0x55a1b3c04014
estática_init    : 0x55a1b3c04018
estática_bss     : 0x55a1b3c0401c
estática_local   : 0x55a1b3c04020
local1           : 0x7ffe2c3a1f2c
local2           : 0x7ffe2c3a1f28
malloc 16        : 0x55a1b3c062a0
malloc 4096      : 0x55a1b3c07000
mmap anónimo     : 0x7f8b4c000000
main             : 0x55a1b3c01160
printf (libc)    : 0x7f8b4c25a340

Se observa:

Las variables locales local1 y local2 están a 4 bytes de distancia: se reservan en la pila de forma contigua. Los bloques de malloc no tienen por qué estar contiguos entre sí.

5. El efecto de ASLR

Si ejecutas el programa varias veces, las direcciones cambian (salvo con ASLR desactivado). Las diferencias relativas dentro de una región se mantienen, pero las bases cambian:

terminal · tres ejecuciones
$ ./explorar | grep global_init
global_init      : 0x55a1b3c04010
$ ./explorar | grep global_init
global_init      : 0x561c8e4f2010
$ ./explorar | grep global_init
global_init      : 0x55f3d2a08010
$ setarch -R ./explorar | grep global_init
global_init      : 0x404010

Con setarch -R las direcciones son fijas y predecibles: el código empieza en 0x400000, los datos en 0x404000, etc. Esto es útil para depurar, pero peligroso en producción.

6. Tamaño, alineación y padding

Las direcciones que imprime el programa no son arbitrarias: el compilador alinea los objetos según su tipo. Un int de 4 bytes se alinea a 4 bytes; un double o un puntero, a 8. Esto produce padding (huecos) en las estructuras.

alineacion.c
#include <stdio.h>
#include <stdlib.h>
#include <stddef.h>

struct A { char c; int x; double d; };
struct B { int x; char c; double d; };

int main(void)
{
    printf("sizeof A = %zu, offsetof x = %zu, offsetof d = %zu\n",
           sizeof(struct A), offsetof(struct A, x), offsetof(struct A, d));
    printf("sizeof B = %zu, offsetof x = %zu, offsetof c = %zu, offsetof d = %zu\n",
           sizeof(struct B), offsetof(struct B, x), offsetof(struct B, c), offsetof(struct B, d));
    return 0;
}
terminal · alineación y padding
$ gcc -std=c17 -Wall -Wextra -o alineacion alineacion.c
$ ./alineacion
sizeof A = 16, offsetof x = 4, offsetof d = 8
sizeof B = 16, offsetof x = 0, offsetof c = 4, offsetof d = 8

El compilador reordena y añade padding para que cada campo esté alineado. Esto afecta al tamaño de las estructuras y, en consecuencia, a la cantidad de memoria y caché que usan.

7. Comparar con /proc/self/maps

La forma de confirmar a qué región pertenece cada dirección es compararla con /proc/self/maps. El programa siguiente imprime las direcciones y luego su propio mapa, para que se pueda verificar visualmente:

terminal · verificar regiones
$ ./explorar >/tmp/out.txt; cat /tmp/out.txt | head -12
$ grep -E 'heap|stack' /proc/self/maps

La técnica consiste en añadir un getchar() al final del programa para que no termine mientras se inspecciona su mapa desde otra terminal: cat /proc/$(pgrep explorar)/maps.

8. Analizar el binario: nm, readelf, size

Antes de ejecutar, se puede inspeccionar el binario para ver qué símbolos contiene y en qué secciones están:

terminal · nm, readelf y size
$ nm explorar | grep -E 'funcion|global|estatica|main'
0000000000001149 t funcion_a
0000000000001151 t funcion_b
0000000000004010 D global_init
0000000000004014 B global_bss
0000000000004018 d estatica_init
000000000000401c b estatica_bss
0000000000001160 T main

Las letras indican el tipo de símbolo: T/t función (T global, t estática), D/d dato inicializado, B/b BSS. Las direcciones son relativas al inicio del binario (offsets), no las direcciones virtuales finales.

terminal · readelf y size
$ readelf -S explorar | grep -E 'text|data|bss'
  [14] .text             PROGBITS         0000000000001060 ...
  [23] .data             PROGBITS         0000000000004000 ...
  [24] .bss              NOBITS           0000000000004024 ...  [24] .bss              NOBITS           0000000000004024 ...
terminal · size
$ size explorar
   text    data     bss     dec     hex filename
   1642     600       8    2250     8ca explorar

size resume el tamaño de las secciones: text (código), data (datos inicializados), bss (no inicializados). La suma es el tamaño del ejecutable en memoria (sin contar el heap ni la pila, que crecen en tiempo de ejecución).

9. Probar con un simulador

Este simulador muestra cómo se distribuyen las direcciones de distintos objetos en el espacio de un proceso, con y sin ASLR. Cambia el modo y observa cómo las bases cambian pero las distancias relativas dentro de cada región se mantienen.

Simulador · explorar direcciones

ObjetoDirecciónRegión

10. Mirar en Linux

terminal · herramientas de inspección
$ nm -C explorar | head
$ readelf -h explorar | grep -E 'Class|Machine|Type'
$ objdump -t explorar | grep global_init
$ size -A explorar | head
$ cat /proc/sys/kernel/randomize_va_space

11. Resumen

12. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿En qué región vive una variable local?

En la pila (stack), porque es automática y su vida está ligada a la ejecución de la función.

2. ¿En qué región vive una variable global no inicializada?

En el BSS, que el núcleo pone a cero al cargar el programa y no ocupa espacio en el archivo ejecutable.

3. ¿Por qué las direcciones cambian entre ejecuciones?

Por ASLR, que aleatoriza las bases de las regiones para dificultar los ataques. Las diferencias relativas dentro de una región se mantienen.

4. ¿Qué muestra nm que no muestra /proc/PID/maps?

Los símbolos del binario con sus offsets y tipos (función, dato, BSS), antes de la ejecución y sin aleatorización.

5. ¿Qué es el padding en una estructura?

Bytes de relleno que el compilador añade para alinear los campos según su tipo, lo que aumenta el tamaño de la estructura.

6. ¿Cómo se desactiva ASLR para un programa?

Con setarch -R ./programa o estableciendo /proc/sys/kernel/randomize_va_space a 0 (requiere privilegios).

7. ¿Qué diferencia hay entre data y BSS?

Data contiene variables globales inicializadas (ocupa espacio en el binario). BSS contiene las no inicializadas (no ocupa espacio en el binario, se ponen a cero al cargar).

8. ¿Qué herramienta muestra el tamaño de las secciones text/data/bss?

size (y readelf -S para el detalle de secciones).

Para practicar

  1. Añade a explorar.c una variable const int c = 5; y un array global grande. ¿En qué región cae cada uno? Verifica con nm.
  2. Modifica explorar.c para imprimir la dirección de una función recursiva en distintos niveles. ¿Cómo cambia la dirección de la variable local?
  3. Ejecuta explorar con setarch -R y sin él, y compara las direcciones de printf (libc).
  4. Usa readelf -S para listar todas las secciones. ¿Cuántas hay? ¿Qué contiene .rodata?
  5. Crea una estructura con campos en distinto orden y mide su sizeof. ¿En qué orden es más pequeña?
  6. Añade un getchar() al final de explorar.c y, mientras espera, inspecciona /proc/PID/maps desde otra terminal. Verifica cada región.
  7. Compila con -fno-pie -no-pie y observa cómo cambian las direcciones (sin ASLR implícito del PIE).