Начнем с простого вопроса:
Что означает 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 ESTThe 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, ®ion);
Память виртуальной машины представляется в виде одного или более непрерывных регионов, каждый из которых имеет начальный физический адрес (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, ®ion) < 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. Если я где-то ошибся по ходу изложения, то надеюсь, что в комментариях меня поправят.