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

Tema 1

Por qué el sistema operativo administra la memoria

Cuando escribes int x = 42; parece que el programa simplemente «tiene» un lugar donde guardar el número. Detrás de esa comodidad hay un trabajo constante del sistema operativo: repartir una memoria limitada entre decenas de programas que no se conocen, impedir que se pisen y hacer que cada uno crea que dispone de un espacio enorme y propio. En este tema verás qué problemas aparecen cuando varios programas comparten la RAM, comprobarás con tres programas en C que el sistema ya los resuelve por ti, entenderás cómo se reparten el trabajo el hardware y el sistema operativo, y calcularás por qué tener más programas en memoria hace que la CPU se aproveche mejor. Es el mapa de todo el curso.

1. La memoria, un recurso que todos necesitan

Un procesador solo sabe hacer dos cosas con los datos: operar con los que tiene en sus registros y traer o llevar datos de la memoria principal, la RAM. Un programa guardado en el disco no puede ejecutarse: para que la CPU lea una instrucción o un dato, éste tiene que estar antes en la memoria. Por eso, cada proceso que existe en el sistema ocupa memoria: su código, sus variables globales, su pila, los bloques que pidió con malloc.

La RAM es, además, un recurso finito y compartido. Un equipo de escritorio puede tener 16 GiB; en él conviven el navegador, el editor, el sistema de ventanas, decenas de servicios y el propio núcleo. Ninguno de esos programas sabe de los demás, y todos están escritos como si la memoria fuera solo suya. El sistema operativo es quien hace posible esa convivencia, y la parte del núcleo que se ocupa de ello se llama gestor de memoria.

Gestión de memoria es el conjunto de mecanismos con que el sistema operativo, ayudado por el hardware, decide qué parte de la memoria usa cada proceso, controla qué accesos se permiten y mueve datos entre la RAM y el disco cuando hace falta.

2. Sin sistema operativo: un programa, toda la memoria

Para apreciar lo que hace el gestor de memoria conviene imaginar su ausencia. En los primeros computadores, y todavía hoy en muchos microcontroladores, solo corre un programa a la vez y ocupa toda la memoria. Las direcciones que usa el programa son directamente posiciones de la RAM: la variable x está en la posición 0x2000 y siempre en la 0x2000. Es un modelo muy simple, pero tiene dos defectos graves:

La solución es tener varios programas cargados a la vez y pasar la CPU de uno a otro cuando alguno espera. Eso se llama multiprogramación y es el origen de todos los problemas que estudia este curso.

3. Varios programas a la vez: tres problemas

En cuanto dos programas comparten la RAM, aparecen tres dificultades que no existían antes:

ProblemaLa preguntaSi no se resuelveDónde se estudia
Reubicación¿En qué posición de la RAM se cargará el programa? No se sabe cuando se escribe ni cuando se compila.Dos programas compilados para usar las mismas direcciones chocarían, o habría que recompilar según qué otros programas estén cargados.Bloques 1, 3, 4 y 5
Protección¿Cómo se impide que un programa lea o escriba la memoria de otro, o la del propio núcleo?Un error o un programa malicioso corrompería a los demás o tomaría el control del equipo.Bloques 3, 4 y 5
Capacidad¿Qué hacer cuando entre todos piden más memoria de la que hay instalada?Los programas nuevos no arrancarían, o los existentes se detendrían con un error de memoria insuficiente.Bloques 7, 8 y 9

Hay una cuarta cuestión, de eficiencia: aun cuando todo cabe y está protegido, el reparto tiene que ser barato. Traducir direcciones en cada acceso, o llevar al disco una página, cuesta tiempo, y si el sistema lo hiciera sin cuidado todos los programas irían lentos. Una buena parte del curso trata precisamente de hacer estas tareas casi gratis (la TLB, el reemplazo de páginas, el copy-on-write).

Veamos ahora que Linux resuelve de verdad estos tres problemas, con tres programas cortos. Todos se compilan con gcc -std=c17 -Wall -Wextra y los resultados que se muestran vienen de una ejecución real.

4. Experimento: la misma dirección, dos valores

El primer programa declara una variable global, crea un proceso hijo con fork y hace que el hijo la modifique. Después cada uno imprime la dirección de la variable y su valor. El padre espera a que el hijo termine, para que las líneas salgan siempre en el mismo orden:

aislamiento.c
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>

int valor = 42;                          /* variable global: una sola, en la dirección que sea */

