Hallo!
Heute veröffentlichen wir einen Artikel darüber, wie man einen KVM-Host schreibt. Wir haben es auf Serge Zaitsevs Blog gesehen , übersetzt und mit unseren eigenen Beispielen in Python für diejenigen ergänzt, die nicht mit C ++ arbeiten.
KVM (Kernel-basierte virtuelle Maschine) ist eine Virtualisierungstechnologie, die mit dem Linux-Kernel geliefert wird. Mit anderen Worten, mit KVM können Sie mehrere virtuelle Maschinen (VMs) auf einem einzelnen virtuellen Linux-Host ausführen. Virtuelle Maschinen werden in diesem Fall als Gäste bezeichnet. Wenn Sie jemals QEMU oder VirtualBox unter Linux verwendet haben, wissen Sie, wozu KVM in der Lage ist.
Aber wie funktioniert es unter der Haube?
IOCTL
KVM macht die API über eine spezielle Gerätedatei / dev / kvm verfügbar . Wenn Sie ein Gerät starten, greifen Sie auf das KVM-Subsystem zu und führen dann ioctl-Systemaufrufe durch, um Ressourcen zuzuweisen und virtuelle Maschinen zu starten. Einige ioctl-Aufrufe geben Dateideskriptoren zurück, die auch mit ioctl bearbeitet werden können. Und so weiter bis ins Unendliche? Nicht wirklich. In KVM gibt es nur wenige API-Ebenen:
- die Ebene / dev / kvm, die zum Verwalten des gesamten KVM-Subsystems und zum Erstellen neuer virtueller Maschinen verwendet wird.
- die VM-Schicht, die zum Verwalten einer einzelnen virtuellen Maschine verwendet wird,
- Die VCPU-Ebene, mit der der Betrieb eines virtuellen Prozessors gesteuert wird (eine virtuelle Maschine kann auf mehreren virtuellen Prozessoren ausgeführt werden) - VCPU.
Darüber hinaus gibt es APIs für E / A-Geräte.
Mal sehen, wie es in der Praxis aussieht.
// 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);
Python-Beispiel:
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);
In diesem Schritt haben wir eine neue virtuelle Maschine erstellt, Speicher dafür zugewiesen und eine vCPU zugewiesen. Damit unsere virtuelle Maschine tatsächlich etwas ausführen kann, müssen wir das Image der virtuellen Maschine laden und die Prozessorregister ordnungsgemäß konfigurieren.
Laden der virtuellen Maschine
Es ist einfach genug! Lesen Sie einfach die Datei und kopieren Sie ihren Inhalt in den Speicher der virtuellen Maschine. Natürlich ist mmap auch eine gute 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);
Python-Beispiel:
# Read guest.bin
guest_bin = load_guestbin('guest.bin')
mem[:len(guest_bin)] = guest_bin
Es wird davon ausgegangen, dass guest.bin einen gültigen Bytecode für die aktuelle CPU-Architektur enthält, da die KVM die CPU-Anweisungen nicht wie die alte virtuelle Maschine nacheinander interpretiert. KVM gibt die Berechnung an die reale CPU weiter und fängt nur E / A ab. Aus diesem Grund arbeiten moderne virtuelle Maschinen mit hoher Leistung in der Nähe von Bare-Metal, es sei denn, Sie führen schwere E / A-Vorgänge aus.
Hier ist der winzige Gast-VM-Kernel, den wir zuerst ausführen werden: Wenn Sie mit Assembler nicht vertraut sind, ist das obige Beispiel eine winzige ausführbare 16-Bit-Datei, die ein Register in einer Schleife inkrementiert und den Wert an Port 0x10 ausgibt.
#
# 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
Wir haben es bewusst als archaische 16-Bit-Anwendung kompiliert, da der gestartete virtuelle KVM-Prozessor wie ein echter x86-Prozessor in mehreren Modi betrieben werden kann. Der einfachste Modus ist der "echte" Modus, in dem seit dem letzten Jahrhundert 16-Bit-Code ausgeführt wird. Der Real-Modus unterscheidet sich in der Speicheradressierung. Er ist direkt, anstatt Deskriptortabellen zu verwenden. Es wäre einfacher, unser Register für den Real-Modus zu initialisieren:
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, ®s);
Python-Beispiel:
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)
Laufen
Der Code wird geladen, die Register sind bereit. Lass uns anfangen? Um eine virtuelle Maschine zu starten, müssen wir für jede vCPU einen Zeiger auf den "Ausführungsstatus" erhalten und dann eine Schleife eingeben, in der die virtuelle Maschine ausgeführt wird, bis sie durch E / A oder andere unterbrochen wird Vorgänge, bei denen die Steuerung zurück an den Host übertragen wird.
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;
}
}
Python-Beispiel:
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
Wenn wir nun die Anwendung ausführen, werden wir sehen: Funktioniert! Der vollständige Quellcode ist unter der folgenden Adresse verfügbar (wenn Sie einen Fehler bemerken, sind Kommentare willkommen!).
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
...
Nennen Sie es den Kern?
Höchstwahrscheinlich ist nichts davon sehr beeindruckend. Wie wäre es stattdessen mit dem Linux-Kernel?
Der Anfang ist der gleiche: open / dev / kvm , Erstellen einer virtuellen Maschine usw. Wir benötigen jedoch einige weitere ioctl-Aufrufe auf der Ebene der virtuellen Maschine, um einen periodischen Intervall-Timer hinzuzufügen, TSS (für Intel-Chips erforderlich) zu initialisieren und einen Interrupt-Controller hinzuzufügen:
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);
Wir müssen auch die Art und Weise ändern, wie die Register initialisiert werden. Der Linux-Kernel benötigt einen geschützten Modus, daher aktivieren wir ihn in den Registerflags und initialisieren die Basis, den Selektor und die Granularität für jeden Sonderfall:
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
Was sind die Boot-Parameter und warum können Sie den Kernel nicht einfach bei Adresse Null booten? Es ist Zeit, mehr über das bzImage-Format zu erfahren.
Das Kernel-Image folgt einem speziellen "Boot-Protokoll", bei dem es einen festen Header mit Boot-Optionen gefolgt vom eigentlichen Kernel-Bytecode gibt. Das Format des Boot-Headers wird hier beschrieben.
Laden eines Kernel-Images
Um das Kernel-Image ordnungsgemäß in die virtuelle Maschine zu laden, müssen wir zuerst die gesamte bzImage-Datei lesen. Wir betrachten den Offset 0x1f1 und erhalten von dort die Anzahl der Sektoren des Setups. Wir werden sie überspringen, um zu sehen, wo der Kernel-Code beginnt. Außerdem kopieren wir die Startparameter vom Beginn von bzImage in den Speicherbereich für die Startparameter der virtuellen Maschine (0x10000).
Aber auch das wird nicht reichen. Wir müssen die Startparameter für unsere virtuelle Maschine korrigieren, um sie in den VGA-Modus zu zwingen und den Befehlszeilenzeiger zu initialisieren.
Unser Kernel muss Protokolle an ttyS0 ausgeben, damit wir E / A abfangen können und unsere virtuelle Maschine sie auf stdout druckt. Dazu müssen wir der Kernel-Befehlszeile "console = ttyS0" hinzufügen .
Aber auch danach werden wir kein Ergebnis erhalten. Ich musste eine gefälschte CPU-ID für unseren Kernel festlegen (https://www.kernel.org/doc/Documentation/virtual/kvm/cpuid.txt). Höchstwahrscheinlich stützte sich der von mir zusammengestellte Kernel auf diese Informationen, um festzustellen, ob er in einem Hypervisor oder auf Bare-Metal ausgeführt wurde.
Ich habe einen Kernel verwendet, der mit einer "winzigen" Konfiguration kompiliert wurde, und einige Konfigurationsflags eingerichtet, um Terminal und virtio (I / O-Virtualisierungsframework für Linux) zu unterstützen.
Den vollständigen Code für das geänderte KVM-Host- und Testkernel-Image finden Sie hier .
Wenn dieses Bild nicht gestartet wird, können Sie ein anderes Bild verwenden, das unter diesem Link verfügbar ist .
Wenn wir es kompilieren und ausführen, erhalten wir die folgende Ausgabe:
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.
...
Offensichtlich ist dies immer noch ein ziemlich nutzloses Ergebnis: keine initrd- oder root-Partition, keine echten Anwendungen, die in diesem Kernel ausgeführt werden könnten, aber es beweist immer noch, dass KVM kein so schreckliches und ziemlich leistungsfähiges Tool ist.
Fazit
Um ein vollwertiges Linux ausführen zu können, muss der Host der virtuellen Maschine viel weiter fortgeschritten sein - wir müssen mehrere E / A-Treiber für Festplatten, Tastaturen und Grafiken simulieren. Der allgemeine Ansatz bleibt jedoch derselbe. Beispielsweise müssen wir die Befehlszeilenparameter für initrd auf die gleiche Weise konfigurieren. Die Festplatten müssen E / A abfangen und entsprechend reagieren.
Niemand zwingt Sie jedoch, KVM direkt zu verwenden. Es gibt libvirt , eine nette, freundliche Bibliothek für Virtualisierungstechnologien auf niedriger Ebene wie KVM oder BHyve.
Wenn Sie mehr über KVM erfahren möchten , empfehlen wir Ihnen, sich die kvmtool- Quelle anzusehen . Sie sind viel einfacher zu lesen als QEMU und das gesamte Projekt ist viel kleiner und einfacher.
Ich hoffe, Ihnen hat der Artikel gefallen.
Sie können die Nachrichten auf Github , Twitter verfolgen oder über RSS abonnieren .
Links zum GitHub Gist mit Python-Beispielen eines Timeweb-Experten: (1) und (2) .