Modalità Protetta
Capitolo 1: Introduzione
Abbiamo visto che il tutorial Assembly Base illustra il linguaggio Assembly
per le CPU della famiglia 80x86, utilizzando il DOS come SO
di riferimento; questa scelta è dovuta al fatto che l'Assembly rappresenta il set
di istruzioni della CPU e quindi risulta strettamente legato alla particolare
piattaforma hardware su cui si intende lavorare. Una volta deciso che la piattaforma
hardware che ci interessa è quella dei PC basati su CPU 80x86, possiamo
studiare il relativo set di istruzioni in modo del tutto indipendente dal SO
usato (DOS, Windows, Linux, MacOSX etc). Il DOS è un
SO capace di eseguire un solo programma per volta e quindi, non esistendo il
rischio di conflitti, permette il libero accesso a qualunque componente hardware del
PC; ciò rappresenta un enorme vantaggio nel momento in cui si decide di fare
esperimenti con un linguaggio di basso livello come l'Assembly.
Sempre in relazione al tutorial Assembly Base, la modalità operativa di
riferimento è quella della CPU 8086; si tratta della cosiddetta Modalità
Reale 8086, così chiamata perché qualunque accesso in memoria deve riguardare
indirizzi realmente (fisicamente) presenti sulla RAM del PC. Anche
questa è una scelta precisa in quanto, come vedremo in questo capitolo e nei capitoli
successivi, tale modalità operativa ha pesantemente condizionato lo sviluppo di tutte
le nuove CPU della famiglia 80x86; è importante quindi partire da un
breve riassunto delle principali caratteristiche della 8086.
1.1 La Modalità Reale 8086
La 8086 ha avuto un notevole successo nel mondo dei PC e ciò ha portato
alla scrittura di una enorme quantità di software per tale piattaforma hardware; i
vari produttori di microprocessori si sono visti costretti quindi a sviluppare nuove
CPU della famiglia 80x86, garantendo la cosiddetta "compatibilità verso
il basso". Un programma scritto per una 8086, deve poter girare inalterato
sulle CPU superiori della stessa famiglia, come le 80186, 80286,
80386, 80486, etc; a tale proposito, come vedremo in dettaglio più
avanti, quando si accende un PC, una qualsiasi CPU della famiglia
80x86 viene inizializzata in modalità reale 8086.
La 8086 è stata progettata per equipaggiare PC dotati di 1 MiB di
memoria RAM (1048576 byte); tale memoria viene vista dai programmi come
un vettore di 1048576 elementi (BYTE), indicizzati da 0 a
1048575. La granularità della RAM (quantità minima di memoria gestibile
dalla CPU) è pari quindi a 1 byte; l'indice di ogni BYTE non è
altro che il suo indirizzo fisico.
Osserviamo ora che:
1048576 = 1024 * 1024 = 210 * 210 = 210+10 = 220
In sostanza, per esprimere in codice binario tutti gli indirizzi della RAM che
vanno da 0 a 1048575, ci servono almeno 20 bit in quanto dobbiamo
rappresentare numeri interi positivi compresi tra 00000000000000000000b e
11111111111111111111b; in termini circuitali, ciò significa che per accedere a
tutti i 1048576 byte della RAM abbiamo bisogno di un Address Bus
a 20 linee, su ciascuna delle quali transita uno dei bit che vanno a formare
l'indirizzo fisico a 20 bit. La 8086 è in effetti dotata di un Address
Bus a 20 linee indicate con i simboli A0, A1, A2, ...,
A19.
Il problema che si presenta è dato dal fatto che la 8086 ha un'architettura a
16 bit e può quindi gestire solamente numeri interi positivi (come gli indirizzi)
che vanno da 0 a 216-1=65535; per risolvere questo problema, i
registri interni della CPU risultano organizzati come in Figura 1.1.
La RAM viene suddivisa in blocchi da 65536 byte ciascuno, denominati
segmenti di memoria; all'interno di ogni blocco possiamo muoverci specificando
uno spiazzamento (offset) a 16 bit, compreso quindi tra 0 e
65535.
Un programma viene suddiviso in vari blocchi denominati segmenti di programma;
ogni segmento di programma viene collocato in un segmento di memoria e quindi non può
superare la dimensione massima di 64 KiB.
Durante l'esecuzione di un programma, per referenziare i segmenti di memoria vengono
utilizzati i registri di segmento visibili in Figura 1.1; il registro CS
referenzia il blocco codice, DS il blocco dati, ES un
eventuale blocco dati extra e SS il blocco stack.
Per specificare un offset all'interno dei vari blocchi, possiamo utilizzare i registri
puntatori di Figura 1.1; la 8086 permette solo l'uso di BX, SI,
DI, SP, BP e IP (non direttamente accessibile dal
programmatore). Il comportamento predefinito della CPU consiste nell'associare
BX, SI e DI a DS, SP e BP a SS e
IP a CS.
La Figura 1.2 illustra la differenza tra segmento di memoria e segmento di programma in
relazione ad un blocco dati; in questo caso, l'offset a cui accedere viene specificato
tramite il registro puntatore SI.
Se, ad esempio, abbiamo un dato di tipo WORD che si trova all'offset
00FCh del segmento di memoria n. 01CBh, posto SI=00FCh e
DS=01CBh, possiamo trasferire in AX il dato stesso con l'istruzione:
mov ax, ds:[si]
Il metodo appena illustrato permette quindi di specificare un indirizzo della RAM
attraverso coppie da 16+16 bit che possiamo indicare simbolicamente come
Seg:Offset; la componente Seg indica uno tra i possibili 65536
segmenti di memoria, mentre la componente Offset indica uno spiazzamento,
tra 0 e 65535, all'interno del segmento stesso. Una coppia di tipo
Seg:Offset viene chiamata indirizzo logico; ovviamente, tale indirizzo
logico deve essere poi convertito dalla CPU in un indirizzo fisico a
20 bit.
Si vede subito che con questo metodo risulta possibile gestire ben 65536 segmenti
di memoria da 65536 byte ciascuno, per un totale di:
65536 * 65536 = 216 * 216 = 216+16 = 232 = 4294967296 byte = 4 GiB
Naturalmente, considerate le caratteristiche della 8086, parlare di GiB di
RAM non ha alcun senso, per cui si rende necessario trovare un modo per
limitare l'accesso ai soli 1048576 byte fisicamente disponibili; a tale
proposito, i progettisti della Intel partoriscono l'idea illustrata in
Figura 1.3.
I vari segmenti di memoria partono ad intervalli di 16 byte (10h byte),
denominati paragrafi; quindi, il segmento n. 0000h parte dal paragrafo
0 all'indirizzo fisico 00000h, il segmento n. 0001h parte dal
paragrafo 1 all'indirizzo fisico 00010h, il segmento n. 0002h
parte dal paragrafo 2 all'indirizzo fisico 00020h
e così via, sino
al segmento n. FFFFh che parte dal paragrafo 65535 all'indirizzo fisico
FFFF0h. Si può notare che, essendo i vari segmenti di memoria allineati al
paragrafo, i loro indirizzi fisici hanno il nibble meno significativo che vale sempre
0000b (in esadecimale, 0h).
Anche in questo modo abbiamo a disposizione 65536 segmenti di memoria, ciascuno
di ampiezza pari a 65536 byte, ma a causa della parziale sovrapposizione dei
segmenti stessi, è come se stessimo suddividendo i 1048576 byte di RAM
in 65536 paragrafi da 16 byte ciascuno; abbiamo, infatti:
65536 * 16 = 216 * 24 = 216+4 = 220 = 1048576
Con lo schema di Figura 1.3, la conversione di un indirizzo logico Seg:Offset
in un indirizzo fisico a 20 bit è semplicissima; tenuto conto che ogni segmento
di memoria risulta allineato al paragrafo (16 byte), la formula generale è:
indirizzo_fisico_a_20_bit = Seg * 16 + Offset
La CPU effettua questo calcolo in modo estremamente rapido in quanto non ha
bisogno di eseguire la moltiplicazione; infatti, osserviamo che 16 può essere
scritto come 24. Moltiplicare un numero binario per
24 equivale a spostare di 4 posti verso sinistra i suoi bit,
riempiendo con 0000b i posti rimasti liberi a destra; analogamente, moltiplicare
un numero esadecimale per 24=161 equivale a spostare di
1 posto verso sinistra i suoi nibble, riempiendo con 0h il nibble
rimasto libero a destra.
La Figura 1.4 illustra un esempio con DS=13BAh e BX=01C4h; l'indice
13BAh del segmento di memoria viene sottoposto ad uno shift a sinistra di
una cifra e il risultato 13BA0h viene sommato all'offset 01C4h
ottenendo così l'indirizzo fisico a 20 bit 13D64h.
Naturalmente, lo schema di Figura 1.3 presenta anche degli inconvenienti; abbiamo
appena visto che i segmenti di memoria risultano parzialmente sovrapposti, per cui
ad ogni indirizzo logico corrisponde uno e un solo indirizzo fisico a 20
bit, ma non è vero il viceversa. La stessa Figura 1.3 mostra infatti l'esempio
dell'indirizzo fisico 00058h che ricade in ben 6 segmenti di memoria
differenti; avremo quindi i 6 indirizzi logici seguenti, tutti equivalenti a
00058h:
0000h:0058h
0001h:0048h
0002h:0038h
0003h:0028h
0004h:0018h
0005h:0008h
Come abbiamo visto nel tutorial Assembly Base, possiamo limitare questo
problema servendoci degli indirizzi logici normalizzati. Dato un indirizzo
fisico a 20 bit (ad esempio, 3FAB2h), il nibble meno significativo
(2) rappresenta la componente Offset (0002h); i 4 nibble più
significativi (3FABh) rappresentano la componente Seg.
Un altro serio inconveniente dello schema di Figura 1.3 è dato dal fatto che i
segmenti di memoria finali della RAM risultano incompleti; considerando che:
65536 / 16 = 216 / 24 = 216-4 = 212 = 4096
dei 4096 segmenti di memoria finali, solo il primo è completo mentre gli
altri 4095 sono incompleti!
La Figura 1.5 illustra questa situazione.
L'ultimo segmento di memoria, il n.FFFFh, risulta composto da appena 16
byte; al suo interno possiamo quindi specificare un offset compreso tra 0000h
e 000Fh. L'offset successivo 0010h produce l'indirizzo logico
FFFFh:0010h che viene convertito nell'indirizzo fisico a 21 bit
100000h; ma l'Address Bus della 8086 ha solo 20 linee,
per cui il bit più significativo viene perso e ci ritroviamo in 00000h, che è
l'inizio della RAM!
Questo fenomeno prende il nome di wrap around è può risultare molto pericoloso
in quanto ci porta ad invadere aree della memoria che potrebbero essere riservate. Nel
caso della modalità reale 8086, ad esempio, sappiamo che da 00000h
partono i vettori di interruzione; invadendo tale area provochiamo quindi un sicuro
crash del programma e dell'intero sistema operativo!
Per evitare che i programmi possano incappare in problemi del genere, il blocco finale
da 64 KiB della RAM (che comprende quindi anche i 4095 segmenti
di memoria incompleti), viene utilizzato per mappare la ROM BIOS del PC.
1.1.1 Accesso alle memorie esterne alla RAM in modalità reale 8086
In un PC, oltre alla RAM, sono presenti svariate periferiche dotate di
memorie di piccole dimensioni, alle quali si può accedere attraverso apposite porte
hardware; le CPU della famiglia 80x86 mappano tali porte in un'apposita
area da 64 KiB esterna alla RAM. Si possono gestire così sino a
65536 porte da 1 byte ciascuna, oppure 32768 porte da 2
byte ciascuna (disposte ad indirizzi pari); si parla allora di port mapped I/O
e le relative operazioni di lettura e scrittura si effettuano mediante istruzioni
come IN e OUT.
In certi casi però si ha a che fare con memorie di medie e grosse dimensioni, come la
memoria video, la ROM BIOS, le espansioni di memoria RAM etc; tali memorie
vengono definite memorie esterne, per distinguerle dalla RAM che invece
ricopre il ruolo di memoria centrale. In casi del genere, non avrebbe senso
usare istruzioni come IN e OUT, visto che abbiamo a che fare con migliaia,
se non milioni di locazioni; sarebbe preferibile invece permettere alla CPU di
accedere direttamente alle memorie esterne, come se si avesse a che fare con la
RAM centrale.
Per quanto è stato esposto in precedenza, sfortunatamente la 8086 è in grado di
vedere esclusivamente 1 MiB di memoria RAM; ciò significa che qualunque
memoria esterna deve apparire, in un modo o nell'altro, in questo stesso MiB. L'esempio
della ROM BIOS illustrato in precedenza, ci suggerisce il metodo da seguire per
risolvere questo problema; si tratta in pratica di riprodurre (mappare) la porzione
desiderata di una qualunque memoria esterna, in un'apposita finestra posizionata nella
RAM (si parla in tal caso di memory mapped I/O). Tale finestra prende il
nome di frame buffer in quanto ci permette di scorrere tutta la memoria esterna,
suddividendola in tante porzioni, paragonabili ai fotogrammi (frame) di una pellicola
cinematografica; tenendo conto delle limitazioni della 8086, appare evidente che
anche il frame buffer non potrà superare la dimensione massima di 64 KiB.
Nel tutorial Assembly Base sono stati illustrati i vari frame buffer usati
in modalità reale 8086 per accedere alle memorie esterne; è stato già citato
il caso della ROM BIOS del PC, che viene mappata in una finestra da
64 KiB a partire dall'indirizzo logico F000h:0000h. La memoria video
per la modalità testo in b/n viene mappata in una finestra da 32 KiB a partire
dall'indirizzo logico B000h:0000h; analogamente, la memoria video per la
modalità testo a colori viene mappata in una finestra da 32 KiB a partire
dall'indirizzo logico B800h:0000h. Per mappare la memoria video in modalità
grafica, invece, si utilizza una finestra da 64 KiB a partire dall'indirizzo
logico A000h:0000h.
La tecnica del frame buffer appena illustrata, si rivela molto utile nel momento in
cui ci si rende conto che l'unico MiB di RAM gestibile dalla 8086
comincia ad essere insufficiente per le esigenze dei nuovi programmi, sempre più
ingombranti e avidi di memoria; questo problema, infatti, può essere risolto con
l'uso delle cosiddette espansioni di memoria. Si tratta di schede hardware
su cui si trova installata memoria RAM aggiuntiva che può ammontare anche a
diverse decine di MiB; tale memoria, come al solito, viene fatta apparire in
un'apposita finestra posizionata nell'unico MiB visibile dalla 8086. In
genere, si ricorre ad un frame buffer da 64 KiB posizionato in un'area
libera, tra gli indirizzi logici D000h:0000h e F000h:0000h; la
Figura 1.6 mostra l'uso di questa tecnica, che si applica anche alle altre memorie
esterne.
In ogni caso, anche se con le espansioni di memoria abbiamo a disposizione decine
di MiB di RAM aggiuntiva, il problema rimane sempre lo stesso: le limitazioni
della 8086 ci costringono a gestire tutta questa grande quantità di memoria
suddividendola in blocchi, ciascuno dei quali non può superare la dimensione massima
di 64 KiB.
L'architettura a 16 bit e le altre caratteristiche hardware della 8086,
rendono troppo complessa e costosa ogni soluzione a questo problema; non resta allora
che rivolgersi alle CPU di classe superiore della famiglia 80x86.
1.2 La Modalità Protetta 80286
Nel 1982 compare sul mercato la CPU 80186, che però risulta avere l'identica
architettura della 8086, compreso l'Address Bus a 20 linee; le
uniche novità riguardano la presenza di funzionalità aggiuntive come il generatore
di clock, l'interrupt controller e il bus controller.
Una prima svolta importante arriva nello stesso anno, con la comparsa sul mercato
della CPU 80286; tale microprocessore è dotato di un Address Bus a
24 linee, capace quindi di indirizzare una quantità di RAM centrale
pari a:
224 = 16777216 byte = 16 MiB
I programmatori restano però perplessi per il fatto che i registri generali,
speciali e di segmento della 80286 risultano identici a quelli della Figura
1.1; l'architettura a 16 bit di questa CPU, infatti, ci fa capire
che permane il problema della segmentazione della memoria a 64 KiB. Resta
da capire come faccia la 80286 ad indirizzare 16 MiB di RAM
con i suoi registri a soli 16 bit; più avanti vedremo tutti i dettagli.
1.2.1 La compatibilità verso il basso e la linea A20 della 80286
Nella fase di realizzazione della 80286, la principale preoccupazione dei
progettisti è quella di garantire la compatibilità con l'enorme quantità di
programmi sviluppata negli anni per la 8086; proprio per questo motivo, i
registri generali, speciali e di segmento della 80286 sono gli stessi di
Figura 1.1 (sono anche presenti diversi nuovi registri per la modalità protetta,
che vengono illustrati in seguito).
Solo dopo che la 80286 inizia a diffondersi sul mercato dei PC, ci si
accorge di un problema a cui nessuno aveva pensato. Abbiamo visto in precedenza che
sulla 8086, a causa dell'Address Bus a 20 linee, l'indirizzo
logico FFFFh:0010h provoca il wrap around in quanto viene associato
all'indirizzo fisico 100000h, troncato a 00000h; ciò vale anche per
gli indirizzi logici successivi, da FFFFh:0011h a FFFFh:FFFFh, che
vengono associati, rispettivamente, agli indirizzi fisici da 00001h a
0FFEFh. Nel caso però della 80286, grazie all'Address Bus a
24 linee, tutti questi indirizzi logici sono perfettamente leciti e vengono
correttamente associati agli indirizzi fisici a 21 bit, da 100000h a
10FFEFh!
Si tratta di un blocco di memoria oltre il primo MiB, che ammonta a:
65536 - 16 = 65520 byte
Come abbiamo visto nel tutorial Assembly Base, questa porzione di memoria
oltre il primo MiB viene denominata HMA o High Memory Area.
Per evitare il rischio di incompatibilità con i programmi destinati alla
8086 (che in certi casi cercano di sfruttare il wrap around), è necessario
trovare una soluzione a questo problema; non essendo possibile modificare la
80286, si decide allora di ricorrere ad un espediente piuttosto singolare,
che consiste nello sfruttare un pin rimasto libero nel chip 8042 Keyboard
Controller. Attraverso tale pin è possibile abilitare o disabilitare la linea
A20 dell'Address Bus. Appare chiaro allora che, per garantire la
compatibilità con la 8086, in fase di avvio di un PC dotato di
CPU 80286, si deve provvedere a disabilitare la linea A20 tramite
l'8042; più precisamente, durante la fase di avvio, il BIOS abilita
la linea A20 per testare tutta la RAM disponibile e poi la
disabilita prima di cedere il controllo al sistema operativo (SO).
La Figura 1.7 mostra il contenuto della porta di output del chip 8042,
già descritto nel Capitolo 13 del tutorial Assembly Avanzato; si noti il
bit in posizione 1 (Gate A20), attraverso il quale si può abilitare
o disabilitare la linea A20.
In definitiva, una 80286 con i suoi registri a 16 bit e con la linea
A20 disabilitata, funziona esattamente come una 8086; in questo modo
viene garantita la massima compatibilità con i programmi scritti per la stessa
8086.
Nel tutorial Assembly Avanzato (Capitolo 2) abbiamo anche visto che, per
convenzione, in seguito ad un RESET della CPU la coppia CS:IP
deve puntare all'ultimo paragrafo della RAM; per un Address Bus a
24 linee si tratta dell'indirizzo fisico FFFFF0h. Come sappiamo, per
tenere conto del fatto che possono anche non essere presenti 16 MiB di
memoria fisica, l'indirizzo FFFFF0h viene mappato in una EPROM,
all'interno della quale i produttori di PC possono inserire tutte le
necessarie istruzioni (in genere, si tratta di una JMP verso un'area che
contiene il codice di inizializzazione); con la linea A20 disabilitata, il
nibble più significativo di FFFFF0h non viene usato, per cui l'indirizzo
diventa FFFF0h (ultimo paragrafo dell'unico MiB di RAM gestibile
dalla 8086).
Per tenere conto di tutti questi aspetti, i principali registri della 80286
vengono inizializzati in questo modo:
- CS=F000h, IP=FFF0h
- DS=0000h
- ES=0000h
- SS=0000h
- FLAGS=0002h
- MSW=FFF0h
(MSW indica il registro Machine Status Word che viene illustrato nel
capitolo successivo).
Mentre in modalità reale il contenuto dei registri di segmento ha il significato
che già conosciamo (paragrafo da cui parte il relativo segmento di memoria), in
modalità protetta il discorso è completamente diverso; come vedremo, tale contenuto
punta ad un "descrittore di segmento" che, tra l'altro, specifica anche l'indirizzo
a 24 bit da cui parte il relativo segmento di programma. Per il registro
CS della 80286, si tratta dell'indirizzo FF0000h; sommando
tale indirizzo all'offset FFF0h contenuto in IP, si ottiene proprio
FFFFF0h (ultimo paragrafo dei 16 MiB di RAM gestibili dalla
80286).
1.2.2 La nascita ufficiale della Modalità Protetta sulle CPU della famiglia 80x86
Quando la linea A20 è abilitata, la 80286 lavora invece con l'intero
Address Bus a 24 linee e ci offre la possibilità di accedere ad una
quantità di memoria RAM pari a 16 MiB (se fisicamente presente sul
PC). Per rendere possibile l'indirizzamento di 16 MiB di RAM
con i registri a 16 bit, la 80286 introduce per la prima volta una
nuova modalità operativa denominata Modalità Protetta; in tale modalità il
contenuto dei registri di segmento assume un significato completamente diverso,
come viene illustrato dalla Figura 1.8.
Il campo Index ha un'ampiezza di 13 bit e rappresenta un indice in
un'apposita tabella contenente i cosiddetti descriptors; un descrittore è
un blocco di dati che fornisce le informazioni complete relative ai segmenti di
programma. Una delle informazioni più importanti fornita dal descrittore è il
Base Address; si tratta dell'indirizzo a 24 bit da cui parte in
memoria un segmento di programma.
Con i 13 bit del campo Index possiamo gestire sino a
213=8192 descrittori.
Il campo TI (table indicator) occupa 1 bit e definisce il
tipo di tabella a cui si riferisce il campo Index; il valore 0
indica la Global Descriptor Table (GDT), mentre il valore 1
indica la Local Descriptor Table (LDT).
La GDT è una tabella globale condivisa, che elenca i descrittori di tabelle
LDT e di altre informazioni importanti per la gestione della modalità protetta;
un SO, ad esempio, attraverso la GDT può fornire servizi condivisi ai
vari programmi in esecuzione, evitando così inutili sprechi di risorse.
La LDT elenca i descrittori di tutti i segmenti di programma utilizzati
dal programma correntemente in esecuzione. Grazie alla LDT viene aperta
la strada ai SO multitasking, capaci di eseguire più programmi
contemporaneamente; ogni programma in esecuzione, denominato task, ha la
propria LDT e ciò permette di attivare meccanismi di protezione che
impediscono ai vari task di interferire tra loro.
Esiste anche un terzo tipo di tabella denominato Interrupt Descriptor Table
(IDT); come vedremo nel capitolo successivo, la IDT in modalità
protetta ha lo stesso ruolo della tabella dei vettori di interruzione (IVT)
in modalità reale.
Il campo RPL ha un'ampiezza di 2 bit e rappresenta il Requested
Privilege Level (livello di privilegio desiderato); con 2 bit possiamo
gestire 4 livelli di privilegio, 0, 1, 2 e 3,
con 0 che equivale al livello più alto. In generale, i livelli di privilegio
hanno lo scopo di introdurre regole di protezione che stabiliscono ciò che un
programma può fare e ciò che non può fare; ad esempio, un programma che gira a
livello 3 deve sottostare a determinate condizioni per richiedere un servizio
del SO che gira a livello 0 (e quindi, con privilegi più alti).
Nel capitolo successivo vedremo che, nel caso dei segmenti di codice e di stack, il
campo RPL coincide con il livello di privilegio del task correntemente in
esecuzione e prende il nome di Current Privilege Level (CPL); per i
segmenti di dati, questo stesso campo viene impiegato per mitigare le conseguenze di
un uso improprio dei puntatori negli indirizzamenti.
A questo punto siamo in grado di capire come sia possibile accedere a 16
MiB di memoria RAM con i registri a 16 bit della 80286. Ogni
segmento di programma ha un suo descrittore che specifica il Base Address
a 24 bit; tale indirizzo base può essere posizionato (teoricamente) in un
punto qualunque dei 16 MiB di RAM. A partire dall'indirizzo base,
possiamo specificare un offset a 16 bit, compreso quindi tra 0000h
e FFFFh; l'offset viene poi sommato all'indirizzo base per ottenere
l'indirizzo a 24 bit a cui vogliamo accedere. La Figura 1.9 illustra un
esempio pratico.
In questo esempio, il campo Index del registro ES punta al relativo
descrittore di segmento nella LDT del programma in esecuzione; tale
descrittore specifica un indirizzo base a 24 bit, pari a 1CA24Eh.
Il registro puntatore DI contiene l'offset 3FB1h, interno al
segmento di programma; la CPU somma tale offset all'indirizzo base ottenendo
l'indirizzo a 24 bit 1CE1FFh a cui vogliamo accedere.
Le considerazioni appena esposte fanno nascere un dubbio: se per ogni indirizzamento
la 80286 dovesse andare a "pescare" tutte le informazioni necessarie dal
relativo descrittore di segmento, allora si avrebbero pesantissime ripercussioni
sulla velocità di esecuzione dei programmi. I progettisti della 80286 si
sono resi conto della gravità di questo problema e hanno trovato una soluzione che
consiste nell'aggiungere ai registri di segmento di Figura 1.1, dei "prolungamenti"
nascosti. Ogni volta che inizializziamo un registro di segmento, la CPU
accede al relativo descrittore e copia nel prolungamento del registro stesso tutte
le informazioni di cui ha bisogno; ovviamente, tra tali informazioni c'è anche il
Base Address a 24 bit. A questo punto, gli indirizzamenti si
svolgono in modo del tutto analogo alla 8086; infatti, anche con la
80286 in modalità protetta, possiamo scrivere istruzioni del tipo:
mov ax, ds:[bx]
In questo caso la CPU, anziché accedere al descrittore di segmento associato
a DS, legge dal prolungamento nascosto dello stesso registro il Base
Address e lo somma all'offset BX per ottenere l'indirizzo a 24 bit
a cui accedere.
Continua a valere anche il concetto di associazioni predefinite tra registri
puntatori e registri di segmento; la precedente istruzione quindi può essere
scritta come:
mov ax, [bx]
Il risultato che si ottiene è identico in quanto, anche in questo caso, la CPU
associa BX a DS.
Più avanti vedremo tutti i dettagli sui prolungamenti nascosti dei registri di
segmento.
1.2.3 Memoria reale e memoria virtuale in modalità protetta 80286
Abbiamo visto che nella modalità reale 8086 non possiamo indicare in modo
esplicito un indirizzo fisico a 20 bit presente nell'unico MiB di RAM
disponibile; la CPU ci obbliga ad utilizzare gli indirizzi logici, costituiti
da coppie Seg:Offset da 16+16 bit, che poi vengono convertite in
indirizzi fisici a 20 bit, secondo il meccanismo illustrato in Figura 1.4.
L'aspetto fondamentale da notare è che ad ogni indirizzo logico Seg:Offset
corrisponde un ben preciso indirizzo fisico a 20 bit realmente esistente
nella RAM (da cui il nome di "modalità reale").
Nella modalità protetta 80286, la situazione è formalmente identica; per
gestire gli indirizzamenti siamo obbligati ad utilizzare coppie Seg:Offset
da 16+16 bit dove però, come sappiamo, la componente Seg ha un
significato diverso. Alla componente Seg è associato un descrittore che
contiene anche il Base Address a 24 bit del relativo segmento di
programma; la 80286 somma tale indirizzo base alla componente Offset
e ottiene, secondo il meccanismo illustrato in Figura 1.9, l'indirizzo fisico a
24 bit presente nei 16 MiB della RAM.
Se la forma è la stessa rispetto alla 8086, la sostanza è però completamente
diversa. Osservando la Figura 1.8 possiamo notare che il campo Index (indice
nella tabella dei descrittori) è formato da 13 bit a cui si aggiunge il bit
del campo TI (tipo di tabella, GDT o LDT), per un totale di
14 bit; se ciascuna di queste due tabelle può contenere 8192 elementi,
allora in totale possiamo avere un numero di descrittori pari a:
8192 * 2 = 213 * 21 = 213+1 = 214 = 16384
Considerando che il segmento associato ad ogni descrittore può avere una dimensione
massima di 64 KiB, risulta che la 80286 deve essere in grado di
gestire una quantità di RAM pari a:
16384 * 65536 = 214 * 216 = 214+16 = 230 = 1073741824 byte = 1 GiB
Come si spiega una situazione del genere?
La risposta è che questa è una delle caratteristiche più potenti della 80286;
si tratta della capacità di simulare sino a 1 GiB di memoria virtuale!
Non essendo disponibile un Address Bus a 30 linee, il metodo che si
segue consiste nel definire una RAM virtuale da 1 GiB, la cui
granularità è espressa, non in byte, ma in segmenti; nel caso più semplice il
nostro GiB viene suddiviso in 16384 segmenti da 64 KiB ciascuno.
Se la richiesta di RAM supera i 16 MiB fisicamente disponibili, la
80286 fornisce ai SO un meccanismo per simulare memoria supplementare
su dispositivi esterni (ad esempio, gli hard disk). Un segmento momentaneamente non
necessario, può essere spostato su disco per liberare spazio in RAM; tale
segmento, attraverso il relativo descrittore, viene marcato come "non presente".
Se si tenta di accedere ad un segmento non presente, la 80286 genera una
eccezione di non presenza; i SO possono intercettare tale eccezione
per ricaricare in memoria il segmento stesso. Un segmento ricaricato in memoria può
richiedere una rilocazione da parte del SO, che consiste nell'aggiornare il
suo Base Address nel relativo descrittore; gli offset chiaramente non
necessitano di rilocazione in quanto sono spiazzamenti relativi allo stesso Base
Address.
La Figura 1.10 illustra i concetti appena esposti.
Come si può notare, attraverso gli indirizzi logici Seg:Offset, i programmi
"credono" di vedere 1 GiB di RAM. La componente Seg, con i
14 bit dei campi Index e TI, permette di specificare uno tra
i possibili 16384 descrittori (in questo esempio, il descrittore contiene
il Base Address del segmento n. 102); la componente Offset, a
sua volta, indica uno spiazzamento all'interno del segmento stesso.
Gli indirizzi logici sono lo strumento che la 80286 utilizza per mappare
1 GiB di memoria virtuale nei 16 MiB di memoria reale; osserviamo,
infatti, che mentre una coppia Seg:Offset permette di indirizzare 1
GiB virtuale, il relativo descrittore specifica un Base Address a 24
bit, che quindi ricade sempre nei 16 MiB reali.
Il segmento a cui vogliamo accedere potrebbe anche trovarsi su disco; in tal caso,
abbiamo visto che il SO può sfruttare l'eccezione di non presenza per
ricaricarlo in memoria. Se in memoria non c'è spazio disponibile per ricaricare il
segmento, si può procedere a scaricare prima su disco un altro segmento
momentaneamente non in uso; questa tecnica, denominata swapping, viene
largamente utilizzata dai moderni SO che devono gestire enormi quantità di
memoria virtuale.
In sostanza, nella modalità protetta della 80286, capita di fare riferimento
a locazioni che virtualmente si trovano in memoria, ma in realtà appartengono a
segmenti scaricati su disco; proprio per questo motivo, appare più appropriata la
definizione di indirizzo virtuale Seg:Offset, al posto di indirizzo
logico Seg:Offset della modalità reale 8086.
Tutti questi aspetti vengono illustrati in dettaglio nel seguito del capitolo e nei
capitoli successivi.
1.2.4 I descrittori di segmento
In modalità protetta, un qualunque processo in esecuzione viene denominato, come
sappiamo, task; per processo si intende un programma ordinario o un blocco
di codice che deve svolgere un particolare compito (ad esempio, un salto da un
segmento ad un altro, la chiamata di una ISR o di un servizio fornito dal
SO, etc). Il concetto di task è molto importante e viene approfondito in
dettaglio nel capitolo successivo.
Ad ogni task vengono assegnate una GDT e una LDT. La GDT è in
realtà unica e viene condivisa da tutti i task; la LDT rappresenta lo spazio
virtuale privato di un task e permette di attivare meccanismi di protezione per
evitare che due o più task possano interferire tra loro. Nel caso più semplice, la
80286 permette di gestire tutti i task con la sola GDT; in questo
modo si elimina tutto il lavoro necessario per la gestione delle LDT, ma
chiaramente si perdono i vantaggi della protezione tra task.
Una tabella, GDT o LDT, può contenere da 1 a 8192
elementi, denominati descrittori; i descrittori si dividono in due grandi
categorie:
- descrittori di segmento
- descrittori speciali
I descrittori di segmento descrivono tutte le caratteristiche dei vari segmenti di
programma contenenti codice, dati e stack; i descrittori speciali fanno riferimento
a particolari segmenti che contengono tutto il necessario per la gestione della
modalità protetta.
Ogni descrittore è un blocco dati formato da 8 byte (64 bit); i
16 bit più significativi sono riservati per usi futuri e devono valere
rigorosamente 0000000000000000b.
La Figura 1.11 mostra la struttura di un descrittore di segmento, individuato dal
bit S (Segment) in posizione 44, che deve valere 1.
Il campo LIMIT è formato da 16 bit e occupa le posizioni da 0
a 15; si tratta della "dimensione meno 1" in byte del segmento di
programma. A differenza della modalità reale, in modalità protetta 80286
bisogna sempre indicare l'offset più alto a cui possiamo accedere in un segmento
di programma; i valori (interi positivi) consentiti vanno da 0000h (un
solo byte) a FFFFh (64 KiB). Qualunque tentativo di accesso ad un
offset oltre il limite, provoca una violazione della protezione.
Il campo BASE è formato da 24 bit e occupa le posizioni da 16
a 39; ovviamente, si tratta del Base Address a 24 bit del
relativo segmento di programma.
Gli 8 bit che occupano le posizioni da 40 a 47 formano il
cosiddetto ACCESS BYTE; si tratta di una serie di informazioni addizionali
necessarie per definire il tipo specifico di segmento, i meccanismi di protezione
e la gestione della memoria virtuale.
La Figura 1.12 illustra la struttura dell'ACCESS BYTE per i segmenti di
codice, individuati dal campo E che deve valere 1.
La Figura 1.13 illustra la struttura dell'ACCESS BYTE per i segmenti di
dati o stack, individuati dal campo E che deve valere 0.
Il campo S vale sempre 1 per indicare un normale segmento di
programma che può contenere codice, dati o lo stack.
Il campo E permette di distinguere tra segmenti di codice e segmenti di
dati/stack; se E=1 (eseguibile) si ha un segmento di codice, mentre
E=0 (non eseguibile) indica un segmento di dati/stack.
Il campo P indica se il segmento è fisicamente presente in memoria
(P=1); se P=0, il segmento si trova su disco e un tentativo di
accesso ad esso provoca una eccezione di non presenza.
Il campo A indica se il relativo segmento è stato acceduto di recente
(A=1). I SO possono usare questo bit per decidere se è possibile
spostare un segmento su disco; se A=0, il segmento non è stato usato di
recente e quindi può essere spostato su disco senza problemi.
Il campo DPL indica il livello di privilegio del segmento; con 2
bit si possono definire quattro livelli di privilegio, dal più alto (0)
al più basso (3).
Il campo C vale solo per i segmenti di codice e permette di "allentare"
le regole da rispettare per le istruzioni che saltano da un segmento all'altro.
Normalmente, se da un segmento di codice si salta ad un altro segmento di codice
che ha un livello di privilegio più alto e C=0 (non conforme), si ottiene
una eccezione di protezione da parte della CPU.
Il campo R vale solo per i segmenti di codice. Se R=1, il segmento
è accessibile anche in lettura; se R=0, il segmento è solo eseguibile.
R=1 viene utilizzato per quei segmenti di codice che devono essere resi
accessibili in lettura in quanto contengono anche dati.
Il campo W vale solo per i segmenti di dati/stack. Se W=1, il
segmento è accessibile anche in scrittura; se W=0, il segmento è solo
leggibile (dati accessibili in sola lettura e quindi non modificabili).
Il campo ED vale solo per i segmenti di dati/stack. Se ED=0, i
dati vanno disposti tra gli offset 0000h e LIMIT; un tentativo
di accesso ad un offset maggiore di LIMIT provoca una violazione della
protezione. Se ED=1, i dati vanno disposti tra gli offset LIMIT+1
e FFFFh; un tentativo di accesso ad un offset inferiore a LIMIT+1
provoca una violazione della protezione.
ED=0 è l'impostazione tipica per i segmenti di dati e per i segmenti
di stack di dimensione fissa; ED=1 è utile quando abbiamo la necessità
di modificare la dimensione di un segmento in fase di esecuzione, tramite la
riduzione del valore LIMIT.
La Figura 1.14 mostra in (a) un segmento di dati expand up
(ED=0) e in (b) un segmento di stack expand down
(ED=1).
Come si può notare, in un segmento con ED=1 l'offset massimo è
FFFFh, mentre LIMIT+1 indica l'offset minimo, al di sotto del quale
non si può andare.
Riassumendo, nell'ACCESS BYTE i campi S, E e ED
definiscono in modo univoco il tipo di segmento di programma, mentre i campi
P e A sono legati alla gestione della memoria virtuale; tutti gli
altri campi vengono utilizzati dalla CPU per gestire i meccanismi di
protezione.
1.2.5 I descrittori speciali
A questo punto sorge una domanda: dove vanno collocate le tabelle dei
descrittori e come vengono trovate dalla CPU?
Una tabella dei descrittori non è altro che un blocco di dati contenente sino
a 8192 descrittori da 8 byte ciascuno; la dimensione massima di
tale tabella è quindi:
8192 * 8 = 213 * 23 = 213+3 = 216 = 65536 byte = 64 KiB
Come per i segmenti di programma, anche le tabelle dei descrittori quindi vengono
collocate in memoria, per cui a loro volta necessitano di un descrittore; abbiamo
a che fare allora con il "descrittore di una tabella dei descrittori".
Consideriamo il caso generale di un SO multitasking con 10 task in
esecuzione; abbiamo bisogno quindi di una GDT (condivisa da tutti i task)
e di 10 LDT (una per ogni task).
La GDT in memoria è unica e le sue caratteristiche vengono definite da un
apposito descrittore; a tale proposito, la 80286 ci mette a disposizione
un registro denominato GDTR (Global Descriptor Table Register). Il
descrittore della GDT viene caricato nel GDTR tramite l'istruzione
LGDT (Load Global Descriptor Table Register).
Abbiamo poi 10 task in esecuzione, ciascuno dei quali ha la propria
LDT; ci occorrono quindi 10 descrittori, uno per ogni LDT.
I descrittori delle varie LDT vanno collocati tassativamente nella
GDT.
In un SO multitasking, i vari task in realtà non sono tutti in esecuzione
contemporaneamente; la tecnica che si utilizza consiste nell'assegnare a ciascuno
di essi un piccolo intervallo di tempo di esecuzione, terminato il quale si passa
al task successivo (secondo un ordine prestabilito). Il passaggio da un task
all'altro rappresenta il cosiddetto task switch (commutazione di task).
In sostanza, i vari task vengono fatti girare a rotazione, uno alla volta, in modo
da dare all'utente l'impressione che stiano girando tutti contemporaneamente.
Dalle considerazioni appena esposte risulta che, in un dato istante, solo un task
è in esecuzione; il descrittore della relativa LDT viene caricato in un
registro denominato LDTR (Local Descriptor Table Register), tramite
l'istruzione LLDT (Load Local Descriptor Table Register). I vari
task vengono eseguiti a rotazione; quando l'esecuzione passa da un task all'altro,
il descrittore della LDT del nuovo task viene caricato nel LDTR.
Esiste naturalmente anche l'IDTR (Interrupt Descriptor Table
Register), dove viene caricato il descrittore della IDT; a tale
proposito, è disponibile l'apposita istruzione LIDT (Load Interrupt
Descriptor Table Register).
Tutti questi aspetti vengono illustrati in dettaglio nel seguito del capitolo e
nei capitoli successivi.
I descrittori delle tabelle dei descrittori dei segmenti di programma fanno parte
della categoria dei descrittori speciali e, come spiegato prima, vanno collocati
tutti nella GDT; nel prossimo capitolo vedremo altri tipi di descrittori
speciali, alcuni dei quali sono destinati anche alla LDT e alla IDT.
Un descrittore speciale è individuato dal bit S che deve valere 0 e
la sua struttura è illustrata in Figura 1.15.
Come si può notare, l'unica differenza rispetto alla Figura 1.11 è nel campo
TYPE, che in questo caso è formato da 4 bit; con 4 bit
possiamo definire sino a 16 tipi differenti di descrittori speciali. La
Figura 1.16 illustra tutti i dettagli.
La Figura 1.17 mostra un esempio che riassume i concetti appena esposti.
Questo schema mostra la RAM dove, per maggiore chiarezza, le varie aree
che ci interessano sono state separate in tre parti.
Sulla sinistra abbiamo la GDT che contiene vari descrittori, tra i quali
quelli delle LDT; viene evidenziato uno di essi, che punta ad una
LDT relativa ad un task.
La LDT al centro contiene i descrittori di vari segmenti; viene evidenziato
uno di essi che punta ad un segmento.
Sulla destra vediamo il segmento in questione, con il suo Base Address e
il suo limite superiore.
1.2.6 I registri della 80286 per la modalità protetta
La Figura 1.17 rende ancora più evidente la necessità dei prolungamenti nascosti
per i registri di segmento, di cui si è parlato in precedenza; vediamo infatti
quali passi deve compiere la CPU in presenza di una istruzione come:
mov ax, ds:[bx]
Supponiamo che i registri GDTR e LDTR siano stati già caricati con
i descrittori della GDT e della LDT relativa al task in esecuzione;
a questo punto la CPU procede in questo modo:
- nel registro DS si ha TI=1 che indica una LDT
- dal registro LDTR si ricava il campo BASE della LDT
- dal registro DS si ricava il campo INDEX del
descrittore di segmento nella LDT
- dal descrittore di segmento nella LDT si ricava il
campo BASE del segmento di programma
- sommando il campo BASE all'offset presente in BX
si ricava l'indirizzo a 24 bit a cui accedere in RAM
- da tale indirizzo si legge una WORD e la si trasferisce in AX
Come si può notare, si tratta di un procedimento esageratamente lungo che si
ripercuote piuttosto negativamente sulle prestazioni della CPU; proprio
per ovviare a questo inconveniente, i registri di segmento della 80286
assumono in realtà la struttura mostrata in Figura 1.18.
Ogni registro di segmento è formato da 64 bit, con i soli 16 bit più
significativi visibili ai programmi; tali 16 bit rappresentano il campo
SELECTOR e coincidono con i registri di segmento mostrati in Figura 1.1 e
il cui contenuto è quello di Figura 1.8.
I restanti 48 bit sono invisibili ai programmi e il loro utilizzo è
riservato alla CPU; ovviamente, tali 48 bit sono destinati a
contenere i campi LIMIT, BASE e ACCESS letti dal descrittore
associato al registro di segmento.
Come abbiamo visto nel tutorial Assembly Base, le istruzioni che modificano
direttamente i registri di segmento sono del tipo LDS, LES, LSS,
MOV, POP, etc; si parla in questo caso di "modifica diretta".
Queste istruzioni usano i registri di segmento DS, ES e SS
come operando destinazione e in modalità protetta modificano esclusivamente la parte
visibile.
Le istruzioni come CALL e JMP intersegmento provocano la modifica
implicita del registro CS; si parla allora di "modifica implicita".
Anche in questo caso, in modalità protetta viene interessata solo la parte visibile
di CS.
Ogni volta che usiamo queste istruzioni in modalità protetta, modifichiamo quindi
solo la parte visibile dei registri di segmento; in tal caso, la CPU
accede automaticamente al relativo descrittore (secondo il procedimento descritto
nel precedente esempio), legge i campi LIMIT, BASE e ACCESS
e li salva nella parte invisibile del registro stesso.
Grazie al meccanismo appena descritto, il lavoro che la CPU deve svolgere
per gli indirizzamenti si semplifica enormemente, con conseguente notevole
miglioramento delle prestazioni; nel caso dell'esempio illustrato in precedenza,
i passi da compiere si riducono ai seguenti:
- dalla parte invisibile del registro DS si ricava il campo BASE
del segmento di programma
- sommando il campo BASE all'offset presente in BX
si ricava l'indirizzo a 24 bit a cui accedere in RAM
- da tale indirizzo si legge una WORD e la si trasferisce in AX
Finché il contenuto di DS rimane invariato, tutte le operazioni di
indirizzamento si svolgono con estrema rapidità, nel modo appena descritto;
in situazioni del genere, è vivamente raccomandato l'uso degli indirizzi
NEAR, costituiti dalla sola componente Offset, in modo da
ottenere codice più compatto ed efficiente (ricordiamo che anche in modalità
protetta, restano valide le associazioni predefinite tra registri di segmento
e registri puntatori).
La Figura 1.19 mostra alcuni dei registri di sistema della 80286 (gli
altri registri vengono illustrati nel capitolo successivo).
Il registro GDTR è destinato a contenere il descrittore della GDT
ed è costituito dai due campi LIMIT e BASE. Il GDTR risulta
inaccessibile alle istruzioni ordinarie; deve essere gestito esclusivamente con
le due istruzioni apposite LGDT (Load Global Descriptor Table
Register e SGDT (Store Global Descriptor Table Register).
Il GDTR viene caricato con LGDT in fase di inizializzazione della
modalità protetta e, in generale, rimane inalterato durante tutta la fase di
esecuzione; eventuali modifiche durante la fase di esecuzione sono vivamente
sconsigliate, ma se necessario possono essere effettuate (dal SO) al più
alto livello di privilegio.
Considerazioni perfettamente analoghe valgono anche per il registro IDTR;
tale registro viene caricato con LIDT (Load Interrupt Descriptor Table
Register) in fase di inizializzazione della modalità protetta e rimane
inalterato per tutta la fase di esecuzione. Se si ha la necessità di leggere il
contenuto dell'IDTR, si deve usare l'istruzione SIDT (Store
Interrupt Descriptor Table Register).
Il registro LDTR è destinato a contenere il descrittore della LDT
relativa al task correntemente in esecuzione; è costituito da una parte visibile
e una invisibile. Nei 16 bit della parte visibile bisogna caricare il campo
SELECTOR che referenzia il descrittore della LDT nella GDT;
a tale proposito, si deve utilizzare l'apposita istruzione LLDT (Load
Local Descriptor Table Register). Se si vuole leggere il contenuto del
LDTR, bisogna usare l'istruzione SLDT (Store Local Descriptor
Table Register); si tenga presente che entrambe le istruzioni LLDT e
SLDT operano esclusivamente sulla parte visibile del LDTR, mentre
la parte invisibile è del tutto inaccessibile.
Il campo TI caricato nella parte visibile del LDTR deve valere
0 (TI=0) in quanto si riferisce ad un descrittore presente nella
GDT; il campo INDEX a sua volta specifica la posizione del
descrittore della LDT nella GDT.
Ogni volta che carichiamo un nuovo selettore nella parte visibile del LDTR,
la CPU automaticamente risale ai campi LIMIT e BASE della
relativa LDT e li salva nella parte invisibile del registro stesso. In un
SO multitasking, in seguito ad ogni task switch cambia anche la
LDT; anche in questo caso, la 80286 provvede ad aggiornare
automaticamente il registro LDTR, per cui non è necessaria una modifica
manuale con l'istruzione LLDT.
I numerosi concetti appena esposti possono generare una certa confusione; vale la
pena allora fare un breve riassunto.
In fase di inizializzazione della modalità protetta, abbiamo bisogno di una
GDT contenente tutti i descrittori "di sistema", tra i quali quelli
relativi alle LDT dei vari task in esecuzione; il descrittore (BASE
e LIMIT) della GDT va caricato nel GDTR tramite l'istruzione
LGDT. Considerazioni del tutto analoghe per la IDT.
Ci serve poi la LDT relativa al primo task da eseguire; il campo
SELECTOR che punta al descrittore della LDT nella GDT va
caricato nella parte visibile del LDTR tramite l'istruzione LLDT.
Un'ultima considerazione riguarda il fatto che, come si può notare, le parti
invisibili di tutti i registri appena descritti, costituiscono una vera e propria
cache della CPU; grazie alle informazioni contenute in tale "memoria
ad alte prestazioni", le operazioni di indirizzamento possono essere gestite via
hardware con notevole efficienza.
Bibliografia
Intel 80286 Hardware Reference Manual
(disponibile su Internet)
Intel 80286 Programmer's Reference Manual
(disponibile su Internet)