NetBSD et Boot PVH : en cours d’étude…

Spécialité(s)


Résumé

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.


Body

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 :

$ qemu-system-x86_64 -machine q35,accel=kvm \
    -kernel /chemin/vers/vmlinux \
    -drive file=/chemin/vers/disque.ext2,if=virtio,format=raw \
    -append 'root=/dev/vda console=ttyS0' -vga none -display none \
        -serial mon:stdio

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 :

$ qemu-system-x86_64 -cpu host -enable-kvm -m 256 \
-kernel netbsd-GENERIC -append "console=com rw -v" \
-display none -serial stdio

Qui aboutit au message suivant :

qemu-system-x86_64: Error loading uncompressed kernel without PVH ELF Note

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 :

/*
* The entry point into the kernel for PVH boot is different from
* the native entry point. The PVH entry is defined by the x86/HVM
* direct boot ABI and is available in an ELFNOTE in the kernel binary.
* [...]
*/

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 :

ELFNOTE(Xen, XEN_ELFNOTE_PHYS32_ENTRY,   .long, xen_start32)

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 :
#define ELFNOTE(name, type, desctype, descdata...) \
.pushsection .note.name        ;
  • Après la modification :
#define ELFNOTE(name, type, desctype, descdata...) \
.pushsection .note.name, "a", @note ;

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.

$ ./build.sh -U -u -j4 -T obj/tooldir -m amd64 kernel=MICROVM
$ cp /home/imil/src/NetBSD-src/sys/arch/amd64/compile/obj/MICROVM/netbsd netbsd-MICROVM

Sans :

$ readelf -Wl netbsd-GENERIC
 
Elf file type is EXEC (Executable file)
Entry point 0xffffffff8020e000
There are 2 program headers, starting at offset 64
 
Program Headers:
  Type Offset    VirtAddr           PhysAddr           FileSiz   MemSiz    Flg Align
  LOAD 0x200000  0xffffffff80200000 0x0000000000200000 0x14a73a8 0x14a73a8 R E 0x200000
  LOAD 0x1800000 0xffffffff81800000 0x0000000001800000 0x0c1d40  0x200000  RW  0x200000
 
Section to Segment mapping:
  Segment Sections...
   00     .text .rodata.hotpatch .rodata .eh_frame link_set_x86_hotpatch_descriptors link_set_modules link_set_sdt_argtypes_set link_set_sdt_probes_set link_set_sdt_providers_set link_set_sysctl_funcs link_set_acpi_device_calls link_set_evcnts link_set_linux_module_param_desc link_set_linux_module_param_info link_set_domains link_set_ieee80211_funcs link_set_ah_chips link_set_ah_rfs link_set_dkwedge_methods link_set_prop_linkpools
   01     .data .data.cacheline_aligned .data.read_mostly .bss

Avec :

$ readelf -Wl netbsd-MICROVM
 
Elf file type is EXEC (Executable file)
Entry point 0xffffffff8020b000
There are 3 program headers, starting at offset 64
 
Program Headers:
  Type  Offset   VirtAddr           PhysAddr           FileSiz  MemSiz   Flg Align
  LOAD  0x200000 0xffffffff80200000 0x0000000000200000 0x488fd4 0x488fd4 R E 0x200000
  LOAD  0x800000 0xffffffff80800000 0x0000000000800000 0x023900 0x200000 RW  0x200000
  NOTE  0x688e50 0xffffffff80688e50 0x0000000000688e50 0x000184 0x000184 R   0x4
 
Section to Segment mapping:
  Segment Sections...
   00     .text .rodata.hotpatch .rodata .eh_frame link_set_x86_hotpatch_descriptors link_set_modules link_set_evcnts link_set_sysctl_funcs link_set_domains link_set_dkwedge_methods link_set_prop_linkpools .note.Xen
   01     .data .data.cacheline_aligned .data.read_mostly .bss
   02     .note.Xen

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 :

