wandres.dev
VIRTUALIZACIÓN · KVM, virtio

La API de KVM: /dev/kvm y el diálogo con QEMU

El contrato completo entre el espacio de usuario y el hipervisor del kernel: abrir /dev/kvm, la jerarquía de descriptores de sistema, de máquina y de vcpu, las ioctls KVM_CREATE_VM, KVM_SET_USER_MEMORY_REGION, KVM_CREATE_VCPU y KVM_RUN, la estructura kvm_run mapeada en memoria compartida por la que se comunican las salidas, y el reparto de trabajo por el que QEMU emula los dispositivos mientras KVM ejecuta la CPU.

⏱ 18 min

El kernel no expone la virtualización con llamadas al sistema nuevas, sino con un dispositivo de caracteres: /dev/kvm. Todo el poder de convertir a Linux en hipervisor se canaliza por ioctl, esa navaja suiza que ya conoces de los drivers. La belleza está en la jerarquía: una ioctl sobre el descriptor del sistema crea una máquina y devuelve un descriptor de máquina; una ioctl sobre la máquina crea una CPU virtual y devuelve un descriptor de vcpu; una ioctl sobre el vcpu lo pone a correr. Con menos de cien líneas de C puedes arrancar código dentro de una máquina virtual real, acelerada por hardware, sin QEMU ni ninguna otra dependencia. Este nivel escribe ese hipervisor mínimo y luego revela cómo QEMU usa exactamente la misma API a gran escala.

🎯 Al terminar esta lección sabrás
  • Comprender la jerarquía de descriptores de KVM: sistema, máquina virtual y vcpu.
  • Crear una VM y darle memoria con KVM_CREATE_VM y KVM_SET_USER_MEMORY_REGION.
  • Ejecutar una CPU con KVM_RUN y leer las salidas por la struct kvm_run compartida.
  • Entender el reparto: QEMU emula dispositivos, KVM ejecuta la CPU.

/dev/kvm: una jerarquía de descriptores

Todo empieza abriendo el dispositivo. El descriptor resultante representa el subsistema KVM en su conjunto —el nivel sistema— y sobre él se consultan capacidades y se crean máquinas. Cada KVM_CREATE_VM devuelve un descriptor nuevo que representa una máquina virtual; cada KVM_CREATE_VCPU sobre esa máquina devuelve otro que representa una CPU virtual. Tres niveles, tres tipos de ioctl, encajados como muñecas rusas:

int kvm = open("/dev/kvm", O_RDWR | O_CLOEXEC);
if (ioctl(kvm, KVM_GET_API_VERSION, NULL) != 12)   /* la version estable es 12 */
	errx(1, "version de API de KVM inesperada");

int vmfd = ioctl(kvm, KVM_CREATE_VM, 0);           /* nace la maquina virtual */

Este diseño no es capricho: al ser descriptores de fichero, la vida de una máquina y de sus vcpus se ata al proceso que los abrió. Si el proceso muere, el kernel cierra los descriptores y destruye la VM. No hay estado huérfano que limpiar; el modelo Unix de “todo es un fichero” gobierna también a los hipervisores.

Crear la VM y darle memoria

Una máquina sin memoria no ejecuta nada. La memoria del invitado es, sencillamente, una región del espacio de direcciones del proceso anfitrión: reservas páginas con mmap y le dices a KVM en qué dirección física del invitado deben aparecer. Esa correspondencia —de una GPA a una dirección de usuario del anfitrión— es lo que registra KVM_SET_USER_MEMORY_REGION:

/* codigo maquina x86 de 16 bits: suma AL += BL, escribe a puerto 0x3f8, HLT */
const uint8_t code[] = {
	0xba, 0xf8, 0x03,   /* mov dx, 0x3f8   ; puerto serie */
	0x00, 0xd8,         /* add al, bl                      */
	0x04, '0',          /* add al, '0'     ; a ASCII       */
	0xee,               /* out dx, al      ; provoca VM exit de E/S */
	0xf4,               /* hlt                              */
};

void *mem = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE,
		 MAP_SHARED | MAP_ANONYMOUS, -1, 0);
memcpy(mem, code, sizeof(code));