int main(void)
{
    pid_t pid = fork();
    if (pid == -1) {
        perror("fork");
        return 1;
    }
    if (pid == 0) {
        valor = 99;                      /* el hijo la modifica */
        printf("hijo   (pid %d): &valor = %p, valor = %d\n", getpid(), (void *)&valor, valor);
        return 0;
    }
    if (waitpid(pid, NULL, 0) == -1) {
        perror("waitpid");
        return 1;
    }
    printf("padre  (pid %d): &valor = %p, valor = %d\n", getpid(), (void *)&valor, valor);
    return 0;
}
terminal · dos procesos, una dirección, dos valores
$ gcc -std=c17 -Wall -Wextra -o aislamiento aislamiento.c
$ ./aislamiento
hijo   (pid 547): &valor = 0x559edd2f6010, valor = 99
padre  (pid 546): &valor = 0x559edd2f6010, valor = 42
$ ./aislamiento
hijo   (pid 549): &valor = 0x56c0f37d8010, valor = 99
padre  (pid 548): &valor = 0x56c0f37d8010, valor = 42

Hay que leer este resultado con calma, porque contiene la idea central del curso:

5. Experimento: una dirección que no es tuya

El aislamiento se completa con la protección: no basta con que cada proceso tenga su espacio, hay que impedir que acceda a lo que no es suyo. El segundo programa intenta leer un byte de una dirección que recibe como argumento:

proteccion.c
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[])
{
    unsigned long direccion = argc > 1 ? strtoul(argv[1], NULL, 0) : 0x1000;
    volatile char *p = (volatile char *)direccion;

    printf("voy a leer la dirección %p\n", (const void *)p);
    fflush(stdout);
    char c = *p;                         /* ¿pertenece esa dirección a este proceso? */
    printf("leí el byte %d\n", c);
    return 0;
}
terminal · leer memoria ajena
$ gcc -std=c17 -Wall -Wextra -o proteccion proteccion.c
$ ./proteccion
voy a leer la dirección 0x1000
Segmentation fault
$ echo $?
139
$ ./proteccion 0x7ffd00000000
voy a leer la dirección 0x7ffd00000000
Segmentation fault

En ambas direcciones el sistema detuvo el programa antes de que leyera nada. El mecanismo es siempre el mismo: la CPU intenta el acceso, el hardware comprueba que esa dirección no corresponde a una zona que el proceso tenga asignada, y lanza una excepción. El núcleo la recibe y le envía al proceso la señal SIGSEGV, cuyo efecto por omisión es terminarlo. El código de salida 139 es 128 + 11, donde 11 es el número de SIGSEGV. La famosa «falla de segmentación» no es un error del compilador ni del lenguaje, sino la protección de memoria funcionando. En C el programador decide a qué dirección apunta un puntero; es el sistema operativo quien decide si puede usarla.

El error más útil

Una falla de segmentación es una buena noticia comparada con la alternativa. Sin protección, el programa leería o escribiría en silencio la memoria de otro y el fallo aparecería mucho después y muy lejos de la causa. El sistema prefiere detener el proceso culpable en el acto.

6. Experimento: más memoria de la que hay

El tercer problema es la capacidad. El programa siguiente pide con malloc un bloque de tantos MiB como se le indique y, en tres momentos, lee de /proc/self/status dos cifras: VmSize, la memoria virtual que el proceso tiene reservada, y VmRSS, la parte que está realmente en la RAM:

virtual.c
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/* Muestra las líneas de /proc/self/status que empiezan por VmSize o VmRSS. */
static void mostrar(const char *momento)
{
    FILE *f = fopen("/proc/self/status", "r");
    if (f == NULL) {
        perror("fopen");
        exit(1);
    }
    char linea[256];
    printf("%s\n", momento);
    while (fgets(linea, sizeof linea, f) != NULL)
        if (strncmp(linea, "VmSize", 6) == 0 || strncmp(linea, "VmRSS", 5) == 0)
            printf("  %s", linea);
    fclose(f);
}

int main(int argc, char *argv[])
{
    size_t mib = argc > 1 ? (size_t)strtoul(argv[1], NULL, 10) : 8192;
    size_t bytes = mib << 20;

    mostrar("al empezar:");
    char *bloque = malloc(bytes);
    if (bloque == NULL) {
        fprintf(stderr, "malloc de %zu MiB falló\n", mib);
        return 1;
    }
    printf("malloc(%zu MiB) devolvió %p\n", mib, (void *)bloque);
    mostrar("tras el malloc, sin tocar la memoria:");

    memset(bloque, 1, (size_t)100 << 20);      /* escribir en los primeros 100 MiB */
    mostrar("tras escribir en los primeros 100 MiB:");

    free(bloque);
    return 0;
}