$ qemu-system-x86_64 -cpu host -enable-kvm -m 256 -kernel netbsd -append "console=com rw -v" -display none -serial stdio

CPU à 100 % d’utilisation, ventilateur qui s’enflamme, rien qui s’affiche… puis :

KVM internal error. Suberror: 1
emulation failure
EAX=0000ffc8 EBX=00000000 ECX=00000000 EDX=00090000
ESI=00000000 EDI=00000000 EBP=00000000 ESP=0000ffb8
EIP=0000001e EFL=00010082 [--S----] CPL=0 II=0 A20=1 SMM=0 HLT=0
ES =0000 00000000 0000ffff 00009300
CS =a171 000a1710 0000ffff 00009b00
SS =0000 00000000 0000ffff 00009300
DS =0200 00002000 0000ffff 00009300
FS =0000 00000000 0000ffff 00009300
GS =0000 00000000 0000ffff 00009300
LDT=0000 00000000 0000ffff 00008200
TR =0000 00000000 0000ffff 00008b00
GDT=     00000000 0000ffff
IDT=     00000000 0000ffff
CR0=60000010 CR2=00000000 CR3=00000000 CR4=00000000
DR0=0000000000000000 DR1=0000000000000000 DR2=0000000000000000 DR3=0000000000000000
DR6=00000000ffff0ff0 DR7=0000000000000400
EFER=0000000000000000
Code=00 00 03 9c 00 00 03 9c 00 00 03 9c 00 00 03 9c 00 00 03 9c <00> 00 03 9c 00 00 03 9c 00 00 03 9c 00 00 03 9c 00 00 03 9d 00 00 03 9d 00 00 03 9d 00 00

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 :

ELFNOTE(Xen, XEN_ELFNOTE_PHYS32_ENTRY,   .long, RELOC(start_xen32))

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 :

#define _RELOC(x)   ((x) - KERNBASE)
#define RELOC(x)    _RELOC(_C_LABEL(x))

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é :

#define ENTRY(x) \
    .text; _ALIGN_TEXT; .globl x; .type x,@function; x:

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 commente l'utilisation du débogueur intégré
#
#options    DDB                     # in-kernel debugger
#options    DDB_HISTORY_SIZE=100    # enable history editing
#
# Et on active le support de gdb
#
options     KGDB        # remote debugger
options     KGDB_DEVNAME="\"com\"",KGDB_DEVADDR=0x3f8,KGDB_DEVRATE=9600
makeoptions DEBUG="-g"  # compile full symbol table

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 ».
$ qemu-system-x86_64 -cpu host -enable-kvm -m 256 -kernel netbsd -append "console=com rw -v" -display none -serial stdio -s -S

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 :

$ gdb netbsd.gdb
Reading symbols from tmp/netbsd.gdb...done.
(gdb)$

On déclare la cible :

(gdb) target remote machine:1234

À 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 :

(gdb) p &start_xen32
$1 = (<text variable, no debug info> *) 0xffffffff8020b440 <start_xen32>

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 :

(gdb) b *0xffffffff8020b440
Breakpoint 1 at 0xffffffff8020b440
(gdb) c
Continuing.
Warning:
Cannot insert breakpoint 1.
Cannot access memory at address 0xffffffff8020b440
 
Command aborted.

0xffffffff80000000 est en réalité l’adresse de base du noyau dans l’espace d’adressage virtuel :

$ rg 'define\sKERNBASE\s' sys/arch/amd64/include/param.h
58:#define      KERNBASE        0xffffffff80000000 /* start of kernel virtual space */

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 :

(gdb) d
(gdb) b *0x20b440
Breakpoint 2 at 0x20b440
(gdb) target remote tatooine:1234
Remote debugging using tatooine:1234
0x000000000000fff0 in ?? ()
(gdb) c
Continuing.
 
