This article also has an English version .
本系列文章主要记录我在尝试用 Rust 实现一个 Hypervisor 的过程。
为什么写这个系列?几个月前在我业余探索 KVM 的过程中我遇到了一些困难,而互联网上很多文章都没能很好地解释清楚,并且也没有一篇文章能够从零到一地构建一个 VMM 并讲清楚每个 Magic Number 的含义和原因。希望我的分享可以一定程度上让初学者少走一些弯路。当然,我也免不了会有一些错误理解,欢迎各位指正。
目录:
用 Rust 实现极简 VMM - 基础
用 Rust 实现极简 VMM - 模式切换
用 Rust 实现极简 VMM - 运行真实的 Linux Kernel
用 Rust 实现极简 VMM - 实现 Virtio 设备
本文是系列的第一篇,主要做一些科普,并能跑起来一段实际的代码。
近年来用 Rust 实现的 microvm 似乎越来越多,从 crosvm 到 firecracker,后面华为和 Intel 也分别做出 stratovirt 和 cloud hypervisor。其主要原因是,作为基础设施,对性能和安全性有极高的要求。基于 Rust 我们可以将 unsafe 代码控制在较小的范围内,以此来尽力避免内存安全问题。
做这么一个 Hypervisor 复杂吗?基于 KVM 做一个极简的 Hypervisor 非常简单,复杂性更多的体现在模拟设备上。本系列文章将使用 Rust 逐步实现一个微型的 VMM,相比直接用 C 实现,我们可以获得更好的安全性保证,不安全的操作被封装在所用到的库中的极少的代码段内。
Chap 0: Basic Knowledge 首先,我们需要知道 KVM 是啥:基于内核的虚拟机 Kernel-based Virtual Machine(KVM)是一种内建于 Linux® 中的开源虚拟化技术。具体而言,KVM 可帮助您将 Linux 转变为虚拟机监控程序,使主机计算机能够运行多个隔离的虚拟环境,即虚拟客户机或虚拟机(VM)。[src ]
怎么使用 KVM 呢?正常你可能会想,既然是 kernel 提供的能力,那就是一个 syscall 咯?嘿嘿,猜错了,是通过 /dev/kvm
设备,在受支持的机器上可以 ls /dev/kvm
看到它。以设备文件的形式抽象相比直接 syscall 更容易做权限管理。
在打开该设备后,可以通过 ioctl
syscall 来操作它。这里有三个层级:
System:影响整个 KVM 子系统,比如创建 VM。
VM:影响单个 VM,如为 VM 创建 vCPU。
vCPU:查询或控制单个 vCPU 的属性。
一个典型的 KVM 使用例子是:打开 /dev/kvm
得到 kvmfd,之后通过 ioctl KVM_CREATE_VM 得到 vmfd,之后再通过 ioctl KVM_CREATE_VCPU 得到 cpufd。在配置上内存(ioctl KVM_SET_USER_MEMORY_REGION)和设备、初始化好寄存器后,即可使用一个线程执行 vCPU(ioctl KVM_RUN)。
在遇到需要 host 介入的事件时 KVM 会发生 VM_EXIT,当 KVM 自身无法处理的事件时,ioctl KVM_RUN 会返回到用户空间等待用户处理,处理完后用户空间可以继续循环 ioctl KVM_RUN 或退出循环(例如遇到 Poweroff)。
另外,所有和 Intel 处理器相关的细节都可以参考 SDM ,KVM 的很多数据结构也是能与之对应的。
Chap 1: Get Hands Dirty 首先得先跑个最简单的 hello world:它没啥设备,也只有一块固定大小的内存,只有一个 vCPU,并且我们仅支持在 Intel x86-64 CPU 上运行。
使用现成的 crates 鉴于手写打开设备、系统调用和一堆各式各样的 Flag 麻烦且没必要,我们直接使用 rust-vmm 做好的 crate:
kvm-bindings:顾名思义,就是一堆 binding,包含一堆内核的结构体定义和常量定义。
kvm-ioctls:KVM API 的安全抽象,我们可以安全地使用 Kvm、VmFd、VcpuFd 等。
vm-memory:内存管理相关,比如将 GVA 转换为 HPA 这种事,当然还提供了一些方便使用的功能比如自动帮你 mmap 一块内存并映射为 GuestMemory。
vmm-sys-util:一些工具,用到的时候再说。
创建 VM 与 内存 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 let kvm = Kvm::new ().expect ("open kvm device failed" );let vm = kvm.create_vm ().expect ("create vm failed" );let guest_addr = GuestAddress (0x0 );let guest_mem = GuestMemoryMmap::<()>::from_ranges (&[(guest_addr, MEMORY_SIZE)]).unwrap ();let host_addr = guest_mem.get_host_address (guest_addr).unwrap ();let mem_region = kvm_userspace_memory_region { slot: 0 , guest_phys_addr: 0 , memory_size: MEMORY_SIZE as u64 , userspace_addr: host_addr as u64 , flags: KVM_MEM_LOG_DIRTY_PAGES, }; unsafe { vm.set_user_memory_region (mem_region).expect ("set user memory region failed" ) };
额外提一句:申请内存实际上就是一次私有匿名 mmap,匿名映射的初始值是对应到 /dev/zero
的,所以是全零的。有时候我们还会附带 MADV_MERGEABLE
做 madvise 打开页面共享,在启动多个内核相同的 vm 时,可以节省一些内存(但 linux kernel 为了做一些优化有自修改行为,这个行为可能导致部分页面共享失效)。
创建 vCPU 并初始化寄存器 这里寄存器默认初始值是零,我们的入口也设置为 0,rflag 设置为 2(因为按照手册它的 bit1 始终是 1)。由于运行在 real mode,我们不需要管页表、GDT 等复杂的初始化逻辑。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 let vcpu = vm.create_vcpu (0 ).expect ("create vcpu failed" );let kvm_cpuid = kvm.get_supported_cpuid (KVM_MAX_CPUID_ENTRIES).unwrap ();vcpu.set_cpuid2 (&kvm_cpuid).unwrap (); let mut regs = vcpu.get_regs ().unwrap ();regs.rip = 0 ; regs.rflags = 2 ; vcpu.set_regs (®s).unwrap (); let mut sregs = vcpu.get_sregs ().unwrap ();sregs.cs.selector = 0 ; sregs.cs.base = 0 ; vcpu.set_sregs (&sregs).unwrap ();
拷贝并运行代码 我们需要生成一小段在 16 位 real mode 下可以运行的代码。
首先手写这么一个文件 demo.asm
,之后 nasm demo.asm
生成二进制,预期生成的文件会命名为 demo
。
1 2 3 4 bits 16 mov ax, 0x42 mov ds:[0x1000], ax hlt
我们可以通过 ndisasm -b16 demo
看到它的实际结果:
1 2 3 00000000 B84200 mov ax,0x42 00000003 3EA30010 mov [ds:0x1000],ax 00000007 F4 hlt
嗯,结果符合预期。当然,你也可以直接查手册手写这句汇编。
由于这段代码比较简单,仅仅是设置寄存器,写内存然后 halt,简便起见我们直接把部分指令硬编码进我们的程序。
1 2 3 4 5 6 7 8 9 10 11 12 13 let code = [0xb8 , 0x42 , 0x00 , 0x3e , 0xa3 , 0x00 , 0x10 , 0xf4 ];guest_mem.write_slice (&code, GuestAddress (0x0 )).unwrap (); let reason = vcpu.run ().unwrap ();let regs = vcpu.get_regs ().unwrap ();println! ("rax: {:x}, rip: {:X?}" , regs.rax, regs.rip);println! ( "memory at 0x10000: 0x{:X}" , guest_mem.read_obj::<u16 >(GuestAddress (0x1000 )).unwrap () );
至此最基础的 hello world 已经写完了,运行一下我们可以得到:
1 2 3 exit reason: Hlt rax: 42, rip: 8 memory at 0x10000: 0x42
可以看出,我们的虚拟机已经跑起来了,并且得到了预期的结果,我们可以正确处理计算和内存访问。
完整的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 use kvm_bindings::{kvm_userspace_memory_region, KVM_MAX_CPUID_ENTRIES, KVM_MEM_LOG_DIRTY_PAGES};use kvm_ioctls::Kvm;use vm_memory::{Bytes, GuestAddress, GuestMemory, GuestMemoryMmap};const MEMORY_SIZE: usize = 0x30000 ;fn main () { let kvm = Kvm::new ().expect ("open kvm device failed" ); let vm = kvm.create_vm ().expect ("create vm failed" ); let guest_addr = GuestAddress (0x0 ); let guest_mem = GuestMemoryMmap::<()>::from_ranges (&[(guest_addr, MEMORY_SIZE)]).unwrap (); let host_addr = guest_mem.get_host_address (guest_addr).unwrap (); let mem_region = kvm_userspace_memory_region { slot: 0 , guest_phys_addr: 0 , memory_size: MEMORY_SIZE as u64 , userspace_addr: host_addr as u64 , flags: KVM_MEM_LOG_DIRTY_PAGES, }; unsafe { vm.set_user_memory_region (mem_region) .expect ("set user memory region failed" ) }; let vcpu = vm.create_vcpu (0 ).expect ("create vcpu failed" ); let kvm_cpuid = kvm.get_supported_cpuid (KVM_MAX_CPUID_ENTRIES).unwrap (); vcpu.set_cpuid2 (&kvm_cpuid).unwrap (); let mut regs = vcpu.get_regs ().unwrap (); regs.rip = 0 ; regs.rflags = 2 ; vcpu.set_regs (®s).unwrap (); let mut sregs = vcpu.get_sregs ().unwrap (); sregs.cs.selector = 0 ; sregs.cs.base = 0 ; vcpu.set_sregs (&sregs).unwrap (); let code = [0xb8 , 0x42 , 0x00 , 0x3e , 0xa3 , 0x00 , 0x10 , 0xf4 ]; guest_mem.write_slice (&code, GuestAddress (0x0 )).unwrap (); let reason = vcpu.run ().unwrap (); let regs = vcpu.get_regs ().unwrap (); println! ("exit reason: {:?}" , reason); println! ("rax: {:x}, rip: {:X?}" , regs.rax, regs.rip); println! ( "memory at 0x10000: 0x{:X}" , guest_mem.read_obj::<u16 >(GuestAddress (0x1000 )).unwrap () ); }