← Programación en C para Sistemas Operativos Bloque 7 · Memoria dinámica · Tema 27 de 42

Tema 27

El mapa de memoria de un proceso

A lo largo del curso fuimos encontrando regiones de la memoria: la pila donde viven las variables locales, la zona de solo lectura de las cadenas literales, las variables globales, el código, y la memoria que pide malloc. Cada una apareció en su tema, con una línea de /proc/self/maps como prueba. Este tema, que abre el bloque 7, las junta en una sola imagen. Veremos cómo se lee cada línea del mapa, cómo el núcleo lo arma a partir del ejecutable, por qué un arreglo global de 100 MiB no agranda el archivo ni gasta memoria hasta que se usa, de dónde sale el montículo, cómo crece la pila, qué son [vdso] y [vvar], por qué las direcciones cambian en cada ejecución, y qué quiere decir exactamente Segmentation fault. Es el mapa que hace falta tener a mano antes de pedir memoria dinámica en el tema siguiente.

1. Un espacio de direcciones por proceso

Las direcciones que imprime un programa con %p no son posiciones de la memoria física. Son direcciones virtuales: el procesador, con la ayuda de tablas que mantiene el núcleo, las traduce a posiciones reales cada vez que el programa lee o escribe. La traducción se hace por páginas, bloques de 4096 bytes en x86-64, y es distinta para cada proceso. Dos procesos pueden usar la misma dirección virtual para datos completamente distintos, y un mismo dato de la memoria física, como las instrucciones de libc, puede aparecer en las direcciones virtuales de cien procesos a la vez.

El espacio de direcciones de un proceso es el conjunto de direcciones virtuales que puede usar. Está formado por regiones (VMA, áreas de memoria virtual), cada una un rango de páginas contiguas con los mismos permisos de lectura, escritura y ejecución. Las direcciones fuera de toda región no existen: usarlas termina el programa.

En x86-64, Linux reserva para los programas la mitad baja del espacio de 48 bits, hasta la dirección 0x00007fffffffffff, 128 TiB; la mitad alta es del núcleo y ningún programa puede tocarla. De esos 128 TiB, un programa sencillo tiene mapeados unos pocos megabytes, repartidos en regiones que empiezan y terminan en múltiplos de 4096. El detalle de cómo el procesador traduce las direcciones y cómo el núcleo reparte la memoria física es materia de un curso de sistemas operativos; aquí nos interesa lo que el programa puede observar, que es mucho, porque Linux publica el mapa de cada proceso como un archivo.

2. Leer /proc/self/maps

El archivo /proc/PID/maps contiene una línea por región del proceso con ese identificador, y /proc/self/maps es el del proceso que lo abre. Este programa pide dos bloques de memoria, uno chico y uno de 1 MiB, y muestra su propio mapa:

mapa.c
#include <stdio.h>
#include <stdlib.h>

int dato_inicializado = 42;
int dato_sin_valor;

int main(void)
{
    char *pequeno = malloc(100);
    char *grande = malloc(1 << 20);
    char linea[512];

    printf("main en %p\n", (void *) main);
    printf("dato_inicializado en %p, dato_sin_valor en %p\n",
           (void *) &dato_inicializado, (void *) &dato_sin_valor);
    printf("malloc(100) en %p, malloc(1 MiB) en %p\n\n", (void *) pequeno, (void *) grande);

    FILE *f = fopen("/proc/self/maps", "r");
    if (f == NULL) {
        perror("/proc/self/maps");
        return 1;
    }
    while (fgets(linea, sizeof linea, f) != NULL)
        fputs(linea, stdout);
    fclose(f);
    free(pequeno);
    free(grande);
    return 0;
}
terminal
$ gcc -std=c17 -Wall -Wextra -g -o mapa mapa.c
$ ./mapa
main en 0x63e531bbd249
dato_inicializado en 0x63e531bc0010, dato_sin_valor en 0x63e531bc0024
malloc(100) en 0x63e545920010, malloc(1 MiB) en 0x7cc2e08ff010

63e531bbc000-63e531bbd000 r--p 00000000 08:30 52301                      /home/ana/c/tema27/mapa
63e531bbd000-63e531bbe000 r-xp 00001000 08:30 52301                      /home/ana/c/tema27/mapa
63e531bbe000-63e531bbf000 r--p 00002000 08:30 52301                      /home/ana/c/tema27/mapa
63e531bbf000-63e531bc0000 r--p 00002000 08:30 52301                      /home/ana/c/tema27/mapa
63e531bc0000-63e531bc1000 rw-p 00003000 08:30 52301                      /home/ana/c/tema27/mapa
63e545920000-63e545941000 rw-p 00000000 00:00 0                          [heap]
7cc2e08ff000-7cc2e0a00000 rw-p 00000000 00:00 0
7cc2e0a00000-7cc2e0a28000 r--p 00000000 08:30 13613                      /usr/lib/x86_64-linux-gnu/libc.so.6
7cc2e0a28000-7cc2e0bc0000 r-xp 00028000 08:30 13613                      /usr/lib/x86_64-linux-gnu/libc.so.6
7cc2e0bc0000-7cc2e0c0e000 r--p 001c0000 08:30 13613                      /usr/lib/x86_64-linux-gnu/libc.so.6
7cc2e0c0e000-7cc2e0c12000 r--p 0020e000 08:30 13613                      /usr/lib/x86_64-linux-gnu/libc.so.6
7cc2e0c12000-7cc2e0c14000 rw-p 00212000 08:30 13613                      /usr/lib/x86_64-linux-gnu/libc.so.6
7cc2e0c14000-7cc2e0c21000 rw-p 00000000 00:00 0
7cc2e0c92000-7cc2e0c95000 rw-p 00000000 00:00 0
7cc2e0c9b000-7cc2e0c9d000 rw-p 00000000 00:00 0
7cc2e0c9d000-7cc2e0c9e000 r--p 00000000 08:30 13610                      /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7cc2e0c9e000-7cc2e0ccd000 r-xp 00001000 08:30 13610                      /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7cc2e0ccd000-7cc2e0cd8000 r--p 00030000 08:30 13610                      /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7cc2e0cd8000-7cc2e0cda000 r--p 0003b000 08:30 13610                      /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7cc2e0cda000-7cc2e0cdb000 rw-p 0003d000 08:30 13610                      /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7cc2e0cdb000-7cc2e0cdc000 rw-p 00000000 00:00 0
7ffc88a39000-7ffc88a5b000 rw-p 00000000 00:00 0                          [stack]
7ffc88b58000-7ffc88b5c000 r--p 00000000 00:00 0                          [vvar]
7ffc88b5c000-7ffc88b5e000 r-xp 00000000 00:00 0                          [vdso]

Cada línea tiene seis campos, separados por espacios:

CampoEjemploSignificado
Rango63e531bbd000-63e531bbe000Dirección de inicio, incluida, y de fin, excluida. Siempre múltiplos de 4096: esta región mide 4 KiB.
Permisosr-xpr lectura, w escritura, x ejecución, - si falta. La última letra es p para una región privada (lo que se escribe no llega al archivo ni a otros procesos) o s para una compartida.
Desplazamiento00001000Posición, dentro del archivo, desde donde se tomó el contenido. Cero para la memoria anónima.
Dispositivo08:30Número del dispositivo donde está el archivo, en la forma principal:secundario. 00:00 si no hay archivo.
Nodo52301El número de nodo del archivo, el mismo que muestra ls -i. Cero para memoria anónima.
Nombre/home/ana/c/tema27/mapaEl archivo que respalda la región, o un nombre especial entre corchetes: [heap], [stack], [vdso], [vvar]. Vacío en la memoria anónima, que arranca llena de ceros.

De arriba abajo, el mapa muestra los grupos en que se organiza todo proceso:

Ninguna región tiene a la vez w y x: lo que se puede escribir no se puede ejecutar, y al revés. Es la regla W^X, una defensa que impide que datos escritos por un atacante, en la pila o en el montículo, se ejecuten como código. Vale la pena comprobarlo en el mapa de cualquier proceso corriente con grep rwx /proc/PID/maps: no devuelve nada, salvo en programas que generan código al vuelo, como los compiladores JIT.

3. Del ejecutable a las regiones

El mapa de un proceso no se improvisa: el núcleo lo arma al ejecutar el programa a partir de las cabeceras de programa (program headers) que el enlazador dejó en el ejecutable. Las vemos con readelf, y el resumen de tamaños con size:

terminal
$ readelf -lW mapa
Elf file type is DYN (Position-Independent Executable file)
Entry point 0x1160
There are 14 program headers, starting at offset 64

Program Headers:
  Type           Offset   VirtAddr           PhysAddr           FileSiz  MemSiz   Flg Align
  PHDR           0x000040 0x0000000000000040 0x0000000000000040 0x000310 0x000310 R   0x8
  INTERP         0x000374 0x0000000000000374 0x0000000000000374 0x00001c 0x00001c R   0x1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD           0x000000 0x0000000000000000 0x0000000000000000 0x000828 0x000828 R   0x1000
  LOAD           0x001000 0x0000000000001000 0x0000000000001000 0x0003c1 0x0003c1 R E 0x1000
  LOAD           0x002000 0x0000000000002000 0x0000000000002000 0x0001b8 0x0001b8 R   0x1000
  LOAD           0x002d78 0x0000000000003d78 0x0000000000003d78 0x00029c 0x0002b0 RW  0x1000
  …
  GNU_STACK      0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW  0x10
  GNU_RELRO      0x002d78 0x0000000000003d78 0x0000000000003d78 0x000288 0x000288 R   0x1

 Section to Segment mapping:
  Segment Sections...
   02     .note.gnu.build-id .interp .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt
   03     .init .plt .plt.got .plt.sec .text .fini
   04     .rodata .eh_frame_hdr .eh_frame .note.gnu.property .note.ABI-tag
   05     .init_array .fini_array .dynamic .got .data .bss
…
$ size mapa
   text	   data	    bss	    dec	    hex	filename
   2626	    668	     16	   3310	    cee	mapa

Las líneas LOAD son los segmentos que el núcleo tiene que cargar en memoria: cuatro, con permisos R, R E, R y RW, que se convierten una a una en las cuatro primeras líneas del mapa. Cada segmento agrupa secciones, las que vimos con nm y objdump en el tema 4 y en el tema 6:

SeccionesSegmento LOADRegión en el mapaContenido
.interp, .dynsym, .rela.dyn…1 · Rr--p, desplazamiento 0Las cabeceras, el nombre del cargador y las tablas de símbolos dinámicos.
.init, .plt, .text, .fini2 · R Er-xpEl código: las funciones del programa.
.rodata, .eh_frame3 · Rr--pConstantes y cadenas literales.
.init_array, .dynamic, .got4 · RW, parte «RELRO»r--p (después de arrancar)Punteros de arranque y la tabla de saltos a funciones de bibliotecas.
.data4 · RWrw-pVariables globales con valor inicial.
.bss4 · RW, más allá de FileSizrw-p, al finalVariables globales sin valor inicial.

Tres detalles de las cabeceras explican detalles del mapa:

Y en la última línea LOAD, FileSiz es 0x29c y MemSiz es 0x2b0: el segmento ocupa en memoria más que en el archivo. La diferencia son los datos que no se guardaron en el archivo porque valen cero: la sección .bss. Es el tema siguiente.

4. .bss: memoria que no existe hasta que se toca

Una variable global sin valor inicial arranca en cero, como vimos en el tema 6. Guardar esos ceros en el ejecutable sería un desperdicio, y el enlazador no lo hace: el archivo solo dice cuántos bytes hacen falta. Dos programas de una línea muestran la diferencia:

vacio.c y lleno.c
/* vacio.c: 100 MiB sin valor inicial */
static char grande[100 * 1024 * 1024];

int main(void)
{
    return grande[0];
}

/* lleno.c: 8 MiB con valor inicial distinto de cero */
static char grande[8 * 1024 * 1024] = {1};

int main(void)
{
    return grande[0];
}
terminal
$ gcc -std=c17 -o vacio vacio.c && gcc -std=c17 -o lleno lleno.c
$ ls -l vacio lleno | awk '{print $5, $9}'
8405504 lleno
16880 vacio
$ readelf -lW vacio | grep -E 'Type|LOAD'
  Type           Offset   VirtAddr           PhysAddr           FileSiz  MemSiz   Flg Align
  LOAD           0x000000 0x0000000000000000 0x0000000000000000 0x0005d0 0x0005d0 R   0x1000
  LOAD           0x001000 0x0000000000001000 0x0000000000001000 0x00014d 0x00014d R E 0x1000
  LOAD           0x002000 0x0000000000002000 0x0000000000002000 0x000118 0x000118 R   0x1000
  LOAD           0x002df0 0x0000000000003df0 0x0000000000003df0 0x000220 0x6400250 RW  0x1000

El arreglo de 100 MiB sin valor deja un ejecutable de 16 880 bytes. El de 8 MiB con un 1 en el primer elemento, 8 405 504 bytes: el archivo tiene que guardar el contenido entero, aunque casi todo sean ceros. En las cabeceras de vacio, el último segmento tiene FileSiz 0x220 y MemSiz 0x6400250, es decir, 100 MiB: el núcleo mapea el archivo para la parte que existe, y crea memoria anónima, llena de ceros, para el resto.

Pero tampoco esa memoria existe del todo. El núcleo no reserva 100 MiB de memoria física al arrancar: la reserva en el espacio de direcciones, y le da páginas reales a medida que el programa las toca por primera vez. Es la paginación por demanda, y este programa la muestra:

bss.c
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/resource.h>
#include <unistd.h>

enum { MIB = 1024 * 1024, TAM = 64 * MIB };

static char grande[TAM];                  /* sin valor inicial: va a .bss */

