Hôte KVM en quelques lignes de code

salut!



Aujourd'hui, nous publions un article sur la façon d'écrire un hôte KVM. Nous l'avons vu sur le blog de Serge Zaitsev, l' avons traduit et complété par nos propres exemples en Python pour ceux qui ne travaillent pas avec C ++.


KVM (Kernel-based Virtual Machine) est une technologie de virtualisation fournie avec le noyau Linux. En d'autres termes, KVM vous permet d'exécuter plusieurs machines virtuelles (VM) sur un seul hôte virtuel Linux. Les machines virtuelles dans ce cas sont appelées des invités. Si vous avez déjà utilisé QEMU ou VirtualBox sous Linux, vous savez de quoi KVM est capable.



Mais comment ça marche sous le capot?



IOCTL



KVM expose l' API via un fichier de périphérique spécial / dev / kvm . Lorsque vous démarrez un périphérique, vous accédez au sous-système KVM, puis effectuez des appels système ioctl pour allouer des ressources et démarrer des machines virtuelles. Certains appels ioctl renvoient des descripteurs de fichier, qui peuvent également être manipulés avec ioctl. Et ainsi de suite à l'infini? Pas vraiment. Il n'y a que quelques niveaux d'API dans KVM:



  • le niveau / dev / kvm utilisé pour gérer l'ensemble du sous-système KVM et pour créer de nouvelles machines virtuelles,
  • la couche VM utilisée pour gérer une machine virtuelle individuelle,
  • Niveau de VCPU utilisé pour contrôler le fonctionnement d'un processeur virtuel (une machine virtuelle peut s'exécuter sur plusieurs processeurs virtuels) - VCPU.


De plus, il existe des API pour les périphériques d'E / S.



Voyons à quoi cela ressemble dans la pratique.



// KVM layer
int kvm_fd = open("/dev/kvm", O_RDWR);
int version = ioctl(kvm_fd, KVM_GET_API_VERSION, 0);
printf("KVM version: %d\n", version);

// Create VM
int vm_fd = ioctl(kvm_fd, KVM_CREATE_VM, 0);

// Create VM Memory
#define RAM_SIZE 0x10000
void *mem = mmap(NULL, RAM_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE, -1, 0);
struct kvm_userspace_memory_region mem = {
	.slot = 0,
	.guest_phys_addr = 0,
	.memory_size = RAM_SIZE,
	.userspace_addr = (uintptr_t) mem,
};
ioctl(vm_fd, KVM_SET_USER_MEMORY_REGION, &mem);

// Create VCPU
int vcpu_fd = ioctl(vm_fd, KVM_CREATE_VCPU, 0);


Exemple Python:



with open('/dev/kvm', 'wb+') as kvm_fd:
    # KVM layer
    version = ioctl(kvm_fd, KVM_GET_API_VERSION, 0)
    if version != 12:
        print(f'Unsupported version: {version}')
        sys.exit(1)

    # Create VM
    vm_fd = ioctl(kvm_fd, KVM_CREATE_VM, 0)

    # Create VM Memory
    mem = mmap(-1, RAM_SIZE, MAP_PRIVATE | MAP_ANONYMOUS, PROT_READ | PROT_WRITE)
    pmem = ctypes.c_uint.from_buffer(mem)
    mem_region = UserspaceMemoryRegion(slot=0, flags=0,
                                       guest_phys_addr=0, memory_size=RAM_SIZE,
                                       userspace_addr=ctypes.addressof(pmem))
    ioctl(vm_fd, KVM_SET_USER_MEMORY_REGION, mem_region)

    # Create VCPU
    vcpu_fd = ioctl(vm_fd, KVM_CREATE_VCPU, 0);


Dans cette étape, nous avons créé une nouvelle machine virtuelle, lui avons alloué de la mémoire et attribué un processeur virtuel. Pour que notre machine virtuelle exécute réellement quelque chose, nous devons charger l'image de la machine virtuelle et configurer correctement les registres du processeur.



