- PVSM.RU - https://www.pvsm.ru -

Хост KVM в паре строчек кода

Привет!

Сегодня публикуем статью о том, как написать хост KVM. Мы увидели ее в блоге Serge Zaitsev [1], перевели и дополнили собственными примерами на Python для тех, кто не работает с языком С++.

KVM (Kernel-based Virtual Machine) — это технология виртуализации, которая поставляется с ядром Linux. Другими словами, KVM позволяет запускать несколько виртуальных машин (VM) на одном виртуальном хосте Linux. Виртуальные машины в этом случае называются гостевыми (guests). Если вы когда-нибудь использовали QEMU или VirtualBox на Linux, вы знаете, на что способен KVM.

Но как это работает под капотом?

IOCTL

KVM предоставляет API [2] через специальный файл устройства — /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: %dn", 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: %dn", 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-разрядный исполняемый файл, который увеличивает регистр в цикле и выводит значение в порт 0x10.

Мы сознательно скомпилировали его как архаичное 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, &regs);

Пример на 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: %xn", 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
...

Работает! Полные исходные коды доступны по следующему адресу [3] (если вы заметили ошибку, комментарии приветствуются!).

Вы называете это ядром?

Скорее всего, всё это не очень впечатляет. Как насчет того, чтобы вместо этого запустить ядро ​​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.

Образ ядра следует специальному «протоколу загрузки», где есть фиксированный заголовок с параметрами загрузки, за которым следует фактический байт-код ядра. Здесь [4] описан формат загрузочного заголовка.

Загрузка образа ядра

Чтобы правильно загрузить образ ядра в виртуальную машину, нам нужно сначала прочитать весь файл bzImage. Мы смотрим на смещение 0x1f1 и получаем оттуда количество секторов настройки. Мы пропустим их, чтобы узнать, где начинается код ядра. Кроме того, мы скопируем параметры загрузки из начала bzImage в область памяти для параметров загрузки виртуальной машины (0x10000).

Но даже этого будет недостаточно. Нам нужно будет исправить параметры загрузки для нашей виртуальной машины, чтобы принудительно перейти в режим VGA и инициализировать указатель командной строки.

Наше ядро ​​должно выводить логи на ttyS0, чтобы мы могли перехватить ввод-вывод и наш виртуальный компьютер распечатал его на stdout. Для этого нам нужно добавить «console = ttyS0» в командную строку ядра.

Но даже после этого мы не получим никакого результата. Мне пришлось установить поддельный идентификатор процессора для нашего ядра (https://www.kernel.org/doc/Documentation/virtual/kvm/cpuid.txt). Скорее всего, ядро, которое я собрал, полагалось на эту информацию, чтобы определить, работает ли оно внутри гипервизора или на голом железе.

Я использовал ядро, скомпилированное с «крошечной» конфигурацией, и настроил несколько флагов конфигурации для поддержки терминала и virtio (фреймворк виртуализации ввода-вывода для Linux).

Полный код модифицированного хоста KVM и тестового образа ядра доступны здесь [5].

Если этот образ не запустился, можно использовать другой образ, доступный по данной ссылке [6].

Если мы скомпилируем его и запустим, мы получим следующий результат:

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 [7], приятная дружественная библиотека для технологий низкоуровневой виртуализации, таких как KVM или BHyve.

Если вам интересно узнать больше о KVM, я предлагаю посмотреть исходники kvmtool [8]. Их намного легче читать, чем QEMU, а весь проект намного меньше и проще.

Надеюсь, вам понравилась статья.

Вы можете следить за новостями на Github [9], в Twitter [10] или подписываться через rss [11].

Ссылки на GitHub Gist с примерами на Python от эксперта Timeweb: (1) [12] и (2) [13].

Автор: tw_community

Источник [14]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/linux/358609

Ссылки в тексте:

[1] Serge Zaitsev: https://zserge.com/posts/kvm/

[2] API: https://www.kernel.org/doc/Documentation/virtual/kvm/api.txt

[3] адресу: https://gist.github.com/zserge/d68683f17c68709818f8baab0ded2d15

[4] Здесь: https://www.kernel.org/doc/html/latest/x86/boot.html#the-real-mode-kernel-header

[5] здесь: https://gist.github.com/zserge/ae9098a75b2b83a1299d19b79b5fe488

[6] данной ссылке: https://gist.github.com/ricarkol/60511f3a4d213bbb700b99429c04088e

[7] libvirt: https://libvirt.org/

[8] kvmtool: https://git.kernel.org/pub/scm/linux/kernel/git/will/kvmtool.git/tree/

[9] Github: https://github.com/zserge

[10] Twitter: https://twitter.com/zsergo

[11] rss: https://zserge.com/rss.xml

[12] (1): https://gist.github.com/twdrozhevskij/6366d688c5b9a592ad841724e03b56a9

[13] (2): https://gist.github.com/twdrozhevskij/a772b29460e2538f5a92c62687d1324d

[14] Источник: https://habr.com/ru/post/526818/?utm_source=habrahabr&utm_medium=rss&utm_campaign=526818