Boot Process
Home Linux Kernel

Il sito si è trasferito a qui

the site has been moved here

 

  

Il boot process di Linux

Il boot process di Linux spiegato step by step.

Questo articolo introduce il boot process di Linux e cerca di illustrare al lettore step by step cosa realmente accade durante questa fase. L'articolo inizia mostrando aI lettore come si crea un floppy di boot capace di stampare la stringa "Hello World" e, partendo da questo semplice esempio, ad ogni step aggiunge sempre nuove funzionalità, fino a completare il processo.

a cura di Salvatore D’Angelo – koala.gnu@tiscalinet.it

Introduzione

Questo articolo introduce il processo di boot adottato da Linux su architetture Intel. Esso guiderà il lettore step by step nella creazione di un floppy di boot capace di avviare una immagine di kernel che, nel nostro caso, sarà una semplice funzione C che stampa il messaggio “Hello Kernel”. Gli esempi riportati in questo articolo sono prevalentemente scritte in linguaggio assembly e C, per cui è prerequisita la loro conoscenza. Poichè andremo a lavorare direttamente con l'hardware del PC, verranno illustrati anche i concetti base della gestione della memoria nei processori i386 (e superiori), il funzionamento dei chips PIC 8259 e del keyboard controller 8042.

Poichè l'obiettivo di questo articolo è prevalentemente didattico, il processo di boot sarà spiegato in una forma semplificata rispetto alle correnti versioni di Linux 2.4, diciamo che come riferimento sono state prese le prime versioni di Linux molto più semplici da comprendere. Il lettore una volta compresi i concetti base potrà provare ad estendere le funzionalità del suo boot loader.

Alcuni concetti di base: modalità reale e protetta

Software di test

Boot process step 0: stampa di un messaggio di boot

Boot Process step 1: copia del boot sector in 0x9000:0

Boot Process step 2: definizione dello spazio per lo stack

Boot Process step 3: stop del motore del floppy

Boot Process step 4: caricamento del codice di setup

Boot Process step 5: routine di debug e stampa del messaggio “Loading system”

Boot Process step 6: jump al codice di setup

Boot Process step 7: caricamento del kernel in memoria

Boot Process step 8: spostiamo il kernel all'indirizzo 0x0100:0

Boot Process step 9: stampiamo un semplice messaggio di setup

Boot Process step 10: setting up global e interrupt descriptor tables

Boot Process step 11: abilita linea A20

Boot Process step 12: reset coprocessore

Boot Process step 13: PIC programming

Boot Process step 14: switch alla modalità protetta

Boot Process step 15: jump al codice kernel

Boot Process step 16: build immagine del kernel

Conclusioni

Bibliografia

 

Clicca qui per scaricare il codice sorgente degli steps sopra riportati.

 

Alcuni concetti di base: modalità reale e protetta.

Un microprocessore a 32 bit della famiglia i386 (o superiori) ha due modalità di funzionamento: reale e protetta. La prima fu introdotta per mantenere la compatibilità con le applicazioni che giravano sui vecchi processori a 16 bit i86/i286. Nella seconda modalità, invece, il microprocessore lavora pienamente a 32 bit e supporta un set di istruzioni molto più ampio.

In modalità reale sono disponibili 20 linee di indirizzamento che consentono di indirizzare fino a 1 Mb. Ogni singolo byte della memoria viene indirizzato attraverso una coppia di puntatori SEGMENTO:OFFSET ciascuno a 16 bit. Questa coppia di puntatori vengono combinati dal processore in un particolare registro interno di 20 bit, ottenendo di conseguenza l'indirizzo lineare del byte da indirizzare in memoria.

Per poter indirizzare la memoria sono disponibili alcuni registri elencati qui di seguito.

CS: punta al segmento codice da eseguire. Questo registro in combinazione con il registro IP punta alla prossima istruzione da eseguire. Entrambi i registri sono modificabili solo attraverso istruzioni di jump e di chiamate a procedure.

DS: punta al segmento dati. Spesso durante operazioni di copia di blocchi di dati, questo registro viene utilizzato in coppia con il registro SI (source index) per indicare la cella di partenza dei dati sorgente.

ES: extra segment. Registro di segmento ausiliario, spesso utilizzato in coppia con il registro DI  (destination index) durante la copia di blocchi di memoria per denotare la cella di partenza dei dati destinazione.

SS: punta allo stack segment. Lo stack viene utilizzato per il salvataggio dei registri. I moderni compilatori utilizzano lo stack per salvare i parametri passati a una procedura. In genere questo registro viene utilizzato in coppia con il registro SP per puntare al top dello stack.

Oltre a questi registri il processore i386 utilizza i seguenti registri general purpose: AX, BX, CX e DX. Per ognuno di questi registri a 16 bit è possibile accedere agli 8 bit più significativi o meno significativi attraverso i registri a 8 bit: AH-AL, BH-BL, CH-CL e DH-DL.

Ad esempio se AH=0x10 e AL=0x10  allora AX=0x1010.

Ipotizziamo di avere la coppia DS:SI con i seguenti valori, DS=8000h e SI=8F00h, allora per ottenere l'indirizzo lineare a cui la coppia di registri punta, basta operare in questo modo.

Si moltiplica DS per 10h e si somma poi l'offset: 80000h+8F00h = 88F00h.

Spiegata questa semplice modalità di indirizzamento, andiamo ad osservare come si presenta il 1° Mb di memoria dopo l'accensione.

La coppia di registri CS:IP punta all'indirizzo F000h:FFF0h, che è anche l'entry point del BIOS. Quest'indirizzo di conseguenza contiene un'istruzione di JUMP al codice effettivo del BIOS.

Il BIOS (Basic Input Output System) non è altro che un insieme di routine software che fornisce il supporto per gestire le periferiche del computer. Oltre a fornire questa interfaccia, il compito del BIOS è quello di verificare il corretto funzionamento dei dispositivi hardware essenziali in un computer, e di segnalare eventuali errori all'utente. Una volta che il diagnostico di sistema termina, viene invocato l'interrupt software 19h, meglio conosciuto come interrupt di boot-strap.

Compito di questo interrupt è quello di caricare dai dispositivi di memoria di massa il codice di boot e stampare un messaggio di errore se questo non viene trovato.

In modalità protetta il processore i386 (o superiori) hanno piene funzionalità a 32 bit, tuttavia il modo in cui vengono gestite le cose cambia completamente. Per comprendere le potenzialità di un processore i386 in modalità protetta, si consideri la seguente frase estratta da [1].

“L'80386 è un potente microprocessore a 32 bit ottimizzato per i sistemi operativi multitasking e progettato per applicazioni che necessitano di prestazioni molto elevate. Il processore può indirizzare fino a 4 Gb di memoria fisica e 64 Tb (2^46 bytes) di memoria virtuale. I mezzi di gestione della memoria onchip comprendono i registri per la conversione dell'indirizzo, un avanzato hardware multitasking, un meccanismo di protezione e il sistema di paginazione della memoria virtuale. Speciali registri di debugging forniscono breakpoint di dati e di codice perfino nel software basato su ROM”.

Queste poche righe dovrebbero dare un idea delle novità introdotte da questi tipi di microprocessori.

Il nuovo processore è dotato di un set di registri general purpose chiamati EAX, EBX, ECX, EDX, EBP, ESP, ESI, EDI che sono la versione a 32 dei noti registri a 16 bit AX, BX, CX, DX, BP, SP, SI, DI. Infatti è possibile accedere ai primi 16 bit del registro semplicemente usando il vecchio nome e così via anche per le due diverse parti a 8 bit.