Chargement de la machine virtuelle



C'est assez simple! Lisez simplement le fichier et copiez son contenu dans la mémoire de la machine virtuelle. Bien sûr, mmap est également une bonne option.



int bin_fd = open("guest.bin", O_RDONLY);
if (bin_fd < 0) {
	fprintf(stderr, "can not open binary file: %d\n", errno);
	return 1;
}
char *p = (char *)ram_start;
for (;;) {
	int r = read(bin_fd, p, 4096);
	if (r <= 0) {
		break;
	}
	p += r;
}
close(bin_fd);


Exemple Python:



    # Read guest.bin
    guest_bin = load_guestbin('guest.bin')
    mem[:len(guest_bin)] = guest_bin


On suppose que guest.bin contient un octet-code valide pour l'architecture actuelle du processeur, car le KVM n'interprète pas les instructions du processeur, l'une après l'autre, comme le faisait l'ancienne machine virtuelle. KVM donne le calcul au CPU réel et n'intercepte que les E / S. C'est pourquoi les machines virtuelles modernes fonctionnent à des performances élevées, proches du bare metal, sauf si vous effectuez des opérations d'E / S lourdes.



Voici le petit noyau de VM invité que nous allons essayer d'exécuter en premier: Si vous n'êtes pas familier avec l'assembleur, l'exemple ci-dessus est un petit exécutable 16 bits qui incrémente un registre dans une boucle et renvoie une valeur sur le port 0x10.



#

# Build it:

#

# as -32 guest.S -o guest.o

# ld -m elf_i386 --oformat binary -N -e _start -Ttext 0x10000 -o guest guest.o

#

.globl _start

.code16

_start:

xorw %ax, %ax

loop:

out %ax, $0x10

inc %ax

jmp loop








Nous l'avons délibérément compilée comme une application 16 bits archaïque, car le processeur virtuel KVM lancé peut fonctionner dans plusieurs modes, comme un vrai processeur x86. Le mode le plus simple est le mode "réel", qui a été utilisé pour exécuter du code 16 bits depuis le siècle dernier. Le mode réel diffère dans l'adressage mémoire, il est direct au lieu d'utiliser des tables de descripteurs - il serait plus facile d'initialiser notre registre pour le mode réel:



struct kvm_sregs sregs;
ioctl(vcpu_fd, KVM_GET_SREGS, &sregs);
// Initialize selector and base with zeros
sregs.cs.selector = sregs.cs.base = sregs.ss.selector = sregs.ss.base = sregs.ds.selector = sregs.ds.base = sregs.es.selector = sregs.es.base = sregs.fs.selector = sregs.fs.base = sregs.gs.selector = 0;
// Save special registers
ioctl(vcpu_fd, KVM_SET_SREGS, &sregs);

// Initialize and save normal registers
struct kvm_regs regs;
regs.rflags = 2; // bit 1 must always be set to 1 in EFLAGS and RFLAGS
regs.rip = 0; // our code runs from address 0
ioctl(vcpu_fd, KVM_SET_REGS, &regs);


Exemple Python:



    sregs = Sregs()
    ioctl(vcpu_fd, KVM_GET_SREGS, sregs)
    # Initialize selector and base with zeros
    sregs.cs.selector = sregs.cs.base = sregs.ss.selector = sregs.ss.base = sregs.ds.selector = sregs.ds.base = sregs.es.selector = sregs.es.base = sregs.fs.selector = sregs.fs.base = sregs.gs.selector = 0
    # Save special registers
    ioctl(vcpu_fd, KVM_SET_SREGS, sregs)

    # Initialize and save normal registers
    regs = Regs()
    regs.rflags = 2  # bit 1 must always be set to 1 in EFLAGS and RFLAGS
    regs.rip = 0  # our code runs from address 0
    ioctl(vcpu_fd, KVM_SET_REGS, regs)


Fonctionnement



