Host KVM en un par de líneas de código

¡Hola!



Hoy publicamos un artículo sobre cómo escribir un host KVM. Lo vimos en el blog de Serge Zaitsev , lo tradujimos y lo complementamos con nuestros propios ejemplos en Python para quienes no trabajan con C ++.


KVM (máquina virtual basada en kernel) es una tecnología de virtualización que viene con el kernel de Linux. En otras palabras, KVM le permite ejecutar múltiples máquinas virtuales (VM) en un solo host virtual Linux. Las máquinas virtuales en este caso se llaman invitados. Si alguna vez ha utilizado QEMU o VirtualBox en Linux, sabe de lo que es capaz KVM.



Pero, ¿cómo funciona bajo el capó?



IOCTL



KVM expone la API a través de un archivo de dispositivo especial / dev / kvm . Cuando inicia un dispositivo, accede al subsistema KVM y luego realiza llamadas al sistema ioctl para asignar recursos e iniciar máquinas virtuales. Algunas llamadas ioctl devuelven descriptores de archivos, que también se pueden manipular con ioctl. ¿Y así ad infinitum? Realmente no. Solo hay algunos niveles de API en KVM:



  • el nivel / dev / kvm utilizado para administrar todo el subsistema KVM y para crear nuevas máquinas virtuales,
  • la capa de VM utilizada para administrar una máquina virtual individual,
  • el nivel de VCPU utilizado para controlar el funcionamiento de un procesador virtual (una máquina virtual puede ejecutarse en varios procesadores virtuales): VCPU.


Además, existen API para dispositivos de E / S.



Veamos cómo se ve en la práctica.



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


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


En este punto, hemos creado una nueva máquina virtual, le hemos asignado memoria y le hemos asignado una vCPU. Para que nuestra máquina virtual realmente ejecute algo, necesitamos cargar la imagen de la máquina virtual y configurar correctamente los registros del procesador.



Cargando la máquina virtual



¡Es bastante fácil! Simplemente lea el archivo y copie su contenido en la memoria de la máquina virtual. Por supuesto, mmap también es una buena opción.



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


Ejemplo de Python:



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


Se supone que guest.bin contiene un código de bytes válido para la arquitectura de la CPU actual, porque el KVM no interpreta las instrucciones de la CPU, una tras otra, como lo hacía la antigua máquina virtual. KVM le da el cálculo a la CPU real y solo intercepta E / S. Esta es la razón por la que las máquinas virtuales modernas se ejecutan con un alto rendimiento, casi sin sistema operativo, a menos que esté realizando operaciones pesadas de E / S.



Aquí está el pequeño kernel de VM invitado que intentaremos ejecutar primero: Si no está familiarizado con el ensamblador, el ejemplo anterior es un pequeño ejecutable de 16 bits que incrementa un registro en un bucle y envía un valor al puerto 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








Lo compilamos deliberadamente como una aplicación arcaica de 16 bits, porque el procesador virtual KVM lanzado puede funcionar en varios modos, como un procesador x86 real. El modo más simple es el modo "real", que se ha utilizado para ejecutar código de 16 bits desde el siglo pasado. El modo real difiere en el direccionamiento de la memoria, es directo en lugar de usar tablas de descriptores; sería más fácil inicializar nuestro registro para el 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);


Ejemplo de 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)


Corriendo



El código está cargado, los registros están listos. ¿Empecemos? Para iniciar una máquina virtual, necesitamos obtener un puntero al "estado de ejecución" para cada vCPU y luego ingresar un ciclo en el que la máquina virtual se ejecutará hasta que sea interrumpida por E / S u otra operaciones en las que el control se transferirá de nuevo al 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;
	}
}


Ejemplo de 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


Ahora si ejecutamos la aplicación, veremos: ¡Funciona! El código fuente completo está disponible en la siguiente dirección (si nota un error, ¡los comentarios son bienvenidos!).



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

...








¿Lo llamas el núcleo?



