A chaque fois que vous démarrez un système d’exploitation, vous l’utilisez ; peut-être même sans le savoir. Son nom est peut-être GRUB [1], LILO [2], ELILO [3], yaboot [4] ou encore BootX [5]. Nous allons voir ici quels en sont les principes en prenant pour exemple un des plus simples : EMILE [6]. Vous ne l’utiliserez sûrement jamais : l’architecture sur laquelle il s’utilise n’est plus commercialisée depuis 1995. En effet, EMILE est un chargeur d’amorçage pour linux/m68k [7] sur Macintosh à base de processeurs de la série 680x0.
Principes fondamentaux
La première des qualités d’un système d’amorçage (ou bootloader) est d’avoir la capacité de prendre le contrôle de l’ordinateur lorsqu’on l’allume. Ce résultat est généralement obtenu en plaçant le système d’amorçage dans le secteur d’amorçage du disque de démarrage. On verra que cela n’est pas aussi simple qu’on peut le penser. Ensuite, le rôle du système d’amorçage est de charger en mémoire l’image d’un noyau et quelquefois un RAMDISK (ou comme GRUB les nomme : des modules). Les plus complexes permettent à l’utilisateur de choisir parmi une liste préétablie. Une fois cette tâche effectuée, il doit placer le système dans un état permettant de démarrer le noyau tout en lui passant quelques paramètres, tels que la ligne de commande ou l’adresse du RAMDISK. À ce point, il ne lui reste plus qu’à passer la main : placer le pointeur d’instruction du processeur sur le point d’entrée du noyau chargé.
Prise de contrôle
La prise de contrôle se fait par l’intermédiaire du secteur d’amorçage. Le nombre et la taille des secteurs d’amorçage dépendent du média (disquette, disque dur, CD) et de l’architecture (PC, Mac ou autres). Nous allons étudier ici le cas du secteur d’amorçage d’une disquette pour Macintosh. La taille d’un secteur physique est 512 octets, et le secteur d’amorçage est composé de deux secteurs physiques. Nous avons donc 1024 octets pour stocker notre système d’amorçage... mais peut-on stocker un système d’amorçage digne de ce nom dans 1024 octets ? La réponse est malheureusement "non". Alors, comment faire ?
Premier niveau
La solution consiste à diviser le chargeur d’amorçage en deux parties : un "niveau 1" et un "niveau 2". Le rôle du niveau 1 est de charger le niveau 2 tout en étant assez petit pour être contenu dans le secteur d’amorçage. Pour illustrer cela, nous allons prendre pour exemple le niveau 1 d’EMILE.
101 start: 102 /* Allocate Memory for second stage loader */ 103 104 lea ioReqCount(%pc),%a0 105 move.l (%a0), %d0 106 add.l #4, %d0 107 NewPtr 108 move.l %a0, %d0 109 add.l #3, %d0 110 and.l #0xFFFFFFFC.l, %d0 111 112 /* save result in the ParamBlockRec.ioBuffer */ 113 114 lea ioBuffer(%pc),%a0 115 move.l %d0,(%a0) 116 117 /* Now, we load the second stage loader */ 118 119 lea param_block(%pc),%a0 120 PBReadSync 121 122 /* call second stage bootloader */ 123 124 move.l ioBuffer(%pc),%a0 125 jmp (%a0)
Comme on le voit, le principe est simple : on alloue la mémoire (NewPtr) et on charge en mémoire (PBReadSync) le niveau 2. Ces opérations sont effectuées en faisant appel à deux traps contenus dans la ROM du Macintosh :
19 .macro NewPtr 20 .short 0xA11E 21 .endm 22 23 .macro PBReadSync 24 .short 0xA002 25 .endm
Les informations à connaître pour être capable de charger le niveau 2 sont sa taille et sa position sur la disquette. Ces informations sont, elles aussi, stockées dans le secteur de démarrage, directement dans la structure utilisée par PBReadSync pour lire les secteurs de données (param_block). IoReqCount est la taille, ioBuffer contient l’adresse en mémoire où stocker le niveau 2. Une fois les données chargées, le niveau 1 passe la main au niveau suivant en sautant au point d’entrée du niveau 2.
Second niveau
Le niveau 2 est donc le système d’amorçage en lui-même. C’est avec lui que vous allez interagir, pour modifier la ligne de commande par exemple. C’est lui qui va préparer la machine et charger les fichiers nécessaires en mémoire. Il s’exécute en mode "superviseur" et a donc tous les droits.
Lecture des fichiers
Maintenant que le système d’amorçage a pris le contrôle de la machine, il doit mettre en place en mémoire les différentes structures. Pour cela, il faut charger à partir du disque l’image du noyau. La lecture de cette image à partir de la disquette se faisant de la même manière que l’a fait le niveau 1 pour lire le niveau 2, nous allons plutôt nous attarder sur la lecture d’un fichier à partir d’un disque dur.
La problématique ici est de retrouver les données d’un fichier. En effet, celles-ci ont été stockées à divers endroits du disque par le système de fichier en fonction des blocs de données disponibles dans la partition. Il existe deux solutions à ce problème : la première est d’ajouter un pilote permettant de lire le fichier en décodant la structure du système de fichiers, la seconde consiste à encoder la liste des blocs à lire dans le système d’amorçage. L’inconvénient de la première solution est qu’il existe différents types de système de fichiers et donc différents algorithmes pour accéder aux données du fichier. Des systèmes d’amorçage comme GRUB ont pris le parti d’intégrer cette méthode. EMILE utilise la seconde méthode, plus simple et déjà éprouvée par LILO, mais qui a l’inconvénient de nécessiter la mise à jour du niveau 2 à chaque fois que l’image du noyau est modifiée (lors d’une mise à jour du système, par exemple). Cette méthode nécessite la présence d’un outil tournant sous le système cible. Cet outil a pour tâche d’établir la liste des blocs utilisés par le fichier et de l’encoder dans le niveau 2. La difficulté ici est d’établir cette liste sans avoir à décoder directement le système de fichier ; si c’était le cas, nous nous retrouverions dans le premier cas de figure. Heureusement, Linux fournit un ioctl() permettant de récupérer le numéro de bloc physique dans la partition à partir du numéro de bloc logique dans le fichier. Le principe est le suivant :
142 /* get first physical block */
143
144 last_physical = 0;
145 ret = ioctl(fd, FIBMAP, &last_physical);
146 if (ret != 0) {
147 perror(“ioctl(FIBMAP)”);
148 return -1;
149 }
150
151 zone = last_physical;
152 aggregate = 1;
153
154 /* seek all physical blocks */
155
156 current = 0;
157 for (logical = 1;
158 logical < (st.st_size + block_size - 1) / block_size;
159 logical++) {
160 physical = logical;
161 ret = ioctl(fd, FIBMAP, &physical);
162 if (ret != 0)
163 break;
164 if (physical == last_physical + 1) {
165 aggregate++;
166 } else {
167 ADD_BLOCK(first_block + zone * sectors_per_block,
168 aggregate * sectors_per_block);
169 zone = physical;
170 aggregate = 1;
171 }
172 last_physical = physical;
173 }
174
175 ADD_BLOCK(first_block + zone * sectors_per_block,
176 aggregate * sectors_per_block);
On appelle donc ioctl() avec la requête FIBMAP qui prend en entrée le descripteur du fichier à analyser et un pointeur sur une variable contenant le numéro de bloc logique, en sortie ce pointeur pointera sur le numéro de bloc physique dans la partition. La particularité de l’algorithme précédent est qu’il essaye d’encoder des séquences de blocs consécutifs. De plus, comme le système d’amorçage n’a pas la connaissance des partitions, l’outil doit ajouter au numéro de bloc obtenu un incrément correspondant au début de la partition (first_block) pour avoir un numéro de bloc relatif au début du disque. Le début de partition est fourni par une autre requête ioctl() avec le paramètre HDIO_GETGEO :
70 ret = ioctl(fd, HDIO_GETGEO, &geom);
71 if (ret == -1)
72 {
73 fprintf(stderr, «%s: ioctl(HDIO_GETGEO) fails: %s»,
74 dev_name, strerror(errno));
75 return -1;
76 }
...
88 *first_block = geom.start;
La liste des blocs est ensuite écrite à un endroit défini dans le niveau 2 (et est donc chargée en même temps que le niveau 2 par le niveau 1). Lorsque le niveau 2 va vouloir trouver le bloc physique du disque associé à un numéro de bloc logique du noyau (ou du RAMDISK). Il va utiliser cette liste avec l’algorithme suivant :
15 static unsigned long seek_block(container_FILE *file)
16 {
17 struct emile_container *container = file->container;
18 ssize_t current;
19 int i;
20 unsigned long offset = file->offset;
21 int block_size = file->device->get_blocksize(file->device->data);
22
23 for (i = 0, current = 0;
24 container->blocks[i].offset != 0; i++)
25 {
26 int extent_size = block_size *
27 container->blocks[i].count;
28
29 if ( (current <= offset) && (offset < current + extent_size) )
30 {
31 return container->blocks[i].offset +
32 (offset - current) / block_size;
33 }
34
35 current += extent_size;
36 }
37
38 return 0;
39 }
file->offset est la position logique dans le fichier. En retour, la fonction est égale au numéro de bloc physique correspondant. La liste des blocs précédemment calculée est stockée dans le tableau contrainer->blocs.
Le système d’amorçage procédera de la même manière s’il doit charger un RAMDISK. La seule différence notable entre le noyau et le RAMDISK est que le noyau est décompressé par le système d’amorçage alors que le RAMDISK l’est par le noyau.
Passage de paramètres
Il y a au moins deux paramètres standards à passer au noyau : la ligne de commande, et, quelquefois, l’adresse du RAMDISK en mémoire. Alors que GRUB utilise une structure de données assez primitive au format multiboot [8], les noyaux pour architecture "m68k" utilisent des tags plus évolués. Pour ceux qui ont connu l’Amiga (une machine de l’architecture m68k) dans les années quatre-vingt, la notion de "tags" leur rappellera étrangement les chunks du format IFF [9]. Le système d’amorçage passe au noyau un pointeur sur le boot record contenant une suite de tags. Chaque tag est identifié par un type. Il contient sa longueur qui permet de trouver le tag suivant et d’ignorer le courant si le noyau ne le connaît pas. Un boot record peut donc contenir la ligne de commande (tag de type V2_BI_COMMAND_LINE) et un autre l’adresse du RAMDISK (tag de type V2_BI_RAMDISK). Vous me direz que c’est bien compliqué pour passer deux informations... effectivement, mais j’ai passé sous silence une phase qui se déroule en début de niveau 2 : l’identification des ressources de la machine. Une des tâches du système d’amorçage est d’identifier le type de la machine (architecture, type de processeur, présence de coprocesseurs) et les ressources présentes (port série, adresse de la mémoire vidéo, organisation de la mémoire vive...). Cette tâche est aisée pour le système d’amorçage, puisqu’il peut encore consulter les routines de la ROM, ce que ne peut pas faire le noyau, une fois démarré. L’emploi des tags permet de passer toutes ces informations d’une manière générique au noyau. Une fois la structure d’information de démarrage initialisée, elle est placée immédiatement après le noyau, ce qui lui permettra de la retrouver.
Démarrage
Tout est en place, il ne reste plus qu’à passer la main au noyau en plaçant le pointeur d’instruction du processeur sur son point d’entrée. En fait, ce n’est pas aussi simple : "avant de sortir, éteignez la lumière !", avant de passer la main au noyau, il faut bien veiller à mettre un terme à toutes les activités du système natif, et, plus particulièrement, à ses gestionnaires d’interruptions. Une autre des difficultés spécifiques à cette architecture est la présence et l’utilisation de la MMU du processeur : le système d’amorçage s’exécute avec la mémoire virtuelle activée. Il y a donc translation entre les adresses connues par le système d’amorçage et les adresses réelles de la mémoire physique. Et malheureusement, le noyau doit s’exécuter avec la mémoire virtuelle désactivée. Il faut donc introduire une phase de bootstrap qui va désactiver la mémoire virtuelle et recopier le noyau à l’adresse physique adéquate (que l’on connaît en décodant le format ELF du noyau), puis faire le saut dans le noyau.
Vous êtes maintenant dans le monde du noyau... et c’est une autre histoire !
Références
[1] http://www.gnu.org/software/grub/
[2] http://lilo.go.dyndns.org/
[3] http://elilo.sourceforge.net/
[4] http://yaboot.ozlabs.org/
[5] http://penguinppc.org/bootloaders/bootx/
[6] http://emile.sourceforge.net/
[7] http://www.debian.org/ports/m68k/
[8] http://www.uruk.org/orig-grub/boot-proposal.html
[9] http://en.wikipedia.org/wiki/Interchange_File_Format