Tra i registri messi a disposizione dal microprocessore, ci sono quelli di segmento che sono sempre CS, DS, ES, SS a cui si aggiungono FS e GS. In modalità protetta questi registri conterranno il selettore di un determinato indirizzo logico. Più avanti introdurremo il concetto di indirizzo logico e di selettore. Per ora ci basta tener presente che SS e ESP conterranno il valore dello stack pointer, mentre CS e EIP conterranno il valore del program counter.

Ci sono poi alcuni registri flags e altri utilizzati nella gestione della memoria tra cui figurano:

·        GDTR, Global Descriptor Table Register;

·        LDTR, Local Descriptor Table Register;

·        IDTR, Interrupt Descriptor Table Register;

·        TR, Task Register.

Esistono poi dei registri di controllo come CR0, CR2 e CR3. CR0 contiene i flags di controllo del sistema, che controllano o indicano le condizioni applicabili al sistema nel suo complesso e non ad un singolo task. Il registro CR2 viene impiegato per gestire gli errori di pagina qualora vengano utilizzate tabelle di pagine per convertire gli indirizzi. Il registro CR3 consente al processore di individuare l'elenco di tabelle di pagine per il tak corrente.

Infine ci sono registri di test e debugging che però non prenderemo in considerazione in questa sede.

Ora concentriamo la nostra attenzione su come venga gestita la memoria in modalità protetta.

In questa modalità di funzionamento un indirizzo è costituito da una coppia (selettore, offset) detto indirizzo logico. La Fig. 1 riporta il formato di un selettore. E' possibile notare un indice di 13 bit che identifica una entry in una GDT o LDT. C'è poi un Table Indicator (TI) che indica se l'indice fa riferimento a una GDT (TI = 0) o LDT (TI = 1, quest'ultima non è presa in considerazione in questo articolo). Infine, ci sono due bits che rappresentano il Requestor Privilege Level (RPL) che in Linux può essere 0 (kernel mode) o 3 (user mode).

                                      
Il registro GDTR contiene il base address della GDT, per cui sommando il suo contenuto con quello del selettore moltiplicato per 8 (visto che ogni entry occupa 8 bytes) si ottiene una entry nella GDT che contiene un descrittore di segmento il cui formato è riportato in Fig. 3.  Questo descrittore contiene il base address di un segmento nell'area di indirizzamento lineare che, sommato all'offset dell'indirizzo logico ci restituisce un indirizzo lineare, come mostra la Fig. 2.

                                         
Osservando la Fig. 3 si può constatare che il descrittore è composto da un certo numero di campi di cui diamo una descrizione solo per quelli che ci interessano ai fini del nostro articolo.

 ·        LIMIT

I due campi Limit 0-15 e Limit 16-19 costituiscono il limite del segmento. Il limite indica la dimensione del segmento. Concatenando i due campi contenuti nel descrittore otteniamo un indirizzo a 20 bit. Questo indirizzo può assumere un valore da 0 a 2^20-1.

 ·        GRANULARITY

Nel descrittore questo bit è indicato con la lettera G ed indica in che modo deve essere interpretato il campo LIMIT. Qualora questo bit non sia settato, il contenuto di LIMIT viene interpretato come unità in byte, quindi si ha un limite massimo di 1Mb, mentre se il bit G è settato il limite sarà considerato come il numero di unità da 4 Kb, ed in tal caso avremo un max di 4 Gb (come avviene nel nostro caso).

·        BASE

      Questo valore identifica la locazione del segmento entro lo spazio d'indirizzi lineare da 4 Gb. In parole semplici, identifica l'indirizzo assoluto in memoria dove si trova il 1° byte del segmento

·        DPL

Questi 2 bit indicano il livello di privilegio del descrittore e di conseguenza i relativi permessi concessi al codice o ai dati in esso contenuti. Nel nostro caso utilizzeremo solo la modalità 0 (kernel mode) e 3  (user mode).

                    
 La prima entry della GDT viene generalmente settata con 8 bytes nulli (cioè uguali a 0) al fine di una corretta gestione dei page fault. La seconda e terza entry, in genere, sono riservati al code e data segment del kernel.

Come ultimo aspetto riguardo la modalità protetta, vediamo invece come viene gestita la tabella degli interrupt.

Il registro IDTR contiene l'indirizzo della tabella dei vettori di interrupt. In modalità reale questa tabella è costituita da vettori di 4 bytes per un numero massimo di 256 elementi, collocata all'indirizzo di memoria 0x0. In modalità protetta, ogni task può avere una sua tabella di interrupt. Gli elementi possono essere fino ad un massimo di 256 elementi, ed ognuno di essi è rappresentato da una struttura di 64 bit come riportato in Fig. 4.

                                       
 Analizziamo ora in dettaglio i campi di un Interrupt Descriptor.

 ·        SELECTOR

Insieme ad OFFSET fornisce l'indirizzo logico dell'handler di interrupt.

·        OFFSET

Insieme ad SELECTOR fornisce l'indirizzo logico dell'handler di interrupt.

·        Bit P

Indica la presenza o meno dell'interrupt.

·        DPL

Indica il livello di privilegio dell'interrupt.

·        Bit T

Indica se si tratta di una trap (T=1) oppure di un interrupt (T=0).

Quando si verifica un errore, la CPU in modo protetto lo comunica al sistema operativo generando una eccezione. Le eccezioni sono delle interruzioni che mandano in esecuzione i relativi interrupt.

Pertanto, i primi 17 interrupt sono riservati al codice di sistema operativo.

IDT[00] Errore di divisione

IDT[01] Eccezione di debugging

IDT[02] Non usato

IDT[03] Breakpoint/Debugging

IDT[04] Overflow

IDT[05] Check limits

IDT[06] Codice operativo non valido (istruzione sconosciuta)

IDT[07] Coprocessore non disponibile

IDT[08] Doppio difetto

IDT[09] Superamento del segmento di coprocessore

IDT[0A] TSS non valido

IDT[0B] Segmento non presente

IDT[0C] Eccezione di stack

IDT[0D] Protezione generale

IDT[0E] Difetto di pagina

IDT[0F] Non usato

IDT[10] Errore di processore

I rimanenti interrupt possono essere definiti dal sistemista o dall'applicativo.

Software di test

Gli esempi riportati in questo articolo sono stati testati su una distribuzione Linux RedHat 8.0 dove è disponibile l'utility make, il compilatore gcc, l'assemblatore as ed il linker ld.

Boot process step 0: stampa di un messaggio di boot

Questa sezione illustrerà come creare un floppy di boot che permette la stampa del messaggio “Hello World” all'avvio del computer. Quando un computer parte e da BIOS viene configurato il floppy come drive di boot, esso cerca di caricare il primo settore nella locazione 0:0x07C0. E' importante che questo settore al primo byte contenga già una istruzione eseguibile e che l'ultima word sia uguale a 0xAA55 (identificativo di un settore di boot).

Quindi per stampare il messaggio di “Hello World” al boot basta utilizzare il servizio 0x0E dell'interrupt 0x10 che consente la stampa di un carattere (caricato nel registro AL) a video .

            .code16

            .text

 

            .global _start

            _start:

                        movb $0x0E,  %ah

                        movb $'H', %al                     // stampa il carattere 'H'

                        int 0x10                       

                        movb $'e', %al                     // stampa il carattere 'e'

                        int 0x10                       

                        ......                                          // stampa altri caratteri

            done:

                        jmp done                             // loop infinito

 

            .org 510

           

            boot_flag:         .word 0xAA55