Le code est chargé, les registres sont prêts. Commençons? Pour démarrer une machine virtuelle, nous devons obtenir un pointeur vers "l'état d'exécution" pour chaque CPU virtuel, puis entrer une boucle dans laquelle la machine virtuelle fonctionnera jusqu'à ce qu'elle soit interrompue par des E / S ou autre opérations où le contrôle sera transféré à l'hôte.



int runsz = ioctl(kvm_fd, KVM_GET_VCPU_MMAP_SIZE, 0);
struct kvm_run *run = (struct kvm_run *) mmap(NULL, runsz, PROT_READ | PROT_WRITE, MAP_SHARED, vcpu_fd, 0);

for (;;) {
	ioctl(vcpu_fd, KVM_RUN, 0);
	switch (run->exit_reason) {
	case KVM_EXIT_IO:
		printf("IO port: %x, data: %x\n", run->io.port, *(int *)((char *)(run) + run->io.data_offset));
		break;
	case KVM_EXIT_SHUTDOWN:
		return;
	}
}


Exemple Python:



    runsz = ioctl(kvm_fd, KVM_GET_VCPU_MMAP_SIZE, 0)
    run_buf = mmap(vcpu_fd, runsz, MAP_SHARED, PROT_READ | PROT_WRITE)
    run = Run.from_buffer(run_buf)

    try:
        while True:
            ret = ioctl(vcpu_fd, KVM_RUN, 0)
            if ret < 0:
                print('KVM_RUN failed')
                return
             if run.exit_reason == KVM_EXIT_IO:
                print(f'IO port: {run.io.port}, data: {run_buf[run.io.data_offset]}')
             elif run.exit_reason == KVM_EXIT_SHUTDOWN:
                return
              time.sleep(1)
    except KeyboardInterrupt:
        pass


Maintenant, si nous exécutons l'application, nous verrons: Fonctionne! Le code source complet est disponible à l' adresse suivante (si vous constatez une erreur, les commentaires sont les bienvenus!).



IO port: 10, data: 0

IO port: 10, data: 1

IO port: 10, data: 2

IO port: 10, data: 3

IO port: 10, data: 4

...








Vous l'appelez le noyau?



Très probablement, tout cela n'est pas très impressionnant. Que diriez-vous d'exécuter le noyau Linux à la place?



Le début sera le même: ouvrir / dev / kvm , créer une machine virtuelle, etc. Cependant, nous avons besoin de quelques appels ioctl supplémentaires au niveau de la machine virtuelle pour ajouter un minuteur d'intervalle périodique, initialiser TSS (requis pour les puces Intel) et ajouter un contrôleur d'interruption:



ioctl(vm_fd, KVM_SET_TSS_ADDR, 0xffffd000);
uint64_t map_addr = 0xffffc000;
ioctl(vm_fd, KVM_SET_IDENTITY_MAP_ADDR, &map_addr);
ioctl(vm_fd, KVM_CREATE_IRQCHIP, 0);
struct kvm_pit_config pit = { .flags = 0 };
ioctl(vm_fd, KVM_CREATE_PIT2, &pit);


Nous devrons également changer la façon dont les registres sont initialisés. Le noyau Linux a besoin du mode protégé, nous l'activons donc dans les drapeaux de registre et initialisons la base, le sélecteur, la granularité pour chaque cas particulier:



sregs.cs.base = 0;
sregs.cs.limit = ~0;
sregs.cs.g = 1;

sregs.ds.base = 0;
sregs.ds.limit = ~0;
sregs.ds.g = 1;

sregs.fs.base = 0;
sregs.fs.limit = ~0;
sregs.fs.g = 1;

sregs.gs.base = 0;
sregs.gs.limit = ~0;
sregs.gs.g = 1;

sregs.es.base = 0;
sregs.es.limit = ~0;
sregs.es.g = 1;

sregs.ss.base = 0;
sregs.ss.limit = ~0;
sregs.ss.g = 1;

