L'avvio del sistema

Di Alessandro Rubini

In queste pagine vengono descritte le prime fasi della procedura di
avvio di un sistema GNU/Linux, con riferimento al codice sorgente del
kernel 2.6.1.

* Introduzione

L'avvio del sistema operativo è il delicato insieme di operazioni che
trasformano un groviglio di metallo e silicio in una potente macchina
da calcolo multiutente e multiprocesso.  Nonostante non sia necessario
conoscere cosa succeda alla partenza della macchina per poterla
successivamente usare, ho sempre trovato molto interessante vedere
come i programmatori hanno affrontato le vari fasi di inizializzazione
del sistema.

Conoscere la divisione dei compiti tra i vari programmi e i vari file
del kernel, può anche essere utile; per esempio per diagnosticare cosa
succede quando la propria macchina si rifiuta di partire o quando si
deve far girare il kernel sulla scheda personalizzata che si è appena
finito di costruire.

* Il segnale di reset

Quando la macchina viene alimentata, viene attivato il segnale di
reset (lo stesso che talvolta è attivabile direttamente dall'utente
premendo un pulsante).  In risposta al reset, il processore inizia ad
eseguire le istruzioni in un modo predeterminato nella CPU stessa.  La
maggior parte dei processori moderni eseguono le istruzioni a partire
dall'indirizzo fisico 0, oppure caricano un puntatore a 32-bit da tale
indirizzo e lo usano come vettore del codice di avvio.

Il PC, invece, si comporta diversamente. In risposta al reset la CPU
si trasforma in un 8086, un processore a 16 bit che può indirizzare
solo 1MB di memoria, ed esegue le istruzioni all'indirizzo 0xffff0,
cioè appena sotto il limite di 1MB.  In entrambi i casi, le prime
istruzioni eseguite dal processore risiedono su memoria non volatile,
spesso memoria cosiddetta "flash".

Nel caso del PC il codice di avvio è parte del famigerato BIOS, con
cui tutti abbiamo imparato a convivere; su altre macchine si può
trattare di "open firmware" o altro codice generico; sui sistemi
embedded spesso il codice eseguito al reset è direttamente il boot
loader, riprogrammabile tramite interfaccia JTAG o BDM.

* Il BIOS

Su PC, il BIOS effettua una generica inizializzazione della macchina e
rende disponibile ai programmi una libreria di funzioni che effettuano
operazioni di base come caricare in memoria settori di dati dal floppy
o dal disco rigido; il nome BIOS significa infatti "Basic Input-Output
System". Una volta determinata la periferica da usare per il boot,
il BIOS carica un settore (512 byte) da tale periferica all'indirizzo
0x7c00 ed esegue il codice ivi contenuto.

Il BIOS si preoccupa anche di chiamare il codice di
auto-inizializzazione delle periferiche PCI o ISA, ove presente;
questo permette, per esempio, alle schede di rete di modificare la
procedura di boot e caricare dalla rete il passo successivo della
procedura di avvio, invece che da un disco locale.  In base ai
protocolli usati dalla scheda di rete e alla configurazione scelta,
tale passo successivo può essere il boot loader o direttamente il
kernel del sistema operativo.

Uno standard frequentemente usato per il boot via rete su x86 si
chiama PXE, "Pre-eXecution Environment". Le schede di rete conformi a
tale standard utilizzano i protocolli DHCP e TFTP per caricare dalla
rete un programma eseguibile. È possibile in questo modo caricare un
boot loader che permetta all'utente interattivo di scegliere tra varie
opzioni di boot o invocare comandi di diagnostica del sistema. GRUB,
per esempio, è un boot loader in grado di girare in ambiente PXE oltre
che in ambiente BIOS.

* Il boot loader

Qualunque sia la piattaforma hardware e la modalità di avvio, a un
certo punto il controllo passa ad un programma che si preoccupa di
caricare il kernel Linux in memoria e predisporre alcuni parametri
hardware necessari al suo funzionamento, si tratta del cosiddetto
"boot loader".

