Introduction

Nous allons ici voir comment en arriver à un noyau présent dans la mémoire de la machine, prêt à être exécuté. La procédure peut varier d’un système à l’autre : le code peut être sur un support amovible, dans une zone mémoire non volatile, sur une machine distante, …

Dans les cas les plus simples, l’auteur du noyau n’a (presque) pas à s’en préoccuper, dans les cas les plus tordus, il ou elle va devoir (presque) tout faire. Nous décrirons ici deux variantes :

  • Nous commencerons par nous fonder sur une situation “à l’ancienne” avec un PC doté d’un lecteur de disquettes. Nous utiliserons alors le bios du PC pour charger le noyau en mémoire, lui passer quelques informations et le faire démarrer.

    Dans cette situation, nous devrons donc (presque) tout faire puisqu’il va falloir aller charger le noyau depuis la disquette vers la mémoire. Heureusement le bios nous fourni des fonctions pour cela.

  • Nous observerons ensuite des outils un peu plus modernes permettant de démarrer de façon un peu plus simple et plus indépendante du matériel sous-jacent.

    Dans cette situation, nous utiliserons par exemple grub qui fera le travail de charger le noyau depuis le support (disque dur, …) vers la mémoire. Nous aurons juste quelques informations à lui fournir.

Démarage à partir d’une disquette

Nous allons donc devoir mettre en œuvre le boot du noyau dans cette configuration. Pour cela, nous allons construire un fichier qui sera placé sur une disquette à partir de laquelle notre système pourra démarrer. Notons que nous utiliserons ici un émulateur comme Qemu ou bochs auquel nous donnerons le chemin vers ce fichier et à qui nous demanderons de démarrer depuis cette disquette. Mais nous pouvons également copier ce fichier sur une disquette pour démarrer une vraie machine.

La première étape d’un tel démarrage est assuré par le système lui-même : il s’agit de récupérer notre code sur la disquette, le mettre en mémoire puis lui donner la main. C’est le BIOS qui va faire cela. Malheureusement, son action est assez limitée, et il ne va charger que 512 octets en mémoire. De ce fait, notre code doit, d’une part être d’une taille inférieure à 512 octets et d’autre part charger en mémoire le noyau lui-même.

Nous allons donc construire un fichier composé essentiellement de trois parties :

  • un secteur de boot (ou bootsector) sera un code de moins de 512 octets qui va s’occuper de charger le noyau ;

  • un code d’initiaisation qui va préparer le terrain pour le noyau avant de démarrer celui-ci ;

  • le noyau lui-même.

Observons la structure de ce fichier pour ManuX puis regardons comment en construire chaque partie.

Construction du fichier de démarrage

Afin de permettre un démarrage “à l’ancienne” par un boot sur disquette du pc, nous allons donc construire une image de disquette dont la structure est décrite par la figure suivante.

Le code d’initialiation est ici nommé initmanux. Je fais figurer à la fin du noyau un fichier appelé ramdisk qui peut être présent et comporté un premier système de fichiers élémentaire.

image

Ce que j’appelle ici le boot du système se passera donc en deux temps. D’abord le secteur de boot est chargé en mémoire par le bios qui lui donne la main.

Le secteur de boot s’occupe alors de charger en mémoire le code d’initialisation ainsi que le code du noyau. Il donne alors à son tour la main à l’initialisation. Ce second élément réalise quelques opérations puis démarre enfin le noyau.

Si le secteur de boot et le code d’initialisation sont deux éléments distincts, c’est pour deux raisons principales

  • d’une part le secteur de boot ne dispose que de 512 octets, si bien qu’il doit être réduit au minimum ;

  • chacun de ces deux éléments à un rôle bien spécifique (charger le code en mémoire et initialiser le système).

Avant de regarder en détail comment ils sont mis en œuvre, regardons les interactions entre les trois éléments.

Liens entre les trois éléments

Les trois parties de codes évoquées ici (le secteur de boot, le code d’initialisation et le noyau) ne sont pas liées entre elles (par une édition de liens). Les appels de fonction, partages de variables, … sont alors à éviter car peu pratiques !

Concrètement, il y a surtout deux “appels de fonction” à réaliser :

  • le secteur de boot doit lancer le code d’initialisation ;

  • le code d’initialisation doit démarrer le noyau.

Pour cela, on va utiliser le fait que tous les éléments sont placés à des adresses connues et faire un saut à ces adresses.

Le secteur de boot

