KVM с точки зрения QEMU
KVM с точки зрения QEMU

Начнем с простого вопроса:

Что означает QEMU/KVM или QEMU-KVM?

Можно ответить - это QEMU + KVM или qemu-system, запущенный с kvm в качестве ускорителя. Но в какой-то степени это еще и анахронизм, так как с появлением KVM его разработчики для интеграции с QEMU поддерживали отдельный форк qemu-kvm, но начиная с QEMU версии 1.3 (декабрь 2012) все основные изменения из qemu-kvm были перенесены в главную ветку QEMU, а qemu-kvm объявлен устаревшим.

В разных дистрибутивах до сих пор еще можно встретить исполняемый файл qemu-kvm или просто kvm, но это лишь обертки над qemu-system:

exec qemu-system-x86_64 -enable-kvm "$@"

или симлинки:

/usr/bin/kvm -> qemu-system-x86_64

А в самом qemu существует проверка:

if (g_str_has_suffix(progname, "kvm")) {
    /* If the program name ends with "kvm", we prefer KVM */
    accelerators = "kvm:tcg";
} else {
    accelerators = "tcg:kvm";
}

Ок, а теперь попытаемся коротко ответить на следующий вопрос:

Что такое KVM (Kernel-based Virtual Machine)?

Кажется, что коротко ответить не получится. Но как на счет такого:

KVM - это часть ядра Linux.

Ответ вроде верный, вот только по сути своей уж очень напоминает известный анекдот про математика и воздушный шар - почти такой же "абсолютно верный, но абсолютно бесполезный".

Можно еще добавить общих фраз про "программное решение для виртуализации", "технологию виртуализации" и тп. Такие фразы может и годятся в качестве определений, но как-то уж чересчур абстрактны. В общем, если мы хотим лучше понять, что же такое KVM, то краткостью придется пожертвовать.

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

Qumranet и появление KVM

KVM: Kernel-based Virtual Machine
From: Avi Kivity
Date: Thu Oct 19 2006 - 09:46:20 EST

The following patchset adds a driver for Intels hardware virtualization extensions to the x86 architecture. The driver adds a character device (/dev/kvm) that exposes the virtualization capabilities to userspace.  Using this driver, a process can run a virtual machine (a "guest") in a fully virtualized PC containing its own virtual hard disks, network adapters, and display.

Using this driver, one can start multiple virtual machines on a host.  Each virtual machine is a process on the host; a virtual cpu is a thread in that process.  kill(1), nice(1), top(1) work as expected.

Так начинается первое упоминание о KVM, которое можно найти в рассылке разработчиков ядра Linux за октябрь 2006 года.

Автором и главным разработчиком KVM был сотрудник израильской компании/стартапа Qumranet - Avi Kivity. На тот момент компания занималась разработкой своего VDI (Virtual Desktop Infrastructure) решения поддерживающим запуск как Windows, так и Linux виртуальных машин в дата-центрах. Этот проект под названием Solid ICE, для которого собственно и разрабатывался KVM, был представлен уже позже - в 2008 году, а спустя несколько месяцев после этого Red Hat объявила о покупке компании Qumranet.

Еще одной не менее известной разработкой Qumranet, также используемой в Solid ICE, стал протокол удаленного доступа SPICE (Simple Protocol for Independent Computing Environments), который сейчас активно применяется в системах виртуализации. Его исходники были выложены Red Hat в открытый доступ в 2009 году уже после приобретения компании.

Изначальный код KVM, опубликованный в октябре 2006, поддерживал только процессоры Intel с технологией VT-x и состоял из загружаемого в ядро драйвера, а также патча для QEMU, позволяющего им управлять.

При инициализации драйвер активировал аппаратную поддержку виртуализации в процессоре и регистрировал новое символьное устройство /dev/kvm, через которое и происходило все дальнейшее взаимодействие.

В свою очередь код в QEMU открывал файл устройства /dev/kvm и отправлял команды управления KVM через API системных вызовов ioctl. Каждая создаваемая виртуальная машина была обычным Linux процессом.

Помимо части, отвечающей за регистрацию устройства /dev/kvm и обработку ioctl команд, в состав драйвера входил программный MMU (Memory Management Unit), задача которого состояла в выделении памяти, создании теневых страниц (shadow pages) и трансляции адресов гостевых систем в физические адреса хостовой системы. А также непосредственно код для поддержки виртуализации процессора и переключения между режимами его работы, используя набор инструкций Intel VMX (Virtual Machine Extension) и отдельный эмулятор/декодер x86 инструкций.

