Scopo di questo capitolo é quello di fornire una panoramica
generale delle principali funzionalità offerte dalle macchine
convenzionali moderne, oltre a quelle classiche (tipo Von Neumann)
viste fino ad ora negli esempi didattici VM-2 e VM-R.
Cominceremo prima con l'arricchimento di alcuni aspetti "funzionali"
quali le interruzioni, la memoria virtuale, e la protezione delle
risorse.
Passeremo poi ad esaminare alcuni tra i principali temi legati al
miglioramento delle prestazioni (questi ultimi storicamente cresciuti
nell'ambito delle macchine RISC, ma oggi largamente adattotati anche
dalle architetture cosiddette CISC).
Le macchine convenzionali moderne sono notevolmente più complesse e sofisticate degli esempi visti fino ad ora. Gran parte di questa maggior complessità deriva dall'esigenza di fornire delle funzionalità e delle prestazioni nettamente superiori a quelle di una classica architettura di "tipo Von Neumann". Macchine semplificate come per esempio VM-2 e VM-R oggi non troverebbero nessuna utilizzazione pratica, non tanto per le limitazioni sul numero di registri, sulla dimensione della memoria RAM, e sulla velocità di esecuzione delle istruzioni rispetto alla frequenza di clock, quanto piuttosto per la mancanza di metodi efficaci di interazione asincrona verso l'esterno, di meccanismi di protezione ed integrità delle computazioni, e di sicurezza di funzionamento a fronte di errori di programma o di alterazioni intenzionali da parte di programmatori o utilizzatori malevoli.
In questa parte vedremo prima quali meccanismi vengono normalmente adottati per ovviare alle carenze funzionali sopra accennate, e poi passeremo ad esaminare le tecniche normalmente utilizzate per aumentare la velocità di esecuzione delle istruzioni a parità di frequenza di clock.
La possibilità di interazione asincrona verso l'esterno viene ottenuta mediante l'adozione di un meccanismo di segnalazione e gestione delle Interruzioni. Passeremo poi ad esaminare il meccanismo delle Trap che, pur essendo realizzato in modo molto simile a quello delle interruzioni, tende invece a risolvere problemi di protezione contro errori di programma in fase di esecuzione. Vedremo che il meccanismo delle Trap viene facilmente adottato anche per realizzare delle tecniche di gestione di tipo "Lazy", dove solo quando una anomalia di funzionamento si verifica effettivamente si passa a correggerne gli effetti, invece di predisporre un programma (più grande e complesso) che consideri tutta la casistica delle situazioni possibili. Tale tecnica consente di ottimizzare i tempi di esecuzione dei programmi nei casi più comuni, rallentandone l'esecuzione solo quando si incontra una "eccezione alla regola" prestabilita.
Infine vedremo la possibilità di "confinare" l'esecuzione
di un programma in modo che questi possa accedere solo ad un
sottoinsieme prestabilito delle risorse effettivamente disponibili
nel sistema.
Tale confinamente viene ottenuto combinando la tecnica di
memoria virtuale a segmentazione con l'adozione
di due o più modalità diverse di esecuzione dei
programmi da parte della CPU e di un sottinsieme di
istruzioni privilegiate eseguibili solo in alcuni
modi di esecuzione (privilegiati).
Il passaggio da un modo di esecuzione (non privilegiato) ad un
altro (privilegiato, in cui tutte le istruzioni sono eseguibili da
parte del processore) viene normalmente realizzato in modo sicuro
attraverso l'uso combinato di Trap e memoria virtuale a
segmentazione.
Il risultato della combinazione di queste diverse tecniche é
quello di arrivare a discriminare i programmi "fidati" (normalmente
quelli che compongono il sistema operativo) ai quali é
demandata la gestione di tutte le risorse del sistema, dai
programmi "potenzialmente nocivi" (ai quali si permette di accedere
solo ad un sottoinsieme delle risorse del sistema in modo da
limitare i danni derivanti da errori e/o "virus").
Anzitutto chiariamo cosa si intende in questo contesto per comportamento "asincrono". Nell'esempio della macchina VM-2, l'interazione tra il sistema ed il mondo esterno avviene attraverso i dispositivi di ingresso (tastiera) e uscita (schermo video) dei dati, sotto il controllo del programma in esecuzione. In questo caso parliamo di modalità "sincrona", perchè la ricezione o l'invio di un carattere possono avvenire solo quando la CPU esegue le corrispondenti istruzioni del programma. Un utente non può, semplicemente premendo dei tasti sulla tastiera, influire sulla esecuzione del programma in corso se il programma stesso non prevede una procedura di lettura di caratteri in quella fase. Attraverso l'adozione della tecnica del "busy waiting" si può obbligare il programma ad aspettare che l'utente inserisca i suoi dati, ma non forzare il programma a leggere dei dati in un momento non esplicitamente previsto dal programmatore. Per "asincrono" si intende un rovesciamento dei ruoli, ovvero, per esempio, la possibilità per la tastiera di "avvertire" la CPU che l'utente ha schiacciato un tasto, in modo che quest'ultima possa "leggere" il codice del carattere immesso dall'utente anche se il programma in fase di esecuzione non prevede la lettura dal dispositivo di ingresso.
Notare che in questo contesto i termini "sincrono" e "asincrono" hanno significato diverso da quello che abbiamo loro attribuito nel caso dei circuiti sequenziali. Questa apparente incongruenza deriva dal fatto che ora stiamo descrivendo un livello di astrazione diverso (L2) rispetto a prima (L0), quindi anche idee fondamentalmente simili trovano una realizzazione diversa.
Per arrivare alla definizione precisa di un meccanismo di interruzione adatto alla realizzazione in una macchina convenzionale, proviamo prima a studiare una analogia con l'esperienza quotidiana. Un meccanismo sostanzialmente sincrono lo troviamo nel sistema dei trasporti ferroviari. Quando ci rechiamo in stazione per prendere un treno per recarci in una certa località dobbiamo aspettare che il treno arrivi in stazione per poterci salire sopra e partire. Se arriviamo in anticipo in stazione (oppure se il treno é in ritardo o soppresso a causa di uno sciopero) non possiamo far altro che aspettarne pazientemente l'arrivo (oppure rinunciare al viaggio), non avendo alcun modo per sollecitarlo. Il comportamneto di tipo asincrono lo ritroviamo invece nel caso dei viaggi in taxi, nei quali normalmente non si ferma nessun taxi sotto casa nostra a meno che non siamo noi a telefonare per chiamarlo. Con la telefonata segnaliamo alla centrale operativa del servizio radio taxi la nostra esigenza, e questa provvederà a gestire l'interruzione asincrona mandandoci la macchina libera più vicina.
Sempre per cercare di chiarire meglio i requisiti di un sistema di interruzione asincrona pensiamo ad un'altra analogia con la vita quotidiana: abbiamo deciso di fare una doccia a casa nostra e, non appena ci siamo cosparsi lo sciampo sui capelli suona il campanello della porta. A questo punto abbiamo ricevuto la segnalazione di una richiesta di interruzione dell'attività in corso per poter rispondere alla richiesta. Normalmente il suono del campanello non ci fornisce nessuna indicazione riguardo a chi sta suonando e per quale motivo. Solo interrompendo la doccia e recandoci ad aprire la porta possiamo scoprire se il "problema" che siamo chiamati a risolvere merita effettivamente la nostra attenzione immediata oppure no. Possiamo immaginare almeno tre scenari possibili:
Cominciamo con la definizione del meccanismo di "risposta" da parte della CPU alle richieste di interruzione, in quanto, forse, di più immediata comprensione, alla luce di quanto visto fino ad ora della organizzazione dei livelli L1 ed L2.
Notare che la soluzione sopra delineata per l'attivazione della risposta a livello di microcodice, "sincronizza" la risposta al termine dell'esecuzione di una istruzione di livello L2 e prima dell'inizio dell'esecuzione dell'istruzione successiva. Detto in altri termini, il sistema di risposta alle interruzioni deve essere considerato "sincrono" a livello L1, mentre può essere tranquillamente considerato "asincrono" a livello L2. La sincronizzazione a livello L1 semplifica la realizzazione della risposta, garantendo che la CPU interrompa la sua attività per rispondere all'interruzione in uno stato consistente, nel quale tutti i cambiamenti di valori di registri e celle di memoria derivanti dall'esecuzione di una istruzione L2 o sono stati completati o non sono stati iniziati.
Ovviamente in pratica le cose sono più complesse da realizzare correttamente. Un primo problema che si pone é quello di consentire che la procedura Interrupt Handler possa essere eseguita effettivamente dopo averne "chiamato" la prima istruzione. Infatti, se ci limitassimo a realizzare lo schema di attivazione microprogrammato delineato sopra, dopo aver eseguito la "pseudo CALL" che modifica il Program Counter non si riuscirebbe a completare il fetch della prima istruzione della procedura Interrupt Handler! Il motivo é che anche dopo aver "già chiamato" l'Interrupt Handler la richiesta di interruzione segnalata dal dispositivo continua ad essere presente, quindi anche il fetch della prima istruzione dell'interrupt Handler verrebbe interrotto e sostituito da una ulteriore "pseudo CALL" di nuovo all'Interrupt Handler. In assenza di un opportuno meccanismo che rende la CPU "insensibile" alla richiesta di interruzione, neanche l'Interrupt Handler (che una volta attivato viene eseguito dalla CPU come un qualsiasi altro programma) potrebbe essere eseguito senza continue interruzioni.
La soluzione a questo inconveniente é molto semplice: si introduce a livello di logica circuitale un dispositivo atto a mascherare le richieste di interruzione, rendendo la CPU insensibile alle stesse. Immaginando di codificare la richiesta di interruzione come "1" e l'assenza di richiesta di interruzione come "0", sono sufficienti una funzione AND a due ingressi ed un flip-flop. Normalmente nel flip-flop verrà memorizzato il valore MASK=1, per fare in modo che la CPU possa rispondere alle richieste di interruzione. La richiesta di interruzione proveniente dal dispositivo esterno arriva ad un ingresso della funzione AND, mentre l'altro ingresso é collegato al valore MASK memorizzato nel flip-flop. L'uscita della funzione AND é il segnale di interruzione mascherabile il cui valore viene "visto" dal microcodice della CPU prima di iniziare il fetch dell'istruzione successiva. Quando il microcodice rileva la presenza di una richiesta, oltre a simulare la CALL InterruptHandler provvede anche ad azzerare il flip-flop. In tal modo la CPU non risponde ad ulteriori richieste, e l'Interrupt Handler può quindi essere eseguito regolarmente, una istruzione dopo l'altra senza interruzioni. Alla fine della gestione dell'interruzione, dopo essersi assicurato che il dispositivo ha rimosso la propria richiesta di interruzione, la procedura Interrupt Handler esegue una istruzione (di livello L2) per reinserire il valore 1 nella maschera, e ripristinare quindi la situazione normale di sensibilità alle richieste di interruzione.
Un altro dettaglio da definire per realizzare il meccanismo di risposta all'interruzione é come specificare l'indirizzo della procedura Interrupt Handler, fin qui individuata in modo simbolico. Un modo semplice ed efficace può essere quello di definire tale indirizzo a livello di macchina convenzionale, fissando una volte per tutte l'indirizzo della cella di RAM nella quale deve essere codificata la prima istruzione dell'Interrupt Handler. Vedremo più avanti un meccanismo sofisticato e flessibile per risolvere questo problema.
La gestione vera e propria degli eventi asincroni segnalati alla CPU mediante interruzioni può avvenire immediatamente appena riconosciuta l'interruzione da parte dell'interrupt handler oppure in modo differito, se c'é qualche altra attività in corso il cui completamento é più urgente. In ogni caso la gestione avviene mediante la chiamata di apposite procedure, sotto il controllo del sistema operativo.
Solitamente le procedure di gestione delle interruzioni segnalate da un particolare dispositivo, vengono associate al dispositivo stesso mediante l'individuazione di un cosiddetto device driver, ovvero un particolare programma di gestione per il dispositivo inserito nel nucleo del sistema operativo all'atto della configurazione iniziale del sistema. Solitamente, quindi, se si vuole attuare una gestione immediata di una interruzione, l'interrupt handler richiama subito la procedura di gestione delle interruzioni definita nel device driver associato al dispositivo che ha segnalato la richiesta, e questa contiene il codice da eseguire per gestire correttamente l'evento.
Nel caso si voglia invece attuare una gestione differita, l'interrupt handler richiama sempre la procedura di gestione delle interruzioni definita nel device driver associato al dispositivo che ha segnalato la richiesta, ma questa contiene solo il codice da eseguire per "registrare" l'occorrenza dell'evento segnalato. Periodicamente (tipicamente dopo il completamento dell'esecuzione di un qualsiasi altro programma) il sistema operativo scandisce la coda delle "richieste pendenti" (non ancora gestite), e provvede quindi a mandare in esecuzione le apposite procedure di gestione differita degli eventi contenute nei vari device driver del sistema, in un ordine prestabilito.
Sia nel caso della gestione immediata, sia in quello della gestione differita, si pone il problema di decidere se la gestione di un evento segnalato mediante interruzione possa essere a sua volta interrotta per la risposta ad altre segnalazioni di interruzione oppure no. La corretta organizzazione di un sistema di risposta e gestione delle interruzioni risulta essere in pratica abbastanza complessa. Rimandiamo ad una successiva discussione e al corso di Sistemi Operativi per ulteriori dettagli.
A livello fisico la segnalazione può essere realizzata aggiungendo un filo sul bus, in modo da consentire ad un dispositivo di inviare la sua richiesta di interruzione alla CPU sotto forma di codifica binaria. Per esempio, il valore 1 potrebbe rappresentare la richiesta di interruzione, ed il valore 0 l'assenza di richiesta di interruzione. Con questa codifica, la segnalazione da parte di più dispositivi potrebbe essere ottenuta con una funzione OR tra i fili di richiesta di ciascun dispositivo per generare il segnale di interruzione per la CPU. Tale soluzione avrebbe però lo svantaggio di richiedere un numero di fili di connessione sul bus proporzionale al numero di dispositivi che possono generare richieste di interruzione.
Per realizzare in modo semplice ed efficiente la segnalazione di più richieste di interruzione utilizzando un solo filo nel bus si ricorre ad un "trucco" a livello di circuiti elettronici, chiamato OR-cablato (wired-OR). Come nel caso dei dispositivi a tre stati di uscita, la realizzazione di una connessione wired-OR sfrutta la possibilità di realizzare dispositivi con uno stato di "uscita non connessa". A differenza dei dispositivi a tre stati, tuttavia, nello stato connesso un dispositivo per connessioni wired-OR può produrre solo il valore 1. Quindi, a differenza dei dispositivi a tre stati, la connessione simultanea di più uscite non può mai generare conflitti (tutte le uscite connesse affermano lo stesso valore 1). Solo la condizione in cui tutti i dispositivi sono nello stato "non connesso" può consentire la presenza di un valore diverso da 1 sul filo di uscita. La connessione viene quindi completata con un dispositivo chiamato "terminatore", realizzato in modo da produrre il valore 0 quando gli altri dispositivi non sono connessi. La caratteristica del terminatore è quella di produrre il valore 0 con un livello di energia molto inferiore rispetto a quella usata dagli altri dispositivi per produrre il valore 1. In questo modo viene calcolata la funzione OR tra tutte le richieste usando un solo filo di collegamento.
Il risultato può essere raffigurato con la seguente metafora: Il terminatore "sussurra" il valore 0, per cui la CPU in un ambiente silenzioso sente il valore 0; gli altri dispositivi, quando si connettono al filo, "urlano" il valore 1, coprendo la "voce" del terminatore, per cui la CPU in queste condizioni "sente" il valore 1. Il terminatore é ben conscio del suo ruolo e con la massima flemma accetta che la sua voce sia sopraffatta da quelle degli altri dispositivi, senza patire alcun danno.
L'uso della tecnica di segnalazione Wired-OR consente a più dispositivi di far confluire le loro richieste su una sola variabile booleana, il cui valore indica alla CPU che almeno un dispositivo chiede attenzione. In generale possiamo quindi considerare il caso in cui più di un dispositivo "suoni" contemporaneamente, ed a questo punto dobbiamo specificare esattamente le modalità seguite per rispondere a più di una richiesta simultanea. É ovvio che la CPU, eseguendo un programma sequenziale, sarà in grado di gestire un solo evento per volta. Quindi in presenza di due o più richieste, una sarà gestita per prima, e la gestione delle altre dovrà essere dilazionata. É altrettanto ovvio che, se la gestione di una interruzione termina in tempo finito, prima o poi la CPU passerà a gestire la seconda richiesta che era stata dilazionata, e così via. Quindi, la scelta su quale richiesta di interruzione gestire in presenza di più richieste, ha un effetto sul tempo necessario per terminare il trattamento dell'evento.
In generale non tutti gli eventi saranno ugualmente urgenti da trattare, e questo indipendentemente dall'importanza dell'evento segnalato. A volte le interruzioni possono segnalare eventi di basilare importanza per il funzionamento del sistema ma non necessariamente urgenti da gestire. Altre volte, eventi di fondamentale importanza devono essere gestiti entro un tempo massimo predefinito, pena la perdita di informazioni oppure la sicurezza del sistema e dell'ambiente in cui opera. Altre volte ancora, un evento segnalato deve essere gestito entro un termine prestabilito, altrimenti l'evento stesso perde di significato ed importanza, ma la mancata gestione dell'evento stesso non provoca danni significativi al sistema. A seconda del caso avrà quindi senso adottare una strategia di ordinamento del trattamento degli eventi piuttosto che un'altra.
In ogni caso, si richiede che il sistema sia in grado di arrivare rapidamente alla decisione sull'evento da gestire per primo, e questo risultato viene normalmente ottenuto definendo una volta per tutte una scala di priorità. Così come nel caso già studiato dell'arbitraggio del bus nel caso di richieste di uso da parte di più master, l'assegnazione di priorità statiche consente una realizzazione semplice ed efficace di un algoritmo di scansione lineare, che interroga i vari dispositivi in ordine decrescente di priorità e si ferma a rispondere al primo dispositivo che risulta aver mandato una richiesta di interruzione. A differenza del caso dell'arbitraggio del bus, tale scansione viene realizzata a livello L2, sotto forma di un ciclo di lettura dei registri dei dispositivi (nell'ordine prefissato) da parte della procedura Interrupt Handler.
Sempre a differenza del caso dell'arbitraggio del bus, la gestione di una richiesta di interruzione potrebbe richiedere un tempo lungo, per cui si pone in pratica il problema di decidere se tale gestione debba essere resa interrompibile oppure no. Gestione non interrompibile in presenza di tempi lunghi di gestione, significa poter arrivare a situazioni di aperta violazione delle regole di priorità stabilite. Per esempio, consideriamo il seguente scenario: un dispositivo a bassa priorità lancia una richiesta di interruzione, alla quale la CPU risponde subito in assenza di altre richeste; la gestione richiede un alto tempo di esecuzione da parte dell'Interrupt Handler, ma non é soggetta a vincoli stringenti circa il tempo di completamento; subito dopo un dispositivo ad alta priorità lancia a sua volta una richiesta di interruzione per segnalare il verificarsi di una situazione di emergenza che richiederebbe un intervento immediato per mantenere l'integrità del sistema, tuttavia la CPU non può rispondere a causa del mascheramento che verrà tolto solo al termine della gestione della richiesta a bassa priorità. Per evitare tale inconveniente si può rendere la gestione delle interruzioni (almeno quella delle richieste a bassa priorità) interrompibile (preemptive priority). Tale risultato viene ottenuto anticipando il momento in cui il mascheramento delle interruzioni viene tolto, prima che l'esecuzione dell'Interrupt Handler sia terminata.
La riattivazione della sensibilità della CPU alle richieste di interruzioni prima del completamento dell'esecuzione dell'Interrupt Handler che consente di realizzare una politica di gestione di tipo "preemptive priority" ha una serie di implicazioni a vari livelli:
Dalla discussione appena fatta, dovrebbe risultare evidente che sarebbe possibile realizzare un sistema sofisticato di gestione di interruzioni a priorità con o senza prelazione e con eventuale gestione differita delle interruzioni meno urgenti mediante accorgimenti esclusivamente a livello di sistema operativo (ossia basandosi su un meccanismo di segnalazione e risposta ai livelli L2, L1 ed L0 che non tengano minimamente conto delle diverse priorità dei dispositivi). Tuttavia le macchine convenzionali moderne offrono tutte dei supporti anche a livello di inferiore per la gestione di priorità statiche per diversi dispositivi. A livello fisico, normalmente i bus prevedono l'uso di più fili (ciascuno col meccanismo wired-OR per consentire la connessione di più controller allo stesso livello di priorità fisica) a supporto della segnalazione di interruzioni a diversi livelli di priorità. Di conseguenza anche il mascheramento delle richieste avviene mediante l'uso di un insieme di bit che stabiliscono indipendentemente per ogni livello di priorità fisica se le richieste provenienti attraverso quel filo di segnalazione devono essere "sentite" o meno da parte della CPU. I bit di mascheramento delle interruzioni sono di solito organizzati come sottoinsieme di un "registro di stato" della CPU. A seconda della macchina convenzionale, vengono poi definite diverse modalità che consentono di "informare" l'Interrupt Handler di quale livello fisico di priorità ha determinato l'attivazione della procedura di gestione.
Col termine "interruzioni vettorizzate" si indica un modo ormai universalmente adottato dai progettisti di macchine convenzionali per supportare l'attivazione della risposta alle interruzioni con segnalazione fisica su più livelli di priorità. Cerchiamo qui di dare una descrizione generale dell'idea, senza fare esplicito riferimento al formato di rappresentazione delle informazioni di un processore specifico (la definizione precisa dei formati di rappresentazione vengono forniti dai costruttori dei processori in appositi manuali).
L'idea é che gli N livelli predefiniti di priorità di interruzione di una macchina convenzionale sono numerati da 0 ad N-1. La risposta da dare ad una richiesta "sentita" (ossia non mascherata) da parte della CPU é descritta in un vettore di N elementi, memorizzato in RAM; ciascuno degli elementi specifica il comportamento a fronte di una richiesta proveniente da uno degli N fili di segnalazione. Ciascun elemento del vettore di interruzione contiene una rappresentazione binaria da inserire nel registro di stato in sostituzione del valore contenuto nel registro prima dell'interruzione e l'indirizzo della procedura di gestione delle interruzioni da mandare in esecuzione. Questa organizzazione permette di eseguire procedure di gestione delle interruzioni diverse a seconda del livello di priorità della richiesta. Consente inoltre di variare in modo arbitrario la maschera di interruzione (sempre a seconda della priorità della richiesta segnalata) in concomitanza con l'inizio dell'esecuzione dell'interrupt handler. La macchina convenzionale esegue l'attivazione dell'interrupt handler in modo non interrompibile, salvando i contenuti precedenti del registro di stato e del contatore di programma prima di sostituirli coi valori recuperati dall'elemento del vettore di interruzione di numero corrispondente al livello di priorità segnalato. Nel caso di segnalazione simultanea di interruzione su più fili, ovviamente la macchina convenzionale risponde alla richiesta a priorità maggiore tra quelle non mascherate. É tuttavia compito del programmatore del sistema operativo che predispone il contenuto del vettore di interruzione di preoccuparsi di definire una configurazione dei bit di mascheramento da inserire nel registro di stato tali da determinare il mascheramento almeno delle richieste allo stesso livello di priorità (per evitare che lo stesso gestore venga interrotto dalla stessa richiesta che dovrebbe iniziare a gestire) e, presumibilmente, di priorità inferiore. La posizione in RAM del vettore di interruzione viene solitamente individuata inserendo l'indirizzo della prima cella di memoria usata per memorizzare il vettore in un apposito registro della CPU, al momento della inizializzazione del sistema (bootstrap).
Per sistemi con "gestione in tempo reale" (real-time systems) si intendono sistemi per i quali é necessario che tra il momento in cui viene segnalato un evento esterno ed il momento in cui il sistema termina la gestione della procedura corrispondente intercorra un intervallo di lunghezza non superiore ad un certo valore massimo prefissato.
Esempi tipici di sistemi informatici con "vincoli di tempo reale" sono quelli di controllo di apparecchiature robotizzate, nelle quali il computer deve "comandare delle reazioni" per difendere l'integrità fisica del dispositivi o degli oggetti e persone che gli stanno intorno. Un dispositivo ABS per evitare il blocco delle ruote di un veicolo in caso di brusche frenate di emergenza oppure un "airbag" devono dare garanzie di tempi massimi di risposta dell'ordine di qualche millisecondo per poter essere considerati efficaci. In queste circostanze non é certamente accettabile che il sistema di controllo computerizzato a bordo ritardi o interrompa la gestione dell'ABS e dell'airbag per gestire l'attività del climatizzatore o dell'autoradio, magari prima facendo apparire il disegno di un orologio o di una clessidra sul cruscotto con la scritta "la richiesta pendente di attivazione dell'airbag verrà gestita quanto prima: si consiglia di ritardare la collisione per evitare gravi danni".
La presenza stessa di un meccanismo di interruzione introduce un elemento di variabilità e quindi di incertezza sul tempo necessario al sistema per completare l'esecuzione di un programma. Anche sapendo esattamente quale sequenza di istruzioni deve essere eseguita e quanto tempo richiede l'esecuzione di ciascuna istruzione della macchina convenzionale, la stima del tempo di completamento che si può calcolare a priori (a tavolino) non sarà altro che un limite inferiore per il tempo realmente necessario per completare l'esecuzione in un sistema reale, in presenza di un meccanismo di interruzioni. Al tempo di esecuzione delle istruzioni del programma che stiamo considerando, bisogna infatti aggiungere la somma dei tempi di risposta ed eventuale gestione di tutte le interruzioni che il programma stesso subirà, cosa non nota a priori.
Tale considerazione si applica non solo ai programmi "utente", ma anche alle procedure di gestione delle interruzioni, se queste sono eseguite in modalità interrompibile. Se la corretta gestione di una interruzione richiede che l'intervento da parte della CPU sia completato entro un determinato tempo massimo prefissato l'unica soluzione possibile sembra essere quella di assegnare massima priorità a tale evento e renderne la gestione non interrompibile.
Un problema ancora più grave che si presenta nel caso di un interrupt handler, anche se stiamo considerando il gestore degli eventi a massima priorità, e sopratutto nel caso in cui la gestione sia organizzata in modo non interrompibile per risolvere il problema visto sopra, é quello della latenza di risposta. Il problema si presenta quando si definiscono due o più eventi con requisiti di risposta in tempo reale. In tal caso, tutti i due o più eventi verranno gestiti alla massima priorità in modalità non interrompibile. Se tutti gli elementi vengono segnalati simultaneamente, il processore comincerà a gestirne uno immediatamente, ritardando quindi la risposta alle altre richieste altrettanto critiche ed importanti. La latenza di risposta all'evento che avrà la "sfortuna" di essere gestito per ultimo sarà la somma dei tempi di gestione di tutte le altre richieste più "fortunate". In un simile contesto, quindi, per poter garantire un tempo massimo di gestione ad una richiesta occorre che la somma dei tempi di gestione di tutte le richieste sia inferiore al valore prestabilito.
L'organizzazione classica di un sistema operativo non é
in grado di assicurare che gli eventi possano soddisfare vincoli
di tempo reale se non in modi molto approssimati ("di solito"
ovvero "molto spesso" il tempo di risposta rientra nei limiti
richiesti), e questo anche se a livello di macchina convenzionale
sono presenti tutte le funzionalità che permetterebbero
l'organizzazione di un sistema in grado di rispettare con
certezza vincoli di tempo reale (purché i tempi di risposta
richiesti siano abbastanza alti rispetto al tempo di esecuzione
dei programmi di gestione).
Sistemi in grado di soddisfare vincoli di risposta in tempo
reale vengono normalmente ottenuti usando hardware "normale"
ma sistemi operativi appositamente riprogettati.
Usando un meccanismo del tutto analogo a quello appena visto per la segnalazione delle interruzione, si realizza una funzionalità concettualmente diversa ma altrettanto indispensabile nella organizzazione di un sistema di calcolo moderno: le trap (a volte chiamate anche "eccezioni"). Semplificando al massimo possiamo definire una trap come la segnalazione di una richiesta di interruzione generata dalla CPU stessa invece che da un dispositivo di controllo esterno. Concettualmente la profonda differenza fra una trap ed una richiesta di interruzione é che la prima si verifica a causa dell'esecuzione del programma in corso, e deve quindi essere considerata come un evento "sincrono" rispetto all'esecuzione dello stesso programma che viene interrotto per rispondere alla segnalazione. Come si ricorderà, invece, la segnalazione di una interruzione viene definita come "asincrona" rispetto al programma in corso di esecuzione, perchè la causa scatenante della segnalazione può non avere niente a che vedere col programma che viene interrotto (anche se magari può essere una conseguenza di una attività iniziata su ordine dello stesso programma che ora viene interrotto oppure di un programma diverso eseguito in precedenza -- per esempio, la fine di un trasferimento dati in DMA eseguito da un controller).
L'esempio classico che viene solitamente presentato per spiegare l'utilità delle trap e giustificarne l'introduzione é quello della segnalazione di errori durante l'esecuzione di un programma. Supponiamo di definire una macchina convenzionale con una istruzione IDIV RA, RB, RC che abbia come effetto quello di dividere il valore intero memorizzato in forma binaria nel registro RA per il valore memorizzato nel registro RB, inserendo il risultato calcolato nel registro RC, sempre in rappresentazione "virgola fissa". L'operazione di divisione è correttamente definita dal punto di vista matematico solo se il divisore é diverso da zero, quindi non si saprebbe quale codifica binaria inserire nel registro RC nel caso in cui RB contenesse il valore 0. Il problema può essere risolto inserendo un dispositivo nell'ALU che riconosca la condizione "il divisore é zero" e segnali una trap. Altro esempio classico di trap per la segnalazione di errore può essere la condizione di "overflow" durante l'esecuzione di una istruzione aritmetica. Come nel caso di una interruzione, la CPU "risponde" alla segnalazione di trap interrompendo l'esecuzione del programma in corso (ossia quello che contiene il codice dell'istruzione IDIV RA, RB, RC) prima di passare all'esecuzione dell'istruzione successiva, e passando ad eseguire una procedura Trap Handler predefinita.
Di solito viene usato lo stesso schema di vettorizzazione sia per le interruzioni che per le trap, ossia il vettore di interruzione contiene, oltre a tutti gli elementi che caratterizzano la risposta alle interruzioni di diversa priorità, anche una serie di elementi aggiuntivi che corrispondono invece a varie condizioni di trap.
L'esempio appena citato rende bene l'idea di trap ma può indurre a sottovalutare le potenzialità del meccanismo. Osserviamo infatti che l'idea di associare un meccanismo di trap unicamente alla segnalazione di condizioni di errore che non si saprebbe altrimenti come gestire é troppo limitativa. In pratica l'uso del meccanismo delle trap é fortemente legato alla possibilità di "ottimizzare" l'organizzazione di un programma. L'ottimizzazione resa possibile da un meccanismo di trap consiste in una semplificazione che rende più semplice la definizione e più veloce l'esecuzione del programma. Tale ottimizzazione é applicabile, per esempio, quando si riescono a riconoscere dei casi particolari di valori dei dati su cui si deve operare, che comportano un trattamento più complesso o gravoso di altri. Se questi casi particolari si possono effettivamente manifestare, ma con una frequenza (probabilità?) piuttosto bassa rispetto agli altri, allora possono essere considerati come "eccezioni complesse" ad una norma intrinsecamente più semplice.
Indubbiamente, per tornare al nostro esempio della divisione, lo stesso risultato (segnalazione della condizione di divisore nullo e interruzione prematura del programma) potrebbe essere ottenuto anche senza la definizione di una trap di errore al livello di macchina convenzionale. Infatti, basterebbe che a livello di programma applicativo ogni istruzione IDIV fosse preceduta da una sequenza di istruzioni per confrontare il valore contenuto in RB con 0, e saltare ad una procedura di segnalazione di errore quando l'esito del confronto fosse RB=0. Notare che tale soluzione sarebbe meno efficiente nel caso ("normale") di valore RB diverso da zero, in quanto provocherebbe l'esecuzione di istruzioni inutili di confronto e salto condizionale. La presenza della trap consente al programmatore dell'applicazione di evitare l'introduzione di queste istruzioni "inutili nel caso normale", ottenendo quindi una semplificazione del suo programma. Il programma viene quindi scritto assumendo ottimisticamente che il divisore non sia mai nullo, ed anche se poi questo lo fosse (eccezione alla regola), il sistema se ne accorgerebbe e gestirebbe correttamente anche quel caso attraverso l'attivazione della trap.
In generale, il principio che sta alla base della corretta ed efficace utilizzazione delle trap é quello di amministrare con oculatezza le proprie risorse per rispondere in modo adeguato a seconda della natura del problema. Il buon senso vuole che per uccidere una mosca che ci infastidisce si adottino misure meno drastiche di quelle che sono necessarie per abbattere un aereo da combattimento. Per difendere il proprio spazio aereo si parte quindi dal presupposto di usare l'insetticida nei casi normali in cui la minaccia é costituita da mosche, e solo nei casi eccezionali in cui la minaccia risulti essere costituita da cacciabombardieri nemici si ricorre alle cannonate. Indubbiamente anche la mosca potrebbe essere abbattuta a cannonate, quindi una soluzione teorica al problema potrebbe essere quella di dotarsi solo di una batteria contraerea da usare in ogni circostanza, senza alcuna necessità di ricorrere all'insetticida. Tuttavia il metodo generale sarebbe troppo costoso (oltre che a maggior impatto ambientale e poco rispettoso della quiete del vicinato), e quindi praticamente non accettabile.
La disponibilità di un meccanismo di segnalazione sotto forma di trap di situazioni che rappresentano una eccezione rispetto alla norma permette quindi di adottare delle semplificazioni del programma in modo che questo tratti correttamente solo i casi "normali" con un costo ridotto. I (pochi) casi "eccezionali" che il programma semplificato non può trattare vengono riconosciuti dalla macchina convenzionale e gestiti dal Trap Handler. Solo quando le eccezioni si verificano effettivamente viene attivato il trap handler, in grado di trattare correttamente il caso generale. Si organizza quindi la soluzione generale sotto forma di una "escalation" di misure via via più potenti e costose solo quando queste sono indispensabili, mentre nel caso normale si adotta una soluzione a costo minimo.
Oltre alle trap segnalate in caso di errore oppure in casi che
costituiscono l'eccezione rispetto ad un caso normale più
frequente e più semplice da trattare, le macchine convenzionali
prevedono anche delle istruzioni esplicite di trap.
In un certo senso, tali istruzioni esplicite di trap possono essere
interpretate come "una istruzione che genera sempre errore".
Vedremo più avanti l'utilità di
questa classe di trap.
Un'altra funzionalità ritenuta ormai fondamentale per la realizzazione di un sistema di calcolo moderno é la cosiddetta memoria virtuale. Con questo termine si intende la possibilità di gestire la memoria in modo molto più strutturato ed ordinato rispetto all'idea originale di Von Neumann. Se da un lato la disponibilità di un'unica RAM nella quale si possono mischiare a piacere dati e codice é concettualmente semplice e teoricamente molto potente, in pratica un uso disordinato della memoria da parte dei programmi é fonte inesauribile di errori di programmazione oltre che un grave impedimento per la possibilità di ottimizzare l'uso della memoria in modo automatico. Molto meglio sarebbe se ciascun programma potesse usufruire di un insieme di RAM diverse per soddisfare le diverse esigenze di memorizzazione di codice e dati. La memoria virtuale realizza proprio questa funzionalità: fa "vedere" al programma in esecuzione un insieme di moduli di memoria distinti e con diverse caratteristiche di accesso ai dati, supportate da dispositivi fisici specializzati, mentre continua ad utilizzare i soliti moduli RAM a livello fisico per questioni di economia di realizzazione. Un altro aspetto che ha portato storicamente allo sviluppo della memoria virtuale (ma che oggi, a causa del costo ridotto della RAM, ha perso gran parte della sua rilevanza pratica) é la possibilità di sfruttare una unità a disco detta swap per simulare la disponibilità di dispositivi RAM di dimensioni superiori a quelli effettivamente disponibili nel sistema. Accenneremo brevemente più avanti a questo aspetto della memoria virtuale, dopo aver visto le tecniche di Caching. Per il momento ci limitiamo a considerare il problema dell'uso razionale ed ordinato della sola RAM fisica disponibile.
La segmentazione (pura) é considerata la tecnica più semplice per la realizzazione di un meccanismo di memoria virtuale. A livello di macchina convenzionale viene definito un insieme di segmenti, che possono essere immaginati come moduli di memoria distinti, ciascuno in grado di supportare modi di accesso specializzati e ciascuno caratterizzato dal suo proprio "spazio di indirizzamento" indipendente.
Chiariamo il concetto mediante un esempio molto semplice. Consideriamo il caso di una macchina convenzionale che fa riferimento a tre segmenti diversi, che chiameremo "segmento codice", "segmento dati statici" e "segmento stack". Il segmento codice sarà ovviamente destinato a contenere la codifica binaria delle istruzioni del programma da eseguire, e nessun altro tipo di informazioni. Al segmento codice la CPU potrà sensatamente fare accesso solo durante la fase di fetch delle istruzioni, e solo mediante operazioni di lettura. Il segmento stack sarà destinato a contenere le celle di memoria da usarsi per la realizzazione della struttura stack, per la memorizzazione di dati locali di una procedura, il passaggio di parametri, il salvataggio di valori contenuti in registri della CPU, ecc. La CPU potrà sensatamente accedere a tale segmento solo durante la fase di esecuzione di alcune istruzioni (CALL, RETN, PUSH, POP, LODL, ecc.), con modalità di lettura o scrittura. Infine il segmento dei dati statici sarà destinato a contenere tutti gli altri dati del programma non memorizzati nello stack, e la CPU potrà sensatamente accedere a questo segmento solo durante la fase di esecuzione di altre istruzioni (per esempio le istruzioni Load/Store con indirizzamento diretto).
Il fatto che i segmenti siano "noti a livello di macchina convenzionale" rende possibile un controllo da parte dei dispositivi che i vincoli di uso dei diversi segmenti siano rispettati dal programma in fase di esecuzione: la violazione di qualcuna delle restrizioni prestabilite per l'accesso ai dati contenuti nei vari segmenti (per esempio il fatto che si acceda in scrittura durante la fase di esecuzione di una istruzione STORE ad una cella appartenente al segmento codice) può essere riconosciuta da dispositivi a livello di microarchitettura e "segnalata" sotto forma di trap, consentendo quindi alla CPU di interrompere l'esecuzione del programma che violerebbe le regole e di mandare invece in esecuzione un'apposita procedura di gestione definita dal sistema operativo.
L'idea astratta di "segmentazione" può essere concretamente definita in due modi diversi:
Come si diceva in precedenza, la struttura dei moduli RAM a livello fisico non cambia, quindi per realizzare un meccanismo di segmentazione (implicita o esplicita) occorre aggiungere dei dispositivi in grado di tradurre indirizzi virtuali (detti anche indirizzi logici) usati dal programma in esecuzione sulla CPU in indirizzi fisici usati sul bus per accedere alla RAM.
Nel caso della segmentazione implicita l'unico vero problema da risolvere per definire uno schema di traduzione da indirizzi logici in indirizzi fisici consiste nel trovare un modo semplice per tradurre un qualunque indirizzo compreso tra 0 ed N-1 (dove N indica la dimensione del segmento che stiamo considerando) in un indirizzo fisico di RAM. La soluzione normalmente adottata fa uso di una coppia di registri associata a ciascun segmento. Tali registri sono detti registro base e registro limite. Il registro base contiene l'indirizzo della cella di RAM corrispondente alla cella di indirizzo logico 0 del segmento. Il registro limite contiene l'indirizzo della cella di RAM corrispondente alla cella di indirizzo logico N-1 del segmento (ossia l'ultima). La traduzione da indirizzo logico in indirizzo fisico consta di due passi: prima si somma all'indirizzo logico il contenuto del registro base, ottenendo l'indirizzo fisico corrispondente; poi si confronta l'indirizzo fisico col contenuto del registro limite, e se l'indirizzo fisico é maggiore del limite allora si genera una condizione di errore (si segnala una Trap).
Per esempio, se volessimo modificare la macchina VM-1 per realizzare il meccanismo di segmentazione implicita sopra delineato per la macchina VM-2 (tre segmenti destinati a contenere rispettivamente il codice, lo stack, ed i dati statici del progamma in esecuzione) a livello di microarchitettura bisognerebbe aggiungere 6 registri da 12 bit (per realizzare le tre coppie di registri base e limite, una coppia per ogni segmento). Il fetch verrebbe realizzato a livello di microcodice sommando al contenuto del registro Program Counter il contenuto del registro base del segmento codice, e inserendo il risultato in un registro ausiliario. Poi bisognerebbe sottrarre a questo valore il contenuto del registro limite e, in caso di risultato positivo, attivare la gestione di trap anzichè procedere col fetch. Infine si caricherebbe nel registro MAR l'indirizzo fisico precedentemente memorizzato nel registro ausiliario e si proseguirebbe con la lettura dalla RAM. Analogamente, la fase di traduzione da indirizzo logico ad indirizzo fisico dovrebbe far riferimento alla coppia di registri associata al segmento dati per la realizzazione delle istruzioni con indirizzamento diretto, ovvero alla coppia di registri associata al segmento stack per la realizzazione delle istruzioni con indirizzamento indicizzato rispetto al registro FP, con indirizzamento di tipo autoincremento rispetto al registro SP, e per le istruzioni CALL e RETN. L'identificazione implicita del segmento richiederebbe probabilmente una riprogettazione delle istruzioni POPI, PSHI e LDIX, che usano il modo di indirizzamento indiretto e indicizzato rispetto all'accumulatore (dovendo scegliere se far riferimento al segmento stack per accedere ai dati locali ovvero al segmento dati statici per accedere ai dati globali).
Un meccanismo di segmentazione esplicita richiede invece un aumento del numero di bit per la rappresentazione dell'indirizzo virtuale, a parità di numero di bit di rappresentazione degli indirizzi fisici in RAM, in quanto occorre dedicare bit aggiuntivi per la rappresentazione del numero di segmento. Nel caso della microarchitettura VM-1 questo richiederebbe una riprogettazione dell'intero sistema (a meno di non limitare ulteriormente la già piccola RAM a disposizione). Tipicamente quando si adotta la segmentazione esplicita nella definizione di una macchina convenzionale si dedica un numero congruo di bit alla rappresentazione del numero di segmento, in modo da non porre troppi vincoli al programmatore e dargli la possibilità di definire anche centinaia o migliaia di segmenti. La traduzione da indirizzo logico a indirizzo fisico può essere realizzata in modo del tutto analogo a come abbozzato per la segmentazione implicita, con l'unica differenza che si preferisce normalmente organizzare le coppie di "registri" (base e limite) in tabelle ordinate per numero di segmento, per facilitare la gestione di un numero normalmente maggiore di segmenti. Nella tabella di descrizione dei segmenti ogni elemento contiene (oltre all'indirizzo base e all'indirizzo limite) anche una serie di bit che specificano le modalità consentite di accesso. Un esempio tipico é quello di associare tre bit di "permessi di accesso", che specificano separatamente per ciascuna delle tre modalità "fetch", "read" e "write" se quella modalità é permessa oppure vietata per quel segmento.
Un problema rilevante che si pone per una gestione efficace di una memoria virtuale a segmentazione (pura) é quello di sfruttare bene la capacità della memoria fisica a disposizione anche in presenza di una variazione dinamica delle esigenze. Un esempio del problema si trova pensando al segmento "stack", la cui dimensione ottimale non é semplice da stabilire a priori e una volta per tutte. La quantità di celle di memoria utilizzate nella struttura stack varia dinamicamente durante l'esecuzione del programma, ed il segmento dovrebbe essere definito di ampiezza tale da contenere tutte le celle nel momento della massima estensione dello stack. Abbondare troppo nella dimensione rispetto a questo requisito porterebbe ad uno spreco di memoria che poi non verrebbe effettivamente usata dal programma, mentre una approssimazione per difetto della dimensione ottimale provocherebbe ad un certo punto la condizione di errore per superamento delle dimensioni del segmento. Pragmaticamente la soluzione adottata potrebbe consistere nel definire un segmento stak di dimensioni "medie" (in modo da non sprecare troppa RAM), con la possibilità però di estendere dinamicamente la dimensione del segmento in caso di superamento da parte dello stack delle dimensioni prestabilite (cosa che può essere gestita mediante il meccanismo delle Trap).
L'estensione di un segmento richiede però la disponibilità di celle di memoria nella RAM con indirizzi contigui rispetto a quelli delle celle facenti già parte dello stesso segmento. Quindi potrebbe succedere che nel momento in cui si vuole estendere un segmento le celle di indirizzo adiacente siano "usate" per realizzare altri segmenti, mentre altre celle di RAM con indirizzi non adiacenti siano libere. In questi casi occorrerebbe "spostare" i segmenti (copiando da una cella all'altra i dati già memorizzati nei segmenti usati) per far in modo che le celle di indirizzo contiguo vengano "liberate" e rese quindi disponibili per l'estensione. Queste operazioni di copia di dati provocherebbero ovviamente rallentamenti nella gestione dell'eccezione di ampliamento del segmento; per questo nei sistemi moderni si cerca di evitarle introducendo il meccanismo della paginazione.
Introducendo il meccanismo della segmentazione ci siamo scontrati col problema di gestione efficiente della memoria dovuto al vincolo di contiguità degli indirizzi delle celle di memoria fisica da utilizzare per realizzare un segmento. Tale vincolo deriva dalla semplicità dell'algoritmo di traduzione da indirizzo logico ad indirizzo fisico (la somma di una costante, detta "indirizzo base"). Ovviamente si può pensare che, complicando un po' l'algoritmo di traduzione sia possibile superare questo vincolo di contiguità sugli indirizzi fisici (pur mantenendo la contiguità degli indirizzi logici).
In effetti una soluzione concettualmente semplice per eliminare totalmente tale vincolo sarebbe quella di definire una funzione uno-uno tra gli indirizzi logici consecutivi del segmento (0, 1, 2, ... N-1) e qualsiasi sequenza di N indirizzi fisici della RAM. In tal modo si potrebbe per esempio specificare la corrispondenza tra l'indirizzo logico 0 e l'indirizzo fisico 31415, tra l'indirizzo logico 1 e l'indirizzo fisico 2047, tra l'indirizzo logico 2 e l'indirizzo fisico 3, ecc. La definizione di tale funzione sarebbe supportata da un vettore di N elementi, che riporti gli indirizzi fisici corrispondenti agli indirizzi logici i cui numeri corrispondono alle posizioni (nel nostro esempio, il vettore [31415, 2047, 3, ...]). Volendo tradurre l'indirizzo logico "1" in indirizzo fisico non dovremmo far altro che estrarre il valore dell'elemento numero "1" dal vettore che rappresenta la nostra funzione, per capire che l'indirizzo fisico corrispondente é "2047".
Ovviamente la soluzione che stiamo prospettando è valida solo da un punto di vista teorico, in quanto la sua realizzazione pratica sarebbe altamente inefficiente. La tabella di traduzione degli indirizzi dovrebbe essere memorizzata da qualche parte (tipicamente un dispositivo RAM, e per indirizzare in modo virtuale una RAM da N celle ci servirebbe un'altra RAM da N celle che contenga la tabella: quindi potremmo usare per scopi utili solo metà delle celle di RAM effettivamente presenti nel sistema. Ci serve quindi una soluzione concettualmente simile ma che richieda la memorizzazione di un vettore di indirizzi fisici di dimensione nettamente inferiore rispetto al numero di celle di RAM che vogliamo indirizzare in modo virtuale.
La soluzione praticamente accettabile viene ottenuta ponendo dei vincoli sui valori che la funzione uno-uno può associare ai vari indirizzi logici. Per esempio, potremmo stabilire che l'indirizzo logico 0 possa corrispondere ad un qualsiasi indirizzo fisico J, ma che poi l'indirizzo logico 1 non possa corrispondere che all'indirizzo fisico J+1. Analogamente, potremo scegliere in modo completamente arbitrario l'indirizzo fisico K da associare all'indirizzo logico 2, ma con questo avremo anche stabilito una volta per tutte che l'indirizzo logico 3 corrisponde all'indirizzo fisico K+1. Detto in altri termini, potremmo specificare una funzione uno-uno arbitraria che associa a ciascun valore pari di indirizzo logico un qualsiasi valore di indirizzo fisico, ed useremo questa stessa funzione per calcolare la corrispondenza anche per gli indirizzi logici dispari (oltre che per quelli pari), semplicemente sommando "1" al valore della funzione applicata all'indirizzo logico di valore pari immediatamente precedente. La traduzione richiederà una operazione di somma in più nel caso dispari rispetto alla funzione completa, per tutti gli indirizzi, ma il vettore richiederà un numero di elementi che é solo la metà del numero di celle di RAM da usare. Quindi, avendo a disposizione un numero totale di M celle di RAM per la realizzazione di una memoria virtuale, potremmo usarne N=2/3 M come celle effettivamente utili, e solo M/3 per la memorizzazione del vettore di traduzione. Il prezzo che dobbiamo pagare per questa maggior efficienza nell'uso della memoria fisica consiste nella necessità di dover sempre considerare due celle di memoria fisica adiacenti. Se anche a noi servisse una sola cella di memoria per memorizzare i nostri dati, saremmo comunque costretti ad "impegnare" anche la traduzione dell'indirizzo della cella di indirizzo fisico successivo, che quindi non sarebbe più utilizzabile per la realizzazione fisica di un indirizzo virtuale diverso dal successore dell'indirizzo logico corrispondente alla cella di indirizzo fisico precedente.
In generale possiamo estendere la nostra idea raggruppando più di due celle di indirizzo (logico e fisico) consecutivo, in modo da aumentare ancora il livello di efficienza nell'uso della memoria. Tale tecnica viene chiamata memoria virtuale a paginazione. Per il momento concentriamoci sulla definizione della sola tecnica di paginazione senza segmentazione, anche se, come vedremo tra poco, le due tecniche (per altro ortogonali) vengono di solito combinate. Se per esempio definissimo delle pagine da 100 celle, potremmo associare indirizzi fisici indipendenti ed arbitrari agli indirizzi logici 0, 100, 200, 300, ecc., mentre l'indirizzo fisico corrispondente ad un qualsiasi indirizzo logico L non multiplo di 100 verrebbe calcolato sommando all'indirizzo fisico F memorizzato nell'elemento numero (L/100) del vettore di traduzione il resto della divisione (L mod 100). In tal caso delle M celle di memoria RAM fisica a disposizione nel sistema potremmo usarne 100/101 M come celle indirizzabili in modo virtuale, mentre dovremmo riservarne M/101 per il vettore di traduzione. Considerando poi che la traduzione implica delle operazioni di divisione e di calcolo del modulo su rappresentazioni binarie di indirizzi logici e fisici risulta evidente la convenienza di definire pagine di dimensione P esattamente uguale ad una potenza di due; in tal modo le operazioni (L/P) e (L mod P) si riducono ad estrarre un sottoinsieme dei bit dalla rappresentazione binaria del valore L.
Una ulteriore semplificazione dell'algoritmo di traduzione viene ottenuta imponendo che anche il valore F dell'indirizzo fisico corrispondente all'indirizzo logico L sia un multiplo della dimensione predefinita P delle pagine. In tal modo abbiamo la garanzia che valga la condizione (F mod P)=0, ossia che gli ultimi "o" bit (dove o = log2(P)) siano tutti zeri. In tal modo la somma F+(L mod P) può essere realizzata semplicemente ricopiando gli "o" bit meno significativi della rappresentazione binaria di L al posto degli "o" zeri che costituiscono i bit meno significativi nella rappresentazione di F.
Tirando le somme, definiamo un sistema di memoria virtuale a paginazione stabilendo a priori una dimensione di pagina P, col vincolo che P sia una potenza esatta di 2. Un qualsiasi indirizzo espresso sotto forma binaria su k bit (con k > o = log2(P)) può quindi essere suddiviso in due "componenti":
Un meccanismo di paginazione come quello delineato in questa parte non viene solitamente utilizzato da solo, ma come complemento per la realizzazione efficiente di una memoria virtuale a segmentazione. L'accoppiamento di segmentazione e paginazione permette infatti di realizzare una memoria virtuale più semplice da gestire in modo efficiente, nella quale i segmenti sono sempre costituiti da un insieme di pagine complete. In tal modo l'estensione (anche dinamica) di un segmento può essere realizzata aggiungendo ulteriori pagine inutilizzate a quelle già usate in precedenza per lo stesso segmento. Il vantaggio derivante dall'uso della paginazione é che le pagine fisiche libere da aggiungere al segmento possono essere associate a numeri di pagina logica consecutivi a quelli già esistenti nel segmento anche se i numeri di pagina fisica non lo sono, senza costose operazioni di "spostamento" in memoria dei dati contenuti in pagine usate. Come accenneremo più avanti, la tecnica di paginazione é poi solitamente alla base della realizzazione di una memoria virtuale più grande di quella fisica (swapping e paginazione a richiesta).
La scelta del numero di bit "o" di rappresentazione dell'offset (e quindi della dimensione P della pagina) é normalmente il frutto di una mediazione tra diverse esigenze. Da un lato, pagine di piccole dimensioni implicano, a parità di bit di indirizzamento k, l'uso di tabelle delle pagine più grandi, e quindi un maggior onere di memorizzazione e di gestione delle stesse. Dall'altro lato, pagine di grandi dimensioni possono anch'esse determinare condizioni di cattiva utilizzazione della memoria fisica disponibile, in quanto anche la necessità di indirizzare una sola cella in modo virtuale implica comunque l'impiego di una intera pagina fisica di P celle (P-1 delle quali non saranno in questo caso utilizzate e nemmeno utilizzabili per altri scopi).
Dalla discussione svolta fin qua appare evidente l'interesse per l'adozione di un meccanismo di memoria virtuale a segmentazione e paginazione. L'uso della segmentazione (implicita o esplicita) permette di tenere sotto controllo l'uso che viene fatto da parte dei programmi delle celle di memoria che contengono codice e dati diversi. L'uso della paginazione consente di alleviare i vincoli di contiguità degli indirizzi fisici di RAM utilizzabili per comporre i vari segmenti, semplificando una gestione dinamica efficiente dei segmenti dedicati ad un programma. La composizione dei segmenti, i permessi di accesso, ecc., vengono gestiti mediante la creazione e l'uso da parte del sistema operativo di opportune tabelle di descrizione allocate in memoria RAM. La parte di gestione vera e propria verrà studiata in modo approfondito nell'ambito del corso di Sistemi Operativi, mentre qui ci occupiamo essenzialmente di quali funzionalità deve offrire (livello L2) e come deve essere realizzata (livello L1) una macchina convenzionale per consentire al sistema operativo di gestire in modo corretto, efficiente e sicuro questa funzionalità.
Passiamo quindi a vedere un esempio realistico di strutturazione della traduzione di indirizzi virtuali in indirizzi fisici facendo riferimento ad un sistema con segmentazione esplicita e paginazione. Tanto per cominciare, in questo caso l'indirizzo virtuale di una cella di memoria potrà essere espresso su v=s+l+o bit, organizzato in tre "campi":
La tabella dei segmenti deve contenere tanti elementi quanti sono i segmenti utilizzabili dal programma (ossia 2s). Il campo "numero di segmento" (ossia gli "s" bit più significativi) dell'indirizzo virtuale viene usato per individuare quale elemento della tabella dei segmenti contiene la descrizione del segmento cui appartiene la cella di memoria di cui stiamo considerando l'indirizzo. Ciascun elemento della tabella dei segmenti sarà costituito da un insieme di bit comprendenti: il sottoinsieme dei bit che indicano le modalità di accesso consentite a quel segmento, la rappresentazione binaria (su "l" bit) della "dimensione" del segmento espressa in numero totale di pagine, e la rappresentazione binaria (su "f" bit) dell'indirizzo fisico della cella di RAM a partire dalla quale si trova la rappresentazione della tabella delle pagine che costituiscono quel segmento.
La tabella delle pagine associata a un segmento deve contenere tanti elementi quante sono le pagine che attualmente costituiscono il segmento (quindi lo stesso numero codificato su "l" bit nell'elemento corrispondente della tabella dei segmenti). Ciascun elemento conterrà la rappresentazione binaria (su "p" bit) del numero di pagina fisica corrispondente al numero di pagina logica che coincide col numero dell'elemento all'interno della tabella delle pagine.
L'algoritmo di traduzione può essere schematizzato nei seguenti passi:
Anche da una superficiale analisi dell'algoritmo di traduzione riportato sopra possiamo subito renderci conto della difficoltà della sfida per la realizzazione della MMU. Nella migliore delle ipotesi, cioé anche se tutti i tempi di esecuzione dei vari passi facenti capo ad operazioni realizzate internamente alla MMU fossero ridotti a valori trascurabili rispetto al periodo di clock della CPU, la traduzione richiederebbe almeno l'accesso ad una riga della tabella dei segmenti e ad una riga di una delle tabelle delle pagine, informazioni memorizzate in RAM, come abbiamo detto. Quindi, un "accesso virtuale" alla RAM dovrebbe richiedere un tempo non inferiore alla somma di 3 tempi di "accesso fisico" alla stessa RAM. In assenza di una idea brillante che ci consenta di evitare questi accessi intermedi alla RAM, rischiamo quindi di rallentare l'esecuzione dei programmi di un fattore tre!
L'idea vincente che ha permesso di evitare (in parte) questo grosso inconveniente é stata quella di introdurre una memoria associativa all'interno del dispositivo MMU. La presenza di tale memoria associativa consente di "copiare" parte delle informazioni contenute nelle tabelle di paginazione e di segmentazione contenute in RAM all'interno della MMU. La copià é solo parziale per problemi di costo e di complessità realizzativa della MMU, che limitano drasticamente la dimensione della memoria associativa rispetto a quella che sarebbe necessaria per contenere una copia completa delle informazioni contenute in tutte le tabelle.
Il principio di funzionamento é il seguente: si parte "a freddo" con la MMU che non contiene alcuna informazione; la prima volta che viene richiesta la traduzione di un indirizzo, la MMU accede alle tabelle di segmentazione e paginazione per recuperare i dati necessari dalla RAM; questi dati vengono inseriti nella memoria associativa interna sotto forma di associazione tra gli (s+l) bit più significativi dell'indirizzo virtuale (che verranno usati per la ricerca associativa) ed i "p" bit di numero di pagina fisica corrispondente accoppiati con i bit di specifica dei modi di accesso consentiti per il segmento che contiene quella pagina (che verranno usati come dati utili a supporto della traduzione); traduzioni successive di indirizzi facenti riferimento alla stessa pagina potranno così essere completate con un solo accesso alla memoria associativa interna alla MMU anzichè con due accessi alle tabelle in RAM. Notare che la "ritraduzione" di un indirizzo facente riferimento alla stessa pagina di un indirizzo già tradotto correttamente non richiede più la verifica sulla dimensione del segmento (che é già stata portata a termine con successo la volta precedente).
Ovviamente, in pratica l'efficacia della soluzione adottata per la realizzazione della MMU dipende dalla maggiore o minore "regolarità con cui i programmi accedono alle diverse pagine dei diversi segmenti, rapportata alla dimensione della memoria associativa utilizzata. La tecnica non produrrebbe alcun beneficio se la dimensione della memoria associativa fosse così ridotta rispetto al numero di pagine richieste dall'esecuzione del programma da non permettere mai di riutilizzare il contenuto di una cella associativa dopo averla inserita e prima di doverla eliminare per far posto ad altre associazioni. Per fortuna, come vedremo meglio in seguito, molto spesso i programmi hanno notevoli caratteristiche di regolarità che consentono di trarre buon vantaggio da questa soluzione, introducendo ritardi per la traduzione degli indirizzi di solito trascurabili rispetto all'uso dell'indirizzamento fisico.
Concludiamo la discussione sull'indirizzamento virtuale con una considerazione pratica di grande importanza per l'organizzazione di una macchina convenzionale. Se, come abbiamo detto, le tabelle di traduzione devono essere memorizzate (nella loro forma completa) in RAM, se i programmi appartenenti al sistema operativo devono poter accedere e manipolare il contenuto di queste tabelle, e se qualunque accesso alla RAM con indirizzamento virtuale richiede che le tabelle di traduzione siano state prima correttamente definite come farebbe il sistema operativo ad inserire in RAM le tabelle che servono al sistema operativo stesso per accedere alla RAM? L'unica risposta positiva possibile a tale domanda è: "Almeno il sistema operativo deve poter eseguire istruzioni che accedano alla RAM con indirizzamento fisico!".
Quindi, qualunque processore che adotti un meccanismo di memoria
virtuale deve essere realizzato in modo da poter eseguire istruzioni
di accesso alla RAM sia con indirizzamento virtuale che con
indirizzamento fisico.
Normalmente sia il sistema operativo che i programmi utente
dovrebbero usare il meccanismo di indirizzamento virtuale.
Eccezionalmente, e comunque almeno durante la fase di bootstrap,
il sistema operativo eseguirà istruzioni di accesso alla
RAM con indirizzamento fisico per predisporre il contenuto delle
tabelle di segmentazione e paginazione, da usare successivamente
(da parte della MMU) per la traduzione degli indirizzi virtuali.
Dopo aver introdotto il meccanismo di memoria virtuale a segmentazione (eventualmente anche con paginazione), potremmo pensare di aver risolto anche un problema molto importante nell'uso pratico dei sistemi di calcolo, ovvero quello di garantire l'integrità e la riservatezza delle informazioni che un programma sta trattando, anche in presenza di altri programmi eseguiti sullo stesso sistema che, per errore oppure per deliberato intento aggressivo, attentassero alla integrità e riservatezza di altri programmi. Una caratteristica molto desiderabile per un sistema operativo é infatti quella di consentire l'accesso a più utenti diversi (contemporaneamente oppure in tempi successivi) agli stessi dispositivi. É ovvio che l'utilità pratica di tale possibile condivisione di risorse viene completamente meno se il sistema non é in grado di garantire elementari requisiti di sicurezza per i dati che vengono memorizzati e manipolati da un utente.
In effetti il meccanismo della memoria virtuale a segmentazione é uno dei principali "ingredienti" che consentono al progettista di un sistema operativo di predisporre un ambiente di lavoro sicuro ed affidabile per più utenti che condividano l'accesso allo stesso sistema. La definizione di segmenti assegnati in uso esclusivo ad un programma e non ad altri consente a tali programmi di far affidamento sul fatto che i dati memorizzati in tali segmenti non potranno essere ne' letti ne' tantomeno cambiati a seguito dell'esecuzione di altri programmi. La garanzia a tempo di esecuzione viene fornita dall'uso del dispositivo MMU, il quale segnala una trap in caso di tentativi di violazione "dei confini" stabiliti da parte di un qualsiasi programma. Il sistema operativo non deve quindi far altro che garantire la corretta assegnazione di segmenti diversi e disgiunti ad ogni programma utente. Tale funzionalità viene spesso indicata col termine confinamento (di una macchina virtuale all'interno di un ambiente ristretto predefinito dal sistema).
Le violazioni dei confini segnalate attraverso trap possono essere "punite" da parte del sistema operativo anche in modo molto drastico determinando la terminazione prematura del programma. L'applicazione sistematica ed inesorabile della "pena di morte" per tutti i programmi che cerchino di violare i loro "confini territoriali" prestabiliti é un metodo estremamente efficace (anche se appare un po' truculento) per prevenire qualsiasi disputa territoriale tra "vicini". Gli effetti devastanti di questo regime che fonda la sua tranquillità sul terrore, sono in parte mitigati dalla relativa facilità di "reincarnazione" di un programma, che può essere di nuovo eseguito dopo aver corretto l'errore che determinava la violazione dei diritti di accesso.
L'uso della memoria virtuale associata alla definizione delle trap di violazione dei segmenti costituisce solo un primo passo nella costruzione di un sistema sicuro contro attacchi portati dall'esecuzione di programmi all'integrità dei dati (di altri programmi o del sistema operativo stesso). L'analogia é quella di un sistema di controllo dei confini che preveda la definizione di un insieme di varchi di frontiera su tutte le strade importanti, presidiati da cecchini con l'ordine di sparare a vista su chiunque tenti di superarli. Se a questi varchi presidiati non si accoppiasse anche un efficace sistema di difesa della parte rimanente dei confini, si rischierebbe di uccidere tutti quelli che cercassero di violare i confini nei varchi presidiati, ma di lasciar passare impunemente chi seguisse sentieri non presidiati.
Uno dei sentieri lasciati incustoditi da un sistema di memoria virtuale a segmentazione é la possibilità di eseguire istruzioni di accesso alle tabelle di paginazione e segmentazione con indirizzamento fisico. Abbiamo visto prima che questo sentiero deve rimanere aperto per consentire al sistema operativo di inserire inizialmente i valori giusti in tali tabelle, prima di passare lui stesso a seguire le strade prinicipali attraverso l'uso dell'indirizzamento virtuale. D'altra parte é ovvio a questo punto che la stessa possibilità non deve essere concessa anche ad un programma "utente", visto che in questo caso il sistema non potrebbe garantire che il programma, una volta imboccato il sentiero non presidiato, non decida di avventurarsi oltre confine.
La soluzione al problema é molto semplice: si definiscono a livello di macchina convenzionale due o più modi di esecuzione dei programmi. Uno di questi modi viene normalmente chiamato "privilegiato", "kernel" oppure "di sistema". Un altro modo di esecuzione viene normalmente chiamato "non privilegiato", "protetto" oppure "user". Il modo di esecuzione viene memorizzato in uno o più bit dedicati del registro di stato della CPU. Di pari passo viene definita una partizione dell'insieme delle istruzioni di livello L2 in due sottoinsiemi, chiamati di solito istruzioni privilegiate e istruzioni non privilegiate. Infine, a livello L1 di realizzazione della macchina convenzionale si fa in modo che, mentre le istruzioni non privilegiate possano essere sempre eseguite dalla CPU nel solito modo, le istruzioni privilegiate possano essere eseguite dalla CPU solo quando il registro di stato indica il modo di esecuzione privilegiato. Il tentativo da parte di un programma eseguito in modo non privilegiato di eseguire una istruzione privilegiata viene "rintuzzato" a livello di microarchitettura mediante la segnalazione di una trap (la cui gestione solitamente determina la terminazione immediata del programma).
Ovviamente si provvede poi a definire come privilegiate le istruzioni della macchina convenzionale che usano l'indirizzamento fisico, e come non privilegiate quelle che usano l'indirizzamento virtuale. Ultimo dettaglio, al momento dell'accensione, il registro di stato viene forzato a contenere l'indicazione di modo di esecuzione privilegiato, in modo da consentire al sistema operativo di eseguire tutte le istruzioni della macchina convenzionale. Con questo, il progettista del sistema operativo ha tutti gli utensili a disposizione per fabbricare un sistema che garantisce una protezione completa dei dati anche in presenza di programmi utente "ostili" verso altri programmi utente o verso il sistema stesso. Basterà infatti che il sistema operativo sia strutturato in modo da garantire le seguenti condizioni di funzionamento:
Notiamo infine che il meccanismo delle trap é l'unico
in grado di mantenere la sicurezza ed al contempo aumentare
il livello di privilegio di esecuzione del codice.
Infatti la segnalazione di trap determina l'interruzione del
programma (utente) in corso e l'avvio di una procedura di
gestione predefinita (dal sistema operativo).
Il livello di privilegio può essere alzato in questo
caso perchè si ha la certezza che il codice che si
andrà ad eseguire non fa parte del programma utente
ma del sistema operativo (della cui correttezza e
integrità ci si deve per forza fidare).
Ben diverso sarebbe il caso di una chiamata di procedura,
in cui l'indirizzo della procedura viene indicato dal
programma che esegue l'istruzione CALL.
Vediamo quindi a questo punto l'utilità dell'introduzione
del concetto di "trap esplicita".
Attraverso l'esecuzione di una istruzione esplicita di trap,
un programma eseguito in modo "utente" può interrompere
volontariamente la propria esecuzione, forzare l'esecuzione
di un gestore predefinito dal sistema operativo in modalità
privilegiata, ed ottenere da questo l'accesso a risorse protette
di sistema senza compromettere la sicurezza del sistema
(come invece accadrebbe se potesse lui stesso richiamare
una sua procedura in modo di esecuzione privilegiato).