Il programma termina con un loop infinito, questo serve per evitare che la CPU esegua codice invalido. Si noti come i bytes finali 510 e 511 siano, rispettivamente, uguali a 0xAA e 0x55.

Una volta scritto il programma in un file che chiameremo bootsect.S è possibile compilarlo attraverso i seguenti comandi.

            as -o bootsect.o bootsect.S

            ld -Ttext 0x0 -s –oformat binary -o bootsect bootsect.o

Il primo comando crea un object file partendo dal codice in assembler. Il secondo comando crea l'immagine di boot chiamata “bootsect”. L'opzione -Ttext 0x0 indica che 0x0 è l'indirizzo di partenza per i segmenti code, data e bss. L'opzione -oformat specifica il formato di output prodotto da ld.

A questo punto per creare il dischetto di boot basta eseguire il seguente comando.

            dd if=bootsect of=/dev/fd0 bs=512

Spegniamo ora il computer e inseriamo il dischetto nel floppy driver (assicuriamoci che il  BIOS sia configurato in modo tale da consentire il boot da dischetto). Durante lo startup della macchina ecco che per magia compare il messaggio “Hello World”.

A questo punto per velocizzare il processo di sviluppo abbiamo bisogno di due cose, un sistema più rapido del reboot per testare il nostro codice e un Makefile.

Il primo problema si risolve semplicemente scaricando da http://sourceforge.net/projects/bochs il tool bochs che è un emulatore Intel open source.

Vediamo, invece, come creare il nostro Makefile.

Chi lavora in ambiente Unix sa che l'utility make viene utilizzata per la build automatizzata di progetti sotware. In pratica se la build di un programma richiede l'inserimento di molti comandi questa utility consente allo sviluppatore di definire semplici comandi con cui gestire lo sviluppo del proprio progetto. Il nostro Makefile dovrà fornire tre semplici comandi.

            make all            ->        per una build completa el progetto

            make disk        ->        per creare l'immagine di boot

            make clean    ->        rimozione di tutti i file generati in fase di build

Qui di seguito è riportato il Makefile utilizzato nel nostro progetto.

            AS=as

            LD=ls

 

            all: bootsect image

                       

            bootsect: bootsect.o

                        $(LD) -Ttext 0x0 -s –oformat binary -o $@ $<

 

            bootsect.o: bootsect.S

                        $(AS) -o $@ $<

 

            disk: image

                        dd if=image of=/dev/fd0 bs=512

 

            image: bootsect

                        cat bootsect > image

            clean:

                        rm bootsect

                        rm image

                        rm *.o

Si provi ora a buildare il nostro software con i comandi di build nel seguente ordine:

            make clean

            make all

            make disk

Boot Process step 1: copia del boot sector in 0x9000:0

Il primo passo del processo di boot è quello di spostare il boot sector dalla locazione 0x07C0:0 a 0x9000:0. Questo per evitare che, quando si caricherà il kernel nello step 7, questi vada a sovrascrivere il codice correntemente in esecuzione.

I registri DS:SI punteranno alla cella iniziale dei dati sorgenti, cioè all'indirizzo 0:0x07C0. I registri ES:DI punteranno, invece, all'indirizzo iniziale del blocco destinazione che, nel nostro caso, sarà 0x9000:0. Per fare la copia si utilizzeranno le istruzioni assembler:

            CLD

            REP

            MOVSW

con CLD si stabilisce che ad ogni word copiata il registro DI venga incrementato. REP indica che l'istruzione successiva deve essere ripetuta per un numero di volte pari al contenuto del registro CX (che nel nostro caso conterrà 256 word= 512 bytes). MOVSW muove una word da DS:SI a ES:DI.

Dopo questa operazione si fa un jump alla locazione 0x9000:go che conterrà l'istruzione successiva da eseguire nel boot sector.

Qui di seguito riportiamo il codice che effettua la copia sopra citata. Per testare che la copia sia avvenuta con successo e che il jump non abbia generato problemi, stampiamo in questa nuova regione il messaggio “Hello World”.

            BOOTSEG=0x07C0

            INITSEG=0x9000

           

            .code16

            .text

 

            .global _start:

            _start:

                        movw $BOOTSEG, %ax

                        movw %ax, %ds                      # DS = 0x07C0

                        movw $INITSEG, %ax

                        movw %ax, %es                      # ES = 0x9000

                        movw $256, %cx                    # CX = 256

                        subw %si, %si                            # SI = 0

                        subw %di, %di                         # DI = 0

                        cld

                        rep

                        movsw                                      # copia boot sector

                        ljmp $INITSEG, $go                # jump a 0x9000:go

 

            go:

                        <stampa “Hello World” come l'esempio precedente>

 

            .org 510

 

            boot_flag:         .word 0xAA55

Compiliamo il nostro programma e creiamo il disco di boot con i comandi make illustrati nello step precedente.

Boot Process step 2: definizione dello spazio per lo stack

Negli steps che seguiranno, si effettueranno chiamate a procedure, per cui è importante definire uno spazio per lo stack settando opportunamente i registri SS e SP.

Abbiamo visto che il boot sector occupa uno spazio compreso tra 0x9000:0 e 0x9000:0x01FF. A partire dall'indirizzo 0x9000:0x0200 nei prossimi steps caricheremo 4 settori (2048 bytes) che conterrano del codice di setup. Per cui la regione di memoria compresa tra 0x9000:0x0200 e 0x9000:0x09FF sarà riservato al codice di setup. Dopo questa regione definiamo la regione dello stack che si estenderà fino a 0x9000:(0x4000-12).

  Il motivo per cui i primi 12 bytes non fanno parte dello stack è che questi nel vero codice di boot di Linux servono a contenere la Disk Parameter Table utilizzata per ottimizzare la lettura dei settori.

  L'unica cosa che per ora ci interessa sapere è che la regione stack parte dall'indirizzo 0x9000:(0x4000-12) e si estende verso l'alto fino all'indirizzo 0x9000:0x0A00 (vedi Fig. 5).


 

Qui di seguito riportiamo il codice per il setting dell'area stack.

                                ....

                go:

                                movw $0x4000-12, %di

                                movw %ax, %ds

                                movw %ax, %ss                   // SS = 0x9000

                                movw %di, %sp                   // SP = 0x4000-12

 

                                <stampa “Hello World” come l'esempio precedente>

 

                .org 510

                boot_flag:              .word 0xAA55

Boot Process step 3: stop del motore del floppy

In questo step introduciamo una procedura che ci consente di effettuare lo stop del motore del floppy dopo la lettura dei dati da disco. Questo ci consentirà di attivare il kernel in una situazione consistente dal punto di vista del floppy. In questa sezione non entreremo in dettaglio circa l'hardware del floppy, bensì vedremo solo le cose necessrie per i nostri scopi.

Di solito i PC utilizzano il controller disco NEC µPD765. I PC AT possono includere anche il controller 82072A, mentre i PS/2 usano un Intel 82077A. Questo controller gestisce un certo numero di registri tra cui il Digital Output Register (DOR) che è un registro a 8 bits a sola scrittura, disponibile all'indirizzo 0x3F2, che si occupa della gestione dei motori dei vari floppy drives disponibili.

                                         

                                                                                          Fig. 6               

In Fig. 6 è possibile osservare il formato di questo registro.

I bit DR1, DR0 selezionano il floppy driver a cui inviare il comando di stop del motore. La selezione del drive ha senso solo se il motore è attivo.

Il bit REST quando è posto a 1 attiva il controller, mentre se vale 0 esegue il reset del controller.

