Noyaux agnostiques du bootloader : concevoir un trampoline d'entrée x86-64 universel
Multiboot2 vous dépose en mode protégé 32 bits ; Limine vous fournit un environnement 64 bits. Un unique trampoline d'entrée qui gère les deux sans forker le noyau.
En écrivant mon système d’exploitation, un problème que j’ai rencontré était de le faire démarrer depuis n’importe quoi, que ce soit Limine, Grub2, uBoot, ou le QEMU Generic Loader
Les CPU x86 fonctionnent dans plusieurs modes d’exécution distincts qui déterminent la largeur de l’espace d’adressage, les instructions disponibles, les niveaux de privilège et le modèle mémoire.
Le Long Mode, alias le mode 64 bits du processeur, est notre objectif. Tous les OS x86-64 (Windows, Linux, etc.) fonctionnent en long mode.
Sur x86, le processeur démarre en Real Mode. C’est un mode d’exécution 16 bits relativement simple. L’accès mémoire se fait via la segmentation.
Voir nos instructions s’exécuter est agréable, mais personnellement, j’aime utiliser chaque Gigabit de RAM que j’ai installé, surtout avec le RAMmageddon.
Depuis le Real Mode, on peut basculer vers le Protected Mode. Qui est un mode 32 bits disponible depuis le 80386. Il est beaucoup plus riche en fonctionnalités que le Real Mode.
Enfin, une fois en Protected Mode, on veut basculer vers le Long Mode (ou mode IA-32e, pour Intel).
Le Long Mode introduit le support 64 bits, étend les registres et en ajoute de nouveaux (généraux et multimédias).
Pour référence, voir 12.9 « Mode Switching » de l’Intel SDM, vol 3. D’autres modes sont disponibles.
(Extrait de l’Intel Software Developer’s Manual, édition de mars 2026)
Identifier dans quel mode nous sommes actuellement
#
Bien que savoir comment changer de mode soit la partie la plus utile, il faut d’abord connaître l’état actuel du CPU.
Il ne semble pas y avoir de « god flag » pour savoir exactement dans quel état on se trouve.
Cependant, on peut vérifier quelques valeurs dans les registres de configuration.
Le flag PE dans CR0 vous indique si vous êtes en Protected Mode
IA32_EFER.LME vous permet de savoir si vous êtes en mode IA-32e 64 bits (Long Mode)
Mon OS est (pour l’instant) formaté comme un unique binaire ELF.
Le bootloader est censé mapper les sections en mémoire, puis sauter au point d’entrée.
Cependant, comme Limine et GRUB2 attendent respectivement du code 32 et 64 bits, nous devons avoir deux points d’entrée.
Note : MB2 et Limine sont tous deux des bootloaders configurables, ce qui signifie que vous pouvez fournir un header qui configure le comportement du BL
Dans multiboot2, le chargement de l’image se fait avec des adresses physiques.
Plus précisément, comme nous créons un binaire ELF, le p_paddr indique où, physiquement, on veut que notre section soit chargée.
Multiboot2 suivra aveuglément vos champs ELF, et écrasera avec plaisir n’importe quel bloc de mémoire réservée que vous lui donnez.
Nous aurons au minimum deux sections :
la section multiboot_header qui inclut :
une chaîne magique
des informations d’architecture
la longueur du header
quelques tags qui personnalisent le comportement du bootloader
Le code
Le multiboot header est une construction plutôt simple.
text
Offset Type Field Name Note
0 u32 magic required
4 u32 architecture required
8 u32 header_length required
12 u32 checksum required
16-XX tags required
Le magic est toujours : MULTIBOOT2_HEADER_MAGIC ou 0xe85250d6
architecture dépend bien sûr de votre cas d’usage, je vais utiliser MULTIBOOT_ARCHITECTURE_I386 (alias 0)
header_length est la longueur totale du multiboot header, tags inclus. Je vous conseille de la calculer directement dans le linker script
checksum est une valeur non signée de 32 bits. D’après la doc : ajoutée aux autres champs magiques (c’est-à-dire magic, architecture et header_length), la somme non signée sur 32 bits doit être nulle :
header_addr : contient l’adresse correspondant au début du header Multiboot2 — l’emplacement en mémoire physique où la valeur magique est censée être chargée. Ce champ sert à synchroniser le mapping entre les offsets de l’image OS et les adresses physiques.
load_addr : contient l’adresse physique du début du segment text. L’offset dans le fichier image de l’OS à partir duquel commencer le chargement est défini par l’offset auquel le header a été trouvé, moins (header_addr - load_addr). load_addr doit être inférieur ou égal à header_addr.
La valeur spéciale -1 signifie que le fichier doit être chargé depuis son début.
load_end_addr : contient l’adresse physique de la fin du segment data. (load_end_addr - load_addr) indique la quantité de données à charger. Cela implique que les segments text et data doivent être consécutifs dans l’image OS ; c’est vrai pour les formats exécutables a.out existants. Si ce champ est nul, le bootloader suppose que les segments text et data occupent la totalité du fichier image OS.
bss_end_addr : contient l’adresse physique de la fin du segment bss. Le bootloader initialise cette zone à zéro, et réserve la mémoire qu’elle occupe pour éviter d’y placer des modules de boot et d’autres données relatives au système d’exploitation dans cette zone. Si ce champ est nul, le bootloader suppose qu’aucun segment bss n’est présent.
En clair : vous choisissez où vous voulez que chaque section soit chargée, en mémoire physique.
Après avoir lu le code de GRUB2, il s’avère que, pour ELF, cela dépend de votre paddr. Donc, le tag address semble un peu inutile. Et après avoir testé, il l’est.
Il existe quelques autres tags intéressants, notamment le tag framebuffer qui fait en sorte que le bootloader vous donne accès à un framebuffer (via un pointeur).
En lisant la spec, il y a quelques différences en ce qui concerne Limine.
The protocol mandates executables to load themselves at or above 0xffffffff80000000. Lower half executables are not supported. For relocatable executables asking to be loaded at address 0, a minimum slide of 0xffffffff80000000 is applied.
De plus, Limine chargera l’exécutable à la VMA demandée, si elle est au-dessus de 0xffffffff80000000
Le placement en mémoire physique n’est pas garanti (seule la contiguïté est garantie).
Maintenant qu’on connaît le terrain, réfléchissons-y différemment.
Limine est en fait le cas facile. Il arrive déjà en Long Mode, paging activé, noyau déjà mappé en higher half.
c
__attribute__((used,section(".limine_requests")))staticvolatilestructlimine_entry_point_requestentry_point_request={.id=LIMINE_ENTRY_POINT_REQUEST_ID,.revision=0,.response=NULL,.entry=limine_entry,// a plain 64-bit C function
};
GRUB2 et le chemin générique ELF/QEMU, en revanche, nous déposent en mode protégé 32 bits. Ils entrent par une porte différente.
Voici donc l’idée : deux portes 32 bits, un chemin partagé.
Une porte pour Multiboot2 (_mb2_entry32)
Une porte pour « quelqu’un a chargé mon ELF et a sauté à e_entry » (_elf_entry32)
Les deux portes mènent au même couloir : un petit morceau d’assembleur qui fait passer le CPU du 32 bits au Long Mode, puis le transmet au noyau. Un binaire ELF, trois symboles d’entrée, mais un seul d’entre eux fait vraiment le travail.
Il y a un détail à régler en premier. Une fois en 64 bits, comment sait-on qui nous a envoyés ? On va laisser un breadcrumb : chaque porte 32 bits place une valeur dans EDI (qui devient RDI en 64 bits). Le magic de Multiboot2 pour un boot MB2, 0 pour « aucune idée, boot générique ». On le lit de l’autre côté pour dispatcher.
Avant tout assembleur, il faut parler du linker script, car c’est lui qui rend tout cela possible.
Notre noyau vit dans le higher half : chaque symbole est lié à une adresse virtuelle >= 0xffffffff80000000. C’est là qu’il s’exécutera une fois le paging activé. Mais le trampoline 32 bits s’exécute avant l’activation du paging, il n’y a pas encore de higher half, le CPU ne voit que des adresses physiques.
La solution est de lier haut mais de charger bas. La VMA (où le code croit vivre) est dans le higher half, la LMA (où le bootloader dépose réellement les octets) est en bas, à 0x200000.
AT(...) est la clé : il définit l’adresse de chargement indépendamment de l’adresse virtuelle.
Maintenant, le code 32 bits doit référencer les choses par leur adresse physique (la pile, les tables de pages, le GDT). Les coder en dur serait moche et fragile, alors on laisse le linker les calculer pour nous :
Chaque phys_X n’est que X - KERNEL_VIRT_BASE. C’est exactement l’astuce __pa() / phys_startup_32 que j’ai mentionnée au début : c’est aussi comme ça que fait Linux. Le code 32 bits utilise phys_* partout, le code 64 bits utilise les symboles hauts normaux.
bits32; GRUB jumps here via the MB2 entry address tag.
global_mb2_entry32_mb2_entry32:climovedi,eax; EDI = magic
movesi,ebx; ESI = info ptr
jmptrampoline32; Anything that loads the ELF and jumps to e_entry lands here.
global_elf_entry32_elf_entry32:clixoredi,edi; EDI = 0 => "no protocol"
xoresi,esijmptrampoline32
_elf_entry32 est aussi la cible d’une note ELF PVH, ce qui permet au même binaire de démarrer en tant qu’invité Xen PVH (et QEMU, si jamais vous retirez le header MB2).
Vous vous souvenez du tag entry address vu plus tôt ? Il force GRUB à entrer par _mb2_entry32 au lieu de e_entry de l’ELF, ce qui permet de garder MB2 et le boot générique sur des portes séparées.
Ensuite, on construit un ensemble minimal de tables de pages. On n’a besoin de rien de sophistiqué, juste assez pour continuer à exécuter après avoir activé le paging. Donc : on identity-map le premier 1 GiB (pour que le trampoline continue de s’exécuter à son adresse physique) et on mappe le higher half (pour pouvoir y sauter ensuite).
PML4[511] et PDPT_high[510] pointent tous les deux vers le même PD que le mapping bas. Cet index 510 est le slot -2 GiB, qui est exactement là où atterrit 0xffffffff80000000. Mêmes pages physiques, deux adresses.
Il faut effectuer ces étapes dans cet ordre exact, sinon ça ne fonctionnera pas :
Activer PAE dans CR4
Charger les tables de pages dans CR3
Positionner EFER.LME (le bit de la section « identifier notre mode », souvenez-vous)
On est techniquement en long mode maintenant, mais on exécute toujours du code 32 bits avec un GDT 32 bits. Il nous faut un segment de code 64 bits et un saut qui recharge CS. J’utilise un retf plutôt qu’un jmp far :
asm
lgdt[phys_gdt64_pointer]pushdword0x08; 64-bit code selector
pushdwordphys_trampoline64; physical addr of the 64-bit half
retf
Pourquoi push/retf plutôt qu’un simple jmp 0x08:trampoline64 ? Parce que le saut far veut une immédiate, et je veux que la cible soit un extern résolu par le linker (phys_trampoline64). Empiler le sélecteur + l’offset et faire un retour far me donne le même effet avec un symbole que le linker peut remplir.
Le GDT 64 bits en lui-même n’a rien d’exotique, juste un descripteur null, un de code et un de données :
On y est arrivés. On est en 64 bits, mais encore en bas dans la région physique identity-mappée. Il est temps de passer dans le higher half. On recharge les segments de données, puis on fait un saut absolu vers un symbole haut :
À partir d’ici, on est un vrai noyau higher-half. On récupère la vraie pile et on lit notre breadcrumb pour savoir qui nous a bootés :
asm
entry64_high:movrsp,kernel_stack_topcmpedi,MB2_BOOTLOADER_MAGICje.dispatch_mb2callkernel_entry_elf; EDI was 0, jmp generic boot
jmp.hang.dispatch_mb2:callkernel_entry_mb2;EDIwastheMB2magic
Et c’est tout. Les trois chemins convergent :
kernel_entry_mb2 (venu par le chemin GRUB)
kernel_entry_elf (venu par le chemin générique)
limine_entry (entré tout seul, en 64 bits depuis le début)
Les trois finissent par appeler le même kernel_main, qui, de son point de vue, se réveille toujours dans le même monde : long mode 64 bits, paging activé, une pile valide, noyau dans le higher half.
Un binaire ELF qui démarre sous Limine, GRUB2, le generic loader de QEMU et Xen PVH. Toute la surface spécifique au protocole tient en trois petits stubs d’entrée, une poignée de requêtes Limine et un linker script. Tout ce qui vient après kernel_main est agnostique du bootloader.
Il y a beaucoup de choses que je ne fais pas ici, pour être clair. Le chemin ELF générique n’a pas de memory map, pas de framebuffer, pas de RSDP. Mais ça, c’est un problème pour kernel_main, pas pour le trampoline.
Un guide pas à pas testé pour activer le Profile Guided Optimization et le Link Time Optimization dans un projet Meson compilé avec Clang, y compris l'étape de fusion llvm-profdata que la plupart des guides oublient.
Avant de toucher la moindre ligne de ma simulation de fluide, j'ai laissé le compilateur faire le travail. Changer simplement les flags m'a fait passer de 1x à environ 40x.