Il boot loader può variare in dimensione da pochi byte ad alcuni
megabyte.  Si tratta di pochi byte quando il controllo viene passato
direttamente ad una copia del kernel residente in un banco di memoria
flash mappata nello spazio di indirizzamento del processore (come
succede su alcune macchine con processore ColdFire, per esempio). Si
può trattare di alcuni megabyte quando, per esempio, il boot loader è
una copia del kernel Linux in qualche modo adattata a questo nuovo
ruolo; su alcune macchine infatti si è scelto di usare il kernel come
boot loader per beneficiare del supporto già presente in Linux per
molte periferiche, senza doverlo reimplementare nel loader. Questo e'
l'approccio usato per il boot del NetWinder (una macchina ARM) e per
la costruzione di MILO (il boot loader usato su molte macchine Alpha);
alcune macchine per usi specifici contengono addirittura un sistema
GNU/Linux completo residente in flash, usato come boot loader per una
nuova versione di kernel e di filesystem.

La maggior parte dei boot loader permettono all'utente di interagire
su porta seriale; quelli per PC (come GRUB e LILO) normalmente
interagiscono su VGA ma possono essere anch'essi configurati per usare
la porta seriale verso un'altra macchina, permettendo quindi il
controllo remoto delle opzioni di avvio, per esempio da una sessione
ssh su questa altra macchina. Il boot loader termina
l'esecuzione nel momento in cui ha caricato in memoria un kernel e lo
ha eseguito.

* arch/i386/boot

Fino alla versione 2.4 del kernel, un semplice boot-loader per PC era
contenuto negli stessi sorgenti del kernel e si preoccupava di
caricare il sistema direttamente da un floppy creato tramite "cat
bzImage > /dev/fd0" o "cat zImage > /dev/fd0". Era composto dai file
assembly in arch/i386/boot: bootsect.S, setup.S, video.S.

Per caricare un kernel zImage, la procedura usata è quella
rappresentata nella figura in questa pagina; un kernel bzImage viene
invece caricato direttamente in memoria alta e può quindi essere più
grande di 512kB (lo spazio disponibile tra 0x10000 e
0x90000). Nonostante la memoria oltre 1MB non sia normalmente
accessibile in modalità reale, il caricamento di un file bzImage usa
meccanismi avanzati per potervi scrivere.




