La memoria virtuale

di Alessandro Rubini

Riprodotto con il permesso di Linux & C, Edizioni Vinco.

La memoria virtuale è una delle più importanti caratteristiche dei processori moderni, ma il suo funzionamento rimane spesso abbastanza oscuro perché risulta difficile vederne l'applicazione pratica.

In queste pagine si cerca di descrivere come funziona e come si usa la memoria virtuale nei sistemi GNU/Linux, limitando la discussione ai sistemi a 32 bit (nel caso del PC questo significa trascurare le complicazioni introdotte con il PAE, Physical Address Estension). Allo stesso modo, poiché la completezza è nemica della chiarezza, ignoreremo tutte le complicazioni introdotte dai vari livelli di memoria cache e gli accessi alle cosiddette "porte di I/O", che comunque esistono solo sui processori x86. I concetti generali sono immutati da Linux 2.1 in poi, ma alcuni dettagli implementativi possono variare tra versioni del kernel o di glibc, quindi gli indirizzi virtuali mostrati sulla vostra macchina possono essere diversi da quelli mostrati in queste pagine.

I due indirizzi

Ogni volta che un microprocessore deve accedere alla memoria, per eseguire una istruzione di programma o per scambiare dati tra i registri e memoria esterna, l'unità di calcolo richiede la lettura o scrittura del dato ad un indirizzo a 32 bit; tale indirizzo è cioè un numero compreso tra 0 e 4GB.

Perché il trasferimento del dato abbia effettivamente luogo, verranno inviati dei segnali elettrici su un bus esterno, in modo che qualche altro componente (memoria RAM o dispositivo periferico) riconosca la richiesta e risponda in modo appropriato. Sul bus esterno vengono normalmente posti oltre al dato anche 32 bit di indirizzo, utilizzati per identificare sia il componente esterno sia la parola di memoria cui accedere al suo interno.

Questi due indirizzi, nonostante siano entrambi numeri binari di 32 bit, non sono lo stesso numero. Il primo si chiama indirizzo virtuale (talvolta detto "indirizzo lineare" nel mondo x86) mentre il secondo si chiama indirizzo fisico. La figura 1 rappresenta gli indirizzi fisici come disposti in due processori diversi e gli indirizzi virtuali come disposti in Linux, su entrambi questi due processori.



Figura 1
Relazione tra indirizzi virtuali e indirizzi fisici in due processori diversi.

La corrispondenza tra indirizzi virtuali e indirizzi fisici è definita dalle tabelle di paginazione, diverse per ogni processo e gestite dal kernel, come descritto nell'articolo su /dev/mem pubblicato nel numero 58 della rivista.

Durante il normale funzionamento del sistema, tutti gli indirizzi di memoria usati dai programmi sono indirizzi virtuali, senza alcuna eccezione, sia quando si lavora in spazio utente sia quando si lavora in spazio kernel. Tutti i componenti esterni all'unità di calcolo, invece, rispondono agli indirizzi fisici sul bus, indipendentemente da quale indirizzo virtuale sia stato usato per generarli.

La memoria virtuale, perciò, non è «un modo per credere di avere piu RAM di quanta se ne possieda», come spesso si dice, ma un modo per costruire i 4GB di indirizzamento di un programma a proprio piacimento, in base alle esigenze di programmazione senza dipendere troppo dalla struttura fisica della macchina su cui si lavora.

Riquadro 1 - Accesso diretto in memoria.

Normalmente, il programmatore può usare solo indirizzi virtuali, senza preoccuparsi di quali siano gli indirizzi fisici corrispondenti. Un'eccezione a questo è la gestione di un'operazione DMA. Si chiama Direct Memory Access un trasferimento dati tra la periferica (per esempio un controllore USB) e la memoria RAM; il processore in questo caso deve istruire il dispositivo esterno riguardo al trasferimento e successivamente rispondere ad una interruzione che comunica la fine del trasferimento stesso.

Poiché il dispositivo periferico accede direttamente in memoria RAM, il device driver deve programmare il traferimento usando indirizzi fisici, anche l'accesso ai dati stessi viene poi effettuato tramite indirizzi virtuali, come tutti gli accessi effettuati dal processore.

