1 points par GN⁺ 2024-07-16 | 1 commentaires | Partager sur WhatsApp
  • À partir d’un secteur d’amorçage de 512 octets, l’article construit étape par étape le flux minimal d’un chargeur d’amorçage qui fait passer un CPU x86_64 du real mode 16 bits au long mode 64 bits
  • Il commence par vérifier l’affichage via les interruptions BIOS, puis relie nasm, ld, objcopy et QEMU pour valider que l’image de boot s’exécute réellement
  • À cause de la limite de taille du secteur d’amorçage, le code est séparé en stage 1 / stage 2, et le code suivant est lu depuis le disque via le BIOS int 0x13 avant l’entrée en protected mode
  • À partir du protected mode 32 bits, les routines BIOS ne peuvent plus être utilisées, ce qui impose une initialisation matérielle comme la GDT, la segmentation plate et l’écriture directe dans le buffer VGA
  • L’entrée en long mode 64 bits exige d’aligner les tables de pages, le PAE, EFER.LME, cr0.PG et une GDT 64 bits, après quoi il devient possible d’appeler du code C freestanding comme un noyau

Environnement 16 bits au démarrage via le BIOS

  • Après un reset, un CPU x86 est en real mode et la taille d’opérande par défaut est de 16 bits
  • Le real mode utilise la segmentation pour créer un espace d’adressage de 20 bits et peut gérer jusqu’à 1 Mo de mémoire
  • Le premier code exécuté par le BIOS se trouve dans le secteur d’amorçage du disque
    • Le BIOS cherche un disque dont le premier secteur se termine par le nombre magique 0xaa55
    • Il charge ce secteur à l’adresse mémoire 0x7c00
  • Comme le BIOS ne fournit que 512 octets, ce code se concentre sur son rôle de bootstrap chargé de récupérer le reste du chargeur d’amorçage
  • Les routines BIOS ne peuvent être utilisées que tant qu’on reste en real mode

Pré requis et environnement de build

Créer un secteur d’amorçage et vérifier son exécution

  • Le premier secteur d’amorçage affiche "Hello, world!" via une routine BIOS, puis s’arrête avec hlt et une boucle
  • Pour afficher la chaîne, il utilise l’appel BIOS de services vidéo int 0x10 avec ah = 0x0e
  • Le Makefile génère l’objet avec nasm, effectue l’édition de liens avec un linker script, puis produit une image de boot brute via objcopy -O binary
  • make boot exécute l’image dans QEMU
    • qemu-system-x86_64 -no-reboot -drive file=$<,format=raw,index=0,media=disk
  • Le linker script configure le placement du secteur d’amorçage à partir de 0x7c00
    • L’origine de boot_sector dans MEMORY est 0x7c00 et sa taille est 512
    • La section .bootsign ajoute 0x55, 0xaa à l’adresse 0x7c00 + 510
  • Il serait possible de gérer directement l’offset et le nombre magique dans l’assembleur du secteur d’amorçage, mais ici c’est le linker script qui s’en charge

Le stage 1 charge le stage 2 depuis le disque

  • Le stage 1 est le code du secteur d’amorçage chargé par le BIOS, et son objectif est de charger le stage 2 en mémoire
  • Le stage 2 contient le code qui fait passer du real mode 16 bits au protected mode 32 bits
  • Une fois en protected mode, les routines BIOS ne sont plus disponibles ; la lecture des secteurs disque doit donc être terminée avant la transition
  • L’accès au disque utilise les services disque du BIOS int 0x13
    • ah = 0x42 correspond à la fonction BIOS extended read
    • dl = 0x80 est le numéro du lecteur
    • Le disk address packet contient le nombre de secteurs à lire, l’adresse cible et le secteur de départ
  • L’exemple lit 64 secteurs avec READ_SECTORS_NUM equ 64
    • Comme le secteur d’amorçage est le secteur 0, le stage 2 est lu à partir du secteur 1
    • L’adresse cible est BOOT_LOAD_ADDR + SECTOR_SIZE, soit 0x7c00 + 512
  • Le code contient encore un traitement provisoire qui accepte qu’un nombre de secteurs inférieur à la demande soit lu
  • Au début, le stage 2 recopie print_string pour le real mode afin d’afficher "Hello from stage 2" et vérifier que le saut depuis le stage 1 s’enchaîne correctement

