
Dans GLMF 265, je vous avais annoncé que la fonctionnalité « boot PVH » du noyau NetBSD était « en cours d’étude », ce que je n’avais pas mentionné, c’est que cette étude était menée… par votre serviteur avec pour plan de l’exposer dans ces colonnes.
Le noyau BSD UNIX NetBSD dispose depuis longtemps d’un mode de boot appelé multiboot qui permet de démarrer un noyau en s’affranchissant du BIOS et d’un bootloader, lui permettant ainsi d’être démarré directement depuis un gestionnaire de machines virtuelles tel que qemu avec le paramètre -kernel. Le problème, c’est que cette fonctionnalité n’a été implémentée qu’en mode 32 bits…
Lorsque j’ai démarré les investigations sur la manière de démarrer un noyau NetBSD 64 bits directement, je suis tombé sur diverses discussions sur la liste de diffusion du noyau datant de 2020 [1] où plusieurs tentatives d’implémentation de multiboot semblent n’avoir jamais trouvé leur chemin vers le code source officiel du projet.
Dans le même temps, un certain Colin Percival [2] annonçait dans plusieurs présentations la capacité de FreeBSD de démarrer en moins de 30 ms sur Firecracker [3], le gestionnaire de machine virtuelle écrit et utilisé par AWS dans son environnement serverless [4]. En investiguant plus avant les succès du sieur Colin, je comprends que ce dernier parvient à faire démarrer le noyau FreeBSD en utilisant un mode de virtualisation que j’avais déjà croisé auparavant sans le savoir : le mode PVH.
1. 50 nuances de virtualisation
La virtualisation est aujourd’hui aussi commune qu’un disque USB de 2 To (cet article va mal vieillir), mais son histoire et ses évolutions sont le résultat de dizaines d’années de recherches et d’innovations.
Le premier mode, le plus ancien et le plus simple, c’est la virtualisation dite « totale » (full virtualization). Cette dernière transforme littéralement les instructions du système d’exploitation invité à la volée. En full virt, tout est interprété, les instructions processeur évidemment, mais également les périphériques, on parle donc ici plutôt d’émulation que de virtualisation. Comme vous l’imaginez aisément, ce mode est par essence peu performant.
Le second est apparu grâce aux travaux du projet Xen, issu de l’université de Cambridge, et se nomme la paravirtualisation (PV). Ce mode impose de modifier le système invité pour supporter des hypercalls, des appels système interceptés par l’hyperviseur, et accentuer la collaboration entre ce dernier et le système invité. Dans ce mode, on n’émule pas, le système invité est plutôt à considérer comme un programme privilégié exécuté par l’hyperviseur.
Un autre concept important dans ce mode, emprunté par des technologies comme VirtIO, c’est la paravirtualisation des périphériques, inutile d’émuler une carte réseau ou un disque quand on peut simplement implémenter un pilote qui sait transférer des octets de l’hôte vers l’invité ; encore une fois, le gain en performance est colossal.
Une nouvelle révolution est apparue au milieu des années 2000 avec l’arrivée des instructions VT dans les processeurs Intel et SVM dans les processeurs AMD. Ces jeux d’instructions spécifiques à la virtualisation permettent la « virtualisation assistée par le processeur ». Concrètement, à l’image d’un changement de contexte, le processeur passe en mode virtualisation en utilisant une structure de données relative à l’invité (VMCS pour Virtual Machine Control Structure). Lorsqu’il effectue cette transition, on dit qu’on passe du mode root operations (accès au matériel) au mode non-root operations.
C’est cette fonctionnalité qu’utilise par exemple le module du noyau Linux kvm, mais aussi le mode Xen HVM (pour Hardware Virtual Machine), dans lequel il faut noter que les périphériques d’entrée / sortie sont émulés par qemu, fatalement plus lent.
À partir de cet outillage de base, plusieurs autres approches ont été implémentées dans Xen :
- PVHVM, où ici on utilise toujours la virtualisation assistée par le processeur, mais on utilise des pilotes paravirtualisés, ce qui accélère grandement les E/S ;
- PVH, l’actuelle panacée, est une évolution de PV, capable de démarrer sans BIOS, directement vers un point d’entrée du noyau, qui utilise toutes les fonctionnalités de la paravirtualisation, mais sait également tirer parti des extensions VMX / SVM pour effectuer les opérations privilégiées, et donc s’affranchir des hypercalls.
Ce dernier mode de fonctionnement ne se cantonne pas à Xen, et l’idée de démarrer directement un noyau sans passer par la mécanique BIOS + bootloader a été implémentée dans qemu et le noyau Linux par Stefano Garzarella en 2019 [5].
Grâce à ce travail, il est possible de démarrer une machine virtuelle avec un noyau Linux avec le paramètre -kernel de la ligne de commande de qemu, par exemple :
Cette commande fera démarrer le noyau Linux en une cinquantaine de millisecondes.
2. Boah ça va prendre quelques minutes
J’imaginais à cet instant que mon noyau NetBSD allait également magiquement démarrer après avoir lu dans la documentation que ce dernier était capable de booter en PVH.
Je suis une personne très naïve, alors incrédule mais curieux, j’essaye la commande suivante :
Qui aboutit au message suivant :
Intéressant. Ce que je n’avais pas compris en lisant la susnommée documentation, c’est que NetBSD sait booter en PVH… avec l’hyperviseur Xen !
Fort de cet échec, je décidais de comprendre un peu mieux de quoi il en retourne, et ce que signifiait réellement ce fameux « boot PVH ».
En fouillant les travaux de Colin et en lisant les documentations de Xen, je finis par comprendre que ces derniers ont proposé, pour ce nouveau mode PVH, un petit hack à ajouter aux noyaux qu’on souhaite démarrer directement, sans bootloader ; ce hack consiste à préciser dans les en-têtes ELF du noyau un point d’entrée spécifique au boot PVH. qemu tire parti de ce hack, et on peut voir dans hw/i386/x86.c [6] le commentaire suivant :
Et en effet, en consultant la documentation de Xen, on trouve cette page [7] décrivant l’ABI « x86/HVM direct boot » qui énonce qu’une note ELF est nécessaire pour indiquer un point d’entrée différent sur le noyau, et qu’il a la forme :
Qu’à cela ne tienne, voyons voir comment les collègues de FreeBSD ont implémenté cela et plagions gaiement !
On trouve effectivement en juin 2022 un commit [8] dont le message explique que « certains outils » (par exemple le chargeur Firecracker) ne cherchent les notes ELF que dans les en-têtes PT_NOTES du programme, ainsi il faut ajouter ces notes dans cette section.
La syntaxe du define utilisé pour enregistrer ces informations dans l’exécutable généré est assez absconse :
- Le début du define avant la modification :
- Après la modification :
On trouve dans la documentation de as(1), l’assembleur de GNU, la définition suivante :
.pushsection permet, comme son nom l’indique, de pousser une section sur le dessus de la pile. La section en question est .note.name, où name sera remplacé par le premier paramètre passé à la macro ELFNOTE. L’option a indique que cette section va être allouée à l’exécution du programme (ici le noyau), et enfin @note, le type qui ici signifie que cette section dispose d’informations utilisées par autre chose que le programme lui-même.
Fort heureusement, NetBSD dispose du même define dans le fichier sys/arch/amd64/amd64/locore.S, appliquons donc la même modification et compilons notre noyau avec ces nouvelles informations, puis comparons un noyau sans et avec les notes relatives au boot PVH.
Sans :
Avec :
Et effectivement, on constate l’apparition d’un nouvel en-tête NOTE, ainsi que la présence de .note.Xen dans les sections à mapper.
Voyons si ce noyau démarre :
CPU à 100 % d’utilisation, ventilateur qui s’enflamme, rien qui s’affiche… puis :
Franc succès.
3. « Cette petite manœuvre va nous coûter 51 ans »
Que se passe-t-il ? Mais également : où sommes-nous ? Qu’est-ce qui provoque ce qui semble être une boucle avant de faire crasher kvm ? Est-ce qemu ? Le noyau ? Dans ce dernier cas, ce serait tout de même une demi-victoire, mais comment en avoir la preuve ?
Finalement, la réponse que nous cherchons se résume à : qemu a-t-il effectivement branché sur le nouveau point d’entrée défini par la modification ci-dessus ?
Livre de chevet
Pour la suite de la lecture de cet article, il sera judicieux d’avoir le fichier sys/arch/amd64/amd64/locore.S ouvert non loin de vous [9].
Lisons ce que la macro ELFNOTE déclare dans le fameux fichier source locore.S :
Traduction, le point d’entrée (entry point) se trouve à RELOC(start_xen32). RELOC est également une macro définie plus haut dans ce fichier :
Cette dernière fournit simplement une adresse relative à l’adresse de base du noyau, ce qui nous intéresse, c’est son paramètre, start_xen32, qui n’est autre qu’un label de fonction déclaré par la macro ENTRY() dont voici le #define simplifié :
Alors, arrivons-nous effectivement jusqu’à cette fonction ? Pour le savoir, nous allons utiliser les capacités d’interaction avec gdb incluses dans qemu.
Avant tout, il faudra recompiler un noyau avec les options nécessaires au débogage distant avec gdb :
On peut alors démarrer qemu avec les options suivantes :
- -s démarre qemu avec l’option -gdb tcp::1234 ;
- -S démarre qemu en mode « pause ».
Puis, soit sur la même machine si son système est NetBSD, soit sur une machine distante NetBSD, on invoque gdb en mode client avec en paramètre le noyau avec les symboles de débogage :
On déclare la cible :
À ce stade, rien n’a démarré, le serveur gdb de qemu attend qu’on poursuivre l’exécution du noyau avec la fonction [c]ontinue, mais ce que nous souhaitons constater ici, c’est si qemu a bien lu les notes lui indiquant de choisir un nouveau point d’entrée. Pour ce faire, nous allons placer un [b]reakpoint à son adresse, qu’on va trouver grâce à la commande print de gdb :
Néanmoins, si nous spécifions cette adresse comme point d’arrêt et qu’on demande à poursuivre l’exécution, gdb nous gratifie d’une erreur :
0xffffffff80000000 est en réalité l’adresse de base du noyau dans l’espace d’adressage virtuel :
Or, nous n’en sommes absolument pas encore au stade du déroulement du noyau lors duquel ce dernier met en place la mémoire virtuelle !Non, non, nous démarrons à peine, nous sommes dans le néant, ici, rien n’existe, tout est à créer, dans locore.S pas même un printf disponible. L’adresse de start_xen32 ici, c’est 0x20b440, soit son adresse dans la mémoire « physique », ou du moins, l’espace d’adressage physique que met qemu a disposition de la machine virtuelle invitée, aussi appelée GPA pour Guest Physical Addresses.Alors on efface notre ancien point d’entrée et on place le nouveau :
A priori, nous sommes bien arrêtés sur le nouveau point d’entrée, le pointeur d’instruction nous en convainc :
Affichons les instructions à suivre :
Qu’on compare avec le code de start_xen32 :
On constate que ces deux portions de code sont pratiquement identiques, à l’optimisation du compilateur près.
La technique a fonctionné, qemu démarre bien l’exécution du noyau sur le endpoint défini dans les en-têtes ELF.
Maintenant, reste à trouver où la suite des opérations échoue…
4. La petite phrase
De proche en proche, ou plutôt de breakpoint en breakpoint, on conclut que toutes les instructions préalables au branchement sur le label .Lbiosbasemem_finished s’exécutent sans encombre. Que se passe-t-il après ce label ? Oh, rien de très important, simplement la préparation des zones de mémoire virtuelle du noyau. Une broutille.
Pour bien comprendre la teneur du problème auquel nous sommes confrontés, il faut prêter attention au petit tableau ASCII dans locore.S qui représente l’espace d’adresse virtuel du noyau :
Dans cette portion du code, on souhaite déterminer où on peut commencer à inscrire les tables de pages. La section jusqu’au (1) correspond à l’espace dans lequel le code du noyau réside. Potentiellement, si le noyau est compilé avec l’option KSYMS (symboles noyau) ou DDB (débogueur interne) ou MODULAR (noyau entièrement modulaire) alors on va se déplacer jusqu’à l’adresse de fin de ces symboles (esym), et donc se placer en (2). Il en ira de même avec de potentiels modules préchargés (eblob), on démarrera alors à (3).
C’est mon ami Gregory Cavelier qui a trouvé un indice crucial : dans ce mode de démarrage, ni esym ni eblob ne semblent être initialisés, et si on force leur valeur à 0, on va plus loin dans le processus de démarrage.
Quelque chose cloche décidément avec ces valeurs, retournons donc analyser la fonction start_xen32. Avant le branchement vers les boucles d’initialisation de la mémoire virtuelle, on peut lire le commentaire suivant :
En effet, la documentation de Xen [7] énonce le postulat suivant :
ebx: contains the physical memory address where the loader has placed the boot start info structure.
Et nous voyons bien que l’adresse du registre EBX est bien sauvegardée dans la variable hvm_start_paddr :
Mais la documentation ne mentionne absolument rien concernant la position de l’adresse en question ! Que dit gdb :
Cette adresse semble bien trop basse pour être située après le noyau, nous pouvons vérifier cela en affichant la valeur de la variable __kernel_end, renseignée lors du linkage et qui comme son nom l’indique, donne l’adresse de la fin du noyau + 1 :
Comme on le présumait, EBX ne pointe pas là où la suite du code de locore.S s’attend à trouver les informations passées par l’hyperviseur ! Mais sommes-nous bien sûrs qu’à l’adresse 0x21c0 on trouve lesdites informations ?
Ce que nous sommes supposés trouver dans EBX, c’est l’adresse d’une structure, hvm_start_info, définie par Xen [10], qui permet de s’affranchir de la séquence BIOS / bootloader en passant les informations relatives à la machine virtuelle qui est en train d’être démarrée. Cette structure est reconnaissable à l’aide d’un magic number :
Et effectivement, dans les 4 premiers octets de l’adresse pointée par EBX :
Nous retrouvons le nombre magique.
Mais alors que se passe-t-il au moment de la préparation des tables de pages qui explique le crash de la machine virtuelle ?
Premièrement, on enregistre cette mauvaise adresse dans ESI, puis au moment de mapper ces adresses, on se place après l’adresse de fin du noyau, là on prépare le compteur ECX avec ESI, toujours faux, mais dont on suppose qu’il pointe vers une adresse plus grande que __kernel_end, auquel on soustrait l’adresse de fin du noyau pour savoir combien d’adresses seront à mapper… Sauf que, l’adresse de fin du noyau vaut 0xa00000, et celle de esym pointée par EBX vaut 0x21c0, la soustraction va aboutir à un nombre négatif :
Or, pour représenter un nombre négatif, il faut passer par son complément à deux, qui dans ce cas est 0xffa00000, ou 4288675840 en décimal, 4 Go parcourus page par page sur une machine virtuelle de 256 Mo, tout va bien se passer.
De toute façon, cette adresse pour le moment physique va se transformer en adresse virtuelle qui sera avant celle de la section .text (code) du noyau, ici 0xffffffff8000021c0 alors que .text commence à 0xffffffff800200000 :
Bref, rien ne va dans cette supposition et dans ces conditions, il est impossible de poursuivre le démarrage du système.
Sans modifier profondément le code du noyau, l’option la plus « simple » dont nous disposons consiste à copier le contenu de la structure hvm_start_info là où elle est attendue, et s’assurer que l’initialisation des tables de pages s’effectuera bien derrière le noyau.
5. Clonage
Cette fameuse structure hvm_start_info contient des informations nécessaires au démarrage d’une machine virtuelle, telles que les éventuels modules passés au noyau, les paramètres passés à la ligne de commande (console=, root=…), un pointeur vers les structures de données ACPI ou encore le tableau des adresses physiques.
Voici concrètement son contenu :
Rappelez-vous, dans locore.S, point de salut, pas de memcpy(3), rien, le néant, ici, on copie les octets à l’ancienne.Là encore, je dois remercier l’ami Gregory dont la qualité de l’assembleur fut bien meilleure que la mienne, aussi j’utiliserai sa version, plus élégante et optimisée.
Boucle de copie en assembleur
Une technique commune pour réaliser une copie de données en assembleur consiste à implicitement utiliser 3 registres :
- ESI pour la source ;
- EDI pour la destination ;
- ECX pour le compteur.
Dans cette méthode, on utilise l’instruction REP qui va repéter une opération ECX fois. Par exemple rep movsl (« Move Long String ») copiera ECX fois 32 bits depuis l’adresse située dans ESI vers l’adresse située dans EDI en incrémentant ces dernières de 32 bits à chaque itération.
En premier lieu, nous allons utiliser un fichier de configuration du noyau NetBSD qui génère des define lors de la préparation à la compilation, il s’agit du fichier sys/arch/amd64/amd64/genassym.cf. On peut y déclarer ce genre de chose :
Ce qui va nous permettre, dans le code assembleur, d’utiliser le define $HVM_START_INFO_SIZE. Ainsi, grâce à cette valeur, nous pouvons d’ores et déjà copier la structure hvm_start_info juste derrière le noyau, là où s’attend à la trouver la suite du code :
On copie maintenant la ligne de commande, pour ce faire, nous avons besoin de connaître son adresse en mémoire, nous créons donc un define dans genassym.cf de cette forme :
offsetof() comme son nom l’indique, donne l’offset du second argument par rapport au premier.
Il nous faut maintenant copier la liste de tables de mémoires disponibles, nous allons pour cela avoir besoin de nouvelles macros.L’adresse du tableau d’adresses disponibles qu’on obtient avec l’offset par rapport au début de la structure hvm_start_info :
Le nombre d’entrées dans ce tableau, qu’on localise de la même façon :
Et la taille d’une de ces entrées :
Et on copie le tableau :
Et finalement, on copie l’adresse de fin du noyau dans EBX, cette dernière étant suivie de la structure hvm_struct_info, comme cela est présumé par la suite du code.
32 bits ?
Le lecteur attentif aura remarqué que depuis le début des manipulations, nous ne travaillons qu’avec des registres 32 bits (EAX, EBX…) et non pas 64 bits (RAX, RBX…), ceci est lié au fait qu’à ce moment du démarrage, le noyau n’est pas encore passé en « long mode » c.-à-d. 64 bits.
Démarrons à nouveau gdb pour comparer le contenu de l’adresse pointée par EBX en arrivant dans start_xen32 avec le contenu qui suit la fin du noyau après la mise en place des adresses virtuelles.
On commence par prendre une photographie des informations actuellement pointées par EBX à l’adresse 0x21e :
On reconnaît le nombre magique qui prouve qu’il s’agit d'une structure start_info. À l’aide de la définition de la structure hvm_start_info [10], on voit avec le second membre de 32 bits qu’on est en version 1, et que la ligne de commande se trouve à l’adresse 0x11c0, on peut s’en convaincre simplement en affichant la chaîne de caractères qui commence à cette adresse :
La liste des plages de mémoire est à l’adresse 0x5a8 et elles sont au nombre de 7.
Moment de vérité n˚1, on place un second point d’arrêt après la préparation de l’espace d’adressage virtuel du noyau, au label compat, si on arrive jusque là, nous avons déjà évité un des premiers écueils :
Première victoire ! Et que trouve-t-on après la fin du noyau ?
Voilà un ensemble de données familier, on remarque que l’adresse de la ligne de commande a changé et est désormais 0xa00038, ce qui est parfaitement cohérent puisque cette dernière est derrière la structure hvm_start_info dont la taille est de 56 octets (0x38).
Et sans surprise, on trouve bien la ligne de commande à cette adresse :
De la même façon, le tableau des adresses physiques disponibles est à l’adresse 0x00a00047 :
On retrouve les données relatives aux zones mémoire disponibles, la copie est réussie.
6. Mais ça boot ?
En l’état, notre machine virtuelle ne démarre pas vraiment, mais nous avons résolu le problème majeur, munis des informations de la structure start_info, nous allons pouvoir passer aux opérations suivantes réalisées par le noyau qui, maintenant qu’il dispose des adresses de mémoire physique disponibles, va pouvoir procéder à l’organisation de ces dernières, et interpréter les commandes passées en paramètre. Pour cela, nous allons déclarer un nouveau type d’hyperviseur : GENPVH.
Mais ça, ce sera la prochaine fois !
Références
[1] https://www.unitedbsd.com/d/909-status-of-multiboot-for-amd64
[2] https://www.daemonology.net/
[3] https://www.usenix.org/publications/loginonline/freebsd-firecracker
[4] https://firecracker-microvm.github.io/
[5] https://stefano-garzarella.github.io/posts/2019-08-23-qemu-linux-kernel-pvh/
[6] https://github.com/qemu/qemu/blob/dd88d696ccecc0f3018568f8e281d3d526041e6f/hw/i386/x86.c#L709
[7] https://xenbits.xen.org/docs/unstable/misc/pvh.html
[8] https://github.com/freebsd/freebsd-src/commit/881c145431b7aa956b93f6d2e7b861fe00ecc892
[9] https://github.com/NetBSDfr/NetBSD-src/blob/trunk/sys/arch/amd64/amd64/locore.S
[10] https://github.com/xen-project/xen/blob/master/xen/include/public/arch-x86/hvm/start_info.h