La máquina de pruebas tiene 15 GiB de RAM. Pedimos 8 GiB, y después 200.000 MiB (casi 200 GiB):

terminal · reservar no es lo mismo que usar
$ gcc -std=c17 -Wall -Wextra -o virtual virtual.c
$ ./virtual
al empezar:
  VmSize:	    2764 kB
  VmRSS:	    1408 kB
malloc(8192 MiB) devolvió 0x7d86a1dff010
tras el malloc, sin tocar la memoria:
  VmSize:	 8391376 kB
  VmRSS:	    1536 kB
tras escribir en los primeros 100 MiB:
  VmSize:	 8391376 kB
  VmRSS:	  103936 kB
$ ./virtual 200000
al empezar:
  VmSize:	    2764 kB
  VmRSS:	    1536 kB
malloc de 200000 MiB falló

Tres observaciones:

Que malloc tenga éxito no garantiza que haya memoria

Como la RAM se entrega al tocar, un programa puede reservar más de lo que luego conseguirá usar. Si entre todos los procesos acaban escribiendo más páginas de las que caben en RAM y en el espacio de intercambio, el sistema tendrá que matar procesos (el «OOM killer», tema 40). El comportamiento exacto se controla con /proc/sys/vm/overcommit_memory, cuyo valor en la máquina de pruebas es 0, la política heurística.

7. Los tres objetivos del sistema operativo

Los experimentos muestran, uno por uno, qué se espera del gestor de memoria. Resumidos:

ObjetivoQué significaLo viste en
AbstracciónCada proceso recibe un espacio de direcciones propio, grande y uniforme, independiente de dónde estén sus datos en la RAM y de cuánta RAM haya. Sirve también para resolver la reubicación.aislamiento.c: misma dirección, otro valor.
ProtecciónUn proceso solo accede a lo que tiene asignado, y con los permisos adecuados: el código se ejecuta pero no se escribe, el núcleo no es accesible desde el programa.proteccion.c: SIGSEGV.
EficienciaAprovechar la RAM al máximo: entregarla solo cuando se usa, compartir lo que es igual entre procesos y usar el disco como reserva, sin que cada acceso sea notablemente más lento.virtual.c: VmSize frente a VmRSS.

Los tres objetivos a veces tiran en direcciones opuestas. Un aislamiento total sería mejor para la seguridad, pero impediría compartir las bibliotecas del sistema, que cargadas una sola vez ahorran cientos de MiB. Una protección muy fina cuesta más tiempo de traducción. Gran parte del diseño de un gestor de memoria consiste en encontrar el equilibrio.

8. Quién hace qué: hardware y sistema operativo

Un detalle importante: el sistema operativo no interviene en cada acceso a memoria. Un programa hace miles de millones por segundo y, si el núcleo tuviera que mirar cada uno, no se podría trabajar. El reparto es este:

Dos procesos, A y B, generan la misma dirección virtual. La MMU del procesador la traduce con las tablas que mantiene el sistema operativo y accede a marcos distintos de la RAM Proceso A usa 0x1000 Proceso B usa 0x1000 MMU traduce y comprueba cada acceso RAM marco 3 (de A) marco 7 (de B) marco 9 (núcleo) marco 12 (de A) virtual física Sistema operativo (núcleo) construye las tablas que lee la MMU
Dos procesos usan la misma dirección virtual y acceden a marcos distintos. La MMU hace la traducción en cada acceso; el sistema operativo mantiene las tablas que ella consulta.

Esta división explica la estructura del curso. Los bloques 3 al 6 describen los mecanismos de traducción (base y límite, segmentación, paginación, TLB), que son sobre todo hardware con una política decidida por el sistema. Los bloques 7 al 9 describen lo que hace el sistema cuando la MMU le pide ayuda.

9. Por qué importa tener varios programas en memoria

Falta dar el motivo cuantitativo de la multiprogramación. Supón que los procesos pasan una fracción p de su tiempo esperando entrada/salida. Con un solo proceso en memoria, la CPU está ociosa esa fracción. Si hay n procesos y sus esperas son independientes, la CPU solo está ociosa cuando los n esperan a la vez, lo que ocurre con probabilidad pn. Es el modelo clásico de la multiprogramación:

Utilización de la CPU = 1 − pn, con p la fracción de tiempo que cada proceso espera E/S y n el número de procesos en memoria (el «grado de multiprogramación»).

Con procesos que esperan el 80 % del tiempo (p = 0,8), que es normal en programas interactivos o que acceden a disco y red:

Procesos en memoria (n)123510
Utilización de la CPU20 %36 %49 %67 %89 %