Lorsqu’on allume un pc normalement configuré avec une disquette dans le premier lecteur, le bios charge le premier secteur de la diquette en mémoire et l’exécute. En fait, il ne fait cela que si les deux derniers octets de ce secteur contiennent la valeur magique 0xAA55. Pour être plus précis, c’est à l’adresse 0x7C00 que ce secteur est chargé.

Un secteur étant composé traditionnellement de 512 octets, on ne peut pas faire des folies avec le secteur de boot ! On va donc se contenter de charger en mémoire d’autres secteurs du disque. Pour cela, on peut utiliser le bios et plus particulièrement l’interruption 13h qu’il nous fournit pour manipuler les lecteurs.

Le secteur de boot de ManuX est codé dans le fichier boot/bootsector.nasm et il est donc écrit en NASM . Il est très simple et se contente d’utiliser le bios pour charger le noyau en mémoire puis de faire un saut au début de la mémoire ainsi initialisée.

Il n’est pas très robuste, en particulier sur la taille attendue du noyau et sur l’adresse à laquelle le placer !

Concrètement, ce chargement se fait en deux phases :

  • Chargement du code d’initialisation : c’est le code qui va avoir pour rôle d’initialiser le système. Ce code est écrit en assembleur dans le fichier boot/init-manux.nasm.

  • Chargement du code du noyau : c’est tout le code de ManuX qui est ici chargé en mémoire. Il sera exécuté par le code d’initialisation.

Après ces deux chargements, le code du secteur de boot exécute le code d’initialisation.

Je ne décris pas plus ce code qui ne présente pas un intérêt profond, les plus curieuses et curieux n’ont qu’à lire le code ! Vous pourrez le trouver dans le fichier boot/bootsector.nasm (dox) .

État de la mémoire après le chargement

L’état de la mémoire à la fin du boot est décrite par la figure suivante. Les nombres sous les différentes zones expriment leur taille en nobre de pages (la taille d’une page est de 4 Ko). Les macros sont définies dans le fichier de configuration de ManuX : include/manux/config.h (dox) .

image

Une fois qu’il a terminé son travail, le code du secteur de boot donne la main au code d’initialisation.

L’initialisation

Le "programme" d’initialisation est traditionnellement consacré à la détection et à l’initialisation (original, vu son nom !) du matériel. Sur un PC de base, on peut là encore se faire aider du bios .

En ce qui concerne ManuX , cette phase d’initialisation va profiter du fait qu’elle n’a pas les mêmes limites de taille que le secteur de boot (un seul secteur comme le nom au singulier le suggère). On va donc pouvoir y réaliser certaines actions un peu plus confortablement, comme :

  • vérifier la mémoire disponible (cette information sera passée au noyau) ;

  • passer en mode protégé ;

  • charger en mémoire un ramdisk qui nous permettra de jouer avec des systèmes de fichiers sans avoir besoin d’implanter la gestion de matériel.

Voyons rapidement quelques points.

Passage en mode protégé

Les processeurs Intel démarre dans un mode de fonctionnement appelé le mode réel (real mode). Dans ce mode, la mémoire est accédé de façon linéaire. En mode protégé, l’utilisation de la mémoire est un peu plus complexe (mais également plus puissante). Je ne vais pas rentrer dans les détails ici, vous trouverez énormément de documentation sur le sujet, par exemple les manuels Intel !

Le basculement du mode réel vers le mode protégé n’est pas immédiat, il nécessite un peu de travail.

Passage des informations au noyau

La plupart des informations recueillies par l’initialisation peuvent se révéler particulièrement intéressantes pour le noyau. Il est donc important de se définir un mécanisme permettant de faire passer ces informations depuis la phase d’initialisation jusqu’au noyau.

Nous allons utilise deux registres pour faire passer de l’information au noyau :

  • Le registre eax contiendra un “nombre magique”, c’est-à-dire une valeur prédéfinie permettant au noyau d’identifier avec une bonne confiance le bootloader utilisé.

  • Le registre ebx contiendra l’adresse d’une structure dans laquelle seront enregistrées les différentes informations vraiment utiles pour le noyau.

(Notons que ce choix (tout comme la structure qui va suivre) est largement inspiré du fonctionnement de Multiboot). Nous aurons donc par exemple la définition suivante dans le fichier

include/manux/multiboot.h (dox)