Breakpoint 1, 0x000000000020b440 in ?? ()

A priori, nous sommes bien arrêtés sur le nouveau point d’entrée, le pointeur d’instruction nous en convainc :

(gdb) p $rip
$3 = (void (*)()) 0x20b440

Affichons les instructions à suivre :

(gdb) x/16i 0x20b440
=> 0x20b440:    mov      $0x8000de,%eax
   0x20b445:    lgdt     (%rax)
   0x20b448:    (bad)
   0x20b449:    rex.WRXB mov $0x20,%r12b
   0x20b44c:    add      %cl,(%rax)
   0x20b44e:    add      %ah,-0x48(%rsi)
   0x20b451:    adc      %al,(%rax)
   0x20b453:    mov      %eax,%ds
   0x20b455:    mov      %eax,%es
   0x20b457:    mov      %eax,%ss
   0x20b459:    mov      $0x800300,%esp
   0x20b45e:    xor      %eax,%eax
   0x20b460:    mov      $0x823940,%edi
   0x20b465:    mov      $0xa00000,%ecx
   0x20b46a:    sub      %edi,%ecx
   0x20b46c:    rep stos %al,%es:(%rdi)

Qu’on compare avec le code de start_xen32 :

ENTRY(start_xen32)
    /* Xen doesn't start us with a valid gdt */
    movl    $RELOC(gdtdesc32), %eax
    lgdt    (%eax)
    jmp     $GSEL(GCODE_SEL, SEL_KPL), $RELOC(.Lreload_cs)
 
.Lreload_cs:
    movw    $GSEL(GDATA_SEL, SEL_KPL), %ax
    movw    %ax, %ds
    movw    %ax, %es
    movw    %ax, %ss
 
    /* we need a valid stack */
    movl    $RELOC(tmpstk),%esp
 
    /* clear BSS */
        xorl    %eax,%eax
    movl    $RELOC(__bss_start),%edi
    movl    $RELOC(_end),%ecx
    subl    %edi,%ecx
    rep
    stosb

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 :

* +------+--------+------+-----+--------+---------------------+----------
* | TEXT | RODATA | DATA | BSS | [SYMS] | [PRELOADED MODULES] | L4 ->
* +------+--------+------+-----+--------+---------------------+----------
*                             (1)      (2)                   (3)
*
* --------------+-----+-----+----+-------------+
* -> PROC0 STK -> L3 -> L2 -> L1 | ISA I/O MEM |
* --------------+-----+-----+----+-------------+
*                               (4)

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 :

    /*
     * save addr of the hvm_start_info structure. This is also the end
     * of the symbol table
     */

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 :

    movl    %ebx, RELOC(hvm_start_paddr)

Mais la documentation ne mentionne absolument rien concernant la position de l’adresse en question ! Que dit gdb :

(gdb) p/x $ebx
$2 = 0x21c0

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 :

(gdb) p/x (uint32_t)&__kernel_end
$5 = 0x80a00000

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 :

#define XEN_HVM_START_MAGIC_VALUE 0x336ec578

Et effectivement, dans les 4 premiers octets de l’adresse pointée par EBX :

(gdb) x/x $ebx
0x21c0: 0x336ec578

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 :

    /* Map [SYMS]+[PRELOADED MODULES] RW. */
    movl    $RELOC(__kernel_end),%eax /* on met 0xa00000 dans EAX */
    movl    %esi,%ecx                 /* et 0x21c0 dans ECX */
    subl    %eax,%ecx                 /* résultat: -10477120 */

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 :

$ readelf -S netbsd
There are 25 section headers, starting at offset 0x8c7288:
 
Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags Link Info Align
  [ 0]                   NULL             0000000000000000 00000000
       0000000000000000 0000000000000000           0     0     0
  [ 1] .text             PROGBITS         ffffffff80200000 00200000
       0000000000400000 0000000000000000  AX       0     0     4096
...

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 :