Quando si effettua lo start del motore di un floppy drive si può decidere di associare ad esso una linea di DMA con relativo canale IRQ.

MOTA, MOTB, MOTC, MOTD controllano lo start/stop per i floppy drive A, B, C e D.

Se il bit MOTx è 1, allora il motore del floppy drive x viene avviato, altrimenti viene spento.

Visto che il nostro obiettivo è quello di spegnere il motore di tutti i floppy drive disponibili, allora bisogna scrivere il valore 0 nel registro DOR.

            out[0x3F2] = 0

Qui di seguito riportiamo il codice della routine chiamata “kill_motor”.

            kill_motor:

                        movw    $0x3f2, %dx

                        xorb    %al, %al

                        outb    %al, %dx           # out[0x3f2] = 0

                        .word   0x00eb, 0x00eb           # breve delay

                        ret

 questa routine verrà invocata nel nostro main program subito dopo la stampa del messaggio di “Hello World”.

Boot Process step 4: caricamento del codice di setup

Il codice di setup segue sul floppy di boot il bootsector, ed esso occupa 4 settori (2048 bytes) di disco. Quindi i settori 2-5 della traccia 0 contengono tale codice. Quest'ultimo deve essere caricato in memoria nel segmento 0x9000 subito dopo il boot sector, per cui a partire dall'offset 0x0200 (512 appunto). Per effettuare questa copia utilizzeremo il servizio 0x02 dell'interrupt 0x13 che consente di copiare n settori dalla locazione di disco (drive, head, track, sector) in memoria a partire dall'indirizzo puntato dai registri ES:BX. Una volta eseguita questa copia, per eseguirlo verrà effettuato un semplice jump alla locazione iniziale del codice di setup (0x9000:0x0200).

Prima di effettuare la copia è necessario eseguire un reset del controller del floppy attraverso il servizio 0x00 dell'interrupt 0x13, come mostra il codice seguente.

            load_setup:

                        xorb %ah, %ah           # AH = 0 -> service 0x00

                        xorb %dl, %dl            # DL = 0 -> drive 0

                        int $0x13                    # reset FDC

Dal codice si intuisce che il valore del servizio deve essere inserito nel registro AH, mentre il floppy drive da resettare va posto nel registro DL.

Dopo questa semplice operazione avviene la vera e propria copia, come anticipata sopra.

Il servizio 0x02 dell'interrupt 0x13 consente di copiare n settori di disco in memoria. Le specifiche di questo servizio sono le seguenti.

            DH = drive da cui vengono letti i dati (0 nel nostro caso)

            DL = testina da cui parte la copia (0 nel nostro caso)

            CH =traccia da cui parte la copia (0 nel nostro caso)

            CL = settore da cui parte la copia (2 nel nostro caso)

            ES:BX = indirizzo di memoria destinazione

            AH = numero servizio (0x02)

            AL = settori da copiare (4 nel nostro caso)

La routine di interrupt ritorna un codice nel registro AX che rappresenta il risultato della copia. Questo valore è 0 se la copia è avvenuta con successo, altrimenti conterrà un opportuno codice di errore.

Per semplicità in questo step faremo stampare a video la stringa “Error” se avviene un errore durante la copia, nel prossimo step vedremo come effettuare il dump del codice di errore.

                                                     

Qui di seguito riportiamo il codice assembler utilizzato per fare il loading del codice di setup.

                        movb    $0x02, %cl

                        movw    $0x0200, %bx

                        movb    $0x02, %ah

                        movb    $4, %al

                        int     $0x13

 

                        jnc ok_load_setup

 

                        stampa il messaggio “Error”

                        jmp     load_setup          # riprova di nuovo

           

            ok_load_setup:

 

                        stampa il messaggio “Setup loaded”

                        call kill_motor

            done:

                        jmp done

La Fig. 7 mostra come appare la memoria dopo quest'ulima operazione di copia.

Boot Process step 5: routine di debug e stampa del messaggio “Loading system”

Dopo aver caricato il codice di setup, stampiamo il messaggio “Loading system” utilizzando il servizio 0x13 dell'interrupt 0x10. Questo perchè quando verrà passato il controllo al codice di setup, questo inizializzerà alcune componenti di sistema e poi provvederà a caricare il kernel in memoria. Ricordiamo al lettore che nel nostro caso il kernel è una semplice routine C che stampa il messaggio “Hello Kernel”.

Il servizio 0x13 consente di stampare a video una intera stringa. Le specifiche di questo servizio sono le seguenti.

            CX= numero caratteri della stringa (compreso lo 0 finale)

            BH=pagina video (0 nel nostro caso)

            BL=attributi di stampa (7 nel nostro caso)

            ES:BP=indirizzo stringa

            DH=riga dove scrivere la stringa

            DL=colonna dove scrivere la stringa

            AH= numero servizio (0x03)

Per avere in DH DL la posizione corrente del cursore, utilizzeremo il servizio 0x03 dell'interrupt 0x10.

Qui riportiamo il nuovo codice da aggiungere al file bootsect.S subito dopo il codice di caricamento dei settori di setup.  Ovviamente è ora possibile rimuovere il codice che stampava il messaggio “Setup loaded!!”. 

            movb    $0x03, %ah     # ottieni la posizione del cursore in DH e DL

            xorb    %bh, %bh        # BH = 0

            int $0x10

                    

            movw    $17, %cx

            movw    $0x0007, %bx

            movw    $msg1, %bp

            movw    $0x1301, %ax

            int     $0x10

 

            ....

 

            msg1:   .byte 13 10                   # new line

                        .ascii “Loading system ”

Aggiungiamo a questo punto al nostro codice due routine che potrebbero tornare utile in fase di debugging: print_all e print_hex. La prima stampa le prime 5 word al top dello stack che dovrebbero contenere: codice errore, AX, BX, CX e DX; Questa routine è utile per verificare il valore dei registri in un dato punto del codice o al termine di una routine di interrupt. La seconda routine stampa la word puntata da SS:BP in formato esadecimale. Evitiamo di riportare in tale sede il codice perchè l'introduzione di queste routine è facoltativa e necessita solo di conoscenze assembler. Il lettore potrà trovare il codice nei files sorgenti relativi a questo step.

Ora che abbiamo a disposizione queste routine di debugging anzichè stampare il messaggio “Error” come facevamo nello step 4, se il caricamento del codice di setup falliva, stampiamo il codice di errore in AX.

            pushw   %ax     # stampa il codice di errore sul video

            call    print_nl    # stampa unanew line sul video

            movw    %sp, %bp

            call    print_hex # stampa AX sul video

            popw    %ax

            jmp     load_setup         # riprova di nuovo

 

Boot Process step 6: jump al codice di setup

In questo step introduciamo un nuovo file che chiameremo setup.S, esso conterrà il codice di setup del boot loader. Questo codice, come già anticipato sopra dovrà occupare 4 settori (2048 bytes). Per ora nel file setup.S effettuiamo la stampa di un semplice messaggio “Wow I am in setup”, mentre in bootsect.S dobbiamo effettuare un jump al codice di setup.

Questo è il codice di setup.S.

            .code16

            .text

 

            .global _start

            _start:

                        stampa il messaggio “Wow I am in setup”

 

            done:                            # loop infinito

                        jmp done

 

            .org 2048                                 # size 4 settori

Nel file bootsect.S, invece, aggiungiamo subito dopo lo stop del floppy motor un jump all'indirizzo di inizio del setup code (0x9000:0x0200).

            ljmp $INITSEG, $0