typedef struct _InfoSysteme {
   uint32_t flags;           // Pour compatibilite avec multiboot
   uint32_t memoireDeBase;   // En Ko
   uint32_t memoireEtendue;  // En Ko
   uint32_t peripheriqueDemarrage ;
   char *   ligneCommande;
} InfoSysteme;

Naturellement, pour que tout cela fonctionne, il est nécessaire, à la fin de la phase d’initialisation, d’initialiser correctement ces registres. Voici donc par exemple à quoi peuvent ressembler les dernières lignes de la phase d’initialisation :

        ; On passe au noyau quelques infos
        ;---------------------------------
        mov eax, MANUX_INIT_MAGIC
        mov ebx, InfoSysteme

        ; Et c'est parti, on saute sur le noyau !
        ;----------------------------------------
        jmp MANUX_KERNEL_START_ADDRESS

Notons que, bien sûr, aucun contôle de type ne peut être fait entre les deux phases et qu’il est donc important que le programmeur définisse de façon cohérente la structure C et la zone mémoire gérée en assembleur.

Détection de la mémoire

En ce qui concerne cette détection, nous allons encore une fois utiliser un service du bios , au travers de l’interruption 0x12 qui va nous fournir ces informations.

Vous trouverez le code correspondant dans le fichier

boot/init-manux.nasm (dox) .

Utilisation de Multiboot

Nous allons ici utiliser la spécification Multiboot définie par la Free Software Foundation qui va nous permettre d’utiliser les services d’outils tels que GRUB afin de grandement simplifier le chargement du noyau de ManuX.

Cela nous permettra également d’utiliser des outils un peu plus modernes qu’un lecteur de disquettes et, par voie de conséquence, de faire fonctionner ManuX sur autre chose que des machines virtuelles ou hors d’age !

Structure du fichier chargé par Multiboot

Nous devons construire un fichier qui sera fourni à Multiboot. Nous allons utiliser ici la version 1 de Multiboot (et non Multiboot2). On trouvera la spécification par exemple https://www.gnu.org/software/grub/manual/multiboot/multiboot.html.

Un tel fichier contient essentiellement notre noyau, mais doit avoir une structure particulière afin d’être pris en charge :

  • Il doit être composé d’un entête suivi d’un programme exécutable.

  • L’entête, qui permet à l’outil de boot (comme grub) d’identifier le fichier, a une structure bien définie, que nous allons décrire ci dessous. Il doit être dans les 8192 premiers octets du fichier.

  • Le programme exécutable peut être à n’importe quel format. Il doit évidemment être “auto-suffisant” (pas de librairies dynamiques !). Il doit pouvoir être positionné à n’importe quelle adresse physique en mémoire.

Le programme exécutable, c’est évidemment le noyau. L’entête est un des éléments permettant l’interface entre le bootloader et le noyau.

En effet, le bootloader et le noyau doivent pouvoir se “reconnaître” (c’est-à-dire savoir chacun que l’autre implante la même version de Multiboot) et, le cas échéant, ils doivent échanger des informations.

C’est d’abord le bootloader qui est exécuté, il doit donc se contenter d’informations statiques pour reconnaître le noyau et lire les informations qui le caractérisent. C’est à cela que sert l’entête : à décrire le noyau. Nous allons décrire la structure de l’entête dans la sous-section suivante.

Lorsque le noyau s’exécute ensuite, le bootloader a fini son travail, et il aura pu laisser en mémoire des informations construites dynamiquement pour que le noyau le reconnaisse et profite de ces informations. Nous décrirons donc ensuite ce passage d’information dans la sous-section 3.2.1

Structure de l’entête

Le système Multiboot nécessite donc un entête devant le noyau avec des caractéristiques bien définies. Cette interface est relativement légère et constituée d’une structure de quelques dizaines d’octets. Elle est décrite par exemple sur la page https://www.gnu.org/software/grub/manual/multiboot/multiboot.html#Architecture

Le premier champ (intitulé magic) doit avoir une valeur bien définie (0x1BADB002), ce qui permet au bootloader de savoir qu’il a bien affaire à un noyau qui implante le protocole Multiboot.

Le deuxième champ (intitulé flags) permet de spécifier quelques contraintes dans la gestion du noyau (quant à la gestion de la mémoire ou de la vidéo).

Le troisième champ (checksum) est une somme de contrôle sur les deux champs précédents.

Les autre champs doivent être présents, mais leur utilité est conditionnée par le champ flags, nous ne nous y intéresserons pas ici.

Passage d’informations depuis le bootloader