Код эмулятора x86 и часть VMX кода были напрямую заимствованы из гипервизора Xen. Для меня, мало знакомого с Xen, стало небольшим открытием то, что поддержка аппаратной виртуализации сперва появилась в Xen 3.0, вышедшем в декабре 2005 (практически за год до KVM), а первые коммиты, добавляющие эту поддержку, вообще датируются декабрем 2004 и написаны кем-то из Intel. Судя по всему Intel, разрабатывая VT-x, тесно сотрудничал с Xen, так как первыми процессорами Intel, поддерживающими VT-x, были Pentium 4 на ядре Prescott, вышедшие в ноябре 2005, всего за месяц до релиза Xen 3.0.

Если подытожить, то краткая история появления KVM выглядит так:

  • Октябрь 2006 - Avi Kivity публикует в рассылке первую версию KVM (поддерживаются только процессоры Intel)

  • Ноябрь 2006 - добавлена поддержка процессоров от AMD с технологией Secure Virtual Machine (SVM)

  • Декабрь 2006 - код KVM принят в главную ветку ядра Linux.

  • Февраль 2007 - релиз ядра Linux 2.6.20, в который впервые вошел KVM.

То есть по прошествии менее 3-х месяцев после первой публикации, KVM уже стал частью Linux. Пожалуй, главная причина этого состояла в его достаточно малом изначальном размере и в том, что для интеграции он не требовал никаких дополнительных изменений ядра. По ходу развития KVM в него так же добавлялась поддержка различных архитектур - ARM, MIPS, PowerPC и IBM S390, но здесь мы не будем их касаться.  

Познакомившись с историей KVM, приступим к изучению того, как он работает на процессорах семейства x86.

Устройство KVM на x86

В большинстве случаев KVM представлен в виде динамически загружаемых модулей ядра, однако ничто не мешает сделать сборку с включением его в само ядро. В зависимости от типа центрального процессора (Intel или AMD) загружается один из соответствующих архитектурно-специфических модулей kvm-intel.ko или kvm-amd.ko, которые в свою очередь используют экспортируемые функции главного модуля kvm.ko, загружаемого в обоих случаях.

При инициализации модули kvm-intel.ko и kvm-amd.ko c помощью команды CPUID и чтения MSR регистров проверяют поддержку процессором аппаратной виртуализации и то, что она не заблокирована в BIOS. Если эти условия выполняются, то модуль активирует функции виртуализации в процессоре и в случае Intel переводит его в VMX root режим, после чего регистрирует в системе символьное устройство /dev/kvm.

Далее все управление KVM производится с помощью этого устройства и вызовов ioctl. Документацию и список поддерживаемых API вызовов можно найти здесь.

Начиная с версии ядра Linux 2.6.22 (KVM_API_VERSION = 12), базовый набор вызовов стабилизировался, а все дополнительные функции реализованы с помощью расширений, поддержку которых нужно проверять вызовом KVM_CHECK_EXTENSION.

Чтобы лучше разобраться с тем, как работает KVM, попробуем написать программу на С, которая c помощью KVM будет создавать и запускать виртуальную PC машину. В качестве BIOS у нас будет выступать SeaBIOS, используемый в QEMU по умолчанию. В конце статьи я приведу полный код, а пока детально разберем, что для этого потребуется.

Работа с API KVM

Для работы с KVM первым делом нам нужно открыть файл устройства /dev/kvm и получить его дескриптор:

int kvm_fd = open("/dev/kvm", O_RDWR);

Теперь, используя этот дескриптор и API на основе ioctl создадим абстракцию виртуальной машины:

int vm_fd = ioctl(kvm_fd, KVM_CREATE_VM, 0);

Вызов KVM_CREATE_VM создает внутри KVM структуру виртуальной машины и возвращает для нее новый (анонимный) дескриптор. При создании, виртуальная машина не имеет ни виртуального процессора ни памяти, так что следующим шагом будет их добавление к ней:

int vcpu_fd = ioctl(vm_fd, KVM_CREATE_VCPU, 0);

Вызов KVM_CREATE_VCPU производится на полученном ранее дескрипторе виртуальной машины и создает внутри нее новую структуру виртуального процессора. В каждой виртуальной машине можно создать множество виртуальных процессоров и отдельно управлять каждым из них используя полученный при его создании дескриптор.

Теперь перейдем к выделению памяти для виртуальной машины и записи в нее кода SeaBIOS, однако тут все не так просто.

Выделяем память для SeaBIOS

