← Gestión de Memoria Bloque 9 · Intercambio y mapeo de memoria · Tema 42 de 52

Tema 42

Memoria anónima y permisos con mprotect

En el tema anterior cada página del mapeo correspondía a una parte de un archivo. Ahora pediremos páginas sin archivo, inicialmente llenas de ceros, y decidiremos qué accesos permite el sistema. Aprenderás a reservar memoria con mmap, cambiar su protección con mprotect, distinguir un fallo de página recuperable de una violación de permisos y construir una región rodeada por páginas de guarda. Los ejemplos en C para Linux y el simulador permiten seguir cada paso.

1. Qué significa memoria anónima

Memoria anónima es una región de memoria virtual cuyo contenido no se inicializa a partir de un archivo ordinario. Un mapeo anónimo nuevo se presenta al programa con todos sus bytes en cero.

«Anónima» no significa que otros procesos puedan leerla ni que el sistema desconozca a quién pertenece. Describe el respaldo del contenido. El sistema registra la región dentro del espacio de direcciones del proceso, conoce sus límites y permisos y proporciona las páginas físicas necesarias cuando se accede a ella.

La memoria de trabajo de un programa, sus pilas y buena parte de la memoria que entrega malloc se apoyan en memoria anónima. Pedirla directamente con mmap permite controlar regiones enteras y su protección. Para objetos pequeños, en cambio, malloc sigue siendo una interfaz más cómoda: administra bloques dentro de regiones mayores, como veremos en el bloque 10.

PropiedadMapeo de archivoMapeo anónimo privado
Contenido inicialBytes del archivoCeros
DescriptorArchivo abierto-1 con MAP_ANONYMOUS
Páginas limpiasPueden descartarse y releerseLas páginas aún sin modificar pueden reconstruirse como ceros
Datos privados modificadosCon MAP_PRIVATE, copias anónimasNecesitan conservarse en RAM o, si se intercambian, en swap
PersistenciaMAP_SHARED puede actualizar el archivoNo hay archivo que actualizar con msync

Con MAP_PRIVATE, un fork hereda el mapeo con semántica de copy-on-write: los cambios posteriores son privados. Linux también permite MAP_SHARED | MAP_ANONYMOUS, útil para compartir una región heredada por procesos relacionados. Dos llamadas independientes a mmap anónimo no se conectan por usar los mismos argumentos. La compartición se desarrollará en el tema 43.

2. Pedir páginas con mmap

La interfaz es la del tema 41. Lo que cambia es el origen: usamos MAP_ANONYMOUS, pasamos -1 como descriptor y 0 como desplazamiento. NULL deja que el núcleo elija una base alineada a página.

anonima.c · reservar, comprobar y liberar tres páginas
#define _DEFAULT_SOURCE
#include <stdio.h>
#include <sys/mman.h>
#include <unistd.h>