À son tour le bootloader doit s’identifier auprès du noyau (afin que ce dernier sache comment il est arrivé là et quelles informations lui sont donc disponibles) et lui fournir des informations décrivant le système.

En ce qui concerne l’identification, elle va simplement consister en l’initialisation du registre eax avec une valeur bien définie, en l’occurence 0x2BADB002.

Les paramètres passés par le bootloader seront quand à eux placés dans une zone mémoire dont l’adresse est fournie au travers du registre ebx.

Le format de cette zone mémoire est décrit dans la spécification de Multiboot, je ne vais donc pas la reprendre ici.

L’implantation dans ManuX

ManuX fait une utilisation minimaliste de Multiboot : un fichier d’entête va être ajouté au noyau de sorte à ce qu’il puisse être pris en charge par GRUB . Quelques informations seront ensuite utilisées par le noyau : la taille de la mémoire disponible, et les paramètres de la ligne de commande.

Organisation de la mémoire au boot

Lorsque GRUB a chargé le noyau en mémoire, il lui passe la main et celui-ci doit donc s’occuper rapidement de la gestion de la mémoire. La structure est alors celle décrite par la figure suivante.

image

La première page est utilisée pour les handlers d’interruptions.

J’ai fait le choix de placer derrière la idt puis la gdt. Leurs adresses et leurs tailles sont définies en ce sens dans

include/config/plan-memoire-pc.h (dox) .

Lors du démarrage d’un pc, le secteur de boot est placé à l’adresse 0x7C00 et GRUB est chargé juste derrière à l’adresse 0x8000. On ne peut évidemment pas demander à GRUB de charger le noyau à une adresse qu’il occupe, je fais donc le choix de lui demander de charger ManuX à partir de la page 16, donc à l’adresse 0x10000. Cette adresse également est définie dans le fichier

include/config/plan-memoire-pc.h (dox) par la macro MANUX_KERNEL_START_ADDRESS. C’est le linker qui utilise cette macro pour positionner le noyau.

Vient ensuite la pile du noyau qui est positionnée juste après le noyau (mais à une frontière de page). Cette place est définie dans le fichier de configuration du linker : noyau/manux.ld (dox) .

Je place ensuite derrière cet espace réservé à la pile une zone mémoire qui va me servir à la gestion des pages mémoire.

À partir l’adresse 0x80000, c’est le bios . Dans un soucis de tranquillité, je vais considérer que les 512 Ko qui commencent à cette adresse sont inutilisables car réservés au bios . Il y a moyen d’être un peu plus subtile, et d’en récupérer tout ou partie en fonction de la situation, mais on ne va pas s’y embêter pour le moment.

Tout ce qui est au delà est alors utilisable.

Pour illustrer cela, la figure ci-dessous montre l’exécution d’un noyau qui alloue quelques pages après son démarrage.

image

On peut constater que les quelques premières pages allouées sont situées entre la gdt et le noyau et que les suivantes sont allouées après la gestion mémoire.

Structure de l’entête

En ce qui concerne ManuX, l’entête est implanté dans le fichier boot/mb-manux.nasm (dox) .

Comme expliqué précédemment, ce fichier contient essentiellement quelques éléments permettant à Multiboot de comprendre que ManuX est compatible avec Multiboot.

Dans ManuX, nous allons simplement demander au bootloader de nous informer sur la mémoire disponible. Le champ flags sera donc positionné avec un bit à un.

Le début de l’entête est donc simplement celui-ci :

;-------------------------------------------------------------------------------
; Quelques macros specifiques a multiboot
;-------------------------------------------------------------------------------
MULTIBOOT_PAGE_ALIGN   equ 0x00000001 ; Alignement des modules sur des pages
MULTIBOOT_MEMORY_INFO  equ 0x00000002 ; Pour que multiboot nous donne le plan

;-------------------------------------------------------------------------------
; Definition de notre entete
;-------------------------------------------------------------------------------
FLAGS    equ  MULTIBOOT_MEMORY_INFO 
MAGIC    equ  0x1BADB002        
CHECKSUM equ -(MAGIC + FLAGS)
header_addr   dd 0
load_addr     dd 0
load_end_addr dd 0
bss_end_addr  dd 0
entry_addr    dd 0
mode_type     dd 0
width         dd 0
height        dd 0
depth         dd 0

;-------------------------------------------------------------------------------
; L'entete a proprement parler
;-------------------------------------------------------------------------------
section .multiboot
align 4
    dd MAGIC
    dd FLAGS
    dd CHECKSUM