Cada proceso adicional aprovecha un poco más la CPU, pero cada uno necesita su memoria. El grado de multiprogramación lo limita la RAM: lo que quepa. Esta es la tensión que atraviesa todo el curso. Con más memoria entran más procesos y se usa mejor la CPU. Con técnicas que hagan que cada proceso use menos RAM (cargar solo lo necesario, compartir el código común, llevar al disco lo que no se usa) entran todavía más. Con un reparto mal hecho se pierde espacio en huecos inutilizables, y con demasiados procesos cada uno recibe tan poca memoria que el sistema pasa el tiempo moviendo páginas entre la RAM y el disco: la hiperpaginación del tema 33.

10. Probar con un simulador

Este simulador calcula cuántos procesos caben en la RAM y qué utilización de la CPU resulta. La barra superior dibuja la memoria: el sistema operativo en gris, los procesos en azul y ámbar alternados y, en blanco, la memoria libre. Los valores iniciales (4096 MiB de RAM, 512 para el núcleo, procesos de 700 MiB y esperas del 80 %) dan cinco procesos y un 67 %.

Simulador · cuántos procesos caben y cuánto rinde la CPU

Mapa de la RAM

MedidaValor
Procesos que caben (n)
Memoria libre que sobra
Utilización de la CPU (1 − pn)
Con 1 GiB más de RAM

Prueba tres cosas. Sube la espera de E/S al 95 % y comprueba que hacen falta muchos más procesos para llenar la CPU. Pon espera 20 % y observa que más procesos casi no mejoran nada: ya estaba bien aprovechada. Y con los valores iniciales, reduce la memoria por proceso a 350 MiB: la mejora de rendimiento equivale a haber duplicado la RAM, que es lo que hacen en la práctica la paginación por demanda y la compartición de código.

11. Mirar la memoria en Linux

Para terminar, tres herramientas que usarás durante todo el curso. free resume la memoria del equipo, /proc/meminfo da el detalle y /proc/PID/status lo da por proceso:

terminal · la memoria del equipo
$ free -h
               total        used        free      shared  buff/cache   available
Mem:            15Gi       595Mi        14Gi       3.5Mi       569Mi        14Gi
Swap:          4.0Gi          0B       4.0Gi
$ getconf PAGESIZE
4096
$ grep -E 'VmSize|VmRSS' /proc/self/status
VmSize:	    4256 kB
VmRSS:	    2304 kB

12. El mapa del curso

Los 52 temas siguen el recorrido de la memoria, desde lo que ve un programa hasta lo que hace el sistema cuando se agota:

BloqueTemasLa pregunta que responde
1. El espacio de direcciones1–5¿Qué memoria ve un programa y cómo se organiza?
2. La pila y el heap6–10¿Cómo se usa la memoria desde C y qué errores aparecen?
3. Asignación contigua11–14¿Cómo se repartía la memoria antes de la paginación y por qué se fragmentaba?
4. Segmentación15–17¿Qué cambia al dividir el programa en partes lógicas?
5. Paginación18–23¿Cómo se traduce una dirección virtual con tablas de páginas?
6. La TLB y la localidad24–27¿Cómo se hace rápida la traducción?
7. Memoria virtual28–33¿Cómo ejecutar programas que no caben y qué pasa en un fallo de página?
8. Reemplazo de páginas34–39¿Qué página se echa cuando no queda sitio: FIFO, LRU u óptimo?
9. Intercambio y mapeo40–44¿Cómo se usan el disco, mmap y la memoria compartida?
10. malloc por dentro45–49¿Cómo funciona el asignador que usa cada programa en C?
11. Integración y cierre50–52¿Cómo se combina todo en programas completos?

Los programas en C se compilan con gcc -std=c17 -Wall -Wextra y se prueban en Linux; los algoritmos se explican además con simuladores como el de este tema. Se da por sabido el C de Programación en C para Sistemas Operativos, en particular los punteros, y lo que cuenta Procesos e Hilos sobre qué es un proceso.

Los números cambiarán

Las direcciones, los PID y las cifras de memoria dependen de la máquina y de cada ejecución: con otra RAM, otro núcleo o simplemente otra vez, verás valores distintos. Lo que debe repetirse es el patrón: misma dirección y distinto valor en dos procesos, una falla de segmentación al tocar lo ajeno, y una reserva grande que casi no consume RAM hasta que se escribe.

13. Resumen

14. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Por qué un programa tiene que estar en la RAM para ejecutarse?

Porque la CPU solo lee instrucciones y datos de sus registros y de la memoria principal; no ejecuta directamente desde el disco. Por eso el sistema carga en la RAM el código y los datos que cada proceso necesita.