Начиная с первых IBM PC 5150, в которых использовался 16 битный процессор 8088 (упрощенная версия 8086 с 8 битной шиной данных) точка входа в BIOS должна была располагаться по адресу 0xFFFF0, так как именно с этого адреса процессор начинал исполнять инструкции после включения или сброса. Процессоры 8086/8088 имели 20 битную шину адреса и поэтому могли адресовать только 1 MB памяти. Но получается, что 0xFFFF0 находится в самом конце адресного пространства и для кода остается всего 16 байт. Поэтому по адресу 0xFFFF0 в коде BIOS всегда располагался переход (jmp) на другой фиксированный адрес, по которому уже и располагалась его основная часть.

Если дизассемблировать seabios, то мы увидим тоже самое:

$ objdump -b binary -D -M intel -m i8086 /usr/share/seabios/bios-256k.bin | tail
   3ffe8:       66 5b                   pop    ebx
   3ffea:       66 5e                   pop    esi
   3ffec:       66 5f                   pop    edi
   3ffee:       66 c3                   retd
   3fff0:       ea 5b e0 00 f0          jmp    0xf000:0xe05b
   3fff5:       30 36 2f 32             xor    BYTE PTR ds:0x322f,dh
   3fff9:       33 2f                   xor    bp,WORD PTR [bx]
   3fffb:       39 39                   cmp    WORD PTR [bx+di],di
   3fffd:       00 fc                   add    ah,bh

Инструкция jmp 0xf000:0xe05b - это так называемый far jump, в котором указывается базовый адрес сегмента(0xf000) и смещение внутри этого сегмента (0xe05b), а реальный адрес вычисляется внутри процессора путем сдвига на 4 бита адреса сегмента и сложением его со значением смещения (address = segment << 4 + offset).

Во всех процессорах x86 использовалась сегментная модель памяти, то есть любое обращение к физическому адресу памяти использовало сегмент и смещение. Даже когда сегмент или сегментный регистр явно не указывался в инструкции, он брался по умолчанию в зависимости от типа инструкции (CS - для кода, SS - для стека, DS и ES для данных).

Так как шина данных у первых процессоров была 20 битная, а все регистры 16 битными, то как я указывал выше, базовый 16 битный адрес сдвигался на 4 бита и складывался со значением смещения, в итоге получался 20 битный адрес, который и выставлялся на физическую шину.

В нашем случае, после старта и выполнения первой инструкции jmp 0xf000:0xe05b, управление перейдет по адресу 0xfe05b и чтобы это все заработало нам нужно расположить образ BIOS по фиксированному адресу в памяти.

Выделяем память для виртуальной машины и записываем код BIOS в конец первого мегабайта памяти:

#define BIOS_FILE "/usr/share/seabios/bios-256k.bin"
#define BIOS_SIZE 256 * 1024
#define RAM_SIZE 2 * 1024 * 1024

uint8_t *ptr = mmap(NULL, 
                    RAM_SIZE, 
                    PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);

FILE *f = fopen(BIOS_FILE, "rb");

fread(ptr + 0x100000 - BIOS_SIZE, 1, BIOS_SIZE, f);

Теперь нужно заполнить структуру kvm_userspace_memory_region с указателем на выделенную только что память и вызовом KVM_SET_USER_MEMORY_REGION добавить ее в качестве физической памяти виртуальной машины.

struct kvm_userspace_memory_region region;

region.slot = 0;
region.flags = 0;
region.guest_phys_addr = 0;
region.memory_size = RAM_SIZE;
region.userspace_addr = (uintptr_t)ptr;

ioctl(vm_fd, KVM_SET_USER_MEMORY_REGION, &region);

Память виртуальной машины представляется в виде одного или более непрерывных регионов, каждый из которых имеет начальный физический адрес (guest_phys_addr), по которому этот регион памяти будет отображаться внутри виртуальной машины.  

В поле flags также можно указывать 2 значения:

  • KVM_MEM_READONLY - сделать регион памяти доступным "только для чтения". В этом случае попытки виртуальной машины записи в него будут приводить к генерации исключений.

  • KVM_MEM_LOG_DIRTY_PAGES - включает логирование операций записи в страницы памяти.

Флаг KVM_MEM_LOG_DIRTY_PAGES также используется при выполнении живых миграций (live migrations) виртуальных машин. В начале на текущей машине включается трекинг "грязных страниц" и вся ее память копируется и отправляется на новую машину, после чего в цикле начинают копироваться только "грязные страницы" (те в которых за время копирования гостевой системой были произведены какие-то изменения). Это происходит до достижения определенного порога, после чего текущая машина останавливается, все оставшиеся "грязные страницы" быстро копируются на новую машину и если все прошло удачно, то после включения новой машины выполнение системы уже продолжается на ней, а в случае какой-то ошибки текущая машина запустится опять.

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