Cependant, pour simplifier un peu le passage de paramètre depuis le bootloader vers le noyau, l’entête contient également un peu de code.

Ce code va copier la signature du bootloader (qui se trouve, d’après le protocole Multiboot dans le registre eax) ainsi que le pointeur sur la structure de données fourni par le bootloader (pointeur passé dans le registre ebx) vers deux variables déclarées dans le noyau. Elles pourront ainsi être manipulées plus confortablement par la suite (car le compilateur risque de très rapidement utiliser ces registres généraux).

Une fois cette copie faite, ce code va pouvoir démarrer véritablement le noyau par un appel à la fonction startManuX() qui sera implantée dans le fichier

noyau/main.c (dox) .

La fin du fichier d’entête est donc finalement la suivante :

;-------------------------------------------------------------------------------
; Le code de demarrage. On se contente d'initialiser le pointeur de pile, car
; ce n'est pas prevu par multiboot. Apparemment GRUB le fait, mais pas le
; loader de qemu par exemple.
;-------------------------------------------------------------------------------
global _startManuX
extern _adresseLimitePileManuX
extern startManuX
extern signatureBootloader
extern _infoSysteme

_startManuX : 
    mov esp, _adresseLimitePileManuX ; Initialisation du pointeur de pile
        mov [signatureBootloader], eax   ; On fournit la signature du bootloader
        mov [_infoSysteme], ebx          ; Le pointeur sur les informations

        call startManuX                  ; C'est parti

    cli
.FinDesTemps:   hlt
    jmp .FinDesTemps

Récupération des informations fournies par le bootloader

Grâce à l’entête ainsi construit, ManuX va pouvoir avoir accès aux informations fournies par le bootloader au travers des trois variables signatureBootloader, _infoSysteme et cmdLine. Une difficulté provient du fait que la majorité de ces informations sont dans une zone mémoire qui risque d’être écrasée lors de l’initialisation du noyau.

Pour remédier à ce problème, quelques outils sont définis dans

manux/bootloader.h (dox) et implantés dans

noyau/bootloader.c (dox) .

La structure InfoSysteme décrit le début des données fournies par le bootloader. Elle ne couvre que les éléments qui intéressent ManuX, soit essentiellement la taille mémoire.

La variable cmdLine contient quant à elle la “ligne de commande”, c’est-à-dire les paramètres fournis au noyau (par exemple via GRUB ).

La fonction bootloaderInitialiser() a donc pour rôle d’initialiser ces trois variables de ManuX en fonction du contenu de ces paramètres fournis par le bootloader dans des zones mémoires qui peuvent alors être réutilisées. Elle doit être invoquée très tôt lors du démarrage de ManuX, a minima avant l’initialisation de la mémoire.

La fonction bootloaderLireLigneCmd() peut alors être invoquée à tout moment plus tard, afin de prendre en compte le contenu de la ligne de commande. On atendra donc avantageusement qu’un système de gestion de mémoire soit disponible !

Un exemple est donné par l’image bootparam qui utilise en particulier le fichier noyau/main-bootparam/c.. (dox)

Réalisation d’une image iso

La cible iso du Makefile principal permet de générer un fichier iso qui peut être utilisé pour démarrer ManuX sur une machine physique ou virtuelle.

La cible multiso du Makefile principal permet de générer une image iso intégrant un noyau pour chaque fichier de configuration présent dans le répertoire multiconf.

Démarrage du noyau

Démarrage du noyau (noyau/main.c)

La dernière action du programme d’initalisation est donc l’exécution de la fonction _start(). Comme je l’ai dit précédemment, il ne s’agit pas d’un appel de foncion C classique, puisque les deux morceaux de code ne sont pas liés entre eux, mais d’un saut à l’adresse de la fonction (le tout après avoir empilé les paramètres).

Ceci termine donc la description de toute la quincailleire qui nous permet d’arriver au début de l’exécution du code du noyau. Nous allons commencer la description de ce dernier par la mise en place des outils de base dont il aura besoin.

Obtention des paramètres passés par le bootloader

Grâce à Grub, qui implante Multiboot, nous pouvons passer des paramètres au noyau. La figure suivante montre un exemple de ligne de commande de Grub sur laquelle un paramètre a été ajouté.

image

La figure suivante montre alors ManuX qui a démarré et qui a récupéré cette information sous forme de chaîne de caractères.

image