Modalità Protetta
Capitolo 4: Modalità Protetta 80386
Nei precedenti capitoli abbiamo visto che la segmentazione a 64 KiB e il
modo contorto di saltare dalla modalità protetta a quella reale, sono le
principali cause dello scarso interesse suscitato dalla 80286 tra gli
sviluppatori di SO per PC. La segmentazione a 64 KiB
vanifica la possibilità di accedere in modo lineare ai 16 MiB di memoria
fisica indirizzabili in modalità protetta. La necessità di un reset della CPU
per tornare in modalità reale, rende altamente inefficienti i SO che, pur
essendo destinati alla modalità protetta, devono anche garantire la possibilità di
eseguire i programmi scritti per la 8086.
La svolta tanto attesa dagli sviluppatori arriva nel 1986 quando, dopo un
anno di sperimentazione, viene immessa sul mercato la CPU 80386; con il suo
Address Bus a 32 bit, questo microprocessore è in grado di indirizzare
una quantità di memoria fisica che può raggiungere
232 = 4294967296 byte = 4 GiB
La vera novità però sta nel fatto che la 80386 dispone di registri puntatori
anch'essi a 32 bit; ciò rende possibile l'accesso lineare a 4 GiB di
RAM attraverso offset che spaziano da 00000000h a FFFFFFFFh!
4.1 Caratteristiche generali della CPU 80386
La 80386 è stata progettata in modo da garantire la compatibilità con le
CPU di classe inferiore; in questo caso, oltre alla modalità reale della
8086, tale compatibilità deve necessariamente riguardare anche la modalità
protetta della 80286.
4.1.1 Organizzazione della memoria
Per garantire la compatibilità verso il basso, un primo aspetto fondamentale
riguarda il modo di rappresentare la RAM. Come accade con i modelli
precedenti di CPU, anche la 80386 fa in modo che ai programmi la
memoria centrale "grezza" (o memoria fisica) appaia come un vettore di
BYTE, indicizzati da 0 a 232-1; l'indice di ogni
BYTE ne rappresenta anche l'indirizzo fisico. In Figura 4.1 vediamo
ad esempio il BYTE in posizione 7 a cui corrisponde quindi
l'indirizzo fisico 7.
Come è stato spiegato nei precedenti capitoli, le CPU che supportano la
modalità protetta non permettono però ai programmi di accedere in modo diretto alla
memoria fisica; viene fatto ricorso invece ad un livello di astrazione denominato
memoria virtuale.
La memoria virtuale viene simulata dalla CPU sfruttando, oltre alla memoria
fisica disponibile, anche altri supporti, come gli hard disk; in questo modo si
ottiene uno spazio di indirizzamento lineare notevolmente più grande di quello
offerto dalla RAM realmente installata sul computer. Nel Capitolo 1 abbiamo
visto, ad esempio, che la 80286 supporta sino a 16 MiB di memoria
fisica, ma ben 1 GiB di memoria virtuale.
I programmi accedono alla memoria virtuale attraverso un metodo basato sul concetto
di indirizzo logico; un indirizzo logico fa riferimento ad una locazione di
memoria che può trovarsi nella RAM o su disco. Se la locazione di memoria si
trova nella RAM, la CPU converte l'indirizzo logico in un indirizzo
lineare, che rappresenta anche l'indirizzo fisico della locazione stessa;
in caso contrario, la CPU genera un'eccezione che deve essere gestita nel
modo più opportuno dal SO.
La Figura 4.2 mostra il caso di un computer che lavora con indirizzi logici formati
da una generica coppia Base:Offset; in assenza di eccezioni, un indirizzo
logico viene convertito in un indirizzo lineare mediante la somma Base+Offset.
In questo esempio vediamo che l'indirizzo logico fa riferimento ad un'area della
memoria virtuale che risiede nella RAM; di conseguenza, la CPU associa
il corrispondente indirizzo lineare ad un indirizzo fisico.
Quando una CPU della famiglia 80x86 opera in modalità reale, sappiamo
che l'hardware rende visibile solo 1 MiB di RAM; l'accesso fisico alla
memoria avviene in tal caso tramite indirizzi logici del tipo Seg:Offset da
16+16 bit, dove Seg indica uno tra i possibili 216
paragrafi da 16 byte da cui partono i vari segmenti, mentre Offset è
uno spiazzamento compreso tra 0 e 216-1, all'interno del
segmento di appartenenza.
Ogni indirizzo logico viene convertito dalla CPU in un indirizzo lineare a
20 bit secondo la formula:
Seg * 16 + Offset
L'indirizzo lineare che si ottiene corrisponde sempre ad un indirizzo fisico
realmente presente nella RAM da 1 MiB, da cui il nome "modalità
reale"; in questo caso, la memoria virtuale coincide con la memoria fisica.
Come vedremo nel seguito, la 80386 introduce una nuova modalità operativa
denominata Virtual 8086 Mode (VM86); grazie a questa funzionalità,
un sistema multitasking in modalità protetta può far girare uno o più programmi
destinati alla modalità reale, riservando a ciascuno di essi uno spazio di
indirizzamento virtuale da 1 MiB.
Nella modalità protetta 80x86, l'accesso fisico alla memoria avviene tramite
indirizzi logici del tipo Selector:Offset; il campo Selector, come
sappiamo, punta ad un descrittore di segmento che contiene il relativo Base
Address, mentre Offset è uno spiazzamento all'interno del segmento stesso.
In assenza di eccezioni, un indirizzo logico viene convertito dalla CPU in
un indirizzo lineare (a 24 bit per la 80286 e a 32 bit per la
80386) secondo la formula:
Base Address + Offset
L'indirizzo lineare che si ottiene viene poi associato dalla CPU ad un
indirizzo fisico nella RAM.
Dalle considerazioni appena esposte risulta che in modalità protetta i programmi
vedono il livello di astrazione della memoria virtuale come un insieme di segmenti;
l'accesso ad ogni segmento avviene tramite un offset calcolato rispetto al Base
Address del segmento stesso.
Come si vede in Figura 4.2, la memoria virtuale è molto maggiore di quella reale, per
cui una parte di essa, se necessario, può essere simulata su un dispositivo esterno,
come un disco fisso; se un indirizzo specificato da un programma in esecuzione fa
riferimento ad un blocco di memoria spostato su disco, la CPU interrompe il
programma stesso e genera una "eccezione di non presenza". Il SO può
intercettare l'eccezione di non presenza e ricaricare in memoria il blocco richiesto;
in questo modo, il programma precedentemente interrotto può essere riavviato.
La 80386 fornisce una serie di funzionalità che permettono ai SO di
organizzare la gestione della memoria virtuale nel modo più opportuno; in particolare,
per venire incontro alle esigenze degli sviluppatori, vengono resi disponibili via
hardware due modelli di memoria:
- segmented memory model
- flat memory model
Il modello segmented è del tutto analogo a quello visto nei precedenti
capitoli per la 80286 e prevede che codice, dati e stack di ogni task vengano
distribuiti in distinti segmenti di programma a cui si accede tramite indirizzi logici
Selector:Offset. Il campo Selector punta ad un descrittore di segmento
(nella GDT o LDT) che contiene il Base Address del segmento a
cui vogliamo accedere; il campo Offset è uno spiazzamento all'interno del
segmento stesso e viene sommato al Base Address per ottenere l'indirizzo
lineare. La novità sta nel fatto che stavolta la componente Offset ha
un'ampiezza di 32 bit, per cui ogni segmento può raggiungere virtualmente la
dimensione di 4 GiB!
La conseguenza di tutto ciò è che la memoria virtuale indirizzabile dalla 80386
è enormemente maggiore dell'unico GiB gestibile con la 80286. Come sappiamo,
ogni task ha a disposizione la GDT e la propria LDT, ciascuna delle
quali permette di gestire sino a 213 descrittori di segmento; ogni
segmento può avere un'ampiezza massima di 232 byte, per cui la
memoria virtuale totale indirizzabile dalla 80386 raggiunge il colossale valore
di ben:
2 * 213 * 232 = 21+13+32 = 246 byte = 64 TiB
(1 terabyte = 1024 gigabyte)!
Con il modello flat, nel caso più semplice un programma vede la RAM
come un unico gigantesco segmento da 4 GiB; all'interno di tale segmento i
BYTE risultano indicizzati in modo lineare (flat) da 0 a
232-1.
Si tratta in realtà di un modello di memoria ugualmente segmentato; la differenza
fondamentale sta nel fatto che i vari segmenti di programma condividono tutti lo
stesso Base Address, per cui risultano sovrapposti tra loro.
Questo modello di memoria viene gestito in modo estremamente efficiente in quanto i
vari registri di segmento, una volta inizializzati, non vengono più modificati per
tutta la fase di esecuzione; di conseguenza, gli indirizzamenti sono tutti di tipo
NEAR. Il codice macchina che ne deriva risulta più compatto e più rapido da
eseguire, grazie anche al fatto che la CPU non deve effettuare i controlli
di protezione necessari ogni volta che si carica un nuovo selettore in un registro
di segmento.
Sia il modello segmentato sia quello flat, possono usufruire di un secondo livello
di astrazione rappresentato dalla paginazione della memoria; in questo modo, come
viene spiegato nel seguito, anche lo spazio di indirizzamento lineare da 4
GiB della 80386 viene virtualizzato.
4.1.2 Paginazione della memoria
Il modello segmentato della 80386 in determinate circostanze può presentare
un serio problema. Nel caso della 80286, la memoria risulta segmentata a
64 KiB e ciò permette di suddividere un programma in tanti piccoli moduli,
ciascuno dei quali viene normalmente inserito in un distinto segmento. La struttura
modulare così ottenuta si rivela piuttosto vantaggiosa nel momento in cui si ha la
necessità di spostare segmenti dalla memoria al disco fisso o viceversa (swapping);
ciò è dovuto proprio al fatto che la dimensione massima di un segmento non supera i
64 KiB.
Con la 80386 la situazione è ben diversa in quanto un segmento di programma
può raggiungere virtualmente i 4 GiB; in tali condizioni, lo scambio di
grossi segmenti tra memoria e disco creerebbe seri problemi di efficienza.
Per ovviare a questo inconveniente, la 80386 rende disponibile un secondo
livello di astrazione che si inserisce tra la memoria virtuale e quella fisica
(Figura 4.2) ed è denominato paging (paginazione). Se si attiva la
paginazione, lo spazio di indirizzamento lineare da 4 GiB della CPU
viene suddiviso in tante "pagine", ciascuna delle quali ha una dimensione fissa di
4 KiB; un indirizzo lineare ottenuto da un indirizzo logico, ricade quindi
in una delle pagine disponibili, che può trovarsi nella RAM o su disco.
Senza la paginazione, siamo costretti ad esempio a caricare interamente nella
RAM un grosso segmento contenente dati da elaborare; con la paginazione, il
segmento stesso può essere suddiviso in tante pagine e solo quelle necessarie
vengono caricate nella RAM.
La situazione che si viene a creare rende molto efficiente lo scambio di blocchi
di programma tra memoria e disco; infatti, tutto si riduce alla "movimentazione"
di piccolissime pagine da 4 KiB ciascuna.
Chiaramente, i due livelli di astrazione appena illustrati (memoria virtuale e
paginazione) comportano ripercussioni negative sulle prestazioni; gli svantaggi
però vengono compensati abbondantemente dai vantaggi nel momento in cui si ha la
necessità di ricorrere in modo massiccio allo swapping delle pagine di memoria.
Bisogna tenere presente, infatti, che la paginazione è stata pensata per
situazioni particolari; ad esempio, programmi che devono gestire strutture dati
di enormi dimensioni, anche di diversi terabyte.
Se la paginazione è disattivata, un indirizzo lineare valido viene associato
direttamente ad un indirizzo fisico.
4.1.3 Tipi di dati supportati
Come si vede in Figura 4.3, la 80386 può maneggiare direttamente blocchi
di dati da 8, 16 e 32 bit; tali blocchi vengono denominati,
rispettivamente, BYTE, WORD e DWORD (Double Word).
In ciascuno di questi blocchi, il bit in posizione 0 è il "bit meno
significativo" (Least Significant Bit o LSB), mentre il bit più a
sinistra è il "bit più significativo" (Most Significant Bit o MSB).
La convenzione Little Endian seguita anche dalla 80386, prevede che
un dato di tipo WORD o DWORD venga disposto in memoria con il suo
BYTE meno significativo che occupa l'indirizzo più basso; tale indirizzo è
anche quello del dato stesso. In Figura 4.4 vediamo il caso di una DWORD che
occupa gli offset 8, 9, 10, 11 e contiene il valore
3DF18C1Ah; si ha quindi 1Ah all'offset 8, 8Ch al
9, F1h al 10 e 3Dh all'11. L'indirizzo della
DWORD è l'offset 8 (rispetto al Base Address del segmento di
appartenenza).
Notiamo ancora una volta che la rappresentazione del vettore di memoria con gli
indirizzi crescenti da destra verso sinistra, è molto vantaggiosa in quanto i
dati di tipo WORD e DWORD appaiono orientati nel modo naturale,
con il peso delle cifre che cresce da destra verso sinistra.
Ciascuno dei blocchi di dati di Figura 4.3 può essere disposto in memoria ad un
indirizzo qualunque; la 80386 traduce tale indirizzo in un numero adeguato
di accessi (cicli di memoria) in lettura o scrittura. Grazie alla possibilità di
accedere a gruppi di 1, 2, 3 o 4 byte per volta, la
80386 limita a 2 il numero massimo di cicli necessario per completare
una operazione di lettura o scrittura in memoria; questo aspetto è stato già
illustrato in dettaglio nel Capitolo 8 del tutorial Assembly Base, da cui è
tratta la Figura 4.5.
La Figura 4.5 si riferisce alla 80386 modello DX, che dispone di un
Data Bus interno e esterno a 32 bit; il modello SX è una
versione più economica con Data Bus interno a 32 bit e esterno a
16 bit.
Come si può notare, la memoria centrale viene organizzata in quaterne di celle; ogni
cella occupa 1 byte e ogni quaterna parte sempre da un indirizzo multiplo
intero di 4. Le 32 linee (da D0 a D31) del Data
Bus della 80386 DX possono posizionarsi esclusivamente su una qualunque
di queste quaterne; non è possibile quindi avere il Data Bus posizionato a
cavallo tra due quaterne (ad esempio, sulle celle C3, C4, C5
e C6).
La conseguenza di tutto ciò è che, se vogliamo spingere al massimo la velocità di
accesso in memoria, dobbiamo provvedere ad allineare adeguatamente i blocchi di dati
di Figura 4.3.
I dati di tipo BYTE non richiedono alcun tipo di allineamento; la 80386,
infatti, può accedere ad un BYTE in memoria sfruttando uno qualunque dei
4 gruppi di 8 linee del suo Data Bus. In riferimento alla
Figura 4.5, per accedere ad esempio al BYTE contenuto nella cella C2,
la CPU usa le linee da D16 a D23.
Un dato di tipo WORD richiede un solo accesso in memoria, purché si trovi
interamente contenuto in una quaterna di celle. In riferimento alla Figura 4.5, per
accedere ad esempio alla WORD contenuta nelle celle C2 e C3,
la CPU usa le linee da D16 a D31; se però la WORD si
trova nelle celle C3 e C4, sono necessari due accessi in memoria in
quanto il Data Bus deve essere prima posizionato sulla prima quaterna e
successivamente sulla seconda.
Un modo semplice per evitare problemi del genere consiste nel disporre i dati di
tipo WORD ad indirizzi multipli interi di 2 (si parla allora di
"allineamento alla WORD"); in questo modo siamo sicuri che i dati stessi si
troveranno sempre all'interno di una quaterna.
Un dato di tipo DWORD richiede un solo accesso in memoria, purché si trovi
interamente contenuto in una quaterna di celle. In riferimento alla Figura 4.5, per
accedere ad esempio alla DWORD contenuta nelle celle C0, C1,
C2 e C3, la CPU usa le linee da D0 a D31; se
però la DWORD si trova a cavallo tra due quaterne, sono necessari due accessi
in memoria in quanto, come abbiamo appena visto, il Data Bus deve essere
spostato da una quaterna all'altra.
Un modo semplice per evitare problemi del genere consiste nel disporre i dati di
tipo DWORD ad indirizzi multipli interi di 4 (si parla allora di
"allineamento alla DWORD"); in questo modo siamo sicuri che i dati stessi
si troveranno sempre all'interno di una quaterna.
I requisiti di allineamento appena illustrati assumono particolare importanza per i
segmenti di stack, i quali con la 80386 possono lavorare solo con dati di tipo
WORD o DWORD.
Per uno stack destinato a dati di tipo WORD è vivamente raccomandato un
allineamento alla WORD; in caso contrario, tutte le operazioni con
PUSH o POP si troverebbero a lavorare con dati posizionati ad
indirizzi dispari e in certi casi anche a cavallo tra due quaterne di celle.
Ancora più grave è il caso di uno stack destinato a dati di tipo DWORD, ma
non allineato alla DWORD; in una situazione del genere, tutti i dati a
32 bit si troverebbero a cavallo tra due quaterne di celle e ogni
operazione con PUSH o POP richiederebbe due accessi in memoria!
I blocchi di dati di Figura 4.3 possono essere utilizzati per contenere svariati
tipi di informazioni, come numeri, stringhe e indirizzi. Nel tutorial Assembly
Base è stato spiegato che una CPU lavora unicamente con due livelli di
tensione elettrica, basso e alto, che possono essere associati ai simboli 0
e 1 del sistema di numerazione binario; la CPU stessa quindi non ha
la più pallida idea di cosa sia un numero intero, un puntatore FAR, una
lettera dell'alfabeto, etc. Sappiamo però che i circuiti logici della CPU
sono concepiti per attribuire un preciso significato a determinate codifiche
binarie; in questo modo è possibile simulare, ad esempio, i numeri interi e le
operazioni matematiche su di essi, come addizioni, sottrazioni, etc.
Ogni CPU quindi è in grado di supportare in modo diretto determinati
tipi di dati; quelli relativi alla 80386 sono illustrati nel seguito.
La Figura 4.6 illustra gli estremi dei numeri interi senza segno (unsigned)
rappresentabili dalla 80386 con i blocchi di dati di Figura 4.3.
In Figura 4.7 vediamo gli estremi dei numeri interi con segno (signed) in
complemento a 2, rappresentabili con i blocchi di dati di Figura 4.3; come
sappiamo, con questo tipo di codifica, il MSB vale 0 per i numeri
positivi (compreso lo zero) e 1 per i numeri negativi.
Dato di tipo BYTE contenente nel nibble meno significativo una cifra
esadecimale compresa tra 0h e 9h; il nibble più significativo deve
valere 0h.
Una sequenza di BYTE di questo tipo permette di codificare numeri in base
10 attraverso numeri esadecimali, con notevoli benefici in termini di
efficienza nei calcoli; ad esempio, il numero 3850 può essere codificato come:
03h 08h 05h 00h
Dato di tipo BYTE contenente in ogni nibble una cifra esadecimale compresa
tra 0h e 9h.
Si tratta di una codifica più compatta del tipo Unpacked BCD; il numero
precedente diventa:
38h 50h
Il "campo di bit" è una sequenza consecutiva e contigua di bit, che può iniziare
da un bit qualunque di un BYTE in memoria; la sua ampiezza massima è pari a
32 bit.
Dato formato da una sequenza di BYTE, WORD o DWORD, disposti
in memoria in modo consecutivo e contiguo; l'ampiezza di una stringa può arrivare
sino a 4 GiB.
Il contenuto di ogni elemento può rappresentare qualsiasi cosa; in particolare, le
stringhe di BYTE sono spesso usate per contenere sequenze di codici
ASCII.
La "stringa di bit" è una sequenza consecutiva e contigua di bit, che può iniziare
da un bit qualunque di un BYTE in memoria; la sua ampiezza massima può
raggiungere 232 bit.
- Tipo NEAR POINTER e FAR POINTER
La 80386 supporta i due tipi di indirizzo logico illustrati in Figura 4.8.
Il NEAR POINTER è un offset a 32 bit all'interno di un segmento e
può essere usato nel modello di memoria flat e in quello segmented;
un puntatore NEAR sfrutta le associazioni predefinite con i registri di
segmento.
Il FAR POINTER è un indirizzo logico a 48 bit formato da una
componente Offset a 32 bit e una componente Selector a
16 bit; è destinato al modello di memoria segmented.
In base ad una nota convenzione adottata dalle CPU della famiglia
80x86, la componente Offset di un puntatore FAR deve
precedere in memoria la componente Selector; posto, ad esempio:
l'istruzione
les ebx, [far_addr]
carica correttamente l'offset 00FAE1A4h in EBX e il selettore
0018h in ES.
4.1.4 Registri della 80386
Un altro aspetto di fondamentale importanza per la compatibilità verso il basso è
quello relativo ai registri interni della CPU; analizziamo innanzi tutto i
registri generali, di segmento, di stato e di istruzione
della 80386.
Come si vede in Figura 4.9, la 80386 fornisce 8 registri generali
a 32 bit, concepiti come prolungamento di quelli a 16 bit delle
CPU 8086 e 80286.
I registri generali vengono così chiamati in quanto possono essere impiegati per
svolgere svariati compiti. Spesso ricoprono il ruolo di operandi delle istruzioni
aritmetiche e logiche. Un altro uso frequente consiste nella gestione delle varie
forme di indirizzamento; come viene illustrato nel seguito, una importante novità
introdotta dalla 80386 è che tutti i registri generali possono essere
impiegati per rappresentare indirizzi.
Alcune istruzioni utilizzano registri predefiniti per svolgere il proprio compito.
EAX è l'accumulatore e ricopre quindi il ruolo di registro di riferimento
in numerose operazioni. ECX è il registro contatore nei loop. ESI e
EDI sono i registri sorgente e destinazione per le istruzioni che operano
sulle stringhe. ESP e EBP sono destinati principalmente alla gestione
dei segmenti di stack.
La Figura 4.9 mostra che la WORD meno significativa di ogni registro a
32 bit coincide con il corrispondente registro a 16 bit delle
8086 e 80286; a loro volta, AX, BX, CX e
DX sono scomponibili nei due BYTE identificati da H (high)
e L (low).
Tutte le istruzioni che utilizzano registri, locazioni di memoria e valori
immediati a 8 o 16 bit, producono gli identici codici macchina
delle 8086 e 80286; ciò permette alla 80386 di eseguire
senza alcuna modifica anche i programmi a 16 bit.
La 80386 dispone di 6 registri di segmento a 16 bit, la cui
parte "visibile" è illustrata in Figura 4.10; il loro scopo è quello di contenere
in ogni istante i selettori dei segmenti di programma correntemente in uso dal
task in esecuzione.
I registri di segmento vengono largamente sfruttati nel modello di memoria
segmented, dove ogni programma risulta distribuito in blocchi di codice,
dati e stack. Un programma può definire numerosi segmenti, ma in un dato istante
solo alcuni di essi risultano in uso; i segmenti correntemente in uso vengono
individuati dalla CPU proprio attraverso i registri di Figura 4.10.
Il selettore presente nel registro CS indica quale segmento di codice è
attualmente in esecuzione. L'offset contenuto nell'Instruction Pointer
(EIP), illustrato più avanti, viene sommato automaticamente dalla CPU
al Base Address del segmento associato a CS, in modo da ottenere
l'indirizzo CS:EIP della prossima istruzione da eseguire.
La gestione di CS è riservata rigorosamente alla CPU; il contenuto di
tale registro viene modificato implicitamente dalle istruzioni per il trasferimento
del controllo (CALL, JMP, INT, RET, etc).
Il selettore presente nel registro SS indica quale segmento di stack è
attualmente attivo per il task in esecuzione. Nei precedenti capitoli abbiamo visto
che un task può avere a disposizione uno o più segmenti di stack; in particolare,
un task che richiede l'esecuzione di codice più privilegiato, deve predisporre un
distinto segmento di stack con quello stesso livello di privilegio. In un determinato
momento solo uno stack è attivo; la CPU ricava questa informazione dal
selettore correntemente caricato in SS.
Il registro ESP di Figura 4.9 indica in ogni istante la cima dello stack
attivo (TOS); tale registro viene usato implicitamente dalle istruzioni
come PUSH, POP, PUSHA, POPA, PUSHAD, etc.
Il registro EBP di Figura 4.9 è a disposizione dei programmi per l'accesso
allo stack attivo; un suo uso molto frequente è quello di indirizzare i parametri
e le variabili locali delle procedure.
Tutti gli indirizzamenti NEAR che usano come base ESP o EBP,
vengono associati automaticamente dalla CPU a SS.
I restanti registri di Figura 4.10 sono destinati alla gestione dei segmenti di dati;
come si può notare, la 80386 introduce i nuovi registri FS e GS,
che aggiunti a DS e ES permettono ad un task di controllare in ogni
momento sino a 4 segmenti di dati distinti.
DS (Data Segment) è il registro predefinito per i segmenti di dati.
Tutti gli indirizzamenti NEAR che usano come base EAX, EBX,
ECX, EDX, ESI o EDI, vengono associati automaticamente
dalla CPU a DS; come sappiamo, possiamo aggirare queste regole
ricorrendo al segment override.
La Figura 4.11 illustra la struttura dell'Instruction Pointer (EIP) e
del Flags Register (EFLAGS) della 80386; entrambi questi
registri sono le estensioni a 32 bit dei corrispondenti IP e
FLAGS.
In ogni istante, EIP contiene l'offset della prossima istruzione da eseguire,
all'interno del segmento di codice puntato da CS; tale registro non è
direttamente accessibile ai programmi in quanto il suo uso è riservato rigorosamente
alla CPU.
Quando la 80386 esegue i programmi scritti per la 8086 o 80286,
i 16 bit meno significativi di EIP equivalgono al registro IP
di tali CPU; è fondamentale in questo caso che qualunque offset a 32
bit caricato in EIP abbia i 16 bit più significativi posti a 0.
Il registro EFLAGS in ogni istante contiene lo stato della 80386 e
fornisce informazioni dettagliate sul risultato prodotto da varie operazioni; la
Figura 4.12 mostra il significato dei vari campi.
I bit colorati in grigio sono riservati e non devono essere modificati. I campi
indicati con SY sono flags di sistema, quelli con ST sono flags di
stato, mentre quelli con C sono flags di controllo.
I 16 bit meno significativi di EFLAGS hanno l'identico contenuto
del registro FLAGS della 80286 (che, a sua volta, include tutti i
campi destinati alla 8086); in questo modo, viene garantita ancora una
volta la compatibilità verso il basso.
I 16 bit più significativi di EFLAGS contengono i due nuovi campi
VM e RF, che saranno analizzati in dettaglio nei capitoli successivi.
Per il momento è sufficiente sapere che RF, se posto a 1, permette
il mascheramento selettivo di alcune eccezioni in fase di debugging; il flag
VM, se posto a 1, fa in modo che la 80386 passi in
modalità VM86.
4.1.5 Struttura delle istruzioni per la 80386
Quando la 80386 sfrutta pienamente la sua architettura a 32 bit,
rende disponibili una serie di nuove funzionalità che comprendono, tra l'altro,
operandi e indirizzamenti a 32 bit; tale modalità operativa viene definita
modo 32 bit. Quando invece la 80386 opera in modalità di compatibilità
con i programmi a 16 bit per le 8086 e 80286, si utilizza la
definizione di modo 16 bit.
Per garantire la compatibilità con i programmi a 16 bit, le istruzioni
della 80386 utilizzano un sistema di codifica che è una estensione di
quello delle 8086 e 80286; la Figura 4.13 illustra tutti i dettagli.
Rispetto alla struttura delle istruzioni per le 8086 e 80286, si
nota la presenza di due nuovi prefissi opzionali, Address Size Prefix e
Operand Size Prefix, che specificano la dimensione, 16 o 32
bit, degli indirizzamenti e degli operandi; come si può facilmente intuire,
questi due prefissi svolgono un ruolo fondamentale nella compatibilità verso il
basso della 80386.
L'effetto prodotto da questi due prefissi nel modo 16 bit è stato già
illustrato nel Capitolo 11 del tutorial Assembly Base; si deve prestare
però molta attenzione al fatto che il loro significato nel modo 32 bit è
diametralmente opposto!
Nel modo 16 bit, la dimensione di default per gli operandi e gli indirizzi
è 16 bit; si ha pertanto che:
- in assenza dell'Operand Size Prefix, gli operandi dell'istruzione
da elaborare hanno un'ampiezza di 16 bit (default)
- in presenza dell'Operand Size Prefix, gli operandi dell'istruzione
da elaborare hanno un'ampiezza di 32 bit
- in assenza dell'Address Size Prefix, l'accesso agli operandi di
tipo Mem avviene attraverso offset a 16 bit (default)
- in presenza dell'Address Size Prefix, l'accesso agli operandi di
tipo Mem avviene attraverso offset a 32 bit
Nel modo 32 bit, la dimensione di default per gli operandi e gli indirizzi
è 32 bit; si ha pertanto che:
- in assenza dell'Operand Size Prefix, gli operandi dell'istruzione
da elaborare hanno un'ampiezza di 32 bit (default)
- in presenza dell'Operand Size Prefix, gli operandi dell'istruzione
da elaborare hanno un'ampiezza di 16 bit
- in assenza dell'Address Size Prefix, l'accesso agli operandi di
tipo Mem avviene attraverso offset a 32 bit (default)
- in presenza dell'Address Size Prefix, l'accesso agli operandi di
tipo Mem avviene attraverso offset a 16 bit
Sia nel modo 16 bit sia nel modo 32 bit, si può presentare il caso
di istruzioni che usano operandi a 8 bit; come viene illustrato più avanti
e come abbiamo già visto nel Capitolo 11 del tutorial Assembly Base, ferme
restando le regole appena illustrate, tale caso viene gestito ponendo a 0
il bit w del campo Opcode di Figura 4.13.
La Figura 4.14 illustra l'elenco completo dei codici macchina (in binario e in
esadecimale) di tutti i prefissi disponibili.
Se si eccettuano i casi dei due nuovi prefissi e dei registri di segmento FS
e GS, tutti i restanti codici macchina risultano identici a quelli delle
8086 e 80286.
Tornando alla Figura 4.13, vediamo che il campo Opcode (l'unico sempre
presente) può occupare 1 o 2 byte; questo ampliamento è necessario in
quanto l'unico byte del campo Opcode delle 8086 e 80286 non è
più sufficiente per codificare tutte le nuove istruzioni disponibili con la
80386. Chiaramente, tutte le vecchie istruzioni vengono codificate con i soliti
opcode da 1 byte; il secondo byte, invece, viene utilizzato solo per le nuove
istruzioni introdotte dalla 80386.
Il campo Displacement può assumere una ampiezza di 32 bit necessaria
per rappresentare anche gli offset a 32 bit disponibili con la 80386;
osserviamo inoltre che con la 80386, il campo Immediate può contenere
ovviamente anche valori numerici a 32 bit.
Il campo mod_reg_r/m rimane inalterato; naturalmente, anche con la 80386
in certi casi i 3 bit del sottocampo reg si combinano con il campo
Opcode.
Osserviamo infine in Figura 4.13 la presenza di un campo opzionale da 1 byte
chiamato S.I.B.; tale campo viene utilizzato per le nuove forme di indirizzamento
illustrate più avanti.
La Figura 4.15 mostra come vengono codificati i registri generali sulla 80386;
il modo 16 bit e il modo 32 bit si riferiscono ai casi di default
(assenza dell'Operand Size Prefix).
Come si può notare, nel modo 16 bit della 80386 i codici macchina dei
registri sono identici a quelli delle 8086 e 80286. Se l'Operand
Size Prefix è assente, allora w=0 nell'Opcode indica operandi a
8 bit, mentre w=1 indica operandi a 16 bit; se, invece,
l'Operand Size Prefix è presente, allora w=0 indica operandi a 8
bit, mentre w=1 indica operandi a 32 bit.
Nel modo 32 bit la situazione come abbiamo visto si inverte. Se l'Operand
Size Prefix è assente, allora w=0 nell'Opcode indica operandi a
8 bit, mentre w=1 indica operandi a 32 bit; se, invece,
l'Operand Size Prefix è presente, allora w=0 indica operandi a
8 bit, mentre w=1 indica operandi a 16 bit.
Come al solito, se l'operando di riferimento è di tipo Reg, allora il suo
codice a 3 bit ricavato dalla Figura 4.15 viene sistemato nel sottocampo
reg del campo mod_reg_r/m; se anche il secondo operando è di tipo
Reg, allora mod=11b, mentre r/m contiene il codice a 3
bit del registro, ricavato sempre dalla Figura 4.15.
In Figura 4.16 vediamo come vengono codificati i registri di segmento sulla
80386.
I 6 registri di segmento della 80386 vengono codificati con 3
bit, anziché i 2 usati dalle 8086 e 80286 per CS,
DS, ES e SS. I primi 4 codici di Figura 4.16 hanno il
bit più significativo che vale 0 e quindi coincidono con quelli delle
8086 e 80286.
Mettendo assieme i concetti appena esposti, nel modo 16 bit una istruzione come
add bx, ax
produce il codice macchina
00000001b 11000011b = 01h C3h
che è identico a quello delle 8086 e 80286 in quanto 16 bit
è la dimensione di default degli operandi.
Nel caso invece dell'istruzione
add ebx, eax
si ottiene il codice macchina
01100110b 00000001b 11000011b = 66h 01h C3h
che è lo stesso di prima, preceduto però da 66h (Operand Size Prefix)
in quanto gli operandi sono a 32 bit che non è la dimensione di default.
Nel modo 32 bit una istruzione come
add bx, ax
produce il codice macchina
01100110b 00000001b 11000011b = 66h 01h C3h
che è preceduto dall'Operand Size Prefix per indicare che la dimensione
degli operandi è di 16 bit e non quella di default di 32 bit.
Nel caso invece dell'istruzione
add ebx, eax
si ottiene il codice macchina
00000001b 11000011b = 01h C3h
che è lo stesso di prima, senza però l'Operand Size Prefix in quanto
gli operandi sono a 32 bit che è la dimensione di default.
4.1.6 Modalità di indirizzamento della 80386 - Effective Address
Se mod è diverso da 11b nel campo mod_reg_r/m, allora il
secondo operando di una istruzione è di tipo Mem (locazione di memoria)
o Imm (valore immediato); se il secondo operando è di tipo Mem,
bisogna indicare alla CPU il modo per accedere alla corrispondente
locazione di memoria.
Un indirizzo di memoria, come sappiamo, è formato da un selettore di segmento e
da un offset all'interno del segmento stesso; nel caso più generale, un offset
si suddivide nelle seguenti componenti:
La componente BASE deve essere un registro e in genere rappresenta
l'offset iniziale di un blocco di memoria.
La componente INDEX deve essere un registro e in genere rappresenta un
offset (indice) da sommare a BASE per spostarsi all'interno di un blocco
di memoria.
La componente DISPLACEMENT deve essere un valore immediato che viene
trattato come numero intero con segno in complemento a 2; il suo scopo
in genere è quello di rappresentare la posizione fissa di un elemento (ad
esempio, un membro di una struttura), rispetto a INDEX.
In fase di decodifica, la CPU calcola
BASE + INDEX + DISPLACEMENT
e ottiene un cosiddetto Effective Address, che è l'offset assoluto della
locazione di memoria a cui si vuole accedere.
Si tenga presente che un Effective Address non deve necessariamente
comprendere tutte le componenti; sono permesse infatti le seguenti forme:
DISPLACEMENT
BASE
BASE + DISPLACEMENT
BASE + INDEX
BASE + INDEX + DISPLACEMENT
Le 8086 e 80286 rendono disponibili 24 modalità di
indirizzamento differenti per accedere ad una locazione di memoria; tali
modalità sono illustrate dalla Figura 11.8 del Capitolo 11, tutorial Assembly
Base.
La stessa Figura 11.8 mostra che solamente BX e BP possono svolgere
il ruolo di BASE; solamente SI e DI possono svolgere il ruolo
di INDEX. Non è consentita la combinazione [BX+BP] o [BP+BX]
ed è proibito l'uso di SP.
In presenza di BX, il registro di segmento predefinito è DS; in
presenza di BP, il registro di segmento predefinito è SS,
indipendentemente dall'ordine dei registri (ad esempio, [BP+DI] è del tutto
equivalente a [DI+BP]).
Nel modo 16 bit, la 80386 supporta tutte le 24 modalità
di indirizzamento delle 8086 e 80286, con gli identici codici
macchina; anche le regole da applicare per combinare BASE, INDEX
e DISPLACEMENT sono le stesse.
Nel modo 32 bit, la 80386 fornisce ulteriori 21 modalità
di indirizzamento (senza S.I.B. byte), illustrate in Figura 4.17.
Notiamo subito che nel modo 32 bit, qualunque registro generale a
32 bit (ad eccezione di ESP) può essere usato come puntatore.
In presenza di EAX, EBX, ECX, EDX, ESI o
EDI, il registro di segmento predefinito è DS; in presenza di
EBP, il registro di segmento predefinito è SS.
Alcune combinazioni tra mod e r/m, visibili in Figura 4.17, indicano
la presenza del campo S.I.B. nel codice macchina di una istruzione; in tal
caso, si ha a che fare con un Effective Address del tipo Scale Index
Base.
Un indirizzamento di tipo S.I.B., nel caso più generale è formato dalle
seguenti componenti:
- BASE
- INDEX
- SCALE
- DISPLACEMENT
La novità, rispetto agli indirizzamenti delle 8086 e 80286, è
la presenza del campo SCALE (fattore di scala) che permette di
moltiplicare INDEX per 1, 2, 4 o 8; la
CPU ottiene l'Effective Address attraverso il calcolo:
BASE + (INDEX * SCALE) + DISPLACEMENT
La moltiplicazione ovviamente viene tradotta (via hardware) in uno shift a
sinistra di, rispettivamente, 0, 1, 2 o 3
posizioni dei bit di INDEX. In assenza del fattore di scala, si ha
implicitamente SCALE=1.
In Figura 4.17 possiamo notare che la presenza del S.I.B. byte viene
indicata da r/m=100b e mod che può valere 00b, 01b
o 10b; in una situazione del genere, la CPU sa che dopo il campo
mod_reg_r/m è presente il campo S.I.B., il quale assume la
struttura mostrata in Figura 4.18.
Il sottocampo scale è formato da 2 bit (posizioni dalla 6
alla 7) e codifica il fattore di scala; le 4 possibili codifiche
del fattore di scala vengono mostrate in Figura 4.19.
Il sottocampo index di Figura 4.18 occupa 3 bit (posizioni dalla
3 alla 5) e codifica il registro a 32 bit che svolge il
ruolo di INDEX; il sottocampo base di Figura 4.18 occupa 3
bit (posizioni dalla 0 alla 2) e codifica il registro a 32
bit che svolge il ruolo di BASE. Questi codici a 3 bit possono
essere ricavati dalla quarta e quinta colonna della Figura 4.15.
Per ogni valore del sottocampo mod (00b, 01b o 10b)
sono previste 8 differenti modalità di indirizzamento; in totale abbiamo
quindi 3*8=24 modalità che vengono illustrate dalla Figura 4.20. Il
termine "(indice scalato)" indica simbolicamente il prodotto tra un
registro indice e un fattore di scala come, ad esempio, (ESI*8).
Osserviamo subito che questa volta è permessa anche la presenza del registro
puntatore ESP, che però può svolgere esclusivamente il ruolo di registro
base; è proibito quindi scrivere indirizzi del tipo [EAX+ESP+02h].
Dalle considerazioni appena esposte risulta quindi che nel modo 32 bit
possiamo scrivere istruzioni del tipo:
mov ebx, [eax+(edx*4)+000003F8h]
Queste forme di indirizzamento sono molto utili per accedere a strutture dati
complesse; supponiamo, ad esempio, di avere a che fare con un vettore i cui
elementi sono strutture del tipo:
Osserviamo che la struttura ha una dimensione di 8 byte, per cui ci serve
un fattore di scala pari a 8 per spostarci tra i vari elementi. Ponendo
allora in EAX l'offset iniziale del vettore e in EBX l'indice
dell'elemento che ci interessa, se vogliamo caricare in DX il campo
varSt3 (che è in posizione 2 nella struttura) possiamo scrivere:
mov dx, [eax+(ebx*8)+2]
Se i fattori di scala 2, 4 o 8 non sono adeguati alle nostre
esigenze, possiamo servirci del campo DISPLACEMENT per impostare lo
spiazzamento desiderato.
Come è stato spiegato in precedenza, un Effective Address non deve
necessariamente comprendere tutte le componenti; sono permesse infatti le seguenti
forme:
DISPLACEMENT
BASE
BASE + DISPLACEMENT
BASE + INDEX
(INDEX * SCALE) + DISPLACEMENT
BASE + INDEX + DISPLACEMENT
BASE + (INDEX * SCALE) + DISPLACEMENT
4.1.7 Nuove istruzioni della 80386
Le istruzioni fornite dalle CPU che supportano anche la modalità protetta,
possono essere suddivise in due grandi categorie:
- istruzioni per le applicazioni
- istruzioni di sistema
Le istruzioni per le applicazioni sono quelle destinate alla scrittura dei
normali programmi (ad esempio, MOV, ADD, SHL, PUSH,
etc); le istruzioni di sistema sono quelle destinate alla scrittura dei
sistemi operativi (ad esempio, LGDT, LTR, ARPL, etc).
In questa sezione esaminiamo le nuove istruzioni fornite dalla 80386 per
la scrittura dei normali programmi; per maggiori dettagli si consiglia di
consultare il tutorial Assembly Base o i manuali della CPU.
Nel seguito indichiamo come al solito con SRC l'operando sorgente e con
DEST l'operando destinazione.
Questa istruzione scandisce SRC alla ricerca del primo bit che vale 1;
la scansione avviene in avanti (forward) a partire dal bit in posizione 0 di
SRC. Se la ricerca fallisce (SRC=0), si ha ZF=1, mentre il
contenuto di DEST è indefinito. Se la ricerca ha successo, si ha ZF=0,
mentre in DEST viene salvato l'indice del bit trovato.
Questa istruzione scandisce SRC alla ricerca del primo bit che vale 1;
la scansione avviene all'indietro (reverse) a partire dal bit più significativo di
SRC. Se la ricerca fallisce (SRC=0), si ha ZF=1, mentre il
contenuto di DEST è indefinito. Se la ricerca ha successo, si ha ZF=0,
mentre in DEST viene salvato l'indice del bit trovato (riferito al bit più
significativo di SRC).
Premettiamo che, con il termine bitBase si indica il bit in posizione 0
di un operando Reg o di una locazione di memoria (Mem); con il termine
bitOffset si indica un numero che rappresenta la distanza in bit tra il
bitBase e il bit che vogliamo esaminare.
L'istruzione BT legge il bitOffset da SRC e lo somma al
bitBase di DEST, in modo da ottenere la posizione del bit da esaminare;
il valore di tale bit viene copiato in CF (Carry Flag). Ad esempio:
bt edx, 7
copia in CF il valore del bit in posizione 7 di EDX.
Se DEST è un Reg, bitOffset viene calcolato modulo n,
dove n è la dimensione in bit (16 o 32) del Reg; ciò
è necessario per non eccedere l'ampiezza in bit di un registro generale.
Se DEST è un Mem16, il bit da copiare in CF si trova alla
distanza bitOffset dall'indirizzo iniziale:
Effective Address + (2 ∗ (bitOffset DIV 16))
Se DEST è un Mem32, il bit da copiare in CF si trova alla
distanza bitOffset dall'indirizzo iniziale:
Effective Address + (4 ∗ (bitOffset DIV 32))
(DIV indica la divisione intera).
L'istruzione BTC legge il bitOffset da SRC e lo somma al
bitBase di DEST, in modo da ottenere la posizione del bit da esaminare;
il valore di tale bit viene sottoposto a complemento a 1 (inversione), dopo
che l'originale era stato salvato in CF.
Valgono tutte le considerazioni esposte per BT in relazione a bitBase e
bitOffset.
L'istruzione BTR legge il bitOffset da SRC e lo somma al
bitBase di DEST, in modo da ottenere la posizione del bit da esaminare;
il valore di tale bit viene azzerato, dopo che l'originale era stato salvato in
CF.
Valgono tutte le considerazioni esposte per BT in relazione a bitBase e
bitOffset.
L'istruzione BTS legge il bitOffset da SRC e lo somma al
bitBase di DEST, in modo da ottenere la posizione del bit da esaminare;
il valore di tale bit viene posto a 1, dopo che l'originale era stato salvato
in CF.
Valgono tutte le considerazioni esposte per BT in relazione a bitBase e
bitOffset.
Questa istruzione tratta il contenuto di AX come intero con segno a 16
bit in complemento a 2 e lo converte in una DWORD mediante estensione
del bit di segno; il risultato viene salvato in EAX.
Si tenga presente che l'analoga istruzione CWD svolge lo stesso compito, ma
salva il risultato in DX:AX.
Questa istruzione tratta il contenuto di EAX come intero con segno a 32
bit in complemento a 2 e lo converte in una QWORD mediante estensione
del bit di segno; il risultato viene salvato in EDX:EAX.
Questa istruzione legge un indirizzo dalla memoria e lo copia nei registri indicati.
Se DEST è un Reg16, allora SRC deve essere un indirizzo di tipo
Seg:Offset da 16+16 bit; la componente Offset viene copiata in
DEST, mentre la componente Seg viene copiata in FS.
Se DEST è un Reg32, allora SRC deve essere un indirizzo di tipo
Seg:Offset da 16+32 bit; la componente Offset viene copiata in
DEST, mentre la componente Seg viene copiata in FS.
Bisogna ribadire che questo tipo di istruzione funziona correttamente solo se la
componente Offset in memoria precede Seg.
Questa istruzione è del tutto analoga a LFS; la componente Seg viene
copiata in GS.
Questa istruzione è del tutto analoga a LFS; la componente Seg viene
copiata in SS.
Questa istruzione legge il contenuto di SRC, lo estende con zeri a sinistra e
salva il risultato in DEST.
Questa istruzione legge il contenuto di SRC, lo estende con il bit di segno a
sinistra e salva il risultato in DEST.
Queste istruzioni modificano l'unico operando DEST in base allo stato assunto
dai flags in seguito ad una operazione logico aritmetica appena eseguita; se la
condizione richiesta è verificata, si ha DEST=1, mentre in caso contrario si
ha DEST=0.
Come si può notare, alcune forme di SETcc fanno esplicito riferimento allo
stato dei flags ("if overflow", "if not overflow", "if sign", etc). Per le altre
forme di SETcc il riferimento ai flags è implicito ("if above", "if below",
"if not greater or equal", etc); in tal caso bisogna distinguere tra numeri interi
senza segno o con segno. Come sappiamo, "above" e "below" trattano gli operandi come
numeri interi senza segno; viceversa, "greater" e "less" trattano gli operandi come
numeri interi con segno. Questa distinzione non è necessaria nel caso di "zero",
"not zero", "equal" e "not equal".
Questa istruzione richiede i tre operandi SRC, DEST e COUNT
(contatore). COUNT (terzo operando) indica di quante posizioni bisogna far
scorrere a sinistra i bit di DEST (primo operando); i posti che si liberano
a destra in DEST vengono riempiti con altrettanti bit fatti uscire dalla
sinistra di SRC (secondo operando). Il contenuto di SRC rimane
inalterato.
Vengono presi in considerazione solo i primi 5 bit di COUNT, in modo da
ottenere un numero di scorrimenti compreso tra 0 e 31. Per ogni singolo
scorrimento, il bit che trabocca dalla sinistra di DEST viene salvato in
CF.
Questa istruzione richiede i tre operandi SRC, DEST e COUNT
(contatore). COUNT (terzo operando) indica di quante posizioni bisogna far
scorrere a destra i bit di DEST (primo operando); i posti che si liberano
a sinistra in DEST vengono riempiti con altrettanti bit fatti uscire dalla
destra di SRC (secondo operando). Il contenuto di SRC rimane inalterato.
Vengono presi in considerazione solo i primi 5 bit di COUNT, in modo da
ottenere un numero di scorrimenti compreso tra 0 e 31. Per ogni singolo
scorrimento, il bit che trabocca dalla destra di DEST viene salvato in
CF.
4.1.8 Istruzioni che operano implicitamente su dati a 32 bit
Oltre alle nuove istruzioni appena illustrate, la 80386 supporta anche le
varianti a 32 bit delle vecchie istruzioni per 8086 e 80286;
in tali varianti, è implicito il riferimento a blocchi di dati da 32 bit.
Le istruzioni per la manipolazione delle stringhe, STOSD, LODSD,
MOVSD, SCASD, CMPSD, operano su dati di tipo DWORD; ad
esempio:
movsd
legge una DWORD da DS:SI e la copia in ES:DI.
Le istruzioni per l'I/O con le porte hardware, INSD e OUTSD
fanno riferimento implicitamente ad un dato di tipo DWORD; ad esempio:
insd
legge una DWORD dalla porta hardware indicata da DX e la copia in
ES:DI.
Al termine di una ISR, nel modo 16 bit l'istruzione IRET come
sappiamo estrae dallo stack due WORD per l'indirizzo di ritorno CS:IP
e una WORD per FLAGS; nel modo 32 bit le istruzioni IRET
e IRETD sono equivalenti e estraggono dallo stack una WORD e una
DWORD per CS:EIP e una DWORD per EFLAGS.
L'istruzione PUSHAD salva nello stack il contenuto di EAX, ECX,
EDX, EBX, ESP originale, EBP, ESI e EDI;
l'istruzione POPAD estrae dallo stack 8 DWORD e le carica negli stessi
registri in ordine inverso.
L'istruzione PUSHFD salva nello stack il contenuto di EFLAGS; l'istruzione
POPFD estrae una DWORD dallo stack e la carica in EFLAGS.
4.1.9 Indirizzamenti nel modo 16 bit e 32 bit
Abbiamo visto in precedenza che una istruzione come MOVSD, legge una
DWORD da DS:SI e la copia in ES:DI.
Questo è ciò che accade quando la 80386 opera nel modo 16 bit; in tale
modalità operativa, i segmenti di programma non possono superare la dimensione di
64 KiB, per cui le componenti offset devono essere comprese tra 0000h
e FFFFh.
Quando però la 80386 opera nel modo 32 bit, la precedente istruzione
legge una DWORD da DS:ESI e la copia in ES:EDI; in questo caso,
è possibile lavorare con segmenti virtuali da 4 GiB, per cui le componenti
offset possono variare da 00000000h a FFFFFFFFh.
Questi aspetti influiscono anche sul comportamento delle varie istruzioni. Quando la
80386 si trova nel modo 16 bit e incontra, ad esempio, una istruzione
RETF, estrae dallo stack due WORD per l'indirizzo di ritorno e le carica
in CS:IP; i 16 bit più significativi di EIP vengono azzerati.
Nel modo 32 bit, invece, vengono estratte dallo stack una WORD e una
DWORD per l'indirizzo di ritorno; tali dati vengono caricati in CS:EIP.
Un altro esempio può essere l'istruzione LOOP; nel modo 16 bit il
contatore è CX, mentre nel modo 32 bit viene usato ECX.
Per indicare agli assembler come MASM e NASM quale modo (16 o
32 bit) vogliamo utilizzare, possiamo servirci dell'apposito attributo
"Dimensione" per i segmenti di programma; come sappiamo, tale attributo può
essere USE16 o USE32.
In presenza di un segmento di codice del tipo:
CODESEGM SEGMENT ALIGN=16 PUBLIC USE16 CLASS=CODE
l'assembler provvede a tradurre le istruzioni nel codice macchina relativo al
modo 16 bit; eventuali istruzioni con operandi o indirizzamenti a
32 bit, vengono precedute dagli appositi prefissi illustrati nella
sezione 4.1.5.
Se invece il segmento di codice viene definito come:
CODESEGM SEGMENT ALIGN=16 PUBLIC USE32 CLASS=CODE
l'assembler provvede a tradurre le istruzioni nel codice macchina relativo al
modo 32 bit; eventuali istruzioni con operandi o indirizzamenti a
16 bit, vengono precedute dagli appositi prefissi illustrati nella
sezione 4.1.5.
Più avanti vedremo che in modalità protetta, la 80386 utilizza un
apposito bit nel descrittore di segmento per indicare la dimensione di default
degli operandi e degli indirizzamenti.
4.2 Programmazione di sistema con la 80386
Il supporto fornito dalla 80386 per la scrittura dei sistemi operativi, è
strutturato come estensione di quello per la 80286, in modo da garantire la
compatibilità verso il basso; molti dei concetti esposti nei precedenti capitoli,
risultano quindi pienamente validi anche con questo nuovo modello di CPU.
Le funzionalità fornite dalla 80386 per la programmazione di sistema
includono:
- gestione della memoria
- protezione
- multitasking
- input/output
- eccezioni e interruzioni
- inizializzazione
- coprocessing e multiprocessing
- debugging
Tutte queste funzionalità sono supportate attraverso apposite istruzioni di sistema
e registri di sistema, di cui si parlerà al momento opportuno. In questo capitolo ci
occupiamo della gestione della memoria; gli altri aspetti vengono illustrati nei
capitoli successivi.
4.2.1 Gestione della memoria - Modello segmentato
Il meccanismo fondamentale per la gestione della memoria è la segmentazione.
Ogni programma viene suddiviso in vari blocchi, tra loro indipendenti, denominati
segmenti; ogni segmento, nel caso più semplice, è destinato a contenere un
tipo specifico di informazione, come codice, dati o stack.
Le caratteristiche complete di un segmento vengono specificate attraverso un blocco
di dati da 8 byte (64 bit) denominato segment descriptor; la
Figura 4.21 illustra la struttura di un descrittore di segmento.
Come si può notare, per garantire la compatibilità verso il basso, le prime tre
WORD coincidono pienamente con quelle destinate alla 80286; la
WORD più significativa viene invece utilizzata per le estensioni dedicate
alla 80386.
Il campo Base Address viene esteso a 32 bit attraverso gli 8
bit nelle posizioni dalla 56 alla 63.
Il campo LIMIT viene esteso a 20 bit attraverso i 4 bit nelle
posizioni dalla 48 alla 51; in questo caso entrano in gioco anche il
bit G (Granularity) in posizione 55 e il bit D/B in
posizione 54 (descritto più avanti). Per il momento, supponiamo di avere
D/B=1, che è il valore di default per la 80386 e indica che la
CPU sta operando nel modo 32 bit.
Se G=0, la granularità del campo LIMIT è in BYTE; con 20
bit possiamo esprimere un campo LIMIT compreso tra 00000h e FFFFFh,
valori che rappresentano l'offset massimo in BYTE. Gli offset all'interno del
segmento vanno da 0 a LIMIT; la dimensione del segmento stesso può
variare quindi da 1 byte (il solo offset 0) a 220
byte (1 MiB).
Se G=1, la granularità del campo LIMIT è in pagine da 4 KiB
ciascuna (4096 byte); con 20 bit possiamo esprimere un campo LIMIT
compreso tra 00000h e FFFFFh, valori che sommati a 1 rappresentano
il numero di pagine disponibili. Gli offset all'interno del segmento vanno da 0
a (LIMIT+1)*4096-1; la dimensione del segmento stesso può variare quindi da
4096 byte (una sola pagina) a
220 * 4096 = 220 * 212 = 220+12 = 232 byte = 4 GiB
(Si tenga presente che la granularità in pagine del campo LIMIT non ha nulla a
che vedere con la paginazione della memoria).
Qualunque tentativo di accedere ad un offset che sconfina dagli estremi indicati,
provoca una eccezione della CPU.
Il bit AV (Available), in posizione 52, viene ignorato dalla
80386 e può essere eventualmente usato dai SO; il bit in posizione
53 è riservato per usi futuri e deve valere rigorosamente 0.
Il bit D/B in posizione 54 è importante per la compatibilità con la
80286 e il suo significato varia a seconda del tipo di segmento. Per i
segmenti di dati, questo bit viene indicato con B e prende il nome di Big
bit. Se B=0, un segmento di dati non può avere una dimensione superiore
a 64 KiB; se B=1, valgono le considerazioni esposte in precedenza per
il campo LIMIT.
Per i segmenti di codice e stack, questo bit viene indicato con D e prende il
nome di Default bit. In un segmento di codice, se D=0 la dimensione
predefinita degli operandi e degli indirizzamenti è 16 bit (modo 16 bit);
se D=1, il default è 32 bit (modo 32 bit). Per lo stack, se
D=0 viene usato SP per gli indirizzamenti e la dimensione del segmento
non può superare 64 KiB; se D=1, viene usato ESP per gli
indirizzamenti e per la dimensione del segmento valgono le considerazioni esposte in
precedenza per il campo LIMIT.
L'ACCESS BYTE, nelle posizioni da 40 a 47, rimane sostanzialmente
invariato rispetto alla 80286.
Il bit P (Present) indica la disponibilità del segmento. Se P=1,
il segmento è usabile in quanto si trova in memoria e contiene informazioni valide; se
P=0, il segmento non è usabile in quanto non si trova in memoria (è stato
deallocato per liberare spazio) o contiene informazioni non valide. Quando P=0,
il SO può utilizzare per i propri scopi tutti i campi del descrittore di Figura
4.21, tranne gli 8 bit riservati all'ACCESS BYTE.
Qualunque tentativo di accedere ad un segmento con P=0, provoca una eccezione
della CPU.
I due bit DPL indicano il livello di privilegio, 0, 1, 2 o
3, del segmento; il livello di privilegio più alto è 0 mentre il più
basso è 3.
Grazie al DPL, è possibile attivare un meccanismo di protezione che stabilisce
le regole di accesso ai vari segmenti.
Il bit S in posizione 44 vale 1 per indicare che il descrittore
si riferisce ad un segmento di programma; i descrittori speciali o di sistema hanno
invece S=0.
I 4 bit riservati a TYPE (compreso il bit A), per i segmenti di
codice assumono il significato illustrato in Figura 4.22.
Il bit E (Executable) per i segmenti di codice vale sempre 1.
Il bit C (Conforming) permette di allentare le regole di protezione
quando si salta verso un segmento con livello di privilegio maggiore; il suo
funzionamento è stato descritto in dettaglio nel Capitolo 2.
Il bit R (Readable) permette di stabilire se il segmento di codice,
oltre che eseguibile, è anche leggibile. Ponendo R=1, autorizziamo l'accesso
in lettura ad eventuali dati memorizzati in un segmento di codice; l'accesso in
scrittura è invece proibito (per ovvi motivi di sicurezza) per cui tali dati non
sono modificabili (dati costanti).
Il bit A (Accessed) viene posto automaticamente a 1 dalla
CPU ogni volta che accediamo ad un segmento. I SO pongono A=0,
per poi verificare se la CPU ha riportato tale bit a 1; in questo
modo è possibile raccogliere dati statistici sulla frequenza di utilizzo di un
segmento. Un segmento che per un certo tempo non viene usato (A=0), può
essere deallocato dal SO per fare posto ad altre informazioni.
I 4 bit riservati a TYPE (compreso il bit A), per i segmenti di
dati assumono il significato illustrato in Figura 4.23.
Il bit E (Executable) per i segmenti di dati vale sempre 0.
Il bit ED (Expand Down) permette di ribaltare il significato del
campo LIMIT per i segmenti di dati. Normalmente si ha ED=0 per
indicare che i dati devono essere disposti tra l'offset minimo 0 e il
massimo consentito dal campo LIMIT; qualunque tentativo di accedere ad
un offset superiore al massimo, provoca una eccezione della CPU.
Se ED=1, i dati devono essere disposti tra l'offset minimo (LIMIT+1
se G=0, (LIMIT+1)*4096 se G=1) e il massimo consentito
(220-1 se G=0, 232-1 se G=1);
ciò permette di creare segmenti la cui dimensione può essere incrementata in
fase di esecuzione, riducendo opportunamente il valore LIMIT.
Qualunque tentativo di accedere ad un offset inferiore al minimo, provoca
una eccezione della CPU.
Chiaramente, ED=0 è l'impostazione predefinita per i segmenti di dati;
anche per i segmenti di stack, se non si ha la necessità di incrementarne la
dimensione, non è necessario impostare ED=1.
Il bit W (Writable) permette di stabilire se il segmento di dati,
oltre che in lettura, è accessibile anche in scrittura; un segmento di dati con
W=0 contiene informazioni accessibili solo in lettura (dati costanti).
Per i segmenti di stack si ha obbligatoriamente W=1; in caso contrario,
la CPU genera una eccezione.
Il bit A ha lo stesso significato già illustrato per i segmenti di codice.
Tutti i descrittori di segmento e i descrittori speciali vengono sistemati in
apposite tabelle denominate descriptor tables; esistono due tipi principali
di tabelle dei descrittori:
- Global Descriptor Table (GDT)
- Local Descriptor Table (LDT)
La GDT è una tabella unica, attraverso la quale il SO può controllare
vari aspetti importanti, come la gestione del multitasking o la condivisione di
servizi (ad esempio, librerie di procedure) con tutti i programmi in esecuzione.
Un qualunque programma destinato a girare in modalità protetta, deve predisporre
almeno la GDT, mentre le altre tabelle sono facoltative.
La LDT è una tabella analoga alla GDT ma riservata ad un singolo
task; ogni task in esecuzione ha la propria LDT.
All'interno della LDT sono presenti, in particolare, i descrittori di
tutti i segmenti usati dal relativo task; tali segmenti formano lo spazio di
indirizzamento privato del task stesso. In un sistema multitasking, grazie
alle LDT, più task possono essere in esecuzione senza interferire tra
loro; ogni task infatti ha il proprio codice eseguibile in segmenti differenti
da quelli degli altri task.
Se necessario, più task possono condividere uno stesso segmento di dati; a tale
proposito, nelle relative LDT devono specificare un descrittore che punta
al segmento condiviso. Eventualmente, è persino consentito a due o più task di
condividere la stessa LDT.
L'accesso alle tabelle dei descrittori avviene tramite un apposito selector
(selettore) la cui struttura è mostrata in Figura 4.24.
Il campo INDEX ha un'ampiezza di 13 bit e rappresenta un indice nella
tabella dei descrittori; con 13 bit 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
GDT, mentre il valore 1 indica una LDT.
Il campo RPL ha un'ampiezza di 2 bit e rappresenta il Requested
Privilege Level (livello di privilegio desiderato); come per il DPL, si
va dal valore 0 (privilegio massimo) a 3 (privilegio minimo). Come
abbiamo visto nei precedenti capitoli, nel caso dei selettori di segmenti di codice
e stack, il campo RPL coincide con il CPL; per i selettori di segmenti
di dati, questo stesso campo viene impiegato per mitigare le conseguenze di un uso
improprio dei puntatori negli indirizzamenti.
Il fatto che il campo INDEX abbia un'ampiezza di 13 bit, fa si che
una tabella possa contenere sino a 213=8192 descrittori (di
segmento e speciali); la dimensione massima della tabella stessa è pari quindi a:
213 * 8 = 213 * 23 = 213+3 = 216 = 65536 byte = 64 KiB
La CPU deve sapere a quale indirizzo di memoria è presente la GDT;
a tale proposito, in genere si utilizza un apposito descrittore. La GDT
però è unica, per cui tutte le necessarie informazioni vengono inserite
direttamente in un apposito registro di sistema denominato Global Descriptor
Table Register (GDTR); la Figura 4.25 illustra tutti i dettagli.
Il campo BASE indica il Base Address a 32 bit da cui parte in
memoria la GDT. Il campo LIMIT è espresso in BYTE e per una
tabella con N descrittori, deve valere chiaramente almeno (N*8)-1. Si
noti che la dimensione massima della GDT è 64 KiB, per cui per il
campo LIMIT sono sufficienti 16 bit.
Il GDTR viene inizializzato attraverso l'istruzione di sistema LGDT
(Load Global Descriptor Table Register).
L'istruzione LGDT richiede come unico operando una locazione di memoria da
6 byte (48 bit); i 16 bit meno significativi rappresentano il
campo LIMIT, mentre i 32 bit più significativi rappresentano il campo
BASE. Nel modo 16 bit, gli 8 bit più significativi del campo
BASE vengono ignorati (il Base Address è a 24 bit).
LGDT è un'istruzione di sistema, disponibile però anche in modalità reale
in quanto il suo scopo è predisporre l'ambiente operativo per il passaggio alla
modalità protetta. Una volta entrati in modalità protetta, eventuali modifiche
della GDT sono sconsigliate; se necessario, possono essere effettuate con
LGDT solo da un task che gira al massimo livello di privilegio (come il
SO).
Se vogliamo conoscere il contenuto del GDTR, dobbiamo usare l'istruzione
di sistema SGDT (Store Global Descriptor Table Register).
Questa istruzione legge 6 byte dal GDTR e li copia nella locazione
di memoria indicata dall'operando Mem. I primi 16 bit rappresentano
il campo LIMIT, mentre i successivi 32 bit contengono il campo
BASE; nel modo 16 bit, il BYTE più significativo del campo
BASE viene posto a 0.
SGDT è un'istruzione di sistema destinata alla modalità protetta; può
essere usata da un task che gira a qualunque livello di privilegio.
Ogni task in esecuzione ha la propria LDT; le caratteristiche di ogni
LDT vengono specificate in un apposito descrittore speciale, la cui
struttura generale è illustrata in Figura 4.26.
La principale differenza rispetto ai descrittori di segmento è il bit S
che deve valere 0; inoltre, a seconda del valore assunto dal campo
TYPE, la struttura di un descrittore speciale può variare notevolmente.
Per i descrittori di LDT si ha TYPE=0011b.
Tutti i descrittori di LDT devono trovarsi tassativamente nella
GDT.
In un sistema multitasking possono essere presenti più task che il SO fa
girare a rotazione; in questo modo, l'utente ha l'impressione che i vari task stiano
girando tutti contemporaneamente. In realtà quindi, in un determinato istante solo
uno dei task è effettivamente in esecuzione. La CPU deve sapere a quale
indirizzo di memoria è presente la LDT del task correntemente in esecuzione;
a tale proposito, tutte le necessarie informazioni vengono inserite in un apposito
registro di sistema denominato Local Descriptor Table Register (LDTR);
la Figura 4.27 illustra tutti i dettagli.
Solamente la parte relativa alla componente Selector è visibile ai programmi;
la restante parte, destinata a contenere le componenti Base Address e
LIMIT, è nascosta in quanto riservata alla CPU. Il campo BASE
indica il Base Address a 32 bit da cui parte in memoria la LDT.
Il campo LIMIT è espresso in BYTE e per una tabella con N
descrittori, deve valere chiaramente almeno (N*8)-1. Si noti che la dimensione
massima della LDT è 64 KiB, per cui per il campo LIMIT sono
sufficienti 16 bit.
Il LDTR viene inizializzato attraverso l'istruzione di sistema LLDT
(Load Local Descriptor Table Register).
L'istruzione LLDT richiede come unico operando un registro o una locazione di
memoria da 16 bit; tale operando è un selettore che contiene l'indice del
descrittore della LDT nella GDT. La CPU accede al descrittore,
legge i campi BASE e LIMIT e li copia nel LDTR; tutte le altre
parti del descrittore vengono ignorate.
LLDT è un'istruzione di sistema destinata alla modalità protetta; può essere
utilizzata solo da un task che gira al massimo livello di privilegio (in genere, si
tratta del SO).
In un sistema multitasking, LLDT viene utilizzata per caricare nel
LDTR il descrittore della LDT del primo task da eseguire; ad ogni
cambio di task (task switch), la CPU provvede automaticamente ad aggiornare
il LDTR.
Se vogliamo conoscere il contenuto del LDTR, dobbiamo usare l'istruzione
di sistema SLDT (Store Local Descriptor Table Register).
Questa istruzione legge unicamente i 16 bit del campo Selector nel
LDTR e li copia nel registro o nella locazione di memoria a 16 bit
specificata come operando.
SLDT è un'istruzione di sistema destinata alla modalità protetta; può
essere usata da un task che gira a qualunque livello di privilegio.
Una volta che il LDTR è stato inizializzato, qualunque riferimento alla
LDT del task correntemente in esecuzione non necessita più di un accesso
al relativo descrittore nella GDT; infatti, tutte le informazioni di cui
la CPU ha bisogno, sono già presenti nello stesso LDTR e possono
essere quindi utilizzate via hardware alla massima velocità possibile.
Le stesse considerazioni valgono anche per i registri di segmento. Supponiamo, ad
esempio, di voler associare ad ES un segmento dati il cui descrittore è
presente nella LDT del task in esecuzione; se AX contiene il
selettore di tale descrittore, possiamo scrivere:
mov es, ax
Se ora vogliamo trasferire in EAX un dato a 32 bit che si trova
all'offset EBX di tale segmento, ci serviamo dell'istruzione:
mov eax, es:[ebx]
Teoricamente, la CPU si serve del selettore in ES per accedere al
descrittore del segmento dati nella LDT; dal descrittore ricava il Base
Address del segmento, gli somma l'offset EBX, accede all'indirizzo
lineare così ottenuto, legge un dato a 32 bit e lo copia in EAX.
Chiaramente, se per ogni indirizzamento fosse necessario un procedimento così
contorto, si andrebbe incontro ad un vero e proprio disastro in termini di
prestazioni.
Per ovviare a questo inconveniente, si segue la stessa tecnica già illustrata
per il LDTR; anche i registri di segmento quindi sono dotati di una parte
nascosta, come viene mostrato dalla Figura 4.28.
Solamente la parte relativa alla componente Selector di ogni registro di
segmento è visibile ai programmi; la parte restante, destinata a contenere i
campi BASE, LIMIT e gli attributi del segmento (ACCESS BYTE,
granularità, etc), è invisibile in quanto riservata alla CPU.
Ogni volta che carichiamo un nuovo selettore in un registro di segmento, la
CPU accede al relativo descrittore nella tabella specificata, legge i campi
BASE, LIMIT, ATTRIBUTES e li carica nella parte nascosta del
registro stesso; da questo momento in poi, ogni indirizzamento che coinvolge quel
segmento, non necessita più di un accesso alla tabella dei descrittori.
Nel caso quindi dell'istruzione:
mov eax, es:[ebx]
la CPU ha già pronto il Base Address (nella parte nascosta di
ES) e lo somma all'offset in EBX per ottenere l'indirizzo lineare
a cui accedere; tutti i necessari calcoli avvengono via hardware e quindi alla
massima velocità possibile.
La Figura 4.29 mostra un esempio relativo ad un programma che sfrutta il modello
di memoria segmentato; si assume che le parti nascoste dei registri di segmento
siano state già caricate dalla CPU con i vari campi BASE, LIMIT
e attributi.
Ovviamente, un programma può disporre di decine di segmenti; in un determinato
momento della fase di esecuzione, sino a sei di essi possono essere gestiti
attraverso altrettanti registri di segmento.
In base a quanto è stato esposto in precedenza, gli accessi in memoria nel modello
segmentato, avvengono attraverso indirizzi logici Selector:Offset, che
vengono convertiti dalla CPU in indirizzi lineari tramite la somma:
Base Address + Offset
(dove il Base Address viene letto dalla parte nascosta del registro di
segmento coinvolto).
In assenza di paginazione della memoria, l'indirizzo lineare così ottenuto viene
associato dalla CPU ad un indirizzo fisico nella RAM.
4.2.2 Gestione della memoria - Modello Flat
La 80386 mette a disposizione anche un modello di memoria estremamente
semplice, il cui scopo è quello di agevolare la conversione a 32 bit di
programmi scritti per CPU che non supportano la segmentazione. Nel caso
ad esempio della CPU 8080, un programma è formato da codice, dati e stack
che condividono uno stesso spazio di indirizzamento da 64 KiB; non esiste
distinzione inoltre tra indirizzi logici, lineari e fisici.
La situazione appena descritta può essere simulata sulla 80386
scrivendo un programma il quale, attraverso la sola GDT, definisce tre
descrittori per codice, dati e stack, che puntano tutti allo stesso segmento
da 4 GiB, caratterizzato da D/B=1, G=1, BASE=0 e
LIMIT=FFFFFh; si ottiene così il risultato mostrato dalla Figura 4.30.
Questa struttura può essere ulteriormente semplificata riducendo a due il numero
di descrittori, uno per il codice e uno per i dati e lo stack; ovviamente, è
compito del programmatore fare in modo che i vari blocchi non si sovrappongano
tra loro (in particolare, ESP deve essere posizionato ad un indirizzo di
memoria sufficientemente lontano dal blocco dati e dal blocco codice).
Non esiste quindi alcuna impostazione hardware che permetta alla 80386 di
attivare il modello flat; si tratta semplicemente di usare il modello
segmented come indicato dalla Figura 4.30.
Il modello flat è particolarmente indicato quando si ha la necessità di
far girare un singolo programma; in un caso del genere, si tende anche a
rinunciare ai vari meccanismi di protezione. Impostando il campo LIMIT
al valore massimo possibile, si evitano le eccezioni dovute al superamento dei
limiti del segmento; altre eccezioni possono essere evitate disabilitando
funzionalità come la paginazione della memoria.
Un programma che gira nel modello flat viene eseguito con più velocità
ed efficienza; infatti, i registri di segmento vengono inizializzati una sola
volta e in modo definitivo, tutti gli indirizzamenti sono di tipo NEAR
e, soprattutto, vengono evitati molti controlli che la CPU usualmente
effettua a causa dei meccanismi di protezione.
Nei precedenti capitoli abbiamo visto che la modalità protetta si rivela molto
utile per la ricerca degli errori nei programmi; qualunque comportamento anomalo
viene subito segnalato dalla CPU attraverso un'eccezione.
Se si hanno esigenze di questo genere, è possibile anche impostare un modello di
memoria flat con protezione. In pratica, lo schema rimane come illustrato
in Figura 4.30, tranne per il campo LIMIT che deve specificare l'offset
massimo per i vari blocchi di codice, dati e stack, come in Figura 4.29; in questo
modo, come sappiamo, la CPU può segnalare eventuali tentativi di invadere
aree di memoria riservate.
Anche per il modello flat, in assenza di paginazione della memoria,
l'indirizzo lineare calcolato dalla CPU viene associato ad un indirizzo
fisico nella RAM.
4.2.3 Gestione della memoria - Paginazione
Abbiamo visto che la 80286, quando opera in modalità protetta, è in grado
di simulare una quantità di memoria ben superiore ai 16 MiB fisicamente
indirizzabili tramite il suo Address Bus a 24 linee; ciò è possibile
grazie ad un livello di astrazione denominato memoria virtuale. Un programma
viene obbligato ad accedere alla RAM attraverso indirizzi logici del tipo
Selector:Offset, dove Offset è uno spiazzamento calcolato rispetto al
Base Address del segmento associato a Selector; il risultato è che il
programma stesso vede la memoria come un insieme di segmenti, ciascuno dei quali
può raggiungere una dimensione massima di 64 KiB. Considerando che la
GDT e la LDT del programma permettono di gestire sino a
214 descrittori di segmento e ipotizzando segmenti tutti da
64 KiB, con questa tecnica è possibile simulare sino a 1 GiB di
memoria virtuale; ovviamente, tutto questo spazio di indirizzamento viene mappato
solo in piccola parte nella RAM, mentre la parte più consistente si trova
su disco.
Se l'indirizzo logico fa riferimento ad un segmento presente in RAM, la
CPU somma Offset a Base Address e ottiene un indirizzo
lineare a 24 bit; tale indirizzo ricade sicuramente nei 16 MiB
gestibili dalla 80286 e quindi rappresenta anche l'indirizzo fisico da
caricare nell'Address Bus. Se l'indirizzo logico fa riferimento ad un
segmento che era stato spostato su disco, il programma viene interrotto e si
ottiene un'apposita eccezione; i SO possono intercettare tale eccezione,
ricaricare il segmento in RAM e riavviare il programma.
Questo stesso livello di astrazione è presente anche sulla 80386;
considerando che in questo caso i segmenti possono raggiungere la dimensione
massima di 4 GiB, i 214 selettori di segmento gestibili
con la GDT e la LDT permettono di simulare ben 64 TiB di
memoria virtuale, contro i soli 4 GiB di RAM indirizzabili
dall'Address Bus a 32 bit della CPU!
La situazione diventa però problematica nel momento in cui si ha la necessità
di spostare segmenti di programma dalla RAM al disco o viceversa. Con
la 80286 questi spostamenti avvengono in modo molto efficiente grazie
al fatto che un segmento non supera i 64 KiB; nel caso invece della
80386, per segmenti relativamente piccoli si ha lo stesso grado di
efficienza, ma l'eventuale presenza di segmenti di enormi dimensioni rende
proibitivo questo modo di procedere.
Per ovviare a questo inconveniente, la 80386 offre la possibilità di attivare
un secondo livello di astrazione denominato paging (paginazione della memoria);
questa funzionalità è supportata via hardware dalla CPU, per cui il suo uso
comporta un calo minimo delle prestazioni.
Un indirizzo logico del modello segmentato, se non si verificano eccezioni, viene
convertito dalla CPU in un indirizzo lineare; senza la paginazione, tale
indirizzo lineare diventa direttamente un indirizzo fisico che ricade all'interno
dei 4 GiB di RAM gestibili dalla 80386. Con la paginazione
attiva, un programma vede gli stessi 4 GiB in forma virtuale, suddivisi in
tanti piccoli blocchi da 4096 byte (4 KiB) ciascuno, denominati
pagine; nel caso generale, solo alcune pagine si trovano in RAM,
mentre buona parte di esse risiede su disco. Un indirizzo lineare quindi ricade
in una di queste pagine e solo in una seconda fase viene convertito in un indirizzo
fisico, attraverso un meccanismo analogo a quello del modello segmentato. Se
l'indirizzo lineare si riferisce ad una pagina in RAM, si ottiene subito
l'indirizzo fisico; in caso contrario, si ottiene un'apposita eccezione (Page
Fault), che permette al SO di caricare in memoria la pagina richiesta.
Per capire i vantaggi della paginazione, consideriamo un programma che deve gestire
strutture dati di diversi terabyte, distribuite in segmenti di grosse dimensioni;
chiaramente, non essendoci spazio a sufficienza nella RAM, buona parte di tali
dati deve necessariamente risiedere su disco. Se ora abbiamo bisogno di elaborare una
parte dei dati, senza la paginazione siamo costretti a caricare in RAM l'intero
segmento che li contiene; con la paginazione attiva invece, possiamo suddividere il
segmento stesso in tante pagine e caricare nella RAM solo quelle che ci
interessano.
Appare evidente poi che, grazie alle loro ridotte dimensioni, le pagine se necessario
possono essere spostate con notevole efficienza, dalla RAM al disco o viceversa.
Per gestire la paginazione della memoria, la 80386 si serve di quattro nuovi
registri di sistema a 32 bit denominati Control Registers e indicati
con CR0, CR1, CR2 e CR3.
Si tenga presente che CR1, pur facendo parte della 80386, risulta
inaccessibile in quanto riservato per usi futuri (Figura 4.31).
La paginazione della memoria può essere attivata o disattivata tramite il registro
CR0, la cui struttura è illustrata in Figura 4.32.
Come si può notare, la WORD meno significativa di CR0 incorpora il
registro MSW (Machine Status Word) della 80286. Per ragioni
di compatibilità, la 80386 supporta le istruzioni LMSW (Load
Machine Status Word) e SMSW (Store Machine Status Word); tali
istruzioni operano esclusivamente sui primi 16 bit di CR0.
Quando si scrive un programma per la 80386, si raccomanda vivamente di
non usare LMSW e SMSW; al loro posto si deve impiegare la variante
di MOV che opera direttamente sui registri di controllo.
Il significato dei bit PE, MP, EM e TS è stato già
illustrato in dettaglio nel Capitolo 3. Nel caso della 80386 è importante
sottolineare una notevole semplificazione introdotta dal bit PE
(Protection Enable) rispetto alla 80286. PE=1 porta la
CPU in modalità protetta; in questo modo vengono attivati tutti i meccanismi
di protezione per i segmenti e per le pagine. PE=0 riporta la CPU
direttamente in modalità reale; non è più necessario quindi il procedimento contorto
richiesto dalla 80286!
Si tenga presente che ponendo PE=0 attraverso l'istruzione LMSW,
la 80386 non viene riportata in modalità reale; per fare ciò è necessario
servirsi dell'istruzione MOV con operando destinazione CR0.
Il bit R (Reserved) è riservato alla 80386; quando si
modifica CR0, il contenuto di tale bit deve essere rigorosamente
preservato!
I bit nelle posizioni dalla 5 alla 30 sono riservati e in genere
vengono posti a 0.
Il bit PG (Paging) consente di attivare o disattivare la paginazione.
PG=1 attiva la paginazione, mentre PG=0 la disattiva.
Se la paginazione è disattivata (PG=0), l'indirizzo lineare calcolato
dalla CPU equivale all'indirizzo fisico della RAM da caricare
nell'Address Bus; se invece la paginazione è attiva (PG=1), entra
in gioco un secondo livello di astrazione, con i 32 bit dell'indirizzo
lineare che vengono suddivisi come illustrato in Figura 4.33.
Per capire il significato dei tre campi DIR, PAGE e OFFSET,
bisogna considerare che con la paginazione attiva, deve essere predisposta in
memoria almeno una tabella denominata Page Directory o "tabella di primo
livello"; tale tabella occupa esattamente una pagina (4096 byte) e al suo
interno può contenere sino a 1024 elementi da 32 bit (4 byte),
ciascuno dei quali punta ad una "tabella di secondo livello" denominata Page
Table.
Ogni tabella di secondo livello occupa esattamente una pagina e al suo interno può
contenere sino a 1024 elementi da 32 bit (4 byte), ciascuno
dei quali punta ad una pagina nella "RAM virtuale", denominata Page
Frame, destinata a contenere codice o dati.
Il campo DIR occupa 10 bit e rappresenta un indice all'interno
della Page Directory; con 10 bit possiamo rappresentare
210=1024 indici, compresi tra 0 e 1023.
Il campo PAGE occupa 10 bit e rappresenta un indice all'interno
della Page Table selezionata da DIR; con 10 bit possiamo
rappresentare 210=1024 indici, compresi tra 0 e
1023.
Il campo OFFSET occupa 12 bit e rappresenta un offset all'interno
della Page Frame selezionata da PAGE; con 12 bit possiamo
rappresentare 212=4096 offset, compresi tra 0 e
4095.
Si noti che una Page Directory permette di gestire sino a 1024
Page Tables; ogni Page Table permette di gestire sino a 1024
Page Frames, ciascuna delle quali occupa 4096 byte. Con questa
tecnica si può quindi coprire l'intero spazio di indirizzamento da 4 GiB
della 80386; abbiamo infatti:
1024 * 1024 * 4096 = 210 * 210 * 212 = 210+10+12 = 232 byte = 4 GiB
In un sistema multitasking si può decidere di predisporre una sola Page
Directory da condividere tra tutti i task in esecuzione oppure si può avere
una Page Directory per ogni task in esecuzione; in questo secondo caso,
ogni task in esecuzione è in grado di vedere sino a 4 GiB virtuali, parte
dei quali si trova su disco. In alternativa, è anche possibile scegliere una via
di mezzo; ad esempio, alcuni task condividono la stessa Page Directory,
mentre altri task usano ciascuno una propria Page Directory.
L'indirizzo in RAM della Page Directory correntemente in uso viene
memorizzato nel registro CR3, che per questo motivo viene anche chiamato
Page Directory Base Register (PDBR). La struttura di CR3 è
illustrata in Figura 4.34.
I 12 bit meno significativi di CR3 sono riservati e vengono
considerati implicitamente tutti di valore 0; un tentativo di modificarli
attraverso MOV non produce alcun effetto.
Questa situazione è legata al fatto che le pagine (e quindi anche le tabelle, che
sono a loro volta delle pagine) sono disposte in memoria con allineamento alla
pagina, per cui partono tutte da indirizzi multipli interi di 4096; un
indirizzo di questo genere ha appunto i 12 bit meno significativi che
valgono 0. La CPU quindi legge solo i 20 bit più significativi
di CR3 e aggiunge ad essi 12 zeri a destra per ottenere l'indirizzo
lineare a 32 bit della Page Directory.
Sia le tabelle di primo livello sia quelle di secondo livello, contengono elementi
la cui struttura è illustrata in Figura 4.35.
I bit colorati in grigio sono riservati alla CPU e devono valere 0;
in ogni caso, si raccomanda di preservare sempre il loro contenuto.
Il campo PAGE FRAME ADDRESS specifica i 20 bit più significativi del
Base Address di una tabella o di una pagina; siccome le tabelle e le pagine
sono allineate ad indirizzi multipli interi di 4096, i 12 bit meno
significativi dello stesso Base Address valgono implicitamente 0.
Nel caso di una Page Directory, il Base Address si riferisce ad una
Page Table. Nel caso di una Page Table, il Base Address si
riferisce ad una Page Frame.
Il bit P (Present) ha senso solo per una Page Table e indica
se la Page Frame a cui si riferisce l'elemento si trova in memoria
(P=1) o su disco (P=0). Quando P=0, tutti i restanti bit,
da 1 a 31, dell'elemento di Figura 4.35 sono a disposizione del
SO che li può utilizzare per memorizzare varie informazioni.
Un tentativo di accesso ad una Page Frame che ha P=0 interrompe il
programma in esecuzione e genera un'apposita eccezione (Page Fault); i
SO possono intercettare tale eccezione, ricaricare la pagina in memoria e
riavviare il programma.
In caso di eccezioni generate dal meccanismo di paginazione, l'indirizzo lineare
del punto in cui si è verificato il problema viene caricato nel registro CR2,
la cui struttura è illustrata in Figura 4.36.
Il bit A (Accessed) di Figura 4.35 viene posto automaticamente a
1 dalla CPU (anche nella Page Directory) ogni volta che
accediamo in lettura o scrittura ad una Page Table o ad una Page
Frame. I SO pongono A=0, per poi verificare se la CPU
ha riportato tale bit a 1; in questo modo è possibile raccogliere dati
statistici sulla frequenza di utilizzo di una tabella di secondo livello o di
una pagina. Una pagina che per un certo tempo rimane inutilizzata (A=0),
può essere spostata su disco per fare posto ad altre pagine.
Il bit D (Dirty) viene posto automaticamente a 1 dalla
CPU ogni volta che accediamo in scrittura ad una Page Frame; ciò
vale solo per gli elementi di una Page Table, mentre il bit D degli
elementi di una Page Directory è indefinito.
I SO pongono D=0, per poi verificare se la CPU ha riportato
tale bit a 1; in caso affermativo, lo stesso SO deve provvedere ad
aggiornare una eventuale copia della pagina su disco in modo che coincida con
quella in memoria.
I bit R/W e U/S vengono utilizzati per impostare i meccanismi di
protezione applicati alle pagine.
I 3 bit del campo AVAILABLE sono a disposizione dei SO.
Possiamo riassumere tutti i concetti appena esposti, attraverso lo schema mostrato
in Figura 4.37, che si riferisce al caso di una Page Frame presente nella
RAM e illustra i passaggi che trasformano un indirizzo lineare in un
indirizzo fisico.
In presenza quindi di un indirizzo lineare e con la paginazione attiva, la CPU
legge da CR3 il Base Address della Page Directory correntemente
in uso; all'indirizzo base della Page Directory viene sommato DIR*4 e si
ottiene la posizione dell'elemento che contiene il Base Address della Page
Table.
All'indirizzo base della Page Table viene sommato PAGE*4 e si ottiene
la posizione dell'elemento che contiene il Base Address della Page Frame.
All'indirizzo base della Page Frame viene sommato OFFSET e si ottiene
l'indirizzo fisico in RAM da caricare sull'Address Bus.
Osservando la Figura 4.37 ci si rende conto che si viene a creare lo stesso problema
già illustrato per GDT e LDT; i continui accessi alla Page
Directory e alle varie Page Tables, possono avere serie ripercussioni in
termini di prestazioni.
La 80386 risolve questo inconveniente grazie ad una ulteriore memoria cache
ad alte prestazioni, denominata Translation Lookaside Buffer (TLB). Nel
TLB vengono memorizzati gli elementi delle Page Tables maggiormente
usati durante le varie operazioni legate alla paginazione; in questo modo, tali
operazioni possono essere svolte via hardware dalla CPU, alla massima velocità
possibile, in quanto vengono evitati gli accessi alle tabelle stesse.
Il TLB è inaccessibile ai normali programmi; la sua gestione è di competenza
dei SO, in quanto richiede il massimo livello di privilegio. Ogni volta che
viene modificato un elemento di una Page Table, il SO deve provvedere
a svuotare il TLB; ciò può avvenire in modo implicito in seguito ad un task
switch che modifica CR3, mentre il metodo esplicito consiste nell'utilizzo
della variante di MOV con operando destinazione CR3.
Questa forma di MOV richiede un operando di tipo Reg32, mentre l'altro
operando deve essere un registro speciale (come i registri di controllo); gli altri
registri speciali vengono esaminati nei capitoli successivi.
Questa forma di MOV è un'istruzione di sistema, disponibile però anche in modalità
reale in quanto il suo scopo è predisporre le tabelle necessarie per la paginazione. Una
volta entrati in modalità protetta, MOV può essere usata solo da un task che gira
al massimo livello di privilegio (come il SO).
Ogni volta quindi che scriviamo, ad esempio
mov cr3, eax
provochiamo lo svuotamento del TLB.
Un aspetto molto importante da sottolineare quando si utilizza il modello di memoria
segmentato in combinazione con la paginazione, riguarda il fatto che, mentre le pagine
partono sempre da indirizzi multipli interi di 4096, i segmenti possono invece
partire da un indirizzo qualunque; inoltre, appare evidente che si può presentare il
caso di grossi segmenti che occupano centinaia di pagine, ma anche di segmenti più
piccoli di una pagina.
Può capitare allora che un segmento contenga la fine di una pagina e l'inizio della
pagina successiva; viceversa, può capitare che una pagina contenga la fine di un
segmento e l'inizio del segmento successivo.
Queste situazioni in certi casi possono avere serie ripercussioni sulle prestazioni;
ad esempio, un segmento più piccolo di 4096 byte che si trova a cavallo tra
due pagine, comporta un aumento del numero di accessi alle tabelle.
Un modo molto efficace per ridurre questi problemi consiste nello scegliere opportuni
requisiti di allineamento per i segmenti; l'ideale chiaramente sarebbe avere segmenti
allineati alla pagina.
Nel caso poi di segmenti molto grandi, una tecnica largamente usata consiste nel
dedicare una Page Table a ciascuno di essi; in questo modo si semplificano
notevolmente le operazioni di accesso in quanto il segmento è gestito sempre dalla
stessa Page Table a cui corrisponde un unico elemento nella Page
Directory.
Bibliografia
Intel 80386 Hardware Reference Manual
(disponibile su Internet)
Intel 80386 Programmer's Reference Manual
(disponibile su Internet)
Intel ASM386 Assembly Language Reference
(disponibile su Internet)