Modalità Protetta
Capitolo 2: Caratteristiche avanzate della Modalità Protetta 80286
In questo capitolo vengono illustrate le caratteristiche avanzate della modalità
protetta 80286; in particolare, si esaminano gli aspetti relativi ai meccanismi
di protezione, al multitasking, alla gestione di interruzioni ed eccezioni e alla
memoria virtuale.
2.1 Protezione
I meccanismi di protezione introdotti dalle CPU 80286 rappresentano un
enorme salto in avanti rispetto alla modalità reale 8086; infatti, oltre
a rendere più ordinata, efficiente e sicura la gestione dei task da parte dei
SO, permettono anche di semplificare notevolmente il lavoro di verifica
del software, con particolare riferimento alla ricerca degli errori. Imponendo
precise regole per le interazioni tra task (ciò che un task può fare o non può
fare), è possibile individuare e segnalare eventuali violazioni, spesso legate
a errori di programmazione. Qualsiasi violazione delle regole viene segnalata
dalla CPU attraverso apposite eccezioni; in base al tipo di
eccezione e ai relativi codici di errore, il programmatore può facilmente
individuare il problema e anche il punto in cui si è verificato.
Una conseguenza positiva di tutto ciò è che viene anche garantita una notevole
stabilità generale del sistema. In modalità reale 8086 un programma è
libero di compiere operazioni illecite, come sovrascrivere un'area riservata,
che nella migliore delle ipotesi possono provocare un crash; spesso però tale
crash può coinvolgere l'intero SO. Nella modalità protetta 80286,
un'operazione illecita compiuta da un programma viene segnalata da una eccezione
della CPU; i SO possono intercettare tale eccezione e prendere gli
opportuni provvedimenti, come chiudere eventualmente il programma che ha creato
il problema, senza per questo interrompere il funzionamento del sistema.
Uno svantaggio dei meccanismi di protezione tradizionali è legato al fatto che le
continue verifiche sul rispetto delle regole, possono avere un impatto piuttosto
negativo sulle prestazioni; proprio per evitare questo inconveniente, la 80286
gestisce tutti questi aspetti via hardware, riducendo così al minimo le perdite di
tempo.
Il sistema di protezione della 80286 si concentra sui seguenti tre aspetti:
- isolamento del software di sistema dai programmi
- isolamento tra i vari task in esecuzione
- verifica sui segmenti di codice e dati
Per isolare il software di sistema dai programmi installati dall'utente, la CPU
mette a disposizione quattro livelli di privilegio, indicati con le cifre da 0
a 3; il livello 0 è il più alto, mentre il livello 3 è il più basso.
Si può assegnare così il livello 0 al SO, mentre i programmi vengono fatti
girare ai livelli di privilegio più bassi (generalmente il 3); in questo modo,
il software di sistema risulta protetto da eventuali comportamenti scorretti da parte
dei programmi (ad esempio, un programma che richiede un servizio al SO, deve
sottostare a precise regole).
L'isolamento tra i vari task in esecuzione viene garantito assegnando a ciascuno di
essi una LDT, che definisce lo spazio virtuale privato di indirizzamento; in
assenza di queste precauzioni, un task potrebbe invadere liberamente lo spazio
riservato ad altri task (in modalità reale, un programma può invadere e anche
sovrascrivere aree riservate al DOS). L'isolamento tra task è un aspetto di
fondamentale importanza, in particolare, nei SO multitasking.
La verifica su codice e dati è necessaria per impedire operazioni illegali come, ad
esempio, la modifica di dati costanti o l'accesso ad un offset fuori limite; tramite
l'ACCESS BYTE e il campo LIMIT specificati da un descrittore di segmento,
è possibile stabilire tutte le caratteristiche del codice o dei dati presenti nel
segmento di programma associato.
2.1.1 Programmi, segmenti e task
Per capire il sistema di protezione offerto dalla 80286 bisogna partire dai
concetti fondamentali di programma, segmento e task. Un programma
è una sequenza di istruzioni suddivise logicamente in blocchi di codice, dati e stack,
residenti su un dispositivo di memorizzazione (ad esempio, un hard disk). Eseguire
un programma significa caricarlo in memoria e disporre ciascun blocco in un apposito
segmento; il segmento, in modalità protetta 80286, rappresenta una sorta di
unità di memoria virtuale.
Le caratteristiche di un segmento vengono definite da un apposito descrittore; il
descrittore specifica l'indirizzo da cui parte il segmento in RAM, le
dimensioni del segmento e le informazioni addizionali relative alla gestione della
protezione e della memoria virtuale. Un segmento, essendo un'unità di memoria
virtuale, viene trattato come entità unica, avente caratteristiche di protezione
ben definite; non è possibile quindi assegnare differenti attributi di protezione a
differenti porzioni dello stesso segmento.
La 80286 fornisce 4 registri di segmento a 16 bit attraverso
i quali è possibile gestire altrettanti segmenti contemporaneamente nella fase di
esecuzione; in ogni istante, CS referenzia il segmento di codice, SS
il segmento di stack, DS il segmento di dati e (se necessario) ES
un segmento di dati extra.
Un selettore caricato in un registro di segmento, specifica il livello di privilegio
desiderato (RPL) e punta (INDEX e TI) al relativo descrittore;
ogni volta che carichiamo un selettore nella parte visibile di un registro di segmento,
la CPU accede al descrittore associato, legge i campi LIMIT, BASE
e ACCESS e li salva nella parte nascosta del registro stesso. La Figura 2.1
illustra questi aspetti.
Avendo a disposizione queste informazioni, la CPU è in grado di controllare
tutte le fasi in cui si suddivide l'esecuzione di un programma; ciascuna di tali
fasi viene assegnata ad un task.
La CPU quindi affida ad un task il compito di gestire l'esecuzione di un
programma tenendo conto delle informazioni memorizzate nella parte nascosta dei
registri di segmento; ogni fase dell'esecuzione può essere svolta solo quando
soddisfa tutti i requisiti richiesti. Se proviamo a caricare in SS il
selettore di un segmento di stack con attributo "read only" (W=0), si
verifica un'eccezione in quanto lo stack deve essere "writable" (W=1). Un
tentativo di accesso ad un segmento "not present" (P=0) provoca un'eccezione.
Un tentativo di scrittura in un segmento di dati "read only" (W=0) provoca
un'eccezione. Un salto verso un segmento con un livello di privilegio più alto (e
C=0) provoca un'eccezione.
Tutti i controlli vengono effettuati via hardware dalla CPU prima che un
determinato task venga eseguito; se tali controlli danno esito negativo, viene
generata un'eccezione.
2.1.2 Spazio di indirizzamento virtuale privato
Ogni task in esecuzione ha a disposizione due tabelle di descrittori, la
GDT e la LDT. La GDT è unica e condivisa da tutti i task e
contiene i descrittori di sistema, compresi quelli che puntano alle LDT
associate ai vari task in esecuzione; ogni task ha la propria LDT che
contiene i descrittori dei segmenti in uso al task stesso.
Le tabelle dei descrittori permettono di assegnare ad ogni task il proprio spazio
di indirizzamento virtuale privato. Un task ha il pieno controllo esclusivamente
sui segmenti elencati dai descrittori presenti nella propria LDT; in questo
modo risulta isolato da tutti gli altri task, che a loro volta sono dotati delle
rispettive LDT. Un task che tenta di accedere ad un segmento non elencato
nella propria LDT, viene subito bloccato da una eccezione.
La Figura 2.2 mostra una parte dei registri di sistema utilizzati dalla 80286
per gestire le tabelle dei descrittori.
Le tabelle dei descrittori sono esse stesse dei segmenti, per cui ciascuna di esse
necessita di un descrittore. In fase di inizializzazione della modalità protetta,
dobbiamo usare l'istruzione LGDT per caricare nel GDTR il descrittore
(formato solo da BASE e LIMIT) della GDT; stesso discorso per
l'IDTR, che viene illustrato nel seguito del capitolo. Generalmente, il
GDTR e l'IDTR, una volta inizializzati non vengono più modificati.
La GDT contiene anche l'elenco dei descrittori delle LDT associate ai
vari task in esecuzione. In ogni istante un solo task è in esecuzione e il relativo
selettore, che punta al descrittore (nella GDT) della propria LDT, va
caricato nella parte visibile del LDTR tramite l'istruzione LLDT; la
CPU accede a tale descrittore, legge i campi BASE e LIMIT e li
copia nella parte invisibile del LDTR.
In un ambiente monotasking, anche il LDTR una volta inizializzato non viene
più modificato. In un ambiente multitasking, il LDTR viene inizializzato con
il selettore della LDT del primo task da eseguire; ad ogni cambio di task
(task switch), la CPU provvede ad aggiornare in automatico il LDTR.
Ogni tabella può contenere sino a 8192 descrittori. Il task correntemente in
esecuzione ha a disposizione la GDT e la propria LDT, per cui "vede"
sino a 1 GiB di memoria virtuale; infatti, abbiamo un massimo di 16384
descrittori (8192*2) che fanno riferimento ad altrettanti segmenti, ciascuno
dei quali può arrivare a 64 KiB.
Sia la GDT che le varie LDT devono specificare le rispettive dimensioni
(-1) in byte attraverso il campo LIMIT; ricordando che ogni descrittore
occupa 8 byte, per una tabella che contiene N descrittori si deve avere,
come minimo:
LIMIT = (N * 8) - 1
In una tabella, non è detto che tutti gli N descrittori presenti siano validi;
soprattutto in un SO multitasking, è normale che ci siano dei "buchi", dovuti
al fatto che continuamente alcuni programmi terminano, mentre altri iniziano la fase
di esecuzione. Il SO gestisce questa situazione provvedendo a deallocare i
segmenti dei programmi che terminano e ad allocare segmenti per i nuovi programmi,
sfruttando se necessario i buchi presenti; il tutto avviene quindi senza la necessità
di modificare il campo LIMIT della tabella.
Un descrittore con ACCESS BYTE posto a 00000000b viene considerato
vuoto; qualsiasi tentativo di accedere ad un descrittore vuoto provoca un'eccezione.
Allo stesso modo, un riferimento ad un descrittore disposto oltre il campo LIMIT
provoca un'eccezione.
2.1.3 Livelli di privilegio
Abbiamo visto che l'uso delle LDT garantisce l'isolamento tra task; cosa
succede però quando, ad esempio, un task deve richiedere un servizio al SO?
In assenza di regole, un task potrebbe accedere ai servizi di sistema (condivisi
attraverso la GDT) e compiere operazioni illecite. Si rende necessario quindi
garantire anche l'isolamento tra ogni task e il SO; a tale proposito, entrano
in gioco i livelli di privilegio.
La 80286 mette a disposizione 4 livelli di privilegio, numerati da
0 a 3; il livello 0 è quello più alto, mentre il 3 è il
più basso. Le regole generali sono molto semplici. Un programma che gira al livello
0 può accedere dappertutto; un programma che gira ai livelli da 1 a
3 può accedere solo a quei segmenti con livello di protezione minore o uguale
al suo (per cui non è autorizzato ad accedere direttamente al software di sistema che
gira a livello 0).
Se un programma deve accedere ad un segmento con livello di privilegio più alto,
deve ricorrere ad appositi meccanismi messi a disposizione dalla 80286;
questo aspetto viene illustrato nel seguito.
La Figura 2.3 mostra l'esempio di una tipica ripartizione dei privilegi per un
SO multitasking.
Il kernel (termine tedesco che equivale a "nucleo") rappresenta lo strato
più interno del SO, che si occupa dell'interfaccia diretta con l'hardware;
tale strato comprende, ad esempio, tutti i servizi necessari per la gestione della
RAM, per l'organizzazione del file system sull'hard disk, per l'accesso alle
porte di I/O, etc. Appare evidente che questa è la parte più delicata del
sistema e quindi viene fatta girare al livello di privilegio 0.
Al livello di privilegio 1 troviamo i servizi che il SO fornisce ai
programmi; questo livello può essere visto come una sorta di barriera che impedisce
ai programmi di accedere direttamente al kernel. Un programma può richiedere, ad
esempio, un servizio per l'allocazione di un blocco di memoria o per la creazione di
un file su disco; se tale richiesta risulta formulata correttamente, viene "girata"
al kernel.
Il livello di privilegio 2 viene assegnato alle estensioni del SO
che offrono vari servizi di configurazione; infine, al livello di privilegio
3 troviamo i normali programmi.
L'organizzazione dei livelli di privilegio non deve seguire necessariamente lo
schema di Figura 2.3; si può anche scegliere, ad esempio, di far girare qualsiasi
cosa a livello 1. I SO come Windows, Linux e MacOS
utilizzano solo due livelli di privilegio; quello più alto, denominato supervisor
mode, è riservato al software di sistema, mentre quello più basso, denominato
user mode, è riservato ai programmi. Questa scelta è legata anche alla
necessità di rendere i vari SO compatibili con altre piattaforme hardware,
alcune delle quali sfruttano appunto solo due livelli di privilegio.
La Figura 2.3 giustifica l'utilizzo, molto diffuso nella programmazione di sistema,
del termine ring (anello) per indicare i livelli di privilegio; si può dire,
ad esempio, che il kernel "gira a ring 0".
I privilegi si applicano ai task e ai seguenti tre tipi di descrittore:
- segment descriptor
- gate descriptor
- task state segment descriptor
Un task ha il compito di eseguire un programma e ciò significa eseguire i segmenti di
codice che appartengono al programma stesso. Ogni segmento di codice ha un livello di
privilegio fisso (assegnato al momento della creazione del segmento stesso), mentre
un task ha un livello di privilegio dinamico; osserviamo, infatti, che durante
l'esecuzione di un programma può presentarsi il caso di un salto ad un altro segmento
di codice con differente livello di privilegio, per cui anche il livello di privilegio
del task deve cambiare di conseguenza.
Come abbiamo visto nel precedente capitolo, il livello di privilegio di un segmento
di codice viene indicato dal campo DPL presente nell'ACCESS BYTE del
relativo descrittore (Figura 2.4).
Il livello di privilegio di un task prende il nome di Current Privilege Level
(CPL) e si trova memorizzato nei bit in posizione 0 e 1 della parte
visibile del registro CS (prende quindi il posto del campo RPL); per quanto
è stato spiegato in precedenza, mentre il DPL è fisso, il CPL cambia
dinamicamente durante la fase di esecuzione.
Nel momento in cui inizia la fase di esecuzione di un programma, il relativo task legge
il campo DPL nel descrittore del primo segmento di codice da eseguire e pone
CPL=DPL in CS; da questo momento in poi, il CPL viene usato dal task
per gestire l'accesso ad altri segmenti di codice o dati.
Se il segmento di codice correntemente in esecuzione deve accedere ad un segmento di dati
che ha privilegio DPL(Data), si deve avere numericamente:
DPL(Data) ≥ CPL
Quindi, da un segmento di codice si può accedere ad un segmento dati il cui DPL
deve essere maggiore o uguale al CPL; in sostanza, il segmento dati deve avere
privilegi minori o uguali a quelli del segmento di codice.
La Figura 2.5 mostra l'ACCESS BYTE di un segmento di dati.
In presenza di istruzioni come CALL o JMP intersegmento, il salto è
possibile solo se il segmento origine e il segmento destinazione (entrambi ovviamente
di codice) hanno lo stesso livello di privilegio (CPL della sorgente uguale al
DPL della destinazione); se si ha la necessità di saltare ad una destinazione
che ha privilegi più alti, è necessario ricorrere ad un gate (illustrato nel
seguito). Osserviamo che, in conseguenza di una CALL intersegmento lecita, anche
una successiva istruzione RET (return) è lecita in quanto si tratta di tornare
indietro ad un segmento di codice che ha privilegi uguali a quello di partenza.
Come vedremo più avanti, anche il campo C di Figura 2.4 permette di alterare le
regole appena illustrate per i salti intersegmento; per il momento, supponiamo di avere
a che fare con segmenti di codice "non conforming" (C=0), per i quali valgono le
regole ordinarie.
La CPU esegue tutte le necessarie verifiche a partire dal momento in cui
tentiamo di caricare un selettore nella parte visibile di un registro di segmento; se
l'esito di tali verifiche è positivo, i campi BASE, LIMIT e ACCESS
vengono letti dal relativo descrittore e copiati nella parte invisibile del registro
stesso. La CPU pone inoltre a 1 il campo A di Figura 2.4 e 2.5;
in questo modo, il SO che gestisce la memoria virtuale sa che quel segmento è
attualmente in uso (accessed).
Uno dei controlli effettuati dalla CPU riguarda il tipo e le caratteristiche
dei segmenti di codice, dati o stack associati al selettore che stiamo tentando di
caricare in un registro di segmento; ciò accade, ad esempio, in presenza di un salto
intersegmento che comporta la modifica implicita di CS, di una istruzione che
accede ad un dato tramite ES:DI, di una PUSH che deve inserire una
WORD in SS:SP, etc. La Figura 2.6 illustra i casi consentiti.
Per l'accesso ai segmenti di dati, i relativi descrittori puntati da DS e
ES devono avere DPL maggiore o uguale a CPL; nel caso del
segmento di stack, il descrittore puntato da SS deve avere DPL=CPL.
I segmenti di dati in DS e ES normalmente hanno ED=0 (Figura
2.5); sono consentiti comunque anche i segmenti di dati "expand down". I segmenti
di stack in SS normalmente hanno ED=1; in questo caso, gli offset
legali vanno da FFFFh a LIMIT+1 (in ordine decrescente). Se vogliamo
un normale segmento dati da 64 KiB, dobbiamo porre ED=0 e
LIMIT=FFFFh; se vogliamo un normale segmento di stack da 64 KiB,
dobbiamo porre ED=1 e LIMIT=FFFFh (infatti, in questo caso con
16 bit si ha LIMIT+1=0000h).
Come si vede in Figura 2.6, un segmento di dati/stack deve avere obbligatoriamente
E=0; un segmento di stack deve anche avere W=1. Un segmento di dati
con W=0 è accessibile in sola lettura; è proibito in tal caso modificare i
dati in esso presenti.
Per i segmenti di codice, la modifica implicita di CS è possibile solo se il
DPL del nuovo segmento è uguale al CPL del task in esecuzione; tali
segmenti devono avere obbligatoriamente E=1. Un segmento di codice con
R=1 (readable) può contenere anche dati e risulta accessibile in lettura,
tramite segment override, con DS o ES. Se un segmento di codice ha
R=0, non è accessibile in lettura, neppure tramite segment override con
DS o ES; in genere, si pone R=0 quando si vuole impedire la
possibilità di lettura del codice.
Ogni tentativo di accesso in scrittura ad un segmento di codice, tramite segment
override con DS o ES, provoca una eccezione di protezione (quindi,
eventuali dati presenti nel segmento non sono modificabili); inoltre, è proibito
caricare in SS il selettore di un segmento di codice.
Ogni violazione delle regole appena descritte provoca un'eccezione, che in certi
casi è associata ad un codice di errore; tale codice viene inserito nello stack,
per cui è a disposizione della procedura di servizio chiamata dalla CPU
per gestire l'eccezione stessa.
La Figura 2.7 illustra la struttura del codice di errore, che permette anche di
individuare il segmento in cui si è verificato il problema.
Il campo EXT in posizione 0 vale 0 quando si è verificata
un'eccezione mentre veniva eseguita un'istruzione all'indirizzo CS:IP
(tale indirizzo viene salvato nello stack prima del codice di errore); si ha
invece EXT=1 se l'eccezione è legata al verificarsi di un evento esterno
al programma (ad esempio, una interrupt hardware).
Se il campo IDT in posizione 1 vale 0, l'eccezione è dovuta
ad un problema presente in un segmento elencato in una normale tabella (GDT
o LDT); in tal caso, il campo TI indica il tipo di tabella. Se
IDT=1, allora il riferimento è alla IDT; in tal caso, il campo
TI deve essere ignorato.
Se IDT=0, allora il campo TI in posizione 2 indica il tipo
di tabella; TI=0 indica la GDT, mentre TI=1 indica la
LDT.
Il campo INDEX (bit da 3 a 15) individua il descrittore nella
tabella (GDT, IDT o LDT) selezionata dai campi precedenti.
2.1.4 Livelli di privilegio e accesso ai segmenti di dati con RPL
Un task che sta eseguendo un segmento di codice può imbattersi ad un certo punto in
una istruzione che prevede un accesso in lettura o scrittura ad un altro segmento
che contiene dati; può trattarsi di un segmento di dati referenziato da DS o
ES, di un segmento di stack referenziato da SS o di un segmento di
codice con R=1, contenente quindi dati "read only" a cui si accede tramite
segment override con DS o ES.
Nel caso di accesso allo stack, sappiamo che il DPL del descrittore puntato
dal selettore in SS deve essere uguale al CPL del task in esecuzione;
se invece l'accesso riguarda un segmento di dati vero e proprio, il DPL del
descrittore puntato dal selettore in DS o ES deve essere numericamente
maggiore o uguale al CPL del task in esecuzione. Anche quando si tratta di
leggere dati in un segmento di codice "readable" (R=1) tramite segment
override, il DPL del descrittore puntato dal selettore in DS o
ES deve essere numericamente maggiore o uguale al CPL del task in
esecuzione.
Tutto il meccanismo di protezione appena descritto, in particolari condizioni può
però presentare una seria vulnerabilità!
Supponiamo che un task con CPL=3 stia eseguendo un segmento di codice con
DPL=3; ad un certo punto si arriva ad una istruzione che chiama un servizio
di sistema, il cui scopo è leggere un blocco di dati da un file e salvarlo in un
buffer. Il servizio di sistema si trova in un segmento di codice con DPL=0
ed è rappresentato da una procedura chiamata FILE_READ, che richiede come
parametri l'ID del file, il numero di byte da leggere e un selettore che
punta al descrittore del segmento in cui scrivere dati; come vedremo più avanti,
un programma può usare un call gate per chiamare una procedura che gira ad
un livello di privilegio più alto.
Questa situazione è piuttosto problematica in quanto FILE_READ si aspetta di
ricevere un selettore che punta ad un descrittore di un segmento dati appartenente
allo spazio di indirizzamento virtuale del caller; nessuno però impedisce allo stesso
caller di passare alla procedura un selettore che punta ad un descrittore di un
segmento dati riservato al SO. FILE_READ gira a livello di privilegio
0, per cui è autorizzata a scrivere dappertutto; tale procedura può, ad esempio,
caricare il selettore in ES e usare poi ES:DI per accedere al buffer e
scrivere i dati, con conseguenze che potrebbero essere disastrose!
La tecnica appena descritta viene sfruttata anche dai malware e prende il nome di
Privilege Escalation in quanto il codice malevolo riesce ad ottenere un
livello di privilegio più elevato per compiere poi operazioni illegali.
Per prevenire situazioni del genere, si ricorre al campo Requested Privilege
Level (RPL), che occupa i bit in posizione 0 e 1 di ogni
selettore; tale campo si applica ai registri DS e ES, mentre gli
analoghi bit di CS e SS, di fatto, rappresentano il CPL.
Una procedura che riceve un selettore come argomento, può cautelarsi impostando
il RPL del selettore stesso, ad un valore non minore di quello del CPL
del caller; tale valore può essere recuperato dall'indirizzo di ritorno CS:IP,
inserito nello stack dall'istruzione CALL (ed è contenuto ovviamente nei due
bit meno significativi di CS). In sostanza, il RPL rappresenta il
livello di privilegio di chi ha originato il selettore e non quello di chi lo ha
ricevuto.
A questo punto, se si tenta di caricare il selettore in DS o ES per
accedere ad un segmento dati con privilegio DPL, la CPU si assicura
non solo che:
CPL ≤ DPL
ma anche che:
RPL ≤ DPL
Gli aspetti appena illustrati possono essere riassunti introducendo il concetto di
Effective Privilege Level (EPL), che viene definito in questo modo:
EPL = max(CPL, RPL)
Quindi, l'EPL è il massimo valore tra il CPL e il RPL. Un
selettore che punta ad un descrittore di segmento con privilegio DPL può
essere caricato in DS o ES solo se:
EPL ≤ DPL
Nel caso del precedente esempio, FILE_READ tenta di caricare il selettore
(che ha RPL=0) in ES per accedere ad un segmento dati del SO
con DPL=0; essendo però:
EPL = max(CPL, RPL) = max(3,0) = 3
il test EPL≤DPL fallisce e viene generata una eccezione per violazione della
protezione!
Apparentemente, il campo RPL è inutile in quanto sembra sufficiente il test
CPL≤DPL; come vedremo tra breve, le cose però non stanno così.
La 80286 mette a disposizione una serie di istruzioni per la validazione dei
selettori; un selettore può essere visto come una sorta di puntatore ad un indirizzo
virtuale Seg:Offset in quanto, con i suoi campi INDEX e TI, punta
ad un descrittore che specifica un indirizzo base da sommare ad un offset per ottenere
l'indirizzo a 24 bit a cui accedere.
- ARPL - Adjust RPL Field of Selector
Questa istruzione richiede un operando sorgente di tipo r16 e un operando
destinazione di tipo r16 o m16. L'operando sorgente contiene un
selettore i cui due bit meno significativi rappresentano il RPL del caller;
nel caso generale, in tale operando viene caricato il CS del caller, per cui
il suo RPL non è altro che il CPL del task che ha effettuato la
chiamata. L'operando destinazione contiene il selettore da validare.
Se il RPL della destinazione è minore del RPL della sorgente, il
flag ZF viene posto a 1 e il RPL della sorgente viene copiato
nel RPL della destinazione; in caso contrario si ha ZF=0 e il campo
RPL della destinazione rimane inalterato.
L'istruzione ARPL può essere usata da una procedura per validare un selettore
ricevuto da un caller che gira con meno privilegi; a tale proposito, vediamo un
esempio che dimostra l'utilità del RPL.
Il caller con CPL=DPL=3 passa una variabile Selector ad una procedura
Livello_2 che ha DPL=2; come vedremo più avanti, tale chiamata avviene
tramite un call gate.
All'interno di Livello_2 abbiamo l'indirizzo di ritorno nello stack, con
IP a BP+2 e CS a BP+4, mentre Selector si
trova a BP+6; quindi, carichiamo il CS del caller in AX e
lo confrontiamo con Selector tramite ARPL. CS del caller
ha RPL=CPL=3, per cui ARPL fa in modo che il RPL di
Selector sia non inferiore a 3. In seguito, passiamo Selector
ad una procedura Livello_0 che ha DPL=0.
All'interno di Livello_0 abbiamo l'indirizzo di ritorno (a Livello_2)
nello stack, con IP a BP+2 e CS a BP+4, mentre
Selector si trova a BP+6; quindi, carichiamo il CS del caller
(Livello_2) in AX e lo confrontiamo con Selector tramite
ARPL. CS del caller ha RPL=CPL=2, per cui ARPL non
modifica il RPL=3 di Selector.
L'esempio appena illustrato ci permette di vedere in pratica diversi aspetti esposti
in precedenza. La prima cosa da notare è che il CPL del task in esecuzione
passa da 3 a 2 e da 2 a 0, per poi tornare a 2 e
infine a 3; il CPL quindi varia dinamicamente durante la fase di
esecuzione di un programma.
L'istruzione PUSH con operando Selector, in modalità reale non fa altro
che copiare Selector in SS:SP; in modalità protetta, ciò è possibile
solo se il DPL del segmento di stack è uguale al CPL del task
correntemente in esecuzione.
La variabile Selector si propaga tra due procedure ma, grazie a ARPL,
il suo RPL rispecchia il CPL=3 di chi ha originato il selettore stesso.
Osserviamo ora che in Livello_0 abbiamo CPL del caller (Livello_2)
uguale a 2 e RPL di Selector uguale a 3; supponiamo poi che
Selector punti ad un descrittore con DPL=2. Se tentiamo di caricare
Selector in ES, il test CPL≤DPL dà esito positivo, mentre
RPL≤DPL fallisce; di conseguenza, la CPU rileva una violazione della
protezione e ci impedisce di scrivere in un segmento che ha un livello di privilegio
più alto di chi aveva generato Selector.
- VERR - Verify a Segment for Reading
Questa istruzione richiede un solo operando di tipo r16 o m16, che
rappresenta un selettore; la CPU verifica se il relativo segmento (di dati
o di codice) è leggibile e se ha DPL≥CPL e DPL≥RPL (del
selettore). Se tutte le condizioni sono verificate, la CPU pone ZF=1;
in caso contrario si ha ZF=0.
- VERW - Verify a Segment for Writing
Questa istruzione richiede un solo operando di tipo r16 o m16, che
rappresenta un selettore; la CPU verifica se il relativo segmento (di
dati) è scrivibile e se ha DPL≥CPL e DPL≥RPL (del selettore).
Se tutte le condizioni sono verificate, la CPU pone ZF=1; in caso
contrario si ha ZF=0.
- LAR - Load Access Right Byte
Questa istruzione richiede un operando sorgente di tipo r16 o m16 e
un operando destinazione di tipo r16; l'operando sorgente contiene un
selettore. Se il descrittore associato ha un DPL compatibile con il CPL
(CPL≤DPL) e con il RPL del selettore (RPL≤DPL), allora il
suo ACCESS BYTE viene copiato negli 8 bit più significativi della
destinazione (gli 8 bit meno significativi vengono posti a 0) e si
ha ZF=1; in caso contrario l'ACCESS BYTE non viene copiato e si ha
ZF=0.
Questa istruzione richiede un operando sorgente di tipo r16 o m16 e
un operando destinazione di tipo r16; l'operando sorgente contiene un
selettore. Se il descrittore associato ha un DPL compatibile con il CPL
(CPL≤DPL) e con il RPL del selettore (RPL≤DPL), allora il
suo campo LIMIT viene copiato nell'operando destinazione e si ha ZF=1;
in caso contrario il campo LIMIT non viene copiato e si ha ZF=0.
2.1.5 Livelli di privilegio e trasferimento del controllo con i gates
Nei precedenti tutorial abbiamo visto che per "trasferimento del controllo" si
intende un salto verso un determinato indirizzo posto in un segmento di codice;
tale salto può essere conseguenza di istruzioni come JMP, CALL,
RET, INT, IRET.
In relazione ai livelli di privilegio, si possono verificare i seguenti tre casi:
1) Il salto è intrasegmento (short jump, near call, near return), quindi
senza che si verifichi un cambio del livello di privilegio.
2) Il salto è intersegmento (far jump, far call, far return), tra due
segmenti di codice con lo stesso livello di privilegio.
3) Il salto è intersegmento (far call, far return), tra due segmenti di
codice con differenti livelli di privilegio; in questo caso, il far jump è
proibito.
I primi due casi non creano problemi di protezione, mentre per il terzo caso è
necessario prendere delle precauzioni in quanto non si può saltare ad un segmento
di codice (non conforme) con un livello di privilegio più alto; a tale proposito,
entra in gioco un particolare tipo di descrittore speciale denominato gate.
In generale, si può utilizzare un gate anche per salti che non comportano un cambio
del livello di privilegio; in ogni caso, lo scopo principale del gate è quello di
gestire un salto da un segmento di codice ad un altro con livello di privilegio
più alto.
In modalità protetta, una istruzione per il trasferimento del controllo può chiamare
un gate anziché saltare direttamente ad un altro segmento di codice; a tale proposito,
abbiamo bisogno di una serie di informazioni che comprendono: il selettore che punta
al descrittore del segmento di codice a cui saltare, l'offset all'interno di quel
segmento e, solo per la chiamata di una procedura, il numero di WORD da
trasferire dallo stack del caller a quello della procedura stessa.
Tutte queste informazioni devono essere inserite in un apposito descrittore che
prende il nome di gate descriptor e assume la struttura mostrata in Figura
2.8; come sappiamo, i descrittori speciali sono identificati dal campo S=0.
I 16 bit più significativi del descrittore, come al solito, sono riservati
per usi futuri e devono valere 0; i bit indicati con X non vengono
presi in considerazione (don't care).
Il campo DESTINATION OFFSET è formato da 16 bit e occupa le posizioni
da 0 a 15; si tratta dell'offset dell'istruzione a cui saltare nel
segmento di codice di destinazione.
Il campo DESTINATION SELECTOR è formato da 16 bit e occupa le posizioni
da 16 a 31. Si tratta del selettore che punta al descrittore del
segmento a cui saltare; vengono presi in considerazione solo i 14 bit più
significativi che contengono i soliti campi TI e INDEX.
Il campo WORD COUNT è formato da 5 bit e occupa le posizioni da 32
a 36. Questo campo è significativo solo per i call gates usati per chiamare
una procedura; il suo contenuto rappresenta il numero di WORD (da 0 a
31) da trasferire dallo stack del caller a quello della procedura stessa.
Gli 8 bit che occupano le posizioni da 40 a 47 formano l'ACCESS
BYTE del gate. Sono presenti i campi che già conosciamo, come il bit di presenza
P e il DPL del gate; il campo TYPE indica uno dei seguenti 4
tipi di gate, chiamati control descriptors (Figura 1.16 del Capitolo 1):
- 0100b - Call Gate
- 0101b - Task Gate
- 0110b - Interrupt Gate
- 0111b - Trap Gate
Per il momento ci occupiamo del call gate; gli altri tipi di gates vengono analizzati
più avanti.
In modalità reale, una chiamata NEAR (indiretta intrasegmento) assume il
seguente aspetto:
call Offset
La CPU salva nello stack l'indirizzo di ritorno correntemente contenuto in
IP (indirizzo dell'istruzione immediatamente successiva alla CALL),
carica Offset in IP e salta CS:IP; si può notare che in questo
caso il contenuto di CS rimane invariato.
Analogamente, una chiamata FAR (indiretta intersegmento) assume il seguente
aspetto:
call Seg:Offset
La CPU salva nello stack l'indirizzo di ritorno correntemente contenuto in
CS:IP (indirizzo dell'istruzione immediatamente successiva alla CALL),
carica Seg in CS, Offset in IP e salta a CS:IP.
Come si può notare, in modalità reale il salto con CALL o JMP è del
tutto incondizionato, per cui nessuno ci impedisce di saltare verso un'area riservata
del SO!
In modalità protetta, i salti intrasegmento non comportano una variazione del
livello di privilegio (il selettore in CS non viene modificato), per cui è
possibile usare CALL o JMP per saltare verso un determinato offset;
analogamente, i salti intersegmento che coinvolgono due segmenti di codice allo
stesso livello di privilegio, possono essere effettuati usando CALL o
JMP con operando Seg:Offset, dove Seg rappresenta il selettore
che punta al descrittore del segmento destinazione.
I salti intersegmento con cambio del livello di privilegio non sono consentiti perché
altrimenti sarebbe possibile, ad esempio, saltare direttamente in un'area riservata
del SO; per rispettare le regole di protezione, dobbiamo servirci di un call
gate attraverso l'istruzione CALL, mentre è proibito l'uso di JMP (si
può usare anche JMP con un call gate purché non si verifichi un cambio del
livello di privilegio). Le considerazioni che seguono si riferiscono all'uso di un
call gate per un salto generico, intrasegmento o intersegmento, con o senza
variazione del livello di privilegio; il segmento destinazione si suppone "non
conforme" (C=0).
Attraverso il selettore specificato dall'istruzione di salto la CPU risale al
relativo descrittore e vede che S=0 (descrittore speciale) e TYPE=0100b
(call gate), per cui capisce di avere a che fare con un call gate; di conseguenza,
mediante il campo DESTINATION SELECTOR risale al descrittore che contiene il
Base Address del segmento di codice di destinazione. Il Base Address viene
sommato al campo DESTINATION OFFSET del call gate e si ottiene l'indirizzo a
24 bit a cui saltare. La Figura 2.9 illustra il meccanismo appena descritto nel
caso dell'istruzione CALL.
Naturalmente, la CPU effettua una serie di controlli prima di caricare in
CS:IP l'indirizzo virtuale di destinazione del salto; se l'esito di tali
controlli è positivo, il campo DESTINATION SELECTOR viene caricato in
CS, mentre il campo DESTINATION OFFSET viene caricato in IP.
Per i salti che non comportano una variazione del livello di privilegio (DPL
della destinazione uguale al CPL), la CPU controlla principalmente che
l'indirizzo a cui saltare non ecceda il campo LIMIT del segmento di destinazione;
in questo caso entrambe le istruzioni CALL e JMP sono consentite. Si può
notare che una procedura chiamata con CALL, restituisce il controllo con una
RET (o RETF per i salti intersegmento) che ugualmente non comporta una
variazione del livello di privilegio.
Per i salti intersegmento verso una destinazione con privilegio maggiore, è richiesto
un call gate; in questo caso è permessa l'istruzione CALL, mentre è proibito
l'uso di JMP. La conseguente istruzione RETF non crea problemi in quanto
viene restituito il controllo ad un segmento con privilegio minore.
Per verificare la validità del salto, la CPU si serve del CPL (livello
di privilegio del task correntemente in esecuzione), del RPL presente nel
selettore del call gate, del DPL presente nel descrittore del call gate e del
DPL presente nel descrittore del segmento di destinazione (target); le seguenti
condizioni devono essere soddisfatte:
EPL = max(CPL, RPL) ≤ gate DPL
e
target DPL ≤ CPL
In sostanza, il caller deve essere più privilegiato del gate; inoltre, il segmento di
destinazione deve essere più privilegiato del caller.
In generale, in presenza di una istruzione di salto che utilizza un call gate, la
CPU effettua le verifiche mostrate in Figura 2.10; GP indica un
errore generale di protezione, mentre NP è un errore di non presenza.
2.1.6 Chiamata di una procedura con call gate
Abbiamo visto in precedenza che un task in esecuzione con privilegio CPL può
accedere ad un segmento di stack solamente se:
CPL = stack DPL
Considerando il fatto che il CPL varia dinamicamente durante l'esecuzione di
un programma, la conseguenza importante è che ogni livello di privilegio richiede
un proprio stack in modo che venga garantito il corretto funzionamento dei meccanismi
di protezione; analizziamo allora il metodo usato per la chiamata con call gate di
una procedura che gira con privilegi più alti.
Supponiamo che il task correntemente in esecuzione abbia CPL=3 e che la
procedura da chiamare si trovi in un segmento di codice con DPL=1; tale
procedura richiede tre parametri che possiamo chiamare Param1, Param2
e Param3, tutti a 16 bit. Se il descrittore del call gate si trova
all'indice 4 della GDT, abbiamo INDEX=0000000000100b,
TI=0b e RPL iniziale uguale a 00b; il selettore vale quindi
0020h e la chiamata della procedura assume il seguente aspetto (l'offset
0000h viene ignorato):
In questo esempio viene utilizzata la convenzione STDCALL per il prolog
code e l'epilog code; i parametri vengono passati a partire da quello
più a destra (in stile C), mentre la pulizia dello stack viene lasciata alla
procedura (in stile Pascal).
I tre parametri vengono inseriti nello stack corrente che ha DPL=3 (uguale
al CPL del task); nel descrittore del call gate dobbiamo indicare quindi
WORD COUNT=3 (tre WORD da trasferire nel nuovo stack), oltre
ovviamente al descrittore del segmento di codice a cui saltare e all'offset della
procedura.
Se tutti i requisiti per il salto sono soddisfatti, la CALL fa si che in
CS venga caricato il selettore del segmento di codice a cui saltare e in
IP l'offset della procedura all'interno di quel segmento; il CPL
diventa quindi 1 ed eguaglia il DPL=1 del nuovo stack.
Il valore iniziale della coppia SS:SP del nuovo stack viene letto da un
apposito segmento denominato Task State Segment (TSS), il cui scopo
è anche quello di tenere traccia di tutti gli stack attivi; come vedremo più avanti,
il descrittore del TSS si trova memorizzato in un registro chiamato Task
State Segment Register (TR).
Nel nuovo stack, a partire dal valore iniziale della coppia SS:SP, vengono
memorizzati, nell'ordine, il vecchio SS, il vecchio SP i tre parametri
Param3, Param2, Param1, il vecchio CS e il vecchio
IP; la vecchia coppia CS:IP rappresenta ovviamente l'indirizzo di
ritorno. Si viene a creare quindi la situazione illustrata dalla Figura 2.11.
Le varie coppie SS:SP memorizzate nel TSS sono a sola lettura; è
proibito qualsiasi tentativo di modifica.
Il campo WORD COUNT nel descrittore di un call gate ha un'ampiezza di
5 bit, per cui può contenere un valore compreso tra 0 e 31;
il valore 0 indica che la procedura da chiamare non richiede alcun
parametro. Il valore massimo 31 permette di trasferire altrettante
WORD; se si ha la necessità di trasferire una quantità maggiore di dati,
dalla procedura chiamata si può accedere al vecchio stack tramite la coppia
SS:SP salvata nel nuovo stack.
Il nuovo stack quindi deve avere un'ampiezza sufficiente a contenere il vecchio
SS:SP, l'indirizzo di ritorno e gli eventuali parametri passati dal caller;
ulteriore spazio deve essere reso disponibile se la procedura chiamata ha bisogno
di creare variabili locali. Se lo spazio nel nuovo stack non è sufficiente, viene
generata una eccezione.
2.1.7 Ritorno intersegmento da una procedura
Nell'esempio che stiamo considerando, una istruzione RET non crea problemi
di protezione in quanto il controllo viene restituito al segmento di codice del
caller, che ha un livello di privilegio inferiore o uguale; a tale proposito, la
CPU verifica il RPL contenuto nel vecchio CS salvato nel
nuovo stack. Anche l'indirizzo di ritorno viene verificato dalla CPU per
assicurarsi che non ecceda il campo LIMIT del segmento di codice in cui si
trova il caller.
Se tutte le verifiche vanno a buon fine, la CPU carica in CS:IP
l'indirizzo di ritorno (vecchio CS:IP) e restituisce il controllo al caller.
Nel rispetto della convenzione STDCALL, l'istruzione RET deve indicare
il numero di byte da sottrarre al vecchio SP per la pulizia del vecchio stack;
se, ad esempio, WORD COUNT=4, dobbiamo rimuovere 8 byte dal vecchio
stack con l'istruzione:
retf 8
In questo modo, dopo la restituzione del controllo al caller, il vecchio stack viene
correttamente ripristinato e il suo TOS diventa SS:(SP-8).
Per quanto riguarda il nuovo stack, non è necessario ripristinare SS:SP in
quanto tale azione viene svolta ogni volta che un call gate determina un cambio
del CPL; abbiamo visto infatti che in una situazione del genere, il valore
iniziale del nuovo SS:SP viene letto dal TSS.
Prima di restituire il controllo al caller, la CPU effettua un'ultima
verifica sul contenuto di DS e ES; ciò è necessario in quanto la
procedura chiamata potrebbe aver utilizzato quei registri caricando in essi dei
selettori che puntano a descrittori di segmenti di dati con privilegi più alti.
In un caso del genere, la CPU provvede a caricare il NULL Selector
in DS e ES; come sappiamo, un tentativo di utilizzare DS o
ES contenenti il NULL Selector, provoca un errore generale di
protezione.
In generale, in presenza di una RET intersegmento, la CPU effettua
le verifiche mostrate in Figura 2.12; il tipo di errore viene indicato dalle sigle
GP, NP e SF (errore di stack), mentre N indica il
numero di byte da sottrarre a SP per ripulire lo stack.
2.2 Multitasking e transizioni di stato
Abbiamo visto quindi che la 80286 affida ad un task le varie fasi in cui si
suddivide l'esecuzione di un programma; inoltre, sappiamo che in modalità protetta
possono essere presenti più task associati a differenti programmi, ma in ogni
istante solo uno di essi è in esecuzione.
Il verificarsi di eventi come le interrupt o l'esecuzione di istruzioni come
CALL, JMP e IRET, possono determinare il passaggio da un task
ad un altro; tale passaggio prende il nome di task switch.
Ogni task possiede il proprio spazio di indirizzamento virtuale privato e ciò
permette di gestire il task switch garantendo il totale isolamento tra i vari
task in esecuzione; come è stato illustrato in precedenza, tale isolamento viene
rafforzato anche attraverso diversi accorgimenti, come assegnare ad ogni task un
proprio insieme di stack (uno per ogni livello di privilegio), impedire che un
segmento di codice sia accessibile in scrittura, etc.
In un sistema multitasking, un aspetto di fondamentale importanza è la gestione
delle interrupt hardware; come sappiamo, tali eventi avvengono in modo asincrono e
quindi possono verificarsi nel bel mezzo della fase di esecuzione di un programma.
In modalità reale, in presenza di una interrupt hardware, l'unico task presente
viene interrotto, il controllo passa alla ISR associata e infine il task
stesso viene riavviato. In modalità protetta un tale procedimento potrebbe creare
seri problemi in relazione all'isolamento tra task; proprio per questo motivo, la
gestione di una interrupt può essere affidata ugualmente ad un task (che quindi
appare alla CPU come uno dei tanti task in esecuzione).
2.2.1 Il Task State Segment
Affinché un sistema multitasking funzioni correttamente, è necessario che ad ogni
task vengano associate una serie di informazioni che ne definiscono lo stato
corrente (task state); tali informazioni vengono memorizzate in un apposito
segmento (uno per ogni task) denominato Task State Segment (TSS).
Un TSS ha una dimensione di 22 word ed assume la struttura mostrata
in Figura 2.13.
La WORD all'offset 0 contiene un selettore che punta al TSS
del task appena interrotto da quello attualmente in esecuzione; questa informazione
è importante per una corretta gestione della restituzione del controllo nel caso di
un task switch legato ad una interrupt.
Consideriamo, ad esempio, un task A che viene interrotto da una interrupt
hardware; per gestire l'interrupt viene chiamata un'apposita ISR la quale
rappresenta un altro task B (avviene quindi un task switch). All'offset
0 del TSS di B viene disposto quindi il selettore che punta
al TSS di A; in questo modo, al termine di B il controllo può
essere restituito correttamente ad A.
Il fatto che il back link venga salvato in un TSS è legato anche a
ragioni di sicurezza; un TSS non è accessibile in scrittura dai normali
programmi, per cui eventuale codice malevolo presente in un task non può
alterare il TSS del task precedentemente interrotto.
Le WORD agli offset da 2 a 12 contengono i valori iniziali di
SS:SP per gli stack associati ai livelli di privilegio 0, 1 e
2; la coppia corretta viene selezionata in base al CPL del segmento
di codice a cui saltare.
Come abbiamo visto, quando un task cambia CPL necessita di un nuovo stack
(con lo stesso livello di privilegio) la cui coppia iniziale SS:SP si trova
memorizzata appunto nel TSS del task stesso; la coppia SS:SP del
caller viene salvata all'inizio del nuovo stack (Figura 2.11).
In ogni istante, un solo task è in esecuzione e un solo stack (con uguale CPL)
è attivo.
Le WORD agli offset da 14 a 40 contengono lo stato corrente del
task; tale stato è rappresentato dal contenuto dei registri generali, speciali, di
segmento (selettori) e FLAGS.
Il selettore in CS e l'offset in IP formano l'entry point da cui
riprenderà l'esecuzione del task una volta riottenuto il controllo.
La WORD all'offset 42 contiene un selettore che punta alla LDT
del task.
2.2.2 Il descrittore del Task State Segment
Il TSS è di fatto un segmento dati, per cui richiede un apposito descrittore;
tale descrittore è necessario per la corretta gestione del multitasking e quindi
deve essere disposto nella GDT in modo che risulti visibile globalmente.
La Figura 2.14 illustra la struttura di un descrittore di TSS.
La WORD più significativa è riservata per usi futuri e deve valere 0.
Il campo TSS LIMIT deve valere almeno 43 (002Bh) in modo da poter
contenere un intero TSS; un tentativo di task switch che usa un descrittore il
cui limite è inferiore a 43 provoca una eccezione di task non valido.
Il campo TSS BASE rappresenta il Base Address a 24 bit del
TSS.
Nel campo ACCESS BYTE, il bit S deve valere 0 per indicare un
descrittore speciale.
Il bit P indica se il descrittore contiene informazioni valide P=1 o no
(P=0).
I due bit del DPL vengono usati per un task switch effettuato tramite le
istruzioni JMP e CALL. In analogia a quanto abbiamo visto per i call
gates, anche in questo caso il DPL serve per garantire che il task switch
possa essere effettuato solo da un task che ha i privilegi adeguati; in genere, si
pone DPL=0 quando si vuole che solamente il SO sia abilitato a gestire
questo tipo di operazione.
I quattro bit riservati a TYPE, come risulta dalla Figura 1.16 del Capitolo
1, possono valere 0001b (Available TSS) o 0011b (Busy
TSS); il bit che indica la condizione "available/busy" (o "idle/busy") è quello
in posizione 1 e viene indicato con B. Se B=0 il TSS è
libero (idle) e quindi invocabile, mentre se B=1 il TSS è temporaneamente
occupato (busy) e quindi non invocabile.
2.2.3 Il Task Register
In analogia al selettore che punta al descrittore della LDT del task corrente,
anche il selettore che punta al descrittore del TSS del task corrente viene
memorizzato in un apposito registro chiamato Task Register (TR); la
Figura 2.15 illustra la struttura di tale registro.
Come avviene per il registro LDTR, anche TR ha una parte visibile e una
invisibile. Nella parte visibile viene caricato il selettore che punta al descrittore
del TSS associato al task corrente; a tale proposito, la 80286 mette a
disposizione l'istruzione LTR (Load Task Register), che richiede un
operando sorgente di tipo r16 o m16. In presenza di questa istruzione,
la 80286 accede al descrittore associato, legge i campi BASE e
LIMIT e li salva nella parte invisibile di TR; ciò permette alla
CPU di gestire via hardware le operazioni relative ad un task switch, con
notevoli benefici in termini di velocità di esecuzione.
L'istruzione LTR è di competenza del SO e viene usata per inizializzare
il registro TR quando si entra in modalità protetta; le successive modifiche
di TR vengono gestite in automatico ad ogni task switch.
Se si vuole conoscere il contenuto di TR (parte visibile), si può ricorrere
all'apposita istruzione STR (Store Task Register), che richiede un
operando destinazione di tipo r16 o m16.
2.2.4 Il Task Gate
In relazione alla chiamata di una procedura abbiamo visto che, se il livello di
privilegio rimane invariato, possiamo utilizzare un metodo diretto attraverso una
istruzione del tipo:
call Selector:Offset
In questo caso, Selector punta al descrittore del segmento di codice a cui
saltare, mentre Offset contiene l'offset della procedura all'interno di
quel segmento.
Se la destinazione del salto si trova in un segmento di codice più privilegiato,
è necessario ricorrere ad un metodo indiretto tramite l'uso di un call gate; in
questo caso, Selector punta al descrittore del call gate stesso, mentre
Offset viene ignorato (in quanto presente nel call gate).
In modo del tutto analogo, un task switch può essere effettuato direttamente (dal
SO) con l'istruzione precedente; il campo Selector punta al descrittore
del TSS, mentre il campo Offset viene ignorato (in quanto la posizione
di un TSS in memoria è individuata dal solo Base Address presente
nel relativo descrittore).
Per permettere ad un programma diverso dal SO di effettuare un task switch, è
necessario invece ricorrere ad un metodo indiretto che prevede l'uso di un task
gate; si tratta quindi di un procedimento del tutto simile a quello visto per
i call gates. Nell'istruzione precedente, Selector punta al task gate, mentre
Offset viene ignorato; il task gate a sua volta contiene un selettore che
punta al descrittore del TSS desiderato.
La Figura 2.16 illustra la struttura di un Task Gate descriptor.
I 16 bit più significativi del descrittore sono riservati per usi futuri e
devono valere 0; i bit indicati con X non vengono utilizzati.
Il campo TSS SELECTOR punta al descrittore del TSS associato al task
a cui passare il controllo; il RPL di questo selettore viene ignorato.
Nel campo ACCESS BYTE, il bit S deve valere 0 per indicare un
descrittore speciale.
Il bit P indica se il descrittore contiene informazioni valide P=1 o no
(P=0).
I due bit del DPL vengono usati per stabilire chi è autorizzato ad effettuare
un task switch; si tratta delle stesse regole che si applicano per l'accesso ad un
segmento di dati. Considerando ad esempio l'istruzione vista in precedenza, innanzi
tutto viene calcolato l'EPL come massimo tra il RPL di Selector
e il CPL del segmento di codice che contiene la CALL; il task switch è
autorizzato solo se, numericamente, EPL≤DPL del descrittore del task gate.
I quattro bit riservati a TYPE, come risulta dalla descrizione della Figura
2.8, devono valere 0101b (Task Gate).
2.2.5 Regole per il Task Switch
Un task switch può avvenire in modo diretto attraverso le istruzioni CALL e
JMP di tipo FAR, che specificano come operando una coppia
Selector:Offset, dove Selector punta al descrittore di un TSS;
la componente Offset viene ignorata.
Un task switch può avvenire in modo indiretto attraverso le istruzioni CALL e
JMP di tipo FAR, che specificano come operando una coppia
Selector:Offset, dove Selector punta al descrittore di un Task
Gate; la componente Offset viene ignorata.
Un task switch può avvenire a causa del verificarsi di una interruzione hardware il
cui vettore punta al descrittore di un Task Gate presente nella IDT;
lo stesso gate contiene il selettore che punta al descrittore del TSS del
task a cui saltare.
Un task switch può avvenire al termine della gestione di una interruzione hardware,
quando viene incontrata l'istruzione IRET e il flag NT (Nested
Task) del registro FLAGS vale 1 per indicare che il task corrente
è innestato in un altro task (vedere più avanti); il selettore del TSS del
task a cui restituire il controllo si trova nel campo back link del TSS
corrente.
La Figura 2.17 illustra la struttura del registro FLAGS della 80286;
i bit colorati in grigio sono riservati per usi futuri e, per motivi di
compatibilità con le altre CPU della famiglia 80x86, non devono essere
modificati.
Come si può notare, oltre ai flags che già conosciamo, sono presenti i due nuovi
campi NT e IOPL; per il momento ci interessa il campo NT in
quanto viene usato per la gestione di un task switch.
La Figura 2.18 illustra la struttura del Machine Status Word (MSW)
della 80286; questo registro indica in ogni istante la configurazione e lo
stato della CPU. In questo caso ci interessa il campo TS che viene
posto a 1 quando si è verificato un task switch.
Prima di effettuare un task switch, la 80286 verifica che tutti i requisiti
siano soddisfatti. Se la richiesta arriva dalle istruzioni CALL, JMP
o INT, come abbiamo visto, il DPL presente nel descrittore del task
gate o del TSS deve essere numericamente maggiore o uguale all'EPL.
Questa verifica non è necessaria se la richiesta arriva da una IRET.
Un ulteriore requisito è che nell'ACCESS BYTE del descrittore del TSS
si abbia P=1 ("present") e B=0 ("not busy").
Se tutte le verifiche danno esito positivo, si procede con il task switch. Innanzi
tutto, nel descrittore del nuovo TSS si pone B=1.
Lo stato del task corrente viene salvato nel relativo TSS e i campi BASE
e LIMIT del nuovo TSS vengono caricati nel TR; lo stato del nuovo
task viene caricato dal nuovo TSS, ad eccezione di DS, ES,
CS, SS e LDT in quanto sono richieste ulteriori verifiche.
In caso di task innestati (come la chiamata di una ISR tramite task gate
nell'IDT), nel registro FLAGS si pone NT=1 ("nested task").
Nel registro MSW si pone anche TS=1 ("task switched") per indicare
che se il nuovo task vuole usare il coprocessore matematico 80287, la
CPU deve generare un'apposita eccezione; in questo modo, il gestore
dell'eccezione può decidere cosa fare.
Il selettore della nuova LDT viene validato e se non ci sono eccezioni si
caricano i campi BASE e LIMIT nel LDTR.
I nuovi selettori per DS, ES, CS e SS vengono validati
e se non ci sono eccezioni i campi BASE, LIMIT e ACCESS vengono
caricati nei rispettivi registri di segmento.
Infine viene validato il nuovo offset da caricare in IP; se tutti i requisiti
sono soddisfatti, l'esecuzione del nuovo task viene avviata a partire dal nuovo
CS:IP ("entry point").
Appare evidente che durante le verifiche appena elencate, possono presentarsi
situazioni di errore che portano la CPU a generare apposite eccezioni; la
Figura 2.19 illustra tutti i dettagli.
Quando un task termina il proprio lavoro, in genere restituisce il controllo al
task interrotto in precedenza; a tale proposito, deve servirsi del back link
presente nel proprio TSS. Come sappiamo, il back link contiene il selettore
che punta al vecchio TSS; tale informazione viene salvata in automatico nel
nuovo TSS ogni volta che si verifica un task switch.
Nel caso particolare delle interruzioni hardware, abbiamo visto che in modalità
protetta è molto importante (anche se non obbligatorio) che una ISR venga
trattata come un qualunque task in modo da garantire l'isolamento tra i vari
processi in esecuzione; a tale proposito, ad ogni task switch la 80286 pone
in automatico NT=1 nel registro FLAGS. Al termine di una ISR,
una istruzione IRET che trova NT=1 provoca un task switch (tramite
il back link del proprio TSS) per restituire il controllo al programma che
aveva interrotto; se NT=0, la IRET viene invece eseguita secondo il
normale procedimento tipico della modalità reale.
Nella gestione di un task switch, anche il "busy bit" B nel descrittore
dei TSS coinvolti, viene modificato in automatico, come accade per il campo
NT di FLAGS; se B e NT non risultano impostati in modo
corretto, viene generata una eccezione generale di protezione.
Nel caso di un task switch provocato da JMP, si ha B=0 per il vecchio
task, mentre per il nuovo task B deve passare da 0 a 1; si ha
poi NT=0 per il nuovo task, mentre per il vecchio rimane invariato. Il back
link per il nuovo e vecchio task rimane invariato.
Nel caso di un task switch provocato da CALL/INT, si ha B invariato
(deve valere già 1) per il vecchio task, mentre per il nuovo task B
deve passare da 0 a 1; si ha
poi NT=1 per il nuovo task, mentre per il vecchio rimane invariato. Il back
link per il nuovo task punta al selettore del vecchio TSS, mentre per il
vecchio task rimane invariato.
Nel caso di un task switch provocato da IRET, si ha B=0 per il vecchio
task, mentre per il nuovo task B rimane invariato (deve valere già 1);
si ha poi NT invariato per il nuovo task, mentre per il vecchio si pone
NT=0. Il back link per il nuovo e vecchio task rimane invariato.
In conclusione di questa parte relativa alle transizioni di stato, vediamo in Figura
2.20 un esempio che illustra un task switch mediante una CALL il cui operando
è un selettore che punta ad un task gate.
Come si può notare, il selettore specificato dalla CALL punta al descrittore
di un task gate che si trova nella LDT del task A; il task gate, a sua
volta, punta al descrittore del TSS del task B (ricordiamo che i
descrittori dei TSS e delle LDT devono trovarsi nella GDT).
Nel TSS del task B, il LDT Selector punta al descrittore della
LDT di B, mentre il selettore back link punta al descrittore
del TSS di A.
Notiamo poi al centro i descrittori delle LDT di A e B che
puntano alle rispettive LDT.
2.3 Interruzioni ed eccezioni
Le interruzioni si suddividono in due grandi categorie:
- interruzioni hardware
- interruzioni software
Le interruzioni hardware vengono generate in conseguenza di determinati eventi
indipendenti dalla fase di esecuzione di un programma, come la pressione di un
tasto sulla tastiera o il movimento del mouse; si tratta quindi di eventi di
tipo asincrono in quanto possono verificarsi in qualunque momento, anche mentre
la CPU è impegnata ad elaborare un'istruzione. Le interruzioni hardware
vengono anche definite "esterne" in quanto generate dalle varie periferiche di un
PC e inviate alla CPU attraverso i pin INTR e NMI.
Le interruzioni software sono invece strettamente legate alla fase di esecuzione
di un programma in quanto vengono generate in modo sincrono attraverso istruzioni
come INT; per questo motivo vengono anche definite "interruzioni interne".
Un tipo molto importante di interruzione software è quello delle eccezioni;
le eccezioni vengono generate in modo sincrono dalla CPU quando, ad esempio,
l'esecuzione di una istruzione non può essere portata a termine a causa della
violazione dei meccanismi di protezione.
Nonostante le differenze appena elencate, le interruzioni e le eccezioni vengono
gestite allo stesso modo in modalità protetta; per questo motivo, spesso i due
termini vengono usati in modo interscambiabile. Nel seguito viene usato il termine
"interrupt" o "interruzione" per indicare indifferentemente una interruzione o una
eccezione.
2.3.1 La Interrupt Descriptor Table (IDT)
Nei precedenti tutorial abbiamo visto che, per una convenzione adottata nel mondo
dei PC, i primi 1024 byte della RAM sono riservati alla
"tabella dei vettori di interruzione" o Interrupt Vectors Table (IVT);
la IVT contiene sino a 256 coppie Seg:Offset da 16+16
bit, chiamate "vettori di interruzione" o Interrupt Vectors. Ogni coppia è
un indirizzo FAR che punta ad una apposita procedura di servizio denominata
Interrupt Service Routine (ISR); ogni ISR è associata quindi ad
una differente "richiesta di interruzione" o Interrupt Request (IRQ).
In modalità reale, ogni volta che arriva una IRQ, l'unico task in esecuzione
viene interrotto e il controllo passa alla ISR associata; al termine della
ISR, l'istruzione IRET restituisce il controllo al task stesso che
può così essere riavviato.
In modalità protetta, le interruzioni vengono gestite formalmente allo stesso modo;
la differenza sostanziale sta nel fatto che le varie ISR devono attenersi
rigorosamente alle regole di protezione illustrate in questo capitolo. Proprio per
questo motivo, non è possibile in generale usare le ISR della modalità reale
in modalità protetta in quanto si andrebbe incontro a gravi violazioni dei meccanismi
di protezione; è necessario quindi seguire un metodo che permetta di ovviare a questo
problema.
Per gestire le interruzioni in modalità protetta, viene definita una tabella chiamata
Interrupt Descriptor Table (IDT); come si intuisce dal nome, tale
tabella è destinata a contenere un elenco di descrittori, ciascuno dei quali è
associato ad una differente interruzione.
La IDT, come la GDT, è unica e condivisa e può essere posizionata
in qualunque area della RAM; per indicare i suoi campi BASE e LIMIT
alla CPU, è disponibile l'Interrupt Descriptor Table Register
(IDTR) illustrato in Figura 2.2. L'IDTR viene caricato in fase di avvio
della modalità protetta, tramite l'apposita istruzione Load Interrupt Descriptor
Table Register (LIDT); come è stato già spiegato all'inizio del capitolo,
nel caso generale l'IDTR e il GDTR, una volta inizializzati, non vengono
più modificati.
Ogni elemento della IDT è un descrittore da 8 byte appartenente alla
categoria dei gates; sono consentiti solo l'Interrupt Gate, il Trap
Gate e il Task Gate. Gli interrupt gates e i trap gates permettono di
elaborare una interruzione senza uscire dal contesto del task corrente (quindi, la
ISR usa le stesse risorse, come stack, LDT e TSS, del task
corrente); il task gate, come abbiamo visto, fa si che l'interruzione venga gestita
come un task differente e quindi provoca un task switch.
La IDT può contenere sino a 256 descrittori da 8 byte ciascuno,
per cui la sua dimensione massima è di 256*8=1848 byte; in questo caso il
campo LIMIT deve valere almeno 1847.
Le varie interruzioni sono indicizzate da 0 a 255 e gli indici da
0 a 31, per convenzione, sono riservati ad altrettante interruzioni
standard (descritte più avanti); di conseguenza, il valore minimo di LIMIT
deve essere (32*8)-1=255 byte. Le restanti 224 interruzioni sono
disponibili per i programmi.
Un tentativo di accedere ad un descrittore fuori limite o di tipo non consentito,
provoca una eccezione di protezione generale, con l'inserimento nello stack di un
codice di errore la cui struttura è stata già illustrata in Figura 2.7; come abbiamo
visto, si ha EXT che può valere 0 (eccezione causata dall'istruzione
in CS:IP) o 1 (evento esterno), IDT=1 (interrupt table), mentre
il campo INDEX contiene l'indice del vettore "incriminato".
2.3.2 Interruzioni hardware
Le richieste di interruzione che arrivano dalle periferiche, vengono raccolte dal
chip 8259 Programmable Interrupt Controller (PIC) e passate alla
CPU in ordine di priorità tramite in pin INTR; in tal caso si parla
di hardware interrupts. Ogni IRQ che arriva al PIC è associata
ad un preciso vettore di interruzione N, compreso tra 0 e 255;
in questo modo, quando la IRQ stessa arriva al pin INTR, la CPU
sa che la ISR da chiamare è individuata dal descrittore in posizione N
nella IDT.
Le interruzioni hardware sono dette "mascherabili" in quanto possono essere inibite
tramite il flag IF del registro FLAGS (Figura 2.17); IF=0
disabilita l'elaborazione delle interruzioni mascherabili, mentre IF=1 la
abilita (a tale proposito, come vedremo più avanti, un task con sufficienti privilegi
può usare le istruzioni CLI e STI).
Una ISR può essere a sua volta interrotta dall'arrivo di un'altra interruzione
mascherabile; per evitare una situazione del genere, la ISR stessa può porre
temporaneamente IF=0. In modalità reale, questo compito viene svolto dalla
CPU; in modalità protetta, come vedremo più avanti, tutto dipende dal contesto.
Un tipo particolare di interruzione hardware è la Non Maskable Interrupt
(NMI), che viene inviata alla CPU tramite il pin NMI quando sul
PC si è verificato qualche grave problema a livello hardware; come dice il nome
stesso, per ovvi motivi la NMI non è mascherabile e non può essere interrotta
neppure da un'altra NMI (la seconda NMI viene elaborata dopo l'istruzione
IRET della prima).
La NMI è sempre associata al vettore n. 2 della IDT e quindi fa
parte del gruppo di vettori riservati, con indici da 0 a 31.
2.3.3 Interruzioni software
Le interruzioni software sono così chiamate in quanto vengono imposte da eventi
legati al programma in esecuzione, anche se IF=0; di conseguenza, si tratta
di interruzioni non mascherabili.
Una interruzione software può essere invocata direttamente tramite l'istruzione
INT seguita dal numero del vettore; il caso speciale INT 3
(breakpoint) chiama il vettore riservato n. 3 (vedere il Capitolo 20
del tutorial Assembly Base).
Altre istruzioni che possono provocare interruzioni software sono: INTO
(in caso di overflow), BOUND (se l'indice di un vettore è fuori limite),
DIV e IDIV (in caso di divisione per zero); le interruzioni legate
a queste istruzioni hanno vettori predefiniti, appartenenti al gruppo dei 32
riservati.
Una classe importantissima di interruzioni software è quella delle eccezioni;
il loro scopo, come sappiamo, è quello di gestire situazioni di errore legate a
violazioni delle regole di protezione della CPU.
Le eccezioni sono tutte non mascherabili e risultano associate al gruppo dei
32 vettori riservati; a differenza delle interruzioni vere e proprie, alcune
eccezioni provvedono anche ad inserire nello stack appositi codici di errore (Figura
2.7).
2.3.4 Interrupt Gates e Trap Gates
L'elaborazione di una interruzione, come abbiamo visto, viene gestita tramite
appositi descrittori di gates presenti nella IDT; esaminiamo innanzi tutto
gli interrupt gates e i trap gates.
La differenza tra questi due tipi di gate sta nel fatto che, mentre un interrupt
gate chiama una ISR ponendo IF=0, il trap gate lascia invece IF
invariato.
Entrambi i tipi di gate inoltre chiamano la ISR ponendo NT=0; ciò
significa che l'interruzione viene gestita senza uscire dal contesto del task
corrente (NT=0 indica infatti che non c'è alcun task innestato e quindi non
si verifica un task switch).
La Figura 2.21 illustra la struttura di un interrupt/trap gate; trattandosi di un
descrittore speciale, si ha sempre S=0.
I 16 bit più significativi del descrittore sono riservati per usi futuri e
devono valere 0; i bit indicati con X non vengono utilizzati.
Il campo INTERRUPT CODE SEGMENT SELECTOR contiene un selettore che punta al
descrittore del segmento di codice dove è presente la ISR.
Il campo INTERRUPT CODE OFFSET specifica l'offset (entry point) della
ISR all'interno del segmento di codice in cui è definita.
Nel campo ACCESS BYTE, il bit S deve valere 0 per indicare un
descrittore speciale.
Il bit P indica se il descrittore contiene informazioni valide (P=1)
o no (P=0).
I due bit del DPL hanno lo stesso significato che abbiamo visto per i call
gates; infatti, anche gli interrupt gates e i trap gates permettono di saltare ad un
altro segmento di codice con identico o maggiore livello di privilegio (e con
C=0).
I quattro bit riservati a TYPE, come risulta dalla descrizione della Figura
2.8, devono valere 0110b per gli interrupt gates e 0111b per i trap
gates.
Per indicare che un elemento della IDT è vuoto, bisogna inserire il valore
00000000b nell'ACCESS BYTE.
In sostanza, la gestione di una interruzione con un interrupt gate o un trap gate, è
del tutto simile ad una CALL indiretta, effettuata attraverso un call gate; al
verificarsi dell'interruzione associata al vettore N, la CPU accede
al gate di indice N nella IDT e ricava l'offset della ISR e,
attraverso il selettore, il descrittore del segmento di codice dove è presente la
ISR stessa. Si viene a creare quindi la situazione illustrata in Figura 2.22.
Formalmente, le operazioni svolte dalla CPU sono le stesse della modalità
reale. Nello stack vengono salvati i valori correnti di FLAGS e CS:IP
(indirizzo di ritorno) e poi viene chiamata la ISR; terminata l'elaborazione
dell'interruzione, l'istruzione IRET restituisce il controllo al task che
era stato interrotto.
Se la chiamata della ISR comporta un cambio di privilegio, un nuovo stack
viene attivato secondo il meccanismo illustrato in Figura 2.11.
Nel caso di una eccezione, anche l'eventuale codice di errore viene inserito nello
stack, subito dopo il vecchio CS:IP; la Figura 2.23 illustra la situazione
che si viene a creare per la gestione di una eccezione, con privilegi invariati o
passaggio ad un privilegio maggiore.
Una ISR, prima di terminare, deve anche provvedere ad eliminare dallo
stack un eventuale codice di errore; questo perché tale compito non viene eseguito
dall'istruzione IRET.
Abbiamo visto che un interrupt gate chiama una ISR con IF=0, dopo
aver salvato FLAGS (con IF=1) nello stack con PUSHF; la stessa
ISR, prima di restituire il controllo, può anche riportare IF a
1 (quando IRET chiama POPF), purché però abbia sufficienti
privilegi per farlo.
In generale, questo aspetto vale per tutti i segmenti di codice e riguarda il fatto
che determinate istruzioni (comprese CLI e STI), per motivi di
sicurezza, possono essere eseguite solo quando il CPL del task corrente ha un
valore adeguato; a tale proposito, entra in gioco il campo I/O Privilege Level
(IOPL) del registro FLAGS (Figura 2.17).
Le istruzioni IN, INS, OUT, OUTS (varianti comprese),
CLI e STI, possono essere eseguite da un task solo se:
CPL ≤ IOPL
Come si vede in Figura 2.13, il TSS di un task memorizza anche il registro
FLAGS e ciò significa che differenti task possono avere differenti valori di
IOPL; in questo modo è possibile stabilire quali task sono autorizzati o
meno ad eseguire le precedenti istruzioni.
Se, ad esempio, vogliamo che solo il software di sistema possa eseguire le istruzioni
sopra elencate, possiamo assegnare ad ogni task IOPL=0; di conseguenza, solo i
task con CPL=0 sono autorizzati a modificare il flag IF o ad accedere
in I/O alle porte hardware.
La Figura 2.24 illustra le verifiche effettuate dalla CPU prima di chiamare
una ISR tramite interrupt gate o trap gate; il bit EXT, come sappiamo,
può valere 0 o 1.
Osserviamo che, al termine di una ISR, l'istruzione IRET restituisce
il controllo al task precedentemente interrotto, che ha privilegi uguali o minori
rispetto al segmento di codice che contiene la ISR stessa; a tale proposito,
la CPU confronta il CPL della ISR con il CPL del segmento
di codice a cui tornare, che è il RPL del vecchio CS salvato nello stack.
Le verifiche effettuate dalla CPU prima di eseguire una IRET sono le
stesse di Figura 2.12 per una RET intersegmento.
In genere, gli interrupt gates vengono usati per interruzioni hardware ad alta
priorità in quanto la ISR viene chiamata con IF=0 per bloccare altre
interruzioni mascherabili; i trap gates, invece, sono preferibili per le interruzioni
software in quanto la ISR viene chiamata con IF inalterato.
La Figura 2.25 illustra come il flag IF e il tipo di interruzione interagiscono
tra loro quando si usa un interrupt/trap gate.
2.3.5 Gestione delle interruzioni con i Task Gates
Una interruzione può essere gestita anche con un task gate; in tal caso, come abbiamo
visto, la ISR da chiamare viene trattata come un nuovo task innestato nel primo
(NT=1). Si verifica quindi un task switch con tutti i vantaggi che abbiamo già
analizzato in precedenza; in particolare, il nuovo task è totalmente isolato dagli
altri e lo stato completo del task che è stato interrotto viene salvato nel relativo
TSS.
Lo svantaggio evidente dei task gates è che risultano nettamente più lenti degli
interrupt gates e dei trap gates; come vedremo però più avanti, in certi casi l'uso
dei task gates è una scelta obbligata.
Se si sta elaborando una eccezione che produce un codice di errore; tale codice viene
inserito nello stack del nuovo task (cioè, della ISR).
Siccome la ISR viene chiamata con NT=1, la conseguente istruzione
IRET provoca a sua volta un task switch per la restituzione del controllo al
task che era stato interrotto; inoltre, lo stato della ISR viene salvato nel
relativo TSS e il flag NT viene riportato al valore che aveva in
precedenza.
Osserviamo che la ISR chiamata da un interrupt gate o da un trap gate, viene
eseguita sempre a partire dalla sua prima istruzione. Nel caso di un task gate, la
ISR chiamata è un task vero e proprio che può essere interrotto da un altro
task; in tal caso, lo stato della stessa ISR viene salvato nel relativo
TSS e la sua esecuzione viene ripristinata a partire dal punto in cui era
stata interrotta.
Bisogna anche ricordare che in caso di task switch, entra in gioco il busy bit nel
descrittore dei TSS coinvolti; abbiamo visto che se il task switch è provocato
da CALL o INT, si ha B=1 (busy) per il vecchio e nuovo TSS.
Se si sta usando un task gate per gestire una interruzione mascherabile, è consigliabile
allora porre IF=0 per evitare che chiamate concatenate di altre interruzioni
possano incappare in qualche task con TSS "busy" (B=1); se ciò accade,
viene generata una eccezione generale di protezione. Grazie al busy bit quindi,
vengono evitati pericolosi loop come, ad esempio, A che chiama B, che
a sua volta chiama nuovamente A.
La gestione di una interruzione con un task gate porta allo schema che abbiamo visto
in Figura 2.20; per una INT N, ad esempio, all'indice N della
IDT viene trovato un task gate il cui campo SELECTOR punta (nella
GDT) al descrittore del TSS del nuovo task. In sostanza, in Figura 2.20
il task indicato con B rappresenta la ISR da chiamare per elaborare
l'interruzione; il back link del TSS della ISR punta al TSS del
task A che viene interrotto.
2.3.6 Interruzioni e scheduling
In inglese il termine scheduling significa "pianificazione". In un SO
multitasking, lo "scheduler" è quella parte che si occupa di eseguire a rotazione i
vari task attraverso una continua sequenza di task switch; si tratta chiaramente di
un compito molto complesso in quanto proprio lo scheduler deve provvedere, tra l'altro,
a impostare il back link nel TSS di ogni nuovo task, a settare correttamente il
busy bit nel descrittore di ogni TSS, etc.
Può capitare che il task correntemente in esecuzione venga interrotto da una interrupt
gestita tramite un task gate; lo scheduler deve essere allora in grado di gestire
anche situazioni del genere, provvedendo a reimpostare i vari back link, busy bit, etc.
Se lo scheduler va a controllare il Task Register e si accorge che il selettore
presente non punta più al TSS dell'ultimo task che era stato avviato, capisce
che si è verificata una interruzione tramite un task gate; in tal caso, per garantire
il corretto funzionamento dell'istruzione IRET, il back link del TSS del
gestore dell'interruzione deve essere impostato dallo stesso scheduler in modo che
punti al task appena interrotto.
2.3.7 Vettori riservati per le eccezioni
Nel Capitolo 3 del tutorial Assembly Avanzato, le Figure 3.10 e 3.11 elencano
le principali interruzioni hardware così come giungono ai due PIC collegati
in cascata; in questo capitolo ci occupiamo invece delle interruzioni software,
comprese le eccezioni, che risultano tutte associate a vettori riservati. Per maggiori
dettagli si consiglia di consultare i manuali elencati nella bibliografia.
Le eccezioni possono essere errori veri e propri, dovuti a violazioni dei meccanismi
di protezione, come scrivere in un segmento a sola lettura, accedere ad un dato che
si trova oltre il campo LIMIT del segmento di appartenenza, etc. Certi tipi di
eccezione rappresentano invece delle richieste di determinati servizi; un SO
che sfrutta la memoria virtuale, ad esempio, può intercettare una eccezione di segmento
non presente per caricare il segmento stesso dal disco e ripristinare l'esecuzione del
task che ha generato il problema.
I vettori di interruzione da 0 a 31, per convenzione, sono riservati; i
primi 14 di essi sono sempre presenti e hanno le caratteristiche illustrate in
Figura 2.26.
Come si può notare, alcune eccezioni provvedono anche ad inserire nello stack un
apposito codice di errore la cui struttura è mostrata in Figura 2.7.
Il termine "riavviabile" indica se il task che ha provocato il problema può essere
ripristinato (riavviato).
Nel caso generale, l'indirizzo di ritorno CS:IP salvato nello stack punta
all'istruzione che ha causato l'eccezione.
Una situazione che si presenta spesso consiste nell'arrivo simultaneo di diverse
interruzioni hardware e software; in casi del genere, tali interruzioni vengono
gestite secondo l'ordine di priorità illustrato in Figura 2.27.
- Vettore 0: Divide Error Exception
Questa eccezione si verifica quando si usano le istruzioni DIV e IDIV
con denominatore 0 o, più in generale, quando tali due istruzioni producono
un risultato troppo grande per essere contenuto nell'operando destinazione.
La coppia CS:IP salvata nello stack punta all'istruzione DIV o
IDIV che ha prodotto l'eccezione; la ISR associata deve provvedere
a gestire anche tale situazione per evitare di innescare un loop infinito. Le
stesse considerazioni valgono per tutte le eccezioni legate a problemi critici.
- Vettore 1: Single Step Interrupt
Ponendo TF=1 nel registro FLAGS (Figura 2.17) si sta chiedendo alla
CPU di generare una INT 1 dopo ogni istruzione eseguita; questa
interruzione viene largamente usata dai debuggers per eseguire i programmi in
modalità "passo passo" (single step).
Il registro FLAGS e l'indirizzo di ritorno vengono salvati nello stack, il
bit TF viene riportato a 0 e la ISR associata al vettore n.
1 nella IDT viene chiamata. TF=0 assicura che le istruzioni
della ISR non vengano sottoposte ad esecuzione passo passo, cosa che
provocherebbe una chiamata ricorsiva alla stessa ISR. Al termine della
ISR, il bit TF viene riportato a 1.
La coppia CS:IP salvata nello stack punta all'istruzione successiva.
- Vettore 2: Non Maskable Interrupt (NMI)
Questa interrupt viene generata quando sul PC si è verificato qualche grave
problema hardware, come un errore di parità in memoria, un livello insufficiente
della tensione elettrica nei circuiti, etc; data la delicatezza della situazione,
la NMI viene inviata direttamente alla CPU tramite l'omonimo pin.
- Vettore 3: Breakpoint Interrupt
L'istruzione INT 3 viene utilizzata dai debuggers per inserire dei punti
di interruzione (breakpoints) nei programmi; la ISR chiamata può così
permettere di esaminare la situazione in quei punti, attraverso l'analisi del
contenuto dei vari registri della CPU.
Per rendere possibile questo tipo di utilizzo, la INT 3 ha un opcode
(CCh) formato da un solo byte, che può essere quindi posizionato
all'inizio di qualsiasi istruzione, senza il rischio di sovrascrivere
l'istruzione successiva. Il debugger salva l'opcode dell'istruzione in cui
posizionare il breakpoint e sostituisce il suo primo byte con CCh;
quando il breakpoint viene rimosso, l'opcode dell'istruzione sovrascritta
viene ripristinato.
La coppia CS:IP salvata nello stack punta all'istruzione immediatamente
successiva alla INT 3.
- Vettore 4: INTO Detected Overflow Exception
Nel caso generale, un overflow prodotto da una determinata operazione viene
segnalato dalla CPU ponendo a 1 il bit OF del registro
FLAGS (Figura 2.17); se si vuole gestire questa situazione tramite una
ISR, si può eseguire l'istruzione INTO immediatamente dopo
l'operazione stessa. Se INTO trova OF=1, provvede a generare
una INT 4.
La coppia CS:IP salvata nello stack punta all'istruzione immediatamente
successiva alla INTO.
- Vettore 5: BOUND Range Exceeded Exception
L'istruzione BOUND richiede un operando di tipo Reg16 e uno di
tipo Mem16:Mem16; il primo operando contiene l'offset dell'elemento di
un vettore a cui vogliamo accedere, mentre il secondo contiene l'offset del
primo elemento e quello dell'ultimo. Se l'offset in Reg16 non rientra
nei limiti indicati, viene generata una INT 5.
La coppia CS:IP salvata nello stack punta alla stessa istruzione
BOUND che ha generato l'eccezione; ciò è necessario per scongiurare un
accesso ad un'area che non appartiene al vettore.
- Vettore 6: Invalid Opcode Exception
In presenza di una istruzione associata ad un opcode non valido, la CPU
genera una INT 6; ciò accade nel momento in cui l'istruzione stessa sta
per essere eseguita e non quando viene caricata nella coda di prefetch.
La coppia CS:IP salvata nello stack punta all'istruzione associata
all'opcode non valido.
- Vettore 7: Processor Extension Not Available Exception
Questa eccezione viene generata quando si tenta di eseguire un'istruzione della
FPU su un PC che però non è dotato di coprocessore matematico; in
tal caso si ha EM=1 nel registro MSW (Figura 2.18), per indicare
che il coprocessore deve essere emulato via software. Un programma che intercetta
questa eccezione, può chiamare l'opportuna procedura che simula via software la
funzione matematica richiesta.
La coppia CS:IP salvata nello stack punta all'istruzione che ha generato
l'eccezione.
- Vettore 8: Double Exception Detected
Può capitare che, mentre la CPU sta elaborando un'eccezione, ne arrivi una
seconda; normalmente, in casi del genere la seconda eccezione viene elaborata
dopo la prima, ma ciò non sempre è possibile. Consideriamo, ad esempio, una
eccezione di segmento non presente (INT 11); se la ISR per gestire
tale eccezione si trova a sua volta in un segmento non presente, si verifica una
condizione di Double Exception e nessuna delle due eccezioni può essere
elaborata.
La Double Exception si verifica sempre quando una seconda eccezione arriva
mentre la CPU sta elaborando una precedente eccezione associata ad uno dei
vettori 0, 10, 11, 12, 13; se la seconda
eccezione si verifica mentre è in fase di elaborazione una Double Exception,
la 80286 avvia la fase di "shutdown" che, in genere, porta al riavvio del
computer.
Per gestire in modo adeguato una Double Exception, la ISR deve essere
chiamata tramite un task gate; il back link del TSS associato alla ISR
punta al TSS del task che ha prodotto l'eccezione.
La coppia CS:IP salvata nello stack punta all'istruzione che ha generato
l'eccezione; la CPU provvede ad inserire nello stack anche un codice di
errore che vale sempre 0.
- Vettore 9: Processor Extension Segment Overrun Exception
La INT 9 segnala che un'istruzione del coprocessore matematico 80287
ha superato il campo LIMIT durante la fase di lettura o scrittura in un
segmento di dati; questo tipo di interruzione è tipico dei sistemi dove la
FPU è separata dalla CPU, per cui le verifiche sul rispetto delle
regole di protezione risultano indipendenti per i due dispositivi.
Nei sistemi basati sulla 80286, il coprocessore matematico 80287 è
un dispositivo esterno; di conseguenza, la INT 9 è un evento asincrono, come
accade per le interruzioni hardware. Per lo stesso motivo, la CPU non è in
grado di sapere quale istruzione della FPU ha provocato l'interruzione; per
conoscere tale informazione, è necessario consultare gli appositi registri della
stessa FPU (si veda il Capitolo 17 del tutorial Assembly Avanzato).
Il task che è stato interrotto dalla INT 9 può anche non essere quello che
ha eseguito l'istruzione della FPU che ha provocato l'interruzione; in tal
caso, il task stesso è riavviabile.
La coppia CS:IP salvata nello stack punta alla stessa istruzione che ha
generato l'eccezione.
- Vettore 10: Invalid Task State Segment Exception
Può capitare che, in seguito ad un task switch, il selettore presente nel task
gate punti ad un TSS non valido; in tal caso, la CPU genera una
INT 10.
La CPU inserisce nello stack anche un codice di errore, con il bit in
EXT che indica se l'eccezione è dovuta o meno ad un evento esterno; la
Figura 2.28 mostra i casi che rendono non valido un TSS e i relativi
codici di errore.
Per gestire in modo adeguato una Invalid TSS Exception, la ISR deve
essere chiamata tramite un task gate; la stessa ISR deve occuparsi della
corretta impostazione del busy bit nel nuovo TSS.
La coppia CS:IP salvata nello stack punta alla stessa istruzione che ha
generato l'eccezione; in certi casi può anche puntare alla prima istruzione del
nuovo task.
- Vettore 11: Segment Not Present Exception
La INT 11 viene generata quando si tenta di caricare dal disco un segmento
inesistente o quando si tenta di accedere ad un segmento che ha P=0; se il
segmento che ha creato il problema è però una LDT legata ad un task switch,
viene generata una INT 10.
Il codice di errore inserito nello stack ha la stessa forma di quelli illustrati
in Figura 2.28; se EXT=1, il problema è dovuto ad un segmento referenziato
dalla IDT.
Bisogna prestare attenzione al fatto che, se la INT 11 avviene durante un
task switch, il contenuto dei registri DS e ES potrebbe essere non
usabile, in quanto modificato dallo stesso task switch; il compito di gestire
correttamente questa situazione spetta alla ISR.
La coppia CS:IP salvata nello stack punta alla stessa istruzione che ha
generato l'eccezione.
- Vettore 12: Stack Segment Overrun or Not Present Exception
Un tentativo di estrarre un dato da uno stack vuoto (underflow) o di inserire un
dato in uno stack pieno (overflow), provoca una INT 12; lo stesso accade
se si tenta di accedere ad uno stack non presente.
Il bit EXT nel codice di errore indica se il problema è stato causato da
un evento interno (programma) o esterno (interruzione). Per gestire questo tipo
di eccezione è raccomandato l'uso di un task gate.
Bisogna prestare attenzione al fatto che, se la INT 12 avviene durante un
task switch, il contenuto dei registri DS e ES potrebbe essere non
usabile, in quanto modificato dallo stesso task switch; il compito di gestire
correttamente questa situazione spetta alla ISR.
La coppia CS:IP salvata nello stack punta alla stessa istruzione che ha
generato l'eccezione.
- Vettore 13: General Protection Exception
Tutte le violazioni delle regole di protezione che non ricadono nei casi illustrati
in precedenza, vengono trattate come General Protection Exception e generano
una INT 13; il codice di errore inserito nello stack fornisce tutti i dettagli
necessari per identificare il problema.
Il codice di errore vale 0 per: violazioni del campo LIMIT, tentativo
di scrittura in un segmento read only, tentativo di accesso ad un segmento dati con
selettore NULL in DS o ES e tentativo di accesso ad un segmento
con DPL<CPL; per altri tipi di violazione, il codice di errore è diverso da
0 e identifica il selettore da consultare. Per ulteriori dettagli si può fare
riferimento alla descrizione della Figura 2.7.
Bisogna prestare attenzione al fatto che, se la INT 13 avviene durante un
task switch, il contenuto dei registri DS e ES potrebbe essere non
usabile, in quanto modificato dallo stesso task switch; il compito di gestire
correttamente questa situazione spetta alla ISR.
La coppia CS:IP salvata nello stack punta alla stessa istruzione che ha
generato l'eccezione.
2.4 Gestione della memoria virtuale
La 80286 fornisce una serie di strumenti che permettono ai SO di
gestire sino a 1 GiB di memoria virtuale. Come abbiamo visto nel
precedente capitolo, un segmento di programma può avere una dimensione massima
di 64 KiB e viene trattato come una unità di memoria virtuale; le varie
tabelle dei descrittori possono gestire sino a 16536 segmenti, per un
totale quindi di 16536*65536 byte, pari a 1 GiB.
La memoria fisica disponibile può arrivare ad un massimo di 16 MiB e
viene usata per mappare la memoria virtuale attraverso il meccanismo degli
indirizzi virtuali del tipo Selector:Offset; il campo Selector
punta al segmento desiderato e il campo Offset indica uno spiazzamento
all'interno del segmento stesso.
Se i vari task in esecuzione richiedono più di 16 MiB di memoria fisica,
è possibile liberare spazio spostando i segmenti non usati su un dispositivo
esterno, come un hard disk; per tenere traccia della situazione, si ricorre ai
due bit P e A dell'ACCESS BYTE presente nel descrittore di
ogni segmento. Ogni volta che si accede ad un segmento, la CPU pone
A=1 nel relativo descrittore; in questo modo si indica al SO che
quel segmento è in uso (accessed).
Sfruttando il bit A, un SO può ricavare dati statistici che gli
permettono di sapere quali sono i segmenti a cui si accede più spesso e quali
invece risultano meno usati; proprio questi ultimi sono i principali candidati
ad essere spostati su disco nel caso in cui si debba liberare memoria fisica. Un
segmento presente in RAM ha P=1 (present) nell'ACCESS BYTE
del suo descrittore; se un segmento viene invece spostato su disco, si pone
P=0 (not present).
Un tentativo di accedere ad un segmento con P=0 provoca una eccezione
11 (#NP) per i segmenti di codice e dati o 12 (#SF)
per i segmenti di stack; la ISR che gestisce l'eccezione può provvedere
a ricaricare (se ciò è possibile) il segmento in RAM e a riavviare il
task che era stato interrotto. Il SO, se necessario, deve provvedere
alla rilocazione di un segmento caricato in memoria; tale rilocazione consiste
nel modificare opportunamente il Base Address del segmento stesso nel
relativo descrittore.
La Not Present Exception viene eventualmente generata quando si tenta di
caricare un selettore in un registro di segmento, quando si tenta di accedere
ad un segmento con un gate o in caso di task switch; l'indirizzo di ritorno
salvato sullo stack punta al primo byte dell'istruzione che ha generato
l'eccezione.
2.5 Segmenti di codice conformi (C=1)
Tutte le considerazioni svolte in questo capitolo in relazione ai meccanismi
di protezione per i segmenti di codice (eseguibili), si riferiscono al caso in
cui nell'ACCESS BYTE del descrittore si abbia C=0; si parla allora
di "segmento non conforme".
Un segmento di codice con il "conforming bit" posto a 1, è in grado di
adeguarsi al livello di privilegio del task (caller) da cui è avvenuto l'accesso;
questo significa che è possibile saltare (con CALL o JMP) a tale
segmento anche da un task con CPL>DPL (del segmento).
Un task che salta da un segmento origine ad uno destinazione che ha C=1,
mantiene inalterato il proprio CPL; il segmento destinazione usa quindi
il vecchio stack del caller, anche se ha privilegi più bassi.
Una istruzione RET intersegmento da un segmento di codice con C=1,
può restituire il controllo ad un altro segmento di codice che ha livelli di
privilegio inferiori; in questo caso, il nuovo CPL viene impostato in
modo che coincida con il RPL presente nel vecchio CS dell'indirizzo
di ritorno salvato nello stack (che è proprio il CPL del caller).
Un segmento di codice con R=1 (readable) e C=1 (conforming), può
essere acceduto in lettura da qualunque altro livello di privilegio; questa è
l'unica deroga alle regole di protezione per i segmenti di codice.
Bibliografia
Intel 80286 Hardware Reference Manual
(disponibile su Internet)
Intel 80286 Programmer's Reference Manual
(disponibile su Internet)
iAPX 286 Operating Systems Writer's Guide
(disponibile su Internet)