Hello KVM
Table of Contents
1. Hello KVM
1.1. hello world
https://github.com/dpw/kvm-hello-world
https://www.cse.iitb.ac.in/~mythili/virtcc/pa/pa1.html
一个简单的基于 KVM 的 userspace 的 hypervisor 的工作流程是:
- 通过 ioctl (KVM_CREATE_VM) 创建 vm
- 通过 mmap 分配一段内存, 用 ioctl(KVM_SET_USER_MEMORY_REGION) 把它设置为 vm 的 `物理`内存, 具体如何工作的参考
- 通过 ioctl(KVM_CREATE_VCPU) 创建 vcpu
- 通过 mmap(vcpu->fd) 返回一段内存(struct kvm_run), kvm 和 hypervisor 可以通过它交换数据, 例如 exit_reason, io 请求的信息等
- 加载代码到 vm 的物理内存 (通过第 2 步的 mmap)
- 通过 ioctl(KVM_GET_SREGS) 和 ioctl(KVM_SET_SREGS) 设置 guest 代码的入口 (例如修改 x86 的 rip)
- 通过 ioctl(KVM_RUN) 执行 guest 代码
- hypervisor 会阻塞在这个 ioctl 上, 当 ioctl 返回后, 检查 kvm_run 中的信息. 例如, 若 kvm_run->exit_reaon 为 KVM_EXIT_HLT, 则表示 guest 执行结束, 若 exit_reason 为 KVM_EXIT_IO, 则表示需要进行 io, hypervisor 需要从 kvm_run->io 中获得具体的 io 请求的信息, 然后代表 guest 进行 io 处理
- 若 guest 没有结束, 则重复 7
1.1.1. x86 保护模式
要开启 x86 保护模式, 需要:
- 设置 gdt (globa description table) 中对应的 cs 的 segment descriptor
- 设置 cr0
KVM_GET_SREGS 和 KVM_SET_SREGS 使用的 kvm_sregs 结构中, 大部分直接对应寄存器的值 (例如 kvm_sregs->cr0), 但涉及到 cs, ss 等 segment register 时 (例如 kvm_sregs->cs) 它包含的实际是 cs 对应的 segment descriptor 的数据. kvm 会负责把它和 gdt 里对应的数据对应起来.
具体代码如下:
static void setup_protected_mode(struct kvm_sregs *sregs) { struct kvm_segment seg = { .base = 0, .limit = 0xffffffff, .selector = 1 << 3, .present = 1, .type = 11, /* Code: execute, read, accessed */ .dpl = 0, .db = 1, .s = 1, /* Code/data */ .l = 0, .g = 1, /* 4KB granularity */ }; sregs->cr0 |= CR0_PE; /* enter protected mode */ sregs->cs = seg; seg.type = 3; /* Data: read/write, accessed */ seg.selector = 2 << 3; sregs->ds = sregs->es = sregs->fs = sregs->gs = sregs->ss = seg; if (ioctl(vcpu->fd, KVM_SET_SREGS, &sregs) < 0) { perror("KVM_SET_SREGS"); exit(1); } }
1.1.2. 使用分页
在保护模式下通过配置 cr0 可以开启分页, 同时需要配置 cr3 为 pd (page directory) 基址, 例如:
static void setup_paged_32bit_mode(struct vm *vm, struct kvm_sregs *sregs) { uint32_t pd_addr = 0x2000; uint32_t *pd = (void *)(vm->mem + pd_addr); /* A single 4MB page to cover the memory region */ pd[0] = PDE32_PRESENT | PDE32_RW | PDE32_USER | PDE32_PS; /* Other PDEs are left zeroed, meaning not present. */ sregs->cr3 = pd_addr; sregs->cr4 = CR4_PSE; sregs->cr0 = CR0_PE | CR0_MP | CR0_ET | CR0_NE | CR0_WP | CR0_AM | CR0_PG; sregs->efer = 0; if (ioctl(vcpu->fd, KVM_SET_SREGS, &sregs) < 0) { perror("KVM_SET_SREGS"); exit(1); } }
上面的代码中只设置了 pd[0] 的一项, 通过 cr4 开启了 4MB page (默认为 4KB page), 且其对应 dir ([31..22]) 为默认值 0, 且, 意味着 4MB 范围内的 va (虚拟地址) 和 pa (物理地址) 是相同的
另外, 代码中设置 pd 地址为 0x2000 (8K), 是因为 guest image 被 load 到 0x0, 且它的大小不超过 8K, 因此这个地址是空闲可用的.
1.2. VMX
https://nixhacker.com/developing-hypervisior-from-scratch-part-1/
https://www.cse.iitb.ac.in/~mythili/virtcc/slides_pdf/04-hwvirt-kvmqemu.pdf
使用 VMX 实现一个简化的 kvm: https://github.com/shubham0d/ProtoVirt
VMX 主要的指令有:
- VMXON - Enable VMX
- VMXOFF - Disable VMX
- VMLAUNCH - Start/enter VM
- VMRESUME - Re-enter VM
- VMCLEAR - Null out/reinitialize VMCS
- VMPTRLD - Load the current VMCS
- VMPTRST - Store the current VMCS
- VMREAD - Read values from VMCS
- VMWRITE - Write values to VMCS
- VMCALL - Exit virtual machine to VMM
- VMFUNC - Invoke a VM function in VMM without exiting guest operation
VMX 的核心是 VMCS (vm control structure), 它是由 host 分配的 buffer, 且 host 和 guest 都能操作它.
它包含了以下信息:
- control
- host state
- guest state
- exit reason
VMCS 的作用有:
kvm 的许多操作都是通过读写 VMCS 实现的, 例如 KVM_SET_SREGS 实际就是修改了 VMCS 中 guest state 中的 sreg 部分, 例如:
void vmx_set_cr0(struct kvm_vcpu *vcpu, unsigned long cr0): // ... vmcs_writel(GUEST_CR0, hw_cr0); __vmcs_writel(field, value); vmx_asm2(vmwrite, "r"(field), "rm"(value), field, value); // ...
- VMCS 同时也控制着 non-root 模式下 guest 怎么运行, 例如执行哪些指令时需要通过VM Exit 退回到 root 模式
- kvm_run 结构体中的 exit_reason 等也是从 VMCS 复制过来的.
- guest 执行时会通过 VMCS 恢复 guest 上下文, 从而读到修改后寄存器值
1.3. qemu
qemu kvm 与 QEMU TCG 属于不同的 accelerator, 都需要实现 AccelOpsClass, kvm 对应的 ops->create_vcpu_thread 为 kvm_start_vcpu_thread, 最终会执行 kvm_cpu_exec
kvm_init: kvm_memory_listener_register(s, &s->memory_listener, &address_space_memory, 0, "kvm-memory"); // ... kvm_region_add(MemoryListener *listener,MemoryRegionSection *section); kvm_set_user_memory_region(kml, mem, false); kvm_vm_ioctl(s, KVM_SET_USER_MEMORY_REGION, &mem); int kvm_cpu_exec(CPUState *cpu): do: if (cpu->vcpu_dirty): kvm_arch_put_registers(cpu, KVM_PUT_RUNTIME_STATE); ret = kvm_getput_regs(x86_cpu, 1); kvm_getput_reg(®s.rip, &env->eip, set); cpu->vcpu_dirty = false; run_ret = kvm_vcpu_ioctl(cpu, KVM_RUN, 0); switch (run->exit_reason): case KVM_EXIT_IO: kvm_handle_io(run->io.port, attrs, (uint8_t *)run + run->io.data_offset, run->io.direction, run->io.size, run->io.count); break;
1.4. hardware vs. software virtualization
https://docs.oracle.com/en/virtualization/virtualbox/6.0/admin/swvirt-details.html
https://cseweb.ucsd.edu/~yiying/cse291j-winter20/reading/Virtualize-CPU.pdf
软件虚拟化大约分成几种情况:
- guest 和 hypervisor 都运行在 ring 3, hypervisor 通过模拟来执行每一条 guest 指令, 例如 spike
- guest 运行在 ring 3, hypervisor 通过 driver 运行在 ring 0, guest 可以直接大部分指令, 但是当 guest 执行特权指令时, 有两种情况:
- 处理器会发生 trap, hypervisor 处理这个 trap
- 处理器不会发生 trap (例如 x86 上的 popf), 这时需要 hypervisor 提前把 popf 翻译, 解释或 patch 成 trap 指令, 例如 virtualbox, vmware
- 处理器不会发生 trap, 需要直接修改 guest 代码, 让它用 trap 代替前面的指令, 即半虚拟化 (paravirtualization), 例如 xen
硬件虚拟化时, guest 运行在 ring0, 大部分特权指令都不是问题, 但某些特定指令例如 IO 或 MMU 相关的指令还是需要 guest 能 VMEXIT 以便 hypervisor 接手做特殊处理.
硬件虚拟化并不是完美的, 例如 guest 进行 IO 操作可能会这样操作:
void nic_write_buffer(char* buf, int size) { for (int i = 0; i < N; i++) { outb(NIC_TX_BUF, *buf++); } }
这个操作都会导致大量的 VMEXIT, 每个 VMEXIT 大约需要 200 个 cycle, 会造成巨大的开销.
如果我们能够修改 guest 的代码, 可以变成这样:
void nic_write_buffer(char* buf, int size) { vmm_write(NIC_TX_BUF, buf, size); }
只需要一次 VMEXIT 即可, 但这需要修改 guest 和 hypervisor 的代码, 相当于 IO 的半虚拟化, qemu 的 virtio 即是这种思想.
1.5. MMU Virtualization
https://www.linux-kvm.org/page/Memory
https://www.cse.iitb.ac.in/~mythili/virtcc/slides_pdf/06-memvirt.pdf
MMU 虚拟化涉及到几个不同的地址:
- GVA, guest virtual address
- GPA, guest physical address
- HVA, host virtual address
- HPA, host physical address
guest 有自己的页表, 但页表查到的 GPA 并不是真正的物理地址. guest 上地址查找的过程是: GVA -> GPA -> HVA -> HPA
- GVA -> GPA 由 guest 页表负责
- GPA -> HVA 由 host 自己维护, 例如 kvm 中的 KVM_SET_MEMORY_REGION
- HVA -> HPA 由 host 页表负责
通过 VMX 的 EPT (Extended Page Tables) 或软件的 shadow page table 可以快速完成 GPA -> HPA 的过程.
1.6. IO Virtualization
https://howtovms.wordpress.com/2018/04/24/virtio/
https://www.cse.iitb.ac.in/~mythili/virtcc/slides_pdf/07-iovirt.pdf
https://blogs.oracle.com/linux/post/introduction-to-virtio
https://www.redhat.com/en/blog/deep-dive-virtio-networking-and-vhost-net
kvm 默认的 IO 处理方式是模拟的:
- 对于 pio (Programmed IO), 处理器的 trap 导致 VMEXIT, userspace hypervisor 进行 IO 并把数据通过 kvm_run 返回给 guest
- 对于 mmio (Memory Mapped IO), shadow page fault 导致 VMEXIT, kvm 负责确认 memory 访问来自 mmio page, 在 kvm_run 中构造 KVM_EXIT_MMIO 数据 (pa, is_write, data, len), 通过 VMEXIT 返回给 userspace hypervisor 处理. 同时 kvm 不会填充 shadow page table, 确保下次 mmio 还可以触发 page fault
qemu virtio 的处理方式是半虚拟化的: virtio 通过 guest 端的 virtio front-end driver 打包 IO 请求, 通过一次 VMEXIT 发送给 hypervisor 端的 back-end driver, 双方用 ring buffer 交换数据
另外, 还可以用 VFIO (Virtual Function IO) 以 pass through 的方式访问外设.
1.7. RISC-V H extension
https://arxiv.org/pdf/2103.14951.pdf
file:///home/sunway/download/riscv-privileged-20211203.pdf
https://github.com/kvm-riscv/linux
H 扩展没有 VMCS, 相关的 host, guest 的上下文都保存在 kvm_vcpu_arch 中. 修改寄存器时直接修改 kvm_vcpu_arch 中的数据, 例如 kvm_riscv_vcpu_set_reg_core
VS 有自己独立的 superior CSR (例如 vsepc, vsatp), 可以加速 VS 和 HS 之间的上下文切换: 这些 CSR 在 HS/VS 切换时不需要保存, 只在多个 VS 切换时需要保存, 参考 kvm_arch_vcpu_load
HS 与 VS 之间的切换是通过标准的 ecall/sret, 参考 kvm-riscv 的 __kvm_riscv_switch_to 函数