22 avril 202613 min de lecture #os #osdev #limine #grub #kernel

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

Le problème #

Grub2 (qui suit la spécification Multiboot2) est orienté x86, Limine (avec son Limine Boot Protocol) est orienté x86-64

En fait. Multiboot2 a été conçu en pensant à du 32 bits. Limine, en pensant à du 64, et ne supporte pas les exécutables 32 bits

C’est un vrai problème : comment faire démarrer le système, alors que l’on peut être en mode 32 ou 64 ?

Modes x86 #

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.

Transitions entre les modes de fonctionnement du processeur
(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)

Point d’entrée #

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

Multiboot2 #

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 :

D’après la doc :

ld
        .long   -(MULTIBOOT2_HEADER_MAGIC + GRUB_MULTIBOOT_ARCHITECTURE_I386 + (multiboot_header_end - multiboot_header))

Le tag address #

TL;DR : c’est inutile pour les fichiers ELF

MB2 fournit un tag address :

text
        +-------------------+
u16     | type = 2          |
u16     | flags             |
u32     | size              |
u32     | header_addr       |
u32     | load_addr         |
u32     | load_end_addr     |
u32     | bss_end_addr      |
        +-------------------+

D’après la spécification :

  • 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.

Le tag entry address. #

Celui-ci, en revanche, n’est pas inutile du tout.

text
        +-------------------+
u16     | type = 3          |
u16     | flags             |
u32     | size              |
u32     | entry_addr        |
        +-------------------+

Il vous permet de spécifier l’adresse physique de votre point d’entrée.

Alors que pour ELF, c’est généralement le champ p_entry qui le définit, vous pouvez vouloir le surcharger pour un comportement spécifique à GRUB2/MB2.

Autres tags #

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

Limine #

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

Voir LimineBootProtocol : Memory Layout At Entry

Deux chemins, un seul but #

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")))
static volatile struct limine_entry_point_request entry_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.

L’astuce du higher half #

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.

ld
KERNEL_PHYS_BASE = 0x200000;
KERNEL_VIRT_BASE = 0xffffffff80000000;

. = KERNEL_VIRT_BASE + KERNEL_PHYS_BASE;

.multiboot2_header : AT(KERNEL_PHYS_BASE)
{
    KEEP(*(.multiboot2_header))
} :boot

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 :

ld
phys_boot_pml4      = boot_pml4      - KERNEL_VIRT_BASE;
phys_gdt64_pointer  = gdt64_pointer  - KERNEL_VIRT_BASE;
phys_trampoline64   = trampoline64   - KERNEL_VIRT_BASE;

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.

Deux points d’entrée 32 bits #

Les chemins eux-mêmes sont minuscules.

asm
bits 32

; GRUB jumps here via the MB2 entry address tag.
global _mb2_entry32
_mb2_entry32:
    cli
    mov  edi, eax            ; EDI = magic 
    mov  esi, ebx            ; ESI = info ptr 
    jmp  trampoline32

; Anything that loads the ELF and jumps to e_entry lands here.
global _elf_entry32
_elf_entry32:
    cli
    xor  edi, edi            ; EDI = 0  => "no protocol"
    xor  esi, esi
    jmp  trampoline32

_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.

Le trampoline : de 32 à 64 #

Il nous amène jusqu’au Long Mode.

D’abord, une pile temporaire et un peu de mémoire de tables de pages mise à zéro pour travailler :

asm
trampoline32:
    mov  esp, phys_boot_stack_top

    mov  edi, phys_boot_pml4
    xor  eax, eax
    mov  ecx, (4096 * 4) / 4
    rep  stosd

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

asm
    mov  dword [phys_boot_pml4],           phys_boot_pdpt_low  + 0x03
    mov  dword [phys_boot_pml4 + 511 * 8], phys_boot_pdpt_high + 0x03
    mov  dword [phys_boot_pdpt_low],            phys_boot_pd + 0x03
    mov  dword [phys_boot_pdpt_high + 510 * 8], phys_boot_pd + 0x03

    ; 512 × 2 MiB pages (0x83 = PS | WRITABLE | PRESENT)
    mov  edi, phys_boot_pd
    mov  eax, 0x00000083
    mov  ecx, 512
.fill_pd:
    mov  [edi], eax
    mov  dword [edi + 4], 0
    add  eax, 0x200000
    add  edi, 8
    dec  ecx
    jnz  .fill_pd

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 :

  1. Activer PAE dans CR4
  2. Charger les tables de pages dans CR3
  3. Positionner EFER.LME (le bit de la section « identifier notre mode », souvenez-vous)
  4. Activer le paging (CR0.PG)
asm
    mov  eax, cr4
    or   eax, CR4_PAE                ; 1. PAE
    mov  cr4, eax

    mov  eax, phys_boot_pml4         ; 2. CR3
    mov  cr3, eax

    mov  ecx, MSR_EFER               ; 3. EFER.LME (+ NXE)
    rdmsr
    or   eax, EFER_LME | EFER_NXE
    wrmsr

    mov  eax, cr0                    ; 4. paging on
    or   eax, CR0_PG | CR0_WP | CR0_PE
    mov  cr0, eax

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]

    push dword 0x08                  ; 64-bit code selector
    push dword phys_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 :

asm
gdt64:
    dq 0                                                ; null
    dq (1 << 43) | (1 << 44) | (1 << 47) | (1 << 53)    ; 64-bit code
    dq (1 << 44) | (1 << 47) | (1 << 41)                ; data

Atterrissage en 64 bits #

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 :

asm
bits 64

trampoline64:
    mov  ax, 0x10                  
    mov  ds, ax
    mov  es, ax
    mov  fs, ax
    mov  gs, ax
    mov  ss, ax

    mov  rax, entry64_high           
    jmp  rax                        

À 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:
    mov  rsp, kernel_stack_top

    cmp  edi, MB2_BOOTLOADER_MAGIC
    je   .dispatch_mb2

    call kernel_entry_elf            ; EDI was 0, jmp generic boot
    jmp  .hang

.dispatch_mb2:
    call kernel_entry_mb2            ; EDI was the MB2 magic

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.

Conclusion #

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.

Sources #

Plus d'articles

Comment utiliser PGO et LTO avec Meson et Clang

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.

5 min

Partie 2 : Profiling, PGO & LTO

Mesurer PGO et LTO sur une simulation SPH en C++ avec perf et des flamegraphs, et pourquoi ils n'ont presque rien apporté.

8 min