/* el valor de un campo de /proc/self/status, en KiB */
static long campo(const char *nombre)
{
    FILE *f = fopen("/proc/self/status", "r");
    char linea[256];
    long valor = -1;

    if (f == NULL)
        return -1;
    size_t n = strlen(nombre);
    while (fgets(linea, sizeof linea, f) != NULL) {
        if (strncmp(linea, nombre, n) == 0) {
            valor = strtol(linea + n, NULL, 10);
            break;
        }
    }
    fclose(f);
    return valor;
}

static void informe(const char *momento)
{
    struct rusage r;

    getrusage(RUSAGE_SELF, &r);
    printf("%-24s VmSize %7ld KiB  VmRSS %7ld KiB  fallos de página %6ld\n",
           momento, campo("VmSize:"), campo("VmRSS:"), r.ru_minflt);
}

int main(void)
{
    long pagina = sysconf(_SC_PAGESIZE);

    printf("tamaño de página: %ld bytes; el arreglo grande tiene %d páginas\n",
           pagina, TAM / (int) pagina);
    informe("al empezar");

    for (long i = 0; i < TAM / 4; i += pagina)
        grande[i] = 1;
    informe("tocado el primer cuarto");

    for (long i = 0; i < TAM; i += pagina)
        grande[i] = 1;
    informe("tocado todo");

    for (long i = 0; i < TAM; i += pagina)
        grande[i] = 2;
    informe("segunda pasada");
    return 0;
}
terminal
$ ./bss
tamaño de página: 4096 bytes; el arreglo grande tiene 16384 páginas
al empezar               VmSize   68304 KiB  VmRSS    1664 KiB  fallos de página     94
tocado el primer cuarto  VmSize   68304 KiB  VmRSS   18176 KiB  fallos de página   4192
tocado todo              VmSize   68304 KiB  VmRSS   67328 KiB  fallos de página  16480
segunda pasada           VmSize   68304 KiB  VmRSS   67328 KiB  fallos de página  16480

Hay dos magnitudes distintas, y confundirlas es un error común:

La primera escritura en cada página provoca un fallo de página: el procesador no encuentra una traducción para esa dirección y llama al núcleo, que ve que la dirección pertenece a una región válida, le da una página nueva llena de ceros y deja que la instrucción se repita. El programa no se entera. Como la página se reserva a pedido y no hace falta leerla de un disco, se llama fallo menor, y getrusage lo cuenta en ru_minflt. Los números cuadran con la cuenta: tocar el primer cuarto, 4096 páginas, suma 4098 fallos y 16 MiB de VmRSS; tocar todo agrega otros 12 288 y llega a 64 MiB; y la segunda pasada no produce ningún fallo, porque las páginas ya están. El costo de unos 16 000 fallos es un tiempo de arranque que el programa paga una sola vez, no por cada acceso.

Las consecuencias alcanzan a todo el bloque que empieza aquí. Una región enorme sin tocar casi no cuesta nada; un programa que pide memoria y no la usa no la gasta; y es posible que una petición grande parezca funcionar y el problema aparezca después, al usar la memoria. Más adelante volveremos a esto con malloc (tema 28) y con las fugas (tema 29).

Una consecuencia práctica de que el sistema use una página como unidad: el mapa solo puede hablar en múltiplos de 4096. La región rw-p de nuestro ejecutable mide una página entera aunque .data y .bss sumen solo 36 bytes: el resto de la página queda sin usar.

5. El montículo: brk y mmap

El montículo (heap) es la región de la que malloc toma su memoria. Linux le ofrece a un programa dos formas de ampliar su espacio de direcciones, y malloc usa las dos:

Este programa pide cuatro bloques y mira, después de cada uno, el fin del programa y la línea [heap] del mapa:

heap.c
#define _DEFAULT_SOURCE                   /* sbrk */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

/* muestra la línea [heap] del mapa, o avisa si todavía no existe */
static void heap(void)
{
    FILE *f = fopen("/proc/self/maps", "r");
    char linea[512];
    int hay = 0;

    while (f != NULL && fgets(linea, sizeof linea, f) != NULL) {
        if (strstr(linea, "[heap]") != NULL) {
            printf("    mapa: %s", linea);
            hay = 1;
        }
    }
    if (!hay)
        printf("    mapa: no hay [heap]\n");
    if (f != NULL)
        fclose(f);
}

static void paso(const char *que, const void *p)
{
    printf("%-26s %p   brk = %p\n", que, p, sbrk(0));
    heap();
}

int main(void)
{
    paso("al empezar", NULL);

    void *a = malloc(100);
    paso("malloc(100)", a);

    void *b = malloc(100 * 1024);
    paso("malloc(100 KiB)", b);

    void *d = malloc(100 * 1024);
    paso("otro malloc(100 KiB)", d);

    void *c = malloc(1024 * 1024);
    paso("malloc(1 MiB)", c);

    free(c);
    free(d);
    free(b);
    free(a);
    paso("después de los free", NULL);
    return 0;
}
terminal
$ ./heap
al empezar                 (nil)   brk = 0x60547ed36000
    mapa: 60547ed36000-60547ed58000 rw-p 00000000 00:00 0                          [heap]
malloc(100)                0x60547ed37910   brk = 0x60547ed58000
    mapa: 60547ed36000-60547ed58000 rw-p 00000000 00:00 0                          [heap]
malloc(100 KiB)            0x60547ed37980   brk = 0x60547ed58000
    mapa: 60547ed36000-60547ed58000 rw-p 00000000 00:00 0                          [heap]
otro malloc(100 KiB)       0x60547ed50990   brk = 0x60547ed8a000
    mapa: 60547ed36000-60547ed8a000 rw-p 00000000 00:00 0                          [heap]
malloc(1 MiB)              0x7ac28fa83010   brk = 0x60547ed8a000
    mapa: 60547ed36000-60547ed8a000 rw-p 00000000 00:00 0                          [heap]
después de los free       (nil)   brk = 0x60547ed8a000
    mapa: 60547ed36000-60547ed8a000 rw-p 00000000 00:00 0                          [heap]
