[Перевод - recovery mode ] Хост KVM в паре строчек кода
Привет!Сегодня публикуем статью о том, как написать хост KVM. Мы увидели ее в блоге Serge Zaitsev, перевели и дополнили собственными примерами на Python для тех, кто не работает с языком С++.
KVM (Kernel-based Virtual Machine) — это технология виртуализации, которая поставляется с ядром Linux. Другими словами, KVM позволяет запускать несколько виртуальных машин (VM) на одном виртуальном хосте Linux. Виртуальные машины в этом случае называются гостевыми (guests). Если вы когда-нибудь использовали QEMU или VirtualBox на Linux, вы знаете, на что способен KVM.
Но как это работает под капотом?
IOCTL
KVM предоставляет API через специальный файл устройства — /dev/kvm. Запуская устройство, вы обращаетесь к подсистеме KVM, а затем выполняете системные вызовы ioctl для распределения ресурсов и запуска виртуальных машин. Некоторые вызовы ioctl возвращают файловые дескрипторы, которыми также можно управлять с помощью ioctl. И так до бесконечности? На самом деле, нет. В KVM всего несколько уровней API:
- уровень /dev/kvm, используемый для управления всей подсистемой KVM и для создания новых виртуальных машин,
- уровень VM, используемый для управления отдельной виртуальной машиной,
- уровень VCPU, используемый для управления работой одного виртуального процессора (одна виртуальная машина может работать на нескольких виртуальных процессорах) — VCPU.
Кроме того, существуют API для устройств ввода-вывода.
Посмотрим, как это выглядит на практике.
// 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:
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);
На этом этапе мы создали новую виртуальную машину, выделили для нее память и назначили один виртуальный ЦП. Чтобы наша виртуальная машина действительно запускала что-то, нам нужно загрузить образ виртуальной машины и правильно настроить регистры процессора.
Загрузка виртуальной машины
Это достаточно легко! Просто прочтите файл и скопируйте его содержимое в память виртуальной машины. Конечно, mmap тоже неплохой вариант.
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:
# Read guest.bin
guest_bin = load_guestbin('guest.bin')
mem[:len(guest_bin)] = guest_bin
Предполагается, что guest.bin содержит валидный байт-код для текущей архитектуры ЦП, потому что KVM не интерпретирует инструкции ЦП одну за другой, как это делали старые виртуальные машины. KVM отдает вычисления настоящему ЦП и только перехватывает ввод-вывод. Вот почему современные виртуальные машины работают с высокой производительностью, близкой к «голому железу», если только вы не выполняете операции с большим количеством ввода-вывода (I/O heavy operations).
Вот крошечное ядро гостевой виртуальной машины, которое мы попробуем запустить в первую очередь:
#
# 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
Если вы не знакомы с ассемблером, то пример выше — это крошечный 16-разрядный исполняемый файл, который увеличивает регистр в цикле и выводит значение в порт 0×10.
Мы сознательно скомпилировали его как архаичное 16-битное приложение, потому что запускаемый виртуальный процессор KVM может работать в нескольких режимах, как настоящий процессор x86. Самый простой режим — это «реальный» режим (real mode), который использовался для запуска 16-битного кода с прошлого века. Реальный режим отличается адресацией памяти, она прямая вместо использования дескрипторных таблиц — было бы проще инициализировать наш регистр для реального режима:
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:
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)
Запуск
Код загружен, регистры готовы. Приступим? Чтобы запустить виртуальную машину, нам нужно получить указатель на «состояние выполнения» (run state) для каждого виртуального ЦП, а затем войти в цикл, в котором виртуальная машина будет работать до тех пор, пока она не будет прервана операциями ввода-вывода или другими операциями, где управление будет передано обратно хосту.
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:
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
Теперь, если мы запустим приложение, мы увидим:
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
...
Работает! Полные исходные коды доступны по следующему адресу (если вы заметили ошибку, комментарии приветствуются!).
Вы называете это ядром?
Скорее всего, всё это не очень впечатляет. Как насчет того, чтобы вместо этого запустить ядро Linux?
Начало будет таким же: откройте /dev/kvm, создайте виртуальную машину и т. д. Однако нам понадобится еще несколько вызовов ioctl на уровне виртуальной машины, чтобы добавить периодический интервальный таймер, инициализировать TSS (требуется для чипов Intel) и добавить контроллер прерываний:
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);
Также нам нужно будет изменить способ инициализации регистров. Ядру Linux требуется защищенный режим, поэтому мы включаем его во флагах регистра и инициализируем базу, селектор, степень детализации для каждого специального регистра:
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
Каковы параметры загрузки и почему нельзя просто загрузить ядро по нулевому адресу? Пришло время узнать больше о формате bzImage.
Образ ядра следует специальному «протоколу загрузки», где есть фиксированный заголовок с параметрами загрузки, за которым следует фактический байт-код ядра. Здесь описан формат загрузочного заголовка.
Загрузка образа ядра
Чтобы правильно загрузить образ ядра в виртуальную машину, нам нужно сначала прочитать весь файл bzImage. Мы смотрим на смещение 0×1f1 и получаем оттуда количество секторов настройки. Мы пропустим их, чтобы узнать, где начинается код ядра. Кроме того, мы скопируем параметры загрузки из начала bzImage в область памяти для параметров загрузки виртуальной машины (0×10000).
Но даже этого будет недостаточно. Нам нужно будет исправить параметры загрузки для нашей виртуальной машины, чтобы принудительно перейти в режим VGA и инициализировать указатель командной строки.
Наше ядро должно выводить логи на ttyS0, чтобы мы могли перехватить ввод-вывод и наш виртуальный компьютер распечатал его на stdout. Для этого нам нужно добавить «console = ttyS0» в командную строку ядра.
Но даже после этого мы не получим никакого результата. Мне пришлось установить поддельный идентификатор процессора для нашего ядра (https://www.kernel.org/doc/Documentation/virtual/kvm/cpuid.txt). Скорее всего, ядро, которое я собрал, полагалось на эту информацию, чтобы определить, работает ли оно внутри гипервизора или на голом железе.
Я использовал ядро, скомпилированное с «крошечной» конфигурацией, и настроил несколько флагов конфигурации для поддержки терминала и virtio (фреймворк виртуализации ввода-вывода для Linux).
Полный код модифицированного хоста KVM и тестового образа ядра доступны здесь.
Если этот образ не запустился, можно использовать другой образ, доступный по данной ссылке.
Если мы скомпилируем его и запустим, мы получим следующий результат:
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.
...
Очевидно, это по-прежнему довольно бесполезный результат: нет initrd или корневого раздела, нет реальных приложений, которые могли бы работать в этом ядре, но все же это доказывает, что KVM не такой уж страшный и довольно мощный инструмент.
Вывод
Чтобы запустить полноценный Linux, хост виртуальной машины должен быть намного более продвинутым — нам нужно смоделировать несколько драйверов ввода-вывода для дисков, клавиатуры, графики. Но общий подход останется прежним, например, нам потребуется настроить параметры командной строки для initrd аналогичным образом. Для дисков нужно будет перехватывать ввод-вывод и отвечать должным образом.
Однако никто не заставляет вас использовать KVM напрямую. Существует libvirt, приятная дружественная библиотека для технологий низкоуровневой виртуализации, таких как KVM или BHyve.
Если вам интересно узнать больше о KVM, я предлагаю посмотреть исходники kvmtool. Их намного легче читать, чем QEMU, а весь проект намного меньше и проще.
Надеюсь, вам понравилась статья.
Вы можете следить за новостями на Github, в Twitter или подписываться через rss.
Ссылки на GitHub Gist с примерами на Python от эксперта Timeweb: (1) и (2).