Настраиваем процессор

В процессоре Intel 80286 был впервые добавлен защищенный режим работы (Protected mode), для его поддержки у сегментных регистров появились скрытые части (segment base, segment limit, access rights), в которые загружались значения из таблицы дескрипторов сегментов, и теперь внутри процессора адрес выполняемой инструкции вычислялся как cs.base + ip. Но для поддержки обратной совместимости в реальном режиме работы процессора (в котором он стартует) скрытая часть base вычисляется как cs.selector << 4.

Если запустить qemu с остановкой, перейти в режим монитора и посмотреть значения регистров:

$ qemu-system-x86_64 -S -nographic
QEMU 6.2.0 monitor - type 'help' for more information
(qemu) info registers
EAX=00000000 EBX=00000000 ECX=00000000 EDX=00060fb1
ESI=00000000 EDI=00000000 EBP=00000000 ESP=00000000
EIP=0000fff0 EFL=00000002 [-------] CPL=0 II=0 A20=1 SMM=0 HLT=0
ES =0000 00000000 0000ffff 00009300
CS =f000 ffff0000 0000ffff 00009b00
...

То увидим, что cs.base (второе поле) в самый первый момент старта процессора равно 0xffff0000, а не cs.selector << 4 и получается, что исполнение инструкций начнется по адресу 0xfffffff0 (cs.base + eip).

Короче, это все тяжелый груз обратной совместимости и я не буду утомлять вас этими деталями, скажу просто, что QEMU отображает BIOS в двух разных областях адресного пространства, что можно посмотреть командой info mtree в мониторе:

00000000000e0000-00000000000fffff (prio 1, rom): alias isa-bios @pc.bios 0000000000020000-000000000003ffff
00000000fffc0000-00000000ffffffff (prio 0, rom): pc.bios

Мы не будем заморачиваться и используя вызовы KVM_GET_SREGS и KVM_SET_SREGS установим нужное значение в cs.base равное cs.selector << 4, чтобы исполнение началось по адресу 0xffff0:

struct kvm_sregs sregs;

ioctl(vcpu_fd, KVM_GET_SREGS, &sregs);

sregs.cs.base = sregs.cs.selector << 4;

ioctl(vcpu_fd, KVM_SET_SREGS, &sregs); 

Для коммуникации с гостевой системой в KVM используется разделяемая память и структура kvm_run. Нам нужно получить размер этой структуры вызовом KVM_GET_VCPU_MMAP_SIZE, а затем отобразить ее с помощью вызова mmap на файловом дескрипторе процессора:

int kvm_run_size = ioctl(kvm_fd, KVM_GET_VCPU_MMAP_SIZE, NULL);

struct kvm_run *kvm_run = mmap(NULL, 
                               kvm_run_size, PROT_READ | PROT_WRITE, 
                               MAP_SHARED, vcpu_fd, 0);

Теперь мы можем запустить виртуальную машину, но перед этим давайте разберемся, что же происходит внутри KVM на примере процессоров Intel с аппаратной поддержкой виртуализации VT-x (Virtualization Technology for x86).

Как работает аппаратная поддержка виртуализации Intel VT-x

Как я уже упоминал, впервые аппаратная поддержка виртуализации VT-x появилась в процессорах Pentium 4, вышедших в конце 2005 года. У AMD аналогичная технология называется SVM (Secure Virtual Machine) и появилась она немного позже - в середине 2006 года, зато сразу имела поддержку виртуализации реального режима (Real mode) работы процессора, которой изначально не было в VT-x вплоть до 2010 года.

Технология аппаратной виртуализации Intel VT-x добавляла новое расширение VMX (Virtual Machine Extension), в которое входили новые инструкции процессора и два новых режима работы процессора - VMX root (в котором должна работать хостовая система в качестве гипервизора) и VMX non-root (в котором должны работать гостевые системы).

Часто при объяснении режим VMX root называют Ring -1, чтобы показать, что он как бы является более привилегированным, чем Ring 0 (kernel mode), но это скорее сбивает с толку, так как режимы VMX root и non-root не имеют прямого отношения к Rings 0-3 защищенного режима.

Напомню, что при работе процессора в защищенном режиме, текущий уровень привилегий программы (CPL) определяется значениями двух первых битов в регистре кодового сегмента CS:

  • 00 - Ring 0 (kernel mode)

  • 01 - Ring 1

  • 10 - Ring 2

  • 11 - Ring 3 (user mode)

Данный механизм существует и работает как в VMX root, так и в non-root режиме.

