In questo capitolo vengono riscritti per la 80386 gli esempi presentati nel
Capitolo 3 per la 80286; prima però è necessario analizzare alcuni aspetti
relativi alla inizializzazione della CPU, all'ingresso in modalità protetta
e al ritorno in modalità reale.
7.1 Inizializzazione del sistema
Dopo un segnale di RESET, ricevuto tramite l'omonimo pin, la 80386
esegue un test diagnostico il cui risultato viene salvato nel registro EAX;
il superamento del test viene espresso da EAX=0, mentre valori diversi da
zero indicano problemi nella CPU.
Dopo il test, il registro EDX contiene le informazioni illustrate in Figura 7.1.
DH contiene il modello di CPU; nel caso della 80386 DX si ha
DH=3. Il registro DL contiene il numero di revisione del modello di
CPU.
I vari campi del registro CR0 (Figura 7.2) vengono inizializzati come indicato
nel seguito.
PG=0. Paginazione disabilitata.
TS=0. Nessun task switch in corso.
EM=0. Nessuna emulazione software del coprocessore matematico.
MP=0. Coprocessore matematico non presente.
PE=0. Protezione disabilitata. Emulazione della modalità reale 8086.
Il fatto che PE venga posto a zero dopo un RESET, indica chiaramente
che la 80386 si inizializza in una modalità che emula quella reale delle CPU
8086.
Il contenuto dei registri EBX, ECX, ESI, EDI, EBP,
ESP, GDTR, LDTR e TR è indefinito dopo un RESET;
gli altri registri vengono inizializzati come indicato dalla Figura 7.3.
I 16 bit più significativi di EFLAGS sono indefiniti; vengono presi in
considerazione solo i 16 bit meno significativi, come accade in modalità reale.
In questa fase non è ancora presente alcun segmento di programma e le tabelle dei
descrittori, come la GDT o le LDT, sono vuote; è necessario allora fare
in modo che la CPU punti ad una locazione di memoria contenente la prima
istruzione da eseguire.
Come abbiamo visto nel Capitolo 2 del tutorial Assembly Avanzato, lo standard
per le CPU della famiglia 80x86 prevede che la prima istruzione in
assoluto da eseguire si trovi all'inizio dell'ultimo paragrafo della RAM
totale indirizzabile; tale locazione prende il nome di Reset Vector.
Nel caso della 8086, con un Address Bus a 20 linee la RAM
totale indirizzabile è pari a 1 MiB, per cui la locazione del Reset Vector
è FFFF0h; in fase di RESET la CPU pone CS=FFFFh e
IP=0000h, da cui si ottiene (modalità di indirizzamento 8086):
Nel caso della 80286, con un Address Bus a 24 linee la RAM
totale indirizzabile è pari a 16 MiB, per cui la locazione del Reset Vector
è FFFFF0h; in fase di RESET la CPU pone CS.BASE=FF0000h
e IP=FFF0h, da cui si ottiene:
CS.BASE + IP = FF0000h + FFF0h = FFFFF0h
Non è detto che un computer basato sulla 80286 abbia realmente a disposizione
tutti i 16 MiB di RAM; di conseguenza, l'indirizzo fisico FFFFF0h
viene mappato in una ROM.
Nel caso della 80386, con un Address Bus a 32 linee la RAM
totale indirizzabile è pari a 4 GiB, per cui la locazione del Reset Vector
è FFFFFFF0h; in fase di RESET la CPU inizializza le parti nascoste
di CS, DS e ES come illustrato in Figura 7.4.
In Figura 7.3 vediamo che EIP=0000FFF0h, per cui si ottiene:
CS.BASE + EIP = FFFF0000h + 0000FFF0h = FFFFFFF0h
Anche in questo caso, non è detto che un computer basato sulla 80386 abbia
realmente a disposizione tutti i 4 GiB di RAM; di conseguenza, l'indirizzo
fisico FFFFFFF0h viene mappato in una ROM.
Per tutte le CPU della famiglia 80x86, il Reset Vector in genere
contiene una JMP che salta verso il BIOS, dove è presente il codice di
inizializzazione e gestione dell'hardware; a tale proposito, lo stesso BIOS
durante il RESET viene letto da una ROM e copiato in RAM. Se la
CPU si inizializza in modalità reale, sappiamo che il BIOS viene mappato
nei 64 KiB compresi tra F0000h e FFFFFh, all'interno quindi
dell'unico MiB indirizzabile in tale modalità.
Uno dei compiti fondamentali svolti dal BIOS consiste nell'inizializzare la
Interrupt Vector Table (IVT); a tale proposito, come si vede in Figura
7.3, nel rispetto delle convenzioni per la modalità reale, l'IDTR contiene
BASE=00000000h e LIMIT=03FFh (infatti, in modalità reale la IVT
si trova all'inizio della RAM e occupa 1024 byte).
Se la CPU rimane in modalità reale, il BIOS provvede ad impostare alcune
aree del primo MiB di RAM come illustrato in Figura 2.1, Capitolo 2, tutorial
Assembly Avanzato.
La fase finale svolta dal BIOS consiste nella scansione di tutte le memorie di
massa (Hard Disk, CD, DVD, chiavette USB, etc), alla ricerca di un bootloader;
con tale termine si indica un piccolo programma da 512 byte contenente il codice
necessario per il lancio del SO.
Per identificare un bootloader, il BIOS verifica se in un blocco da
512 byte posto all'inizio delle varie memorie di massa, gli ultimi 2 byte
assumono il valore AA55h; in caso affermativo, il BIOS legge tale blocco,
lo copia in memoria all'indirizzo fisico 07C00h, carica 0000h:7C00h in
CS:IP e salta a CS:IP.
Possiamo verificare questo aspetto attraverso il bootloader presentato nel
Capitolo 2 del tutorial Assembly Avanzato; la Figura 7.5 mostra ciò che si
ottiene con la macchina virtuale 86Box impostata su CPU 386/486.
7.2 Passaggio in Modalità Protetta
Per sfruttarne al massimo le potenzialità, la 80386 deve essere portata in
modalità protetta (che è la modalità operativa naturale per tale CPU); in tal
caso, è necessario predisporre un numero minimo di strutture dati e inizializzare
appositi registri.
Deve essere presente obbligatoriamente la GDT; al suo interno, oltre al
NULL DESCRIPTOR, bisogna inserire almeno due descrittori: uno per il segmento
di codice e uno per il segmento di dati. Il segmento di stack può essere predisposto
all'interno dello stesso segmento di dati, che quindi deve essere leggibile e scrivibile.
I campi Base Address e LIMIT della GDT devono essere caricati nel
registro GDTR tramite l'istruzione LGDT.
Se necessario, soprattutto se si intende gestire la NMI, bisogna predisporre
anche la IDT; in tal caso, i campi Base Address e LIMIT della
IDT devono essere caricati nel registro IDTR tramite l'istruzione
LIDT.
Bisogna ricordare che entrambe le istruzioni, LGDT e LIDT, sono disponibili
anche in modalità reale in quanto il loro scopo è quello di predisporre il sistema per
l'ingresso in modalità protetta.
Per accedere alla modalità protetta, oltre all'abilitazione della linea A20,
bisogna impostare PE=1 nel registro CR0; a tale proposito, si può usare
l'istruzione LMSW (il registro MSW è incluso nella WORD meno
significativa di CR0) o direttamente una MOV con destinazione CR0.
Come è stato spiegato nei precedenti capitoli, tutti i bit non usati in CR0
devono essere rigorosamente preservati; possiamo scrivere allora:
A questo punto è necessario compiere un'operazione molto importante in quanto la coda
di prefetch della CPU contiene attualmente istruzioni destinate alla modalità
reale. Effettuando una JMP di tipo FAR verso un indirizzo da cui parte
il codice in modalità protetta, la coda di prefetch viene svuotata e successivamente
riempita con le nuove istruzioni.
Una volta entrati in modalità protetta, bisogna tenere presente che anche i vari registri
di segmento contengono ancora componenti Seg destinate alla modalità reale; è
estremamente importante quindi inizializzare tali registri con gli opportuni selettori.
CS contiene già il selettore del segmento di codice da eseguire, grazie alla
JMP effettuata in precedenza; il CPL iniziale deve essere 0 in
quanto tutte queste operazioni vengono svolte dal SO.
La coppia SS:ESP deve essere inizializzata con il selettore e il TOS
dello stack. Nel registro DS bisogna caricare il selettore del segmento dati
principale del programma.
Nei restanti registri di segmento non usati, conviene caricare temporaneamente il
NULL SELECTOR; in questo modo si previene un loro uso improprio. Abbiamo
visto infatti che se si carica il NULL SELECTOR in un registro di segmento
per i dati, non viene generata alcuna eccezione; se però si prova ad utilizzare
tali registri negli indirizzamenti, si provoca un intervento della CPU per
violazione dei meccanismi di protezione
7.2.1 Segmentazione
I passi appena descritti ci portano automaticamente in un modello di memoria a segmenti;
non esiste infatti alcun bit (come PG per la paginazione) che ci permetta di
attivare o disattivare la segmentazione.
Nel caso più semplice, abbiamo la sola GDT con un descrittore per un segmento di
codice e un descrittore per un segmento di dati/stack; inoltre, i due descrittori puntano
allo stesso segmento, per cui si ha una configurazione di tipo flat.
Il modello flat è particolarmente indicato quando si ha la necessità di eseguire
un solo programma; in questo caso, come abbiamo visto nel precedente capitolo, è anche
possibile ridurre al minimo i controlli di protezione (ad esempio, impostando al valore
massimo di 4 GiB il campo LIMIT e assegnando il livello di privilegio
0 a codice, dati e stack).
Se si hanno maggiori esigenze, come il multitasking, si può ricorrere al modello di
memoria multi-segmento. Ad ogni task viene assegnato un proprio spazio privato di
indirizzamento tramite le LDT; il SO usa la GDT per gestire le
varie operazioni e anche per fornire servizi ai task stessi.
Il multitasking richiede anche la predisposizione di un TSS per il SO
e un TSS per ogni task; il TSS del primo task da eseguire deve essere
caricato nel registro TR tramite l'istruzione LTR. Ogni TSS
appena creato, deve avere il Busy Bit posto a 0; solamente in seguito
ad un task switch, tale bit viene posto a 1.
Il modello multi-segmento sfrutta pienamente i meccanismi di protezione in modo da
impedire interferenze tra i vari task e isolare il SO dai task stessi; in questo
caso, in genere si assegna il livello di protezione 0 al SO e il 3
ai task associati ai normali programmi.
7.2.2 Paginazione
A differenza della segmentazione, la paginazione può essere attivata o disattivata
tramite il bit PG in CR0; la modifica di tale bit comporta l'accesso
alla WORD più significativa di CR0, per cui è richiesto l'uso esplicito
dell'istruzione MOV.
Prima di attivare la paginazione con PG=1, le seguenti condizioni devono essere
soddisfatte:
La CPU deve avere già a disposizione la Page Directory e almeno
una Page Table
Il registro CR3 (PDBR) deve già contenere il Base Address
della Page Directory
La CPU deve essere già in modalità protetta (PE=1 in CR0)
Anche in questo caso, la condizione PE=1 deve essere immediatamente seguita da
una JMP verso il codice in modalità protetta in modo da aggiornare la coda di
prefetch.
7.3 Ritorno in Modalità Reale
Il ritorno in modalità reale con la 80386 richiede una certa attenzione in
quanto stiamo saltando da un ambiente a 32 bit ad uno a 16 bit; non
basta quindi impostare PE=0 in CR0. Vediamo allora quali passi bisogna
compiere per un corretto ritorno in modalità reale.
Paginazione
Se la paginazione è abilitata, bisogna innanzi tutto posizionarsi su un indirizzo
lineare non mappato in una Page Frame; in sostanza, l'indirizzo lineare deve
coincidere con l'indirizzo fisico.
Disabilitare la paginazione con PG=0 nel registro CR0.
Caricare il valore 0 in CR3 per svuotare il Translation Lookaside
Buffer (TLB).
Impostazione di CS
Effettuare un trasferimento del controllo verso un segmento di codice il cui campo
LIMIT valga FFFFh; in questo modo, nella parte nascosta di CS
viene caricato appunto un limite pari a 64 KiB, come richiesto dalla modalità
reale.
Impostazione di SS e dei registri di segmento per i dati
Caricare nei registri SS, DS, ES, FS, GS selettori
di descrittori di segmento le cui caratteristiche siano compatibili con la modalità
reale; in particolare, il campo LIMIT deve valere FFFFh (64 KiB),
la granularità deve essere espressa in BYTE (G=0), il tipo di segmento
deve essere Expand Up (ED=0), scrivibile (W=1) e presente
(P=1).
Il Base Address può avere un valore qualunque (in quanto in modalità reale tale
campo non viene utilizzato per gli indirizzamenti).
Disabilitazione delle interruzioni
Disabilitare con CLI le interruzioni mascherabili che giungono sul pin INTR.
Nel caso della NMI, la sua disattivazione potrebbe richiedere un circuito esterno.
Impostazione del campo PE in CR0
Impostare PE=0 in CR0.
Come è stato spiegato nei precedenti capitoli, ciò è possibile solo con una MOV;
l'istruzione LMSW, per garantire la compatibilità con la 80286, permette di
portare PE a 1, ma non di riportare tale bit a 0.
Per riportare PE a 0 preservando i bit non coinvolti di CR0,
possiamo scrivere:
Salto in modalità reale
Effettuare una JMP di tipo FAR verso il codice in modalità reale; in questo
modo, la coda di prefetch viene aggiornata e nei registri CS:IP viene caricata una
coppia Seg:Offset della modalità reale.
Ripristino di SS e dei registri di segmento per i dati
Caricare in SS:SP una coppia Seg:Offset che punti allo stack per la modalità
reale. Inizializzare i registri di segmento, principalmente DS, in modo che puntino
ai segmenti di dati desiderati per la modalità reale.
Ripristino della Interrupt Vector Table
Utilizzare LIDT per caricare un Base Address pari a 00000000h e
LIMIT pari a 03FFh nel registro IDTR; questi parametri, come
sappiamo, sono quelli standard della IVT in modalità reale.
Riabilitazione delle interruzioni
Riabilitare con STI le interruzioni mascherabili che giungono sul pin INTR.
Nel caso della NMI, la sua riattivazione potrebbe richiedere un circuito esterno.
7.3.1 Eccezioni in Modalità Reale
Bisogna prestare attenzione al fatto che in modalità reale, le eccezioni risultano
assegnate con alcune differenze rispetto alla modalità protetta.
Il vettore n. 8 viene assegnato ad una eccezione che segnala una IDT
troppo piccola; in modalità protetta invece tale vettore fa riferimento ad una
Double Fault.
I vettori dal 9 all'11 sono riservati.
I vettori 14 e 15 sono riservati.
7.4 Esempi pratici
In analogia a quanto esposto nel Capitolo 3, utilizziamo il DOS come base di
partenza; i programmi di esempio presentati nel seguito assumono quindi una struttura
di questo tipo:
all'accensione del PC la CPU si inizializza in modalità reale
(vedere la sezione 7.1)
il controllo passa al DOS
il DOS carica in memoria ed esegue il nostro programma
il programma passa in modalità protetta
il programma termina tornando in modalità reale
il controllo viene restituito al DOS
Per rispettare questa struttura, facciamo in modo che il nucleo (kernel) del nostro
programma risieda interamente nel primo MiB di RAM; una volta entrati poi in
modalità protetta, possiamo anche decidere se sfruttare ulteriore memoria presente
nei 4 GiB indirizzabili dalla 80386.
Osserviamo ora che, quando il DOS carica un programma in memoria, procede
alla rilocazione delle componenti Seg e Offset; noi però vogliamo che
gli offset partano da zero e non vengano rilocati in quanto, in modalità protetta, un
indirizzo virtuale è formato da una coppia Selector:Offset, dove Offset
è uno spiazzamento compreso tra 00000000h e FFFFFFFFh, da sommare al
Base Address. Ricordando quanto è stato esposto nel Capitolo 12 del tutorial
Assembly Base, in modalità reale gli offset partono da zero e non subiscono
rilocazione quando il segmento di appartenenza è allineato al paragrafo (16
byte); tutto ciò che dobbiamo fare allora consiste nel definire segmenti di programma
con quel tipo di allineamento.
Con NASM possiamo scrivere, ad esempio:
CODESEGM SEGMENT ALIGN=16 PUBLIC USE16 CLASS=CODE
In versione MASM:
CODESEGM SEGMENT PARA PUBLIC USE16 'CODE'
Per definire i descrittori dei vari segmenti di programma, abbiamo bisogno dei
rispettivi Base Address e ciò significa convertire a 32 bit gli
indirizzi fisici associati alle componenti Seg; tale compito è semplicissimo
in quanto sappiamo che, in modalità reale, l'indirizzo fisico a 20 bit
associato si ottiene moltiplicando Seg per 16 (shift di 4 bit
verso sinistra). Se, ad esempio, CODESEGM=03BFh, si ha:
03BFh * 16 = 03BF0h
Questo risultato va poi esteso a 32 bit con degli zeri a sinistra, in modo
da ottenere 00003BF0h.
Nel caso invece della memoria oltre il primo MiB, possiamo definire direttamente
dei descrittori di segmento con Base Address a 32 bit come, ad
esempio, 0013FBC0h; ciò è possibile in quanto stiamo accedendo ad un'area
della RAM al di fuori del controllo del DOS. Appare evidente però
che, per gestire al meglio una simile situazione, sarebbe opportuno scrivere un
apposito memory manager che si occupi di allocare e deallocare i blocchi di
memoria di cui abbiamo bisogno.
In relazione al campo LIMIT, abbiamo già visto che i segmenti usati per
il ritorno in modalità reale (nel nostro caso, DATASEGM, CODESEGM e
STACKSEGM), devono avere LIMIT=FFFFh, G=0 e D/B=0; in
tutti gli altri casi, possiamo procedere in vari modi.
Se decidiamo di esprimere la granularità in BYTE (G=0), la situazione
più semplice riguarda il caso in cui abbiamo a che fare con segmenti di lunghezza
predefinita; ad esempio, se per un segmento dati DSEGM abbiamo definito una
costante DSEGM_SIZE, otteniamo subito:
LIMIT = DSEGM_SIZE - 1
Se non conosciamo la dimensione esatta, possiamo servirci di una etichetta posta
alla fine del segmento; ad esempio, se alla fine di un segmento di codice di nome
CODESEG32 poniamo l'etichetta:
end_codeseg32:
allora si ha:
LIMIT = (offset end_codeseg32) - 1
Ci servono anche gli ACCESS BYTE per i tre tipi di segmenti di programma,
codice, dati e stack; a tale proposito, possiamo definire le tre costanti seguenti:
(Si può notare che in questo caso, per lo stack viene usato un normale segmento
Expand Up).
Tutto ciò torna utile nel momento in cui vogliamo scrivere procedure che
visualizzano stringhe o numeri nella memoria video; ricordando che alla modalità
video testo a colori è riservata un'area da 32 KiB a partire dall'indirizzo
logico B800h:0000h, si tratta di definire un apposito descrittore con
Base Address000B8000h, LIMIT pari a 32768-1 byte e
ACCESS BYTE di tipo ACCBYTE_DATA.
Per quanto riguarda gli attributi dei segmenti, definiamo delle costanti di questo
tipo:
Questi bit devono essere disposti nel nibble alto di un BYTE, in quanto
nel descrittore del segmento vanno ad occupare un'area da 1 byte che
comprende anche il nibble più significativo del campo LIMIT a 20
bit; se allora il BYTE degli attributi ha la forma AAAA0000b e
quello del campo LIMIT è 0000LLLLb, si ha:
AAAA0000b OR 0000LLLLb = AAAALLLLb
Una volta chiariti questi aspetti, vediamo in dettaglio i vari passi da compiere
per preparare la CPU al passaggio in modalità protetta e al ritorno in
modalità reale. Facciamo riferimento ad un primo esempio estremamente semplice,
che si basa sull'uso della sola GDT; tutto il codice gira a CPL=0,
i descrittori di segmento hanno tutti DPL=0 e i relativi selettori
specificano RPL=0.
7.4.1 Identificazione del tipo di CPU
Il requisito minimo per gli esempi che seguono è la presenza di una 80386; la
prima cosa da fare consiste quindi nell'identificare il tipo di CPU di cui
disponiamo.
Le informazioni mostrate in Figura 7.1 purtroppo sono inutilizzabili in quanto dopo
un RESET il controllo passa al BIOS il quale, nell'effettuare il proprio
lavoro, finisce per sovrascrivere vari registri, compreso DX; in questo capitolo
seguiamo allora un diverso metodo basato sul fatto che i vari modelli di CPU
della famiglia 80x86 presentano delle differenze nelle impostazioni iniziali del
registro FLAGS/EFLAGS.
Partiamo dal fatto che le 8086/8088 tengono sempre a 1 i quattro bit più
significativi (posizioni da 12 a 15) di FLAGS; un tentativo di
modificare tali bit (portandoli a 0) non produce alcun effetto.
Possiamo scrivere allora il seguente codice:
Se l'istruzione TEST produce 0 come risultato (e quindi ZF=1),
siamo in presenza di una 80286/80288 o superiore; se invece il risultato è
diverso da zero, abbiamo a che fare con una 8086/8088.
Si tenga presente che questo tipo di test potrebbe fallire con le 80186/80188
(che comunque sono modelli piuttosto rari).
Le 80286/80288, in modalità reale, tengono sempre a 0 i quattro bit più
significativi (posizioni da 12 a 15) di FLAGS; un tentativo di
modificare tali bit (portandoli a 1) non produce alcun effetto.
Possiamo scrivere allora il seguente codice:
Se l'istruzione TEST produce un risultato diverso da zero (e quindi ZF=0),
siamo sicuri di avere a disposizione una 80386 o superiore; se invece il risultato
è ZF=1, abbiamo a che fare con una 80286/80288.
Si ricordi che in questa fase la CPU si trova in modalità reale, per cui con le
80286/80288 o superiori il CPL è sempre 0; possiamo quindi modificare
liberamente i flags (come NT o IOPL) senza provocare un'eccezione.
Come si può vedere nei listati appena illustrati, è fondamentale ripristinare sempre il
contenuto originale del registro FLAGS/EFLAGS, in modo da rispettare le convenzioni
seguite dalla CPU; lo stesso discorso vale anche per registri come CR0/MSW.
7.4.2 Disabilitazione delle interruzioni mascherabili e della NMI
Ricordiamo che, una volta entrati in modalità protetta, non possiamo più
utilizzare le ISR predefinite che gestiscono la NMI e le
interruzioni hardware inviate dai PIC alla CPU attraverso il pin
INTR; tali ISR vengono installate in fase di avvio del PC
dal BIOS e dal DOS e sono destinate alla modalità reale. Se
arrivasse quindi una IRQ mentre siamo in modalità protetta, verrebbe
chiamata la relativa ISR con conseguente violazione dei meccanismi di
protezione.
Per evitare questa situazione, è necessario definire una IDT e installare
le proprie ISR per le IRQ e per le eccezioni generate dalla
80386; il secondo esempio presentato in questo capitolo illustra proprio
tale aspetto.
Le interruzioni mascherabili, come sappiamo, vengono disabilitate con la semplice
istruzione:
cli
Abbiamo visto che tale istruzione può essere eseguita senza restrizioni in
modalità reale, mentre in modalità protetta ciò è consentito solo ai task che
girano con CPL≤IOPL.
In relazione alla NMI, la situazione è più delicata in quanto il suo stato
può essere gestito via software solo su certi computer, mentre su altri ciò non
è possibile; in più, la documentazione su tale aspetto è piuttosto scarsa e su
Internet si possono reperire procedure di cui non è garantito il funzionamento.
Nel caso generale, sui PC di classe AT si sfrutta il fatto che
gli indirizzi per accedere ai 128 byte della CMOS attraverso la
porta 70h (Capitolo 4 del tutorial Assembly Avanzato), richiedono
al massimo 7 bit; si è deciso allora di utilizzare il bit più significativo
per gestire lo stato della linea NMI. Se il bit in posizione 7 viene
posto a 1, la NMI viene disabilitata; se lo stesso bit vale 0,
la NMI viene abilitata.
Una volta che il bit 7 è stato modificato, si deve subito effettuare una
lettura della porta 71h (porta di I/O della CMOS); questo
passaggio è necessario per evitare che il Real Time Clock (RTC) venga
lasciato in uno stato indefinito.
Possiamo scrivere allora il seguente codice:
Come è stato spiegato in precedenza, il funzionamento di questa procedura non è
garantito; si tenga anche presente che su certi PC la porta 70h è
accessibile in sola scrittura, per cui risulta impossibile ottenere lo stato
della CMOS.
7.4.3 Salvataggio dello stato dei PIC Master e Slave
Anche se in questo esempio non è prevista una IDT, prima di entrare in
modalità protetta è buona norma salvare lo stato dei due PIC, Master e Slave;
a tale proposito, dobbiamo effettuare una lettura dalle rispettive porte 21h
e A1h.
Come riporta il Capitolo 3 del tutorial Assembly Avanzato, un comando inviato
ad un PIC dopo la sua inizializzazione, viene trattato come Operational
Command Word (OCW); nel nostro caso, inviamo un OCW1 ad entrambi i
PIC in modo da avere come risposta lo stato (masked/unmasked) delle varie
IRQ, memorizzato nell'Interrupt Mask Register (IMR).
Supponendo allora di aver definito le due variabili picm_state e
pics_state, di tipo BYTE, possiamo scrivere il seguente codice:
7.4.4 Abilitazione della linea A20 dell'Address Bus
In modalità reale, sfruttando gli indirizzi logici Seg:Offset, nessuno
ci impedisce di scrivere FFFFh:FFFFh; l'unico MiB accessibile in tale
modalità è formato però da tutti gli indirizzi fisici a 20 bit compresi
tra 00000h e FFFFFh. L'ultimo BYTE della RAM (quello
di indice FFFFFh) può essere rappresentato con l'indirizzo logico
normalizzato FFFFh:000Fh; se proviamo ad avanzare di 1 otteniamo
FFFFh:0010h, che corrisponde all'indirizzo fisico a 21 bit
100000h. Con l'Address Bus a 20 linee della 8086,
il bit più significativo viene troncato e si ottiene 00000h; questo
fenomeno, detto wrap around, si ripete per tutti gli indirizzi logici
compresi tra FFFFh:0010h e FFFFh:FFFFh.
Con gli Address Bus a 24 e più linee delle CPU 80286 e
superiori, tali indirizzi logici sono perfettamente leciti, per cui il wrap
around non si verifica; si presenta allora il problema di come garantire la
compatibilità verso l'enorme quantità di programmi destinata alla modalità
reale 8086 (alcuni di tali programmi ricorrono in modo esplicito al
fenomeno del wrap around).
Il problema è stato risolto sfruttando un pin rimasto libero nel chip 8042
Keyboard Controller; si tratta del pin P21 visibile in Figura 7.6.
Attraverso P21, è possibile modificare lo stato della linea A20
dell'Address Bus; se P21=1, la linea A20 è abilitata, mentre
P21=0 tiene la linea A20 sempre a 0.
Tenendo la linea A20 a 0, il wrap around è garantito in quanto tutti
gli indirizzi logici compresi tra FFFFh:0010h e FFFFh:FFFFh
produrranno indirizzi fisici a 21 bit, con il bit più significativo (quello
in posizione 20) che vale sempre 0.
Per garantire la compatibilità verso la 8086, tutte le CPU della
famiglia 80x86 vengono inizializzate in modalità reale; la linea A20
viene quindi tenuta a 0 attraverso il pin P21 dell'8042.
Noi abbiamo la necessità di portare la 80386 in modalità protetta, con la
possibilità di accedere a tutti i 4 GiB di RAM (virtualmente)
disponibili, per cui dobbiamo riprogrammare in modo opportuno l'8042; in
pratica, si tratta di modificare il bit in posizione 1 di Figura 7.6, in
modo da avere P21=1.
Come risulta dal Capitolo 13 del tutorial Assembly Avanzato, il comando da
inviare alla porta di input per richiedere la modifica della Output Port di
Figura 7.6 è D1h; il successivo comando per abilitare la linea A20
(P21=1) è DFh.
Dobbiamo ricordare che, prima di scrivere nella porta di input dell'8042,
bisogna assicurarsi che il buffer di input sia vuoto; a tale proposito, si deve
attendere che il bit in posizione 1 dello Status Register si sia
portato a 0. Prima di leggere dalla porta di Output dell'8042,
bisogna assicurarsi che il buffer di output sia pieno; a tale proposito, si deve
attendere che il bit in posizione 0 dello Status Register si sia
portato a 1. Supponendo allora di avere a disposizione due apposite
procedure wait_for_write e wait_for_read, possiamo scrivere il
seguente codice:
Considerando il fatto che stiamo parlando di hardware piuttosto datato, il
codice appena illustrato potrebbe non essere compatibile con tutti i PC.
Un metodo alternativo consiste nel riprogrammare l'8042 in modo da
modificare direttamente il bit in posizione 1 di Figura 7.6; tale metodo
è disponibile nel codice sorgente presentato più avanti.
7.4.5 Salvataggio dello stack per la modalità reale
Prima di passare in modalità protetta, è importante salvare la coppia
SS:SP correntemente in uso in modalità reale; a tale proposito, basta
servirsi di due variabili di tipo WORD in cui memorizzare SS e
SP. Se chiamiamo le due variabili old_ss e old_sp, si ha:
7.4.6 Predisposizione della Global Descriptor Table
Qualunque programma destinato ad accedere alla modalità protetta, è tenuto
a predisporre almeno la GDT; come è stato spiegato nei precedenti
capitoli, la IDT e le LDT sono invece facoltative. Appare
chiaro però che, senza la IDT non possiamo gestire interruzioni ed
eccezioni; analogamente, senza le LDT non possiamo beneficiare dei
meccanismi di protezione tra i vari task e, soprattutto, non è garantito
l'isolamento tra task e SO.
Predisporre la GDT è semplicissimo. Definiamo un blocco dati destinato
a contenere i vari descrittori, di segmento e speciali; ogni descrittore, come
sappiamo, ha un'ampiezza di 8 byte. Il descrittore di indice 0
(simbolicamente, GDT[0]), deve essere il NULL descriptor.
Scriviamo un'apposita procedura destinata ad inserire i vari descrittori nella
GDT; i parametri richiesti per i segmenti di programma sono:
SegLimit (dimensione segmento), SegBaseAddr (indirizzo base del
segmento), SegAccByte (ACCESS BYTE del segmento), SegAttrib
(attributi del segmento) e SegIndex (indice del descrittore nella
GDT).
Il blocco dati in cui inserire la GDT è puntato da DS:BX; la
posizione in cui inserire un descrittore nella GDT si ottiene
moltiplicando SegIndex per 8. Possiamo scrivere allora il
seguente codice:
Questa procedura riceve in SegBaseAddr una componente Seg della
modalità reale e provvede a convertirla in un Base Address a 32
bit dopo averla moltiplicata per 16.
Gli ACCESS BYTE per i segmenti di programma di tipo CODE, DATA
e STACK, come è stato già esposto in precedenza, hanno la seguente struttura:
Per comodità, riserviamo GDT[1] al descrittore della stessa GDT.
GDT[2] viene riservato invece al segmento B800h in cui risulta
mappata la memoria video in modo testo; tale segmento viene usato da tutte le
procedure che mostrano stringhe e numeri sullo schermo, per cui il relativo
selettore deve essere disponibile globalmente.
In base a come abbiamo riempito la GDT, definiamo anche i selettori
associati ai vari segmenti; abbiamo, ad esempio:
e così via.
Una volta predisposta la GDT, possiamo caricare il relativo descrittore
nel GDTR, con l'istruzione LGDT; come sappiamo, tale istruzione
è disponibile anche in modalità reale in quanto il suo scopo è preparare
l'ambiente operativo per l'ingresso in modalità protetta.
7.4.7 Ingresso in modalità protetta
Una volta effettuate tutte le necessarie inizializzazioni, possiamo finalmente
abilitare il supporto per la modalità protetta della 80386.
Innanzi tutto, poniamo a 1 il bit PE in CR0 attraverso
l'istruzione MOV, ricordandoci di preservare tutti gli altri bit; possiamo
scrivere quindi:
Una volta posto PE=1, qualunque indirizzo viene trattato dalla
CPU come coppia Selector:Offset della modalità protetta!
A questo punto entriamo in modalità protetta attraverso un salto FAR
verso un indirizzo virtuale Selector:Offset; la componente Offset
punta all'etichetta da cui parte il nostro codice, mentre la componente
Selector è il selettore del segmento di programma contenente il codice
stesso.
CODESEG32 si serve di un segmento di dati DATASEG32 e di un segmento
di stack STACKSEG32, entrambi a 32 bit; i selettori per questi tre
segmenti sono:
Gli attributi sono:
Come si può notare, la granularità è in BYTE (G=0), mentre il campo
D/B vale 1 per tutti i segmenti; nel precedente capitolo abbiamo visto
che D/B=1, nel caso di segmenti di codice e di stack, indica che operandi e
indirizzamenti sono di default a 32 bit.
Per evitare che l'assembler effettui ottimizzazioni indesiderate sull'istruzione
JMP FAR, inseriamo direttamente il relativo opcode EAh; se abbiamo
definito un'etichetta protected_mode all'interno del segmento di codice
CODESEG32 con selettore SEL_CODESEG32, possiamo scrivere quindi:
(si può anche ricorrere ad un salto FAR indiretto, con opcode FFh
e indirizzo di tipo m16:m16)
Il salto viene effettuato da un segmento di codice con attributo USE16
e D/B=0, per cui l'operatore offset restituisce un valore a 16
bit.
Il salto deve essere FAR in quanto dobbiamo aggiornare, non solo il
registro IP, ma anche CS; le istruzioni precedenti fanno in
modo che il vecchio contenuto CODESEGM di CS venga sostituito
con il selettore SEL_CODESEG32.
L'altro aspetto di fondamentale importanza è che, in presenza di una qualsiasi
istruzione di salto, la CPU svuota la prefetch queue e la riempie con
le nuove istruzioni presenti a partire dalla destinazione del salto stesso;
nel nostro caso, le vecchie istruzioni per la modalità reale vengono eliminate
e sostituite con quelle per la modalità protetta, presenti a partire
dall'etichetta protected_mode.
Appena entrati in modalità protetta, dobbiamo svolgere un compito importantissimo
che consiste nell'inizializzare SS, DS, ES, FS e
GS con dei selettori validi; possiamo scrivere, ad esempio:
In modalità reale non esistono meccanismi di protezione, per cui nei registri di
segmento possiamo caricare anche dei valori casuali (che comunque provocherebbero
un crash); in modalità protetta ciò causerebbe una eccezione di segmento non valido!
Consideriamo, ad esempio, una procedura che usa ES dopo averne preservato
il contenuto con PUSH; al termine di tale procedura, lo stesso ES
viene ripristinato con l'istruzione POP. Se ES non era stato
inizializzato con un selettore valido prima della chiamata della procedura,
PUSH inserisce nello stack un valore casuale; di conseguenza, POP
estrae tale valore e lo carica in ES provocando, appunto, una eccezione di
segmento non valido.
Si tenga presente che all'interno di un segmento di codice USE32, tutti
gli inserimenti (PUSH) e estrazioni (POP) sono a 32 bit e
la CPU usa in modo predefinito ESP e EBP.
Un ulteriore compito da svolgere appena entrati in modalità protetta consiste
nell'impostare i campi NT e IOPL del registro EFLAGS; nel
nostro caso, non sono previsti task switch e tutto il codice gira a CPL=0,
per cui dobbiamo porre NT=0 e IOPL=0. Possiamo scrivere quindi:
Questa operazione deve essere eseguita dal kernel in quanto, come sappiamo, solo
un task che gira a CPL=0 può modificare IOPL.
7.4.8 Ritorno in modalità reale
Al termine del nostro programma, dobbiamo tornare in modalità reale in modo
da restituire correttamente il controllo al DOS; a tale proposito, è
importante seguire il procedimento descritto nella sezione 7.3.
Prima di riportare PE a 0 in CR0, i registri di segmento
per lo stack e per i dati devono contenere il selettore di un segmento compatibile
con la modalità reale; nel nostro caso, utilizziamo SEL_STACKSEGM (che è
il selettore di STACKSEGM) e SEL_DATASEGM (che è il selettore di
DATASEGM), per cui possiamo scrivere:
Saltiamo poi verso un segmento di codice compatibile con la modalità reale; tale
segmento deve avere quindi LIMIT=FFFFh, G=0 e D/B=0. Nel
nostro caso, torniamo al segmento CODESEGM attraverso il suo selettore
SEL_CODESEGM; se in CODESEGM abbiamo definito una apposita etichetta
ret_to_real_mode, possiamo scrivere quindi:
Il salto viene effettuato da un segmento di codice con attributo USE32
e D/B=1, per cui l'operatore offset restituisce un valore a 32
bit.
Riportiamo ora PE a 0 con le seguenti istruzioni:
Una volta posto PE=0, qualunque indirizzo viene trattato dalla
CPU come coppia Seg:Offset della modalità reale!
Per svuotare la prefetch queue e riempirla con le istruzioni per la modalità
reale, effettuiamo un salto FAR intrasegmento che carica CODESEGM
in CS al posto di SEL_CODESEGM; se in CODESEGM abbiamo
definito un'etichetta real_mode, possiamo scrivere quindi:
Ripristiniamo i registri di segmento per i dati con le istruzioni:
Ripristiniamo lo stack con le istruzioni:
Disabilitiamo la linea A20 e ripristiniamo lo stato dei PIC;
riattiviamo la gestione delle NMI con le istruzioni:
Infine, riattiviamo la gestione delle interruzioni mascherabili con l'istruzione
STI.
7.4.9 Configurazione degli emulatori DOS e delle macchine virtuali
Gli esempi presentati in questo capitolo sono stati testati con successo con
l'emulatore DOSBox-X e con MS-DOS 6.22 installato nella macchina
virtuale 86Box; prima di procedere, dobbiamo quindi configurare in modo
opportuno l'ambiente operativo che intendiamo usare.
Bisogna ricordare che quando il nostro programma passa in modalità protetta,
può svolgere una serie di operazioni totalmente al di fuori del controllo del
DOS, dei vari DOS extender, gestori della memoria espansa, etc;
è importantissimo quindi disattivare tutto ciò che ha a che vedere con
XMS, EMS e HMA.
Nel caso di MS-DOS installato in 86Box, dall'interno della macchina
virtuale dobbiamo editare (con EDIT) il file C:\CONFIG.SYS e
commentare (con REM) le seguenti righe (se presenti):
86Box è capace di emulare in modo estremamente accurato l'hardware dei
vecchi PC basati sulle CPU dalla 8088 ai primi modelli
di Pentium; nel nostro caso, dobbiamo impostare almeno una 80386.
Per gli esempi presentati in questo capitolo è stata scelta la seguente
configurazione hardware:
DOSBox-X è un emulatore DOS, per cui tutte le impostazioni
hardware e software sono disponibili nel relativo file di configurazione
dosbox-x.conf; dopo aver editato tale file, dobbiamo impostare su
false il supporto XMS, EMS e HIMEM nella sezione
[dos].
Nella sezione [dosbox] il campo memsize deve valere almeno
16 (MiB).
Nella sezione [cpu], come modello di CPU possiamo scegliere:
cputype = 486
Nella sezione [keyboard] si consiglia di impostare:
7.4.10 Programma di esempio con GDT
Mettendo assieme tutti i concetti appena esposti, possiamo scrivere un semplice
programma che ha unicamente lo scopo di mostrare come entrare in modalità
protetta e come tornare in modalità reale; a tale proposito, come è stato già
anticipato, utilizziamo solo la GDT e facciamo girare tutto il codice a
CPL=0. Tutti i segmenti di codice, dati e stack hanno quindi DPL=0;
analogamente, tutti i selettori specificano RPL=0.
Come al solito, suddividiamo il nostro programma in un modulo principale che
rappresenta il kernel, un modulo che contiene tutte le procedure di gestione
dell'hardware e un ulteriore modulo riservato alla GDT.
Facciamo anche ricorso ad apposite macro per tutte quelle procedure che richiedono
parametri; in questo modo possiamo scrivere codice più semplice e chiaro, simile a
quello dei linguaggi di alto livello.
Il riempimento della GDT, ad esempio, assume un aspetto del genere:
Per i tre segmenti principali, la dimensione è data dalla costante RMSEG_SIZE
che vale FFFFh.
Per lo stack utilizziamo normali segmenti Expand Up. STACKSEGM è
destinato alla modalità reale, mentre STACKSEG32 viene usato all'interno
della modalità protetta.
Le componenti Seg sono a 16 bit e devono essere passate alla
procedura add_gdt_segdescr come valori a 32 bit; per questo motivo
vengono usate le variabili di tipo DWORDcode_dseg16,
data_dseg16, etc.
Si può notare che viene definito anche un segmento dati HMEMDSEGM, con
LIMIT=FFFFFh (1 MiB), G=0 e D/B=1; il Base
Address è 00A00000h (10485760) e deve essere passato alla
procedura add_gdt_segdescr come 000A0000h (shift a destra di 4
bit).
Questo segmento dati è a 32 bit, per cui al suo interno possiamo definire,
ad esempio, un vettore di 100000 elementi di tipo WORD, pari a
200000 byte di spazio occupato; ciò non è ovviamente possibile in modalità
reale. Di conseguenza, all'interno dello stesso segmento possiamo accedere a degli
offset superiori a FFFFh.
Per ulteriori dettagli, si può fare riferimento ai commenti presenti nel codice
sorgente.
La Figura 7.7 mostra il codice sorgente del modulo principale KRNL32A.ASM,
contenente il kernel del nostro esempio.
Selezione codice sorgente:
La Figura 7.8 mostra il codice sorgente del modulo HRDW32A.ASM, che
contiene le procedure per l'accesso all'hardware.
La Figura 7.9 mostra il codice sorgente del modulo GDT32A.ASM, che
contiene la GDT e le procedure per la sua gestione.
I moduli HRDW32A.ASM e GDT32A.ASM sono disponibili solo in versione
NASM; gli object file che si ottengono sono perfettamente linkabili al
modulo KRNL32A.ASM per MASM. Se lo si ritiene opportuno, possono
essere facilmente convertiti in versione MASM, grazie alla elevata
compatibilità tra i due assembler; sono richiesti MASM 6.11 e NASM
2.15.5 o superiore.
La Figura 7.10 mostra l'output del programma su MS-DOS 6.22 installato
nella macchina virtuale 86Box.
Il programma visualizza una serie di informazioni, compreso l'indirizzo di ingresso
in modalità protetta; in Figura 7.10 si può notare che il registro CS contiene
il valore 0030h, che è proprio il selettore del segmento CODESEG32
(infatti, affiancando INDEX=0000000000110b, TI=0b e RPL=00b, si
ottiene in esadecimale 0030h).
L'offset di ingresso in modalità protetta è 00000000h in quanto l'etichetta
protected_mode si trova proprio all'inizio di CODESEG32.
In seguito, viene usata l'istruzione SGDT per ricavare il Base Address
della GDT contenuto nel GDTR; in questo modo possiamo scorrere la
stessa GDT e visualizzare i vari descrittori in essa presenti.
Nel caso, ad esempio, di CODESEG32, possiamo notare che il segmento è stato
caricato in memoria dal DOS al paragrafo 22CFh (questo valore può
variare da un computer all'altro); nel descrittore dello stesso segmento, tale
paragrafo è stato convertito nel Base Address a 32 bit 00022CF0h
(con shift a sinistra di 4 bit e aggiunta di zeri a sinistra).
Sempre nel descrittore di CODESEG32, il 4 si riferisce al nibble
degli attributi, mentre 9Bh è l'ACCESS BYTE.
Sfruttando poi il fatto che siamo in modalità protetta e la linea A20 è attiva,
una stringa viene letta da DATASEG32 e copiata con REP MOVSB in un
segmento dati HMEMDSEGM che si trova oltre il primo MiB, all'indirizzo a
32 bit 00A00000h (10485760); successivamente, tale stringa viene
visualizzata sullo schermo (e quindi copiata da HMEMDSEGM a 000B8000h).
La stringa viene copiata all'offset 100000 di HMEMDSEGM; chiaramente,
ciò non è possibile in modalità reale.
Osserviamo come, con la modalità protetta attiva (PE=1 in CR0), tutte
le istruzioni si comportino di conseguenza; REP MOVSB usa sempre DS:ESI
come sorgente e ES:EDI come destinazione, ma stavolta DS e ES
contengono i selettori di due segmenti di dati che possono trovarsi in un'area
qualunque dei 4 GiB indirizzabili dalla 80386!
La chiamata delle varie procedure avviene in modo formalmente identico alla modalità
reale; non è necessario l'uso dei Call Gate in quanto, anche saltando da un
segmento all'altro, non si verifica alcun cambio di privilegio.
Le procedure print_string32, print_hexnum32 e print_binnum32 si
trovano in un segmento di codice a 32 bit, per cui possono usare operandi e
indirizzi a 32 bit.
In Figura 7.10 si può osservare che l'indirizzo di ritorno in modalità reale è
229Eh:01C1h; infatti, tale indirizzo si trova nel segmento CODESEGM
che, come si vede nella stessa figura, è stato caricato dal DOS al paragrafo
229Eh.
7.4.11 Segmenti di codice e dati nella memoria oltre il primo MiB
Abbiamo visto che, una volta entrati in modalità protetta, possiamo disporre a
nostro piacimento di tutta la memoria presente oltre il primo MiB; in particolare,
possiamo usare tale memoria per immagazzinare grosse quantità di dati.
Vediamo ora un semplice esempio che consiste nel leggere il contenuto dei segmenti
DATASEG32 e CODESEG32 e copiarlo nella memoria oltre il primo MiB, in
due altri segmenti denominati DATA32H e CDS32H; l'ingresso in modalità
protetta avviene tramite un salto verso CDS32H.
Ciò è possibile in quanto un eseguibile caricato in memoria non è altro che una
sequenza di codice macchina che rappresenta istruzioni e dati; alla CPU
interessa sapere a che offset si trovano tali informazioni nei segmenti di
appartenenza. In modalità protetta, gli offset sono spiazzamenti, compresi tra
0 e LIMIT-1, riferiti ad un Base Address; l'indirizzo fisico
viene ottenuto sommando un offset allo stesso Base Address, che può trovarsi
in qualunque area della memoria disponibile.
Essendo proibito scrivere in un segmento di codice come CDS32H, utilizziamo
l'espediente descritto nel precedente capitolo, che consiste nell'usare un apposito
segmento dati scrivibile (DTS32H), sovrapposto allo stesso CDS32H. In
realtà, il nostro programma gira a CPL=0, per cui siamo autorizzati a
scrivere anche nei segmenti di codice; per rispettare le regole però utilizziamo
ugualmente il metodo dei segmenti sovrapposti.
Prima di tutto, aggiungiamo alla GDT i seguenti tre segmenti:
Come si può notare, CDS32H e DTS32H hanno lo stesso Base Address
00200000h e la stessa dimensione di 1 MiB (LIMIT=FFFFFh); il
segmento DATA32H si trova al Base Address 00400000h.
I selettori per questi segmenti sono:
All'interno di CODESEG32, è importantissimo effettuare la seguente
modifica, in modo che venga utilizzato DATA32H come segmento dati:
All'interno di CODESEGM, una volta posto PE=1 in CR0,
effettuiamo un salto FAR intrasegmento per aggiornare la prefetch queue;
copiamo poi CODESEG32 in DTS32H (e quindi in CDS32H) con le
seguenti istruzioni:
In pratica, viene effettuato un trasferimento dati da DS:0000h di
CODESEG32 a ES:0000h di DTS32H; il numero di BYTE
da trasferire (CX) è dato dalla dimensione di CODESEG32.
Si tenga presente che CODESEGM è un segmento a 16 bit, per cui
MOVSB usa DS:SI come sorgente, ES:DI come destinazione e
CX come contatore.
Copiamo ora DATASEG32 in DATA32H con le seguenti istruzioni:
A questo punto, effettuiamo un salto FAR intersegmento verso l'etichetta
protected_mode, usando però SEL_CDS32H come selettore; in questo
modo possiamo notare che le istruzioni vengono eseguite normalmente dalla
CPU, come nell'esempio di Figura 7.9!
L'etichetta protected_mode viene vista dalla CPU come un offset
00000000h rispetto al Base Address del segmento di appartenenza;
che il segmento sia CODESEG32 o CDS32H, non fa alcuna differenza.
La Figura 7.11 mostra il listato di KRNL32A2.ASM in versione NASM.
La Figura 7.12 mostra l'output del programma su MS-DOS 6.22 installato
nella macchina virtuale 86Box.
Si può notare che stavolta, appena entrati in CDS32H, il registro CS
contiene il valore 0048h, che è proprio il selettore SEL_CDS32H dello
stesso CDS32H; il Base Address di questo segmento di codice è
00200000h (2 MiB), mentre quello di DATA32H è
00400000h (4 MiB).
7.4.12 Programma di esempio con GDT e IDT
Tutto il codice di KRNL32A relativo all'ingresso in modalità protetta e
al ritorno in modalità reale, consiste in una serie di azioni comuni a tutti i
programmi di questo tipo; vediamo infatti ora un nuovo esempio, denominato
KRNL32B, che riutilizza il 100% del codice scritto per lo stesso
KRNL32A.
KRNL32B introduce una funzionalità fondamentale, che consiste nella
gestione delle interruzioni; in questo modo abbiamo la possibilità di vedere
ciò che accade in presenza di violazioni (volontarie o involontarie) dei
meccanismi di protezione della 80386.
In base a quanto è stato esposto nel precedente capitolo, i primi 32
vettori di interruzione (da 00h a 1Fh) sono riservati alla
80386 per la gestione delle interruzioni software (comprese quindi le
eccezioni); per questo motivo, la IDT deve avere una dimensione minima
pari a 32*8 byte. Il nostro obiettivo è quello di gestire anche le
richieste di interruzione hardware (IRQ) generate dai due PIC;
si tratta quindi di aggiungere alla IDT ulteriori 8+8=16 vettori
di interruzione, per un totale di 48.
Osservando la Figura 3.10 del Capitolo 3, tutorial Assembly Avanzato,
possiamo notare che si presenta un problema, dovuto al fatto che le 8
IRQ gestite dal PIC Master, vengono associate ai vettori che
vanno da 08h a 0Fh; si verifica quindi una sovrapposizione con
i corrispondenti vettori riservati alla 80386. Ciò che dobbiamo fare
consiste quindi nel riprogrammare il PIC Master per eliminare tale
sovrapposizione. Per evitare di lasciare buchi nella IDT, associamo le
IRQ del PIC Master ai vettori che vanno da 32 a 39
(da 20h a 27h); per lo stesso motivo, riprogrammiamo anche il
PIC Slave associando le sue IRQ ai vettori che vanno da
40 a 47 (da 28h a 2Fh).
Il codice da scrivere è lo stesso presentato nel già citato Capitolo 3 e
consiste nell'inviare in sequenza i 4 comandi di inizializzazione
ICW; a tale proposito, si consiglia di rileggere la parte relativa
alla programmazione dei PIC.
Definiamo innanzi tutto le costanti relative alle porte dei PIC:
A questo punto, indicando con MPIC_BASE_TYPE il Base Type 20h
del PIC Master e con SPIC_BASE_TYPE quello (28h) del
PIC Slave, possiamo scrivere:
Ci serve anche il codice necessario per abilitare solamente le IRQ che
intendiamo gestire. Nel nostro caso, vogliamo intercettare i codici di scansione
generati dalla tastiera in seguito alla pressione dei tasti, per cui dobbiamo
abilitare la sola IRQ1 (che abbiamo associato al vettore n. 21h);
come sappiamo, tale IRQ è connessa al chip 8042 Keyboard Controller.
Ciò che dobbiamo fare consiste nell'inviare un comando OCW1 per modificare
i bit desiderati nell'IMR del PIC; abbiamo visto che, se un bit vale
0, la corrispondente IRQ è abilitata (unmasked), mentre se vale
1 è disabilitata (masked). Ponendo allora in AL la bitmask per il
PIC Master e in AH quella per il PIC Slave, possiamo scrivere:
Inserendo questo codice in una procedura chiamata PIC_mask, se vogliamo
abilitare la sola IRQ1 ci possiamo servire delle seguenti istruzioni:
Chiaramente, bisogna ricordare che prima di effettuare queste impostazioni, lo
stato dell'IMR dei PIC deve essere salvato, come abbiamo fatto
nell'esempio KRNL32A.
Come al solito, una volta tornati in modalità reale, dobbiamo ripetere in ordine
inverso tutte le impostazioni precedentemente effettuate per l'ingresso in
modalità protetta; in particolare, è di vitale importanza riprogrammare i
PIC in modo da ripristinare i Base Type, che sono 08h per
il PIC Master e 70h per il PIC Slave.
Per la gestione della IDT ci serviamo di un apposito modulo denominato
IDT32B.ASM; il lavoro da svolgere è del tutto simile a quello già visto
per la GDT.
Dobbiamo ricordare che nella IDT possiamo inserire solo descrittori di
Interrupt Gate, Trap Gate o Task Gate; per i relativi
ACCESS BYTE ci serviamo delle seguenti costanti:
Come si può notare, abbiamo DPL=0 in tutti i descrittori, per cui anche
le relative ISR gireranno a CPL=0.
Per semplicità, ci serviamo solo di Interrupt Gate, anche se, in certi casi,
tale scelta non è corretta; nel Capitolo 2 infatti abbiamo visto che determinate
eccezioni devono essere gestite tramite Task Gate.
Come sappiamo, un descrittore di Interrupt Gate deve contenere, oltre
all'ACCESS BYTE, l'offset della ISR da chiamare e il selettore
che punta al descrittore del segmento di codice contenente la ISR stessa;
tale descrittore viene inserito nella GDT. Indicando con ics_sel
il selettore e facendo puntare DS:BX alla IDT, per una generica
ISR denominata isr_n possiamo scrivere:
(Si noti che gli offset delle varie ISR hanno un'ampiezza di 32
bit).
Per evitare di ripetere 32 volte questo codice, nell'esempio che segue
vengono definiti 32 puntatori alle ISR e si ricorre ad un loop che
effettua 32 iterazioni.
Per i 16 vettori associati alle IRQ viene usata la stessa tecnica;
è importante ricordare che, in questo caso, le relative ISR devono inviare
sempre l'EOI ai PIC di competenza.
Le ISR associate ai 32 vettori riservati alla 80386 si
limitano a mostrare un messaggio di errore; solo alcune mostrano anche l'indirizzo
di ritorno e l'eventuale codice di errore.
Lo stesso discorso vale per le ISR associate ai 16 vettori dei
PIC; a noi interessa intercettare solo la IRQ1 proveniente dalla
tastiera, per cui la relativa ISR mostra gli scancode generati dalla
pressione o dal rilascio dei tasti.
Una volta riempita la IDT, carichiamo nell'IDTR il relativo
descrittore tramite l'istruzione LIDT; a tale proposito, utilizziamo una
procedura che richiede un parametro il cui valore può essere 0 o 1.
Il valore 0 indica che ci stiamo riferendo alla IDT per la modalità
protetta; il valore 1 invece ripristina la IVT per la modalità reale
(come indicato in 3.1.4 del Capitolo 3).
Per ulteriori dettagli, si può fare riferimento ai commenti presenti nel codice
sorgente.
La Figura 7.13 mostra il codice sorgente del modulo principale KRNL32B.ASM,
contenente il kernel del nostro esempio.
Selezione codice sorgente:
La Figura 7.14 mostra il codice sorgente del modulo HRDW32B.ASM, che
contiene le procedure per l'accesso all'hardware.
La Figura 7.15 mostra il codice sorgente del modulo IDT32B.ASM, che
contiene la IDT e le procedure per la sua gestione.
La Figura 7.16 mostra il codice sorgente del modulo GDT32B.ASM, che
contiene la GDT e le procedure per la sua gestione.
I moduli HRDW32B.ASM, IDT32B.ASM e GDT32B.ASM sono disponibili
solo in versione NASM; gli object file che si ottengono sono perfettamente
linkabili al modulo KRNL32B.ASM per MASM. Come al solito, questi
moduli possono essere facilmente convertiti in versione MASM.
La Figura 7.17 mostra l'output del programma sull'emulatore DOSBox-X.
Il programma KRNL32B, una volta entrato in modalità protetta, permette di
generare tre tipi di eccezione; la prima è una "division by zero" che si può
ottenere con il codice:
La CPU chiama il vettore 00h e inserisce nello stack return
CS:EIP che punta all'istruzione (DIV BX) responsabile del problema;
è compito della ISR gestire questa situazione per evitare un loop
infinito.
Nel nostro caso, l'opcode di DIV BX occupa 2 byte, per cui nella
ISR incrementiamo return EIP di 2. Per capire come
individuare la posizione di return EIP nello stack, possiamo osservare
la Figura 7.18.
Per le eccezioni non associate ad un codice di errore (Figura 7.18 (a)), salviamo
innanzi tutto EBP nello stack e poniamo EBP=ESP; a questo punto si
può notare che return EIP si trova a EBP+4.
Se l'eccezione è associata ad un codice di errore (Figura 7.18 (b)), la prima cosa
da fare è estrarre il codice stesso con, ad esempio, POP EAX; anche in questo
caso quindi, dopo aver salvato EBP nello stack e posto EBP=ESP, si
perviene alla stessa situazione di Figura 7.18 (a).
Si tenga presente che il codice appena illustrato è un semplice esempio e
funziona solo se l'eccezione è stata provocata da una istruzione con opcode da
2 byte; in caso contrario, il programma si blocca. In una situazione
reale, in genere il SO chiude il task che ha provocato il problema.
Un altro caso è la "interrupt on overflow" che possiamo ottenere con il
codice:
La CPU chiama il vettore 04h e inserisce nello stack return
CS:EIP che punta all'istruzione successiva a INTO; si tratta quindi
di una situazione che non presenta problemi in quanto generata da una
eccezione non critica.
La Figura 7.17 mostra infine il caso di una "general protection exception"
che possiamo ottenere, ad esempio, caricando in ES un selettore non
valido, come 1234h; la CPU chiama il vettore 0Dh e inserisce
nello stack return CS:EIP che punta alla stessa istruzione che ha provocato
il problema e anche un codice di errore (nel nostro caso, si tratta proprio del
selettore 1234h).
La ISR deve provvedere ad estrarre il codice di errore dallo stack e a
gestire correttamente il return CS:EIP; anche in questo caso, il nostro
esempio funziona solo se l'istruzione che ha creato il problema ha un opcode
da 2 byte.
Come si vede in Figura 7.17, l'indirizzo di ritorno è l'offset 000000A0h
del segmento CODESEG32 (selettore 0030h); se si chiede all'assembler
di generare il listing file di KRNL32B.ASM (nella versione per NASM),
si può notare che l'istruzione successiva a quella che ha generato l'eccezione si
trova proprio all'offset 000000A0h.
Prima di lasciare la modalità protetta, il programma KRNL32B avvia un
loop dal quale si esce premendo il tasto [ESC]; come si può notare, la
CPU associa correttamente la IRQ1 al vettore 21h come da
noi stabilito attraverso la riprogrammazione dei PIC.
Una volta tornati in modalità reale, dobbiamo svolgere un compito estremamente
importante che consiste nel ripristinare i Base Type predefiniti 08h
e 70h dei due PIC, così come lo stato (salvato in precedenza) dei
rispettivi IMR; infine, nell'IDTR dobbiamo caricare il descrittore
della IVT, con Base Address 00000000h e LIMIT 1024-1.
Per verificare la correttezza di queste reimpostazioni, vediamo in Figura 7.17
che viene chiesto di premere un tasto per terminare il programma; questa volta,
la CPU associa la IRQ1 alla ISR predefinita (vettore
09h) installata dal BIOS (o dal DOS) per la modalità reale.
7.4.13 Programma di esempio con GDT, LDT, IDT e Task Switch
Vediamo un ultimo esempio KRNL32C il quale, ancora una volta, riutilizza
il 100% del codice scritto in precedenza. Formalmente, KRNL32C appare del
tutto simile a KRNL32B; la differenza sostanziale sta nel fatto che
stavolta, il kernel cede il controllo ad un task che gira a CPL=3!
Il kernel rappresenta il task principale, denominato TASK0, mentre in un
nuovo modulo TSK132C viene inserito il codice di un secondo task indicato
con TASK1. TASK0 usa solo la GDT, per cui ha bisogno di un
TSS i cui campi Back Link e LDT Selector puntano al NULL
SELECTOR; TASK1 invece ha bisogno di un TSS e di una LDT,
la quale specifica i descrittori dei segmenti privati di codice dati e stack usati
(lo spazio di indirizzamento virtuale privato del task stesso).
TASK1 non è innestato in TASK0, per cui il campo Back Link
del suo TSS punta al NULL SELECTOR; il campo LDT Selector
invece punta alla LDT.
TASK1 gira a CPL=3, per cui deve disporre di un apposito stack con
DPL=3; inoltre, dobbiamo anche definire uno stack con DPL=0 per
l'accesso ai servizi forniti dal kernel (che girano in questo caso a CPL=0).
Il riempimento della LDT è del tutto simile a quanto già visto per la
GDT e la IDT; nel nostro caso, dobbiamo inserire i descrittori per
i segmenti di codice, dati, stack con DPL=0 e stack con DPL=3. Si
tratta di normali segmenti di programma, i cui descrittori hanno quindi una
struttura che già conosciamo; se DS:BX punta alla LDT, per il
segmento privato di dati (TSK1DATA32), ad esempio, possiamo scrivere:
Osserviamo che in questo caso, gli ACCESS BYTE per codice, dati e stack a
CPL=3 specificano DPL=3, mentre per lo stack a CPL=0 si ha
DPL=0 (anche per questo esempio, utilizziamo normali segmenti di tipo
Expand Up per i due stack).
Analogamente, per il campo RPL nei selettori degli stessi segmenti si ha:
Per gli attributi dei segmenti abbiamo:
Si può notare che i segmenti di dati (TASK1DATA32) e di codice
(TASK1CODE32) sono di tipo USE32; nel campo degli attributi viene
specificato D/B=1. Lo stesso vale per i due stack utilizzati per i livelli
di privilegio 3 e 0; tali segmenti vengono disposti in memoria
oltre il primo MiB, agli indirizzi 00120000h e 00130000h.
In relazione ai TSS la situazione è più delicata; in questo caso infatti
dobbiamo riempire tutti i campi essenziali per un corretto Task Switch.
Per TASK0 dobbiamo riempire i campi Back Link, LDT Selector
e SS:ESP per CPL=0; gli altri campi (stato corrente di TASK0)
vengono riempiti in automatico dalla 80386 quando viene ceduto il controllo
a TASK1.
Per TASK1 invece è essenziale riempire i campi Back Link, LDT
Selector, SS:ESP per CPL=0 e tutti i registri che puntano ai
segmenti di programma privati con DPL=3, come SS:ESP, CS:EIP
(entry point del task), DS, ES, FS e GS. Ovviamente,
nei registri di segmento vanno inseriti i relativi selettori.
Sempre in relazione al TSS di TASK1, è importante inizializzare
anche il campo EFLAGS; nel nostro caso, dobbiamo porre IF=1 in modo
da abilitare le interruzioni mascherabili.
Se DS:BX punta al TSS, ldt_sel è il selettore della LDT
e l'entry point del task è rappresentato dall'etichetta task1_start, abbiamo
quindi (Figura 6.16 del Capitolo 6):
Come risulta dai manuali della 80386, un valore di I/O Map Base
maggiore o uguale a LIMIT del TSS, indica che la I/O Map
è assente; in questo caso, la CPU autorizza il task a compiere
operazioni di I/O con le porte hardware solo se CPL≤IOPL.
Come al solito, quando viene restituito il controllo a TASK0, la
CPU provvede a salvare lo stato corrente di TASK1 negli appositi
campi del TSS associato.
Bisogna ricordare che la gestione di TSS e LDT è di competenza
del kernel; di conseguenza, i relativi descrittori vanno inseriti obbligatoriamente
nella GDT.
Trattandosi di normali segmenti di dati, i descrittori vengono inseriti nella
GDT con lo stesso procedimento usato per i generici segmenti di programma;
è necessario solo ricordare che gli ACCESS BYTE devono specificare
S=0, mentre il campo TYPE deve valere 0010b per le LDT
e 1001b per i TSS.
L'esempio KRNL32C illustra anche l'utilizzo di un Call Gate, tramite
il quale TASK1 può chiamare un servizio fornito dal kernel; in questo caso
si tratta di una procedura kprint_string32 che visualizza una stringa sullo
schermo.
Il descrittore di Call Gate viene inserito nella GDT (perché è un
servizio fornito dal kernel) tramite un'apposita procedura; in questo caso,
l'ACCESS BYTE deve specificare S=0 e TYPE=1100b.
L'elenco dei vari servizi forniti dal kernel si trova in un include file chiamato
KRNL32C.INC, che deve essere incluso in TSK132C.ASM.
Siccome kprint_string32 richiede 5 parametri di tipo DWORD, nel
campo DWORD COUNT del descrittore del Call Gate dobbiamo inserire il
valore 5.
Lo stack di sinistra ha DPL=3 ed è associato al CPL=3 di TASK1
nel modulo TSK132C; vengono inseriti (in questo esempio) tre parametri di
tipo DWORD e viene chiamata una procedura tramite un Call Gate.
La procedura si trova definita in un segmento di TASK0 con CPL=0, per
cui la CPU copia tutto il necessario nel nuovo stack con DPL=0 (la
cui coppia SS:ESP è memorizzata nel TSS di TASK1); come si vede
in Figura 7.19, dopo i tre parametri vengono inseriti nel nuovo stack il return
CS (come WORD bassa di una DWORD) e il return EIP.
Per accedere ai parametri, dobbiamo prima salvare EBP nello stack e porre
poi EBP=ESP; a questo punto, si può notare che Param1 è a
EBP+12, Param2 è a EBP+16 e Param3 è a EBP+20.
Per ulteriori dettagli, si può fare riferimento ai commenti presenti nel codice
sorgente.
La Figura 7.20 mostra il codice sorgente del modulo principale KRNL32C.ASM,
contenente il kernel (TASK0) del nostro esempio.
Selezione codice sorgente:
La Figura 7.21 mostra il codice sorgente del modulo TSK132C.ASM, contenente
il TASK1 del nostro esempio, il TSS e la LDT.
Selezione codice sorgente:
La Figura 7.22 mostra il codice sorgente del modulo HRDW32C.ASM, che
contiene le procedure per l'accesso all'hardware.
La Figura 7.23 mostra il codice sorgente del modulo IDT32C.ASM, che
contiene la IDT e le procedure per la sua gestione.
La Figura 7.24 mostra il codice sorgente del modulo GDT32C.ASM, che
contiene la GDT e le procedure per la sua gestione.
I moduli HRDW32C.ASM, IDT32C.ASM e GDT32C.ASM sono disponibili
solo in versione NASM; gli object file che si ottengono sono perfettamente
linkabili al modulo KRNL32C.ASM per MASM. Questi moduli possono essere
facilmente convertiti in versione MASM.
La Figura 7.25 mostra l'output del programma su DOSBox-X.
KRNL32C, dopo le solite impostazioni dell'hardware, procede con il
riempimento di LDT, TSS, IDT e GDT; come si può
notare, nella GDT vengono inseriti anche i descrittori di TSS0,
TSS1, LDT1 e Call Gate per la procedura kprint_string32.
Dopo l'ingresso in modalità protetta, si passa al caricamento dei registri
LDTR e TR con le informazioni relative al primo task da eseguire;
nel nostro caso si tratta ovviamente di TASK0.
TASK0 non usa la LDT per cui nel LDTR dobbiamo caricare
il NULL SELECTOR; in TR dobbiamo invece mettere il selettore
del TSS0.
A questo punto TASK0 effettua un salto diretto a TASK1; la
CPU provvede a salvare in TSS0 lo stato dello stesso TASK0
e ad aggiornare in automatico LDTR e TR. Come sappiamo, la
destinazione del salto è il selettore (SEL_TSS1SEGM) che punta a
TSS1, mentre la componente Offset viene ignorata; con NASM
si potrebbe scrivere:
call SEL_TSS1SEGM:00000000h
La sintassi varia però da un assembler all'altro.
Un metodo che funziona sempre è il ricorso al codice macchina della precedente
istruzione; possiamo scrivere:
Quando TASK1 riceve il controllo, svolge un compito del tutto simile
a quello di KRNL32B; oltre a provocare delle eccezioni a scelta, mostra
una stringa e poi attende che l'utente prema il tasto [ESC].
La parte interessante è la stampa della stringa, attraverso un Call Gate
che permette di chiamare la procedura kprint_string32; si tratta solo di
un esempio che ha lo scopo di mostrare come i vari task possano richiedere dei
servizi al kernel.
In questo caso abbiamo TASK1 che gira a CPL=3 e richiede un
servizio a TASK0 che invece gira a CPL=0; come sappiamo, prima di
autorizzare la chiamata della procedura, la CPU prende in esame i seguenti
quattro livelli di privilegio:
Il CPL contenuto nei due bit meno significativi di CS.
Il RPL contenuto nel selettore usato come operando della CALL.
Il DPL del descrittore di Call Gate (DPL_SRC).
Il DPL del descrittore del segmento di codice a cui saltare (DPL_DEST).
La chiamata è valida solo se:
max(CPL, RPL) ≤ DPL_SRC
e
DPL_DEST ≤ CPL
Quindi, il caller deve essere più privilegiato del gate, mentre il segmento di
destinazione (dove si trova kprint_string32) deve essere più privilegiato
del caller.
Anche per la chiamata di kprint_string32 tramite selettore di Call
Gate (SEL_KPRINTSTR), ci possono essere differenze sintattiche tra i
vari assembler; con NASM possiamo scrivere:
Questa sintassi non viene accettata da MASM; ricorrendo allora al codice
macchina, abbiamo:
Dopo la pressione del tasto [ESC], TASK1 restituisce il controllo
a TASK0; in questo caso, il task switch è stato ottenuto tramite una
CALL, per cui una conseguente istruzione IRETD trova NT=1
in EFLAGS e provoca un altro task switch per tornare a TASK0. Come
al solito, la CPU provvede a salvare in TSS1 lo stato di TASK1
e ad aggiornare LDTR e TR.
Con il programma KRNL32C si possono fare numerosi esperimenti per testare
i meccanismi di protezione della 80386; a tale proposito, si consiglia di
commentare prima il codice che genera le eccezioni nel file TSK132C.ASM.
Per questo tipo di esperimenti conviene decisamente orientarsi su una macchina
virtuale, come 86Box o VirtualBox; tali strumenti infatti offrono
una simulazione molto accurata dell'hardware del PC.
Se nel descrittore del Call Gate mettiamo DPL=0 anziché DPL=3,
la stringa non viene stampata e si ottiene questa eccezione:
Il codice di errore in binario diventa 0000000001100000b; si ha quindi
EXT=0 (nessun evento esterno), IDT=0 (la IDT non è coinvolta),
TI=0 (il problema è nella GDT), INDEX=12 (indice nella
GDT). Ma all'indice 12 della GDT troviamo proprio il
descrittore del Call Gate!
Se creiamo TSS1 con una dimensione inferiore a 104 byte, il Task
Switch fallisce e vengono mostrati questi messaggi:
Il codice di errore in binario diventa 0000000001011000b; si ha quindi
EXT=0 (nessun evento esterno), IDT=0 (la IDT non è coinvolta),
TI=0 (il problema è nella GDT), INDEX=11 (indice nella
GDT). Ma all'indice 11 della GDT troviamo proprio il
descrittore di TSS1!
Come spiegato nel precedente capitolo, questo tipo di eccezione deve essere
gestito tramite un Task Gate; la relativa ISR, oltre ad estrarre
il codice di errore dallo stack, deve anche provvedere a reimpostare in modo
corretto il TSS coinvolto.
Un altro esperimento consiste nell'omettere per TASK1 lo stack per
CPL=0; in questo caso si può constatare che il Task Switch verso
TASK1 fallisce!
Un ulteriore esperimento consiste nel tenere IF=0 nel campo EFLAGS
del TSS di TASK1; la conseguenza è che TASK1 si blocca in
un loop infinito in quanto le interruzioni mascherabili sono disabilitate e
quindi non può essere rilevata la pressione del tasto [ESC]!
Se nel modulo TSK132C.ASM proviamo ad inserire istruzioni che accedono
alle porte hardware, otteniamo una General Protection Exception con codice
di errore 0000h; ciò accade perché, come sappiamo, un task può usare tali
istruzioni solo se ha CPL≤IOPL o se è autorizzato tramite la I/O
Map. In questo caso TASK1 non ha la I/O Map e, inoltre, il suo
CPL vale 3, mentre IOPL vale 0!
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)