下一站 - Ihcblog!

远方的风景与脚下的路 | 子站点:ihc.im

0%

用 Rust 实现极简 VMM 1 - 基础

This article also has an English version.

本系列文章主要记录我在尝试用 Rust 实现一个 Hypervisor 的过程。

为什么写这个系列?几个月前在我业余探索 KVM 的过程中我遇到了一些困难,而互联网上很多文章都没能很好地解释清楚,并且也没有一篇文章能够从零到一地构建一个 VMM 并讲清楚每个 Magic Number 的含义和原因。希望我的分享可以一定程度上让初学者少走一些弯路。当然,我也免不了会有一些错误理解,欢迎各位指正。

目录:

  1. 用 Rust 实现极简 VMM - 基础
  2. 用 Rust 实现极简 VMM - 模式切换
  3. 用 Rust 实现极简 VMM - 运行真实的 Linux Kernel
  4. 用 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 来操作它。这里有三个层级:

  1. System:影响整个 KVM 子系统,比如创建 VM。
  2. VM:影响单个 VM,如为 VM 创建 vCPU。
  3. 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
// create vm
let kvm = Kvm::new().expect("open kvm device failed");
let vm = kvm.create_vm().expect("create vm failed");

// create memory
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
// create vcpu and set cpuid
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();

// set regs
let mut regs = vcpu.get_regs().unwrap();
regs.rip = 0;
regs.rflags = 2;
vcpu.set_regs(&regs).unwrap();

// set sregs
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
// copy code
// B84200 mov ax,0x42
// 3EA30010 mov [ds:0x1000],ax
// F4 hlt
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() {
// create vm
let kvm = Kvm::new().expect("open kvm device failed");
let vm = kvm.create_vm().expect("create vm failed");

// create memory
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")
};

// create vcpu and set cpuid
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();

// set regs
let mut regs = vcpu.get_regs().unwrap();
regs.rip = 0;
regs.rflags = 2;
vcpu.set_regs(&regs).unwrap();

// set sregs
let mut sregs = vcpu.get_sregs().unwrap();
sregs.cs.selector = 0;
sregs.cs.base = 0;
vcpu.set_sregs(&sregs).unwrap();

// copy code
// B84200 mov ax,0x42
// 3EA30010 mov [ds:0x1000],ax
// F4 hlt
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()
);
}

欢迎关注我的其它发布渠道