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:
#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;
}
$ 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:
| Campo | Ejemplo | Significado |
|---|---|---|
| Rango | 63e531bbd000-63e531bbe000 | Dirección de inicio, incluida, y de fin, excluida. Siempre múltiplos de 4096: esta región mide 4 KiB. |
| Permisos | r-xp | r 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. |
| Desplazamiento | 00001000 | Posición, dentro del archivo, desde donde se tomó el contenido. Cero para la memoria anónima. |
| Dispositivo | 08:30 | Número del dispositivo donde está el archivo, en la forma principal:secundario. 00:00 si no hay archivo. |
| Nodo | 52301 | El número de nodo del archivo, el mismo que muestra ls -i. Cero para memoria anónima. |
| Nombre | /home/ana/c/tema27/mapa | El 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:
- El ejecutable, en cinco líneas (direcciones
63e5…): una regiónr--pcon las cabeceras y las tablas, otrar-xpcon el código, otrar--pcon las constantes, otrar--pde punteros que se vuelven de solo lectura al arrancar, y unarw-pcon las variables globales. Allí estánmain(…bd249, en lar-xp) y las dos globales (…c010y…c024, en larw-p). [heap](63e545920000): el montículo, a una distancia aleatoria del ejecutable. Allí cayómalloc(100).- Una región anónima de 1 MiB más una página (
7cc2e08ff000-7cc2e0a00000): contiene elmalloc(1 MiB), en la dirección 16 bytes después de su inicio, donde termina la cabecera quemallocpone delante de cada bloque. - Las bibliotecas (
7cc2…):libc.so.6y el cargadorld-linux-x86-64.so.2, cada una con sus cinco regiones, más algunas regiones anónimas sueltas. [stack](7ffc88a39000): la pila, 136 KiB, la más alta.[vvar]y[vdso]: dos regiones que el núcleo agrega a todo proceso (sección 9).
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:
$ 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:
| Secciones | Segmento LOAD | Región en el mapa | Contenido |
|---|---|---|---|
.interp, .dynsym, .rela.dyn… | 1 · R | r--p, desplazamiento 0 | Las cabeceras, el nombre del cargador y las tablas de símbolos dinámicos. |
.init, .plt, .text, .fini | 2 · R E | r-xp | El código: las funciones del programa. |
.rodata, .eh_frame | 3 · R | r--p | Constantes y cadenas literales. |
.init_array, .dynamic, .got | 4 · RW, parte «RELRO» | r--p (después de arrancar) | Punteros de arranque y la tabla de saltos a funciones de bibliotecas. |
.data | 4 · RW | rw-p | Variables globales con valor inicial. |
.bss | 4 · RW, más allá de FileSiz | rw-p, al final | Variables globales sin valor inicial. |
Tres detalles de las cabeceras explican detalles del mapa:
INTERPes el intérprete. El ejecutable pide ser cargado por/lib64/ld-linux-x86-64.so.2, el cargador dinámico. El núcleo mapea el programa y el cargador, y entrega el control a este último, que busca, mapea y enlaza las bibliotecas (sección 7). Por esold-linuxaparece en el mapa.GNU_RELROdivide la última región en dos. El segmentoRWnecesita ser escribible mientras el cargador completa las direcciones de los saltos; terminado ese trabajo, el cargador llama amprotecty deja en solo lectura la parte que lo permite, 4096 bytes desde la dirección…bf000. De ahí sale la cuarta línea del mapa,r--pcon el mismo desplazamiento que la anterior, y es la protección RELRO que mencionamos en el tema 20 a propósito de la tabla de despacho.GNU_STACKcon permisosRW: la pila no es ejecutable. Si dijeraRWE, la región[stack]aparecería conrwx.
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: 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];
}
$ 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:
#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;
}
$ ./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:
VmSizees el tamaño del espacio de direcciones: la suma de todas las regiones del mapa. Desde el primer instante vale unos 66 MiB, porque los 64 MiB del arreglo ya están mapeados. No es memoria consumida.VmRSS(resident set size) es la memoria física que el proceso usa en este momento: las páginas que tienen un marco real. Arranca en 1664 KiB, el costo del programa y delibc, y sube a medida que se tocan páginas.
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:
brkmueve el «fin del programa» (program break), el final de una región contigua que empieza detrás del ejecutable: es la región[heap]. Para agrandarla,mallocllama abrkcon una dirección mayor.sbrk(0)devuelve el valor actual.mmapcrea una región nueva independiente, en cualquier lugar libre del espacio de direcciones.mallocla usa para los pedidos grandes: más de 128 KiB por omisión.
Este programa pide cuatro bloques y mira, después de cada uno, el fin del programa y la línea [heap] del mapa:
#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;
}
$ ./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:
- «Al empezar», el fin del programa vale
…36000, igual que el inicio de la región. Al arrancar el programa, el montículo está vacío: tiene tamaño cero. La línea[heap]que se ve a continuación ya existe porque la propia funciónheapabrió un archivo confopen, que pidió memoria. - La primera petición hizo crecer el montículo hasta
…58000: 136 KiB, 0x22000 bytes.mallocpide al núcleo bastante más de lo necesario, 128 KiB de reserva adicional redondeados a páginas, para no tener que llamar abrken cada petición. Se ve enstracecomo el primerbrk(0x…531000). - Los 100 KiB siguientes cupieron dentro de lo ya reservado, sin tocar
brk. Los otros 100 KiB ya no cupieron, ymallocvolvió a ampliar con otrobrk, de 200 KiB, hasta…8a000. - El bloque de 1 MiB no fue al montículo: su dirección,
0x7ac28fa83010, está en la zona de las bibliotecas, ybrkno se movió.mallocusómmapcon memoria anónima, 1 052 672 bytes, que es 1 MiB más una página para la cabecera, y es la región de 1 MiB del mapa de la sección 2. Y al llamar afree,mallocla devolvió conmunmap, que la quita del espacio de direcciones. - Después de los
free, el montículo no se achicó.mallocretiene la memoria debrkpara reutilizarla, y solo la devuelve en ciertas condiciones. Quefreelibere un bloque no significa que el proceso le devuelva esa memoria al sistema.
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:
#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;
}
$ ./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:
$ 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:
- Abre la biblioteca con
openat, después de consultar la caché/etc/ld.so.cache, que dice dónde está cada una. abrir - Reserva todo el rango con un
mmapde 2 231 696 bytes, con el contenido del archivo, en solo lectura. reservar - Sobrescribe partes con permisos distintos, con
MAP_FIXED: 1 671 168 bytes enPROT_READ|PROT_EXECdesde 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íneasr--p,r-xp,r--pyrw-pdelibcen el mapa. cortar - Agrega memoria anónima con
MAP_ANONYMOUSpara el.bssde la biblioteca: 52 624 bytes de ceros a continuación de los datos. Es una de las regiones sin nombre del mapa. bss - Protege lo que ya no se tocará, con
mprotect(…, PROT_READ): el RELRO delibc, 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:
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ón | Quién la crea | Permisos | Cómo crece | Contiene |
|---|---|---|---|---|
| Ejecutable | El núcleo, al hacer execve | r--, r-x, rw- | No crece | Código, constantes, datos y .bss. |
[heap] | El núcleo, vacía; malloc la amplía con brk | rw- | Hacia arriba, con brk | Bloques pequeños de malloc. |
Anónimas de mmap | malloc o el programa | Los que se pidan | No crece; se crea y se destruye entera | Bloques grandes, pilas de hilos. |
| Bibliotecas | El cargador dinámico, con mmap | r--, r-x, rw- | No crece | El código y los datos de libc y las demás. |
[stack] | El núcleo, al hacer execve | rw- | Hacia abajo, sola, hasta RLIMIT_STACK | Variables locales, argumentos y entorno. |
[vdso], [vvar] | El núcleo | r-x, r-- | No crece | Funciones 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:
#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;
}
$ ./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"
AT_ENTRYes el punto de entrada, donde empieza a ejecutarse el programa: la función_start, en la regiónr-xpdel ejecutable, nomain. Es elEntry pointque mostróreadelf, más la base de carga._startprepara los argumentos y llama amain, como mencionamos en el tema 3.AT_RANDOMapunta a 16 bytes aleatorios en la pila, de donde se toman el canario del protector de pila (tema 4 y tema 12) y otras defensas.AT_EXECFNapunta al nombre con que se ejecutó el programa, que el núcleo copió en la pila.AT_SYSINFO_EHDRes la dirección de la región[vdso].
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:
#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;
}
$ 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:
- Lo que se mueve son las regiones enteras, no lo que hay adentro. La distancia de
mainaglobales 0x2e7b en las tres ejecuciones, y las direcciones dentro de cada región terminan siempre igual (…199,…014,…080): el desplazamiento no cambia, solo la base, y las bases son múltiplos de 4096. Si un programa filtra una sola dirección, como el relleno de las estructuras del tema 25, el atacante calcula todas las demás de esa región, y por eso las fugas de punteros son graves aunque parezcan inofensivas. gdbdesactiva el ASLR al lanzar un programa, para que las direcciones se repitan entre sesiones. Por eso las direcciones degdben este curso empiezan en0x5555…y0x7fff…, y por eso no coinciden con las de una ejecución normal. Un programa no debe suponer direcciones fijas ni guardarlas entre ejecuciones.
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:
#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;
}
$ ./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
| Causa | si_code | Ejemplos |
|---|---|---|
| La dirección no pertenece a ninguna región. | SEGV_MAPERR | Desreferenciar 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_ACCERR | Escribir 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:
| Herramienta | Muestra |
|---|---|
cat /proc/PID/maps | El mapa crudo, el de este tema. Es el archivo que leen las demás. |
pmap -x PID | El mapa con el tamaño, la memoria residente (RSS) y la modificada (Dirty) de cada región. |
/proc/PID/smaps, smaps_rollup | Más campos por región (Rss, Pss, Shared_Clean, Private_Dirty…), o su suma. |
/proc/PID/status | Totales: VmSize, VmRSS, VmData, VmStk, VmExe, VmLib. |
readelf -lW, size | Los segmentos y secciones del ejecutable, antes de ejecutarlo. |
info proc mappings en gdb | El mapa del proceso que se depura, con el tamaño de cada región. |
$ 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:
$ 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
| Error | Qué pasa | Corrección |
|---|---|---|
| Creer que una dirección es física | Es virtual: otro proceso puede usar la misma para otros datos. | Pensar en términos de regiones de un proceso. |
Medir el consumo con VmSize | Cuenta 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 otro | En 64 bits están separados por decenas de TiB. | Mirar el mapa del proceso. |
| Declarar un arreglo local de varios megabytes | Supera RLIMIT_STACK: Segmentation fault al entrar a la función. | static, global o malloc. |
| Escribir en una cadena literal o en una constante | SEGV_ACCERR: la región es r--p. | Un arreglo propio: char s[] = "…". |
Suponer que free devuelve la memoria al sistema | malloc la retiene para reutilizarla; VmRSS no baja. | Medir con /proc/self/status, sin suponer. |
Usar sbrk junto con malloc | Las dos mueven el mismo fin del programa. | Solo malloc. |
| Guardar direcciones entre ejecuciones, o fijarlas en el código | El 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 normal | gdb desactiva el ASLR. | set disable-randomization off, si hace falta reproducir el caso. |
Consultar /proc/self/maps para saber si un puntero es válido | El 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 datos | No hay regiones rwx: SEGV_ACCERR. | mmap/mprotect con los permisos justos, si de verdad hace falta. |
Esperar que un manejador de SIGSEGV «arregle» el fallo | Volver repite la instrucción que falló. | Terminar con _exit; depurar la causa. |
14. Resumen
- Las direcciones de un programa son virtuales: el procesador las traduce a memoria física por páginas de 4096 bytes, y cada proceso tiene su espacio propio. En x86-64, los programas usan hasta
0x7fffffffffff. - El espacio está formado por regiones de páginas con los mismos permisos.
/proc/PID/mapslas lista, una por línea, con rango, permisos (rwxypos), desplazamiento, dispositivo, nodo y nombre. - El núcleo arma el mapa a partir de las cabeceras
LOADdel ejecutable: cuatro segmentos (R,R E,R,RW) que agrupan las secciones.GNU_RELROdeja parte del último en solo lectura, yINTERPpide al cargador dinámico. - Ninguna región es a la vez escribible y ejecutable (W^X). La pila no es ejecutable (
GNU_STACK). .bssno ocupa lugar en el archivo:MemSizsupera aFileSiz, y el núcleo crea la diferencia con memoria anónima de ceros. Un arreglo global de 100 MiB sin valor deja un ejecutable de 16 KB.- La memoria se entrega por demanda:
VmSizees el espacio mapeado;VmRSS, la memoria física realmente usada. La primera escritura en cada página produce un fallo de página menor. [heap]nace vacío y crece conbrk;mallocatiende conmmaplos pedidos grandes (desde 128 KiB por omisión) y los devuelve conmunmap.freeno implica que el proceso devuelva la memoria al sistema.[stack]crece hacia abajo por sí sola, por fallos de página, hastaRLIMIT_STACK(8 MiB por omisión); pasado el límite,SIGSEGV.- El cargador dinámico mapea las bibliotecas con
mmap, en cuatro o cinco regiones cada una, y las protege conmprotect. Las páginas de solo lectura se comparten entre procesos; las escritas se copian al escribir (copy on write). - El núcleo agrega
[vdso], con funciones comoclock_gettimeque no entran al núcleo, y[vvar], con sus datos. El vector auxiliar, congetauxval, informa el punto de entrada, las cabeceras, el tamaño de página y 16 bytes al azar. - El ASLR mueve las regiones a una base distinta en cada ejecución, sin cambiar los desplazamientos internos.
setarch -Rygdblo desactivan. SEGV_MAPERRes acceder a una dirección sin región;SEGV_ACCERR, a una región sin el permiso.pmap,smaps,/proc/PID/statusygdbpermiten mirar el mapa de un proceso.
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
- Ejecuta
mapay 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 delmalloc(1 MiB)mide una página más de 1 MiB. - Agrega a
mapa.cuna variablestatic 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-piey repite: ¿qué cambia en las direcciones del ejecutable? - Modifica
bss.cpara usar un arreglo demalloc(64 MiB)en lugar de uno global, y compara los fallos de página y laVmRSSen cada paso. ¿Cambia la conducta? Repite concallocy explica por qué es equivalente amallocmásmemsetsolo en apariencia. - Escribe un programa que cree un arreglo local de 10 MiB y lo use. Captura el
SIGSEGVcon una pila alternativa (sigaltstack,man 2 sigaltstack) y muestrasi_codeysi_addr; después ejecútalo conulimit -s 16384. - Ejecuta
heapconstrace -e trace=brk,mmap,munmapy cambia el tamaño de los pedidos para encontrar el umbral exacto demmap. Pide después y libera bloques de 200 KiB varias veces y observa si el umbral cambia (man 3 mallopt,M_MMAP_THRESHOLD). - Escribe un programa que cree una región con
mmapde una página, conPROT_NONE, y haz que intente leerla. Cambia los permisos conmprotectaPROT_READy luegoPROT_READ|PROT_WRITE, y observa el mapa en cada paso. - Con
cat /proc/PID/maps,pmap -xysmaps_rollup, compara un proceso debashy uno desleep: ¿cuánta memoria comparten? ¿Qué procesos del sistema tienen la mayor diferencia entreVmSizeyVmRSS? - Ejecuta
./aslrcien 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?