Nonostante non capiti tutti i giorni di istruire un'operazione di DMA, è interessante notare come in questo caso il programmatore non possa ignorare il dualismo fisico/virtuale, in quanto lo stesso driver deve predisporre sia accessi in memoria mascherati dalla MMU sia accessi che avvengono al di là di tale meccanismo di virtualizzazione.

Costruzione dello spazio virtuale in GNU/Linux

Lo spazio virtuale, i 4GB indirizzabili dal processore, è separato in due parti: gli indirizzi più bassi, normalmente fino a 3GB, sono a disposizione del processo, mentre gli indirizzi più alti sono "privilegiati": l'accesso è consentito al solo nucleo del sistema operativo.

Poiché il kernel è il tramite della comunicazione tra i processi e dell'accesso al filesystem, ha bisogno di un'area di memoria condivisa che sia sempre accessibile, sia eseguendo le chiamate di sistema per conto dei processi sia durante la gestione delle interruzioni.

L'ultimo gigabyte di spazio virtuale, perciò, contiene il codice del kernel, tutte le sue struture dati e la memoria delle periferiche cui devono accedere i driver di sistema. Per semplicità, la prima parte di questo GB è direttamente mappata sulla memoria RAM del sistema. Tramite opportuni bit nelle tabelle di paginazione l'accesso agli indirizzi virtuali più alti è impedito ai processi utente.

La costruzione dello spazio virtuale per gli indirizzi non privilegiati è invece a totale discrezione del processo. L'assegnamento delle varie aree di indirizzi viene effettuato in varie fasi:

La mappa di memoria iniziale di un programma, quella attivata da execve, viene a sua volta costruita in due fasi:

/proc/<pid>/maps

La mappa di memoria virtuale di ogni processo è sempre disponibile nel file maps, nella directory relativa al processo stesso in /proc. Ogni riga del file rappresenta un'area omogenea di memoria virtuale, caratterizzata da un intervallo di indirizzi, permessi di accesso e talvolta un file su disco a cui si riferisce. Per esempio, il comando cat, sulla mia macchina, contiene le seguenti aree di memoria virtuale:

08048000-0804c000 r-xp 00000000 03:03 628994     /bin/cat
0804c000-0804d000 rw-p 00003000 03:03 628994     /bin/cat
0804d000-0806e000 rw-p 0804d000 00:00 0          [heap]
b7db8000-b7db9000 rw-p b7db8000 00:00 0
b7db9000-b7ee3000 r-xp 00000000 03:03 469307     /lib/tls/libc-2.3.2.so
b7ee3000-b7eec000 rw-p 00129000 03:03 469307     /lib/tls/libc-2.3.2.so
b7eec000-b7eee000 rw-p b7eec000 00:00 0
b7f06000-b7f07000 rw-p b7f06000 00:00 0
b7f07000-b7f1d000 r-xp 00000000 03:03 695564     /lib/ld-2.3.2.so
b7f1d000-b7f1e000 rw-p 00015000 03:03 695564     /lib/ld-2.3.2.so
bfb0f000-bfb25000 rw-p bfb0f000 00:00 0          [stack]
ffffe000-fffff000 ---p 00000000 00:00 0          [vdso]

Le prime due righe si riferiscono a codice e dati del programma, a partire dall'indirizzo virtuale 0x08048000 (poco oltre i 128MB), con permessi di accesso r-x per il codice ed rw- per i dati. Questi indirizzi e permessi sono stati assegnati dal compilatore, e sono visibili nell file eseguibile ELF, tramite il comando objdump -hheader»).

Gli altri campi numerici in ogni riga si riferiscono alla relazione tra la memoria virtuale e i file su disco, ove presenti: rappresentano l'offset all'interno del file, il numero del dispositivo che ospita il file (qui /dev/hda3) e il numero di inode del file all'interno del filesystem.

Le altre righe nella tabella mostrano, oltre ad aree di memoria cosiddetta anonima, altre coppie di codice e dati, relative al dynamic loader e alle librerie dinamiche; di queste non c'è traccia nell'esguibile /bin/cat, in quanto vengono create durante la complessa procedura di caricamento del programma stesso.