/*
* C representation of the x86/HVM start info layout.
*
* The canonical definition of this layout is above, this is just a way to
* represent the layout described there using C types.
*/
struct hvm_start_info {
    uint32_t magic;             /* Contains the magic value 0x336ec578       */
                                /* ("xEn3" with the 0x80 bit of the "E" set).*/
    uint32_t version;           /* Version of this structure.                */
    uint32_t flags;             /* SIF_xxx flags.                            */
    uint32_t nr_modules;        /* Number of modules passed to the kernel.   */
    uint64_t modlist_paddr;     /* Physical address of an array of           */
                                /* hvm_modlist_entry.                        */
    uint64_t cmdline_paddr;     /* Physical address of the command line.     */
    uint64_t rsdp_paddr;        /* Physical address of the RSDP ACPI data    */
                                /* structure.                                */
    /* All following fields only present in version 1 and newer */
    uint64_t memmap_paddr;      /* Physical address of an array of           */
                                /* hvm_memmap_table_entry.                   */
    uint32_t memmap_entries;    /* Number of entries in the memmap table.    */
                                /* Value will be zero if there is no memory */
                                /* map being provided.                       */
    uint32_t reserved;          /* Must be zero.                             */
};
 
struct hvm_modlist_entry {
    uint64_t paddr;             /* Physical address of the module.           */
    uint64_t size;              /* Size of the module in bytes.              */
    uint64_t cmdline_paddr;     /* Physical address of the command line.     */
    uint64_t reserved;
};
 
struct hvm_memmap_table_entry {
    uint64_t addr;              /* Base address of the memory region         */
    uint64_t size;              /* Size of the memory region in bytes        */
    uint32_t type;              /* Mapping type                              */
    uint32_t reserved;          /* Must be zero for Version 1.               */
};

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 :

define HVM_START_INFO_SIZE sizeof(struct hvm_start_info)

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 son contenu dans ESI
* comme adresse "source" */
movl %ebx, %esi
/* on copie l'adresse de fin du noyau dans
* EDI comme adresse "destination" */
movl $RELOC(__kernel_end), %edi
/* on copie l'adresse de fin du noyau
* dans EDI comme adresse "destination" */
movl $HVM_START_INFO_SIZE, %ecx
/* on décrémente ECX en le divisant par
* 4 (2²) parce que nous allons copier
* 32 bits par 32 bits (4 octets) */
shrl $2, %ecx
/* on répète la copie de 2 mots
* (32 bits, ou 4 octets) jusqu'à ce
* que ECX soit égal à 0 */
rep movsl

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 :

define CMDLINE_PADDR offsetof(struct hvm_start_info, cmdline_paddr)

offsetof() comme son nom l’indique, donne l’offset du second argument par rapport au premier.

/* la source de la copie sera à l'adresse
* du début de la ligne de commande */
movl CMDLINE_PADDR(%ebx), %esi
/* on enregistre l'adresse de fin du noyau */
movl $RELOC(__kernel_end), %ecx
/* et on copie EDI (situé à la fin de start_info)
* comme adresse de la ligne de commande à l'endroit
* de la variable cmdline_paddr */
movl %edi, CMDLINE_PADDR(%ecx)
.cmdline_copy:
/* on copie 1 octet (un caractère) depuis
* l'adresse source */
movb (%esi), %al
/* puis on copie ce caractère dans la
* destination (EDI) */
movsb
cmp $0, %al /* était-ce un \0 ? */
jne .cmdline_copy /* sinon on poursuit la copie */

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 :

define MMAP_PADDR      offsetof(struct hvm_start_info, memmap_paddr)

Le nombre d’entrées dans ce tableau, qu’on localise de la même façon :

define MMAP_ENTRIES        offsetof(struct hvm_start_info, memmap_entries)

Et la taille d’une de ces entrées :

define MMAP_ENTRY_SIZE     sizeof(struct hvm_memmap_table_entry)