Lo más probable es que todo esto no sea muy impresionante. ¿Qué tal ejecutar el kernel de Linux en su lugar?



El comienzo será el mismo: abrir / dev / kvm , crear una máquina virtual, etc. Sin embargo, necesitamos algunas llamadas ioctl más a nivel de máquina virtual para agregar un temporizador de intervalo periódico, inicializar TSS (requerido para chips Intel) y agregar un controlador de interrupción:



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


También necesitaremos cambiar la forma en que se inicializan los registros. El kernel de Linux necesita modo protegido, por lo que lo habilitamos en las banderas de registro e inicializamos la base, el selector y la granularidad 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


¿Cuáles son los parámetros de arranque y por qué no puede arrancar el kernel en la dirección cero? Es hora de aprender más sobre el formato bzImage.



La imagen del núcleo sigue un "protocolo de arranque" especial donde hay un encabezado fijo con parámetros de arranque seguidos del código de bytes del núcleo real. El formato del encabezado de inicio se describe aquí .



Cargando una imagen de kernel



Para cargar correctamente la imagen del kernel en la máquina virtual, primero debemos leer todo el archivo bzImage. Observamos el desplazamiento 0x1f1 y obtenemos el número de sectores de la configuración a partir de ahí. Los omitiremos para ver dónde comienza el código del kernel. Además, copiaremos los parámetros de arranque desde el principio de bzImage al área de memoria para los parámetros de arranque de la máquina virtual (0x10000).



Pero incluso eso no será suficiente. Necesitaremos corregir los parámetros de arranque de nuestra máquina virtual para forzarla al modo VGA e inicializar el puntero de la línea de comandos.



Nuestro kernel necesita enviar registros a ttyS0 para que podamos interceptar E / S y nuestra máquina virtual los imprima en stdout. Para hacer esto, necesitamos agregar "console = ttyS0" a la línea de comando del kernel.



Pero incluso después de eso, no obtendremos ningún resultado. Tuve que configurar una ID de CPU falsa para nuestro kernel (https://www.kernel.org/doc/Documentation/virtual/kvm/cpuid.txt). Lo más probable es que el kernel que armé se basara en esta información para determinar si se estaba ejecutando dentro de un hipervisor o en bare metal.



Utilicé un kernel compilado con una configuración "pequeña" y configuré algunos indicadores de configuración para admitir terminal y virtio (marco de virtualización de E / S para Linux).



El código completo para el host KVM modificado y la imagen del kernel de prueba está disponible aquí .



Si esta imagen no comienza, puede usar otra imagen disponible en este enlace .


Si lo compilamos y lo ejecutamos, obtenemos el siguiente resultado:



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 sigue siendo un resultado bastante inútil: no hay initrd o partición raíz, no hay aplicaciones reales que puedan ejecutarse en este kernel, pero aún prueba que KVM no es una herramienta tan terrible y tan poderosa.



Conclusión



Para ejecutar un Linux en toda regla, el host de la máquina virtual debe ser mucho más avanzado: necesitamos simular varios controladores de E / S para discos, teclado y gráficos. Pero el enfoque general sigue siendo el mismo, por ejemplo, necesitamos configurar los parámetros de la línea de comando para initrd de la misma manera. Los discos deberán interceptar las E / S y responder de forma adecuada.



Sin embargo, nadie le obliga a utilizar KVM directamente. Existe libvirt , una biblioteca agradable y amigable para tecnologías de virtualización de bajo nivel como KVM o BHyve.



Si está interesado en aprender más sobre KVM, le sugiero que consulte la fuente kvmtool . Son mucho más fáciles de leer que QEMU y todo el proyecto es mucho más pequeño y sencillo.



Espero que hayas disfrutado del artículo.



Puede seguir las noticias en Github , Twitter o suscribirse a través de rss .



Vínculos a GitHub Gist con ejemplos de Python de un experto en Timeweb: (1) y (2) .



All Articles