Passer au protected mode 32 bits

  • Pour entrer en protected mode, il faut d’abord définir une Global Descriptor Table (GDT)
  • En protected mode, la segmentation sert fondamentalement à la protection mémoire
  • Le long mode 64 bits nécessite la pagination, mais l’entrée préalable en protected mode demande d’abord une configuration de la segmentation
  • La GDT d’exemple suit le modèle plat du manuel Intel
    • Elle comporte un segment de code et un segment de données
    • Ces deux segments couvrent tout l’espace d’adresses linéaire
    • Comme il s’agit d’une étape intermédiaire vers le long mode, le modèle le plus simple est utilisé
  • La GDT est une structure continue placée en mémoire
    • Elle commence par un descripteur nul pour intercepter les traductions invalides
    • Elle est suivie d’un descripteur de segment de code puis d’un descripteur de segment de données
  • La transition suit l’ordre suivant
    • cli désactive les interruptions
    • lgdt [gdt32_pseudo_descriptor] charge l’adresse et la taille de la GDT dans le GDTR
    • cr0.PE, c’est-à-dire le bit 0 de cr0, active le protected mode
    • Un far jump vide le pipeline d’instructions et met à jour cs avec le nouveau segment de code
  • Après l’entrée en protected mode, les anciennes valeurs de segment n’ont plus de sens ; ds, ss, es, fs, gs sont donc configurés avec le nouveau sélecteur du segment de données
  • Pour réactiver les interruptions ensuite, du travail supplémentaire est nécessaire

Afficher à l’écran sans le BIOS

  • En protected mode, il n’est plus possible d’appeler les routines BIOS
  • L’affichage de chaînes passe donc par l’écriture directe dans le buffer VGA
  • print_string32 écrit des octets de caractère et de couleur à l’adresse 0xb8000
    • La valeur de couleur est 0xf
    • Chaque cellule de caractère utilise 2 octets
  • Cette fonction d’affichage est très simple, si bien que les messages apparaissent toujours en haut à gauche de l’écran

Tables de pages pour entrer en long mode

  • Le mode IA-32e de la documentation Intel correspond au long mode du manuel AMD64
  • Pour passer en long mode, le CPU doit déjà être en protected mode et la pagination doit aussi être activée
  • Pour les concepts de pagination, l’article renvoie à Introduction to Paging et à OSTEP
  • En long mode avec PAE activé, on utilise des tables de pages à 4 niveaux
  • build_page_table construit ces tables à l’adresse indiquée
    • La taille de page est 0x1000
    • La taille de chaque table de pages est 0x1000
    • Le nombre d’entrées est 512
    • Les quatre tables sont d’abord initialisées à 0 pour que toutes les entrées soient marquées non présentes
    • Les premières entrées de PML4 → PDP → PD → table de pages sont reliées entre elles
    • Au niveau le plus bas, 512 entrées sont configurées

GDT 64 bits et ordre de transition vers le long mode

  • Même si la pagination gère l’espace d’adresses virtuel et les permissions, une GDT reste nécessaire en long mode
  • La GDT 64 bits suit aussi un modèle plat et ressemble beaucoup à celle du protected mode
  • La différence tient aux bits liés au long mode
    • Le drapeau de segment de code 64 bits est activé dans le segment de code
    • Quand ce drapeau est activé, le bit de taille d’opération par défaut doit être à 0
  • La transition vers le long mode suit alors le flux suivant
    • Construction des tables de pages à 4 niveaux à l’adresse 0x1000
    • Chargement de l’adresse de la table PML4 dans cr3
    • Activation du PAE en positionnant le bit 5 de cr4
    • Lecture du MSR 0xc0000080 puis activation de EFER.LME, c’est-à-dire le bit 8
    • Activation de la pagination en positionnant le drapeau PG, bit 31 de cr0
    • Chargement de la GDT 64 bits avec lgdt
    • Far jump vers le segment de code 64 bits pour entrer en mode 64 bits
  • Juste après l’activation de la pagination, le processeur est en mode de compatibilité IA-32e ; il passe ensuite en mode 64 bits lors du saut vers une GDT dont le drapeau de segment 64 bits est activé
  • Le message confirmant la réussite s’affiche en haut à gauche de l’écran via le buffer VGA