Nel riquadro 2 è mostrato lo stesso comando eseguito su una macchina ARM con un sistema ridotto: si noti come la disposizione in memoria virtuale delle varie aree, pur rimanendo sotto i 3GB, sia notevolmente diversa. Qui, cat è parte di busybox e per questo motivo è collegato anche a libm e libcrypt.

Riquadro 2 - Mappe di memoria su un processore ARM


# grep Processor /proc/cpuinfo
Processor       : ARM920Tid(wb) rev 0 (v4l)
# uname -r
2.6.12
# cat /proc/self/maps
00008000-000bd000 r-xp 00000000 01:00 13         /bin/cat
000c4000-000c6000 rw-p 000b4000 01:00 13         /bin/cat
000c6000-000ce000 rwxp 000c6000 00:00 0          [heap]
40000000-40015000 r-xp 00000000 01:00 37         /lib/ld-2.3.2.so
40015000-40016000 rw-p 40015000 00:00 0
4001c000-4001d000 rw-p 00014000 01:00 37         /lib/ld-2.3.2.so
4001d000-40022000 r-xp 00000000 01:00 35         /lib/libcrypt-2.3.2.so
40022000-40025000 ---p 00005000 01:00 35         /lib/libcrypt-2.3.2.so
40025000-4002a000 rw-p 00000000 01:00 35         /lib/libcrypt-2.3.2.so
4002a000-40051000 rw-p 4002a000 00:00 0
40051000-400c0000 r-xp 00000000 01:00 33         /lib/libm-2.3.2.so
400c0000-400c1000 ---p 0006f000 01:00 33         /lib/libm-2.3.2.so
400c1000-400c8000 rw-p 00068000 01:00 33         /lib/libm-2.3.2.so
400c8000-401db000 r-xp 00000000 01:00 31         /lib/libc-2.3.2.so
401db000-401e0000 ---p 00113000 01:00 31         /lib/libc-2.3.2.so
401e0000-401e7000 rw-p 00110000 01:00 31         /lib/libc-2.3.2.so
401e7000-401ea000 rw-p 401e7000 00:00 0
beefc000-bef11000 rwxp beefc000 00:00 0          [stack]

A riprova che la memoria virtuale viene definita in compilazione, ho compilato su x86 un programma statico minimalista, il cui sorgente è mostrato nel listato 1, usando due file di configurazione per il linker: in un caso il codice viene posto all'indirizzo 0 e nell'altro all'indirizzo 2GB. L'output del programma è, come al solito, il contenuto di /proc/self/maps. Il risultato è riportato nel riquadro 3.

Listato 1 - Il programma cat.c (compilare con catlow.lds)


#define FILENAME "/proc/self/maps"

/* define those to avoid errors in unistd.h */
int errno;
typedef int off_t;
typedef int pid_t;

/* force syscalls to be defined */
#define __KERNEL_SYSCALLS__
#include <asm/unistd.h>

#define BSIZE 4096
char buf[BSIZE];

void catmain(void)
{
    int fd, i;
    fd = open(FILENAME, 0 /* O_RDONLY */, 0);
    i = read(fd, buf, BSIZE);
    write(1, buf, i);
    _exit(0);
}

Riquadro 3 - Mappe di memoria generate da linker script personalizzati.


favonio% /tmp/catlow
00000000-00001000 rwxp 00001000 03:03 774830     /tmp/catlow
00001000-00002000 rw-p 00001000 00:00 0          [heap]
bfaf9000-bfb0f000 rw-p bfaf9000 00:00 0          [stack]
ffffe000-fffff000 ---p 00000000 00:00 0          [vdso]
favonio% /tmp/cathigh
80000000-80001000 rwxp 00001000 03:03 774851     /tmp/cathigh
80001000-80002000 rw-p 80001000 00:00 0          [heap]
bfb80000-bfb96000 rw-p bfb80000 00:00 0          [stack]
ffffe000-fffff000 ---p 00000000 00:00 0          [vdso]

Il sorgente, con il Makefile ed il semplice linker script, si possono scaricare da http://www.linux.it/kerneldocs/vmem/src.tar.gz. Anche se la descrizione del linker script esula dall'argomento di questo mese, la sua comprensione e modifica è abbastanza semplice se si usa la documentazione di binutils e gli esempi dispobinili, come quelli che si trovano nei sorgenti del kernel

Il server grafico su x86

