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: (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: 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: 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: 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)