int main(void)
{
    long consulta = sysconf(_SC_PAGESIZE);
    if (consulta <= 0) {
        fprintf(stderr, "No se pudo consultar el tamaño de página\n");
        return 1;
    }
    size_t pagina = (size_t) consulta;
    size_t longitud = 3 * pagina;
    unsigned char *p = mmap(NULL, longitud,
        PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (p == MAP_FAILED) {
        perror("mmap");
        return 1;
    }

    printf("Base: %p; página: %zu bytes\n", (void *) p, pagina);
    printf("Valores iniciales: %u %u %u\n",
           (unsigned) p[0], (unsigned) p[pagina],
           (unsigned) p[2 * pagina]);
    for (size_t i = 0; i < 3; i++)
        p[i * pagina] = (unsigned char) (10 + i);
    printf("Después: %u %u %u\n", (unsigned) p[0],
           (unsigned) p[pagina], (unsigned) p[2 * pagina]);

    if (munmap(p, longitud) == -1) {
        perror("munmap");
        return 1;
    }
    return 0;
}
terminal · Linux con glibc
$ gcc -std=c17 -Wall -Wextra -O2 anonima.c -o anonima
$ ./anonima
Base: 0x7f...; página: 4096 bytes
Valores iniciales: 0 0 0
Después: 10 11 12

La dirección y el tamaño de página dependen del entorno; 4096 es un ejemplo, no una constante que debamos escribir en el programa. _DEFAULT_SOURCE expone MAP_ANONYMOUS en glibc cuando se compila en modo C estricto. Los ejemplos de este tema están dirigidos a Linux.

La longitud de mmap debe ser mayor que cero. Su error se compara con MAP_FAILED. La liberación se realiza con munmap, nunca con free, y después de liberarla no se debe utilizar ningún puntero hacia la región.

3. Ceros, primer acceso y memoria física

Reservar una región virtual no equivale a obtener de inmediato un marco físico exclusivo para cada página. En el caso habitual, sin opciones de precarga, el núcleo crea el mapeo y difiere el trabajo hasta el acceso.

  1. Reserva virtual. mmap registra el intervalo y sus permisos. El proceso puede tener mucho espacio virtual y un RSS pequeño.
  2. Primera lectura. Si falta la traducción, se produce un fallo de página. Linux puede resolver una lectura de ceros utilizando una página física de ceros compartida y de solo lectura.
  3. Primera escritura. El sistema proporciona una página privada inicializada a cero, o copia la página de ceros si estaba compartida. La instrucción se reanuda y guarda el dato.
  4. Accesos posteriores. Con la traducción presente y permisos suficientes, la CPU accede normalmente. Bajo presión, los datos anónimos modificados pueden trasladarse a swap.

Los primeros accesos a páginas anónimas nuevas suelen producir fallos menores: no hay que leer un archivo del disco. Un acceso posterior a una página que fue al swap puede requerir E/S y producir un fallo mayor. Las páginas grandes y otras optimizaciones impiden deducir una relación exacta de «un fallo por cada página base» en todas las ejecuciones.

4. La interfaz mprotect

mprotect cambia los permisos de páginas que ya pertenecen al proceso. No asigna una región nueva y no cambia los bytes guardados:

prototipo · <sys/mman.h>
int mprotect(void *addr, size_t len, int prot);
ProtecciónIntenciónUso habitual
PROT_NONENingún accesoReserva inaccesible o página de guarda
PROT_READLecturaDatos que deben permanecer sin cambios
PROT_READ | PROT_WRITELectura y escrituraBúfer de trabajo
PROT_READ | PROT_EXECLectura y ejecuciónCódigo preparado para ejecutarse

PROT_NONE se usa solo; los otros indicadores se combinan con OR binario (|). La llamada devuelve 0 si tiene éxito y -1 ante un error, dejando la causa en errno. Siempre se debe comprobar ese resultado antes de confiar en la nueva protección.

Los permisos sustituyen los anteriores: pasar PROT_READ a una región RW elimina su permiso de escritura. Para restaurarlo se solicita de nuevo PROT_READ | PROT_WRITE. Tras un paso por PROT_NONE, los datos siguen allí al volver a habilitar el acceso; no se trata de una operación de borrado ni de liberación.

La MMU aplica la protección con ayuda de las tablas de páginas; el núcleo actualiza las estructuras y las traducciones afectadas. Un puntero const expresa una restricción en C, pero mprotect impone una restricción al acceso real del proceso. Convertir el tipo del puntero no permite saltársela.

Los permisos efectivos dependen también de la arquitectura y del entorno: por ejemplo, en algunas arquitecturas escribir implica poder leer. Para datos normales se pide explícitamente RW; no conviene suponer que todas las combinaciones R/W/X pueden implementarse de forma independiente en cualquier máquina.

5. Alineación y alcance por páginas

La dirección inicial de mprotect debe ser múltiplo del tamaño de página. La longitud no necesita serlo, pero se afectan todas las páginas que contienen algún byte del intervalo. No es posible proteger un único campo de una estructura sin afectar los demás datos situados en su página.

Con páginas de 4096 bytes y una base B alineada, una región de tres páginas queda dividida así:

PáginaDesplazamientosDespués de mprotect(B + 4096, 1, PROT_READ)
00 a 4095RW
14096 a 8191R, en toda la página
28192 a 12287RW

mprotect(B + 4100, 20, PROT_READ) falla por dirección desalineada. Para cubrir un intervalo arbitrario se redondea su inicio hacia abajo y su final hacia arriba, verificando que no haya desbordamientos y que las páginas completas pertenezcan a regiones que se puedan proteger.

ejemplo aritmético · offsets dentro de un mapeo propio
Página P = 4096; inicio = 5000; cantidad = 4000
Final exclusivo = 5000 + 4000 = 9000
Base alineada = (5000 / 4096) * 4096 = 4096
Final alineado = 12288
Longitud protegida = 12288 - 4096 = 8192 bytes
Se modifican las páginas 1 y 2.

Un bloque obtenido con malloc puede empezar a mitad de una página y compartirla con otros bloques o con metadatos del asignador. Para estos experimentos se usa un mapeo propio: alinear hacia abajo un puntero cualquiera y protegerlo podría inutilizar datos ajenos al bloque.

6. Provocar una falla controlada

Un acceso permitido a una página ausente puede resolverse cargando o creando la página. Una escritura a una página protegida contra escritura es otro caso: el núcleo no debe concederla y entrega SIGSEGV. La acción predeterminada termina el proceso y puede generar un volcado de memoria.

El siguiente programa guarda un valor, convierte la página en solo lectura y crea un hijo que intenta escribir. El padre observa cómo termina el hijo, recupera RW y continúa. Así la falla queda aislada sin instalar un manejador de señales ni intentar volver a ejecutar indefinidamente la instrucción que falló.

proteccion.c · observar SIGSEGV desde el padre
#define _DEFAULT_SOURCE
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void)
{
    long n = sysconf(_SC_PAGESIZE);
    if (n <= 0) {
        fprintf(stderr, "Tamaño de página no disponible\n");
        return 1;
    }
    size_t pagina = (size_t) n;
    unsigned char *p = mmap(NULL, pagina, PROT_READ | PROT_WRITE,
        MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (p == MAP_FAILED) { perror("mmap"); return 1; }
    p[0] = 42;
    int resultado = 1;

    if (mprotect(p, pagina, PROT_READ) == -1) {
        perror("mprotect R"); goto liberar;
    }
    printf("Lectura permitida: %u\n", (unsigned) p[0]);
    fflush(stdout);  /* no duplicar salida pendiente al crear el hijo */
    pid_t hijo = fork();
    if (hijo == -1) { perror("fork"); goto liberar; }
    if (hijo == 0) {
        volatile unsigned char *prueba = p;
        prueba[0] = 99;  /* escritura real sobre página de solo lectura */
        _exit(2);        /* llegar aquí indicaría un resultado inesperado */
    }

    int estado;
    pid_t esperado;
    do {
        esperado = waitpid(hijo, &estado, 0);
    } while (esperado == -1 && errno == EINTR);
    if (esperado == -1) { perror("waitpid"); goto liberar; }
    if (!WIFSIGNALED(estado) || WTERMSIG(estado) != SIGSEGV) {
        fprintf(stderr, "El hijo no terminó con el SIGSEGV esperado\n");
        goto liberar;
    }
    puts("El hijo terminó con SIGSEGV al intentar escribir.");
    if (mprotect(p, pagina, PROT_READ | PROT_WRITE) == -1) {
        perror("mprotect RW"); goto liberar;
    }
    p[0] = 77;
    printf("El padre continúa y escribe: %u\n", (unsigned) p[0]);
    resultado = 0;

liberar:
    if (munmap(p, pagina) == -1) { perror("munmap"); resultado = 1; }
    return resultado;
}
terminal · ejecutar sin generar un archivo core en esta shell
$ gcc -std=c17 -Wall -Wextra -O2 proteccion.c -o proteccion
$ ulimit -c 0
$ ./proteccion
Lectura permitida: 42
El hijo terminó con SIGSEGV al intentar escribir.
El padre continúa y escribe: 77

volatile hace observable la escritura del experimento para el compilador; no concede permisos ni sincroniza procesos. El hijo hereda la protección R y el mecanismo copy-on-write no la evita: una copia privada solo puede crearse para una escritura autorizada por el mapeo. Además, cambiar permisos en el padre después del fork no cambia los permisos del hijo.

Un manejador que simplemente retorna después de este SIGSEGV no soluciona el problema: la instrucción suele repetirse y fallar otra vez. Tampoco debe usarse printf como si fuera seguro dentro de un manejador de señales. Para esta práctica, observar la terminación del hijo es suficiente.

7. Páginas de guarda

Una página de guarda es una página deliberadamente inaccesible junto a una región utilizable. Si un acceso alcanza esa página, se detecta mediante una falla de protección. Es una técnica útil en pilas, asignadores y herramientas de depuración.

Podemos reservar tres páginas con PROT_NONE y habilitar RW solo en la central. No se crean huecos libres alrededor: las dos guardas siguen ocupando direcciones virtuales y no pueden reutilizarse para otros mapeos normales mientras se conserven.

guardas.c · una página de datos entre dos guardas
#define _DEFAULT_SOURCE
#include <stdio.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>

int main(void)
{
    long n = sysconf(_SC_PAGESIZE);
    if (n <= 0) {
        fprintf(stderr, "Tamaño de página no disponible\n");
        return 1;
    }
    size_t pagina = (size_t) n;
    size_t total = 3 * pagina;
    unsigned char *base = mmap(NULL, total, PROT_NONE,
        MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (base == MAP_FAILED) { perror("mmap"); return 1; }
    unsigned char *datos = base + pagina;
    if (mprotect(datos, pagina, PROT_READ | PROT_WRITE) == -1) {
        perror("mprotect");
        munmap(base, total);
        return 1;
    }
    memset(datos, 'A', pagina);
    printf("PID: %ld; base: %p; datos: %p\n",
           (long) getpid(), (void *) base, (void *) datos);
    puts("Regiones: [sin acceso] [lectura/escritura] [sin acceso]");
    puts("Pulsa Enter para liberar las tres páginas.");
    getchar();

    /* datos[pagina] = 'X'; alcanzaría la guarda derecha. */
    if (munmap(base, total) == -1) { perror("munmap"); return 1; }
    return 0;
}

Se libera base con el tamaño total, no solo datos: las guardas también pertenecen a la reserva. Para comprobar la falla, se puede adaptar el patrón de fork del ejemplo anterior y realizar la escritura a la guarda únicamente en el hijo.

La detección tiene granularidad de página. Si un objeto de 100 bytes está al comienzo de una página de 4096, escribir en el byte 101 continúa dentro de la página RW y no dispara la guarda. También puede escaparse un acceso que salte por encima de ella hasta otra región válida. Las guardas ayudan a detectar ciertos desbordamientos; no reemplazan la comprobación de límites ni herramientas como AddressSanitizer.

8. Permiso de ejecución y W^X

Leer bytes y ejecutarlos como instrucciones son operaciones diferentes. El código suele estar en páginas RX y los datos en páginas RW. Con soporte de protección de ejecución, intentar ejecutar desde una página sin ese permiso provoca una falla.

La política W^X expresa que una página no debe ser escribible y ejecutable al mismo tiempo. Un generador de código puede necesitar una etapa RW para producir instrucciones y otra RX para ejecutarlas:

secuencia conceptual · no es un programa ejecutable
1. mmap(..., PROT_READ | PROT_WRITE, ...)
2. Generar instrucciones válidas para la arquitectura
3. Sincronizar la caché de instrucciones si la plataforma lo requiere
4. mprotect(..., PROT_READ | PROT_EXEC)
5. Publicar y ejecutar el código con la sincronización adecuada

No basta con copiar cualquier secuencia de bytes y convertir un puntero a datos en uno a función de forma supuestamente portable. Intervienen la arquitectura, la ABI, las cachés y las políticas de seguridad. Linux puede rechazar determinadas transiciones a ejecutable. Para los ejemplos de datos de este tema basta con R, RW y NONE.

9. Simulador de permisos

Este modelo tiene cuatro páginas anónimas de 4096 bytes. Selecciona un intervalo para cambiar su protección y prueba una lectura o escritura por desplazamiento. Cada acceso se considera una prueba independiente: una violación se registra como SIGSEGV simulado y puedes continuar experimentando, como si repitieras la prueba en otro hijo.

Simulador · memoria anónima y protección por página

PáginaBytesPermisosEstado físico del modelo

Pruebas: 0 · Fallos menores resueltos: 0 · Violaciones: 0

#OperaciónResultado

Prueba esta secuencia: lee el byte 0, escríbelo y vuelve a escribirlo. El modelo pasa de «sin materializar» a «ceros compartidos» y luego a «privada». Protege la página 0 con R y vuelve a escribir: ahora hay una violación. Aplica NONE y después RW: el estado físico se conserva. Reinicia y protege desde la página 3 una cantidad de 2: el intervalo se rechaza sin cambiar el modelo.

El simulador distingue un fallo menor resuelto de una violación de acceso. Simplifica Linux: no modela swap, TLB, páginas grandes, valores de los bytes ni fallos de asignación. Un estado «privada» significa que se materializó una página para escritura; no representa una medición de RSS.

10. Observar las regiones en Linux

Compila guardas.c y déjalo esperando Enter. En otra terminal consulta el PID que imprime y localiza las direcciones mostradas:

terminal · ejemplo con páginas de 4096 bytes
$ gcc -std=c17 -Wall -Wextra -O2 guardas.c -o guardas
$ ./guardas
PID: 4217; base: 0x7f5000000000; datos: 0x7f5000001000
Regiones: [sin acceso] [lectura/escritura] [sin acceso]
Pulsa Enter para liberar las tres páginas.
$ cat /proc/4217/maps
... extracto ilustrativo ...
7f5000000000-7f5000001000 ---p 00000000 00:00 0
7f5000001000-7f5000002000 rw-p 00000000 00:00 0
7f5000002000-7f5000003000 ---p 00000000 00:00 0

Los tres primeros caracteres indican lectura, escritura y ejecución. La p final indica mapeo privado. La ausencia de una ruta y un inodo cero son habituales en estas regiones anónimas. El núcleo puede fusionar regiones vecinas compatibles; el listado exacto y las direcciones variarán.

terminal · seguir las llamadas del programa de protección
$ strace -f -e trace=mmap,mprotect,munmap ./proteccion
... llamadas de inicio del programa ...
mmap(NULL, 4096, PROT_READ|PROT_WRITE,
     MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f...
mprotect(0x7f..., 4096, PROT_READ) = 0
[hijo] --- SIGSEGV {si_code=SEGV_ACCERR, ...} ---
mprotect(0x7f..., 4096, PROT_READ|PROT_WRITE) = 0
munmap(0x7f..., 4096) = 0

-f permite seguir también al hijo. SEGV_ACCERR indica una dirección mapeada con permisos incompatibles; SEGV_MAPERR indica una dirección sin mapeo. Los fallos menores resueltos por el núcleo no aparecen como una señal por acceso en strace. Para contarlos puedes usar /usr/bin/time -v ./anonima o getrusage, recordando que también incluyen actividad de arranque y otras regiones del proceso.

11. Errores frecuentes y costos

SituaciónConsecuencia o corrección
Dirección inicial desalineadamprotect falla con EINVAL; usar una base alineada
Intervalo que incluye páginas sin mapearEn Linux puede fallar con ENOMEM; comprobar previamente el intervalo propio
Protección incompatible con el respaldoPuede fallar con EACCES, por ejemplo al pedir escritura compartida sobre un archivo abierto solo para lectura
Escribir después de aplicar RSIGSEGV, aunque el puntero conserve un tipo modificable
Usar NONE como si fuera munmapLa región sigue reservada y los datos no se borran
Cambiar permisos y asumir sincronizaciónNo reemplaza mutex, atomics ni un protocolo entre hilos
Ignorar un error de mprotectNo se puede asumir que se aplicó la protección solicitada ni usar el intervalo sin revisar su estado

Proteger solo el centro de una región puede dividir una VMA en varias, como en el ejemplo de las guardas. Muchos cambios pequeños aumentan la cantidad de regiones y pueden alcanzar límites del sistema. Además, actualizar protecciones puede exigir modificar tablas de páginas e invalidar entradas de TLB en las CPU pertinentes. Por eso mprotect es apropiado para transiciones de estado de regiones, no para sustituir una comprobación de límites en cada acceso.

Todos los hilos de un proceso comparten el espacio de direcciones: cambiar la protección afecta a los demás hilos que acceden a esas páginas. Antes de retirar acceso o ejecución se necesita coordinar su uso. La protección frente a errores accidentales tampoco aísla código hostil dentro del mismo proceso que pueda volver a solicitar otros permisos.

Para contrastar las interfaces y sus casos límite, consulta los manuales de Linux de mmap y munmap, mprotect y waitpid.

12. Resumen

13. Preguntas de repaso

Intenta responder antes de desplegar cada explicación.

1. ¿Qué significa que un mapeo sea anónimo?

Que su contenido inicial no proviene de un archivo ordinario. Un nuevo mapeo anónimo entrega bytes a cero; no significa que sea público ni que carezca de propietario.

2. ¿Por qué mmap puede aumentar mucho la memoria virtual sin aumentar igual el RSS?

Porque crear el intervalo virtual no exige disponer de inmediato de una página física privada por cada página virtual. La materialización suele realizarse bajo demanda.

3. ¿Cuál es la diferencia entre MAP_FAILED y el error de mprotect?

mmap devuelve un puntero y utiliza MAP_FAILED para el error. mprotect devuelve un entero: cero si funciona y -1 si falla.

4. Con páginas de 4096, ¿qué protege mprotect(base, 4097, PROT_READ)?

Las dos primeras páginas completas, suponiendo una base alineada y ambas páginas mapeadas. El byte con offset 4096 ya está en la segunda página.

5. ¿PROT_NONE libera o borra el contenido?

No. Conserva la región y retira el acceso. Al recuperar permisos, los datos previos siguen disponibles. Para quitar el mapeo se usa munmap.

6. ¿Por qué la escritura del hijo no activa simplemente copy-on-write?

Porque la región está protegida contra escritura. Copy-on-write resuelve escrituras autorizadas sobre páginas compartidas; no concede un permiso que el mapeo no tiene.

7. ¿Una guarda detecta cualquier desbordamiento de un arreglo?

No. Un acceso fuera del arreglo que todavía cae dentro de la página RW no la alcanza. Un salto que atraviese la guarda y termine en otra región accesible también puede escapar a la detección.

8. ¿Por qué se crea un hijo para provocar SIGSEGV?

Para aislar la terminación y observarla con waitpid desde el padre. No hace falta intentar recuperar el contexto de una instrucción que violó los permisos.

9. ¿mprotect afecta solo al hilo que lo llama?

No. Los hilos comparten las regiones del proceso. Los otros hilos que usan esas páginas también quedan sujetos a la nueva protección.

10. ¿Qué expresa W^X?

Que una página no debe ser escribible y ejecutable a la vez. Un generador de código puede escribir durante una etapa RW y pasar luego a RX, atendiendo a los requisitos de la plataforma.

Para practicar

  1. Compila los tres programas. Comprueba los ceros iniciales, la terminación del hijo y las regiones de las guardas en /proc/PID/maps.
  2. En proteccion.c, sustituye la protección R por NONE y haz que el hijo intente una lectura mediante un puntero volatile. Explica el resultado.
  3. Escribe 42, aplica NONE, restaura RW y verifica que el valor continúa siendo 42 sin acceder mientras la página está protegida.
  4. Reserva tres páginas RW y protege solo la central como R. Prueba escrituras en cada página utilizando un hijo distinto para cada intento.
  5. Calcula las páginas afectadas por un intervalo de 6000 bytes que comienza en el offset 7000, con páginas de 4096. Comprueba que son las páginas 1, 2 y 3.
  6. Adapta el ejemplo de guardas para tener dos páginas centrales utilizables y una guarda en cada extremo. Conserva la base y el tamaño total para liberar todo.
  7. En el simulador, escribe en una página, aplica NONE, restaura R y lee. Explica por qué ya no hay otro fallo de materialización en este modelo.
  8. Compara con getrusage la reserva de una región grande, una lectura por página y una escritura por página. Usa un tamaño moderado y explica las limitaciones de las mediciones.