struct kvm_userspace_memory_region region = {
	.slot            = 0,
	.guest_phys_addr = 0x1000,          /* donde lo vera el invitado */
	.memory_size     = 0x1000,          /* una pagina */
	.userspace_addr  = (uint64_t)mem,   /* donde vive en el anfitrion */
};
ioctl(vmfd, KVM_SET_USER_MEMORY_REGION, &region);

La estructura es minúscula pero lo dice todo: un slot numerado para poder tener varias regiones, la dirección física del invitado, el tamaño y el puntero al respaldo real. KVM programará las tablas EPT (nivel 53.2) para que, cuando el invitado acceda a la GPA 0x1000, la MMU acabe tocando esas páginas del anfitrión.

El vcpu y KVM_RUN

Ahora la CPU. KVM_CREATE_VCPU devuelve su descriptor, y a través de él se mapea en memoria una estructura compartida entre el kernel y tu proceso: la struct kvm_run. Es el buzón bidireccional por el que KVM te cuenta por qué salió el invitado y tú le pasas datos de vuelta. Su tamaño lo dicta el propio KVM:

int vcpufd = ioctl(vmfd, KVM_CREATE_VCPU, 0);
int mmap_size = ioctl(kvm, KVM_GET_VCPU_MMAP_SIZE, NULL);

struct kvm_run *run = mmap(NULL, mmap_size, PROT_READ | PROT_WRITE,
			   MAP_SHARED, vcpufd, 0);

Falta fijar los registros iniciales. Los de segmento van en kvm_sregs; los generales, incluido el puntero de instrucción rip, en kvm_regs:

struct kvm_sregs sregs;
ioctl(vcpufd, KVM_GET_SREGS, &sregs);
sregs.cs.base = 0; sregs.cs.selector = 0;   /* CS en 0: rip es fisico directo */
ioctl(vcpufd, KVM_SET_SREGS, &sregs);

struct kvm_regs regs = {
	.rip    = 0x1000,   /* donde cargamos el codigo */
	.rax    = 2,
	.rbx    = 2,
	.rflags = 0x2,      /* bit 1 siempre a 1 en x86 */
};
ioctl(vcpufd, KVM_SET_REGS, &regs);

Y por fin el bucle de ejecución. Cada KVM_RUN hace VM entry y no regresa hasta que hay un VM exit que el kernel no pudo resolver solo. Al volver, run->exit_reason dice qué pasó, y para la E/S los datos viven en run->io con un data_offset relativo al inicio de la propia kvm_run:

for (;;) {
	ioctl(vcpufd, KVM_RUN, NULL);
	switch (run->exit_reason) {
	case KVM_EXIT_HLT:                       /* el invitado ejecuto HLT: fin */
		return 0;
	case KVM_EXIT_IO:
		if (run->io.direction == KVM_EXIT_IO_OUT && run->io.port == 0x3f8) {
			char *p = (char *)run;           /* base de la region compartida */
			putchar(p[run->io.data_offset]); /* el byte que escribio el invitado */
		}
		break;
	case KVM_EXIT_FAIL_ENTRY:
	case KVM_EXIT_INTERNAL_ERROR:
		errx(1, "fallo de KVM: reason=0x%x", run->exit_reason);
	}
}

Compila esto, ejecútalo, y verás imprimirse un 4: el invitado sumó 2 + 2, lo convirtió a ASCII y lo sacó por el puerto serie 0x3f8, cuyo acceso provocó el KVM_EXIT_IO que tu bucle atendió. Acabas de escribir un hipervisor.

⚠️
El anfitrión no confía en el invitado, y viceversa

KVM_SET_USER_MEMORY_REGION mapea memoria del anfitrión al invitado, pero el invitado jamás recibe punteros del anfitrión: solo ve sus GPA. Y el anfitrión nunca desreferencia a ciegas lo que el invitado escribe; usa data_offset dentro de la kvm_run con límites verificados por el kernel. Esta desconfianza mutua es la frontera de seguridad de la máquina virtual, mucho más estrecha que la superficie de sistema que comparte un contenedor (nivel 53.5).

Cómo QEMU usa KVM

QEMU es este mismo programa, pero completo. Donde tú atendiste un solo puerto, QEMU tiene un modelo entero de dispositivos: cuando el invitado escribe en el registro de una tarjeta de red emulada, se produce un KVM_EXIT_MMIO o un KVM_EXIT_IO, KVM_RUN regresa y QEMU, en espacio de usuario, ejecuta el código que emula esa tarjeta. El reparto es tajante y es la idea central del nivel:

