Toutes les mesures sous Linux des chapitres précédents ont été faites dans une machine virtuelle. Sur le Mac où ce chapitre a été écrit, uname -a répond :
Darwin studio.local 25.6.0 Darwin Kernel Version 25.6.0: … RELEASE_ARM64_T6020 arm64
et la même commande dans un conteneur Docker répond :
Linux 84ef2fddeaae 6.12.76-linuxkit #1 SMP Thu Apr 30 11:19:05 UTC 2026 aarch64 GNU/Linux
Deux noyaux différents, qui tournent en même temps sur le même M2. Celui de Linux croit disposer de 24 CPU, de 8 Gio de RAM, de disques et d’une carte réseau. Ce qu’il a vraiment, c’est une part du Mac, distribuée par un hyperviseur : le logiciel qui crée et exécute des machines virtuelles, dont chacune ressemble, pour son système d’exploitation, à un ordinateur complet.
Pourquoi virtualiser
La motivation classique est celle de l’hébergeur : plutôt que d’acheter un serveur par client, faire tourner les systèmes de nombreux clients sur un même serveur, et n’ajouter du matériel que lorsque les machines existantes sont pleines. C’est le cloud d’aujourd’hui. Chaque machine virtuelle est isolée des autres comme si elle était sur un matériel séparé, peut recevoir une part précise de CPU, de mémoire et d’E/S, peut être photographiée (snapshot), et peut être déplacée vers un autre serveur physique pendant qu’elle tourne. Pour les particuliers, l’autre raison est de faire tourner plusieurs systèmes d’exploitation à la fois : c’est ainsi que Docker fait tourner des conteneurs Linux sur un Mac, dont le noyau n’est pas Linux.
Hyperviseurs de type 1 et de type 2
On classe traditionnellement les hyperviseurs selon leur place :
- Un hyperviseur de type 1 tourne directement sur le matériel, à la place d’un système d’exploitation, et chaque système tourne au-dessus comme invité : VMware ESXi, Xen, Microsoft Hyper-V. C’est ce que font tourner les serveurs du cloud.
- Un hyperviseur de type 2 tourne comme un programme au-dessus d’un système hôte normal : VirtualBox, VMware Workstation et Fusion, Parallels.
La frontière s’est brouillée. KVM fait du noyau Linux lui-même un hyperviseur : chaque VM est un processus Linux ordinaire, et ses CPU virtuels des threads. macOS offre le même genre de service du noyau aux applications, par son framework Hypervisor, avec au-dessus un framework Virtualization de plus haut niveau. C’est lui qui fait tourner la VM de Docker ici : la liste des processus du Mac montre com.apple.Virtualization.VirtualMachine, un processus du framework d’Apple, et sysctl kern.hv_support répond 1.
Déroutement et émulation
Le noyau de l’invité s’attend à tourner en mode noyau, maître des tables de pages, des interruptions et des périphériques. L’hyperviseur ne peut pas le lui permettre : il prendrait le contrôle de la vraie machine. La solution classique, celle de CP/CMS puis de VM/370 chez IBM à la fin des années 1960 et au début des années 1970, est le déroutement et l’émulation (trap and emulate). Le noyau invité tourne sans privilèges, en mode utilisateur. Ses instructions ordinaires (additions, chargements, branchements) s’exécutent directement sur le matériel, à pleine vitesse. Quand il exécute une instruction privilégiée, comme charger le registre de la table des pages ou masquer les interruptions, le CPU déroute, exactement comme pour un programme utilisateur qui s’y essaierait, et le déroutement arrive à l’hyperviseur. Celui-ci effectue l’opération sur l’état virtuel de l’invité et rend la main. L’invité ne s’aperçoit de rien.
On peut voir le même mécanisme, un niveau plus bas, dans la VM Linux utilisée ici. MIDR_EL1, le registre ARM qui identifie le modèle de CPU, ne se lit qu’en mode noyau. Un programme utilisateur qui exécute mrs x0, midr_el1 provoque une exception vers le noyau, et Linux, au lieu de tuer le programme, émule l’instruction : il place la valeur dans x0 et reprend après l’instruction.
unsigned long v;
__asm__ volatile("mrs %0, midr_el1" : "=r"(v)); // privileged, but Linux emulates it
printf("read %#lx\n", v);
Dans la VM Linux, il a affiché read 0x610f0000 (0x61 est le code de fabricant d’Apple), et chaque lecture a coûté environ 118 ns, un peu moins qu’un vrai appel système dans la même VM (154 ns). Lire CurrentEL, que Linux n’émule pas, a tué le programme avec SIGILL, tout comme les deux lectures sous macOS, qui n’émule ni l’une ni l’autre. Un hyperviseur fait au noyau invité ce que ce noyau fait à son programme.
Quand l’architecture ne coopère pas
En 1974, Popek et Goldberg ont énoncé la condition pour que le déroutement et l’émulation fonctionnent. Appelons sensibles les instructions qui lisent ou modifient l’état de contrôle de la machine (niveau de privilège, masque d’interruptions, correspondance mémoire), et privilégiées celles qui déroutent quand on les exécute en mode utilisateur. Une architecture est virtualisable efficacement si toute instruction sensible est privilégiée : alors toute opération que l’hyperviseur doit intercepter déroute, et tout le reste peut s’exécuter directement.
Le x86 32 bits échouait à ce test. Une étude de 2000 a compté 17 instructions sensibles qui ne déroutaient pas. POPF est l’exemple classique : en mode noyau, elle peut modifier l’indicateur d’autorisation des interruptions ; en mode utilisateur, la même instruction laisse silencieusement cet indicateur inchangé. Pas de déroutement, si bien qu’un noyau invité privé de privilèges qui masque les interruptions avec elle ne les masque pas, et l’hyperviseur n’en sait rien. SGDT, SIDT et SMSW laissent le code utilisateur lire les adresses des tables de descripteurs et des bits de contrôle, si bien qu’un invité pouvait voir les vraies valeurs de l’hyperviseur au lieu des siennes.
Deux contournements logiciels ont malgré tout rendu la virtualisation du x86 praticable :
- La traduction binaire, l’approche de VMware dès 1999 : l’hyperviseur examine le code du noyau invité avant son exécution et réécrit les instructions problématiques en séquences sûres qui appellent l’hyperviseur, en gardant le code traduit en cache. Le code invité en mode utilisateur tourne sans modification.
- La paravirtualisation, l’approche de Xen en 2003 : modifier le système invité pour qu’il n’utilise plus du tout ces instructions et appelle directement l’hyperviseur, par des hyperappels (hypercalls), quand il a besoin d’une opération privilégiée. Rapide, mais il faut un noyau modifié.
Le soutien du matériel
Intel et AMD ont ensuite corrigé le problème dans le matériel : VT-x en 2005 et AMD-V en 2006. Plutôt que de faire dérouter chaque instruction sensible dans l’anneau 3, ils ont ajouté une nouvelle dimension. Le CPU tourne soit en mode VMX root, pour l’hyperviseur, soit en mode VMX non-root, pour les invités, et chaque mode a ses propres anneaux 0 à 3. Un noyau invité tourne vraiment dans l’anneau 0, mais celui du mode non-root. Des événements configurables provoquent une sortie de VM (VM exit) vers l’hyperviseur : instructions sensibles, accès d’E/S choisis, certaines interruptions, défauts dans les tables de second niveau. L’hyperviseur décrit tout cela dans une VMCS (virtual machine control structure), qui contient aussi l’état sauvegardé de l’invité, et relance l’invité par une entrée de VM.
ARM a intégré la virtualisation à ses niveaux de privilège. Depuis les extensions de virtualisation d’ARMv7 (où il s’appelait mode Hyp), et dans pratiquement tous les cœurs d’application ARMv8 et ARMv9, il existe un EL2 entre l’EL1 du noyau invité et l’EL3 du micrologiciel sécurisé : l’hyperviseur tourne en EL2, et des registres de ce niveau décident quelles opérations de l’invité lui sont déroutées. La valeur de MIDR_EL1 ci-dessus illustre ce que contrôle l’EL2 : quand un noyau invité lit ce registre, le matériel renvoie une valeur choisie par l’hyperviseur. ARMv8.1 a ajouté VHE (virtualization host extensions) pour qu’un noyau hôte comme Linux avec KVM puisse tourner entièrement en EL2. RISC-V a son extension H dans le même but.
La mémoire : deux traductions
Un système invité gère des tables de pages qui traduisent les adresses virtuelles de ses processus vers ce qu’il croit être des adresses physiques. Mais ces adresses « physiques de l’invité » ne sont qu’une plage de la mémoire de l’hyperviseur, qu’il faut traduire à nouveau en adresses physiques de l’hôte. Avant l’aide du matériel, les hyperviseurs tenaient des tables de pages fantômes (shadow page tables) : une table combinée, du virtuel de l’invité au physique de l’hôte, maintenue à jour en déroutant chaque modification que l’invité faisait à ses propres tables : correct, mais coûteux.
Le matériel fait désormais les deux traductions. Les EPT d’Intel (extended page tables, 2008) et les NPT d’AMD (nested page tables) ajoutent un second arbre, propriété de l’hyperviseur, que la MMU applique à chaque adresse physique de l’invité, y compris aux adresses des tables de pages de l’invité lui-même. Lors d’un échec TLB, chacun des quatre niveaux du parcours de l’invité doit voir son adresse traduite à travers les quatre niveaux de l’arbre de l’hôte, plus l’adresse finale de la donnée : jusqu’à 24 accès mémoire au lieu de 4. Le TLB garde le résultat combiné, si bien que la plupart des accès ne paient rien. Mais c’est une des raisons pour lesquelles la chasse aux pointeurs du chapitre sur la mémoire virtuelle coûtait 30 ns par lecture avec des pages de 4 Kio dans cette VM, et pour lesquelles les grandes pages aident les VM encore plus que les systèmes natifs. ARM appelle sa version la traduction de niveau 2 (stage-2 translation).
Les E/S : émulées, paravirtuelles ou directes
L’hyperviseur le plus simple intercepte chaque accès d’E/S de l’invité et l’exécute en logiciel. C’est l’approche la plus compatible (l’invité voit un périphérique familier, comme une ancienne carte réseau, et son pilote non modifié fonctionne) et la plus lente, puisque chaque accès à un registre est une sortie de VM. Deux alternatives dominent aujourd’hui.
Les périphériques paravirtuels renoncent à imiter du vrai matériel. L’invité utilise des pilotes écrits pour un périphérique virtuel dont l’interface est conçue pour être peu coûteuse : les requêtes vont dans des anneaux de tampons en mémoire partagée, et l’invité ne prévient l’hyperviseur qu’une fois par lot. La norme en la matière est virtio. Les périphériques de la VM de Docker, listés dans /sys/bus/virtio/devices, sont tous virtio : un périphérique réseau, deux périphériques bloc (des disques), une console, une source d’entropie, un ballon mémoire (qui permet à l’hôte de reprendre de la mémoire à l’invité), un périphérique de sockets pour communiquer entre hôte et invité, et six périphériques virtio-fs, qui partagent des répertoires du Mac avec la VM : le -v "$PWD":/w de chaque commande Docker de ces chapitres.
Le passage direct (device passthrough) confie à une VM un vrai périphérique, protégé par l’IOMMU, qui traduit et restreint les adresses DMA du périphérique comme la MMU le fait pour celles du CPU. Avec SR-IOV, une carte réseau ou un SSD se présente comme de nombreuses fonctions virtuelles, chacune attribuée à une VM différente, si bien que le pilote de l’invité parle au matériel sans l’hyperviseur sur le chemin. Les fournisseurs de cloud s’appuient là-dessus pour des E/S quasi natives.
Ce que ça coûte
Avec le soutien du matériel, le code non privilégié d’un invité s’exécute directement sur le CPU. Un appel de fonction a pris 0,9 ns sous macOS et 0,9 ns dans la VM Linux, et les additions atomiques à un seul thread du chapitre sur les threads ont pris les mêmes 2 et 4 ns des deux côtés. Les coûts apparaissent là où l’hyperviseur intervient, ou là où le matériel fait un double travail :
- Les appels système ne passent pas par l’hyperviseur : ils vont directement au noyau invité.
getppida pris 154 à 155 ns dans la VM Linux contre 95 ns sous macOS, mais cela compare deux noyaux différents, pas une VM et une machine nue. - Les échecs TLB parcourent deux jeux de tables, comme on l’a vu.
- La mémoire que l’invité touche pour la première fois peut aussi solliciter l’hyperviseur : un défaut de page dans l’invité, puis un défaut de niveau 2 si l’hôte n’a pas encore fourni cette page physique de l’invité.
- Les E/S passent par l’hyperviseur ou un processus auxiliaire. Un
fsyncde 4 Kio a pris 740 µs dans la VM, contre 40 µs pour unfsyncnatif ; le disque virtuel est un fichier sur le Mac, et le vidage doit aller jusqu’au bout.
Pour la plupart des charges, le résultat est à quelques pour cent du natif. Pour celles qui font beaucoup d’E/S, tout dépend de la façon dont les périphériques sont virtualisés.
Les conteneurs ne sont pas des machines virtuelles
Un conteneur Docker ressemble lui aussi à une petite machine, mais ce n’est pas du matériel virtualisé. C’est un groupe de processus ordinaires, sur le même noyau que tout le reste, auxquels le noyau montre une vue restreinte du système grâce aux espaces de noms (namespaces), et qu’il limite grâce aux cgroups. Dans un conteneur :
$ echo $$
1
$ ls /proc/self/ns
cgroup ipc mnt net pid pid_for_children time time_for_children user uts
Le shell se croit processus 1, parce qu’il est le premier processus de son propre espace de noms de PID ; le noyau de la VM le connaît sous un autre numéro. Les autres espaces de noms lui donnent sa propre table de montage, sa pile réseau, son nom d’hôte, ses objets d’IPC et sa vue des cgroups. Lancé avec --memory 256m --cpus 2, le conteneur voit dans ses fichiers de cgroup 268435456 pour memory.max et 200000 100000 pour cpu.max : 200 ms de CPU par période de 100 ms, soit l’équivalent de deux cœurs. L’expérience d’écroulement du chapitre sur la mémoire virtuelle utilisait exactement cette limite de mémoire.
Comme les conteneurs partagent le noyau, uname dans n’importe quel conteneur de ce Mac affiche le même 6.12.76-linuxkit : l’unique noyau de la VM, partagé par tous les conteneurs. C’est pourquoi les conteneurs démarrent en quelques millisecondes et ne coûtent presque rien, mais aussi pourquoi ils isolent moins que des VM (une faille du noyau expose tous les conteneurs de la machine), et pourquoi les conteneurs Linux sous macOS ou Windows ont besoin d’une VM Linux en dessous. Quand l’isolation compte, on combine les deux : Firecracker, chez AWS, fait tourner chaque fonction serverless ou conteneur dans sa propre VM minuscule, qui démarre en une fraction de seconde.
À retenir
- Un hyperviseur fait tourner des machines virtuelles, chacune avec son système. Le type 1 tourne sur le matériel (ESXi, Xen, Hyper-V), le type 2 sur un système hôte ; KVM et le framework Hypervisor de macOS font du noyau hôte l’hyperviseur.
- Déroutement et émulation : le code invité s’exécute directement, et les opérations privilégiées déroutent vers l’hyperviseur. Cela ne marche que si toute instruction sensible est privilégiée (Popek et Goldberg, 1974). Le x86 32 bits avait 17 exceptions, comme
POPF. - Avant le soutien du matériel, la traduction binaire (VMware) et la paravirtualisation (Xen) contournaient le x86. VT-x et AMD-V ont ajouté les modes root et non-root avec des sorties de VM ; ARM a l’EL2.
- Les EPT/NPT (le niveau 2 d’ARM) traduisent en matériel les adresses physiques de l’invité en adresses physiques de l’hôte ; un échec TLB peut demander jusqu’à 24 accès mémoire.
- Les E/S sont émulées, paravirtuelles (virtio : la VM de Docker a ici 13 périphériques virtio) ou passées directement grâce à une IOMMU et à SR-IOV.
- Le code non privilégié tourne à vitesse native ; les coûts viennent des sorties de VM, des parcours de tables imbriqués et des E/S.
- Les conteneurs sont des processus isolés par des espaces de noms et limités par des cgroups, qui partagent un noyau : tous les conteneurs de ce Mac tournent sur le Linux 6.12 de la VM.