Базові поняття роботи віртуалізації на сучасних x64 процесорах

Перед тим як розбиратись як працює KVM з ядром Лінукса, важливо розуміти як працює віртуалізація, і які механізми процесора використовуются для цього.


VT-x - x86_64 CPU Intel Virtualization Technology

По суті технологія забезпечує підтримку “легальної” віртуалізації. Але ж технологія була додана тільки в 2005 році, виникає питання: До 2005 року не було віртуалізації? До 2005 року використовувались специфічні техніки обходу обмежень системи, серед самих відомих:

  • VMWare Workstation (1999): Dynamic Binary Translation - не вдаваючись у подробниці, гіпервізор (type 2) робив бінарну підстановку деяких викликів гостьової машини для забезпечення правильного доступу до ресурсів системи. Мінуси цього підходу буле суттуєве зменшення продуктивності гостьової віртуальної машини.
  • Xen(2003): Hypercalls механізм яких вимагав зміну гостьової операційної системи і полягав в підстановці процедур гіпервізора замість деяких CPU інструкцій які робили систему неможливою до віртуалізації. Мінус цього підходу: Зміна гостової операційної системи, що ламає вимоги до віртуалізації (неможливо змінити не OpenSource систему)

Технологія VT-x додала новий режим роботи процесора (який ортогональний до інсуючих) root mode в якому знаходится система (host) та гіпервізор, та non-root mode в якому запускаєтся гостьова система.

VT-x modes

Не вдаваючись у деталі імплеменації (ця доповідь не про це) ця невелика зміна повністю забезпечила всі вимоги до віртуалізації.

Extended Page Tables

Доступ віртуальної машини до памʼяті ще одна проблема яка була вирішена за допомогою hardware підтримки, але вона була додана значно пізніше VT-x.

Розглядаються тільки системи з підтримкою Paging, ранні x86 з pure segmentaion не розглядаются.

Використовуючи памʼять, віртуальна машина повинна мати доступ тільки для своєї виділеної області, також вона повинна працювати в середовіщі де буде можливість контролювати всю свою памʼять, MMU та TLB також повинні бути свої, і кожний page fault повинен менаджитись гіпервізором. До підтримки Extended Page Tables використовувались наступні рішення:

  • Shadowing Page Tables + page fault tracing(VMWare): механізм при якому гіпервізор зберігає в памʼяті Page Table гостьвих віртуальних машин, і при старті віртуальної машини, гіпервізор підміняє системну таблицю (%cr3) на shadowing page table, яка зберігає virtual guest address -> physical host address (яких визначає гіпервізор).
  • Paravirtualization(Xen) інший підхід це Hypercalls для віртуалізації як у CPU описаний вище. В двух словах, система віртуальної машини патчилась так, щоб система розрізняла фізичну памʼять host від памʼяті виділеної під роботу guest OS, тому і не немагалась отримати доступ до недоступних ділянок.

Технологія Extended Page Tables була описана о 2008 році, та згодом Intel, AMD запровадили свої реалізації в чіпи. Основне положення технології, це те що в хардвейрі комбінуєтся звичайна page table з іншою новою таблицею яка підтримуєтся гіпервізором і зберігає guest-physical -> host-physical відповідність.

З Extended Page Tables, структура TLB не змінилась, вона все ще зберігає відповідність virtual -> physical pages, проте логіка TLB miss змінилась значно.

TLB miss

На зображені наведено реалізацію TLB lookup, в x86_64 середовищі з host системою з page level 4, та guest ситемою з pagel level 4. Як ми бачимо для віртуального середовища алгоритм квадратичний, що не дуже єффективно, але задяки різним оптимізаціям та правильно підібраним структурам данних, цей процес може відбуватись досить отпимізовно, хоча значно довше ніж в не віртуальному середовищі. До того ж shadowing page table вимагає для коректної роботи контроль над кожним записом в памʼять, що забезпечуєтся досить накладною логікою, а Paravitualization, вимагає модифікацію ядра, що часто не можливо (Windows віртуалізація).

KVM

KVM - Linux-based Kernel Virtual Machine, це найпопулярніший type-2 гіпервізор з відкритим кодом, який є офіційно підримуємий багатьма дестрибутивами, та був доданий у код лінукса у 2007 році.

QEMU - використовуєтся для емулювання IO KVM гіпервізором. Aле і без KVM, це потужній інструмент для IO/CPU емуляції, та бінарної трансляції.

Qemu - відповідальний за емуляцію IO фронтенду, на стороні користувача (userspace), при тому KVM kernel module, вдповідальний за мюлтіплексію MMU (Памʼять), та CPU.

KVM Kernel Module

Хоча KVM і type 2 гіпервізор, для коректної роботи все рівно необхідно мати доступ для системних ресурсві щоб мати контроль над емуляцією x86 процесору, MMU, та переривання IO (APIC, IOAPIC, etc.). Але всі функції емуляції IO контролюются в userspace

KVM trap На картинці можна побачити всю основну логіку kvm kernel модуля від VT-x виклику #vmexit, до #vmenter під час kernel trap.

Під час #vmexit, KVM kernel module аналізує причину виходу (vcms.exit_reason), та викликає відповідних хєндлер для причини (handler_* в arch/x86/kvm/vmx.c) В залежності від vcms полів, KVM може робити наступне:

  • емулювати семантику інструкції, та збільшити instruction pointer
  • вирішити що fault або interupt, повинен бути оброблений гостовим обробником, в цьому випадку KVM підміняє стек ферйм, і відновлює роботу на інструкції яка вказує на guest interrupt descriptor table.
  • змінити середовеще і спробувати ще раз запустити інструкцію (наприклад при EPT violation)
  • нічго не робити (наприкоад при external interrupt)