⚙️

KVM ejecuta la CPU

Vive en el kernel. Hace VM entry, deja correr el código del invitado sobre el metal real, gestiona la paginación anidada y atrapa solo lo privilegiado. No sabe qué es una tarjeta de red.

🖥️

QEMU emula los dispositivos

Vive en el espacio de usuario. Recibe los VM exit de E/S por KVM_RUN, emula discos, redes, BIOS y buses, y devuelve el control. No ejecuta ni una instrucción del invitado.

El bucle de QEMU por vcpu es, despojado de todo, idéntico al tuyo: un hilo que llama a KVM_RUN y despacha según exit_reason.

/* accel/kvm/kvm-all.c: el corazon de cada hilo de vcpu de QEMU */
do {
	run_ret = kvm_vcpu_ioctl(cpu, KVM_RUN, 0);
	switch (run->exit_reason) {
	case KVM_EXIT_IO:
		kvm_handle_io(run->io.port, ...);        /* emula el puerto */
		break;
	case KVM_EXIT_MMIO:
		address_space_rw(&address_space_memory,  /* emula el registro MMIO */
				 run->mmio.phys_addr, ...);
		break;
	/* ... HLT, SHUTDOWN, INTERNAL_ERROR ... */
	}
} while (ret == 0);

Por eso QEMU con KVM es rápido donde importa —la CPU corre nativa— y flexible donde conviene —los dispositivos se emulan en un espacio de usuario que puede depurarse, actualizarse y aislarse sin tocar el kernel. Y por eso, cuando la emulación de dispositivos también se vuelve el cuello de botella, la respuesta no es emular más rápido sino dejar de emular: paravirtualizar con virtio (nivel 53.4).

El hipervisor cabe en una ioctl porque el kernel ya es el hipervisor

Vuelve sobre lo que acabas de escribir y mide su asombro. Con open, tres ioctl de creación, un mmap y un bucle has puesto a correr otra CPU dentro de la tuya, acelerada por el silicio, aislada de tu proceso, capaz de ejecutar un sistema operativo entero. No hubo que enlazar con ninguna biblioteca de virtualización, ni cargar un hipervisor, ni pedir permisos exóticos: bastó hablar con un dispositivo de caracteres por el mismo ioctl con el que un día configuraste un puerto serie. Esa economía no es casualidad, es la tesis entera de KVM hecha interfaz. El trabajo difícil —planificar el vcpu como un hilo, respaldar la memoria del invitado con páginas paginables, programar las EPT, ejecutar VMLAUNCH— ya lo hace el kernel, porque el kernel ya sabía planificar, paginar y mandar sobre el hardware. Lo único que la API de KVM te concede es un asa: un puñado de verbos para pedirle al kernel que aplique a un huésped de rango kernel las capacidades que llevaba cincuenta niveles aplicando a los procesos. Y fíjate en la simetría con la que se cierra el círculo del oficio. Un proceso normal le pide servicios al kernel con llamadas al sistema; tú, escribiendo un hipervisor, le pides al kernel que ejecute otro kernel con ioctl. La frontera entre usar el sistema operativo y fabricar máquinas donde corran sistemas operativos resulta ser del grosor de un descriptor de fichero. Comprender eso es dejar de ver la virtualización como una tecnología aparte y verla como lo que es: la última llamada al sistema, la que pide no un recurso, sino una máquina entera.

⚔️ Escribe tu propio hipervisor mínimo
  1. Compila el ejemplo completo de arriba y confirma que imprime 4; explica qué VM exit produjo la instrucción out dx, al.
  2. Cambia el código máquina para que sume 5 + 3 y verifica la salida; razona por qué rip debe apuntar a 0x1000.
  3. Añade un segundo KVM_EXIT_IO interceptando otro puerto y describe cómo distinguirías cuál lo provocó.
  4. Explica en cuatro líneas la jerarquía de descriptores sistema→máquina→vcpu y qué ocurre con la VM si tu proceso recibe un SIGKILL.
  5. Sitúa en el bucle de QEMU dónde acaba KVM y empieza la emulación, y argumenta por qué esa frontera es a la vez su fortaleza de rendimiento y de seguridad.