Host KVM em algumas linhas de código

Olá!



Hoje estamos publicando um artigo sobre como escrever um host KVM. Vimos no blog de Serge Zaitsev , traduzimos e complementamos com nossos próprios exemplos em Python para quem não trabalha com C ++.


KVM (Kernel-based Virtual Machine) é uma tecnologia de virtualização que vem com o kernel Linux. Em outras palavras, o KVM permite que você execute várias máquinas virtuais (VMs) em um único host virtual Linux. As máquinas virtuais, neste caso, são chamadas de convidados. Se você já usou QEMU ou VirtualBox no Linux, sabe do que o KVM é capaz.



Mas como isso funciona nos bastidores?



IOCTL



O KVM expõe a API por meio de um arquivo de dispositivo especial / dev / kvm . Ao iniciar um dispositivo, você acessa o subsistema KVM e, em seguida, faz chamadas de sistema ioctl para alocar recursos e iniciar máquinas virtuais. Algumas chamadas de ioctl retornam descritores de arquivo, que também podem ser manipulados com ioctl. E assim por diante, ad infinitum? Na verdade não. Existem apenas alguns níveis de API no KVM:



  • o nível / dev / kvm usado para gerenciar todo o subsistema KVM e para criar novas máquinas virtuais,
  • a camada VM usada para gerenciar uma máquina virtual individual,
  • o nível de VCPU usado para controlar a operação de um processador virtual (uma máquina virtual pode ser executada em vários processadores virtuais) - VCPU.


Além disso, existem APIs para dispositivos de E / S.



Vamos ver como fica na prática.



// KVM layer
int kvm_fd = open("/dev/kvm", O_RDWR);
int version = ioctl(kvm_fd, KVM_GET_API_VERSION, 0);
printf("KVM version: %d\n", version);

// Create VM
int vm_fd = ioctl(kvm_fd, KVM_CREATE_VM, 0);

// Create VM Memory
#define RAM_SIZE 0x10000
void *mem = mmap(NULL, RAM_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE, -1, 0);
struct kvm_userspace_memory_region mem = {
	.slot = 0,
	.guest_phys_addr = 0,
	.memory_size = RAM_SIZE,
	.userspace_addr = (uintptr_t) mem,
};
ioctl(vm_fd, KVM_SET_USER_MEMORY_REGION, &mem);

// Create VCPU
int vcpu_fd = ioctl(vm_fd, KVM_CREATE_VCPU, 0);


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


Nesta etapa, criamos uma nova máquina virtual, alocamos memória para ela e atribuímos uma vCPU. Para que nossa máquina virtual realmente execute algo, precisamos carregar a imagem da máquina virtual e configurar corretamente os registros do processador.



Carregando a máquina virtual



É bastante fácil! Basta ler o arquivo e copiar seu conteúdo para a memória da máquina virtual. Claro, o mmap também é uma boa opção.



int bin_fd = open("guest.bin", O_RDONLY);
if (bin_fd < 0) {
	fprintf(stderr, "can not open binary file: %d\n", errno);
	return 1;
}
char *p = (char *)ram_start;
for (;;) {
	int r = read(bin_fd, p, 4096);
	if (r <= 0) {
		break;
	}
	p += r;
}
close(bin_fd);


Exemplo Python:



    # Read guest.bin
    guest_bin = load_guestbin('guest.bin')
    mem[:len(guest_bin)] = guest_bin


Presume-se que guest.bin contém um byte-code válido para a arquitetura atual da CPU, porque o KVM não interpreta as instruções da CPU, uma após a outra, como fazia a máquina virtual antiga. O KVM fornece cálculos para a CPU real e apenas intercepta E / S. É por isso que as máquinas virtuais modernas são executadas com alto desempenho, quase totalmente vazio, a menos que você esteja executando operações pesadas de E / S.



Aqui está o minúsculo kernel da máquina virtual convidada que tentaremos executar primeiro: Se você não estiver familiarizado com o assembler, o exemplo acima é um minúsculo executável de 16 bits que incrementa um registro em um loop e gera um valor para a porta 0x10.



#

# 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








Nós o compilamos deliberadamente como um aplicativo arcaico de 16 bits, porque o processador virtual KVM lançado pode operar em vários modos, como um processador x86 real. O modo mais simples é o modo "real", que tem sido usado para executar código de 16 bits desde o século passado. O modo real difere no endereçamento de memória, é direto em vez de usar tabelas de descritores - seria mais fácil inicializar nosso registro para o modo real:



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


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


Corrida



O código está carregado, os registros estão prontos. Vamos começar? Para iniciar uma máquina virtual, precisamos obter um ponteiro para o "estado de execução" de cada vCPU e, em seguida, inserir um loop no qual a máquina virtual será executada até ser interrompida por E / S ou outro operações em que o controle será transferido de volta para o host.



