C1. MEMORIA CENTRALE
Al fine di migliorare lo scheduling della CPU è necessario tenere in memoria parecchi
processi, rendendo condivisa la memoria. Vi sono diversi metodi per gestire la memoria,
tra cui spiccano paginazione e segmentazione. La scelta un metodo piuttosto che un altro è legata all’architettura del sistema.
Introduzione
La memoria è un ampio vettore di parole o byte, ciascuno con il proprio indirizzo. Nel
suo funzionamento, vede soltanto un flusso di indirizzi di memoria e non sa come questi
sono generati oppure a che cosa servono.
Dispositivi essenziali
La CPU può accedere direttamente a due sole aree di memorizzazione, ovvero i registri e
la memoria. Non è quindi possibile accedere direttamente ad un’area del disco, che
dev’essere prima caricata in memoria.
In generale i registri sono accessibili nell’arco di un colpo di clock. Questa considerazione non è valida per la memoria centrale, che richiede più di un colpo di clock per essere
consultata. Nella situazione più sfortunata, questo aspetto può portare a stalli del processore quando quest’ultimo ha bisogno di un dato contenuto in memoria per completare una certa operazione. Un buon palliativo è l’aggiunta all’interno del sistema di una
memoria cache, che funge da cuscinetto tra memoria centrale e CPU e ha prestazioni intermedie rispetto a quelle dei due attori coinvolti.
Oltre alle prestazioni, è necessario fare attenzione al sistema operativo che dev’essere
protetto dall’accesso dei processi utente. Questi ultimi, inoltre, devono essere protetti
l’un l’altro in modo che non si pestino i piedi. Un processo deve quindi poter accedere legalmente solo a un certo intervallo di indirizzi.
Questa protezione è fatta mediante due registri, base e limite: il primo contiene il più
piccolo indirizzo legale della memoria fisica, mentre il secondo determina la dimensione
dell’intervallo ammesso. Ogni indirizzo generato in modalità utente è confrontato con i
valori contenuti nei due registri. Quando l’indirizzo generato è “fuori dai margini” viene
inviato un segnale d’eccezione interpretato come errore fatale dal sistema operativo.
La modifica dei due registri è eseguibile solo dal sistema operativo, grazie a una particolare istruzione privilegiata eseguibile solo in modalità di sistema. Il sistema operativo
può dunque accedere indiscriminatamente sia alla memoria a lui riservata che a quella
degli utenti.
Associazione degli indirizzi
A seconda del tipo di gestione della memoria adoperato, un processo può essere trasferito dalla memoria al disco e viceversa. Tutti i processi che attendono di essere trasferiti
in memoria forma la cosiddetta coda d’ingresso, da cui viene
scelto uno dei processi e caricato in memoria.
Tranne che nei sistemi embedded, ogni processo utente può risiedere in qualsiasi parte della memoria fisica: anche se lo spazio di indirizzi del calcolatore inizia all’indirizzo 00000, il primo indirizzo del processo utente non deve essere per forza 00000.
Questo perché un programma utente, prima di essere eseguito,
deve passare attraverso vari stadi, ciascuno dei quali si trova tra
le mani indirizzi rappresentabili in modi diversi.
Gli indirizzi del programma sorgente sono simbolici (es.: int
pippo). Il compilatore associa mediante binding gli indirizzi simbolici a indirizzi rilocabili (es.: 14 byte dall’inizio di questo modulo). Il loader fa corrispondere gli indirizzi rilocabili a indirizzi assoluti (es.: 74014).
In generale, l’associazione di istruzioni e dati a indirizzi di memoria si può compiere in
qualsiasi fase del processo
• fase di compilazione
◦ si sa a priori dove il processo risiederà in memoria
◦ genera codice assoluto
◦ se per qualche motivo il processo dev’essere spostato, bisogna ricompilare
• fase di caricamento
◦ in fase di compilazione non è noto dove risiederà il processo in memoria
◦ genera codice rilocabile
◦ se per qualche motivo il processo dev’essere spostato, basta ricaricarlo
• fase di esecuzione
◦ durante l’esecuzione il processo dev’essere spostato da un segmento all’altro
◦ associazione ritardata fino alla fase di esecuzione
◦ richiede supporto da parte dell’architettura
Spazi di indirizzi logici e fisici a confronto
Durante il suo funzionamento, la CPU genera indirizzi logici, mentre l’unità di memoria
utilizza solamente indirizzi fisici. L’insieme di tutti gli indirizzi logici generati da un programma è perciò chiamato spazio degli indirizzi logici, e analogamente l’insieme di tutti
gli indirizzi fisici corrispondenti a tali indirizzi logici è chiamato spazio degli indirizzi fisici.
I metodi di associazione degli indirizzi nelle fasi di compilazione e caricamento producono indirizzi logici e fisici identici. Nell’associazione degli indirizzi in fase di esecuzione,
invece, gli indirizzi logici non corrispondono agli indirizzi fisici (e, per estensione, anche i
due spazi degli indirizzi sono differenti). In questo caso ci si riferisce agli indirizzi logici
con il termine indirizzi virtuali.
L’associazione in fase di esecuzione è svolta dalla MMU (Memory Management Unit) il
cui funzionamento è sulla falsariga di quello illustrato con i registri base e limite. Ogni
indirizzo generato dal programma (es.: 00346) viene sommato a un registro di rilocazione (es.: 14000) e inviato alla memoria.
L’utente genera dunque indirizzi logici ( 00000 ÷ max), che vengono convertiti in indirizzi
fisici (00000 + R ÷ max + R) prima di essere veicolati alla memoria.
Caricamento dinamico
Al fine di migliorare l’utilizzo della memoria si può ricorrere al meccanismo del caricamento dinamico, che prevede il caricamento di una procedura in memoria solo quando
questa viene richiamata. Il sistema operativo non deve intervenire in modo particolare
per gestire il caricamento dinamico, ma aiuta il programmatore fornendogli una serie di
librerie di procedure atte a realizzarlo.
Lo svantaggio di questo approccio è dettato dal fatto che se viene richiamata una proedura che non è presente in memoria deve intervenire il caricatore di collegamento rilocabile per caricare in memoria la procedura richiesta e aggiornare le tabelle degli indirizzi del programma.
Il vantaggio, però, è dato dal fatto che le procedure non adoperate non sono caricate: è
possibile quindi evitare di inserire subito in memoria grandi quantità di codice per gestire casi poco frequenti.
Collegamento dinamico e librerie condivise
Simile al caricamento dinamico è il concetto di collegamento dinamico, in cui si differisce il collegamento della procedura fino al momento in cui questa viene chiamata. Richiede generalmente l’assistenza del sistema operativo, in quanto questo è l’unica entità che può controllare se la procedura richiesta dal processo X è contenuta nello spazio
di memoria del processo Y.
Senza il collegamento dinamico bisognerebbe ricorrere al collegamento statico, in cui le
librerie di sistema del linguaggio sono combinate nell’immagine binaria del programma.
In questo modo, invece, si inserisce all’interno dell’immagine una piccola porzione di codice di riferimento (stub) che indica come localizzare la giusta procedura di libreria residente in memoria o come caricare la libreria se la procedura non è già presente. Quando
la procedura è caricata, tale codice sostituisce se stesso con l’indirizzo della procedura
che viene poi eseguita.
Questo sistema ha diversi vantaggi:
• tutti i processi che usano una libreria del linguaggio si limitano ad eseguire la
stessa copia del codice dalla libreria
• una libreria si può sostituire con una nuova versione senza dover ricollegare
Avvicendamento dei processi (swapping)
Per essere eseguito un processo deve trovarsi in memoria centrale, ma si può trasferire
temporaneamente in memoria ausiliaria (backing store) da cui si riporta in memoria
centrale al momento di riprenderne l’esecuzione. Una volta ripresa l’esecuzione, il processo ha a disposizione un quanto di tempo per compiere la sua elaborazione. Una volta
terminato, il processo in esecuzione viene scambiato con un altro processo.
Questo meccanismo è chiamato swapping e si basa
sulle operazioni di swap-out (da memoria centrale a
backing store) e swap-in (viceversa). Possibili criteri
per l’avvicendamento sono il round-robin o algoritmi basati sulla priorità. Quando lo scheduler della
CPU seleziona un processo da eseguire può verificarsi uno swap-in se il processso non è in memoria
centrale e uno swap-out se non c’è spazio libero a sufficienza. La porzione del disco riservata alle operazioni di avvicendamento è generalmente separata da quella del file
system.
Importante notare che se l’associazione degli indirizzi logici con gli indirizzi fisici si effettua nella fase di assemblaggio o di caricamento, il processo non può essere ricaricato in
posizioni diverse. Se l’associazione è invece fatta nella fase di esecuzione, un processo
può essere ricaricato in un punto diverso della memoria.
In un sistema di avvicendamenti così composto, il tempo di cambio di contesto da un
processo all’altro è nell’ordine dei secondi e direttamente proporzionale alla quantità di
memoria utilizzata. Sarebbe quindi utile conoscere la memoria effettivamente utilizzata
da un processo in modo da scaricare la giusta quantità e ridurre il tempo di contesto:
l’efficacia di questo approccio aumenta se l’utente tiene informato il sistema operativo
circa i requisiti dinamici di memoria da parte di un processo.
Per scaricare un processo dalla memoria è necessario che questo sia completamente
inattivo, quindi è necessario prestare molta attenzione agli I/O pendenti: se il processo
P1 ha un I/O pendente e nel frattempo viene scambiato col processo P 2, alla ripresa
dell’operazione di I/O si va a scrivere in una zona di memoria che non appartiene più a
P1. Il problema è risolvibile:
• non permettendo lo swap-out di processi con I/O pendenti
• eseguendo operazioni di I/O solo in aree per l’I/O del sistema operativo
Nei sistemi moderni si usano sistemi di avvicendamento sofisticati al fine di garantire ad
ogni processo quanti di esecuzione considerevoli.
Allocazione contigua della memoria
La memoria centrale contiene sia il sistema operativo che i processi utente. Solitamente
è quindi divisa in due partizioni e il sistema operativo viene piazzato nella stessa partizione - quella bassa - del vettore delle interruzioni.
Nell’altra partizione trovano spazio i processi utente. E’ necessario considerare come assegnare la memoria ai vari processi in coda che attendono di essere eseguiti: con l’allocazione contigua della memoria, ciascun processo è contenuto in una singola sezione di
memoria.
Rilocazione e protezione della memoria
Quando lo scheduler della CPU seleziona un
processo da eseguire, il dispatcher, durante
l’esecuzione del cambio di contesto, carica il registro di rilocazione e il registro limite con i valori corretti. Questo sistema consente la protezione del sistema operativo, dei programmi e
dei dati perché ogni indirizzo generato dalla
CPU è confrontato con i valori di questi registri.
Questo schema consente inoltre al sistema operativo di cambiare le sue dimensioni: se
parte del codice transiente del sistema operativo (es.: i driver dei dispositivi) non è utilizzato si può concedere più spazio ai processi utente.
Allocazione della memoria
Uno dei metodi più semplici per l’allocazione consiste nel suddividere la memoria in partizioni di dimensione fissa. Ogni partizione deve contenere esattamente un processo,
quindi il grado di multiprogrammazione è limitato dal numero di partizioni.
Il sistema operativo conserva una tabella in cui sono indicate le partizioni di memoria disponibili e quelle occupate. Inizialmente tutta la memoria è a disposizione dei processi,
man mano che si prosegue la memoria conterrà una serie di buchi di diverse dimensioni.
Quando entrano nel sistema, i processi vengono inseriti in una coda di ingresso. Sulla
base dei suoi requisiti, ad ogni processo si assegna dello spazio ed entra in competizione per il controllo della CPU. Al termine il processo rilascia la memoria che gli è stata assegnata e il sistema operativo la usa per soddisfare le richieste degli altri processi. In
ogni dato istante è sempre disponibile una lista delle dimensioni dei blocchi liberi e della
coda di ingresso, ordinabile per mezzo di un algoritmo di scheduling. La memoria è assegnabile finché esiste un buco disponibile sufficientemente grande da accogliere un
dato processo.
In generale è sempre presente un insieme di buchi sparsi per la memoria: questo perché
quando si presenta un processo che ha bisogno di memoria, il sistema cerca nel gruppo
un buco di dimensioni sufficienti per contenerlo. Se è troppo grande, questo è diviso in
due parti: una è assegnata al processo, l’altra è nell’insieme dei buchi. Quando il processo termina e si libera la sua memoria, se il buco che si viene a creare è accanto ad altri
buchi questi si possono unire per formare un buco più grande.
Questa procedura è una particolare istanza del problema di allocazione dinamica della
memoria, che consiste nel soddisfare una richiesta di dimensione n data una lista di buchi liberi.
I criteri usati per scegliere un buco libero sono:
• first fit, in cui si assegna il primo buco libero abbastanza grande
• best fit, in cui si assegna il più piccolo buco libero
• worst fit, in cui si assegna il buco libero più grande
In generale first-fit e best-fit sono migliori a worst-fit in termini di uso della memoria, anche se first-fit è in generale più veloce.
Frammentazione
I criteri first-fit e best-fit soffrono del problema della frammentazione esterna, che si ha
quando lo spazio di memoria totale è sufficiente per soddisfare una richiesta, ma non è
contiguo. Il problema non è trascurabile: analisi statistiche condotte sull’algoritmo firstfit, ad esempio, rivelano che per n blocchi assegnati ce ne sono 0,5⋅n che vanno persi
a causa della frammentazione.
Vi può essere anche frammentazione interna: se un processo richiede 18462 byte e gli
viene assegnato un blocco da 18464 byte, la sua frammentazione interna è di 2 byte e
rappresenta memoria interna alla partizione, ma non in uso.
La frammentazione esterna è risolvibile con la compattazione, in modo da riunire la memoria libera in un unico grosso blocco. Bisogna però tenere presente che non è realizzabile nel caso in cui la rilocazione è statica ed è fatta nella fase di assemblaggio o di caricamento. Quando è realizzabile è necessario tenere in considerazione il costo.
Un altro metodo per risolvere la frammentazione esterna è data dal consentire la non
contiguità dello spazio degli indirizzi logici. Due tecniche – combinabili – per arrivare a
questo risultato sono la paginazione e la segmentazione.
Paginazione
La paginazione è un metodo di gestione della memoria che consente la non contiguità
dello spazio degli indirizzi fisici, eliminando il problema della sistemazione di blocchi di
memoria di diverse dimensioni in memoria ausiliaria.
Metodo di base
La memoria fisica viene divisa in blocchi di dimensioni costante, detti frame o pagine fisiche. Allo stesso tempo, la memoria logica è divisa in blocchi di pari dimensione, detti
pagine. Quando si deve eseguire un processo si caricano le sue pagine nei frame disponibili prendendole dalla memoria ausiliaria che è divisa in blocchi di dimensione fissa e
uguale a quella dei frame della memoria.
La differenza tra la memoria vista dall’utente e la memoria fisica è colmata dall’architettura di traduzione degli indirizzi. Poiché il sistema operativo gestisce la memoria fisica,
dev’essere informato dei relativi particolari di allocazione (frame assegnati, disponibili,
numero totale...) contenute in una tabella dei frame che contiene un elemento per ogni
frame. Ogni elemento dice se il frame è libero o assegnato e, se assegnato, a quale processo/i.
Ogni indirizzo generato dalla CPU è
suddiviso in due parti: un numero di
pagina p e un offset di pagina d . Il
numero di pagina viene usato come indice per accedere alla tabella delle pagine (che è sempre in memoria!) contenente l’indirizzo di base di memoria
fisica. Questo viene poi combinato con
l’offset di pagina e l’indirizzo così ottenuto è inviato all’unità di memoria.
La dimensione di una pagina è in genere una potenza di 2 compresa tra 512B e 16MB.
In generale si ha che se la dimensione dello spazio degli indirizzi logici è 2m e la dimensione di una pagina è 2n , l’indirizzo segue questo formato
p
d
m−n
n
Con la paginazione è possibile evitare la frammentazione esterna, in quanto qualsiasi
frame libero si può assegnare a un processo che ne abbia bisogno.
Tuttavia si può avere frammentazione interna: se le pagine sono grandi 2048 byte e un
processo chiede 72766 byte, servono 35 pagine più una, con una frammentazione interna di 962 byte. Nel caso peggiore un processo ha bisogno di una pagina in più per un
solo byte.
Considerazioni analoghe si possono fare per determinare quale sia la dimensione migliore della pagina: siccome la dimensione del processo è indipendente da quella della pagina, converrebbe fare pagine piccole per ridurre la frammentazione interna. Allo stesso
tempo, però, ad ogni elemento della tabella delle pagine è associato un overhead, riducibile con pagine più grandi. La dimensione tipica delle pagine oscilla tra 512KB e 16MB
nell’ambito delle potenze di 2.
Quando si deve eseguire un processo che richiede n pagine, devono essere disponibili
almeno n frame che si assegnano al processo stesso. Viene caricata la prima pagina del
processo in uno dei frame assegnati e si inserisce il numero del frame nella tabella delle
pagine relativa al processo in questione, e così via per le altre pagine.
Architettura di paginazione
Ogni sistema operativo adotta una sua strategia per salvarsi la tabella delle pagine: la
maggior parte impiega una tabella delle pagine per ciascun processo, il cui PCB contiene – insieme ad altri valori – anche il puntatore alla tabella delle pagine.
L’architettura d’ausilio usa un insieme di registri, tra cui uno che punta alla tabella stessa contenuta in memoria. Questo registro è il registro di base della tabella delle pagine
(PTBR) e il cambio delle tabelle delle pagine richiede soltanto di modificarlo. Oltre al
PTBR è presente anche un registro di lunghezza della tabella (PTLR) per indicare le dimensioni della tabella.
Siccome la tabella delle pagine è contenuta in memoria, per accedere a un byte occorrono due accessi in memoria. Questo ritardo non è molto tollerabile, quindi si usa una pic-
cola cache di ricerca veloce detta Transation Look-aside Buffer TLB.
La TLB è una memoria associativa ad alta velocità e contiene una piccola parte degli elementi
della tabella delle pagine. Quando la CPU genera
un indirizzo logico, si presenta la parte relativa al
numero di pagina alla TLB. Se tale numero è presente, il corrispondente numero di frame è immediatamente disponibile e si usa per accedere
alla memoria. Se invece in TLB non c’è il numero
di pagina si deve consultare la tabella delle pagine in memoria (e poi riportare questo riferimento
in TLB). Alcune TLB, inoltre, memorizzano gli identificatori dello spazio degli indirizzi
(Address-Space Identifier ASID), che identificano in modo univoco ciascun processo in
modo da proteggere il rispettivo spazio di indirizzo: prima della traduzione si verifica che
il processo attualmente in esecuzione abbia ASID uguale a quello contenuto nel record
della TLB.
L’inserimento di una TLB modifica il tempo effettivo di accesso alla memoria EAT . Sia
α l’hit ratio della TLB, t TLB il tempo di ricerca nella TLB e t mem il tempo di accesso alla
memoria. Si ha
EAT=α⋅(t TLB +t mem )+(1−α )⋅(t TLB +2t mem )
Protezione
Ad ogni frame sono associati dei bit di protezione, contenuti nella tabella delle pagine, che esprime se l’operazione tentata sulla pagina è legittima (es.: sto scrivendo
in una pagina in sola lettura?). Oltre a questi bit vi è un
ulteriore bit, detto bit di validità. Se tale bit è impostato
a valido, la pagina corrispondente è nello spazio degli
indirizzi logici del processo.
Ipotizzando di avere pagine di 2KB ( n=11 ) e uno spazio
di indirizzamento di m=14 bit, un programma che deve
usare gli indirizzi da 0 a 10.468 può usare solo 10468/2048=5,11=6 delle 8 pagine. Nella pagina numero 5 (la sesta), inoltre, non tutti gli indirizzi sono validi in quanto
quest’ultima mappa fino all’indirizzo 12.287: questo problema è dovuto alla dimensione
delle pagine e riflette la frammentazione interna.
Pagine condivise
Un altro vantaggio della paginazione consiste nella possibilità di condividere codice comune. Se il codice è rientrante
può essere eseguito da diversi processi nello stesso momento, mentre i dati ovviamente variano da processo a processo.
La tabella delle pagine di ogni utente fa quindi corrispondere gli stessi frame contenente, ad esempio, un elaboratore
di testi. E’ possibile condividere altri programmi di uso frequente, sempre a patto che il codice sia rientrante.
Struttura della tabella delle pagine
Vi sono diverse tecniche per strutturare la tabella delle pagine.
Paginazione gerarchica
In sistemi a m=32 bit e con pagine grandi 4KB ( n=12 ), la tabella delle pagine può contenere fino a 220 elementi. Se ogni elemento è grande 4KB, ciascun processo potrebbe
richiedere fino a 4MB solo per la tabella delle pagine.
Un metodo per aggirare questo problema consiste nell’adottare un algoritmo di paginazione a due livelli, in cui è paginata anche la tabella delle pagine. Paginando la tabella
delle pagine, quindi, il numero di pagina è suddiviso in due sottocampi
p1
p2
d
in cui p1 è un indice su 10 bit che indirizza una tabella delle pagine di primo livello. Il
contenuto della tabella è l’indirizzo di partenza di una seconda tabella a cui si somma
come displacement p2 , anch’esso su 10 bit. In questo modo si trova l’indirizzo di partenza della pagina desiderata, a cui poi va sommato il displacement d .
Su sistemi a 64 bit è possibile aggiungere un ulteriore livello di paginazione.
Tabella delle pagine di tipo hash
Oltre i 32 bit però si preferisce impiegare una tabella delle pagine di tipo
hash, in cui l’argomento della funzione
di hash è il numero della pagina virtuale.
Ogni elemento della tabella contiene
una lista concatenata di elementi che
la funzione di hash fa corrispondere
alla stessa locazione. Ciascun elemento
contiene il numero della pagina virtuale, l’indirizzo del frame e un puntatore all’elemento successivo. L’utilizzo prevede l’applicazione della funzione di hash al numero di pagina p con l’identificazione di un primo elemento della lista concatenata. Se uno degli
elementi corrisponde a p si prende l’indirizzo di inizio della pagina r , lo si concatena al
displacement e si accede in memoria, altrimenti si va all’elemento successivo.
Tabella delle pagine invertita
Per limitare l’occupazione di memoria, in alcuni SO si usa la tabella delle pagine invertita, ovvero un’unica struttura dati globale che ha un elemento per ogni frame. Ogni elemento della tabella delle pagine invertita rappresenta un frame (indirizzo pari alla posizione nella tabella) e, in caso di frame allocato, contiene
pid , identificatore del processo a cui è assegnato il frame
•
p , numero della pagina logica
•
Come sempre d è l’offset all’interno della pagina.
Segmentazione
Con la paginazione si introduce il problema della separazione tra la visione della memoria dell’utente e l’effettiva memoria fisica.
Metodo di base
La segmentazione è uno schema di gestione della memoria che suddivide la memoria fisica disponibile in blocchi di lunghezza fissa o variabile detti segmenti. Uno spazio di indirizzi logici è quindi una raccolta di segmenti, ciascuno dei quali ha un nome e una lunghezza. L’utente fornisce ogni indirizzo logico come una coppia ordinata di valori (numero di segmento, scostamento).
Un compilatore per il linguaggio C può ad esempio creare segmenti distinti per codice,
variabili globali, heap e stack.
Architettura di segmentazione
Sebbene l’utente possa far riferimento agli oggetti del
programma per mezzo di un indirizzo bidimensionale,
la memoria resta unidimensionale.
Occorre quindi un meccanismo di traduzione, reso
possibile dalla tabella dei segmenti. Ogni elemento di
questa tabella è una coppia ordinata (base del segmento, limite del segmento). Quando si ha tra le mani
un indirizzo logico si prende il numero di segmento s
e lo si usa come indice per accedere alla tabella dei segmenti, che fornisce gli indirizzi
base e limite con cui confrontare d e trovare l’indirizzo per accedere in memoria fisica.
***