$ strace -e trace=brk,mmap,munmap ./heap 2>&1 >/dev/null | grep -E 'brk|1052672|munmap'
brk(NULL)                               = 0x5d747350f000
munmap(0x7f75814d0000, 21491)           = 0
brk(NULL)                               = 0x5d747350f000
brk(0x5d7473531000)                     = 0x5d7473531000
brk(0x5d7473563000)                     = 0x5d7473563000
mmap(NULL, 1052672, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f75810ff000
munmap(0x7f75810ff000, 1052672)         = 0

Cada línea cuenta una parte de la historia:

El umbral de 128 KiB y estos comportamientos son detalles de glibc, no del lenguaje, y el umbral se ajusta solo según el uso. sbrk está declarada «obsoleta» por POSIX, y aquí solo observa; un programa nunca debe llamarla a la vez que usa malloc, porque las dos moverían el mismo fin del programa. Cómo se piden y se devuelven los bloques, y qué errores se cometen con ellos, es el contenido de los temas 28 y 29.

6. La pila crece bajo demanda

La pila del hilo principal es la región [stack]. Al arrancar el programa es chica; el núcleo la agranda automáticamente cuando el programa toca una dirección por debajo del borde actual, mientras no supere un límite. Este programa llama a una función recursiva, cada nivel con un arreglo local de 64 KiB, y muestra la región [stack] cada cuatro niveles:

pila.c
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <string.h>
#include <sys/resource.h>

/* la línea [stack] del mapa, tal cual */
static void mostrar_pila(int nivel, const void *local)
{
    FILE *f = fopen("/proc/self/maps", "r");
    char linea[512];

    while (f != NULL && fgets(linea, sizeof linea, f) != NULL) {
        if (strstr(linea, "[stack]") != NULL) {
            unsigned long inicio, fin;

            sscanf(linea, "%lx-%lx", &inicio, &fin);
            printf("nivel %2d  local en %p  [stack] %lx-%lx  %5lu KiB\n",
                   nivel, local, inicio, fin, (fin - inicio) / 1024);
        }
    }
    if (f != NULL)
        fclose(f);
}

static void bajar(int nivel)
{
    char relleno[64 * 1024];

    memset(relleno, nivel, sizeof relleno);       /* tocar las páginas */
    if (nivel % 4 == 0)
        mostrar_pila(nivel, relleno);
    if (nivel < 16)
        bajar(nivel + 1);
    relleno[0] += 1;                              /* que la recursión no sea de cola */
    if (relleno[0] == 99)
        puts("");
}

int main(void)
{
    struct rlimit lim;

    getrlimit(RLIMIT_STACK, &lim);
    printf("límite de la pila: %lu KiB\n", (unsigned long) (lim.rlim_cur / 1024));
    bajar(0);
    return 0;
}
terminal
$ ./pila
límite de la pila: 8192 KiB
nivel  0  local en 0x7ffdf2d52ce0  [stack] 7ffdf2d43000-7ffdf2d65000    136 KiB
nivel  4  local en 0x7ffdf2d12c20  [stack] 7ffdf2d12000-7ffdf2d65000    332 KiB
nivel  8  local en 0x7ffdf2cd2b60  [stack] 7ffdf2cd2000-7ffdf2d65000    588 KiB
nivel 12  local en 0x7ffdf2c92aa0  [stack] 7ffdf2c92000-7ffdf2d65000    844 KiB
nivel 16  local en 0x7ffdf2c529e0  [stack] 7ffdf2c52000-7ffdf2d65000   1100 KiB
$ ulimit -s
8192

El borde superior de la región es siempre el mismo, …d65000: es el fondo de la pila, donde el núcleo colocó los argumentos, el entorno y el vector auxiliar al ejecutar el programa. El borde inferior baja con cada nivel de recursión, y la región crece: de 136 a 1100 KiB. Cada cuatro niveles son 256 KiB, cuatro arreglos de 64 KiB, como esperábamos; el primer salto es menor porque parte del primer arreglo cabía en el espacio inicial. Es el crecimiento hacia abajo que vimos en el tema 12, ahora visto desde el mapa.

El mecanismo es el mismo que el de .bss: el programa toca una dirección por debajo de la región, el procesador produce un fallo de página, y el núcleo comprueba que la dirección está cerca del borde y que el tamaño total no supera el límite RLIMIT_STACK, 8192 KiB, el valor de ulimit -s. Si es así, extiende la región y sigue; si no, envía SIGSEGV. Así termina una recursión infinita, o un arreglo local de 10 MiB: no por un error del lenguaje, sino por un límite del sistema, que se puede subir con ulimit -s pero que conviene respetar. Los datos grandes pertenecen al montículo o a las variables globales.

El hilo principal es el único que tiene una pila que crece sola. Cada hilo adicional recibe una región de tamaño fijo (8 MiB por omisión) que se crea con mmap al iniciar el hilo, como veremos en el curso Procesos e Hilos.

7. Las bibliotecas y mmap

En el mapa de la sección 2, las regiones de libc.so.6 y de ld-linux no están pegadas al ejecutable sino lejos, en la zona 7cc2…. Las colocó el cargador dinámico con mmap, y se ve con strace:

terminal
$ strace -e trace=openat,mmap,mprotect,brk ./mapa 2>&1 >/dev/null | head -24
brk(NULL)                               = 0x57b916501000
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x73246627e000
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
mmap(NULL, 21491, PROT_READ, MAP_PRIVATE, 3, 0) = 0x732466278000
openat(AT_FDCWD, "/usr/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
mmap(NULL, 2231696, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x732466000000
mmap(0x732466028000, 1671168, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x28000) = 0x732466028000
mmap(0x7324661c0000, 319488, PROT_READ, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1c0000) = 0x7324661c0000
mmap(0x73246620e000, 24576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x20e000) = 0x73246620e000
mmap(0x732466214000, 52624, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x732466214000
mmap(NULL, 12288, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x732466275000
mprotect(0x73246620e000, 16384, PROT_READ) = 0
mprotect(0x57b913def000, 4096, PROT_READ) = 0
mprotect(0x7324662bb000, 8192, PROT_READ) = 0
brk(NULL)                               = 0x57b916501000
brk(0x57b916522000)                     = 0x57b916522000
mmap(NULL, 1052672, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x732466eff000
openat(AT_FDCWD, "/proc/self/maps", O_RDONLY) = 3

Es la secuencia con la que el cargador (que el núcleo ya había mapeado) carga libc:

  1. Abre la biblioteca con openat, después de consultar la caché /etc/ld.so.cache, que dice dónde está cada una. abrir
  2. Reserva todo el rango con un mmap de 2 231 696 bytes, con el contenido del archivo, en solo lectura. reservar
  3. Sobrescribe partes con permisos distintos, con MAP_FIXED: 1 671 168 bytes en PROT_READ|PROT_EXEC desde el desplazamiento 0x28000 (el código), 319 488 en solo lectura (las constantes), y 24 576 en lectura y escritura (los datos). Son las líneas r--p, r-xp, r--p y rw-p de libc en el mapa. cortar
  4. Agrega memoria anónima con MAP_ANONYMOUS para el .bss de la biblioteca: 52 624 bytes de ceros a continuación de los datos. Es una de las regiones sin nombre del mapa. bss
  5. Protege lo que ya no se tocará, con mprotect(…, PROT_READ): el RELRO de libc, el del propio ejecutable (0x57b913def000, la cuarta línea de la sección 2) y el de otra región más. proteger

Hay algo más para notar: MAP_PRIVATE con un archivo. El contenido de la región es el del archivo, pero las páginas no se copian a la memoria del proceso al mapearlas: se leen de la caché de páginas del núcleo, que tiene una sola copia de libc para todos los procesos. Mientras nadie escriba, el código de libc ocupa memoria física una vez en todo el sistema, sin importar cuántos procesos lo usen. Si un proceso escribe en una página rw-p, el núcleo le hace una copia privada en ese momento (copy on write), y solo ese proceso la ve. Esto explica que el costo real de cada proceso sea mucho menor que su VmSize, como veremos con pmap en la sección 12.

8. El panorama completo

Con los grupos identificados, el espacio de direcciones de un programa cualquiera tiene esta forma, de las direcciones bajas a las altas:

El espacio de direcciones de un proceso, de arriba hacia abajo: el espacio del núcleo, inaccesible, por encima de 0x7fffffffffff; la pila, que crece hacia abajo; las regiones vvar y vdso; un hueco; el cargador ld.so; libc; regiones anónimas de mmap; un gran hueco; el montículo, que crece hacia arriba; un hueco aleatorio; y el ejecutable con cabeceras, código, constantes y datos. Debajo, sin mapear, hasta la dirección cero direcciones altas núcleo · inaccesible (por encima de 0x7fff ffff ffff) [stack] · rw- · crece ↓ · 7ffc… [vvar] r-- · [vdso] r-x hueco ld-linux · 5 regiones · 7cc2… libc.so.6 · 5 regiones + anónimas mmap anónimas · malloc ≥ 128 KiB, pilas de hilos huecos enormes (decenas de TiB) [heap] · rw- · crece ↑ · 63e5… hueco aleatorio (ASLR) el ejecutable · 63e5… .data/.bss rw- · .rodata r-- · .text r-x · cabeceras r-- (5 regiones) direcciones bajas · debajo del ejecutable, sin mapear hasta la dirección 0
El mapa de un programa en Linux x86-64. Las proporciones no están a escala: casi todo el espacio, de 128 TiB, son huecos.

Hay una imagen más antigua, de los libros de los años noventa, con el montículo creciendo hacia arriba y la pila hacia abajo, uno hacia el otro, en el mismo espacio, hasta chocar. Ya no describe un sistema real: en 64 bits están separados por decenas de terabytes, y las bibliotecas y las asignaciones grandes están en el medio. La tabla resume lo que sí vale:

RegiónQuién la creaPermisosCómo creceContiene
EjecutableEl núcleo, al hacer execver--, r-x, rw-No creceCódigo, constantes, datos y .bss.
[heap]El núcleo, vacía; malloc la amplía con brkrw-Hacia arriba, con brkBloques pequeños de malloc.
Anónimas de mmapmalloc o el programaLos que se pidanNo crece; se crea y se destruye enteraBloques grandes, pilas de hilos.
BibliotecasEl cargador dinámico, con mmapr--, r-x, rw-No creceEl código y los datos de libc y las demás.
[stack]El núcleo, al hacer execverw-Hacia abajo, sola, hasta RLIMIT_STACKVariables locales, argumentos y entorno.
[vdso], [vvar]El núcleor-x, r--No creceFunciones y datos que se leen sin entrar al núcleo.

9. vdso, vvar y el vector auxiliar

Al ejecutar un programa, el núcleo le deja en la pila, junto a los argumentos y al entorno, una tercera lista: el vector auxiliar, pares de tipo y valor con información que el programa y el cargador necesitan, como el tamaño de página o dónde está su código. getauxval, de sys/auxv.h, consulta uno por su tipo. Este programa pregunta por seis y averigua en qué región cae cada dirección:

auxv.c
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <sys/auxv.h>

/* en qué región del mapa cae una dirección */
static void donde(const char *que, unsigned long dir)
{
    FILE *f = fopen("/proc/self/maps", "r");
    char linea[512];

    while (f != NULL && fgets(linea, sizeof linea, f) != NULL) {
        unsigned long inicio, fin;
        char permisos[5];
        int fin_campos = 0;

        if (sscanf(linea, "%lx-%lx %4s %*s %*s %*s %n", &inicio, &fin, permisos, &fin_campos) >= 3
            && dir >= inicio && dir < fin) {
            linea[strcspn(linea, "\n")] = '\0';
            printf("%-28s 0x%lx  %s  %s\n", que, dir, permisos,
                   linea[fin_campos] != '\0' ? linea + fin_campos : "(anónima)");
            break;
        }
    }
    if (f != NULL)
        fclose(f);
}

int main(void)
{
    printf("AT_PAGESZ  = %lu\n", getauxval(AT_PAGESZ));
    donde("AT_ENTRY (punto de entrada)", getauxval(AT_ENTRY));
    donde("AT_PHDR (cabeceras ELF)", getauxval(AT_PHDR));
    donde("AT_SYSINFO_EHDR (vdso)", getauxval(AT_SYSINFO_EHDR));
    donde("AT_RANDOM (16 bytes al azar)", getauxval(AT_RANDOM));
    donde("AT_EXECFN (nombre del programa)", getauxval(AT_EXECFN));
    printf("AT_EXECFN apunta a \"%s\"\n", (const char *) getauxval(AT_EXECFN));
    return 0;
}
terminal
$ ./auxv
AT_PAGESZ  = 4096
AT_ENTRY (punto de entrada)  0x6100820df140  r-xp  /home/ana/c/tema27/auxv
AT_PHDR (cabeceras ELF)      0x6100820de040  r--p  /home/ana/c/tema27/auxv
AT_SYSINFO_EHDR (vdso)       0x7ffc22a77000  r-xp  [vdso]
AT_RANDOM (16 bytes al azar) 0x7ffc22a01d39  rw-p  [stack]
AT_EXECFN (nombre del programa) 0x7ffc22a04ff1  rw-p  [stack]
AT_EXECFN apunta a "./auxv"

Las regiones [vdso] y [vvar] no vienen de ningún archivo: las crea el núcleo para cada proceso. El vdso (virtual dynamic shared object) es una pequeña biblioteca compartida con funciones como clock_gettime, gettimeofday y time, y vvar contiene las variables del núcleo que esas funciones leen, como la hora actual. Una llamada al sistema normal cuesta entrar al núcleo y volver; leer la hora es tan frecuente y tan simple que el núcleo publica el dato en una página que el programa puede leer directamente, y la función del vdso lo hace sin salir del programa. Es la razón por la que clock_gettime, que usamos en el tema 23 para medir tiempos, cuesta mucho menos que una llamada al sistema de verdad.

10. ASLR: direcciones distintas en cada ejecución

Hasta ahora todas las direcciones de este tema son de una ejecución. En otra serían distintas: cada vez que se ejecuta un programa, el núcleo elige al azar dónde poner el ejecutable, el montículo, las bibliotecas y la pila. Es la aleatorización del espacio de direcciones (ASLR), una defensa que mencionamos en el tema 15: si un atacante no sabe en qué dirección está el código o la pila, no puede apuntar hacia ella. Se controla con un archivo de /proc, y el comando setarch -R la desactiva para un solo programa:

aslr.c
#include <stdio.h>
#include <stdlib.h>

int global;

int main(void)
{
    int local;
    void *dinamica = malloc(32);

    printf("código %p  global %p  heap %p  libc %p  pila %p\n",
           (void *) main, (void *) &global, dinamica, (void *) printf, (void *) &local);
    free(dinamica);
    return 0;
}
terminal
$ cat /proc/sys/kernel/randomize_va_space
2
$ ./aslr; ./aslr; ./aslr
código 0x5c5d9d1fe199  global 0x5c5d9d201014  heap 0x5c5dd73fe010  libc 0x7dc449e64080  pila 0x7fff32129fdc
código 0x583e50879199  global 0x583e5087c014  heap 0x583e7ae76010  libc 0x70e39c264080  pila 0x7ffd9733118c
código 0x607271a31199  global 0x607271a34014  heap 0x60728e965010  libc 0x7d78e3464080  pila 0x7fffcb842b1c
$ setarch -R ./aslr; setarch -R ./aslr; setarch -R ./aslr
código 0x555555555199  global 0x555555558014  heap 0x555555559010  libc 0x7ffff7c64080  pila 0x7fffffffd80c
código 0x555555555199  global 0x555555558014  heap 0x555555559010  libc 0x7ffff7c64080  pila 0x7fffffffd80c
código 0x555555555199  global 0x555555558014  heap 0x555555559010  libc 0x7ffff7c64080  pila 0x7fffffffd80c

Con el valor 2 (el de la mayoría de las distribuciones), se aleatorizan el ejecutable, el montículo, las bibliotecas, vdso y la pila; con 1, todo menos el montículo; con 0, nada. En las tres ejecuciones normales cada región cae en un lugar distinto, y con setarch -R las tres ejecuciones son idénticas, con las direcciones clásicas 0x555555555199 y 0x7ffff7c64080. Hay dos observaciones importantes:

Para que el ejecutable pueda moverse, tiene que ser un ejecutable independiente de la posición (PIE), como lo es por omisión en Ubuntu: el «Elf file type is DYN» de readelf y las direcciones virtuales que empiezan en 0 en las cabeceras. Con -no-pie el ejecutable se carga siempre en la misma dirección, y se pierde la mitad de la defensa.

11. Permisos y fallos de segmentación

Con el mapa en la mano se entiende qué es un Segmentation fault. El procesador comprueba, en cada acceso, que la dirección pertenezca a una región y que la región permita ese acceso, lectura, escritura o ejecución. Si no, avisa al núcleo, que envía al proceso la señal SIGSEGV. Hay dos causas distintas, y el núcleo informa cuál en el campo si_code de la señal. Este programa instala un manejador para ver la información, y provoca cada fallo según su argumento:

fallos.c
#define _POSIX_C_SOURCE 200809L
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

static void escribir(const char *s)
{
    write(2, s, strlen(s));
}

static void escribir_hex(unsigned long v)
{
    char tmp[16], salida[19] = "0x";
    int n = 0, k = 2;

    do {
        tmp[n++] = "0123456789abcdef"[v & 15];
        v >>= 4;
    } while (v != 0);
    while (n > 0)
        salida[k++] = tmp[--n];
    salida[k] = '\0';
    escribir(salida);
}

static void manejador(int senal, siginfo_t *info, void *contexto)
{
    (void) senal;
    (void) contexto;
    escribir("SIGSEGV en la dirección ");
    escribir_hex((unsigned long) info->si_addr);
    escribir(info->si_code == SEGV_MAPERR ? ": SEGV_MAPERR, no hay ninguna región ahí\n"
             : info->si_code == SEGV_ACCERR ? ": SEGV_ACCERR, la región no permite el acceso\n"
             : ": otra causa\n");
    _exit(1);
}

static const char literal[] = "solo lectura";

int main(int argc, char *argv[])
{
    struct sigaction sa;

    memset(&sa, 0, sizeof sa);
    sa.sa_sigaction = manejador;
    sa.sa_flags = SA_SIGINFO;
    sigaction(SIGSEGV, &sa, NULL);

    if (argc != 2) {
        fprintf(stderr, "uso: %s nulo|hueco|literal|codigo|lectura\n", argv[0]);
        return 2;
    }
    if (strcmp(argv[1], "nulo") == 0) {
        *(volatile int *) 0 = 1;
    } else if (strcmp(argv[1], "hueco") == 0) {
        *(volatile char *) 0x123456789000UL = 1;
    } else if (strcmp(argv[1], "literal") == 0) {
        *(volatile char *) literal = 'X';
    } else if (strcmp(argv[1], "codigo") == 0) {
        *(volatile char *) (void *) main = 0;
    } else if (strcmp(argv[1], "lectura") == 0) {
        printf("leer el código sí se puede: primer byte de main = 0x%02x\n",
               *(volatile unsigned char *) (void *) main);
        return 0;
    }
    printf("no hubo error\n");
    return 0;
}
terminal
$ ./fallos nulo
SIGSEGV en la dirección 0x0: SEGV_MAPERR, no hay ninguna región ahí
$ ./fallos hueco
SIGSEGV en la dirección 0x123456789000: SEGV_MAPERR, no hay ninguna región ahí
$ ./fallos literal
SIGSEGV en la dirección 0x5c43ad3370a8: SEGV_ACCERR, la región no permite el acceso
$ ./fallos codigo
SIGSEGV en la dirección 0x57c872a403dc: SEGV_ACCERR, la región no permite el acceso
$ ./fallos lectura
leer el código sí se puede: primer byte de main = 0xf3
Causasi_codeEjemplos
La dirección no pertenece a ninguna región.SEGV_MAPERRDesreferenciar NULL o un puntero con basura; usar un puntero a memoria liberada con munmap; leer más allá del final de una región; superar el límite de la pila.
La dirección pertenece a una región, pero sin el permiso pedido.SEGV_ACCERREscribir en una cadena literal o en una constante (r--p); escribir en el código (r-xp); ejecutar datos (sin x).

El puntero nulo cae en SEGV_MAPERR porque Linux no mapea nunca las primeras páginas del espacio de direcciones, como dijimos en el tema 15; la dirección arbitraria 0x123456789000, un lugar vacío entre el ejecutable y las bibliotecas, da el mismo resultado. En cambio, la cadena literal y el código sí están mapeados, y escribir en ellos es SEGV_ACCERR. Y leer el primer byte de main funciona: el código es r-x, y esos bytes f3 0f 1e fa son la instrucción endbr64 que vimos en el tema 4.

Cuatro precisiones sobre el manejador, que anticipan el curso Procesos e Hilos: se instala con sigaction y el indicador SA_SIGINFO, que permite recibir el siginfo_t con la dirección en si_addr y la causa en si_code; dentro de un manejador solo se pueden llamar funciones «seguras ante señales», como write y _exit, y no printf, de modo que el programa arma el texto a mano; el manejador termina con _exit, porque volver de una señal SIGSEGV repetiría la instrucción que falló, en un bucle; y un desbordamiento de pila no se puede atender así, porque el manejador necesita pila, y haría falta una pila alternativa con sigaltstack. En la práctica, los programas no capturan SIGSEGV: dejan que termine el proceso y se estudian con gdb o AddressSanitizer, que usa exactamente este mecanismo para señalar el acceso.

12. Herramientas para mirar un proceso

El mapa de cualquier proceso en ejecución se puede mirar sin modificarlo. Las herramientas principales:

HerramientaMuestra
cat /proc/PID/mapsEl mapa crudo, el de este tema. Es el archivo que leen las demás.
pmap -x PIDEl mapa con el tamaño, la memoria residente (RSS) y la modificada (Dirty) de cada región.
/proc/PID/smaps, smaps_rollupMás campos por región (Rss, Pss, Shared_Clean, Private_Dirty…), o su suma.
/proc/PID/statusTotales: VmSize, VmRSS, VmData, VmStk, VmExe, VmLib.
readelf -lW, sizeLos segmentos y secciones del ejecutable, antes de ejecutarlo.
info proc mappings en gdbEl mapa del proceso que se depura, con el tamaño de cada región.
terminal · un proceso dormido: sleep 30 &
$ grep -E '^(VmPeak|VmSize|VmRSS|VmData|VmStk|VmExe|VmLib)' /proc/$!/status
VmPeak:	   16108 kB
VmSize:	   16108 kB
VmRSS:	    7424 kB
VmData:	     288 kB
VmStk:	     136 kB
VmExe:	    4832 kB
VmLib:	    3240 kB
$ pmap -x $!
415:   sleep 30
Address           Kbytes     RSS   Dirty Mode  Mapping
00005d079c43c000     968     968       0 r---- sleep
00005d079c52e000    4832    2108       0 r-x-- sleep
00005d079c9e6000    3836     616       0 r---- sleep
00005d079cda5000    1428     964     964 r---- sleep
00005d079cf0a000      24      12      12 rw--- sleep
00005d079cf10000       4       4       4 rw---   [ anon ]
00005d07d2c58000     132     112     112 rw---   [ anon ]
00007f0bbda00000     160     156       0 r---- libc.so.6
00007f0bbda28000    1632    1296       0 r-x-- libc.so.6
00007f0bbdbc0000     312     184       0 r---- libc.so.6
00007f0bbdc0e000      16      16      16 r---- libc.so.6
00007f0bbdc12000       8       8       8 rw--- libc.so.6
…
00007ffcf6c9d000     136      20      20 rw---   [ stack ]
00007ffcf6d9b000      16       0       0 r----   [ anon ]
00007ffcf6d9f000       8       4       0 r-x--   [ anon ]
---------------- ------- ------- -------
total kB           16108    7508    1228

Con pmap se aprecian las ideas del tema. La columna Kbytes suma el VmSize (16 108 KiB), la memoria que el proceso podría usar. La columna RSS suma 7508 KiB, lo que está en la memoria física ahora, y la mayor parte son páginas de código y de bibliotecas, compartidas con otros procesos. La columna Dirty, apenas 1228 KiB, es lo que este proceso modificó y por lo tanto tiene en copias privadas. Por eso comparar procesos por VmSize no dice nada del consumo real, y comparar por VmRSS cuenta varias veces las páginas compartidas: smaps ofrece Pss, que reparte cada página compartida entre los procesos que la usan. En este pmap el montículo aparece como [ anon ] de 132 KiB, el tamaño inicial típico que vimos en la sección 5 (la reserva de 128 KiB más lo pedido), y el [vdso] y el [vvar] también aparecen como anónimos, pero se reconocen por sus permisos y su posición cerca de la pila.

Con gdb, info proc mappings muestra el mapa del programa que se depura, detenido en el punto que se quiera:

terminal · gdb detenido en main de mapa
$ gdb -q -batch -ex 'break main' -ex run -ex 'info proc mappings' ./mapa
Breakpoint 1, main () at mapa.c:8
8	{
process 540
Mapped address spaces:

Start Addr         End Addr           Size               Offset             Perms File
0x0000555555554000 0x0000555555555000 0x1000             0x0                r--p  /home/ana/c/tema27/mapa
0x0000555555555000 0x0000555555556000 0x1000             0x1000             r-xp  /home/ana/c/tema27/mapa
0x0000555555556000 0x0000555555557000 0x1000             0x2000             r--p  /home/ana/c/tema27/mapa
0x0000555555557000 0x0000555555558000 0x1000             0x2000             r--p  /home/ana/c/tema27/mapa
0x0000555555558000 0x0000555555559000 0x1000             0x3000             rw-p  /home/ana/c/tema27/mapa
0x00007ffff7c00000 0x00007ffff7c28000 0x28000            0x0                r--p  /usr/lib/x86_64-linux-gnu/libc.so.6
0x00007ffff7c28000 0x00007ffff7dc0000 0x198000           0x28000            r-xp  /usr/lib/x86_64-linux-gnu/libc.so.6
…
0x00007ffffffdd000 0x00007ffffffff000 0x22000            0x0                rw-p  [stack]

Las direcciones, 0x555555554000 para el ejecutable y 0x7ffff7c00000 para libc, son las del ASLR desactivado de la sección 10. Aquí todavía no hay [heap], porque el programa está detenido antes del primer malloc: el montículo, vacío al arrancar, no aparece en el mapa hasta que se agrega la primera página. Es el mismo mapa; el depurador solo lo presenta con otras columnas.

13. Errores frecuentes

ErrorQué pasaCorrección
Creer que una dirección es físicaEs virtual: otro proceso puede usar la misma para otros datos.Pensar en términos de regiones de un proceso.
Medir el consumo con VmSizeCuenta toda la memoria mapeada, tocada o no.VmRSS o Pss, según lo que se pregunte.
Imaginar la pila y el montículo creciendo uno hacia el otroEn 64 bits están separados por decenas de TiB.Mirar el mapa del proceso.
Declarar un arreglo local de varios megabytesSupera RLIMIT_STACK: Segmentation fault al entrar a la función.static, global o malloc.
Escribir en una cadena literal o en una constanteSEGV_ACCERR: la región es r--p.Un arreglo propio: char s[] = "…".
Suponer que free devuelve la memoria al sistemamalloc la retiene para reutilizarla; VmRSS no baja.Medir con /proc/self/status, sin suponer.
Usar sbrk junto con mallocLas dos mueven el mismo fin del programa.Solo malloc.
Guardar direcciones entre ejecuciones, o fijarlas en el códigoEl ASLR las cambia en cada ejecución.Guardar desplazamientos, nombres o índices.
Confiar en que las direcciones de gdb son las de una ejecución normalgdb desactiva el ASLR.set disable-randomization off, si hace falta reproducir el caso.
Consultar /proc/self/maps para saber si un puntero es válidoEl mapa cambia entre la consulta y el uso.Diseñar para no tener punteros inválidos; usar AddressSanitizer al depurar.
Esperar ejecutar código desde un arreglo de datosNo hay regiones rwx: SEGV_ACCERR.mmap/mprotect con los permisos justos, si de verdad hace falta.
Esperar que un manejador de SIGSEGV «arregle» el falloVolver repite la instrucción que falló.Terminar con _exit; depurar la causa.

14. Resumen

15. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Por qué dos procesos pueden tener datos distintos en la misma dirección?

Porque las direcciones de un programa son virtuales: el procesador las traduce a memoria física con tablas que el núcleo mantiene por proceso. La misma dirección virtual puede apuntar a páginas físicas distintas en cada uno, y una misma página física, como las instrucciones de libc, puede aparecer en las direcciones de muchos procesos.

2. ¿Qué significa la línea 63e531bbd000-63e531bbe000 r-xp 00001000 08:30 52301 /home/ana/c/tema27/mapa?

Una región de 4 KiB, entre esas dos direcciones, con permisos de lectura y ejecución, privada. Su contenido viene del archivo mapa (nodo 52301 del dispositivo 08:30), a partir del byte 0x1000 del archivo. Es el código del programa, la sección .text.

3. ¿Por qué el archivo ejecutable de un programa con un arreglo global de 100 MiB sin valor inicial ocupa 16 KB?

Porque esas variables van a la sección .bss, que no se guarda en el archivo: el segmento LOAD tiene un MemSiz de 100 MiB y un FileSiz de unos cientos de bytes. El núcleo crea la diferencia como memoria anónima llena de ceros al ejecutar el programa.

4. ¿Qué diferencia hay entre VmSize y VmRSS, y cuál mide el consumo real de memoria?

VmSize es el tamaño del espacio de direcciones mapeado, tocado o no; VmRSS es la memoria física que el proceso tiene en este momento. Se acerca más al consumo real VmRSS, aunque cuenta completas las páginas compartidas con otros procesos; Pss, de smaps, las reparte entre quienes las usan.

5. ¿Qué ocurre la primera vez que se escribe en una página de .bss que nunca se tocó?

El procesador no encuentra la traducción de esa dirección y produce un fallo de página. El núcleo comprueba que pertenece a una región válida, le asigna un marco de memoria física lleno de ceros, actualiza las tablas y repite la instrucción, sin que el programa lo note. Es un fallo menor, porque no hace falta leer ningún disco, y se cuenta en ru_minflt.

6. ¿Por qué un malloc(1 MiB) no mueve el fin del programa, y uno de 100 bytes sí puede hacerlo?

Porque malloc atiende los pedidos grandes, desde 128 KiB por omisión, con una región propia creada con mmap, que devuelve con munmap al hacer free. Los pequeños los toma del montículo, que se amplía con brk cuando no alcanza; y como malloc pide de a bastante más de lo necesario, muchas peticiones seguidas no producen ninguna llamada.

7. ¿Qué pasa con la región [stack] cuando una función recursiva usa cada vez más pila?

El borde inferior baja y la región crece: cuando el programa toca una dirección por debajo del borde, el fallo de página hace que el núcleo la extienda, mientras no se supere RLIMIT_STACK (8 MiB por omisión). Pasado el límite, el núcleo envía SIGSEGV y el programa termina.

8. ¿Por qué libc aparece en cinco regiones del mapa y no en una?

Porque sus segmentos tienen permisos distintos: cabeceras de solo lectura, código r-x, constantes r--, una parte de datos que se vuelve de solo lectura después de enlazar (RELRO) y los datos rw-. El cargador reserva todo con un mmap y lo reparte en partes con MAP_FIXED y mprotect. Además hay regiones anónimas para su .bss.

9. ¿Para qué sirven [vdso] y [vvar]?

Son regiones que el núcleo agrega a todo proceso. El vdso es una pequeña biblioteca con funciones como clock_gettime, y vvar contiene los datos del núcleo que esas funciones leen, como la hora. Así, consultar la hora no requiere una llamada al sistema, que cuesta entrar al núcleo y volver.

10. ¿Qué cambia entre una ejecución y otra con el ASLR activado, y qué no cambia?

Cambia la dirección base de cada región: el ejecutable, el montículo, las bibliotecas y la pila se colocan en lugares distintos. No cambia la disposición dentro de cada región: la distancia entre main y una variable global es la misma, y los 12 bits bajos de una dirección se repiten. Por eso la filtración de una dirección revela todas las de su región.

11. ¿Cuál es la diferencia entre SEGV_MAPERR y SEGV_ACCERR?

SEGV_MAPERR es acceder a una dirección que no pertenece a ninguna región, como un puntero nulo o una dirección inventada. SEGV_ACCERR es acceder a una dirección que sí pertenece a una región, pero sin el permiso que el acceso exige, como escribir en una cadena literal (r--p) o en el código (r-xp).

12. ¿Por qué un manejador de SIGSEGV no puede simplemente volver y seguir?

Porque al volver se repite la instrucción que falló, con la misma dirección y los mismos permisos, y falla otra vez, en un bucle infinito. Por eso el manejador de fallos.c termina con _exit. Y dentro de un manejador solo se pueden llamar funciones seguras ante señales, como write.

13. ¿Por qué después de free la memoria del proceso (VmRSS) no siempre baja?

Porque malloc conserva la memoria obtenida con brk para atender pedidos futuros, y solo la devuelve al sistema en ciertas condiciones. Los bloques grandes, obtenidos con mmap, sí se devuelven con munmap. Que un bloque quede libre para malloc no es lo mismo que quede libre para el sistema.

Para practicar

  1. Ejecuta mapa y calcula con una calculadora el tamaño de cada región a partir de su rango. Comprueba que el [heap] mide 0x21 páginas, 132 KiB, y que la región del malloc(1 MiB) mide una página más de 1 MiB.
  2. Agrega a mapa.c una variable static const char texto[] = "hola" y una función auxiliar, e indica en qué línea del mapa caen sus direcciones. Compila además con -no-pie y repite: ¿qué cambia en las direcciones del ejecutable?
  3. Modifica bss.c para usar un arreglo de malloc(64 MiB) en lugar de uno global, y compara los fallos de página y la VmRSS en cada paso. ¿Cambia la conducta? Repite con calloc y explica por qué es equivalente a malloc más memset solo en apariencia.
  4. Escribe un programa que cree un arreglo local de 10 MiB y lo use. Captura el SIGSEGV con una pila alternativa (sigaltstack, man 2 sigaltstack) y muestra si_code y si_addr; después ejecútalo con ulimit -s 16384.
  5. Ejecuta heap con strace -e trace=brk,mmap,munmap y cambia el tamaño de los pedidos para encontrar el umbral exacto de mmap. Pide después y libera bloques de 200 KiB varias veces y observa si el umbral cambia (man 3 mallopt, M_MMAP_THRESHOLD).
  6. Escribe un programa que cree una región con mmap de una página, con PROT_NONE, y haz que intente leerla. Cambia los permisos con mprotect a PROT_READ y luego PROT_READ|PROT_WRITE, y observa el mapa en cada paso.
  7. Con cat /proc/PID/maps, pmap -x y smaps_rollup, compara un proceso de bash y uno de sleep: ¿cuánta memoria comparten? ¿Qué procesos del sistema tienen la mayor diferencia entre VmSize y VmRSS?
  8. Ejecuta ./aslr cien veces y guarda las direcciones de la pila. Calcula cuántos bits cambian, comparando el mínimo y el máximo: ¿cuánta aleatoriedad tiene cada región?