Nella maggior parte dei casi, i programmi applicativi non agiscono direttamente sulla propria immagine di memoria virtuale; le modifiche introdotte sono effetti collaterali di normali operazioni come malloc.

Nel caso del server grafico, invece, oltre a tutte le librerie dinamiche cui è collegato l'eseguibile, troviamo alcune righe particolarmente interessanti, che si riferiscono allo stesso file /dev/mem di cui si è parlato nel numero 58:

favonio% grep /dev/mem /proc/`pidof X`/maps
000a0000-000c0000 rwxs 000a0000 00:0e 2759       /dev/mem
000f0000-00100000 r-xs 000f0000 00:0e 2759       /dev/mem
b3caa000-b7caa000 rwxs f0000000 00:0e 2759       /dev/mem
b7caa000-b7d2a000 rwxs e7800000 00:0e 2759       /dev/mem

Ricordando che il numero esadecimale dopo i permessi di acesso indica l'offset all'interno del file /dev/mem, la terza e la quarta riga indicano come il server X «veda» gli indirizzi fisici 0xf0000000 e 0xe7800000 nel suo spazio di indirizzamento. Una semplice verifica con lspci -v mostra che le due zone di memoria fisica siano proprio relative alla scheda video:

favonio% lspci -v | egrep 'VGA|Memory' | tail -3
0000:01:00.0 VGA compatible controller
        Memory at f0000000 (32-bit, prefetchable) [size=128M]
        Memory at e7800000 (32-bit, non-prefetchable) [size=64K]

La prima di queste aree fisiche corrisponde alla memoria grafica vera e propria (il frame buffer) e serve ad X11 per disegnare i pixel sullo schermo. La seconda area, più piccola, contiene i registri di controllo, dove vengono inviati per esempio i comandi per le accelerazioni grafiche.

