Capitolo 3: Esempi pratici per la Modalità Protetta 80286
In questo capitolo vengono presentati degli esempi che mettono in pratica i concetti
teorici esposti nei capitoli 1 e 2; prima però è necessario analizzare alcuni aspetti
relativi al controllo e inizializzazione della CPU 80286 e alle istruzioni
"privilegiate".
3.1 Controllo e inizializzazione del sistema. Istruzioni privilegiate
La CPU 80286 si serve di appositi registri per il controllo del sistema e
per la gestione della modalità protetta; il contenuto di tali registri può essere
alterato attraverso una serie di nuove istruzioni, eseguibili solo a determinati
livelli di privilegio.
3.1.1 Il registro FLAGS
La Figura 3.1 mostra la struttura del registro FLAGS, che sulla 80286
introduce i due nuovi campi NT e IOPL.
Il campo IOPL è formato da due bit e permette di rappresentare i soliti
4 livelli di privilegio; il suo scopo è quello di stabilire il massimo
valore numerico del CPL al di sopra del quale il task corrente non è più
autorizzato ad eseguire determinate istruzioni.
Un task che gira con livello di privilegio CPL può eseguire le istruzioni
IN, INS, INSB, INSW, OUT, OUTS,
OUTSB, OUTSW, STI e CLI, se e solo se:
CPL ≤ IOPL
Un task che prova ad eseguire CLI o STI avendo CPL maggiore
(numericamente) di IOPL, produce una eccezione 13; in questo caso,
il flag IF rimane inalterato. Analogo discorso se si tenta di modificare
IF eseguendo POPF, direttamente o tramite IRET.
Il flag NT viene posto a 1 quando una istruzione CALL o
INT specifica un selettore che punta ad un task gate; il task corrente
viene quindi interrotto e il controllo passa ad un nuovo task innestato nel
primo (nested task). Il vecchio e il nuovo TSS vengono marcati come
"busy" (B=1); nel back link del nuovo TSS viene caricato un
selettore che punta al vecchio TSS.
Al termine di un task innestato, una IRET che trova NT=1
restituisce il controllo al vecchio task attraverso un task switch; per i
task non innestati si ha NT=0, per cui la IRET si comporta
come in modalità reale.
Osserviamo che in relazione a SAHF (Store AH register into FLAGS),
non ci sono problemi in quanto tale istruzione influisce solo sugli 8 bit
meno significativi del registro FLAGS.
3.1.2 Il registro Machine Status Word
Il MSW rappresenta in ogni istante la configurazione e lo stato della
80286 (e non del task in esecuzione); la Figura 3.2 illustra la struttura
di questo registro.
Il contenuto del MSW in genere viene caricato in fase di inizializzazione
della modalità protetta attraverso l'apposita istruzione LMSW (Load Machine
Status Word Register); se si vuole conoscere il contenuto del MSW si può
ricorrere all'istruzione SMSW (Store Machine Status Word Register).
Il flag TS viene posto automaticamente a 1 dalla CPU ogni volta
che si verifica un task switch; ciò è necessario perché il task switch stesso può
verificarsi proprio mentre era iniziata la fase di decodifica ed esecuzione di una
istruzione della FPU. Una istruzione della FPU eseguita con TF=1
produce una eccezione 7 (Processor Extension Not Available); la
ISR che intercetta questa eccezione può così verificare se la FPU è in
uso al task corrente (quello conseguente al task switch) o ad un altro task. Se la
FPU era in uso ad un altro task, il suo stato deve essere salvato in modo da
poter essere ripristinato quando il nuovo task restituisce il controllo; in questo
modo si evita che il nuovo task, usando a sua volta la FPU, ne alteri lo stato
precedente.
Questa situazione è tipica dei sistemi dove la CPU è separata dalla FPU,
per cui le due unità si servono di meccanismi di protezione indipendenti; il flag
TS quindi ha anche lo scopo di proteggere un task da un errore causato da un
task differente mentre eseguiva una istruzione del coprocessore.
La ISR che intercetta l'eccezione 7 ha il compito di riportare TS
a 0 attraverso l'apposita istruzione CLTS (Clear Task Switched Flag).
Il flag EM permette di stabilire se una determinata funzione del coprocessore
matematico deve essere emulata via software. Se EM=1 e MP=0 (funzione
non presente e coprocessore non presente), il tentativo di eseguire una istruzione
della FPU produce una eccezione 7; la ISR che intercetta tale
eccezione può così chiamare la procedura che deve emulare via software quella istruzione.
Il flag MP indica la presenza o meno della FPU; se MP=1, la
FPU è presente. Se MP=1 e TS=1, il tentativo di eseguire una
qualsiasi istruzione della FPU produce l'eccezione 7; come abbiamo
visto, la ISR che intercetta tale eccezione può così verificare se la
FPU è in uso al task corrente o ad un altro task.
La Figura 3.3 elenca le configurazioni raccomandate per i primi tre bit del
registro MSW; tutte le restanti configurazioni sono da evitare in quanto
possono dare luogo ad anomalie (in particolare, se è previsto l'uso delle
istruzioni della FPU, uno dei due bit MP o EM deve essere
posto a 1, ma non entrambi).
Ricordiamo che le istruzioni della FPU sono chiamate Escape
Instructions (o ESC Instructions) in quanto i loro opcode iniziano
tutti per 11011b, che coincide con il codice
ASCII del tasto ESC.
Il flag PE indica la modalità operativa della 80286. Se PE=0,
la CPU è in configurazione di compatibilità con la modalità reale; se
PE=1, la CPU è in modalità protetta. Si tenga presente però che
PE=1 indica solo che sono attive tutte le funzionalità della 80286
per il supporto della modalità protetta; come abbiamo visto nel capitolo 1, per
accedere fisicamente a tutta la RAM disponibile in tale modalità è necessario
abilitare la linea A20 dell'Address Bus.
Un aspetto curioso è che i progettisti della 80286 hanno previsto un modo
per entrare in modalità protetta, ma non per uscirne; l'unica possibilità di tornare
in modalità reale, come vedremo nel seguito, è un reset della CPU.
3.1.3 Istruzioni privilegiate
Si definiscono "privilegiate" tutte quelle istruzioni eseguibili solamente da un
task che gira a CPL=0; se questo requisito non è soddisfatto, il tentativo
di eseguire tali istruzioni produce una eccezione 13 con codice di errore
0.
Si tratta di istruzioni destinate a modificare il contenuto delle tabelle dei
descrittori e dei registri di sistema; in questo capitolo e in quelli precedenti
abbiamo già incontrato alcune di esse, come LGDT, LLDT, LIDT,
LTR, LMSW e CLTS.
Le istruzioni LGDT e LIDT vengono usate in fase di inizializzazione
della modalità protetta, per cui sono necessariamente disponibili anche in modalità
reale; il loro scopo è quello di caricare nei rispettivi registri, GDTR e
IDTR, i descrittori della GDT e della IDT. Eventuali modifiche
successive della GDT o della IDT con tali istruzioni, possono essere
effettuate solo da un task (generalmente, il SO) che gira con CPL=0.
Le corrispondenti istruzioni SGDT e SIDT possono essere eseguite a
qualunque livello di privilegio.
L'istruzione LLDT può essere usata solo quando si è già in modalità protetta,
da un task che gira a CPL=0; in caso contrario, viene generata l'eccezione
13.
LLDT ha lo scopo di inizializzare il LDTR (parte visibile) con il
selettore che punta al descrittore (nella GDT) della LDT del primo
task da eseguire; gli aggiornamenti successivi di tale registro vengono gestiti in
automatico dalla CPU ad ogni task switch.
La corrispondente istruzione SLDT può essere eseguita a qualunque livello di
privilegio.
Si tenga presente che LLDT aggiorna il LDTR, ma non ha alcun effetto
sul TSS del relativo task e sui registri di segmento; per una corretta
gestione di un task switch, i passaggi da compiere sono i seguenti:
caricare il selettore della LDT nell'apposito campo del TSS
caricare il selettore della LDT nel LDTR
aggiornare i registri DS e ES con i selettori presi dalla LDT
Per il TR valgono considerazioni del tutto analoghe. L'istruzione LTR
può essere usata solo da un task che gira a CPL=0 ed ha lo scopo di caricare
nel TR (parte visibile) il selettore che punta al descrittore (nella GDT)
del TSS del primo task da eseguire; successivamente, lo stesso TR viene
aggiornato in automatico ad ogni task switch.
La corrispondente istruzione STR può essere eseguita a qualunque livello di
privilegio.
LTR provvede anche a porre a 1 il busy bit nel descrittore del nuovo
TSS; il compito di aggiornare i registri di segmento spetta invece al
software di sistema.
L'istruzione LMSW ha il compito di inizializzare il MSW, per cui è
disponibile anche in modalità reale; eventuali modifiche successive di tale
registro con LMSW sono possibili solo da parte di un task che gira a
CPL=0.
La corrispondente istruzione SMSW può essere eseguita a qualunque livello di
privilegio.
L'istruzione CLTS ha il compito di riportare a 0 il flag TS
nel MSW; può essere eseguita solo da un task che gira a CPL=0.
L'istruzione HLT (halt) fa in modo che la 80286 interrompa l'esecuzione
delle istruzioni; la CPU entra in una modalità denominata HALT state
e può essere riesumata solo dal ricevimento di una interrupt o da un comando di
reset. Questa istruzione può essere eseguita solo da un task che gira a CPL=0.
L'istruzione POPF può modificare il campo IOPL del registro
FLAGS solo se viene eseguita da un task che gira a CPL=0.
3.1.4 Inizializzazione della CPU 80286
Quando si accende un PC o si invia un segnale RESET all'omonimo pin,
la 80286 compie una serie di operazioni il cui scopo è quello di definire
lo stato iniziale della CPU; terminate queste operazioni, la linea A20
dell'Address Bus viene disabilitata e si entra nella modalità operativa
reale.
Lo stato iniziale dei vari registri è il seguente:
FLAGS = 0002H
MSW = FFF0H
IP = FFF0H
CS = F000H, CS Base = FF0000H, CS Limit = FFFFH
DS = 0000H, DS Base = 000000H, DS Limit = FFFFH
ES = 0000H, ES Base = 000000H, ES Limit = FFFFH
SS = 0000H, SS Base = 000000H, SS Limit = FFFFH
IDTR:IDT Base = 000000H, IDT Limit = 03FFH
Nel registro FLAGS quindi, tutti i bit sono azzerati, tranne quello in
posizione 1 che però non viene usato dalla 80286. IF=0 fa
si che vengano disabilitate le interruzioni mascherabili; ciò è importantissimo
in quanto si evita che una eventuale interrupt chiami la relativa ISR,
con conseguente utilizzo dello stack, che però non è stato ancora allocato
(SP, come viene spiegato più avanti, è indefinito).
Nel registro MSW tutti i bit vengono posti a 1, tranne i primi
quattro che valgono 0; come è stato spiegato in precedenza, se si vuole
garantire la compatibilità con le CPU di classe superiore, è fondamentale
preservare il contenuto dei bit dalla posizione 4 alla 15.
Abbiamo quindi PE=0 (modalità reale), MP=0 (coprocessore assente)
e EM=0 (nessuna emulazione); si ha poi TS=0 per indicare l'assenza
di task innestati.
La IDT coincide chiaramente con la IVT della modalità reale in
quanto ha una dimensione di 1024 byte e parte dall'indirizzo assoluto
000000H (che in modalità reale corrisponde a 00000H); viene quindi
riservata un'area sufficiente a contenere 256 vettori di interruzione da
16+16 bit ciascuno.
La coppia CS:IP in modalità reale viene interpretata come indirizzo logico
F000H:FFF0H, che corrisponde all'indirizzo fisico a 20 bit
FFFF0H; nel rispetto della convenzione adottata sulle piattaforme hardware
80x86, si tratta dell'ultimo paragrafo del primo MiB di RAM
indirizzabile dalla 8086, dove in genere è presente una JMP che salta
ad un'area della memoria contenente il codice di inizializzazione del sistema.
In modalità protetta, la parte invisibile di CS fa riferimento ad un
segmento di codice con indirizzo base FF0000H e LIMIT pari a
FFFFH (64 KiB); sommando il Base Address a IP=FFF0H si
ottiene l'indirizzo a 24 bit FFFFF0H, che anche in questo caso è
l'ultimo paragrafo dei 16 MiB fisici indirizzabili dalla 80286 (in
genere, tale indirizzo è mappato in una EPROM).
I registri di segmento DS, ES e SS, sia in modalità reale
sia in quella protetta, referenziano i primi 64 KiB della RAM; il
registro SP è da considerare indefinito.
3.1.5 Inizializzazione della Modalità Protetta
Per una corretta inizializzazione della modalità protetta, si raccomanda di
procedere con i seguenti passi:
caricare i vari programmi in memoria con le rispettive LDT
caricare la GDT e la IDT in memoria e i rispettivi descrittori nel GDTR e nell'IDTR
nel MSW porre PE=1 per abilitare la modalità protetta
eseguire una JMP intrasegmento per svuotare la coda di prefetch
caricare un TSS in memoria per il primo task da eseguire
caricare il LDTR con il selettore della LDT del primo task da eseguire
allocare uno stack e impostare la coppia SS:SP
marcare con P=0 tutti i segmenti non presenti in memoria
impostare nei registri FLAGS e MSW la configurazione desiderata della CPU
inizializzare i device esterni
definire una ISR per ogni possibile interrupt
abilitare le interrupt
avviare l'esecuzione
Il passo 4 è importantissimo dato che, una volta posto PE=1, le
istruzioni presenti nella coda di prefetch della 80286 non sono più
valide in quanto destinate alla modalità reale; eseguendo allora una JMP
intrasegmento, si obbliga la CPU a svuotare la stessa coda e a riempirla
con le nuove istruzioni per la modalità protetta.
Per il passo 6, se non si intende utilizzare alcuna LDT, si deve
caricare il NULL Selector nel LDTR.
3.2 Esempi pratici
La modalità protetta rappresenta una sfida piuttosto impegnativa per gli sviluppatori,
in quanto si tratta di gestire aspetti tipici della programmazione di sistema, che
richiedono quindi una adeguata conoscenza anche dell'hardware del computer; ciò è
dovuto al fatto che, come è stato già spiegato in precedenza, con la CPU in
tale stato operativo non possiamo più beneficiare di tutto ciò che è destinato alla
modalità reale, come i servizi forniti dal DOS e dal BIOS o le ISR
per l'elaborazione delle interruzioni predefinite.
Non possiamo allocare memoria, né gestire l'I/O su file e neppure interagire
con le varie periferiche del PC; se abbiamo bisogno di tali funzionalità,
dobbiamo scrivere tutto il codice necessario per riprogrammare l'hardware che
controlla gli hard disk, la tastiera, il mouse, etc.
In sostanza, la gestione completa della modalità protetta comporta la scrittura di
un intero sistema operativo!
Questi tutorial hanno uno scopo puramente didattico, per cui gli esempi illustrati
devono essere necessariamente semplici e comprensibili; anche così però, come
vedremo nel seguito, il codice da scrivere presenta tutte le caratteristiche di un
"mini sistema operativo".
Per semplificarci la vita, prendiamo spunto da una tecnica impiegata da alcuni vecchi
SO, che consiste nell'utilizzare il DOS come "trampolino di lancio" della
modalità protetta; in questo modo, possiamo scrivere dei programmi di esempio che
assumono la seguente struttura:
all'accensione del PC la CPU si inizializza in modalità reale
(vedere la sezione 3.1.4)
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 16 MiB indirizzabili dalla 80286.
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 0000h e FFFFh, 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 poi i descrittori dei vari segmenti di programma, abbiamo bisogno dei
rispettivi Base Address e ciò significa convertire a 24 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 24 bit con degli zeri a sinistra, in modo
da ottenere 003BF0h.
In relazione al campo LIMIT, la situazione più semplice riguarda il caso
in cui abbiamo a che fare con segmenti di lunghezza fissa; ad esempio, se per un
segmento dati DSEGM abbiamo definito una costante DSEGM_SIZE,
otteniamo subito:
LIMIT = DSEGM_SIZE - 1
Negli altri casi possiamo servirci di una etichetta posta alla fine del segmento;
se, alla fine di un segmento di codice di nome CODESEGM scriviamo:
end_codesegm:
allora si ha:
LIMIT = (offset end_codesegm) - 1
Ci servono anche gli ACCESS BYTE per i tre tipi di segmenti di programma,
codice, dati e stack (Expand Down); a tale proposito possiamo definire le tre
costanti seguenti:
Tutto ciò 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 Address0B8000h, LIMIT pari a 32768-1 byte e
ACCESS BYTE di tipo ACCBYTE_DATA.
Nel caso invece della memoria oltre il primo MiB, possiamo definire direttamente
dei descrittori di segmento con Base Address a 24 bit come, ad
esempio, 13FBC0h; 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.
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.
3.2.1 Identificazione del tipo di CPU
Il requisito minimo per gli esempi che seguono è la presenza di una 80286;
la prima cosa da fare consiste quindi nell'identificare il tipo di CPU di
cui disponiamo. Il metodo seguito si basa sul fatto che i vari tipi di CPU
della famiglia 80x86 possono supportare o meno determinate caratteristiche;
si tratta quindi di procedere per esclusione.
Nel nostro caso, come risulta da quanto esposto nel Capitolo 16 del tutorial
Assembly Base, sfruttiamo il fatto che in presenza dell'istruzione:
PUSH SP
le 8086, 8088, 80186 e 80188 salvano nello stack
SP-2; in sostanza, SP viene prima decrementato di 2 e il
valore così ottenuto viene salvato nello stack.
Questo comportamento non è stato ritenuto corretto dai progettisti di CPU,
per cui a partire dalla 80286 viene salvato il vecchio SP nello
stack; quindi, prima viene salvato nello stack il valore corrente di SP
e poi lo stesso SP viene decrementato di 2.
Possiamo scrivere allora il seguente codice:
Non è necessario effettuare ulteriori verifiche, in quanto gli esempi presentati
nel seguito girano perfettamente anche su CPU 80386 o superiori;
l'importante è rispettare tutti gli aspetti esposti in questo e nei precedenti
capitoli, in relazione alla compatibilità verso l'alto della modalità protetta
80286 (in particolare, tutti i campi riservati nei descrittori e nei
registri come FLAGS e MSW, devono essere posti a zero).
3.2.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
80286; 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.
3.2.3 Salvataggio dello stato dei PIC Master e Slave
Anche se abbiamo disabilitato le interruzioni mascherabili, 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:
3.2.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 3.4.
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 80286 in modalità protetta, con la
possibilità di accedere a tutti i 16 MiB di RAM disponibili, per cui
dobbiamo riprogrammare in modo opportuno l'8042; in pratica, si tratta di
modificare il bit in posizione 1 di Figura 3.4, 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 3.4 è 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 3.4; tale metodo
è disponibile nel codice sorgente presentato più avanti.
3.2.5 Predisposizione della 80286 per il ritorno in modalità reale
All'inizio del capitolo si è accennato al fatto che la 80286 è stata
progettata per rendere semplice l'ingresso in modalità protetta, mentre non si
è pensato ad un modo per tornare in modalità reale; tale possibilità è stata
ritenuta superflua in quanto, in origine, questa nuova CPU era destinata
principalmente all'ambito professionale dei sistemi multi-utente, multitasking,
real-time, comunicazioni, etc.
La situazione però ha subito una svolta imprevista quando la IBM ha
deciso di impiegare la 80286 per i suoi nuovi PC di classe
AT; a quel punto, da più parti è arrivata la richiesta di studiare
un metodo rapido ed efficiente per semplificare la scrittura di SO
destinati alla modalità protetta, ma capaci di eseguire anche i programmi
per la modalità reale, che continuavano ad invadere il mondo del software.
La soluzione escogitata si appoggia ancora una volta sull'8042 ed è
divenuta famosa per la sua struttura a dir poco contorta; nel seguito viene
illustrata in dettaglio anche perché rappresenta un pezzo di storia del mondo
dei PC.
Come si vede in Figura 3.4, il pin P20 della Output Port del chip
8042 risulta associato ad un System Reset; attraverso tale pin è
possibile anche inviare un impulso all'ingresso RESET della CPU.
Nel Capitolo 13 del tutorial Assembly Avanzato, si può notare che i
comandi da F0h a FFh per l'8042 prendono il nome di Pulse
Output Port, in quanto permettono di inviare un impulso per circa 6
microsecondi da specifici pin nelle posizioni da 0 a 3 della
Output Port di Figura 3.4; i pin da cui inviare gli impulsi devono essere
specificati ponendo a 0 i corrispondenti bit nelle posizioni da 0
a 3 del comando stesso. In particolare, il comando FEh
(11111110b) indica che vogliamo inviare un impulso che dal pin P20
(in posizione 0) della porta di output del controller, giunge all'ingresso
RESET della CPU; la conseguenza è che la 80286 si resetta come
spiegato in 3.1.4 e ritorna in modalità reale.
Questa non è la soluzione ideale in quanto, dopo il reset, l'esecuzione riprende da
CS:IP=F000h:FFF0h e cioè, dall'indirizzo fisco a 20 bit FFFF0h
(ultimo paragrafo dell'unico MiB indirizzabile in modalità reale); come sappiamo, a
tale indirizzo è presente un JMP FAR verso il codice di inizializzazione del
sistema. A noi interessa invece una soluzione più sofisticata, che renda possibile
scrivere un programma (o un intero SO), capace di saltare dalla modalità
protetta a quella reale e viceversa, senza dover ogni volta riavviare totalmente
il computer; tale soluzione esiste e si basa sull'uso della BIOS Data Area
(BDA) e della CMOS.
Nel Capitolo 2 del tutorial Assembly Avanzato è stato spiegato che la
BDA è un'area di memoria da 256 byte, posizionata a partire dal
paragrafo 0040h. All'offset 0067h della BDA è presente un
campo da 16+16 bit denominato CS:IP for 80286 return from protected
mode; in tale campo bisogna salvare l'indirizzo logico Seg:Offset
da cui si vuole far ripartire un programma dopo il reset della CPU. Se
abbiamo definito un'etichetta real_mode come destinazione per il ritorno
in modalità reale, possiamo scrivere allora il seguente codice:
Come si può notare, nel rispetto delle convenzioni che già conosciamo, la
componente Offset deve precedere Seg.
A questo punto bisogna dire al BIOS che, dopo il reset, in CS:IP
devono essere caricati i valori che abbiamo salvato nella BDA; per fare
ciò dobbiamo servirci della CMOS. Nel Capitolo 4 del tutorial Assembly
Avanzato possiamo vedere che all'offset 0Fh della CMOS è
presente un campo da 1 byte denominato IBM - Reset Code; in tale
campo possiamo inserire un codice che indica il tipo di reset da effettuare.
Nel nostro caso, dobbiamo inserire il codice 05h; in questo modo, il
BIOS sa che dopo il reset deve caricare in CS:IP la coppia di
valori salvata nella BDA all'indirizzo 0040h:0067h.
Possiamo scrivere quindi:
Durante la fase di reset, il BIOS provvede a cancellare il codice che
abbiamo inserito all'offset 0Fh della CMOS; ciò è necessario
per evitare che ad ogni riavvio del computer si ripeta il procedimento appena
descritto.
In sostanza, stiamo effettuando un reset "a caldo", che non comporta la
cancellazione del contenuto della RAM; il nostro programma resta quindi
in memoria e la sua esecuzione può riprendere dall'indirizzo da noi specificato.
La tecnica appena illustrata funziona perfettamente, ma non può essere certo
definita una soluzione efficiente; su un PC basato sulla 80286,
un simile procedimento può richiedere parecchi secondi. Si può dire anzi che,
insieme alla segmentazione a 64 KiB, questo modo contorto di saltare
dalla modalità protetta a quella reale e viceversa, è la causa principale che
ha decretato l'insuccesso della 80286 nel mondo degli sviluppatori di
SO.
3.2.6 Salvataggio dello stack per la modalità reale
Prima di passare in modalità protetta, è consigliabile 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.
In teoria, questo passaggio non sarebbe necessario, purché però non vengano
commessi errori nella gestione dello stack; in ogni caso, si tratta di poche
istruzioni che rappresentano una garanzia in più.
3.2.7 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:
sd_size (dimensione segmento), sd_seg16 (paragrafo da cui parte
il segmento), sd_accbyte (ACCESS BYTE del segmento) e
sd_index (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 sd_index per 8. Possiamo scrivere allora il
seguente codice:
Osserviamo che sd_seg16*16 ci dà i primi 16 bit (da 0 a
15) del Base Address. Nella moltiplicazione, il nibble alto di
sd_seg16*16 sconfina da sinistra; isolando tale nibble in AH
otteniamo i restanti 8 bit (da 16 a 23) del Base
Address. Nel caso, ad esempio, di AX=B800h, l'istruzione
shl ax, 4
ci dà AX=8000h (primi 16 bit del Base Address). Ponendo
nuovamente AX=B800h (e quindi AH=B8h), l'istruzione
shr ah, 4
ci dà AH=0Bh (8 bit più significativi del Base Address).
Alla fine otteniamo Base Address = 0B8000h.
Le considerazioni appena esposte si riferiscono ai segmenti di programma
definiti nel primo MiB di memoria; in tal caso, è necessario procedere nel
modo descritto per determinare il Base Address a 24 bit. Per i
segmenti di programma definiti nella memoria oltre il primo MiB, abbiamo già
a disposizione il Base Address a 24 bit, per cui i calcoli
precedenti non sono necessari; per inserire questo tipo di segmenti nella
GDT, utilizziamo quindi una apposita procedura.
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;
in questo modo, possiamo esplorare il contenuto della tabella.
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.
3.2.8 Ingresso in modalità protetta
Una volta effettuate tutte le necessarie inizializzazioni, possiamo finalmente
abilitare il supporto per la modalità protetta della 80286.
Innanzi tutto, poniamo a 1 il bit PM nella MSW attraverso
l'istruzione LMSW; anche LMSW è disponibile in modalità reale per
le stesse ragioni già illustrate per LGDT. Possiamo scrivere, ad esempio:
A questo punto entriamo in modalità protetta attraverso un salto FAR
intrasegmento, 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.
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 di un segmento di codice
CODESEGM con selettore SEL_CODESEGM, possiamo scrivere quindi:
(si può anche ricorrere ad un salto FAR indiretto, con opcode FFh
e indirizzo di tipo m16:m16)
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 dello stesso CODESEGM.
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 DS, ES e SS 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; 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 noti che, se non si hanno esigenze particolari, non è necessario inizializzare
anche SP; tale lavoro viene effettuato infatti dal DOS grazie alla
presenza del segmento STACKSEGM con attributo di combinazione STACK.
Se non ci sono stati errori nella gestione dello stack in modalità reale, quando
entriamo in modalità protetta abbiamo SP=STACK_SIZE; inoltre, il segmento
STACKSEGM è stato definito come Expand Down, per cui SP
viene decrementato ad ogni PUSH.
Un ulteriore compito da svolgere appena entrati in modalità protetta consiste
nell'impostare i campi NT e IOPL del registro FLAGS; 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.
3.2.9 Ritorno in modalità reale
Al termine del nostro programma, dobbiamo tornare in modalità reale in modo
da restituire correttamente il controllo al DOS. Avendo già predisposto
tutto il necessario, come spiegato in precedenza non dobbiamo fare altro che
inviare il comando FEh alla porta di input 64h dell'8042.
Possiamo scrivere quindi:
Con i moderni emulatori e macchine virtuali, queste istruzioni vengono eseguite
in modo rapidissimo; con un PC basato su una vera 80286, il reset
della CPU avviene in modo estremamente lento, per cui può essere necessario
aggiungere il seguente loop infinito:
L'istruzione HLT, come sappiamo, costringe la CPU a sospendere
l'esecuzione delle istruzioni; il loop è necessario in quanto gli effetti di
HLT possono essere annullati dall'arrivo di una interrupt (in tal
caso, la CPU verrebbe riattivata).
Il comando FEh resetta quindi la 80286 e l'esecuzione del nostro
programma riprende dall'indirizzo da noi impostato in precedenza; a questo punto,
la 80286 è di nuovo in modalità reale.
Ciò che dobbiamo fare ora consiste nel ripetere in ordine inverso le operazioni
illustrate in precedenza.
Carichiamo DATASEGM in DS, ripristiniamo SS:SP e lo stato
dei PIC, Master e Slave.
Disabilitiamo la linea A20 inviando all'8042 il comando DDh;
ripristiniamo la NMI con le istruzioni:
Infine, riattiviamo la gestione delle interruzioni mascherabili con l'istruzione
STI.
3.2.10 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; l'emulatore DOSBox invece non supporta la CPU
80286, per cui si blocca durante la fase di reset descritta in precedenza.
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:
86Box è capace di emulare in modo estremamente accurato l'hardware dei
vecchi PC basati sulle CPU dalla 8088 ai primi modelli
di Pentium; in ogni caso, non è necessario impostare una macchina
virtuale 80286, anche perché ciò equivale a fare un viaggio indietro
nel tempo sino al 1982, con tutte le complicazioni che ne derivano in
termini di hardware e SO dell'epoca.
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 [cpu], per abilitare il supporto al reset della CPU
tramite 8042 dobbiamo impostare:
core = normal
Per evitare che l'emulatore richieda LIMIT=FFFFh per tutti i segmenti,
dobbiamo impostare:
segment limits = false
Come modello di CPU possiamo scegliere:
cputype = 486
Nella sezione [keyboard] è importantissimo impostare:
3.2.11 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.
Uno dei (pochi) pregi della segmentazione a 64 KiB della modalità protetta
80286 è che viene favorito uno stile di programmazione modulare; per
sfruttare questo aspetto, suddividiamo il nostro programma in un modulo principale
che rappresenta il kernel, un modulo che contiene tutte le procedure che operano
sull'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:
e così via.
Si noti che STACKSEGM è un segmento di tipo Expand Down; di conseguenza,
per il campo LIMIT dobbiamo specificare l'offset minimo indirizzabile.
Per ulteriori dettagli, si può fare riferimento ai commenti presenti nel codice
sorgente.
La Figura 3.5 mostra il codice sorgente del modulo principale KRNL16A.ASM,
contenente il kernel del nostro esempio.
Selezione codice sorgente:
La Figura 3.6 mostra il codice sorgente del modulo HRDW16A.ASM, che
contiene le procedure per l'accesso all'hardware.
La Figura 3.7 mostra il codice sorgente del modulo GDT16A.ASM, che
contiene la GDT e le procedure per la sua gestione.
I moduli HRDW16A.ASM e GDT16A.ASM sono disponibili solo in versione
NASM; gli object file che si ottengono sono perfettamente linkabili al
modulo KRNL16A.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 3.8 mostra l'output del programma su DOSBox-X.
Il programma visualizza una serie di informazioni, compreso l'indirizzo di ingresso
in modalità protetta; in Figura 3.8 si può notare che il registro CS contiene
il valore 0018h, che è proprio il selettore del segmento CODESEGM
(INDEX=0000000000011b, TI=0b, RPL=00b, in esadecimale danno
0018h).
In seguito, grazie al selettore SEL_GDTDATA, vengono visualizzati alcuni
descrittori di segmento presenti nella GDT.
Sfruttando poi il fatto che siamo in modalità protetta e la linea A20 è attiva,
una stringa viene letta da DATASEGM e copiata con REP MOVSB in un
segmento dati HMEMSEGM che si trova oltre il primo MiB, all'indirizzo a
24 bit 1F0000h (2031616); successivamente, tale stringa viene
visualizzata sullo schermo (e quindi copiata da HMEMSEGM a 0B8000h).
Osserviamo come, con la modalità protetta attiva (PM=1 nel MSW), tutte
le istruzioni si comportino di conseguenza; REP MOVSB usa sempre DS:SI
come sorgente e ES:DI come destinazione, ma stavolta DS e ES
contengono i selettori di due segmenti di dati che possono trovarsi in un'area
qualunque dei 16 MiB indirizzabili dalla 80286!
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.
Possiamo anche notare che la programmazione in modalità protetta è del tutto simile
a quella in modalità reale; anzi, per certi versi è anche più semplice. Le procedure
print_string, print_hexnum e print_binnum possono essere usate
in entrambe le modalità operative; in modalità reale inviano l'output sul video, al
segmento B800h, mentre in modalità protetta viene usato il selettore del
segmento stesso, con Base Address a 24 bit 0B8000h.
Eseguendo DOSBox-X da un terminale, abbiamo la possibilità di leggere l'output
diagnostico generato dall'emulatore; in particolare, notiamo le seguenti due righe:
Effettivamente, come si vede in Figura 3.8, l'indirizzo di ritorno in modalità
reale (da noi memorizzato nella BDA), è proprio 0850h:0396h!
3.2.12 Programma di esempio con GDT e IDT
L'esempio appena presentato ha richiesto la scrittura di una quantità notevole
di codice; bisogna considerare però che la quasi totalità del lavoro svolto si
riferisce a impostazioni e reimpostazioni dell'hardware, comuni a tutti i
programmi che operano in modalità protetta. Vediamo infatti ora un nuovo esempio
denominato KRNL16B, che riutilizza il 100% del codice scritto per
KRNL16A.
KRNL16B 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 80286.
In base a quanto è stato esposto nel precedente capitolo, i primi 32
vettori di interruzione (da 00h a 1Fh) sono riservati alla
80286 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 80286. 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 KRNL16A.
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
IDT16B.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 precedente capitolo 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:
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 80286 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).
Per ulteriori dettagli, si può fare riferimento ai commenti presenti nel codice
sorgente.
La Figura 3.9 mostra il codice sorgente del modulo principale KRNL16B.ASM,
contenente il kernel del nostro esempio.
Selezione codice sorgente:
La Figura 3.10 mostra il codice sorgente del modulo HRDW16B.ASM, che
contiene le procedure per l'accesso all'hardware.
La Figura 3.11 mostra il codice sorgente del modulo IDT16B.ASM, che
contiene la IDT e le procedure per la sua gestione.
La Figura 3.12 mostra il codice sorgente del modulo GDT16B.ASM, che
contiene la GDT e le procedure per la sua gestione.
I moduli HRDW16B.ASM, IDT16B.ASM e GDT16B.ASM sono disponibili
solo in versione NASM; gli object file che si ottengono sono perfettamente
linkabili al modulo KRNL16B.ASM per MASM. Come al solito, questi
moduli possono essere facilmente convertiti in versione MASM.
La Figura 3.13 mostra l'output del programma su MS-DOS 6.22 installato nella
macchina virtuale 86Box.
Il programma KRNL16B, 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:IP 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 IP di 2; si può notare che dopo
PUSH BP, troviamo return IP a BP+2.
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:IP 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 3.13 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:IP 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:IP; anche in questo caso, il nostro
esempio funziona solo se l'istruzione che ha creato il problema ha un opcode
da 2 byte.
Prima di lasciare la modalità protetta, il programma KRNL16B 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 000000h e LIMIT 1024-1.
Per verificare la correttezza di queste reimpostazioni, vediamo in Figura 3.13
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.
3.2.13 Programma di esempio con GDT, LDT, IDT e Task Switch
Vediamo un ultimo esempio KRNL16C il quale, ancora una volta, riutilizza
il 100% del codice scritto in precedenza. Formalmente, KRNL16C appare del
tutto simile a KRNL16B; 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 TSK116C 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 (TSK1DATA), 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.
Analogamente, per il campo RPL nei selettori degli stessi segmenti si ha:
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:SP per CPL=0; gli altri campi (stato corrente di TASK0)
vengono riempiti in automatico dalla 80286 quando viene ceduto il controllo
a TASK1.
Per TASK1 invece è essenziale riempire i campi Back Link, LDT
Selector, SS:SP per CPL=0 e tutti i registri che puntano ai
segmenti di programma privati con DPL=3, come SS:SP, CS:IP
(entry point del task), DS e ES. Ovviamente, nei registri di
segmento vanno inseriti i relativi selettori.
Sempre in relazione al TSS di TASK1, è importante inizializzare
anche il campo FLAGS; 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 2.13 del Capitolo 2):
Anche in questo caso, 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 i TSS e le LDT devono essere gestite dal
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 0001b per i TSS.
L'esempio KRNL16C 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_string 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=0100b.
Siccome kprint_string richiede 5 parametri di tipo WORD, nel
campo WORD COUNT del descrittore del Call Gate dobbiamo inserire il valore
5.
L'elenco dei vari servizi forniti dal kernel si trova in un include file chiamato
KRNL16C.INC, che deve essere incluso in TSK116C.ASM.
Per ulteriori dettagli, si può fare riferimento ai commenti presenti nel codice
sorgente.
La Figura 3.14 mostra il codice sorgente del modulo principale KRNL16C.ASM,
contenente il kernel (TASK0) del nostro esempio.
Selezione codice sorgente:
La Figura 3.15 mostra il codice sorgente del modulo TSK116C.ASM, contenente
il TASK1 del nostro esempio, il TSS e la LDT.
Selezione codice sorgente:
La Figura 3.16 mostra il codice sorgente del modulo HRDW16C.ASM, che
contiene le procedure per l'accesso all'hardware.
La Figura 3.17 mostra il codice sorgente del modulo IDT16C.ASM, che
contiene la IDT e le procedure per la sua gestione.
La Figura 3.18 mostra il codice sorgente del modulo GDT16C.ASM, che
contiene la GDT e le procedure per la sua gestione.
I moduli HRDW16C.ASM, IDT16C.ASM e GDT16C.ASM sono disponibili
solo in versione NASM; gli object file che si ottengono sono perfettamente
linkabili al modulo KRNL16C.ASM per MASM. Questi moduli possono essere
facilmente convertiti in versione MASM.
Codice sorgente per MASM:
KRNL16C MASM
La Figura 3.19 mostra l'output del programma su MS-DOS 6.22 installato nella
macchina virtuale 86Box.
KRNL16C, 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_string.
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:
jmp SEL_TSS1SEGM:0000h
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 KRNL16B; 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_string; 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, devono
verificarsi allora le seguenti condizioni:
Quindi, il caller deve essere più privilegiato del gate, mentre il segmento di
destinazione (dove si trova kprint_string) deve essere più privilegiato
del caller.
Anche per la chiamata di kprint_string 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 sempre tramite salto diretto a TSS0; come al solito, la
CPU provvede a salvare in TSS1 lo stato di TASK1 e ad
aggiornare LDTR e TR.
Con il programma KRNL16C si possono fare numerosi esperimenti per testare
i meccanismi di protezione della 80286; a tale proposito, si consiglia di
commentare prima il codice che genera le eccezioni nel file TSK116C.ASM.
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 0000000001010000b; si ha quindi
EXT=0 (nessun evento esterno), IDT=0 (la IDT non è coinvolta),
TI=0 (il problema è nella GDT), INDEX=10 (indice nella
GDT). Ma all'indice 10 della GDT troviamo proprio il
descrittore del Call Gate!
Se creiamo TSS1 con una dimensione inferiore a 44 byte, il Task
Switch fallisce e vengono mostrati questi messaggi:
Il codice di errore in binario diventa 0000000001001000b; si ha quindi
EXT=0 (nessun evento esterno), IDT=0 (la IDT non è coinvolta),
TI=0 (il problema è nella GDT), INDEX=9 (indice nella
GDT). Ma all'indice 9 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 FLAGS
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 TSK116C.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, ma in questo caso TASK1 ha
CPL=3 e IOPL vale 0!
Bibliografia
Intel 80286 Hardware Reference Manual
(disponibile su Internet)
Intel 80286 Programmer's Reference Manual
(disponibile su Internet)
iAPX 286 Operating Systems Writer's Guide
(disponibile su Internet)