1. Dos clases de direcciones
El programa ilusion.c del tema 2 dejó planteado el hecho central de este tema. Dos procesos imprimían la dirección de una variable, 0x404044, y era la misma en ambos; sin embargo, cada uno guardaba allí un valor distinto. Dijimos entonces que esa dirección no era una posición real de la memoria. Ahora podemos decir qué es.
En una computadora con sistema operativo hay dos clases de direcciones:
| Dirección virtual | Dirección física | |
|---|---|---|
| Es | Un número con el que un proceso nombra un byte de su memoria | La posición de un byte en los circuitos de memoria del equipo |
| La usan | Los programas. Todo puntero de C es una dirección virtual | El hardware, y el kernel para administrarlo |
| Su significado | Depende del proceso: el mismo número designa cosas distintas en cada uno | Es único en toda la máquina |
| Rango, en x86-64 | 128 TB por proceso | La memoria instalada: unos GB |
La memoria virtual es la técnica por la que cada proceso trabaja con un espacio de direcciones propio, y cada acceso se traduce, en el momento, a una posición de la memoria física. La traducción la hace el procesador, con unas tablas que mantiene el kernel.
Un programa no ve nunca una dirección física. No tiene manera de obtenerla ni de usarla. Lo que printf("%p") muestra, lo que gdb informa y lo que aparece en un mensaje de dmesg son siempre direcciones virtuales.
El mecanismo responde a tres necesidades que una memoria única no puede satisfacer: que los procesos no se vean entre sí, que cada uno disponga de una memoria amplia y continua sin importar cómo esté ocupada la real, y que el conjunto pueda usar más memoria de la que hay instalada.
2. Páginas y marcos
Traducir cada dirección por separado requeriría una tabla tan grande como la memoria misma. La traducción se hace por bloques. El espacio de direcciones de cada proceso se divide en páginas, y la memoria física, en marcos del mismo tamaño: 4096 bytes en la mayoría de los equipos, el valor que informaba getconf PAGESIZE en el tema 15.
Para cada proceso, el kernel mantiene una tabla de páginas: la lista de qué marco corresponde a cada una de sus páginas, si es que le corresponde alguno.
La figura muestra las tres propiedades del esquema:
- El orden no importa. Páginas contiguas de un proceso pueden estar en marcos dispersos. El proceso ve su memoria seguida; la física puede estar repartida de cualquier manera.
- Una página puede no tener marco. La tabla lo indica, y un acceso a ella produce un fallo de página.
- Un marco puede estar en varias tablas. Los dos procesos usan la misma copia física de la biblioteca.
3. La traducción
La traducción ocurre en cada acceso a memoria: cada instrucción captada, cada dato leído o escrito. La realiza un circuito del procesador, la unidad de gestión de memoria o MMU, sin intervención del kernel. Con páginas de 4096 bytes, que son 212, una dirección se parte en dos:
dirección virtual 0x404044
│ │
│ └── 0x044 desplazamiento dentro de la página (12 bits)
└────── 0x404 número de página
tabla de páginas del proceso 4211: página 0x404 → marco 0x1a7f3
tabla de páginas del proceso 4212: página 0x404 → marco 0x09c21
dirección física en el proceso 4211: 0x1a7f3044
dirección física en el proceso 4212: 0x09c21044
El número de página se busca en la tabla y se reemplaza por el número de marco; el desplazamiento pasa sin cambios. Los números de marco del ejemplo son inventados, pero el resultado es el del tema 2: la misma dirección virtual lleva a dos lugares distintos, porque cada proceso tiene su tabla. Al cambiar de proceso, el kernel carga en el registro cr3 la dirección de la tabla del que entra, como vimos en el tema 19, y desde ese instante todas las direcciones significan otra cosa.
Cada entrada de la tabla contiene, además del número de marco, unas marcas que la MMU comprueba en cada acceso. Son las que ya encontramos por sus efectos:
| Marca | Indica | Si el acceso la viola |
|---|---|---|
| Presente | Si la página tiene un marco asignado. | Fallo de página. El kernel decide si lo resuelve o envía SIGSEGV, como vimos en el tema 18. |
| Escritura | Si se puede escribir en ella. | |
| Usuario | Si es accesible en modo usuario, o solo en modo kernel. | |
| No ejecutable | Si su contenido puede ejecutarse como código. |
Las tres primeras marcas corresponden, una a una, a los tres bits del código de error que aprendimos a leer en dmesg. La tercera es la que protege la memoria del kernel, según el tema 12. Y la cuarta impide un tipo de ataque: que un programa sea engañado para ejecutar como código unos datos que recibió.
Quedan dos detalles de implementación, que conviene conocer de nombre:
- La tabla tiene varios niveles. Una tabla plana para 128 TB ocuparía cientos de gigabytes por proceso. Se la organiza como un árbol de cuatro niveles, del que solo existen las ramas que corresponden a memoria en uso.
- Hay una caché de traducciones. Recorrer cuatro niveles en cada acceso sería muy lento. El procesador guarda las traducciones recientes en una memoria pequeña y rapidísima, la TLB. Es la caché que mencionamos en el tema 19 como parte del costo de un cambio de contexto: al cambiar de tablas, sus entradas dejan de valer.
4. Lo que la memoria virtual hace posible
Una vez que existe una tabla intermedia entre lo que el programa pide y lo que la memoria tiene, el kernel puede hacer con ella una serie de cosas. Casi todas aparecieron ya en el curso; esta es la lista completa:
| Capacidad | Cómo se logra | Tema |
|---|---|---|
| Aislamiento | La tabla de un proceso no contiene los marcos de otro. No puede nombrarlos. | 2 |
| Protección del kernel | Sus páginas están en todas las tablas, marcadas como inaccesibles en modo usuario. | 12 |
| Entrega a pedido | Las páginas se dejan sin marco hasta el primer acceso. | 18 |
| Carga a pedido | Las páginas de un ejecutable se traen del disco solo si se usan. | 9 |
| Compartir | El mismo marco figura en varias tablas. | Sección 11 |
| Copia al escribir | Tras fork, padre e hijo comparten marcos marcados como de solo lectura. | 26 |
| Intercambio | El contenido de un marco se guarda en el disco y la página se marca como no presente. | 7 |
| Detección de errores | La dirección 0 y otras zonas se dejan siempre sin asignar. | 18 |
Todas son variaciones de dos operaciones: decidir qué marco pone el kernel en cada entrada, y qué marcas le pone. El resto de este tema mira el resultado desde el lado del proceso.
5. El espacio de direcciones, a la vista
En el tema 9 vimos cómo el kernel arma la memoria de un proceso al cargarlo, y en el tema 11, que ocupa la mitad baja del espacio de direcciones. El siguiente programa permite comprobarlo desde adentro. Tiene un dato en cada zona, imprime la dirección de cada uno y después muestra su propio mapa de memoria.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int con_valor = 42; /* global con valor inicial */
int sin_valor; /* global sin valor inicial */
int main(void)
{
int local = 0;
const char *literal = "texto constante";
char *chico = malloc(100);
char *grande = malloc(1024 * 1024);
char comando[64];
printf("codigo (main) %p\n", (void *)main);
printf("constante (literal) %p\n", (void *)literal);
printf("global con valor %p\n", (void *)&con_valor);
printf("global sin valor %p\n", (void *)&sin_valor);
printf("malloc de 100 bytes %p\n", (void *)chico);
printf("malloc de 1 MB %p\n", (void *)grande);
printf("biblioteca (printf) %p\n", (void *)printf);
printf("pila (local) %p\n", (void *)&local);
fflush(stdout);
/* mostrar el mapa de memoria de este mismo proceso */
snprintf(comando, sizeof(comando), "cat /proc/%d/maps", (int)getpid());
if (system(comando) == -1)
perror("system");
free(chico);
free(grande);
return 0;
}
La función system ejecuta un comando en un proceso hijo y espera a que termine: es el fork, exec y wait del tema 26, empaquetado. El fflush previo evita el problema del búfer duplicado que vimos allí.
$ gcc -Wall -Wextra -g mapa.c -o mapa
$ ./mapa
codigo (main) 0x5b3a1c2f51a9
constante (literal) 0x5b3a1c2f6004
global con valor 0x5b3a1c2f8010
global sin valor 0x5b3a1c2f8018
malloc de 100 bytes 0x5b3a2d0a12a0
malloc de 1 MB 0x7c41e8cff010
biblioteca (printf) 0x7c41e8e60100
pila (local) 0x7ffd4a1b3c2c
5b3a1c2f4000-5b3a1c2f5000 r--p 00000000 103:02 1840112 /home/diego/so/mapa
5b3a1c2f5000-5b3a1c2f6000 r-xp 00001000 103:02 1840112 /home/diego/so/mapa
5b3a1c2f6000-5b3a1c2f7000 r--p 00002000 103:02 1840112 /home/diego/so/mapa
5b3a1c2f7000-5b3a1c2f8000 r--p 00002000 103:02 1840112 /home/diego/so/mapa
5b3a1c2f8000-5b3a1c2f9000 rw-p 00003000 103:02 1840112 /home/diego/so/mapa
5b3a2d0a1000-5b3a2d0c2000 rw-p 00000000 00:00 0 [heap]
7c41e8cff000-7c41e8e00000 rw-p 00000000 00:00 0
7c41e8e00000-7c41e8e28000 r--p 00000000 103:02 1315020 /usr/lib/x86_64-linux-gnu/libc.so.6
7c41e8e28000-7c41e8fb0000 r-xp 00028000 103:02 1315020 /usr/lib/x86_64-linux-gnu/libc.so.6
…
7ffd4a194000-7ffd4a1b5000 rw-p 00000000 00:00 0 [stack]
7ffd4a1f2000-7ffd4a1f4000 r-xp 00000000 00:00 0 [vdso]
Cada dirección impresa cae dentro de una línea del mapa, y la línea explica por qué ese dato se comporta como se comporta:
| Dato | Región | Permisos | Consecuencia |
|---|---|---|---|
La función main | Código del ejecutable | r-x | Se puede ejecutar; no se puede modificar. |
| La cadena literal | Constantes del ejecutable | r-- | Escribir en ella produce el fallo de segmentación con código 7 del tema 18. |
| Las dos variables globales | Datos del ejecutable | rw- | Se leen y se escriben. Existen durante toda la ejecución. |
| El bloque de 100 bytes | [heap] | rw- | Memoria pedida en ejecución. |
| El bloque de 1 MB | Una región sin nombre | rw- | También pedida en ejecución, pero aparte. Lo explicamos en la sección 9. |
La función printf | Código de libc.so.6 | r-x | Es código de otro archivo, mapeado en este proceso. |
| La variable local | [stack] | rw- | Existe solo mientras se ejecuta la función. |
Las direcciones muestran además la disposición general. El ejecutable y el heap están en la zona de 0x5b…; las bibliotecas, muy lejos, en 0x7c…; la pila, casi en el límite superior, en 0x7ffd…. Entre esas zonas hay extensiones enormes sin asignar: entre el heap y las bibliotecas caben unos 33 TB. Un espacio de direcciones es, sobre todo, vacío. Y gracias a la tabla de varios niveles, ese vacío no ocupa nada.
6. Leer /proc/PID/maps
El archivo maps lista las regiones del espacio de direcciones de un proceso: los tramos contiguos de páginas con los mismos permisos y el mismo origen. Cada línea tiene seis campos:
7c41e8e28000-7c41e8fb0000 r-xp 00028000 103:02 1315020 /usr/lib/x86_64-linux-gnu/libc.so.6
│ │ │ │ │ │
│ │ │ │ │ └─ archivo de origen, o un nombre entre corchetes
│ │ │ │ └─ número de inodo del archivo
│ │ │ └─ dispositivo donde está el archivo
│ │ └─ posición dentro del archivo donde empieza la región
│ └─ permisos
└─ dirección inicial y final
La región del ejemplo empieza en la posición 0x28000 del archivo de la biblioteca: es la parte que contiene su código. Los permisos son cuatro caracteres:
| Carácter | Significado |
|---|---|
r, w, x | Se puede leer, escribir, ejecutar. Un guion indica que no. |
p | Privada: lo que el proceso escriba no llega al archivo ni a otros procesos. |
s | Compartida: las escrituras son visibles para los demás procesos que la tengan mapeada. |
La última columna distingue dos clases de regiones. Las que tienen un archivo detrás obtienen su contenido de él; las que no lo tienen, llamadas anónimas, empiezan llenas de ceros. Algunas llevan un nombre entre corchetes:
| Nombre | Contenido |
|---|---|
[heap] | La zona de la que malloc toma los bloques pequeños. |
[stack] | La pila del hilo principal. |
[vdso] | El código que el kernel coloca en cada proceso para responder consultas sin entrar al kernel, del tema 13. |
| Sin nombre | Memoria anónima: bloques grandes de malloc, pilas de otros hilos, datos de las bibliotecas. |
Que una región aparezca en el mapa no significa que ocupe memoria física. El mapa describe lo que el proceso tiene permitido usar. Es contra esta lista que el kernel compara la dirección cuando llega un fallo de página: si pertenece a una región, asigna el marco y continúa; si no pertenece a ninguna, envía SIGSEGV. Es la decisión que describimos en el tema 18, y este archivo es la tabla con que se toma.
El comando pmap presenta la misma información en forma más legible, con el tamaño de cada región.
7. Direcciones al azar
Si se ejecuta mapa varias veces, las direcciones cambian en cada una:
$ ./mapa | head -1; ./mapa | head -1; ./mapa | head -1
codigo (main) 0x5b3a1c2f51a9
codigo (main) 0x61d0e84a71a9
codigo (main) 0x55f2b9c131a9
$ cat /proc/sys/kernel/randomize_va_space
2
Es deliberado. La técnica se llama ASLR, de Address Space Layout Randomization: en cada ejecución, el kernel elige al azar dónde ubicar el ejecutable, el heap, las bibliotecas y la pila. El valor 2 del archivo indica que está activada por completo.
Es una medida de seguridad. Muchos ataques consisten en lograr que un programa defectuoso salte a una dirección elegida por el atacante: una función de la biblioteca, por ejemplo. Si las direcciones cambian en cada ejecución, el atacante no sabe adónde apuntar. No corrige el defecto, pero vuelve mucho más difícil aprovecharlo.
Dos detalles de la salida merecen atención. Los tres últimos dígitos, 1a9, no cambian nunca: el azar se aplica por páginas, y el desplazamiento dentro de la página es parte del programa. Y las direcciones cambian en bloque: la distancia entre main y las variables globales es siempre la misma, porque el ejecutable se desplaza entero.
La aleatoriedad se puede desactivar para una ejecución, con el comando setarch:
$ setarch -R ./mapa | head -1; setarch -R ./mapa | head -1
codigo (main) 0x5555555551a9
codigo (main) 0x5555555551a9
Esa dirección que empieza con una fila de cincos resulta conocida: es la que mostraba gdb en el tema 18. Los depuradores desactivan la aleatoriedad para que una sesión sea repetible.
Con esto queda saldada una cuenta del tema 2. Para que un ejecutable pueda cargarse en cualquier dirección, su código no debe contener direcciones fijas; se lo llama independiente de la posición, PIE por sus siglas en inglés, y es lo que gcc produce por defecto. La opción -no-pie que usamos entonces pedía lo contrario: un ejecutable que se carga siempre en el mismo lugar. Por eso las dos copias de ilusion mostraban la misma dirección, y con un ejecutable normal habrían mostrado dos distintas, lo que habría ocultado justamente lo que queríamos ver.
8. La pila
La pila es la región donde viven las variables locales, los argumentos y las direcciones de retorno de las funciones en curso, como vimos en el tema 6. Su manejo es automático: cada llamada a función reserva un tramo, y cada retorno lo libera. El programador no interviene.
#include <stdio.h>
static void bajar(int nivel)
{
int local = nivel;
printf("nivel %d: variable local en %p\n", nivel, (void *)&local);
if (nivel < 4)
bajar(nivel + 1);
}
int main(void)
{
bajar(1);
return 0;
}
$ gcc -Wall -Wextra -g profundidad.c -o profundidad
$ ./profundidad
nivel 1: variable local en 0x7ffd3c1a5b14
nivel 2: variable local en 0x7ffd3c1a5ae4
nivel 3: variable local en 0x7ffd3c1a5ab4
nivel 4: variable local en 0x7ffd3c1a5a84
$ ulimit -s # tamaño máximo de la pila, en KB
8192
La función es una sola, y su variable local existe cuatro veces a la vez, en cuatro direcciones. Cada llamada tiene la suya. Las direcciones disminuyen: la pila crece hacia abajo, y cada nivel ocupa aquí 48 bytes, que incluyen la variable, la dirección de retorno y otros datos de la llamada.
La pila tiene un tamaño máximo, 8 MB por defecto. El kernel no los entrega de entrada: asigna unas pocas páginas y añade más, mediante fallos de página, a medida que la pila desciende. Si desciende más allá del límite, el fallo de página no se resuelve y el proceso recibe SIGSEGV. Es el caso de la recursión infinita de la tabla del tema 18, y lo que se conoce como desbordamiento de pila.
De estas propiedades salen las reglas de uso que todo programador de C aprende:
- Una variable local deja de existir cuando su función retorna. Devolver su dirección es un error: ese lugar será reutilizado por la siguiente llamada.
- La pila no es lugar para datos grandes. Un arreglo local de varios megabytes puede agotarla.
- La profundidad de una recursión está limitada por el tamaño de la pila.
9. El heap
Para los datos cuyo tamaño o duración no se conocen al compilar, un programa pide memoria durante la ejecución, con malloc, y la devuelve con free. Esa memoria se llama dinámica, y la zona de donde sale, heap.
| Pila | Heap | |
|---|---|---|
| Se reserva | Al entrar a una función, automáticamente | Al llamar a malloc |
| Se libera | Al salir de la función, automáticamente | Al llamar a free |
| Duración de un dato | La de la llamada a la función | La que decida el programa |
| Tamaño | Limitado: 8 MB por defecto | Limitado por la memoria disponible |
| Costo de reservar | Casi nulo: mover un registro | Mayor: buscar un bloque libre |
| Error típico | Desbordamiento; usar una variable de una función que ya retornó | Olvidar liberar; usar un bloque ya liberado |
En el tema 15 vimos que malloc no es una llamada al sistema, sino una función de la biblioteca que administra la memoria por su cuenta y recurre al kernel de vez en cuando. La salida de mapa muestra que lo hace de dos maneras, según el tamaño del pedido:
| Bloques pequeños | Bloques grandes | |
|---|---|---|
| A partir de | — | 128 KB, por defecto |
| Se obtienen de | La región [heap], que se amplía con la llamada brk | Una región propia, creada con la llamada mmap |
| Al liberarlos | Quedan en poder de la biblioteca, para futuros pedidos | La región se devuelve al kernel de inmediato |
| En el experimento | El bloque de 100 bytes | El bloque de 1 MB |
La tercera fila explica una observación frecuente: un programa libera mucha memoria y, visto desde afuera, sigue ocupando lo mismo. Los bloques pequeños liberados no vuelven al sistema; quedan disponibles para el propio programa.
El error característico del heap es la fuga de memoria: un bloque que el programa pidió, dejó de usar y nunca liberó. El kernel no puede detectarlo, porque para él esa memoria está legítimamente asignada. En un programa breve no importa, ya que al terminar el proceso toda su memoria se recupera. En un servicio que funciona durante meses, una fuga pequeña y repetida termina por agotar la memoria del equipo.
10. Reservar no es ocupar
En el tema 18 contamos los fallos de página que produce usar memoria recién pedida. El siguiente programa mide lo mismo en kilobytes, leyendo de /proc/self/status dos valores: VmSize, el tamaño de su espacio de direcciones, y VmRSS, la memoria física que ocupa.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define TAM (200L * 1024 * 1024) /* 200 MB */
/* Muestra el tamaño del espacio de direcciones y la memoria física en uso. */
static void mostrar(const char *etapa)
{
FILE *f = fopen("/proc/self/status", "r");
char linea[128];
long tamano = 0, residente = 0;
if (f == NULL)
return;
while (fgets(linea, sizeof(linea), f) != NULL) {
sscanf(linea, "VmSize: %ld", &tamano);
sscanf(linea, "VmRSS: %ld", &residente);
}
fclose(f);
printf("%-14s VmSize %7ld kB VmRSS %7ld kB\n", etapa, tamano, residente);
}
int main(void)
{
char *bloque;
mostrar("inicio");
bloque = malloc(TAM);
if (bloque == NULL) {
perror("malloc");
return 1;
}
mostrar("tras malloc");
memset(bloque, 1, TAM); /* escribir en todo el bloque */
mostrar("tras escribir");
free(bloque);
mostrar("tras free");
return 0;
}
$ gcc -Wall -Wextra -g memoria.c -o memoria
$ ./memoria
inicio VmSize 2812 kB VmRSS 1536 kB
tras malloc VmSize 207616 kB VmRSS 1536 kB
tras escribir VmSize 207616 kB VmRSS 206336 kB
tras free VmSize 2812 kB VmRSS 1536 kB
| Etapa | Qué cambió | Qué hizo el kernel |
|---|---|---|
malloc | El espacio de direcciones creció 200 MB. La memoria física, nada. | Añadió una región al mapa. No asignó ningún marco. |
| Escribir | La memoria física creció 200 MB. | Atendió unos cincuenta mil fallos de página, asignando un marco en cada uno. |
free | Los dos valores volvieron al comienzo. | Eliminó la región y recuperó los marcos. Era un bloque grande. |
Son las dos columnas de ps y de top que explicamos en el tema 26, vistas en movimiento: VmSize es VSZ o VIRT, y VmRSS es RSS o RES. La primera mide promesas; la segunda, memoria entregada. Cuando se quiere saber cuánto ocupa un programa, la que cuenta es la segunda.
Los valores exactos de las líneas primera y última varían de un sistema a otro; la diferencia de 200 MB entre las etapas, no.
11. Memoria compartida, y cómo contarla
La figura de la sección 2 mostraba un marco presente en las tablas de dos procesos. Es lo que ocurre con el código de las bibliotecas. En el mapa de mapa, la biblioteca de C aparecía como una región con el archivo libc.so.6 detrás. Todos los procesos del sistema tienen esa misma región, respaldada por el mismo archivo, y el kernel usa para todos los mismos marcos. Hay una sola copia del código de la biblioteca en la memoria física, por más procesos que la usen.
Es seguro porque esas páginas son de solo lectura. Las que un proceso puede modificar, como los datos de la biblioteca, se comparten hasta el momento en que alguien escribe, y entonces se duplican: la copia al escribir.
El ahorro es enorme, y complica una pregunta que parece simple: ¿cuánta memoria usa un proceso? Hay cuatro respuestas, y todas son correctas en su sentido:
| Medida | Cuenta | Inconveniente |
|---|---|---|
| VSZ | Todo el espacio de direcciones reservado. | Incluye lo que nunca se usó. Sobrestima mucho. |
| RSS | Los marcos que el proceso tiene asignados, incluidos los compartidos, completos. | Cada marco compartido se cuenta entero en cada proceso. La suma de todos supera la memoria real. |
| PSS | Lo mismo, pero cada marco compartido se divide entre los procesos que lo usan. | Es la medida más justa. La suma de todos coincide con la memoria ocupada. |
| USS | Solo los marcos que no comparte con nadie. | Es lo que se liberaría si ese proceso terminara. |
$ grep -E '^(Rss|Pss):' /proc/$$/smaps_rollup
Rss: 5632 kB
Pss: 1894 kB
La shell tiene asignados 5,6 MB, de los cuales buena parte son páginas de bibliotecas que comparte con decenas de procesos. Repartidas, le corresponden menos de 2 MB. Es la razón por la que no se puede calcular el uso de memoria de un sistema sumando la columna RSS de ps: el resultado excede con facilidad la memoria instalada.
12. La memoria del sistema
La vista global la da free, que presentamos en el tema 7 al explicar la caché de disco. Ahora se puede leer completo:
$ free -h
total used free shared buff/cache available
Mem: 15Gi 4.2Gi 1.1Gi 412Mi 10Gi 10Gi
Swap: 2.0Gi 0B 2.0Gi
| Columna | Significado |
|---|---|
total | La memoria física que administra el kernel. |
used | La ocupada por los procesos y por el propio kernel. |
free | La que no se usa para nada. Que sea poca es normal. |
shared | Memoria compartida explícitamente entre procesos, y sistemas de archivos en memoria. |
buff/cache | La caché de disco. Se puede recuperar en cuanto haga falta. |
available | Lo que podrían obtener programas nuevos sin recurrir al intercambio. Es el dato que importa. |
Swap | El área de intercambio en disco, y cuánto de ella está en uso. |
La caché de disco usa los mismos marcos y las mismas tablas que todo lo demás. Cuando un proceso mapea un archivo, como ocurre con las bibliotecas, las páginas de su región son las de la caché: no hay una copia en la caché y otra en el proceso. Por eso el kernel puede descartarlas sin perder nada, ya que el contenido está en el disco, y por eso una biblioteca usada hace poco se carga al instante.
free obtiene sus cifras de /proc/meminfo, que tiene varias decenas de líneas. Dos de ellas introducen el asunto de la sección siguiente:
$ grep -E '^(MemTotal|MemAvailable|Committed_AS):' /proc/meminfo
MemTotal: 16252416 kB
MemAvailable: 10931548 kB
Committed_AS: 19874320 kB
Committed_AS es la suma de toda la memoria que el kernel tiene prometida a los procesos. En este equipo son 19 GB, y la memoria instalada es de 15,5.
13. Cuando la memoria se acaba
El kernel promete más memoria de la que existe. No es un descuido: es una política, llamada sobrecompromiso (overcommit), y se apoya en lo que mostró la sección 10. Los programas piden mucho más de lo que usan, de modo que conceder solo lo respaldado por memoria real desperdiciaría la mayor parte. Es el razonamiento de una aerolínea que vende más pasajes que asientos, contando con que algunos pasajeros no se presenten.
El problema aparece cuando se presentan todos. Si los procesos empiezan a usar lo que tienen prometido, el kernel recurre a tres medidas, en orden:
- Descarta caché de disco. Es gratis: los datos están en el disco. El único efecto es que las próximas lecturas serán más lentas. sin daño
- Envía páginas al área de intercambio. Guarda en el disco el contenido de páginas que no se usan hace tiempo, y reutiliza sus marcos. El sistema sigue funcionando, cada vez más despacio; si la presión continúa, llega a la hiperpaginación que describimos en el tema 7. lento
- Finaliza un proceso. Cuando no queda nada que recuperar, un componente del kernel elige un proceso —por lo general, el que más memoria ocupa— y lo elimina con
SIGKILL. último recurso
El componente del tercer paso se llama OOM killer, de Out Of Memory. Deja constancia en el registro del kernel:
[18422.107745] Out of memory: Killed process 9321 (devorador) total-vm:18402112kB, anon-rss:15102848kB, …
Para el programa, la consecuencia es desconcertante. Su malloc tuvo éxito, y mucho después, al escribir en esa memoria, o incluso sin hacer nada, es eliminado sin aviso. No hay valor de retorno que comprobar ni señal que atender. Quien investigue encontrará la explicación en dmesg, o en journalctl -k si hubo un reinicio de por medio, como vimos en el tema 22.
Esto no vuelve inútil comprobar el resultado de malloc. Hay casos en que sí devuelve NULL: cuando el pedido es absurdamente grande, y cuando el proceso tiene un límite propio. Este último se puede provocar sin riesgo, con ulimit -v, que restringe el tamaño del espacio de direcciones de los procesos lanzados desde esa shell:
$ (ulimit -v 100000; ./memoria) # un límite de 100 MB, solo para este comando
inicio VmSize 2812 kB VmRSS 1536 kB
malloc: Cannot allocate memory
Los paréntesis hacen que el límite valga solo dentro de ellos. Con un máximo de 100 MB, el kernel rechaza la creación de una región de 200, malloc devuelve NULL, y el programa lo informa y termina con orden. Es la diferencia entre un límite conocido de antemano, que se puede manejar, y el agotamiento global, que no.
La memoria es el recurso que más condiciona el rendimiento de un sistema, y la memoria virtual, su mecanismo más elaborado. Aquí vimos el panorama; el curso de gestión de memoria desarrolla las tablas de páginas, los algoritmos que deciden qué página enviar al disco y el funcionamiento interno de malloc. En el tema 28 pasamos al servicio con que los datos sobreviven al proceso que los creó: los archivos.
14. Resumen
- Los programas trabajan con direcciones virtuales, propias de cada proceso. El procesador las traduce a direcciones físicas en cada acceso, usando tablas que mantiene el kernel.
- La traducción se hace por páginas de 4096 bytes. La tabla de páginas de cada proceso indica qué marco de memoria física corresponde a cada página, y con qué permisos.
- Una dirección se divide en número de página, que se traduce, y desplazamiento, que se conserva. La TLB guarda las traducciones recientes.
- Sobre ese mecanismo se construyen el aislamiento, la entrega y la carga a pedido, la memoria compartida, la copia al escribir y el intercambio.
- El espacio de direcciones de un proceso contiene el código, las constantes y los datos del ejecutable, el heap, las bibliotecas y la pila, separados por grandes zonas sin asignar.
/proc/PID/mapslista las regiones con sus permisos y su origen. Es contra esa lista que el kernel decide si un fallo de página es legítimo.- ASLR ubica las regiones en direcciones al azar en cada ejecución, para dificultar los ataques.
- La pila se maneja sola, crece hacia abajo y tiene un tamaño máximo. El heap lo administra
malloc, que pide memoria al kernel conbrko conmmapsegún el tamaño. - Reservar memoria amplía el espacio de direcciones; solo al usarla se ocupa memoria física.
VSZmide lo primero yRSS, lo segundo. - El código de las bibliotecas existe una sola vez en la memoria física. Por eso la suma de los
RSSexcede la memoria real, yPSSes la medida más justa. - En
free, el dato relevante esavailable. La caché de disco ocupa la memoria que nadie más necesita. - El kernel promete más memoria de la que hay. Si se agota, descarta caché, usa el intercambio y, en último caso, elimina un proceso.
15. Preguntas de repaso
Intenta responder cada una antes de abrir la respuesta.
1. ¿Qué clase de dirección muestra printf("%p", …)?
Una dirección virtual, válida solo dentro de ese proceso. Un programa no tiene acceso a las direcciones físicas.
2. ¿Cómo pueden dos procesos tener valores distintos en la misma dirección?
Cada uno tiene su propia tabla de páginas. La misma página virtual corresponde, en cada tabla, a un marco distinto de la memoria física.
3. Con páginas de 4096 bytes, ¿en qué partes se divide la dirección 0x7ffd4a1b3c2c?
Los 12 bits inferiores, 0xc2c, son el desplazamiento dentro de la página. El resto, 0x7ffd4a1b3, es el número de página, que es lo que se traduce.
4. ¿Quién hace la traducción, y quién mantiene las tablas?
La traducción la hace el procesador, mediante la MMU, en cada acceso y sin intervención del kernel. Las tablas las construye y las mantiene el kernel, que solo interviene cuando la MMU produce un fallo de página.
5. ¿Qué es la TLB, y por qué encarece los cambios de contexto?
Una caché del procesador con las traducciones recientes, que evita recorrer la tabla en cada acceso. Al cambiar de proceso cambian las tablas, sus entradas dejan de servir y hay que volver a llenarla.
6. En mapa, la cadena literal está en una región r--p. ¿Qué pasa si el programa intenta modificarla?
La MMU detecta una escritura en una página sin permiso de escritura y genera un fallo de página. El kernel comprueba que la región no admite escritura y envía SIGSEGV.
7. ¿Qué diferencia hay entre una región con archivo y una anónima?
La primera obtiene su contenido de un archivo, como el código de un ejecutable o de una biblioteca. La anónima no tiene archivo detrás y empieza llena de ceros: el heap, la pila, los bloques grandes de malloc.
8. Una dirección figura dentro de una región de maps. ¿Significa que ocupa memoria física?
No. Significa que el proceso tiene permitido usarla. El marco se asigna en el primer acceso, mediante un fallo de página.
9. ¿Para qué sirve ASLR, y por qué no cambian los tres últimos dígitos de las direcciones?
Para que un atacante no pueda conocer de antemano las direcciones del código y los datos de un programa. Los tres últimos dígitos hexadecimales son el desplazamiento dentro de la página; el azar se aplica a la ubicación de las regiones, que se alinean a páginas completas.
10. ¿Por qué es un error devolver la dirección de una variable local?
Porque la variable está en la pila, en el tramo de esa llamada. Al retornar la función, ese tramo queda libre y la siguiente llamada lo reutiliza, de modo que el puntero pasa a señalar datos ajenos.
11. Tras un malloc de 200 MB, VmSize crece y VmRSS no. ¿Por qué?
El kernel añadió una región de 200 MB al espacio de direcciones, sin asignar ningún marco. La memoria física se ocupa recién cuando el programa escribe en cada página.
12. ¿Por qué la suma de los RSS de todos los procesos puede superar la memoria instalada?
Porque los marcos compartidos, como los del código de las bibliotecas, se cuentan completos en cada proceso que los usa. PSS los reparte entre ellos, y su suma sí coincide con la memoria ocupada.
13. free muestra muy poca memoria en la columna free. ¿Hay un problema?
No necesariamente. El kernel usa la memoria que nadie necesita como caché de disco, y la libera en cuanto hace falta. Lo que hay que mirar es available.
14. ¿Qué es el sobrecompromiso, y qué ocurre cuando falla?
Es la política de conceder más memoria de la que existe, contando con que los procesos no usan todo lo que piden. Si llegan a usarla, el kernel descarta caché, recurre al intercambio y, cuando no queda nada, el OOM killer elimina un proceso.
Para practicar
- Ejecuta
mapay ubica cada dirección impresa en su línea del mapa. Calcula la distancia entre el final de[heap]y el comienzo de la primera biblioteca. - Ejecuta
mapatres veces y anota la dirección demainy la de la variable global con valor. ¿Cambia la diferencia entre ambas? Repite consetarch -R. - Compila
mapa.ccon-no-piey ejecútalo varias veces. ¿Qué direcciones dejaron de cambiar y cuáles siguen cambiando? - Cambia el segundo
mallocdemapa.ca 100 KB y luego a 200 KB. ¿En qué región cae el bloque en cada caso? - Modifica
profundidad.cquitando la condición de corte y añadiendo un contador. ¿Hasta qué nivel llega antes del fallo? Multiplica por el tamaño de cada nivel y compara conulimit -s. - Repite el ejercicio anterior con
(ulimit -s 1024; ./profundidad). ¿Cómo cambia la profundidad alcanzada? - En
memoria.c, cambia elmemsetpara que escriba solo la mitad del bloque. Predice los cuatro valores deVmRSSantes de ejecutar. - Reduce
TAMdememoria.ca 64 KB. ¿VuelveVmSizea su valor inicial después defree? Explícalo con la tabla de la sección 9. - Compara
RssyPssde tu shell, y luego los de un programa grande, como el navegador, usando su PID. ¿En cuál es mayor la proporción compartida? - Calcula con
/proc/meminfoel cociente entreCommitted_ASyMemTotalde tu equipo. ¿Cuánta memoria tiene prometida el kernel por cada gigabyte real?