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.
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.
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.
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:
ld
) dispone codice e dati del
programma in memoria virtuale in base alle direttive che gli vengono
passate./lib/ld.so
) dispone
in memoria virtuale le librerie dinamiche e i loro dati.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 -h
(«header»).
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
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.
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:
start
: l'indirizzo virtuale dove si vuole la nuova
mappa; se non usato è convenzione passare 0.length
: la dimensione della mappa. Questo valore deve
essere un multiplo della dimensione di pagina.prot
: i permessi di accesso richiesti: lettura, scrittura,
esecuzione.flags
: vari bit di opzione:
MAP_SHARED
oppure MAP_PRIVATE
(descritti
nell'articolo); MAP_FIXED
se si richiede di usare
come indirizzo virtuale quello
passato come primo argomento; MAP_ANONYMOUS
fd
: il descrittore di file, l'argomento è ignorato
per le mappe anonime.offset
: la posizione del file cui si vuole
accedere come numero di byte dall'inizio del file. Questo valore deve
essere un multiplo della dimensione di pagina.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:
/dev/mem
), il controllo viene passato al driver relativo,
che dovrà segnalare a quale indirizzo fisico corrisponde
l'indirizzo virtuale dove si è verificata l'eccezione.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.
#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 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.
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.
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.