sregs.cs.db = 1;
sregs.ss.db = 1;
sregs.cr0 |= 1; // enable protected mode

regs.rflags = 2;
regs.rip = 0x100000; // This is where our kernel code starts
regs.rsi = 0x10000; // This is where our boot parameters start


Quels sont les paramètres de démarrage et pourquoi ne pouvez-vous pas simplement démarrer le noyau à l'adresse zéro? Il est temps d'en savoir plus sur le format bzImage.



L'image du noyau suit un "protocole de démarrage" spécial où il y a un en-tête fixe avec des paramètres de démarrage suivi du bytecode du noyau réel. Le format de l'en-tête de démarrage est décrit ici .



Chargement d'une image de noyau



Afin de charger correctement l'image du noyau dans la machine virtuelle, nous devons d'abord lire l'intégralité du fichier bzImage. Nous regardons l'offset 0x1f1 et obtenons le nombre de secteurs de l'installation à partir de là. Nous les ignorerons pour voir où commence le code du noyau. De plus, nous allons copier les paramètres de démarrage du début de bzImage dans la zone mémoire pour les paramètres de démarrage de la machine virtuelle (0x10000).



Mais même cela ne suffira pas. Nous devrons corriger les paramètres de démarrage de notre machine virtuelle pour la forcer en mode VGA et initialiser le pointeur de ligne de commande.



Notre noyau doit écrire des journaux sur ttyS0 afin que nous puissions intercepter les E / S et notre machine virtuelle les imprime sur stdout. Pour ce faire, nous devons ajouter "console = ttyS0" à la ligne de commande du noyau.



Mais même après cela, nous n'obtiendrons aucun résultat. J'ai dû définir un faux identifiant CPU pour notre noyau (https://www.kernel.org/doc/Documentation/virtual/kvm/cpuid.txt). Très probablement, le noyau que j'ai assemblé s'est appuyé sur ces informations pour déterminer s'il fonctionnait à l'intérieur d'un hyperviseur ou sur du métal nu.



J'ai utilisé un noyau compilé avec une configuration "minuscule" et mis en place quelques indicateurs de configuration pour prendre en charge le terminal et virtio (framework de virtualisation d'E / S pour Linux).



Le code complet de l'hôte KVM modifié et l'image du noyau de test sont disponibles ici .



Si cette image ne démarre pas, vous pouvez utiliser une autre image disponible sur ce lien .


Si nous le compilons et l'exécutons, nous obtenons la sortie suivante:



Linux version 5.4.39 (serge@melete) (gcc version 7.4.0 (Ubuntu 7.4.0-1ubuntu1~16.04~ppa1)) #12 Fri May 8 16:04:00 CEST 2020
Command line: console=ttyS0
Intel Spectre v2 broken microcode detected; disabling Speculation Control
Disabled fast string operations
x86/fpu: Supporting XSAVE feature 0x001: 'x87 floating point registers'
x86/fpu: Supporting XSAVE feature 0x002: 'SSE registers'
x86/fpu: Supporting XSAVE feature 0x004: 'AVX registers'
x86/fpu: xstate_offset[2]:  576, xstate_sizes[2]:  256
x86/fpu: Enabled xstate features 0x7, context size is 832 bytes, using 'standard' format.
BIOS-provided physical RAM map:
BIOS-88: [mem 0x0000000000000000-0x000000000009efff] usable
BIOS-88: [mem 0x0000000000100000-0x00000000030fffff] usable
NX (Execute Disable) protection: active
tsc: Fast TSC calibration using PIT
tsc: Detected 2594.055 MHz processor
last_pfn = 0x3100 max_arch_pfn = 0x400000000
x86/PAT: Configuration [0-7]: WB  WT  UC- UC  WB  WT  UC- UC
Using GB pages for direct mapping
Zone ranges:
  DMA32    [mem 0x0000000000001000-0x00000000030fffff]
  Normal   empty