E' necessario, a questo punto, modificare il Makefile in modo tale che venga buildato anche setup.S e che il relativo object file venga concatenato al boot sector nel file immagine.

            all: bootsect setup image

            .....

            setup: setup.o

                        $(LD) -Ttext 0x0 -s --oformat binary -o $@ $<

            setup.o: setup.s

                        $(AS) -o $@ $<

            setup.s: setup.S

                        $(CPP) -traditional $< -o $@

La regola per creare il file immagine, invece, è la seguente.

            image: bootsect setup

                        cat bootsect > image

                        cat setup >> image

Buildiamo il nostro nuovo codice, e avviamo il nostro dischetto di boot. Dovrebbero comparire i seguenti messaggi.

            Loading system

            Wow I am in setup

Boot Process step 7: caricamento del kernel in memoria

Prima di effettuare il jump al codice di setup, il codice di boot provvede a caricare il codice del kernel nella locazione 0x1000:0. La lettura avviene utilizzando sempre il servizio 0x02 dell'interrupt 0x10 che abbiamo già esaminato nello step 4. La lettura avviene una traccia per volta. Visto che il nostro dischetto di boot lavora prevalentemente con floppy da 1.44Mb, avremo che per ogni traccia leggeremo 18 settori (solo per la 1° traccia leggeremo 13 settori, visto che il boot sector e i settori di setup sono già stati letti).

E' importante ricordare, inoltre, che la lettura delle tracce avviene in questo modo.

Per il drive 0, vengono lette prima le tracce 0 per entrambe le testine, poi la traccia 1 per entrambe le testine e così via, questo ovviamente serve a ridurre al minimo il movimento delle testine. Per i floppy 1.44Mb abbiamo 2 traccie per testina, per cui la lettura delle tracce avviene nel seguente ordine:

            drive=0, testina=0, traccia=0

            drive=0, testina=1, traccia=0

            drive=0, testina=0, traccia=1

            drive=0, testina=1, traccia=1

            drive=0, testina=0, traccia=2

            drive=0, testina=1, traccia=2

                        ......

Come abbiamo più volte detto il nostro kernel è una semplice routine C che stampa il messaggio “Hello Kernel”. Questo kernel non è altro che un file eseguibile a.out che chiameremo “kernel” la cui size è definita in bootsect.S attraverso la variabile SYSIZE. Questa variabile deve contenere la size del kernel in CLICKS (16 bytes). In pratica se la size del kernel è x, allora SYSIZE=(x+15)/16.

Visto che read_it è una routine abbastanza complessa, riportiamo in questo articolo solo la versione in pseudo codice, rimandando il lettore ai listati sorgenti allegati all'articolo per i dettagli implementativi.

            head -> testina corrente

            track -> traccia corrente

            sread -> settore corrente

 

            check iniziali

            rp_read:

                        se tutti i bytes del kernel sono stati letti dal disco, allora stop

                        altrimenti vai a ok1_read;

            ok1_read:

                        es contiene il segmento che conterrà la copia della traccia

                        corrente;

                        bx contiene l'offset di memoria dove si inizierà a copiare;

 

                        se bx non ha superato i limiti dei 64 Kb allora vai a ok2_read;

 

                        è stato superato il limite di 64 Kb, per cui meno settori devono

                        essere letti, per sapere quandi settori si dovrà leggere basta

                        calcolare la distanza tra bx e la fine del segmento e dividere per

                        512.

            ok2_read:

                        leggi la traccia (o parte di essa) attraverso il servizio 0x02

                        dell'interrupt 0x13;

                        se la traccia non è stata letta completamente vai a ok3_read;

                        se per la testina n solo una traccia è stata letta allora vai a

                        ok4_read;

                        entrambe le tracce sono state lette per la testina n, quindi

                                    head = 1 - n                            

            ok4_read:

                        per la testina n entrambe le tracce sono state lette, quindi

                                    head = 1 -n

                                    track = track +1

            ok3_read:

                        update sread

                        se non abbiamo superato i limiti di 64 kb allora vai a rp_read;

                        sono stati superati i limiti di 64 Kb, per cui incrementa il

                        registro ES e a BX assegna 0.

                        salta a rp_read;

Questa routine deve essere invocata nel main program prima dell'invocazione alla routine kill_motor.

Abbiamo visto come la costante SYSIZE tiene traccia della size del kernel in CLICKS. E' chiaro che in fase di sviluppo la size del kernel varia in continuazione e ad ogni compilazione aggiornare questo valore potrebbe essere dispendioso. Per evitare ciò modifichiamo il Makefile in modo tale che questa venga calcolata a compile time in base alla size del kernel. Ecco la modifica suggerita:

            bootsect.o: bootsect.s

                        (echo -n "SYSSIZE = ("; echo -n `ls -gG kernel | cut -c16-24`; \

                        echo "+ 15 ) / 16") > tmp.s

                        cat bootsect.s >> tmp.s

                        mv tmp.s bootsect.s

                        $(AS) -o $@ $<

Per avere un minimo di kernel, scriviamo un file main.c e dentro definiamo la routine start_kernel (entry point del kernel) che effettua un loop infinito.

            void start_kernel(void) {

                        while(1) ;

            }

Aggiorniamo il Makefile affinchè da questa semplice file C venga creato una file eseguibile (il nostro kernel).

            all: kernel bootsect setup image

            ......

            kernel: main.o

                        $(LD) -e stext -Ttext 0x1000 -s --oformat binary head.o main.o -o $@

            main.o: main.c

                        $(CC) -Wall -O -fstrength-reduce -fomit-frame-pointer -c $< -o  $@

 

            image:

                        cat bootsect > image

                        cat setup >> image

                        cat kernel >> image

Nel processo di build comparirà un messaggio di warning come il seguente:

            ld: warning: cannot find entry symbol stext; defaulting to 00001000

per ora il lettore non si deve preoccupare di questo messaggio, negli step successivi, quando completeremo l'implementazione del kernel, questo messaggio non apparirà più.

Boot Process step 8: spostiamo il kernel all'indirizzo 0x0100:0

A questo punto del processo di boot il kernel viene copiato dall'indirizzo 0x1000:0 a 0x0100:0. A questo punto vi chiederete:  ma perchè il kernel non è stato copiato direttamente lì? La risposta è semplice, se avessimo copiato il kernel direttamente all'indirizzo 0x0100:0 avremmo coperto tutte l'area BIOS e, quindi, anche le routine di interrupt, tra cui la 0x13, cosa che non ci avrebbe consentito più la lettura da disco.

La prima cosa da fare in questo step è quello di disabilitare gli interrupt e NMI bootup, per evitare che da questo punto in poi avvenga un qualche tipo di interruzione.

            cli                     # no interrupts

            movb    $0x80, %al      # disabilita NMI bootup

            outb    %al, $0x70        # out[0x80] = 0x70

A questo punto inizia la vera e propria fase di copia, dove DS:SI punta all'area sorgente, mentre ES:DI a quella di destinazione. La copia avviene a blocchi di 4Kb.

            do_move:

                        movw    %ax, %es        # ES:DI = indirizzo destinazione

                        addw    $0x100, %ax

                        cmpw    $0x9000, %ax            # if (AX == 0x9000) jump a end_move

                        jz  end_move

                        movw    %bx, %ds       # DS:SI = indirizzo sorgente

                        addw    $0x100, %bx

                        subw    %di, %di

                        subw    %si, %si

                        movw    $0x800, %cx  # copia 0x800 words (4096 bytes == 4Kb)

                        rep

                        movsw

                        jmp do_move

 

            end_move:

                        ....