/*
 * 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,
....

Але іноді інформації для того щоб вибрати правильний хендленр не достатньо, інформації з VCMS, треба ще розуміти яка інструкція викликала #vmexit, для цього в KVM є 5000+ строчок коду емулятора в (arch/x86/kvm/emulate.c) який досить таки складний і низькорівневий, тому я не буду його описувати. Але цей код є цікавим місцем в KVM, так як він повний багів та проблем, навіть після того як частина цього коду була винесена в userspace, щоб полегшити пошук проблем.

Роль host операційної системи у віртуалізації CPU

Навідміну від VMWare Workstation та VirtualBox, KVM розроблялась як тісно інтегрована з Linux (навіть perf toolkit, має спеціальний модуль щоб профілювати KVM)

Якщо до цього я показував логіку роботи модуля ядра, то зараз я наведу процес роботи KVM в цілому на host операційній системі.

KVM execution loop

Для простоти наведина діаграма для одного CPU. Зовнішній цикл (usermode)

  • Виклик до KVM Kernel module через ioctl до /dev/kvm
  • Система виконує guest код до моменту поки
    • Віртуальна машина ініціює IO через IO Instruction/Memory mapped IO
    • Хост система отримує external IO, або timer interrupt
  • Qemu емулює ініційоване IO якщо це потрібно

Внутрішній цикл (kernel mode)

  • Відновлює стан virtual CPU
  • Входнить в non-root mode через #vmresume інструкцію (в цей момент віртуальна машина працює до моменту #vmexit)
  • Обробляє #vmexit відповідно до vcms.exit_reason як було описано вище
  • Якщо exit_reason == IO, або memory_mapped IO, то зупиняє цикл та повертаєтся в userspace
  • Якщо exit_reason == interup і зумовлений зовнішнім івентом (наприклад IO до іншої програми, або CPU sheduler вирішив запустити інший процес)

Віртуалізація памʼяті в KVM

Хоча KVM була розроблена і до технології Extended Page Tables, і може підтримувати віртуальні машини та операційні системи в яких виключена ця технологія, проте такі машини рідко зустрічаются, і дуже лімітовані в своїх можливостях.

KVM Virtual memory management Перш за все, існує 3 різні page table, які менаджатся різними сутностями (показано червоними точками).

  • Qemu як userspace процес, алокує памʼять для віртуальної машини як порцію своєї віртуальної памʼяті це було зроблено по декільком причинам:
    • Це залишає деталі алокації та менеджменту памʼяті хостовій операційній системі (як це потребує реалізація type-2 гіпервізора)
    • Це дає змогу з userspace котролювати гостьову фізичну памʼять, це корисно наприклад для IO DMA. Linux менаджить памʼять віртуальної машини як віртуальну памʼять будь-якого процесу, коли процесс QEMU запланований (scheduled) то page table хоста вміщає в собі як памʼять яку використовує QEMU, так і памʼять яку використовує віртуальна машина
  • Віртуальна машина відповідальна за свою власну page table, яка описує область памʼяті guest-virtual -> guest-physical, ця таблиця під ексклюзивним контролем віртуальної машини.

Також важливо зауважити одне спостереження описане на малюнку: Для гіпервізора легко отримати доступ до гостьового фізичного адресного простору: процес(userspace) може просто додати постійне зміщення для посилання на місце пам’яті. Сам модуль KVM фактично може використовувати той самий підхід, оскільки процес простору користувача вже відображає адресний простір. Однак для гіпервізора набагато важче та складніше отримати доступ до поточних віртуальних адресних просторів віртуальної машини, оскільки ці відображення присутні лише в MMU процесора, коли віртуальна машина виконується, але не під час виконання гіпервізора.

Однак іноді доводится і працювати з віртуальною адресою гостової машини, наприклад під час декодінгу інструкції яка виклика trap, або операндів в памʼяті. Результатом цього є те що декодер та емулятор робить багато надлишкових звернень до гостової page table, щоб визначити знаходження фізичного адресу.

Оптимізація віртуальної памʼяті

  • Linux KSM механізм, дозволяє спільний доступ до памʼяті у декількох віртуальних машин, працює як COW механізм.
  • Memory Balooning, дозвозяє виділяти більше памʼяті віртуальній машині яка це потребує.

Практичне використання KVM як userspace VM

Напишемо просту програму яка запускає BIOS (в нашому випадку SeaBios) в віртуальній машині.

Спочатку треба відкрити файл /dev/kvm/ для роботи з KVM

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

створемо абстракцію віртуальної машини:

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

Після виклику KVM_CREATE_VM, система повертає дескриптор віртуальної машини, яка не має ні CPU, ні памʼяті Створемо 1 vCPU

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

Тепер виділемо памʼять, в яку запишемо SeaBIOS Не вдаваючись у потробиці віділення памʼяті для SeaBIOS це буде вигядати так:

#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:

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);

Налаштуємо CPU

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.

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);

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

#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 KVM переводить процесор в режим non-root виклоком #vmlaunch, далі виконуєтся код SeaBIOS, до поки він не почне зчитувати або читати з порта.

Результат

Так як не було емульяції ніякого hardware, то BIOS нічого і не знаходить, проте запускаєтся нормально:

make 
...
dmalovan> ./bin
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.
...

Заключення

В цій статті було описано базові внутрішності KVM та QEMU. І продемонстровано як можна запустити віртуальну машину маючи тільки KVM. В наступних статтях я покажу як KVM/QEMU працює з IO через virtIO, та також покажу особливості віртуалізації на ARM