int runsz = ioctl(kvm_fd, KVM_GET_VCPU_MMAP_SIZE, 0);
struct kvm_run *run = (struct kvm_run *) mmap(NULL, runsz, PROT_READ | PROT_WRITE, MAP_SHARED, vcpu_fd, 0);

for (;;) {
	ioctl(vcpu_fd, KVM_RUN, 0);
	switch (run->exit_reason) {
	case KVM_EXIT_IO:
		printf("IO port: %x, data: %x\n", run->io.port, *(int *)((char *)(run) + run->io.data_offset));
		break;
	case KVM_EXIT_SHUTDOWN:
		return;
	}
}


Exemplo 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


Agora, se executarmos o aplicativo, veremos: Funciona! O código-fonte completo está disponível no seguinte endereço (se você notar um erro, comentários são bem-vindos!).



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

...








Você chama isso de núcleo?



Provavelmente, tudo isso não é muito impressionante. Que tal rodar o kernel do Linux?



O início será o mesmo: abrir / dev / kvm , criar uma máquina virtual etc. No entanto, precisamos de mais algumas chamadas ioctl no nível da máquina virtual para adicionar um cronômetro de intervalo periódico, inicializar o TSS (necessário para chips Intel) e adicionar um controlador de interrupção:



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


Também precisaremos alterar a maneira como os registros são inicializados. O kernel do Linux precisa do modo protegido, então o ativamos nos sinalizadores de registro e inicializamos a base, o seletor e a granularidade para cada caso especial:



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


Quais são os parâmetros de inicialização e por que você não pode simplesmente inicializar o kernel no endereço zero? É hora de aprender mais sobre o formato bzImage.



A imagem do kernel segue um "protocolo de inicialização" especial onde há um cabeçalho fixo com parâmetros de inicialização seguidos pelo bytecode real do kernel. O formato do cabeçalho de inicialização é descrito aqui .



Carregando uma imagem de kernel



Para carregar corretamente a imagem do kernel na máquina virtual, precisamos ler todo o arquivo bzImage primeiro. Olhamos para o deslocamento 0x1f1 e obtemos o número de setores da configuração a partir daí. Vamos ignorá-los para ver onde o código do kernel começa. Além disso, copiaremos os parâmetros de inicialização do início de bzImage para a área de memória para os parâmetros de inicialização da máquina virtual (0x10000).



Mas mesmo isso não será suficiente. Precisamos corrigir os parâmetros de inicialização de nossa máquina virtual para forçá-la no modo VGA e inicializar o ponteiro da linha de comando.



Nosso kernel precisa se logar em ttyS0 para que possamos interceptar o I / O e nossa máquina virtual imprima em stdout. Para fazer isso, precisamos adicionar "console = ttyS0" à linha de comando do kernel.



Mas mesmo depois disso, não obteremos nenhum resultado. Tive que definir um falso CPU ID para nosso kernel (https://www.kernel.org/doc/Documentation/virtual/kvm/cpuid.txt). Provavelmente, o kernel que criei se baseou nessas informações para determinar se estava sendo executado em um hipervisor ou em bare metal.



Eu usei um kernel compilado com uma configuração "minúscula" e configurei alguns sinalizadores de configuração para suportar terminal e virtio (framework de virtualização de E / S para Linux).



O código completo para o host KVM modificado e a imagem do kernel de teste estão disponíveis aqui .



Se esta imagem não iniciar, você pode usar outra imagem disponível neste link .


Se o compilarmos e executarmos, obteremos a seguinte saída:



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.
...


Obviamente, este ainda é um resultado bastante inútil: sem initrd ou partição raiz, sem aplicativos reais que poderiam ser executados neste kernel, mas ainda prova que KVM não é uma ferramenta terrível e muito poderosa.



Conclusão



Para executar um Linux completo, o host da máquina virtual precisa ser muito mais avançado - precisamos modelar vários drivers de E / S para discos, teclado, gráficos. Mas a abordagem geral permanece a mesma, por exemplo, precisamos configurar os parâmetros da linha de comando para initrd da mesma maneira. Os discos precisarão interceptar E / S e responder de forma adequada.



No entanto, ninguém está forçando você a usar o KVM diretamente. Existe a libvirt , uma boa biblioteca amigável para tecnologias de virtualização de baixo nível como KVM ou BHyve.



Se você estiver interessado em aprender mais sobre KVM, sugiro olhar para o código- fonte kvmtool . Eles são muito mais fáceis de ler do que QEMU e todo o projeto é muito menor e mais simples.



Espero que você tenha gostado do artigo.



Você pode acompanhar as novidades no Github , Twitter ou se inscrever via rss .



Links para o GitHub Gist com exemplos Python de um especialista do Timeweb: (1) e (2) .



All Articles