Si osservi che questo codice copia il kernel fino a coprire l'area da 0x1000 a 0x90000 per un totale di 572 Kb che rappresenta anche la size max del nostro kernel.

Boot Process step 9: stampiamo un semplice messaggio di setup

Per questo messaggio di setup usiamo il classico servizio 0x0E dell'interrupt 0x10. Stamperemo una stringa finchè non viene trovato il carattere null (0). Questo è il codice da aggiungere al nostro processo di boot. Questo step è opzionale.

                        leaw    msg, %si            # DS:SI puntano a msg

                        call    prtstr                   # stampa il mesaggio

                        ret

 

            prtstr:   lodsb                    # carica il carattere da stampare da DS:SI in AX

                        andb    %al,%al

                        jz      fin                        # stampa finchè AL non è 0

                          call    prnt1                   # stampa

                        jmp     prtstr

            fin:       ret

 

            prnt1:

                        pushw   %ax

                        pushw   %cx

                        xorb    %bh,%bh

                        movw    $0x01, %cx

                        movb    $0x0e, %ah

                          int     $0x10

                        popw    %cx

                        popw    %ax

                        ret

                        ....

 

            msg:     .byte 13, 10

                        .ascii "Setting up system"

                        .byte 0x0

 

Boot Process step 10: setting up global e interrupt descriptor tables

La prima cosa da fare in questo step è quello di caricare in IDTR e GTDR i base address e le size delle tabelle IDT e GDT.

Il seguente codice mostra come ciò viene fatto nel nostro codice.

            lidt    idt_48                  # base == 0, limit == 0

            lgdt   gdt_48     # limit == 2048 -> 256 entries

 

            ....

 

            idt_48:

                        .word   0          # idt limit = 0

                        .word   0, 0      # idt base = 0L

            gdt_48:

                        .word   0x8000            # gdt limit=2048, 256 GDT entries

                        .long   gdt + SETUPSEG*0x10

Si osservi come la GDT sia stata definita con 256 entries e con base address pari all'indirizzo corrispondente alla label gdt del codice di setup (infatti SETUPSEG*0x10 è l'indirizzo assoluto dove inizia il codice di setup e gdt è la label da dove parte la definizione della tabella).

            gdt:

                        .word   0, 0, 0, 0          # dummy

                        .word   0xFFFF           # 4Gb - (0x100000*0x1000 = 4Gb)

                        .word   0                      # base address = 0

                        .word   0x9A00            # code read/exec

                        .word   0x00CF           # granularity = 4096, 386

                                                #  (+5th nibble of limit)

 

                        .word   0xFFFF           # 4Gb - (0x100000*0x1000 = 4Gb)

                        .word   0                      # base address = 0

                        .word   0x9200            # data read/write

                        .word   0x00CF           # granularity = 4096, 386

                                                #  (+5th nibble of limit)

Cerchiamo di capire ora cosa significano questi valori. Dalla label gdt inizia la GDT che come abbiamo visto, contiene 256 entries. La prima entry generalmente è usata nella gestione dei page fault, per cui contiene, in genere, valori dummy. Da qui si spiega perchè le prime 4 word (primo descriptor) della GDT sono uguali a 0. Il secondo e terzo descriptor, in genere, descrivono il code e data segment del kernel.

Il kernel code segment è definito attraverso il seguente descriptor.

                        .word   0xFFFF           # size segment = 4Gb

                        .word   0                      # base address = 0

                        .word   0x9A00            # code read/exec

                        .word   0x00CF           # granularity = 4096, 386

il quale dice che il code segment ha come base address 0x0, size 4Gb e accesso in read/exec.

Il kernel data segment, invece, è definito attraverso quest'altro descriptor.

                        .word   0xFFFF           # 4Gb - (0x100000*0x1000 = 4Gb)

                        .word   0                      # base address = 0

                        .word   0x9200            # data read/write

                        .word   0x00CF           # granularity = 4096, 386

                                                #  (+5th nibble of limit)

Differisce dal precedente per il fatto che l'accesso è di tipo read/write.

Si noti come entrambi le aree hanno base 0x0 e size pari a 4Gb. Questo ci semplificherà le cose, perchè tutti gli indirizzi a 32 bit che utilizzeremo, saranno indirizzi assoluti.

Le restanti entries della GDT sono lasciati ai processi utenti, per cui non verranno al momento definiti.

Boot Process step 11: abilita linea A20

In origine il processore 8088 aveva 20 linee di indirizzamento, con cui poteva gestire uno space address di 1 Mb. Se un programma cercava di far riferimento ad un indirizzo maggiore di 1 Mb un wrap around veniva applicato. Con l'introduzione dei processori 286 le linee di indirizzamento furono portate a 24 dando così la possibilità di indirizzare fino a 16 Mb. Per mantenere la compatibilità verso i vecchi processori di default questi operavano con 20 linee di indirizzamento e la nuova modalità doveva opportunamente essere abilitata via software. Questa nuova funzionalità era ed è tuttora pilotabile attraverso il controller 8042 della testiera.

Nell'abilitare la linea A20 è importante che la coda della tastiera non contenga dati da elaborare, a tale scopo viene utilizzata la routine empty_8042 che ritorna non appena tale coda è vuota.

            empty_8042:

                        call    delay                   # piccolo delay

                        inb $0x64, %al # %al = in[0x64]

                        testb   $0x1, %al

                        jz  no_output    # if (bit %al[0] == 0) jump no_output

                        call    delay                   # piccolo delay

                        inb $0x60, %al # %al = in[0x60]

                        jmp empty_8042          # jump a empty_8042

            no_output:

                        testb   $2, %al  # if (bit %al[1] == 1) jump a

                                                # empty_8042

                        jnz empty_8042

                        ret

 

                        ....

 

            delay:

                        .word   0x00eb

                        ret

Si osservi come questo codice controlla che i 2 bits del registro di stato siano 0. Nel caso in cui il bit 0 non sia nullo (quindi ci sono dati in coda), questi vengono rimossi leggendo dalla porta 0x60 gli scan codes o i keybord data.

A questo punto si può abilitare la linea A20 ricordandoci di assicurare che prima di ogni write command la 8042 queue sia vuota.

            call empty_8042

            out[ 0x64 ] = 0xD1      # command: write to the output port

            call empty_8042

            out[ 0x60 ] =  0xDF     # bit 1 is set -> A20 enabled

            call empty_8042

Con il primo comando  specifichiamo che effettueremo una scrittura sulla porta di output del controller. Con il secondo comando, invece, abiliteremo la linea A20 impostando a 1 il bit 1 dell'output port.

Boot Process step 12: reset coprocessore

Il reset del coprocessore si effettua semplicemente scrivendo 0 sulle porte di I/O 0xF0 e 0xF1 come riporta il codice seguente.

           # Reset coprocessor

            xorw    %ax, %ax

            outb    %al, $0xf0      # out[0xF0] = 0

            call    delay

            outb    %al, $0xf1      # out[0xF1] = 0

            call    delay

 

Boot Process step 13: PIC programming

Il compito del controller PIC 8259 è quello di notificare la CPU di eventi provenienti da dispositivi hardware attraverso delle linee chiamate IRQs. Quando un evento viene notificato al PIC, questi provvederà poi ad avvertire la CPU che attiverà l'opportuna routine di handling. Se durante il processamento di un evento arriva un altro evento, questi viene messo in coda. Nel caso di arrivo di due eventi contemporanei, essi vengono gestiti in base ad una priorità ben precisa. In un PC con processore i386 o superiore, troviamo due controller PIC 8259 collegati in cascata, ognuno che gestisce 8 linee IRQ (IRQ0-IRQ7), chiamati “master” e “slave”. Per comodità chiameremo IRQ0-IRQ7 le linee IRQ del controller master e IRQ8-IRQ15 quelle dello slave. La Fig. 8 mostra il collegamento tra i due controller PIC che avviene attraverso le linee IRQ2 e IRQ12.