Переход процессора из защищенного режима в режим VMX root производится выполнением инструкции VMXON, а переход обратно инструкцией VMXOFF.

Работа процессора в VMX root режиме практически аналогична работе процессора без поддержки виртуализации. Главное различие состоит в том, что только из режима VMX root инструкциями VMLAUNCH/VMRESUME можно осуществить переход в режим VMX non-root, в котором запускаются виртуальные машины. Попытаюсь это изобразить:

      Linux                     Linux/KVM Host                            Guests
        |                             |                                     |
+----------------+            +----------------+                    +---------------+
|                |    VMXON   |                |  VMLAUNCH/VMRESUME |               |  
| Protected mode |  --------> | VMX root mode  | -----------------> |  VMX non-root |
|                |            |                |                    |               |
|   Rings 0-3    |    VMXOF   |   Rings 0-3    |      VM-exit       |   Rings 0-3   |
|                |  <-------- |                |  <---------------- |               |
+----------------+            +----------------+                    +---------------+

До 2010 года процессоры Intel не поддерживали реальный режим (Real mode) работы в VMX root и non-root. Это означало, что все виртуальные машины должны были работать только в защищенном режиме (Protected mode) и с включенной страничной памятью (Paging), которые нельзя было отключить. По этому до появления опции unrestricted guest (2010 год) в KVM приходилось эмулировать Real mode с помощью режима VM86 (режим виртуального 8086), впервые введенного в процессорах 80386 и, например, использовавшимся для запуска DOS приложений из Protected mode.

Еще одним главным нововведением было добавление аппаратной поддержки вложенных страниц - Extended Page Table (EPT) у Intel и Nested Page Tables (NPT) у AMD добавленные в 2008 году. Для трансляции адресов между гостевой и хостовой системой, в KVM приходилось на лету создавать и поддерживать теневые таблицы страниц (shadow pages), в которых сохранялись соответствия между виртуальными адресами гостевых систем и физическими адресами хостовой системы, а при каждом изменении таблиц или переключении процесса внутри гостевой системы (при этом регистр CR3 меняется), таблицы требовалось синхронизировать. До появления аппаратной поддержки со стороны процессоров (NPT/EPT), процедура трансляции адресов в некоторых ситуациях приводила к огромному числу переключений между KVM и виртуальной машиной, что очень сильно замедляло работу.

Но вернемся к процессу запуска виртуальной машины в KVM.

VT-x в KVM

Как я уже упоминал, перевод процессора в режим VMX root выполняется командой VMXON, это происходит на этапе загрузки модуля kvm-intel.ko. Для управления виртуальными машинами в памяти поддерживаются специальные структуры VMCS (Virtual Machine Control Structure), в которых имеются области для сохранения состояния хостовой и гостевых машин. Для работы с VMCS используются команды процессора VMREAD и VMWRITE.  

Перед запуском виртуальной машины KVM настраивает для нее структуру VMCS, после чего выполняет специальную команду VMLAUNCH, которая переводит процессор в режим non-root (этот процесс называется VM-entry), в котором и начинается выполнение гостевой системы.

При возникновении аппаратных прерываний, исключений, операций ввода/вывода или попытке виртуальной машины выполнить некоторые привилегированные операции происходит автоматический выход процессора из non-root обратно в root режим (то, что называется VM-exit) и управление получает KVM, а причина выхода и данные сохраняются в структуре VMCS.

Далее KVM считывает информацию из VMCS, определяет причину выхода и выполняет одно из следующих действий:

1) Если соответствующая причина требует дополнительной обработки/эмуляции, но ее можно произвести внутри KVM, то после выполнения этих действий работа виртуальной машины возобновляется командой VMRESUME. Вот неполный список обработчиков из файла arch/x86/kvm/vmx/vmx.c:

/*
 * The exit handlers return 1 if the exit was handled fully and guest execution
 * may resume.  Otherwise they set the kvm_run parameter to indicate what needs
 * to be done to userspace and return 0.
 */