2. ¿Qué tres problemas aparecen al poner varios programas a la vez en memoria?

La reubicación (no se sabe en qué posición de la RAM se cargará cada programa), la protección (impedir que uno acceda a la memoria de otro o del núcleo) y la capacidad (qué hacer cuando entre todos piden más memoria de la instalada).

3. En aislamiento.c, padre e hijo imprimen la misma dirección pero valores distintos. ¿Qué se concluye?

Que la dirección no es una posición física de la RAM, sino una dirección virtual: cada proceso tiene su propio espacio de direcciones y el sistema traduce la misma dirección virtual a posiciones físicas diferentes. La modificación del hijo no afecta al padre.

4. ¿Qué ocurre exactamente cuando un programa lee una dirección que no le pertenece?

La MMU detecta que la dirección no está asignada al proceso (o que el acceso no está permitido) y lanza una excepción. El núcleo la atiende y envía al proceso la señal SIGSEGV, que por omisión lo termina; el shell lo informa como «Segmentation fault» y el código de salida es 139 (128 + 11).

5. ¿Por qué malloc(8 GiB) casi no cambió VmRSS?

Porque malloc reserva espacio de direcciones, no memoria física. El sistema solo anota que el proceso puede usar ese rango. La RAM se asigna página a página cuando el programa escribe en ella por primera vez (paginación por demanda). Por eso, al escribir en 100 MiB, VmRSS subió unos 100 MB.

6. ¿Qué diferencia hay entre VmSize y VmRSS?

VmSize es el tamaño total del espacio de direcciones que el proceso tiene reservado (virtual). VmRSS (resident set size) es la parte que está efectivamente en la RAM en ese momento. VmRSS casi siempre es mucho menor que VmSize.

7. ¿Interviene el sistema operativo en cada acceso a memoria de un programa? ¿Quién lo hace?

No. La traducción y la comprobación de cada acceso las hace la MMU, un componente del procesador, con tablas que mantiene el sistema operativo. El núcleo solo entra en acción en los casos excepcionales, como una página ausente o un acceso no permitido.

8. Los procesos esperan E/S el 60 % del tiempo. ¿Qué utilización de CPU da el modelo con 1, 3 y 6 procesos?

Con p = 0,6: n = 1 → 1 − 0,6 = 40 %. n = 3 → 1 − 0,216 = 78,4 %. n = 6 → 1 − 0,66 = 1 − 0,0467 ≈ 95,3 %.

9. ¿Por qué la cantidad de RAM limita la utilización de la CPU?

Porque cada proceso en memoria necesita su espacio y la RAM es finita: solo caben unos cuantos. Cuantos más procesos quepan, más probable es que alguno esté listo para ejecutar mientras los demás esperan E/S, y mejor se aprovecha la CPU.

10. ¿Por qué hay que comprobar siempre que malloc no devuelva NULL, si el sistema deja reservar mucho más de lo que hay?

Porque no todo se acepta: una petición que supera lo que el sistema considera razonable (por ejemplo, 200.000 MiB en una máquina de 15 GiB más el espacio de intercambio) falla y devuelve NULL. Usar ese puntero sin comprobarlo provocaría un acceso a la dirección 0 y una falla de segmentación.

Para practicar

  1. Compila y ejecuta aislamiento.c varias veces. ¿Cambia la dirección entre ejecuciones? Ejecútalo con setarch -R ./aislamiento, que desactiva la aleatorización, y compara.
  2. Modifica aislamiento.c para que imprima también la dirección de una variable local de main y la de un bloque obtenido con malloc. ¿Coinciden esas direcciones entre padre e hijo?
  3. Ejecuta proteccion con las direcciones 0, 0x400000 y la dirección de una variable propia (imprímela con otro programa). ¿Cuáles provocan SIGSEGV y cuáles no? Averigua por qué.
  4. Ejecuta virtual con 1024, 4096 y 16384 MiB, y mira el resultado de cat /proc/sys/vm/overcommit_memory en tu equipo. ¿A partir de qué tamaño falla el malloc?
  5. Modifica virtual.c para que escriba en todo el bloque de a 1 GiB y muestre VmRSS tras cada gigabyte. Hazlo con un tamaño que quepa holgado en tu RAM.
  6. Calcula a mano la utilización de la CPU con p = 0,9 para n = 1, 5, 10 y 20, y comprueba los resultados con el simulador. ¿Cuántos procesos hacen falta para pasar del 90 %?
  7. Abre /proc/meminfo y localiza MemTotal, MemAvailable y SwapTotal. Compara MemAvailable con la columna free de free -h.