Le prime due mappe virtuali di X su /dev/mem, invece, rappresentano la soluzione del server ad un serio problema delle schede video per PC: la periferica include una memoria ROM (comunemente detta «VGA BIOS») che deve essere eseguita per svolgere sull'hardware alcune operazioni non altrimenti documentate. Questo codice risiede all'indirizzo fisico 0xa0000 (640k) e può essere eseguito solo a tale indirizzo (questo, tra l'altro, rende arduo montare due schede video sulla stessa macchina). Proprio per la necessità di eseguire il programma in ROM, una comune scheda video PCI può funzionare su processori diversi dal PC solo dopo l'installazione di un interprete di codice macchina x86.

Poiché, all'interno del server X, l'indirizzo virtuale 0xa0000 corrisponde all'indirizzo fisico 0xa0000, è possibile per il programma eseguire le procedure all'interno del BIOS, anche se tale codice obsoleto è pensato per eseguire senza memoria virtuale. Lo stesso trucco è usato da X11 per eseguire il BIOS della piastra madre, all'indirizzo 0xf0000.

La chiamata di sistema mmap

Il meccanismo comunemente usato dai processi per modificare la propria mappa virtuale è la chiamata di sistema mmap, con la quale si richiede al sistema operativo di vedere nel proprio spazio di memoria il contenuto di un file o di un dispositivo, oppure ottenere indirizzi di memoria anonima, cioe RAM, non associata ad un file. Il meccanismo è talmente flessibile che anche la memoria condivisa (chiamata di sistema shmat) e la normale allocazione (chiamata di sistema brk) si appoggiano sul codice che implementa mmap.

Gli argomenti passati ad mmap sono descritti brevemente nel riquadro 4, mentre il listato 2 mostra un esempio di uso della chiamata di sistema. Il programma richiede due mappe su /dev/zero e una mappa anonima, una delle mappe è richiesta ad un indirizzo specifico (2GB).

L'esecuzione del programma su un sistema Linux-2.4 mostra il seguente output:

mapped /dev/zero at 0x40018000
mapped /dev/zero at 0x80000000
mapped anonymous at 0x40023000
08048000-08049000 r-xp 00000000 03:41 144902     /tmp/showmap
08049000-0804a000 rw-p 00000000 03:41 144902     /tmp/showmap
40000000-40016000 r-xp 00000000 03:41 320124     /lib/ld-2.3.2.so
40016000-40017000 rw-p 00015000 03:41 320124     /lib/ld-2.3.2.so
40017000-40018000 rw-p 00000000 00:00 0
40018000-40022000 r--p 00000000 03:41 128387     /dev/zero
40022000-40023000 rw-p 00000000 00:00 0
40023000-4002d000 rwxp 00000000 00:00 0
4002e000-40156000 r-xp 00000000 03:41 320127     /lib/libc-2.3.2.so
40156000-4015e000 rw-p 00127000 03:41 320127     /lib/libc-2.3.2.so
4015e000-40161000 rw-p 00000000 00:00 0
80000000-8000a000 r--p 00000000 03:41 128387     /dev/zero
bffff000-c0000000 rwxp 00000000 00:00 0

Come si nota, ci sono alcune differenze nella struttura della memoria di processo rispetto a quanto visto su Linux-2.6, ma i concetti qui descritti si applicano allo stesso modo.

Risulta ora intuitivo come il meccanismo usato dal server X per accedere al BIOS e alla memoria video sia proprio la chiamata di sistema mmap, così come mmap è la chiamata di sistema usata dal kernel per eseguire ld.so, e da quest'ultimo per collegare le librerie dinamiche prima di passare il controllo al programma richiesto.

Listato 2 - showmap.c

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/mman.h>

int main(int argc, char **argv)
{
    char cmd[128];
    int fd; void *addr;
    int pagesize = getpagesize();

    /* use a readable file */
    fd = open("/dev/zero", O_RDONLY);
    if (fd < 0) exit(1);

    /* map at any address */
    addr = mmap(0, 10*pagesize, PROT_READ,
		MAP_PRIVATE, fd, 0);
    printf("mapped /dev/zero at %p\n", addr);

    /* map it again at a chosen address (2G) */
    addr = mmap((void *)(2<<30), 10*pagesize, PROT_READ,
		MAP_PRIVATE | MAP_FIXED, fd, 0);
    printf("mapped /dev/zero at %p\n", addr);
    close(fd);

    /* map anonymous memory */
    addr = mmap(0, 10*pagesize, PROT_READ | PROT_WRITE | PROT_EXEC,
		MAP_PRIVATE | MAP_ANONYMOUS, 0, 0);
    printf("mapped anonymous at %p\n", addr);

    /* show our map, lazily */
    sprintf(cmd, "cat /proc/%i/maps", getpid());
    system(cmd);
    exit(0);
}

Riquadro 4 - La chiamata di sistema mmap.

La chiamata di sistema mmap ritorna un puntatore generico e riceve sei argomenti, secondo questa dichiarazione:
void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);

Il significato degli argomenti è il seguente:

La paginazione

Dopo l'esecuzione di mmap, normalmente, il file richiesto non è stato ancora caricato in memoria: sono solo state predisposte le page table per quegli indirizzi virtuali, ma ogni pagina è marcata come «assente» tramite un bit di stato. Le tabelle di paginazione erano già state presentate nel numero 58 dal prof. Bovet ed il bit di presenza è proprio un campo di tale struttura dati.

Nel momento in cui il processo accede ad un indirizzo all'interno di una pagina non presente, il processore genera una eccezione, che assomiglia all'interruzione di una periferica esterna, e il controllo passa al sistema operativo perché risolva il problema. Se l'indirizzo fa parte di un'area di memoria valida, si presentano questi due casi:

In questo modo, il caricamento di un programma può essere dilazionato nel tempo, in base alle effettive necessità, evitando anzi di caricare in memoria le parti di codice che non vengano usate in quella particolare istanza d'uso. Allo stesso modo, un programma può accedere a parti di un grosso file senza doverlo caricare tutto in memoria: facendo mmap invece di read si ha immediatamente accesso a tutto il file, ma solo le parti che vengono effettivamente usate saranno trasferite dal disco. Inoltre, se il sistema si trova a corto di memoria, può semplicemente liberare le pagine di mmap, senza bisogno di salvarne il contenuto in spazio di swap; in caso di bisogno la pagina sarà recuperata nuovamente dal disco, come la prima volta.