Appeler du code C freestanding

  • Une fois le long mode 64 bits atteint, il devient possible d’appeler du code C freestanding
  • kernel.c efface le buffer VGA 0xb8000 puis affiche "Hello from C"
  • Côté assembleur, start_long_mode affiche d’abord une chaîne en 64 bits, déclare extern _start_kernel puis appelle _start_kernel
  • Le linker script découpe la mémoire en trois régions
    • boot_sector : 0x7c00, taille 512
    • stage2 : 0x7e00, taille 512
    • kernel : 0x8000, taille 0x10000
  • Les sections .text, .data, .rodata, .bss sont placées dans la région kernel
  • Le Makefile est modifié pour construire à la fois l’assembleur et le C
    • Le compilateur C est gcc
    • Les principaux CFLAGS sont -std=c99 -ffreestanding -m64 -mno-red-zone -fno-builtin -nostdinc -Wall -Wextra
  • Le code complet de l’exemple est fourni via un lien de téléchargement

1 commentaires

 
GN⁺ 2024-07-16
Commentaires sur Hacker News
  • Il est possible d’entrer directement en long mode avec bien moins de code, sans passer par le mode protégé : https://wiki.osdev.org/Entering_Long_Mode_Directly
    Il existait un petit bootloader pour un noyau 64 bits construit de cette manière, et même en incluant le code pour lire le noyau depuis le disque et configurer le mode VESA, tout tenait largement dans le secteur d’amorçage. Il n’y avait même pas besoin d’un chargeur de deuxième étape

    • Je me demande comment tout ça peut tenir dans 512 octets. Il ne doit probablement pas y avoir de véritable système de fichiers permettant de placer le noyau n’importe où sur le disque comme un fichier ordinaire, et rien que gérer la fragmentation des fichiers dépasserait sans doute largement 512 octets
    • Il est bien plus simple d’utiliser simplement https://limine-bootloader.org/. Il n’y a pas besoin de toucher au mode réel, c’est pareil en SMP, le noyau est chargé automatiquement avec un mapping higher-half, et cela fonctionne aussi sur aarch64 et riscv64
    • C’est vrai, mais si l’on veut aussi prendre en charge des contrôleurs AHCI modernes et SATA avec une table de partitions, l’espace disponible pour le bootloader diminue encore, ce qui impose plus d’optimisation. Dans ce cas, on ne peut pas utiliser les 510 octets entiers pour le chargeur, mais bien moins, et c’est encore plus délicat parce qu’on ne peut pas non plus utiliser les octets à l’intérieur de la table si l’on veut conserver des entrées de partition valides
      Si l’on utilise réellement des disques modernes, il vaut mieux regarder GPT que MBR. Cela permet de dépasser les limites des tables de partitions et de gérer aussi les disques de plus de 2 To. UEFI supprime ce problème et permet d’utiliser une disposition de disque correcte sans difficulté particulière
      Il n’est pas nécessaire de passer par le mode protégé pour entrer en mode 64 bits. En revanche, mieux vaut éviter d’utiliser le BIOS. C’est sale et cela ne fait que compliquer les choses
      Il vaut mieux utiliser UEFI avec EDK2 ou GnuEFI, et les deux sont assez faciles et confortables à mettre en œuvre. Il faut un peu de temps pour se familiariser avec les concepts de base d’UEFI, mais en regardant les projets d’exemple sur GitHub, on peut vite comprendre la structure. EDK a ses fichiers .dec et .inf un peu pénibles, et avec GnuEFI il faut lire les fichiers d’en-tête pour trouver les fonctionnalités, mais c’est malgré tout bien mieux que les interfaces BIOS aux spécifications floues. Sur du vrai matériel, on ne peut même pas supposer que int 0x10, int 0x15, etc. soient correctement présents
      Sur les systèmes UEFI, on peut supposer une base minimale stable, et les fonctions matérielles ou de plateforme peuvent aussi être énumérées de manière raisonnable. De plus, comme l’UEFI a déjà largement configuré la plateforme, le composant de chargement de l’OS n’a pas besoin d’effectuer beaucoup d’initialisation : il suffit de charger directement les éléments correspondant à la conception de l’OS, des pilotes et du noyau. Il suffit d’obtenir la carte mémoire, d’accéder au système de fichiers EFI et d’y lire ce dont on a besoin
    • Je ne savais pas que c’était possible. Si le but est simplement d’entrer en long mode, je me demande pourquoi il faudrait de toute façon passer par le mode protégé
  • Le 80286 possédait un registre 16 bits appelé Machine Status Word (MSW), et le 80386 l’a étendu en registre 32 bits CR0. Plus tard, le long mode 64 bits a ajouté l’EFER MSR et a étendu CR0 à 64 bits, mais aujourd’hui encore seuls 11 bits sont utilisés dans CR0, et EFER n’a que 8 bits actifs
    Je me demande pourquoi Intel/AMD ont fait deux fois de nouveaux choix au lieu de simplement utiliser les bits libres dans les registres existants : https://wiki.osdev.org/CPU_Registers_x86-64#CR0

    • C’est probablement pour rendre la rétrocompatibilité plus robuste. Un logiciel peut supposer la valeur de bits réservés ou écrire dedans. L’attribution des bits dans ce type de registre matériel est assez arbitraire, et utiliser les bits de poids fort n’a pas vraiment de coût
    • En un mot, la réponse est probablement la bureaucratie. Les grandes organisations, globalement, prennent rarement de très bonnes décisions, et cela produit souvent des choix absurdes
      Le fait que CR1 et CR5 à CR7 soient encore réservés alors que CR8 a été ajouté semble relever de la même logique
  • Ce qui semble le plus inutilement complexe dans cet article, c’est le Makefile et le script de l’éditeur de liens. NASM prend en charge la sortie en binaire plat, mais apparemment cela a été jugé trop « hacky »

    • Personnellement, je trouve le script de l’éditeur de liens bien plus lisible et plus facile à raisonner qu’un NASM plat, surtout quand il y a plusieurs fichiers source
    • C’est tout à fait vrai. Plus tard, les Makefile et les scripts de l’éditeur de liens deviennent d’importants casse-têtes, mais si l’on veut produire un binaire plat, autant simplement produire un binaire plat. Pas besoin de gonfler le tout
      Dans le temps, mon OS avait un fichier nommé make.sh juste pour se moquer de ça. Maintenant, j’utilise des choses sophistiquées comme des « formats de fichier », donc -fbin et --oformat=binary sont de lointains souvenirs. J’ai longtemps essayé de séparer les fichiers C de données et les fichiers C de code, de les dumper en binaire, puis d’assembler un monstre à partir de ça, mais l’édition de liens et le chargement deviennent trop difficiles. Mieux vaut simplement utiliser ELF ou PE, et c’est d’ailleurs ce à quoi ces formats semblent servir
  • Cela a l’air sympa et constitue un bon exercice, mais je ne sais pas si c’est utile. Je me demande s’il y a au moins une UX façon jouet Fisher-Price permettant de vérifier ou modifier la configuration pendant l’exécution
    Le boot est le processus qui mène d’un mode minimaliste, mono-utilisateur ou de récupération, à un état opérationnel
    J’utilise Unix avec des produits Microsoft depuis l’époque de Xenix/DOS, cela doit faire environ 40 ans. Je me demande quels progrès ont réellement été accomplis pendant ce temps
    J’utilise aussi Linux depuis la version suédoise, c’est-à-dire sa toute première version, et j’ai même utilisé GNU 0.1
    Je m’excuse d’avoir appelé Xenix un Unix. Xenix était un produit foutraque de type me-too qui, dès sa sortie et jusqu’à son déclin, semblait déjà vouloir appartenir au passé
    Microsoft ressemble à une entreprise qui ne lance pas des produits, mais vide une litière pour chat sur ses clients. Les exemples récents sont Copilot et 22H2
    Quand on voit l’évolution des F1, des crayons ou des calculatrices de poche, on se demande à quel point nous sommes proches d’un idéal réellement utilisable
    Je me demande aussi pourquoi le bootloader n’est pas en mode noyau statique. C’était le cas autrefois, et quelqu’un a récemment proposé de revenir à cela, ce avec quoi j’étais d’accord

  • https://wiki.osdev.org/A20_Line

  • Il est surprenant de voir que toutes les étapes nécessaires pour faire passer le CPU dans le bon mode semblent totalement inutiles. On dirait que la plupart des procédures ne sont nécessaires qu’à cause de la rétrocompatibilité
    Je me demande si Intel n’aurait pas pu fournir dès le départ un drapeau ou une instruction permettant de démarrer directement dans le bon mode, ou bien supprimer la rétrocompatibilité
    Il me semble qu’ARM64 a aussi eu un peu ce genre de problème. Je me demande s’il existe un CPU conçu dès l’origine pour le 64 bits, sans besoin de rétrocompatibilité, et qui arrive par défaut dans l’état souhaité. C’était peut-être l’objectif ou la philosophie d’Itanium

    • Le X86S proposé par Intel vise justement cet objectif

      X86S is a legacy-reduced-OS ISA that removes outdated execution modes and operating system ISA.
      The presence of the X86S ISA is enumerated by a single, main CPUID feature LEGACY_REDUCED_ISA in CPUID 7.1.ECX[2] which implies all the ISA removals described in this document. A new, 64-bit “start-up” interprocessor interrupt (SIPI) has a separate CPUID feature flag.
      [0] https://cdrdv2.intel.com/v1/dl/getContent/776648 [attention, PDF]

    • Intel a essayé de faire cela avec le 80376, mais sans succès : https://en.wikipedia.org/wiki/Intel_80376
      Itanium, alias Itanic, a connu le même sort
      La rétrocompatibilité est précisément la raison principale de choisir x86 plutôt qu’ARM, MIPS ou RISC-V. Malheureusement, certaines personnes chez Intel et AMD ne semblent pas bien le comprendre
    • UEFI existe déjà. Il suffit de mettre un binaire façon Windows dans un dossier de la partition, et il s’exécute dans un environnement hébergé en mode 64 bits. Bien sûr, il existe aussi d’innombrables bootloaders qui se chargent de tout cela à votre place
    • Je ne vois pas bien quel serait le problème avec arm64
  • Excellent projet. Si les partisans d’UEFI ici se demandent pourquoi quelqu’un irait créer une nouvelle méthode de bootloader, j’ai l’impression qu’ils passent à côté de la raison pour laquelle les gens font ce genre de travail
    Comme l’auteur l’écrit à la fin, « si vous êtes arrivé jusque-là, c’est génial » — et oui, c’est vraiment génial

  • Je me demande depuis combien de temps UEFI existe. C’est dommage qu’ils n’aient pas aussi abandonné le BIOS avec l’arrivée du long mode

    • Le BIOS est déjà en voie d’abandon. Sur les cartes mères récentes, cette fonctionnalité est en pratique émulée via UEFI, et elle n’est plus étendue
      En voie d’abandon ne veut pas dire supprimé, mais qu’on ne le met plus à jour ni ne le développe davantage, avec pour objectif de le retirer
  • Je me demande si cette procédure de démarrage fonctionne aussi avec EFI/UEFI. Si oui, je me demande si le superviseur UEFI émule les transitions entre mode réel, mode protégé et long mode, ou si cela s’exécute sur le vrai matériel

    • Non. Le firmware UEFI fournit au bootloader UEFI un environnement complètement différent de l’environnement BIOS hérité, c’est-à-dire du mode réel. Le firmware UEFI des systèmes modernes passe directement en long mode 64 bits et configure aussi un GDT à modèle mémoire plat ainsi qu’une pagination identity-mapped
      J’ai décrit ici le processus de création d’un bootloader UEFI pour un OS amateur : https://0xc0ffee.netlify.app/osdev/05-bootloader-p1.html
  • Je me demande si c’est plus simple sur ARM

    • C’est plus simple dans le sens où chaque fabricant de carte fait un peu ce qu’il veut. C’est simple pour les fabricants de cartes, mais horriblement complexe pour tout le monde
    • Oui. Les bootloaders restent complexes, mais il y a moins de configuration legacy nécessaire. Cela dit, en ciblant UEFI plutôt que le BIOS, c’est aussi bien plus simple sur x86
    • Je n’en suis pas certain, et je ne m’y attendrais pas. En ce moment, je creuse RISC-V en profondeur, et il semble y avoir de l’espoir de ce côté-là