L'avvio del sistema

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.

Riprodotto con il permesso di Linux Magazine, Edizioni Master.

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.

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, è l'usura provocata dalle operazioni di cancellazione; un blocco di flash non può essere cancellato più 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. Può 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 è 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.

È importante ricordare che sulla memoria flash non è 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, è anche quello di disaccoppiare l'utilizzo reale della flash dalla struttura dati visibile dalla macchina ospite, che spesso è proprio un filesystem FAT.

Riquadro 2 - JTAG e BDM

L'interfaccia JTAG (Joint Test Action Group, IEEE Std 1149.1) è uno standard elettrico e un protocollo di comunicazione per la verifica della funzionalità 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 è sempre possibile riprogrammare la memoria flash di un dispositivo il cui processore è dotato di JTAG.

BDM (Background Debug Mode) è un'interfaccia sviluppata da Motorola per il controllo remoto. Offre funzionalità simili a JTAG ed è generalmente disponibile sui processori ColdFire e su alcuni PowerPC. Alcuni processori sono dotati sia di interfaccia JTAG sia di interfaccia 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.

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) è quella tradizionale dei sistemi Unix, spesso chiamata "sintassi AT&T", diversa dalla sintassi cosiddetta "Intel" cui è 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 è passati all'uso di gas per questi sorgenti come per quelli a 32-bit.

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 riportata qui sotto; 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.



Figura 1 - L'avvio di un kernel zImage

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, però, 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 è 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.

Riquadro 4 - La MMU

L'unità di gestione della memoria (MMU: Memory Management Unit) è 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 è in funzione quando un processore esce dal reset, perché 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 (è il caso di ARM e della famiglia 68000, abbreviato in m68k, appunto).

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 dovrà 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 può funzionare su tutte le implementazioni dell'architettura, a parte ovviamente le applicazioni che gestiscono periferiche specifiche.

Il compito di crt0_ram.S è 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 è necessaria in quanto in questi sistemi l'immagine di boot è 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.

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.");

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 è scritto in C, anche se nelle macro talvolta è 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 è 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 è autoesplicativa ed è riportata nel riquadro 5. 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 più impegnativa (e noiosa) della procedura di avvio di un sistema GNU/Linux è in realtà gestita dal processo init e dalla sua configurazione; tecnicamente il sistema a questo punto è già avviato.

In un articolo successivo vedremo alcuni dettagli interessanti relativi all'inizializzazione dei vari sottosistemi del kernel, su cui questa volta abbiamo sorvolato.

Alessandro Rubini

La copia letterale e la redistribuzione su qualsiasi supporto di questo articolo nella sua integrità è permessa, purchè questa nota sia conservata.