static int (*kvm_vmx_exit_handlers[])(struct kvm_vcpu *vcpu) = {
	[EXIT_REASON_EXCEPTION_NMI]           = handle_exception_nmi,
	[EXIT_REASON_EXTERNAL_INTERRUPT]      = handle_external_interrupt,
	[EXIT_REASON_TRIPLE_FAULT]            = handle_triple_fault,
	[EXIT_REASON_NMI_WINDOW]	      = handle_nmi_window,
	[EXIT_REASON_IO_INSTRUCTION]          = handle_io,
	[EXIT_REASON_CR_ACCESS]               = handle_cr,
	[EXIT_REASON_DR_ACCESS]               = handle_dr,
	[EXIT_REASON_CPUID]                   = kvm_emulate_cpuid,
	[EXIT_REASON_MSR_READ]                = kvm_emulate_rdmsr,
	[EXIT_REASON_MSR_WRITE]               = kvm_emulate_wrmsr,
	[EXIT_REASON_INTERRUPT_WINDOW]        = handle_interrupt_window,
	[EXIT_REASON_HLT]                     = kvm_emulate_halt,
	[EXIT_REASON_INVD]		      = kvm_emulate_invd,
	[EXIT_REASON_INVLPG]		      = handle_invlpg,
	[EXIT_REASON_VMCLEAR]		      = handle_vmx_instruction,
	[EXIT_REASON_VMLAUNCH]		      = handle_vmx_instruction,
	[EXIT_REASON_VMPTRLD]		      = handle_vmx_instruction,
	[EXIT_REASON_VMPTRST]		      = handle_vmx_instruction,
	[EXIT_REASON_VMREAD]		      = handle_vmx_instruction,
....

2) Для случаев требующих обработки со стороны внешней программы (например обработки ошибок, прерываний или эмуляции оборудования для ввода/вывода) KVM записывает причину выхода (exit_reason) и связанные с ней данные в структуру kvm_run, после чего передает управление в пользовательский режим. Для примера приведу несколько определений из файла include/uapi/linux/kvm.h:

#define KVM_EXIT_UNKNOWN          0
#define KVM_EXIT_EXCEPTION        1
#define KVM_EXIT_IO               2
#define KVM_EXIT_HLT              5
#define KVM_EXIT_MMIO             6
#define KVM_EXIT_SHUTDOWN         8
#define KVM_EXIT_FAIL_ENTRY       9
#define KVM_EXIT_INTR             10
#define KVM_EXIT_NMI              16
#define KVM_EXIT_INTERNAL_ERROR   17
....

Теперь, когда мы в общих чертах разобрали, что происходит внутри KVM, осталось добавить обработку ввода/вывода и запустить виртуальную машину.

Обработка ввода/вывода и и запуск виртуальной машины

SeaBIOS пишет дебаг сообщения в IO порт по адресу 0x402, а чтобы определить, что порт реально используется на этапе запуска, SeaBIOS так же производит чтение из этого порта и если прочитанный байт не равен специальному значению 0xe9, то вывод сообщений прекращается.

Чтобы просто убедиться, что виртуальная машина запускается и работает, мы всего лишь будем читать и выводить данные из этого порта. Для этого сделаем бесконечный цикл, в котором будет находится вызов ioctl(vcpu_fd, KVM_RUN, 0), запускающий виртуальный процессор, а также код обработки ввода/вывода.

#define BIOS_DEBUG_PORT 0x402
#define BIOS_DEBUG_VALUE 0xe9
while (1) {
    ioctl(vcpu_fd, KVM_RUN, 0);
    if (kvm_run->exit_reason == KVM_EXIT_IO) {
        ptr = (uint8_t *)kvm_run + kvm_run->io.data_offset;
        if (kvm_run->io.port == BIOS_DEBUG_PORT) {
            if (kvm_run->io.direction == KVM_EXIT_IO_OUT) {
                putchar(*ptr);
            } else {
                *ptr = BIOS_DEBUG_VALUE;
            }
        }
    }
}

Внутри вызова ioctl(vcpu_fd, KVM_RUN, 0) KVM заполнит нужными данными структуру VMCS и выполнит команду VMLAUNCH, которая переводит процессор в режим VMX non-root и начнется выполнение кода SeaBIOS. Когда SeaBIOS попытается записать данные в порт 0x402, произойдет VM-exit (возврат процессора из VMX non-root в root), получивший назад управление KVM возьмет нужные данные из VMCS и заполнит ими структуру kvm_run:

//Условно
kvm_run->exit_reason = KVM_EXIT_IO
kvm_run->io.port = 0x402
kvm_run->io.direction = KVM_EXIT_IO_OUT
kvm_run->io.data_offset = смещение в структуре kvm_run по которому находятся данные

После этого происходит возврат из вызова ioctl(vcpu_fd, KVM_RUN, 0) в программу, где мы читаем эти данные и выводим на консоль.

Аналогичная ситуация происходит при попытке SeaBIOS прочитать данные из порта 0x402. Только в этом случае kvm_run->io.direction будет равен KVM_EXIT_IO_IN, а мы вместо чтения данных по смещению kvm_run->io.data_offset, записываем туда специальное значение 0xe9, назначение которого я уже объяснял.