I file zImage e bzImage sono tuttora composti dal codice di questi sorgenti assembly, cui viene concatenato al codice di decompressione del kernel (il cui sorgente sta in arch/i386/boot/compressed) e l'immagine compressa del kernel stesso. Con la versione 2.6, pero`, bootsect.S e setup.S non si occupano più di caricare dati dal floppy; se si prova ad avviare un kernel da dischetto si otterrà un messaggio che invita ad usare un vero boot loader, come si può vedere dalla versione corrente di bootsect.S. La parte finale di bootsect.S, però, contiene ancora alcune informazioni usate da altre parti della procedura di avvio; occorre quindi ancora copiare il primo settore di bzImage all'indirizzo 0x90000. I file setup.S e video.S (indicati genericamente come "setup" in figura) eseguono all'indirizzo 0x90200, come rappresentato in figura, e il loro ruolo è quello di recuperare dal BIOS informazioni sul sistema, come la mappa di memoria e il tipo di supporto APM disponibile, per renderle disponibili al kernel secondo un formato prestabilito. Un boot loader su x86 deve quindi caricare il primo settore dal file zImage o bzImage all'indirizzo 0x90000 seguito dal codice di setup a 0x90200, caricare il resto del file in memoria bassa (zImage) o alta (bzImage) e infine saltare all'indirizzo 0x90200. Il codice di setup, una volta recuperate le informazioni dal BIOS, attiva la modalità "protetta" della CPU, uscendo quindi dalla compatibilità 8086 e abilitando la gestione di tutta la memoria; infine esegue il codice successivo, saltando ad arch/i386/boot/compressed/head.S (rilocato all'indirizzo 0x1000 in figura) da cui viene invocato il decompressore: arch/i386/boot/compressed/misc.c . Tutto ciò può sembrare estremamente contorto, ma avviare un sistema operativo completo su un processore a 16-bit con 1MB di spazio di indirizzamento non è un lavoro da poco, in particolare considerando che occorre convivere con tante piccole incompatibilità tra macchine di costruttori diversi che non sono così uguali come dovrebbero essere. La descrizione precedente e` stata un po' semplificata cercando di guadagnare in chiarezza. Per chi vuole saperne di più, il protocollo di boot del kernel su piattaforma x86 è documentato in dettaglio nel file Documentation/i386/boot.txt . * Altre piattaforme L'avvio del sistema su altre piattaforme è un'operazione molto più lineare, in quanto il processore all'accensione lavora già a 32-bit (o 64-bit) e può vedere tutta la memoria installata, anche se non è attiva la MMU. Nella maggior parte dei casi viene comunque usato un boot loader prima di eseguire il kernel Linux, per permettere all'utente di scegliere tra varie opzioni di boot o invocare comandi specifici. Quasi tutti i boot loader per piattaforme embedded permettono di caricare un kernel e/o un filesystem via rete oltre che dalla memoria flash locale. Alcuni di essi permettono anche il debugging remoto del kernel tramite un'istanza di gdb su macchina host e un cavo seriale incrociato di comunicazione tra host e target. Alcune piattaforme (ARM, Cris, SH, x86_64) contengono codice simile a quello del PC per la decompressione dell'immagine binaria del kernel e la directory "compressed" contiene un'istanza dei file head.S e misc.c, con contenuti equivalenti a quelli della piattorma x86. * kernel/head.S e alternative L'esecuzione del kernel vero e proprio inizia con il codice che si trova nel file kernel/head.S nella subdirectory di piattaforma. Questo file azzera la memoria associata al segmento BSS (dati inizializzati a zero), predispone le strutture dati relative alla paginazione e abilita la MMU, per poi passare il controllo alla funzione start_kernel, definita in init/main.c . Un'eccezione a questa regola sono le piattaforme senza MMU: h8300 e m68knommu. In questi due casi non esiste un file "kernel/head.S" e l'esecuzione inizia dal file crt0_rom.S o crt0_ram.S relativo alla specifica macchina selezionata in configurazione. Per quasi tutte le piattaforme non-PC, infatti, il kernel deve essere configurato e compilato per la macchina sulla quale dovra` girare, in quanto le varie implementazioni di processori e schede richiedono una gestione differente a livello di kernel a causa del diverso numero e tipo di periferiche integrate e della diversa mappatura fisica degli spazi di memoria. Queste differenze implementative sono poi invisibili allo spazio utente, quindi lo stesso filesystem puo` funzionare su tutte le implementazioni dell'architettura, a parte ovviamente le applicazioni che gestiscono periferiche specifiche. Il compito di crt0_ram.S e` configurare la cache, se necessario spostare l'immagine del rom-filesystem all'indirizzo corretto (quello dove il kernel si aspetta di trovarlo) e azzerare la memoria del segmento BSS; crt0_rom.S deve inoltre copiare il segmento dati dalla ROM alla RAM, dove possano essere modificati. La copia delle aree di memoria e` necessaria in quanto in questi sistemi l'immagine di boot e` solitamente un kernel (segmento codice e segmento dati) concatenato con un rom-filesystem, mentre il segmento BSS viene allocato dal linker subito dopo la fine del segmento dati ma non fa parte dell'immagine del kernel. * init/main.c L'esecuzione del codice C ha inizio, come accennato, nella funzione start_kernel(). Da questo punto in avanti tutto il codice di avvio e` scritto in C, anche se nelle macro talvolta e` ancora nascosto un po' di assembly. La funzione inizializza i vari sottosistemi del kernel, evitando brillantemente la lunga serie di "#ifdef" presente nel "main.c" di tutte le versioni fino alla 2.2 compresa. Alla fine, dentro a rest_init() nello stesso file, crea il processo 1, "init", tramite la funzione kernel_thread(). Il processo padre, cui e` associato il pid 0, viene chiamato "idle task" ed ha l'unica funzione di far dormire il processore quando non ci sono processi attivi. Il processo 1 chiama le ultime funzioni di inizializzazione; collega a /dev/console i file descriptor associati a stdin, stdout e stderr; infine esegue il programma init. Questa fase finale della procedura di avvio e` autoesplicativa ed e` riportata in questa pagina. La funzione run_init_process() usa execve() internamente, per cui non ritorna mai in caso di successo. Una volta eseguito il processo init (con pid 1), il kernel non fa altro che eseguire le chiamate di sistema per conto dei processi utente. Quella che viene percepita come la parte piu` impegnativa (e noiosa) della procedura di avvio di un sistema GNU/Linux e` in realta` gestita dal processo init e dalla sua configurazione; tecnicamente il sistema a questo punto e` gia` avviato. Il prossimo mese vedremo alcuni dettagli interessanti relativi all'inizializzazione dei vari sottosistemi del kernel, su cui questa volta abbiamo sorvolato. RIQUADRI <Riquadro 1 - La memoria flash> <Riquadro 2 - JTAG e BDM> <Riquadro 3 - Assembly> <Riquadro 4 - La MMU> <Riquadro 5 - Esecuzione di init> <Riquadro 6 - Errata> <Riquadro 1 - La memoria flash> La memoria flash è memoria non volatile che si può cancellare a blocchi senza rimuoverla dal circuito; tipicamente un blocco è di 32kB, 64kB o 128kB. È l'evoluzione di una storia fatta di ROM ("Read-Only Memory"), PROM ("Programmable ROM", non cancellabile), EPROM ("Erasable PROM", cancellabile tramite esposizione ai raggi ultravioletti), EEPROM ("Electrically Erasable PROM", cancellabile senza rimuoverla dal circuito). Il principale problema della memoria flash, come degli altri dispositivi cancellabili, e` l'usura provocata dalle operazioni di cancellazione; un blocco di flash non puo` essere cancellato piu` di qualche migliaio o qualche decina di migliaia di volte. La flash è il componente che si trova all'interno di tutte le schede di memoria oggi disponibili sul mercato: Compact Flash, MMC, Secure Digital, chiavette USB. In questi dispositivi la flash è associata ad un microcontrollore che gestisce l'allocazione e la cancellazione dei blocchi, oltre al protocollo di alto livello -- IDE, USB o altro -- usato per comunicare con la macchina ospite. In un dispositivo embedded, come un palmare o una macchina industriale non-x86, la flash è saldata sulla motherboard e direttamente visibile sul bus di memoria del processore, come la RAM e le periferiche. Puo` quindi ospitare direttamente il boot loader, il kernel e il filesystem della macchina, senza bisogno di trasferire i dati in RAM. La gestione di alto livello delle aree di memoria ROM o flash nel kernel Linux e` implementata dal sottosistema MTD (Memory Technology Device), che coordina l'accesso agli spazi di indirizzi associati a memoria non-RAM e gestisce allocazioni e cancellazioni dei blocchi di flash. E` importante ricordare che sulla memoria flash non e` possibile ospitare direttamente un filesystem come ext2 o FAT, sia a causa dell'elevata dimensione dei blocchi, sia per l'elevato numero di riscritture del "superblocco" da parte di questi filesystem. Sulla flash deve essere ospitato un filesystem apposito (come JFFS2) oppure un filesystem read-only (per esempio ROMFS o CRAMFS). Il ruolo del microcontrollore nei dispositivi elencati in precedenza, infatti, e` anche quello di disaccoppiare l'utilizzo reale della flash dalla struttura dati visibile dalla macchina ospite, che spesso e` proprio un filesystem FAT. <Riquadro 2 - JTAG e BDM> L'interfaccia JTAG (Joint Test Action Group, IEEE Std 1149.1) e` uno standard elettrico e un protocollo di comunicazione per la verifica della funzionalita` dei circuiti integrati. Molti circuiti integrati oggi contengono un modulo di supervisione e di controllo remoto accessibile tramite interfaccia JTAG, cui di solito si accede con un cavo apposito dalla porta parallela di un PC. Nel caso di sistemi a microprocessore, l'interfaccia JTAG permette tra le altre cose di pilotare il bus dati e il bus indirizzi della CPU, per cui e` sempre possibile riprogrammare la memoria flash di un dispositivo il cui processore e` dotato di JTAG. BDM (Background Debug Mode) e` un'interfaccia sviluppata da Motorola per il controllo remoto. Offre funzionalita` simili a JTAG ed e` generalmente disponibile sui processori ColdFire e su alcuni PowerPC. Alcuni processori sono dotati sia di interfaccia JTAG sia di interfaccia BDM. <Riquadro 3 - Assembly> I file con suffisso .S sono sorgenti in "linguaggio di assemblatore", ovvero "assembly language" comunemente soprannominato "assembler" in Italia. La sintassi del linguaggio di assemblatore usata dagli strumenti GNU (gcc, gas) e` quella tradizionale dei sistemi Unix, spesso chiamata "sintassi AT&T", diversa dalla sintassi cosiddetta "Intel" cui e` abituato chi ha programmato in assembly su PC con altri sistemi operativi. Una descrizione delle differenze si trova nel nodo "i386-Syntax", nelle pagine info del pacchetto "gas". Fino alla versione 2.2 del kernel, i sorgenti assembly a 16-bit per l'architettura i386 (bootsect.S, setup.S, video.S) venivano compilato da "as86", un assemblatore con sintassi Intel, ma dalla versione 2.4 si e` passati all'uso di "gas" per questi sorgenti come per quelli a 32-bit. <Riquadro 4 - La MMU> L'unita` di gestione della memoria (MMU: Memory Management Unit) e` la parte di processore che implementa la memoria virtuale, ovvero la gestione degli indirizzi logici usati dai programmi in base a regole stabilite dal sistema operativo, permettendo quindi implementazioni come la protezione di memoria tra processi, la memoria condivisa, la gestione dello spazio di swap. Normalmente la MMU non e` in funzione quando un processore esce dal reset, perche` il sistema operativo deve predisporre le strutture dati associate alle pagine di indirizzi logici prima di poter abilitare la gestione di memoria virtuale. Interessante notare come la letteratura Intel non parli mai di MMU; il bit di abilitazione della memoria virtuale in questo caso si chiama "paging bit". Alcuni processori non sono dotati di MMU, come le famiglie di processori supportati da arch/m68knommu e arch/h8300, mentre alcune famiglie di processori esistono sia in versione con-MMU sia senza-MMU (e` il caso di ARM e della famiglia 68000, abbreviato in m68k, appunto). <Riquadro 5 - Esecuzione di init> if (open("/dev/console", O_RDWR, 0) < 0) printk("Warning: unable to open an initial console.\n"); (void) dup(0); (void) dup(0); /* * We try each of these until one succeeds. * * The Bourne shell can be used instead of init if we are * trying to recover a really broken machine. */ if (execute_command) run_init_process(execute_command); run_init_process("/sbin/init"); run_init_process("/etc/init"); run_init_process("/bin/init"); run_init_process("/bin/sh"); panic("No init found. Try passing init= option to kernel."); <Riquadro 6 - Errata> Nella societa` dell'informazione, o dell'eccesso di informazione, e` inevitabile che una parte delle informazioni che circolano siano errate, di parte o piu` semplicemente imprecise. Nonostante verifichi tutti i comandi che suggerisco e controlli tutto quello che scrivo sul codice sorgente o sui manuali hardware che ho a disposizione, anche a me capita di fare degli errori o di ignorare qualcosa sugli argomenti su cui scrivo; per fortuna ci sono dei lettori attenti che segnalano tali imprecisioni. Riguardo all'articolo sulla compilazione del kernel pubblicato due mesi fa, mi era sfuggito che con la versione 2.6 i comandi "make bzImage" e "make modules" svolgono operazioni gia` svolte dal semplice "make". Inoltre, per configurare il kernel con un'interfaccia grafica esiste l'alternativa "make gconfig", basata sulle librerie gtk invece che kde; su alcune distribuzioni "make xconfig" non trova le librerie necessarie ma "make gconfig" si. Infine, nonostante UML sia distribuito come parte del kernel ufficiale e sia predisposta la compilazione per piu` piattaforme, al momento funziona solo su x86 e solo dopo l'applicazione di patch aggiuntive, disponibili su user-mode-linux.sourceforge.net oppure people.redhat.com/mingo/UML-patches/ .