Ok, eccoci qua all'essay :) Tutto il resto del tutorial sarà prevalentemente incentrato sull'analisi
del programmino che esegue lo switch da real mode a protected mode; guarderemo insieme le diverse
porzioni di codice, e tenterò di spiegarvi passo per passo alcuni dei concetti
chiave della protected mode.
Prima di tutto, però, mi sembra opportuno spiegare il funzionamento dei tool che useremo.
1. Settiamo Bochs e Nasm
Entrambi i tool sono facili da installare: basta scaricare gli zip e decomprimerli nella directory
che più vi piace; io li ho messi rispettivamente in "c:\programming\bochs" e "c:\programming\nasm".
Per facilitarci il discorso, mettiamoci d'accordo anche sulla directory di lavoro: io ho usato
"c:\programming\nasm\pmode". Quindi potete prendere il mio programma, e decomprimerlo in quest'ultima
directory.
Una volta preparate le directory, la cosa da fare è il settaggio di Bochs: si tratta
di creare il file "bochsrc" nella directory di Bochs, e di inserirci alcune direttive.
Fortunatamente troviamo già un file di esempio ("bochsrc-sample.txt"), ma
dobbiamo comunque cambiare qualche riga per adattarlo alle nostre esigenze. Quindi,
copiamo "bochsrc-sample.txt" dentro ad un nuovo file "bochsrc", e apriamo quest'ultimo
con un qualsiasi editor di testo.
La prima cosa da cambiare è "romimage": ci basta cambiare da "file=bios/BIOS-bochs-latest"
a "file=BIOS-bochs-latest" (a meno che non vogliate creare una nuova directory "bios" e
copiarci dentro i file di rom).
Subito sotto troviamo il settaggio della memoria emulata:
Bochs prevede 32 MB...qui mettete quello che volete, io ho messo 8 MB, tanto non ci servirà parecchia
memoria.
Arriviamo a "vgaromimage"...stesso discorso di "romimage", togliete la directory "bios" dal
path (oppure copiate "VGABIOS-elpin-2.40" all'interno di una directory "bios").
Troviamo "floppya", che è impostato su /dev/fd0, invece noi useremo un file di immagine, quindi
commentiamo quella linea e aggiungiamo invece "floppya: 1_44=pmode.img, status=inserted"
(ho chiamato l'immagine del floppy "pmode.img").
Commentiamo la riga relativa a floppyb, e tutte quelle relative ai controller ata, diskc, diskd
e cdrom.
Arrivati all'opzione "boot", commentiamo "boot: disk" e lasciamo non commentata invece
la riga "book: floppy".
Lasciamo invariato il resto del file, e quindi salviamo il tutto ed usciamo dall'editor di testo.
Per quanto riguarda Nasm, non c'è nulla da settare...io per comodità mi sono creato un
semplice file batch "make.bat" per velocizzare l'assemblaggio del file, in questo modo:
nasmw -f bin -o c:\programming\bochs\pmode.img pmode.asm
pause
Questa command line dice a Nasm che vogliamo un file binario ("-f bin", è di default
ma è sempre bene specificarlo) e che l'output sarà "c:\programming\bochs\pmode.img".
Bene, una volta fatte queste operazioni preliminari, possiamo semplicemente copiare i file
"pmode.asm" e "vga32.asm" nella directory "c:\programming\nasm\pmode", avviare il nostro
comodo "make.bat", lanciare Bochs, scegliere l'opzione "Begin simulation" (basta premere
enter visto che è l'opzione di default), e se tutto va bene dovremmo vedere una schermata
del genere:
Che cosa sono quelle scritte? Cosa è successo? Beh...una cosa per volta, ora vedremo tutto :)
2. Il bootsector
Iniziamo ora ad addentrarci nei "meandri" del programma, che è composto principalmente da
tre parti:
- bootsector
- codice in real mode, che serve ad eseguire lo switch in protected mode
- codice in protected mode
Accenno brevemente al funzionamento generale dei bootsector, senza
soffermarmi eccessivamente su questa parte (che potete comunque approfondire
per esempio da
John Fine o, più semplicemente, su
Bona Fide OS development).
Il bootsector è, in generale, il primo settore di un disco.
Questo settore contiene un frammento di codice: è il codice che viene eseguito
per primo, subito dopo l'accensione del PC e le routine di diagnostica del BIOS.
Funziona così: il BIOS, una volta eseguite le sue routine, inizia a cercare un bootsector
valido nei vari dischi (a seconda di come è stato configurato); una volta trovato un
bootsector, il BIOS lo carica all'indirizzo di memoria 0x7C00, e poi esegue un jmp a questo
indirizzo.
Tutti i sistemi operativi prevedono un bootsector, che può essere più o meno complicato,
avere più o meno opzioni, ecc. Anche noi, dal momento che facciamo funzionare il
nostro programma direttamente all'avvio del PC, abbiamo bisogno di un bootsector.
Il compito del nostro bootsector è semplice: deve solo caricare il resto del programma in
memoria, e proseguire l'esecuzione saltando al codice appena caricato.
Un bootsector, in generale, non può fare molto di più di questo:
infatti il suo codice è limitato ad una dimensione di circa 512 byte (deve risiedere in
un unico settore), e quindi nella maggior parte dei casi si limita a caricare qualcos'altro
(di solito, il loader di un sistema operativo).
Detto questo, vediamo come funziona il nostro bootsector.
Il codice, molto semplice, è il seguente:
ORG 0x7C00 ;0x7C00 è dove ci troviamo all'inizio del bootsector
BITS 16
;--------------------------------------------------------------------
;bootsector - qui carichiamo il resto del programma
boot:
;prima cosa da fare: inizializzare i registri di segmento e lo stack
xor ax,ax
mov ds,ax
mov es,ax
mov ss,ax
mov sp,0x7C00 ;mettiamo lo stack appena sotto al bootsector
;ora carichiamo il resto del programma
mov ax,0x0200 + BODY_SEC_LENGTH ;al=numero di settori da leggere
mov cx,2 ;iniziamo a leggere dal secondo settore
xor dx,dx
mov bx,0x7E00
int 0x13
;saltiamo al body che abbiamo appena caricato
jmp 0x0000:0x7E00
Prima di tutto, specifichiamo
al Nasm l'entry point: 0x7C00. Come abbiamo detto, infatti, verremo caricati
lì dal BIOS. Specifichiamo anche che vogliamo un segmento a 16 bit ("BITS 16").
Il codice inizia con l'inizializzazione dei registri di segmento e dello stack:
in questo momento, infatti, non sappiamo come il BIOS aveva inizializzato i segmenti, e neppure
dove aveva posizionato lo stack: ci conviene quindi riportare tutte queste cose ad uno
stato "sicuro". Settiamo SP a 0x7C00, in modo che lo stack cresca al di sotto
del nostro codice, e quindi non lo sovrascriva.
Una volta "sbrigate" queste "formalità" iniziali, occupiamoci di caricare il "body"
(così ho chiamato il resto del programma) nell'area di memoria successiva al bootsector;
per fare questa operazione usiamo l'int 13h (per maggiori informazioni su questo interrupt
e sugli int in generale, rimando alla [Ralph Brown's Interrupt List]).
BODY_SEC_LENGTH è una costante che abbiamo dichiarato prima, e che contiene il numero di
settori di cui si compone il nostro "body", e che quindi dovremo caricare.
L'ultima operazione del bootsector è un jmp al "body"; il resto del settore è riempito
di zeri, tranne gli ultimi due byte che devono essere rispettivamente 0x55 e 0xAA: questo
infatti è un "magic number", che dice al BIOS che il nostro è un bootsector valido. Se il BIOS
non trovasse questi due byte, ci direbbe una cosa tipo "FATAL: Not a bootable disk".
Abbiamo detto tutto sul bootsector...vediamo ora il "body" che eseguirà lo switch da real mode
a protected mode.
3. Body: lo switch in protected mode
Il compito di questa porzione di programma consiste nell'eseguire lo switch
in protected mode. Inoltre, per abbellirlo un po', ho aggiunto la visualizzazione di qualche stringa ;)
Lo switch è composto da cinque passi:
1 - disabilitazione degli interrupt
2 - abilitazione della linea A20
3 - caricamento della GDT
4 - settaggio del PE bit
5 - far jmp al codice a 32 bit
Nei prossimi paragrafi cercherò di spiegare approfonditamente ognuno di questi step.
3.1. Disabilitazione degli interrupt
Come mai vogliamo disabilitare gli interrupt? Il problema sta nella tabella
degli interrupt: la real mode ha una tabella
completamente diversa dalla protected mode. Se durante lo switch si verificasse
un interrupt, senza che noi avessimo preparato la corretta tabella...non sarebbe
un bell'evento :) La CPU tenterebbe di leggere una tabella di interrupt che non
esiste, salterebbe ad un indirizzo che non corrisponde a nessuna ISR (Interrupt
Service Routine), e quindi in parole povere otterremmo un reboot della macchina.
Per disabilitare gli interrupt, eseguiamo prima di tutto una "cli"; inoltre,
per completezza, disabilitiamo anche i NMI (Non Maskable Interrupt): questi ultimi
non vengono disabilitati da una normale "cli", ma bisogna scrivere un comando
direttamente al CMOS:
cli
in al,0x70 ;disabilito i NMI
or al,0x80
out 0x70,al
La porta del CMOS, come vediamo dal codice sopra, è la 0x70...e il bit che maschera
i NMI è l'ottavo bit del byte letto da questa porta.
A titolo informativo, i NMI sono interrupt che si possono verificare per due motivi:
- qualche hardware esterno asserisce il pin NMI
- la CPU riceve un messaggio di tipo NMI proveniente dal bus APIC
Entrambe le eventualità non capiteranno MAI durante l'esecuzione del nostro
semplice programmino...tuttavia, i manuali Intel consigliano di disabilitare
tutto il disabilitabile, e, visto che sono tre righe di codice, tanto vale farlo :)
3.2. Abilitazione della linea A20
3.2.1 Indirizzi fisici in real mode
In questo paragrafo spiego il modo in cui la real mode traduce
gli indirizzi logici in indirizzi fisici: questo ci servirà per
capire le ragioni per cui dovremo abilitare la linea A20. Se conoscete
già il modo in cui la real mode tratta gli indirizzi fisici, passate
pure al prossimo paragrafo.
In real mode, siamo abituati ad esprimere un indirizzo di memoria nella
forma segmento:offset, per esempio CS:IP o SS:SP.
Questi indirizzi "logici" vengono poi tradotti dalla CPU
in indirizzi fisici in questo modo: si prende il valore del segmento,
si moltiplica per 16 (0x10), e si aggiunge il valore dell'offset. Il risultato
rappresenta il nostro indirizzo fisico.
Per esempio, se abbiamo segmento=0xABCD e offset=0x1234, l'indirizzo
fisico corrispondente sarà:
0xABCD * 0x10 = 0xABCD0; 0xABCD0 + 0x1234 = 0xACF04.
3.2.2 Venti linee di indirizzamento
L'8086, che funziona solo in real mode, ha un bus CPU-memoria largo 20 bit, quindi
possiede 20 linee di indirizzamento, chiamate solitamente A0, A1...A19.
In sostanza, l'indirizzo di memoria massimo a cui si può accedere è (2^20-1).
In realtà, il modo in cui la real mode tratta gli indirizzi permetterebbe,
da un punto di vista logico, di accedere a circa 64 kb oltre questo limite:
infatti, se noi consideriamo un segmento 0xFFFF e un offset 0xFFFF, otteniamo
un indirizzo fisico pari a 0xFFFF0 + 0x0FFFF = 0x10FFEF, che corrisponde
ad un'area di memoria grande 1 MB + 64 KB - 16 Byte. Il problema
è che il bus, invece, non può spingersi oltre 1 MB (dal momento che
fisicamente manca la 21-esima linea di indirizzamento).
Quindi per ogni accesso al di sopra di 0xFFFFF viene eseguito un
warp-around a 0: è come se si eseguisse un AND tra l'indirizzo e
la mask 0x0FFFFF.
Dal 286 in poi, invece, la 21-esima linea esiste (e naturalmente viene chiamata
linea A20). Quindi, pur rimanendo in real mode, è possibile usare
quei 64 KB - 16 Byte in più. Tra l'altro, questa è l'area di memoria
conosciuta con il nome di "High Memory Area": quando si caricava il DOS
high, in realtà si caricava il sistema proprio in questi 64 KB.
Il problema di tutto questo era che, a questo punto, la real mode di un 286+
non era più totalmente compatibile con la real mode dell'8086, e quindi
i progettisti Intel dovettero pensare ad una soluzione per mantenere comunque
la compatibilità all'indietro (probabilmente qualche programmatore 8086
"giocava" con il warp-around, cioè scriveva applicazioni con dei puntatori
che sfruttavano la proprietà di warp-around: queste applicazioni non
sarebbero più funzionate correttamente sotto un 286+).
La soluzione adottata fu di creare un AND tra la linea A20 e un'altra
linea di qualche chip, inizializzando quest'ultima linea a zero: in questo modo
la linea A20 rimaneva a zero finchè non veniva portata ad 1 la linea con cui
era posta in AND.
Quindi il warp-around continua ad esserci, e la compatilibità
è garantita; per sfruttare invece tutta la memoria accessibile dalla protected mode,
abbiamo bisogno di porre ad 1 la linea in AND con la A20. (Se non lo facessimo,
potremmo accedere unicamente ai MB dispari! O meglio, ogni MB pari diventerebbe un "mirror"
del precedente)
3.2.3 Abilitiamo la linea A20
La A20 è posta in AND con un pin del keyboard controller, quindi dovremo andare a scrivere
qualche byte a questo chip. I passi che dovremo compiere consistono in:
1 - aspettare che il keyboard buffer sia vuoto
2 - leggere il current status del controller
3 - porre ad 1 il secondo bit del current status
4 - aspettare di nuovo che il buffer sia vuoto
5 - scrivere il nuovo status con il secondo bit a 1
Se volessimo fare qualcosa di più accurato, potremmo anche rileggere, alla fine,
lo status e verificare che effettivamente sia andato tutto per il verso giusto
(cioè che il secondo bit sia ad 1).
L'attesa per il buffer vuoto si traduce in un loop di questo tipo:
.kbd_wait_output:
;cicliamo finchè il command buffer della tastiera non è vuoto
in al,0x64
test al,2
jnz short .kbd_wait_output
Quando usciamo da questo loop, vuol dire che siamo pronti a mandare un byte
al keyboard controller: manderemo il comando 0xD0, che corrisponde
a "read output port" (ovvero informiamo il controller che siamo interessati
a leggere lo status).
Una volta mandato questo byte, possiamo quindi leggere lo status; prima di fare
questo, però, dobbiamo eseguire un altro loop per aspettare che il buffer di output
della tastiera non sia vuoto (ovvero, dobbiamo aspettare che il controller
ci fornisca il byte che abbiamo richiesto):
.kbd_wait_input:
;cicliamo finchè non c'è un dato disponibile nel buffer della tastiera
in al,0x64
test al,1
jz short .kbd_wait_input
Usciti da questo loop, siamo in grado di leggere lo status:
xor ax,ax
in al,0x60 ;current status in AL
or al,2 ;settiamo il bit 2, che abilita la linea a20
push ax ;salviamolo per dopo
Ora dobbiamo semplicemente scrivere il nuovo status: manderemo il comando
0xD1, aspetteremo di nuovo che il buffer sia vuoto, scriveremo il byte, e...
ta-dan, avremo la linea A20 abilitata :)
3.3 La GDT, ovvero Global Descriptor Table
La GDT è una struttura fondamentale per la protected mode: senza di essa, non
è possibile eseguire lo switch.
Nei paragrafi successivi vedremo come è fatta e come costruirla.
3.3.1 Segmenti, descrittori, selettori
Come in real mode, anche in protected mode esistono i segmenti. Quelli della
modalità reale, però, non hanno nulla a che vedere con quelli della protetta:
mentre i primi sono semplicemente un "trucco" per estendere la quantità di memoria
indirizzabile (ovvero, pur avendo registri da 16 bit, riesco ad indirizzare
20 bit usando due registri combinati insieme), i secondi sono "segmenti di memoria"
nel vero senso della parola: sono, cioè, porzioni di memoria di dimensioni e scopi
variabili. Inoltre, essi non sono fissati dalla CPU: il programmatore
(di sistemi operativi) è libero di scegliere, per ogni segmento,
le dimensioni e i tipi che più soddisfano i suoi bisogni.
Più in dettaglio, ogni segmento è caratterizzato da:
- una base, ovvero un indirizzo che ne determina l'inizio
- una dimensione
- un indicatore del tipo di segmento (codice, dati, read-only, read-write, ecc...
Ci sono 32 diversi tipi, ma noi non li vedremo tutti in questo documento; per maggiori
informazioni, consultate i paragrafi 3.4 e 3.5 del manuale 3 di
Intel)
- un livello di privilegio necessario per l'accesso al segmento
- alcuni flag
Tutte queste informazioni vengono memorizzate in particolari strutture chiamate
descrittori di segmento. I descrittori, a loro volta, sono contenuti in
un'unica grande struttura chiamata Global Descriptor Table.
Inoltre, per usare un segmento (cosa che risulta necessaria ad ogni accesso
alla memoria) abbiamo bisogno di un meccanismo che ci permetta di specificare,
ogni volta, il segmento stesso che intendiamo usare; o, meglio,
dobbiamo specificare il descrittore del segmento.
Per far ciò, usiamo dei selettori: questi ultimi non sono altro che i vecchi
registri di segmento della real mode, ovvero CS,DS,SS, ecc., che però in protected
mode assumono un significato diverso. La loro funzione, infatti, è di
puntare al descrittore voluto.
Il formato di un selettore è il seguente:
- bit 0-1: Requested Privilege Level (non ci interessa)
- bit 2: Table Indicator (neanche questo ci interessa)
- bit 3-15: Index. Quest'ultimo campo serve a puntare un
determinato descrittore, ed è proprio il campo che interessa a noi.
Chiarisco questo punto, forse un po' oscuro, con un esempio tratto dal nostro programmino.
Noi useremo due segmenti: uno per il codice ed un altro per i dati.
Il descrittore del segmento di codice starà alla posizione 1 della GDT,
mentre quello del segmento dei dati starà
alla posizione 2. Quindi il campo Index del selettore del segmento di codice
sarà 1, mentre quello del selettore del segmento dei dati sarà 2.
In pratica, avremo CS=0x08 (quindi il campo Index di CS è 1), e
DS=ES=...=0x10 (quindi il campo Index di DS,ES, ecc. è 2).
3.3.2 Descrittori in dettaglio
Una volta chiariti i ruoli di segmenti, descrittori e selettori,
possiamo procedere alla costruzione della nostra GDT.
Per fare ciò, dobbiamo innanzitutto sapere com'è fatto un descrittore, ovvero
conoscerne la struttura; essa consiste in:
segment_limit_0_15 dw ? ;i primi 16 bit del segment limit
base_address_0_15 dw ? ;i primi 16 bit del base address
base_address_16_23 db ? ;bit 16-23 del base address
type__flag1 db ? ;indicatore del tipo di segmento, più alcuni flag
limit_16_19__flag2 db ? ;bit 16-19 del limit, più altri flag
base_address_24_31 db ? ;bit 24-31 del base address
Segment limit specifica la dimensione del segmento. Se osservate
attentamente la struttura sopra, noterete che ci sono solo 20 bit per questo campo;
non vi sareste invece aspettati una dimensione massima di 32 bit? Con 20 bit di
dimensione, ogni segmento è grande al massimo 1 MB...e quindi non abbiamo guadagnato
molto rispetto alla real mode.
Ma...c'è un trucco :) E' possibile specificare, tra i vari flag, la granularità
(G) della dimensione del segmento. Questo flag indica l'unità base in cui viene espresso
il segment limit; in pratica, se G=0, il segment limit viene
interpretato in unità di byte, mentre se G=1 il limit è interpretato in unità
di 4 KB.
Quindi, facendo due conti, se poniamo G=1 e limit=0xFFFFF (quindi dimensione = limit+1 = 0x100000),
otteniamo una dimensione del segmento pari a (limit+1)*(4 KB) = 0x100000*0x1000 = 0x100000000 byte,
che è esattamente 2^32 byte ( = 4 GB). Se invece, con lo stesso limit, avessimo tenuto G=0, avremmo avuto
una dimensione di 0x100000*1 = 0x100000, ovvero 2^20 byte.
Base address indica il punto di partenza del segmento. Se pensavate che
tutti i segmenti partissero da 0, ovvero dall'inizio della memoria, sbagliavate:
un segmento può partire da qualunque punto della memoria. Se per esempio ho
un segmento con base=0x1000, e voglio accedere ad un dato contenuto alla posizione
1 di quel segmento (ad esempio, con "mov eax,dword [es:1]"), in realtà
accederò all'indirizzo fisico 0x1001. Il base address viene sempre sommato
all'indirizzo specificato; in questo modo può essere garantita una qualche forma di
protezione. Per esempio, costruendo un segmento di codice per
un'applicazione, potremmo stabilire come base il punto di partenza del codice
in memoria, e come limite la dimensione del codice stesso: in questo modo,
se ci fosse qualche puntatore sbagliato, le altre applicazioni
e il sistema operativo stesso sarebbero protetti da eventuali scritture sbagliate,
perchè il programma buggato rimarrebbe comunque entro i suoi limiti. (O, meglio,
se cercasse di scavalcare il segment limit, verrebbe generata un'eccezione,
e quindi il sistema operativo sarebbe in grado di chiudere l'applicazione buggata).
Il campo type specifica il tipo di segmento. Come abbiamo già detto nel
paragrafo 3.3.1, esistono diversi tipi; a noi interessano in particolare
il tipo data read-write (type=2) e code execute-read (type=0x0A).
Il campo type è contenuto nei 4 bit meno significativi di type__flag1.
Sempre in type__flag1 troviamo alcuni flag:
- S: specifica se il segmento
è di sistema (S=0) o se è un segmento normale di codice/dati (S=1).
Noi lo porremo ad 1. Per maggiori informazioni sui system descriptor
type, consultate il paragrafo 3.5 del manuale 3 di Intel.
- DPL (descriptor privilege level): in questo tutorial
ho omesso la (importantissima) trattazione dei vari livelli
di privilegi presenti in protected mode. Infatti non
sono necessari per gli scopi del nostro programmino;
vi consiglio comunque di approfondire il discorso sul solito manuale
3 di Intel.
- P (Present): indica se il segmento è presente in memoria
(P=1) o meno (P=0). Naturalmente i nostri segmenti saranno tutti
presenti in memoria, quindi inizializzeremo P ad 1.
In limit_16_19__flag2, oltre a 4 bit dedicati al segment limit, troviamo
altri flag:
- AVL (Available): un flag disponibile per la fantasia di noi programmatori ;)
- D/B (Default operation size / stack pointer size): indica
la dimensione degli operandi di alcune istruzioni. In pratica, senza
addentrarci troppo nei dettagli, se vogliamo un segmento a 32 bit settiamo
DB a 1, mentre se invece vogliamo un segmento a 16 bit poniamo DB=0.
- G (Granularity): di questo flag abbiamo già parlato sopra.
3.3.3 Costruiamo la nostra GDT
La nostra GDT sarà composta, come ho già accennato, da due segmenti:
uno di codice e uno di dati. Ho scelto di porre la base di entrambi
a 0, e il limit a 0xFFFFF (con granularità di 4 KB, quindi
una dimensione di 2^32 byte). Questa configurazione è
quella che sul manuale Intel viene chiamata basic flat model:
è il modello di memoria più semplice che si possa immaginare, in cui
la memoria è vista come un unico grosso spazio ("flat") di 4 GB, ed
ogni indirizzo logico equivale al corrispondente indirizzo fisico
(perchè la base è 0).
I nostri due descrittori quindi saranno:
code_desc:
dw 0xFFFF ;limit 0-15
dw 0x0000 ;base 0-15
db 0x00 ;base 16-23
db GDTFLAG_P+GDTFLAG_S+GDTFLAG_TYPE_CODE
db GDTFLAG_G_4KB+GDTFLAG_DB_32+0x0F
db 0x00 ;base 24-31
data_desc:
dw 0xFFFF ;limit 0-15
dw 0x0000 ;base 0-15
db 0x00 ;base 16-23
db GDTFLAG_P+GDTFLAG_S+GDTFLAG_TYPE_DATA
db GDTFLAG_G_4KB+GDTFLAG_DB_32+0x0F
db 0x00 ;base 24-31
Per aumentare un po' la leggibilità, ho dichiarato i vari flag come
costanti (trovate tutto in "pmode.asm").
Guardando nel codice, noterete che ho dichiarato anche un null descriptor:
è necessario, infatti, che il primo descrittore della GDT sia
un descrittore nullo. E' possibile, in questo modo, porre a 0
i selettori non utilizzati; se poi uno di questi selettori
inutilizzati viene per sbaglio usato, si verifica un'eccezione.
3.3.4 Carichiamo la GDT
Una volta creata la nostra GDT, dobbiamo dire alla CPU il suo indirizzo
e la sua dimensione. Esiste uno speciale registro, GDTR, che contiene appunto
queste due informazioni. Il formato di GDTR è:
- bit 0-15: dimensione della GDT
- bit 16-47: indirizzo della GDT
Allora, possiamo scrivere da qualche parte queste due informazioni, in questo modo:
gdtr:
dw GDT_SIZE
dd null_desc
"null_desc" è l'indirizzo del primo descrittore della nostra GDT, mentre GDT_SIZE,
definita in fondo alla GDT come "GDT_SIZE equ $ - null_desc", ne contiene la dimensione.
Per caricare questi dati in GDTR usiamo l'istruzione "lgdt":
lgdt [gdtr]
A questo punto la CPU è pronta per passare in protected mode;
per poter essere a tutti
gli effetti in modalità protetta, ci manca solo da
settare un flag del registro CR0 (Control Register 0).
3.4 Lo switch
Come ho già accennato, ora dobbiamo porre a 1 il bit PE
(Protection enable) del
registro CR0 (il PE è il primo bit di questo registro):
quando settato, infatti, questo bit dice alla CPU
che ci troviamo in protected mode.
Una volta fatto questo, dobbiamo eseguire un far jump
ad un segmento di codice a 32 bit. Le ragioni di quest'ultima operazione
non sono del tutto chiare (nel senso che il manuale Intel sorvola sull'argomento,
fornendo spiegazioni non troppo esaurienti),
comunque è bene eseguirla, perlomeno per inizializzare CS in modo che
punti al descrittore del code segment nella GDT (ovvero, abbiamo bisogno
di inizializzare CS a 0x08).
Se vi interessa approfondire l'argomento c'è questo
thread
(del 2001) su alt.os.developing, in cui si discute l'utilità del far jump.
Il codice quindi risulta:
mov eax,CR0
or al,1 ;settiamo PE
mov CR0,eax
jmp dword 8:body_32 ;far jump
Da questo punto in poi l'esecuzione proseguirà da body_32.
4. Body_32: il codice in protected mode
La prima cosa che dobbiamo fare, una volta arrivati in
modalità protetta, è inizializzare tutti i selettori
(tranne CS, che è già stato inizializzato dal far jump)
in modo che puntino ad un descrittore valido.
Notate che inizializziamo anche SS con il descrittore del
segmento dati: lo stack infatti è un data segment a tutti
gli effetti. Sarebbe stato forse meglio prevedere un
descrittore separato dedicato allo stack...ma per i
nostri scopi va più che bene un unico descrittore dati.
Inizializziamo anche ESP, ponendolo a 0x000A0000...qui
un indirizzo vale l'altro, io ho scelto questo perchè è il
limite dei primi 640 KB di memoria (al di sopra dei quali
iniziano le varie rom e memory mapped i/o, ad esempio della vga).
Una volta fatto ciò, visualizziamo una stringa;
ho scritto alcune routine per la gestione della vga a basso
livello, ma le ho messe in un altro file ("vga32.asm") perchè
non rientrano nel nostro discorso sulla protected mode.
Se siete interessati, potete consultare le pagine del
FreeVga Project, un'ottima risorsa per la
programmazione della vga e della svga a basso livello.
L'ultima cosa da sottolineare è il conteggio della memoria
fisica: questo avviene con un semplice loop, in cui
vado a scrivere una dword in un certo indirizzo, e poi
controllo se la dword che leggo allo stesso indirizzo è uguale
a quella scritta; se è diversa, vuol dire che in quel punto non
esiste più memoria fisica.
In realtà, questo non è uno dei modi migliori per contare la memoria:
sarebbe meglio chiamare la funzione 0xE801 dell'int 15h
(consultate la Ralph Brown's interrupt list, o
http://www.uruk.org/orig-grub/mem64mb.html
per maggiori informazioni). Ad ogni modo, questo conteggio
voleva solo servire come semplice esempio per dimostrare
che possiamo accedere all'intera memoria fisica.
L'ultima cosa che facciamo è "appendere la cornetta":
un "cli / hlt" fa al nostro caso :) Potevamo anche
ritornare in real mode ed eseguire un reboot del sistema;
il ritorno in real mode, però, non è del tutto banale
(cioè, non basta rimettere il PE bit a zero); per maggiori
informazioni, consultate il manuale 3 di Intel, al paragrafo 8.8.2,
che descrive dettagliatamente l'intera procedura.
Bibliografia e referenze
Interrupt real mode e porte hardware:
Ralph Brown's Interrupt List
(anche qua
per una versione in html)
Linea A20:
http://www.frick-cpa.com/dos/DOS_Memory_1.asp
http://www.mega-tokyo.com/os/os-faq.html
http://osdev.neopages.net/tutorials/a20.php
Protected mode in generale:
http://x86.ddj.com/articles/pmbasics/tspec_a1_doc.htm
http://ritz.oltrelinux.com/osdevel/i32.txt
I dettagli (manuali Intel):
http://developer.intel.com
Altri link:
FreeVga Project
John Fine
Bona Fide OS development
alt.os.development