Movable zone start for each node
Early memory node ranges
  node   0: [mem 0x0000000000001000-0x000000000009efff]
  node   0: [mem 0x0000000000100000-0x00000000030fffff]
Zeroed struct page in unavailable ranges: 20322 pages
Initmem setup node 0 [mem 0x0000000000001000-0x00000000030fffff]
[mem 0x03100000-0xffffffff] available for PCI devices
clocksource: refined-jiffies: mask: 0xffffffff max_cycles: 0xffffffff, max_idle_ns: 7645519600211568 ns
Built 1 zonelists, mobility grouping on.  Total pages: 12253
Kernel command line: console=ttyS0
Dentry cache hash table entries: 8192 (order: 4, 65536 bytes, linear)
Inode-cache hash table entries: 4096 (order: 3, 32768 bytes, linear)
mem auto-init: stack:off, heap alloc:off, heap free:off
Memory: 37216K/49784K available (4097K kernel code, 292K rwdata, 244K rodata, 832K init, 916K bss, 12568K reserved, 0K cma-reserved)
Kernel/User page tables isolation: enabled
NR_IRQS: 4352, nr_irqs: 24, preallocated irqs: 16
Console: colour VGA+ 142x228
printk: console [ttyS0] enabled
APIC: ACPI MADT or MP tables are not detected
APIC: Switch to virtual wire mode setup with no configuration
Not enabling interrupt remapping due to skipped IO-APIC setup
clocksource: tsc-early: mask: 0xffffffffffffffff max_cycles: 0x25644bd94a2, max_idle_ns: 440795207645 ns
Calibrating delay loop (skipped), value calculated using timer frequency.. 5188.11 BogoMIPS (lpj=10376220)
pid_max: default: 4096 minimum: 301
Mount-cache hash table entries: 512 (order: 0, 4096 bytes, linear)
Mountpoint-cache hash table entries: 512 (order: 0, 4096 bytes, linear)
Disabled fast string operations
Last level iTLB entries: 4KB 64, 2MB 8, 4MB 8
Last level dTLB entries: 4KB 64, 2MB 0, 4MB 0, 1GB 4
CPU: Intel 06/3d (family: 0x6, model: 0x3d, stepping: 0x4)
Spectre V1 : Mitigation: usercopy/swapgs barriers and __user pointer sanitization
Spectre V2 : Spectre mitigation: kernel not compiled with retpoline; no mitigation available!
Speculative Store Bypass: Vulnerable
TAA: Mitigation: Clear CPU buffers
MDS: Mitigation: Clear CPU buffers
Performance Events: Broadwell events, 16-deep LBR, Intel PMU driver.
...


Evidemment, c'est quand même un résultat plutôt inutile: pas d'initrd ou de partition root, pas de vraies applications qui pourraient tourner dans ce noyau, mais cela prouve quand même que KVM n'est pas un outil si terrible et assez puissant.



Conclusion



Pour exécuter un Linux à part entière, l'hôte de la machine virtuelle doit être beaucoup plus avancé - nous devons modéliser plusieurs pilotes d'E / S pour les disques, le clavier et les graphiques. Mais l'approche générale reste la même, par exemple, nous devons configurer les paramètres de ligne de commande pour initrd de la même manière. Les disques devront intercepter les E / S et répondre de manière appropriée.



Cependant, personne ne vous oblige à utiliser directement KVM. Il existe libvirt , une belle bibliothèque conviviale pour les technologies de virtualisation de bas niveau comme KVM ou BHyve.



Si vous souhaitez en savoir plus sur KVM, je vous suggère de consulter la source kvmtool . Ils sont beaucoup plus faciles à lire que QEMU et l'ensemble du projet est beaucoup plus petit et plus simple.



J'espère que vous avez apprécié l'article.



Vous pouvez suivre l'actualité sur Github , Twitter ou vous abonner via rss .



Liens vers les exemples GitHub Gist avec Python d'un expert Timeweb: (1) et (2) .



All Articles