Ci sono però situazioni in cui questo meccanismo di «demand paging» non è auspicabile: se un processo deve avere delle garanzie sui suoi tempi di esecuzione, evitando latenze inaspettate dovute a page fault, il meccanismo descritto risulta potenzialmente dannoso. È possibile perciò richiedere che la memoria di un processo sia sempre presente in memoria; a tal fine sono definite le chiamate di sistema mlock, mlockall ed munlock. Inoltre, nell'invocazione di mmap può essere passato il parametro MAP_LOCKED. Quando si richiede che un'area di memoria virtuale sia bloccata in memoria fisica, il kernel simula subito i page fault per tutte le pagine dell'area interessata e impedisce che queste pagine vengano liberate prima che il processo termini o le rilasci esplicitamente.

Quando un processo accede ad un indirizzo di memoria per cui la CPU segnala un'eccezione e il sistema operativo non trova una corrispondenza nelle tabelle di memoria, il processo riceve un SIGSEGV: il segnale di segmentation violation, altrimenti detto segmentation fault, che provoca la morte violenta del processo. Il segnale può essere intercettato: per esempio il server X stampa informazioni diagnostiche che possano aiutare a correggere il problema. L'invio del segnale è quello che succede, per esempio, quando si dereferenzia un puntatore nullo.

È interessante notare come non ci sia nessuna gestione speciale per l'indirizzo zero, che è trattato come tutti gli altri indirizzi sia dal processore sia dal sistema operativo. Tramite una mmap opportuna, perciò, è possibile usare senza errori il puntatore nullo, come mostrato dal programma nullptr.c (listato 3) che scrive e legge il valore 1 all'indirizzo zero, o da catlow che esegue all'indirizzo zero.

Listato 3 - nullptr.c


#include <stdio.h>
#include <unistd.h>
#include <sys/mman.h>

int main(int argc, char **argv)
{
    int *ptr = NULL;
    int pagesize = getpagesize();

    mmap(0, pagesize, PROT_READ | PROT_WRITE,
	 MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, 0, 0);
    *ptr = 1;
    printf("reading null pointer: %i\n", *(int *)NULL);
    return 0;
}

La scrittura sulla memoria virtuale

La gestione dell'accesso in scrittura alle aree di memoria virtuale è alla base di tutti i sistemi di memoria condivisa, ma anche dell'implementazione della chiamata di sistema fork e dell'uso efficiente della page cache, la struttura dati in memoria che ospita le pagine dei file su disco attualmente in uso o usate da poco.

Ad alto livello, una zona di memoria può essere dichiarata privata o condivisibile (MAP_PRIVATE e MAP_SHARED nel caso di mmap): se è privata, le modifiche fatte ai dati rimangono locali al processo che le effettua, altrimenti si comporta come zona di memoria condivisa; è questo il quarto bit dei permessi nel file maps, dopo rwx; negli esempi precedenti solo le mappe di /dev/mem effettuate dal server X erano marcate come shared.

A più basso livello, i processori dotati di un meccanismo di paginazione gestiscono due bit di stato associati ad ogni pagina: un bit di protezione in scrittura e uno di «pagina sporca». Quando il programma effettua un accesso in scrittura, l'hardware genera un'eccezione detta «minor page fault» se la pagina è protetta. Se la scrittura è invece consentita, viene acceso il bit di pagina sporca se non è già attivo. Chiaramente, se il «dirty bit» non fosse implementato, si potrebbe comunque simularlo via software.

Appoggiandosi su queste primitive hardware si implementa il meccanismo di «Copy on Write»: quando più processi devono accedere alla stessa pagina di memoria senza condividere i dati, la pagina viene condivisa comunque, ma protetta in scrittura. Quando uno dei processi cercasse di modificare i dati in quella pagina, il sistema operativo in risposta all'eccezione farebbe una nuova copia della pagina, cambiando la tabella di paginazione del processo e attivando il permesso in scrittura su tale copia, non più condivisa.

Perciò, quando viene eseguita l'ennesima copia di bash, il processo vede nel suo spazio di indirizzamento le pagine della page cache relative al file su disco, tutte protette in scrittura a livello di tabelle di paginazione; le pagine eseguibili sono protette in scrittura anche ad alto livello, mentre le pagine dei dati verranno copiate e rese scrivibili solo quando (e se) l'istanza del programma prova a modificarle. Lo stesso succede per le librerie di sistema, usate da tutti i processi in esecuzione ma presenti in memoria una sola volta. Naturalmente, un mmap su un dispositivo deve essere fatto con MAP_SHARED, altrimenti le scritture fatte dal programma non raggiungerebbero l'hardware, finendo invece in una copia locale in RAM della memoria della periferica.