Et on copie le tableau :

/* la source de la copie est l'adresse
* du début du tableau */
movl MMAP_PADDR(%ebx), %esi
/* on sauvegarde l'adresse de fin du noyau
* dans ECX */
movl $RELOC(__kernel_end), %ecx
/* la destination est à la fin de la
* précédente copie */
movl %edi, MMAP_PADDR(%ecx)
/* on récupère le nombre d'entrées dans
* le tableau */
movl MMAP_ENTRIES(%ebx), %eax
/* ainsi que la taille de chaque entrée */
movl $MMAP_ENTRY_SIZE, %ebx
/* on multiplie le nombre d'entrées par
* la taille d'une entrée */
mull %ebx
/* on copie le compteur dans ECX */
movl %eax, %ecx
/* puis on divise par 32 bits par un décalage
* à droite, de façon à correspondre à la copie
* qui suit, 32 bits par 32 bits */
shrl $2, %ecx
/* on copie le contenu de l'adresse pointée par
* ESI dans l'adresse pointée par EDI, 32 bits à
* la fois, on répète cette opération ECX fois */
rep movsl

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.

movl $RELOC(__kernel_end), %ebx

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 :

(gdb) p/x $ebx
$2 = 0x21e0
(gdb) p/x &start_xen32
$1 = 0xffffffff8020b450
(gdb) b *0x20b450
Breakpoint 1 at 0x20b450
(gdb) x/16wx $rbx
0x21e0: 0x336ec578      0x00000001      0x00000000      0x00000000
0x21f0: 0x00000000      0x00000000      0x000011c0      0x00000000
0x2200: 0x000f59d0      0x00000000      0x000005a8      0x00000000
0x2210: 0x00000007      0x00000000      0x00000000      0x00000000

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 :

(gdb) x/10s 0x000011c0
0x11c0: "console=com rw -v"

La liste des plages de mémoire est à l’adresse 0x5a8 et elles sont au nombre de 7.

(gdb) x/16x 0x5a8
0x5a8: 0x00000000      0x00000000      0x0009fc00      0x00000000
0x5b8: 0x00000001      0x00000000      0x0009fc00      0x00000000
0x5c8: 0x00000400      0x00000000      0x00000002      0x00000000
0x5d8: 0x000f0000      0x00000000      0x00010000      0x00000000

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 :

(gdb) p &compat
$3 = (<text variable, no debug info> *) 0xffffffff8020b38f <start+911>
(gdb) b *0x20b38f
Breakpoint 2 at 0x20b38f
(gdb) c
Continuing.
 
Breakpoint 2, 0x000000000020b38f in ?? ()

Première victoire ! Et que trouve-t-on après la fin du noyau ?

(gdb) x/16wx &__kernel_end
0xffffffff80a00000:     0x336ec578      0x00000001      0x00000000      0x00000000
0xffffffff80a00010:     0x00000000      0x00000000      0x00a00038      0x00000000
0xffffffff80a00020:     0x000f59d0      0x00000000      0x00a00047      0x00000000
0xffffffff80a00030:     0x00000007      0x00000000      0x6320762d      0x6f736e6f

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 :

(gdb) x/s 0x00a00038
0xa00038:       "-v console=com"

De la même façon, le tableau des adresses physiques disponibles est à l’adresse 0x00a00047 :

(gdb) x/16x 0xa00047
0xa00047:       0x00000000      0x00000000      0x0009fc00      0x00000000
0xa00057:       0x00000001      0x00000000      0x0009fc00      0x00000000
0xa00067:       0x00000400      0x00000000      0x00000002      0x00000000
0xa00077:       0x000f0000      0x00000000      0x00010000      0x00000000

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



Article rédigé par

Abonnez-vous maintenant

et profitez de tous les contenus en illimité

Je découvre les offres

Déjà abonné ? Connectez-vous