Попробуем скомпилировать и запустить:

vm.c - полный текст программы с дополнительной проверкой ошибок
#include <stdio.h>
#include <stdlib.h>
#include <inttypes.h>
#include <linux/kvm.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <string.h>
#include <err.h>
#include <errno.h>

#define BIOS_FILE "/usr/share/seabios/bios-256k.bin"
#define BIOS_SIZE 256 * 1024
#define RAM_SIZE 2 * 1024 * 1024
#define BIOS_DEBUG_PORT 0x402
#define BIOS_DEBUG_VALUE 0xe9

int main ()
{
    struct kvm_sregs sregs;
    struct kvm_pit_config pit_config;

    int kvm_fd = open("/dev/kvm", O_RDWR);

    if (kvm_fd < 0) {
        err(1, "open /dev/kvm");
    }

    int vm_fd = ioctl(kvm_fd, KVM_CREATE_VM, 0);

    if (vm_fd < 0) {
        err(1, "ioctl KVM_CREATE_VM");
    }

    if (ioctl(vm_fd, KVM_CREATE_IRQCHIP) < 0) {
        err(1, "ioctl KVM_CREATE_IRQCHIP");
    }

    memset(&pit_config, 0, sizeof(pit_config));

    if (ioctl(vm_fd, KVM_CREATE_PIT2, &pit_config) < 0) {
        err(1, "ioctl KVM_CREATE_PIT2");
    }

    int vcpu_fd = ioctl(vm_fd, KVM_CREATE_VCPU, 0);

    if (vcpu_fd < 0) {
        err(1, "ioctl KVM_CREATE_VCPU");
    }

    FILE *f = fopen(BIOS_FILE, "rb");
    if (!f) {
        err(1, "fopen %s", BIOS_FILE);
    }

    uint8_t *bios_buf = malloc(BIOS_SIZE);

    if (fread(bios_buf, 1, BIOS_SIZE, f) != BIOS_SIZE) {
        err(1, "fread bios");
    }

    fclose(f);

    uint8_t *ptr = mmap(NULL, RAM_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);

    if (ptr == MAP_FAILED) {
        err(1, "mmap ram");
    }

    struct kvm_userspace_memory_region region;
    region.slot = 0;
    region.flags = 0;
    region.guest_phys_addr = 0;
    region.memory_size = RAM_SIZE;
    region.userspace_addr = (uintptr_t)ptr;

    if (ioctl(vm_fd, KVM_SET_USER_MEMORY_REGION, &region) < 0) {
        err(1, "ioctl KVM_SET_USER_MEMORY_REGION");
    }

    memcpy(ptr + 0x100000 - BIOS_SIZE, bios_buf, BIOS_SIZE);

    int kvm_run_size = ioctl(kvm_fd, KVM_GET_VCPU_MMAP_SIZE, NULL);

    struct kvm_run *kvm_run = mmap(NULL, kvm_run_size, PROT_READ | PROT_WRITE, MAP_SHARED, vcpu_fd, 0);

    if (kvm_run == MAP_FAILED) {
        err(1, "mmap kvm_run");
    }

    ioctl(vcpu_fd, KVM_GET_SREGS, &sregs);
    sregs.cs.base = sregs.cs.selector << 4;
    ioctl(vcpu_fd, KVM_SET_SREGS, &sregs);

    while (1) {
        int ret = ioctl(vcpu_fd, KVM_RUN, 0);
        if (ret < 0) {
            if (ret == -EINTR || ret == -EAGAIN) {
                continue;
            }
            err(1, "KVM_RUN");
        }

        switch (kvm_run->exit_reason) {
            case KVM_EXIT_IO:
                ptr = (uint8_t *)kvm_run + kvm_run->io.data_offset;
                for (int i = 0; i < kvm_run->io.count; i++) {
                    if (kvm_run->io.port == BIOS_DEBUG_PORT) {
                        if (kvm_run->io.direction == KVM_EXIT_IO_OUT) {
                            putchar(*ptr);
                        } else {
                            *ptr = BIOS_DEBUG_VALUE;
                        }
                    }
                    ptr += kvm_run->io.size;
                }
                break;
        }
    }

    return 0;
}