La seguente tabella illustra i dispositivi che generalmente sono collegati ai due controller.

PIC 8259 Master

IRQ0

System Timer

IRQ1

Keyboard

IRQ2

Collegamento al controller slave

IRQ3

COM2/COM4

IRQ4

COM1/COM3

IRQ5

LPT2

IRQ6

FDC

IRQ7

LPT1

PIC 8259 Slave

IRQ8

Real Time Clock Chip

IRQ9

Networking adapter

IRQ10

Non usato

IRQ11

Non usato

IRQ12

Collegamento al controller master

IRQ13

Floating Processor Unit

IRQ14

HDC

IRQ15

Non usato

 Chi ha configurato almeno una volta una scheda audio sa che spesso questa viene associata all' IRQ5, questo perchè generalmente ad un PC non si collega una seconda stampante, per cui questa associazione non crea alcun conflitto. E' fondamentale, tuttavia, che due dispositivi hardware realmente collegati al PC non utilizzino la stessalinea IRQ, altrimenti si genera un conflitto che provocherà malfunzionamenti nel nostro sistema.

 La comunicazione tra la CPU ed i due controller avviene attraverso 4 porte I/O (2 per ogni PIC).

                                           
Le porte sono la 0x20 e 0x21 per il controller master, e la 0xA0 e 0xA1 per il controller slave. Il PIC accetta due tipi di comandi:

·        Initialization Command Words (ICW);

·        Operation Command Words (OCW).

 

I comandi sono 7 e vengono spediti uno in coda all'altro seguendo l'ordine numerico.

Comando ICW1 (porta 0x20 o 0xA0)

Bits               7  6  5  4  3  2  1  0

                                  | |  | | |_ 1: comando ICW4 verrà inviato;

                                  | |  | |___ 1: master; 0: slave;

                                  | |  |_____ 1: dim. vettori interrupt è 8; 0: dim. vettrii intr. è 4;

                                  | |_______  1: level triggered (PS/2); 0: edge triggered;

                                  |_________  1: comando ICW1;

Comando ICW2 (porta 0x21 o 0xA1)

 Bits      7  6   5   4   3  2  1  0

                           |_|_|_|_|______ specifica il vettore di  interrupt per ogni IRQ partendo da     

                                                                IRQ0. Ad esempio, se questo valore è 0x20. allora IRQ0 -> 0x20,     

                                                                IRQ1 -> 0x21 e così via.

Comando ICW3 (porta 0x21 o 0xA1)

Bits 0-7. Indica a quale IRQ è collegato il canale slave sul canale master e viceversa. Su architetture AT i due controller sono collegati attraverso IRQ2 e IRQ12.

Comando ICW4 (porta 0x21 o 0xA1)

Questo comando viene inviato se e solo se il bit 0 di ICW1 è impostato a 1.

Bits               7    6    5   4    3     2   1   0

                     |    |   |   |   |    |   |   |_ sempre uguale a 1;

                     |    |   |   |   |    |   |___ questo bit seleziona il metodo con cui si termina un

                     |    |   |   |   |    |             interrupt. Se vale 1, viene impostato in AUTO MODE,

                     |    |   |   |   |    |             ovvero il PIC imposta il relativo bit dopo aver inviato

                     |    |   |   |   |    |             al processore il segnale di interrupt, mentre in

                     |    |   |   |   |    |             NORMAL MODE  il processore deve comunicare

                     |    |   |   |   |    |             attraverso OCW2 la fine dell'interrupt;

                     |    |   |   |   |    |______ buffered mode;

                     |    |   |   |   |_________  buffered mode;

                     |    |   |   |___________  1: Special Fully Nested Mode (SFNM);

                     |    |   |                             0: SEQUENTIAL MODE;

                     |    |   |_____________  sempre uguale a 0;

                     |    |________________  sempre uguale a 0;

                     |___________________ sempre uguale a 0;

Comando OCW1 (porta 0x21 o 0xA1)

Bits 0-7 – Ognuno di questi bit corrisponde allo stato del relativo IRQ. Se il bit è settato, i segnali provenienti dall'IRQ vengono ignorati, altrimenti se sono azzerati vengono elaborati.

Comando OCW2 (porta 0x20 o 0xA0)

Bits               7    6    5   4    3    2    1    0

                     |    |   |   |   |    |   |   |_  determina il livello prioritario;

                     |    |   |   |   |    |   |____ determina il livello prioritario;

                     |    |   |   |   |    |_______ determina il livello prioritario;

                     |    |   |   |   |__________ sempre uguale a 0;

                     |    |   |   |_____________sempre uguale a 0;

                     |    |   |_______________  1: se si comunica la terminazione di un interrupt;

                     |    |__________________  0: priorità “Rotate one”;

                     |                                           1: priorità è data dai bits 0-2;

                     |_____________________ 1: viene impostata la priorità in base al bit 6,

                                                                  altrimenti non viene modificata;

Comando OCW3 (porta 0x20 o 0xA0)

Comando inviato al PIC per leggere lo stato dell'ISR, dell'IRR e della MHI (Mask Hardware Interrupt).

Bits               7    6    5   4    3    2    1    0

                     |    |   |   |   |    |   |   |_ impostato secondo necessità (vedi bit 1);

                     |    |   |   |   |    |   |____ 1: bit 0 specifica quale registro leggere (0 ISR, 1 IRR);

                     |    |   |   |   |    |______ 1: PIC in modalità POLLING;

                     |    |   |   |   |_________  sempre uguale a 1;

                     |    |   |   |___________  sempre uguale a 0;

                     |    |   |______________  impostato secondo necessità (vedi bit 6);

                     |    |________________  1: bit 5 controlla la modalità della maschera;

                     |                                        (1 ON, 0 OFF) e tutte le richieste vengono elaborate

                     |                                        secondo privilegi;

                     |____________________ non usato

Dopo questa breve panoramica siamo pronti ad esaminare come i due controller PIC vengono programmati nel nostro piccolo progetto.

Innanzittutto inviamo il comando ICW1 ad entrambi i controller.

            movb    $0x11, %al      # Comando ICW1 (out[0x20] = 0x11, Master)

            outb    %al, $0x20

            call    delay  

            outb    %al, $0xa0      # Comando ICW1. (out[0xA0] = 0x11, Slave)

            call    delay

Questo comando ci dice che ICW4 verrà inviato e che la size di un elemento in un vettore di interrupt è 4 bytes.

Viene poi inviato il comando ICW2 con i quali specifichiamo i vettori di interrupt associati a ciascun IRQ. Nel nostro caso IRQ0 -> 20h, IRQ1 -> 21h, ..., IRQ8 -> 28h e così via.

            movb    $0x20, %al      # Comando ICW2. (out[0x21] = 0x20, Master)

            outb    %al, $0x21

            call    delay

 

            movb    $0x28, %al      # Comando ICW2. (out[0xA1] = 0x28, Slave)

            outb    %al, $0xa1

            call    delay

Con il comando ICW3 specifichiamo su quale IRQ del Master è collegato il controller Slave e viceversa. Con il codice seguente specifichiamo che il controller Slave è collegato su IRQ2 del Master, mentre quest'ultimo è collegato su IRQ4 (IRQ12) dello Slave.

            movb    $0x04, %al

            outb    %al, $0x21

            call    delay

   

            movb    $0x02, %al

            outb    %al, $0xa1

            call    delay