Così, nella chiamata di sistema fork, che divide un processo in due processi uguali, viene semplicemente aumentato il contatore d'uso di ogni pagina, in quanto la separazione dei rispettivi dati è garantita dal meccanismo di copy on write.

Il bit di «pagina sporca», invece, viene usato per richiedere la scrittura su disco di quelle pagine in «page cache» che sono state modificate dai processi. Quando si fa mmap di un file in modalità MAP_SHARED, si ha perciò la garanzia che le modifiche effettuate sul file, anche se operate solo lavorando in memoria, senza usare write, saranno trasferite sul disco a tempo debito, oltre ad essere visibili immediatamente ad altri processi che stiano accedendo in modalità shared allo stesso file. Per le pagine private, il bit di pagina sporca viene usato in caso di carenza di memoria: se il sistema operativo deve liberare memoria RAM e la pagina del processo è pulita, basterà invalidarla; se la pagina è sporca bisognerà invece salvarla in spazio di swap per poterla ripristinare in futuro nello stato (modificato) in cui si trova.

Il meccanismo di copy on write è usato anche per la gestione della memoria anonima: quando viene fatta una allocazione tramite brk o mmap con MAP_ANONYMOUS, tutte le pagine vengono fatte puntare alla zero page (una pagina piena di zeri), con protezione in scrittura. Questo approccio garantisce che la RAM venga usata solo se effettivamente necessario, ma anche che tale memoria sia pulita, prevenendo automaticamente il travaso accidentale di informazioni da un processo all'altro.

Conclusione

La memoria virtuale, come abbiamo visto, è un concetto che permea profondamente il kenrel Linux e le sue strutture dati, a partire dalla costruzione di un file eseguibile ELF fino ai meccanismi che sottostanno ad una semplice malloc. Uno dei compiti del kernel Linux è quello di nascondere i meccanismi hardware sottostanti al codice applicativo e agli stessi driver del kernel; gli stessi concetti e le stesse primitive si applicano perciò a sistemi di paginazione basati su page table a 2 livelli (processori x86), a 3 livelli (processori Alpha), a hash table (PowerPC) o adirittura senza Memory Management Unit (ColdFire).

Il caso dei processori senza MMU è particolarmente interessante, perché mancando il supporto hardware per gli indirizzi virtuali, il codice lavoro solo con gli indirizzi fisici. Ci sono perciò alcune limitazioni nel sistema (per esempio, occorre rilocare in RAM i programmi per poterli eseguire), ma il programmatore può quasi sempre ignorare la differenza e pensare comunque in termini di indirizzi virtuali, anche se la divisione tra spazio utente e spazio kernel non si troverà a 3GB come al solito.

La forza di un'astrazione sta nella sua flessibilità, e il concetto di memoria virtuale rivela la sua forza anche nel poter essere applicato ai casi limite. L'uso in contesti senza MMU di codice progettato in un contesto di memoria virtuale rivela la versatilità dell'idea, ma anche l'intelligenza e la dedizione dei programmatori del kernel che hanno realizzato un sistema facilmente portabile tra piattaforme estremamente diverse.

Riquadro 4 - Approfondimenti

Ulteriori informazioni sull'uso quotidiano della memoria virtuale si trovano sulle pagine di manuale relative alle chiamate di sistema ("man 2 mmap" e similari).

A più basso livello, per l'implementazione dei meccanismi di paginazione all'interno della CPU, si vedano i manuali (data sheet) degli specifici processori, normalmente reperibili in rete sul sito del costruttore.

A più alto livello, la memoria virtuale è descritta nei testi di architettura dei calcolatori o di sistemi operativi, anche se spesso la discussione tende ad essere più teorica e matematica, coprendo aspetti che non si applicano letteralmente all'implementazione del kernel Linux.

Alessandro Rubini

La copia letterale e la redistribuzione su qualsiasi supporto di questo articolo nella sua integrità è permessa, purchè questa nota sia conservata.