$ gcc -o vm vm.c
$ sudo ./vm
SeaBIOS (version 1.13.0-1ubuntu1.1)
BUILD: gcc: (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0 binutils: (GNU Binutils for Ubuntu) 2.34
No Xen hypervisor found.
Unable to unlock ram - bridge not found
RamSize: 0x00100000 [cmos]
Relocating init from 0x000d7d80 to 0x0007cc80 (size 78656)
=== PCI bus & bridge init ===
Detected non-PCI system
No apic - only the main cpu is present.
Copying PIR from 0x0008fc60 to 0x000f5d80
Copying MPTABLE from 0x00006e40/74bc0 to 0x000f5cb0
WARNING - Unable to allocate resource at smbios_legacy_setup:520!
Scan for VGA option rom
No VGA found, scan for other display
Turning on vga text mode console
SeaBIOS (version 1.13.0-1ubuntu1.1)
WARNING - Timeout at i8042_wait_read:38!
ATA controller 1 at 1f0/3f4/0 (irq 14 dev ffffffff)
ATA controller 2 at 170/374/0 (irq 15 dev ffffffff)
Found 0 lpt ports
Found 0 serial ports
Scan for option roms

Press ESC for boot menu.
...

Как видим SeaBIOS загружается, но естественно ног не чувствует никакого оборудования не находит, так как у него их нет мы его не эмулируем.

Заключение

Думаю, что пока на этом стоит остановиться. В следующей статье, посвященной VirtIO, я продолжу эту тему и покажу, как добавить сюда простой эмулятор PCI шины и блочного VirtIO устройства (в SeaBIOS есть для него драйвер), создать виртуальный образ диска, записать в его MBR какой-то ассемблерный "Hello, world" использующий прерывания BIOS, загрузиться с этого диска и вывести сообщение на консоль.

Как уже говорил, я не являюсь экспертом в области виртуализации и решил написать статью о некоторых темах, которые мне самому было интересно узнать в последние несколько месяцев. По теме внутреннего устройства KVM вообще достаточно мало доступной информации и чтобы разобраться в чем-то, нужно все время смотреть исходники и читать историю сообщений из lkml. Если я где-то ошибся по ходу изложения, то надеюсь, что в комментариях меня поправят.

Комментарии (8)


  1. vadimr
    00.00.0000 00:00
    +2

    Получается, что виртуальная машина KVM всегда загружается в режиме Legacy?


    1. lorc
      00.00.0000 00:00
      +2

      Насколько я знаю, можно настроить любой режим процессора. Ну и если вместо seabios в память закинуть какой-нибудь UEFI, то можно будет бутаться в режиме UEFI.


      1. nick1612 Автор
        00.00.0000 00:00
        +1

        Да, можно еще уточнить, что если вопрос был про запуск KVM вручную (через API /dev/kvm), то перед вызовом KVM_RUN, используя KVM_SET_REGS и KVM_SET_SREGS можно настроить регистры процессора как угодно, в том числе так, чтобы он сразу стартовал в Protected mode. Но по умолчанию (если их не менять), значения регистров устанавливаются как при старте условного физического процессора.

        А если вопрос по поводу запуска из QEMU, то по умолчанию он использует SeaBIOS (legacy BIOS), но с помощью опций -bios или -pflash, можно задать ему какой-то UEFI, например OVMF и он будет грузить его. Ну и еще, QEMU может запускать ядро Linux напрямую.


  1. NightShad0w
    00.00.0000 00:00
    +4

    Спасибо за статью, прекрасный разбор изложен отличным языком.

    Правильно ли я понимаю, что ioctl(vcpu_fd, KVM_RUN, 0); вызов блокирующий и выполняется на вызывающем потоке в пользовательском пространстве?

    А как происходит взаимодействие хост вызова kill(9) вызывающей программы и KVM?

    Если виртуальный процессор представлен как хост-поток, то с точки зрения виртуальной машины как выглядит kill рабочего потока? Аппаратный отказ процессора?

    Еще раз спасибо за проделанную работу.


    1. tzlom
      00.00.0000 00:00
      +2

      выглядит как "я перестал существовать"


    1. nick1612 Автор
      00.00.0000 00:00

      Правильно ли я понимаю, что ioctl(vcpu_fd, KVM_RUN, 0); вызов блокирующий и выполняется на вызывающем потоке в пользовательском пространстве?

      Да, все верно.

      А как происходит взаимодействие хост вызова kill(9) вызывающей программы и KVM?

      Если имеется ввиду kill -9 vm_pid, то так же, как и с любым другим процессом, операционная система его просто принудительно завершит.

      Если виртуальный процессор представлен как хост-поток, то с точки зрения виртуальной машины как выглядит kill рабочего потока? Аппаратный отказ процессора?

      Если kill -9, то скорее как будто выдернули шнур питания.


  1. ku4in
    00.00.0000 00:00
    +1

    Отличная статья! Буду ждать продолжения.


  1. Slach
    00.00.0000 00:00
    +1

    Спасибо, побольше бы таких статей на Habr