Il comando ICW4 specifica che l'End Of Interrupt (EOI) verrà gestito dalla CPU attraverso il comando OCW2.

             movb    $0x01, %al

            outb    %al, $0x21

            call    delay

 

            outb    %al, $0xa1

            call    delay

Infine con il comando OCW1 mascheriamo tutti gli interrupt sul controller slave, mentre sul Master li mascheriamo tutti eccetto IRQ2, dove è collegato lo Slave.

            movb    $0xff, %al

            outb    %al, $0xa1

            call    delay

 

            movb    $0xfb, %al

            outb    %al, $0x21

Boot Process step 14: switch alla modalità protetta

A questo punto siamo pronti per passare alla modalità protetta. Per fare ciò basta settare a 1 il bit 0 (PE) della Machine Status Word (MSW) che è parte del registro CR0.

Qui di seguito riportiamo il codice utilizzato per effettuare lo swicth alla modalità protetta.

            movw    $0x0001, %ax

            lmsw    %ax

            call    delay

 

Boot Process step 15: jump al codice kernel

Come prima cosa in questo step, scriviamo il codice del kernel con il suo header, per poi ritornare al codice di setup (setup.S) ed effettuare il jump ad esso.

Come già detto all'inizio di questo articolo, il nostro kernel stampa il messaggio “Hello Kernel”. Sappiamo che i servizi BIOS non sono più disponibili, per cui per scrivere il messaggio dovremo scrivere direttamente nell'area di memoria su cui è mappato il video. Questa area di memoria inizia all'indirizzo lineare 0xB8000. Il codice seguente nel file main.c stamperà il suddetto messaggio in rosso.

            void start_kernel(void) {

                        unsigned char *vid_mem = (unsigned char *)(0xb8000);

                       

                        *vid_mem++ = 'H';

                        *vid_mem++ = 0x06;

                        *vid_mem++ = 'e';

                        *vid_mem++ = 0x06;

                        *vid_mem++ = 'l';

                        *vid_mem++ = 0x06;

                        *vid_mem++ = 'l';

                        *vid_mem++ = 0x06;

                        *vid_mem++ = 'o';

                        *vid_mem++ = 0x06;

                        ....

                        while(1);

            }

A questo punto definiamo un nuovo file head.S che conterrà l'header per il codice kernel. Fondamentalmente questo file contiene del codice di inizializzazione che verrà eseguito prima che il controllo possa passare al kernel. Questo codice carica nei registri DS, ES, SS, FS e GS l'entry della GDT (0x10) che contiene il descrittore del segmento dati.

Riportiamo qui di seguito il codice di questo file.

            .text

 

            .globl stext

            .align 4,0x90

            stext:

            startup_32:

                        cld

                        movl    $0x10,%eax      # DS = ES = FS = SS = GS = entry 0x10

                        movw     %ax,%ds          # in GDT contiene il riferiemnto

                        movw     %ax,%es          # al data segment

                        movw     %ax,%fs  

                        movw     %ax,%ss 

                        movw     %ax,%gs

 

                        call    start_kernel         # call the kernel

 

            done:

                        jmp done

Si noti la chiamata alla routine principale del kernel.

Con l'introduzione di questo file è necessario anche la modifica del Makefile. La modifica deve essere tale che da head.S venga generato un object file head.o da linkare insieme a main.o per formare l'immagine del kernel.

            kernel: head.o main.o

                $(LD) -e stext -Ttext 0x1000 -s --oformat binary head.o main.o -o $@

 

            head.o: head.s

                $(AS) -o $@ $<

 

            head.s: head.S

                $(CPP) -traditional $< -o $@

Si osservi come nel comando utilizzato per la creazione dell'immagine del kernel, specifichiamo esplicitamente il suo offset 0x1000 che coincide con la label stext (definita nel file head.S).

A questo punto come ultimo passo, dobbiamo aggiungere in setup.S il codice per effettuare il jump alla prima istruzione del kernel.

                        .byte   0x66, 0xea         # ljmp (0x08, 0x1000)

            code32:            .long   0x1000

                        .word  0x08

L'indirizzo logico del kernel è (0x08, 0x1000), dove 0x08 è il selettore e 0x1000 è l'offset. Il valore del selettore ci dice che bisogna prendere l'entry della GDT che parte dal byte 0x08 (cioè la seconda entry, visto che ogni entry è 8 bytes). Questa entry conterrà il descrittore del segmento codice del kernel, il cui base address è 0x0. Quindi sommando quest'ultimo valore con l'offset, avremo che il kernel avrà un indirizzo lineare pari a 0x1000.

L'istruzione di jump deve essere una istruzione a 32 bit, mentre il file setup.S è compilato come codice a 16 bit. Per fare in modo che una istruzione 32 bit possa essere eseguita durante l'esecuzione di codice a 16 bit, si utilizza una particolare funzionalità dei processori i386 nel quale si fa precedere al codice operativo dell'istruzione da eseguire (0xEA per il ljmp) il codice 0x66.

Boot process step 16: build immagine del kernel

Questo step serve ad introdurre una piccola utility per la build dell'immagine del kernel presente fin dalla release 0.01 di Linux. Questa utility si chiama build ed è un semplice programma C che fa quello che fino ad ora abbiamo fatto con il comando cat nel Makefile. Questo piccolo programma semplicemente prende in input il bootsector, il file di setup e il codice di sistema e spara su stdout l'immagine del kernel. Quindi volendo creare con questa utility un file immagine basta scrivere l'istruzione riportata si seguito.

            ./tools/build bootsect setup kernel > image

E' chiaro che una utility di questo tipo è molto più versatile del comando cat perchè consente di effettuare molti controlli ad-hoc e avere numerose opzioni di build. Ad esempio, è possibile controllare che il boot sector sia effettivamente 512 bytes e che gli ultimi bytes siano effettivamente 0xAA55 ed altri controlli di questo tipo.  Noi utilizzeremo questa utility anche per scrivere nel boot sector la size del kernel nella variabile syssize in un modo più affidabile rispetto alla regola del Makefile:

                bootsect.o: bootsect.s

                                (echo -n "SYSSIZE = ("; echo -n `ls -gG kernel | cut -c16-24`; \

                                echo "+ 15 ) / 16") > tmp.s

<                                cat bootsect.s >> tmp.s

                                mv tmp.s bootsect.s

                                $(AS) -o $@ $<

che si basa sull'output di un comando shell.

Questa utility si preoccuperà anche di aggiungere il padding al codice di setup (cioè quello che noi facevamo con l'istruzione finale .org 2048 in setup.S, che ora andrà rimossa).

Vista la semplicità di quest'utility evitiamo di riportare il codice sorgente, rimandando il lettore direttamente al codice allegato all'articolo.

Conclusioni

In questo articolo abbiamo visto step by step come funziona il processo di boot nativo di Linux. Questo processo non è più supportato a partire dalla release 2.6 visto che diventava molto complesso gestire le varie geometrie dei dispositivi fisici. Quindi a partire da questa release si delega ai boot loader il compito di effettuare questo processo.

Bibliografia

[1] INTEL CORPORATION - “80386 Programmer's Manual”

[2] http://www.programmazione.it – Articolo sul sistema operativo Prometeus di Antonio Mazzeo

[3] Understanding Linux Kernel – Daniel P. Bovet & Marco Cesati – O' Reilly