This article also has an English version.
本系列文章主要记录我在尝试用 Rust 实现一个 Hypervisor 的过程。目录:
- 用 Rust 实现极简 VMM - 基础
- 用 Rust 实现极简 VMM - 模式切换
- 用 Rust 实现极简 VMM - 运行真实的 Linux Kernel
- 用 Rust 实现极简 VMM - 实现 Virtio 设备
本文是系列的第三篇,会做一些准备工作,并实际跑起来一个真正的 Linux。
在前面的章节中我们能够在 64bit 模式下运行任意代码了,本章的目标是能够将一个真实的 Linux Kernel 跑起来。
可能有人会好奇,Linux 也能以实模式启动,为什么我们需要做这么多麻烦事呢?是因为正常情况 Linux 启动会依赖 bootloader 完成模式切换和内核代码加载,而这一步我们 VMM 来做更高效。我们切换完模式后,只要确保内核代码和 initrd 已经加载到对应页表项的内存地址中,即可直接跳转启动 vcpu。
环境准备
由于我们只想启动 Linux 而不是完整启动一个包含硬盘的 Linux,所以我们只需要准备:
- 内核文件:你可以自己编译,也可以从 firecracker 提供的地址下载预编译调优过的文件
- initrd 镜像:(这其实不是个磁盘镜像,只是沿用了旧的名字)你可以自己打包,也可以使用脚本创建。
这里有一份我在尝试 Rust for Linux 时写的简单的过程,包含了内核编译、initrd 手动构建与启动,可以作为参考。但构建 Kernel 和 initrd 并不是我们目前关注的东西,为了确保我们不被这部分的问题影响,我们这里直接用别人搞好的。
vmlinux.bin: https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/x86_64/kernels/vmlinux.bin
initrd.img: 按照 https://github.com/marcov/firecracker-initrd.git
构建(注:这个有点旧了,会提示 root 用户密码过于简单,手动修改一下即可)
我们将 vmlinux.bin
和 initrd.img
两个文件放置于 /tmp/mini-kvm
下。
IRQ 与 PIT 创建
PIC 和 APIC
除了 CPU 与内存,计算机的另一个重要部分是 IO 设备。设备是否有数据有两种方式判定:要么 CPU 轮询,频率较高时 cost 比较大,频率较低时又会导致延迟问题;另一种方式就是设备主动在数据就绪时通知 CPU,就是通过所谓的中断。每个指令周期结束后,CPU 都会查看其中断标识 IF 有没有被设置,如果被设置则跳转对应的中断处理程序。
外设有各种各样,CPU 不可能为每种外设都留相应的引脚接收中断,所以需要一个 dispatcher 角色的硬件来辅助工作。IBM 设计了 8259A 中断控制器,有 8 个信号线,以可编程的形式工作,可以动态地注册引脚和优先级、屏蔽中断等。为了支持更多的外设,往往以级联的方式使用多片 8259A 一起工作。这种可编程中断控制器被称为 PIC(Programmable Interrupt Controller)。
到了多 CPU 时代,Intel 提出 APIC(Advanced Programmable Interrupt Controller)技术。APIC 由两部分组成:一个是 LAPIC(Local APIC),存在于每块 CPU 中(现在每个逻辑核心都有一个);另一个是 IOAPIC,可能有一个或多个,其连接外部设备。两者通过 APIC Bus 连接。外设通过 IOAPIC 向 LAPIC 广播中断,LAPIC 自行决定是否处理。
IRQ 虚拟化
KVM 为我们虚拟化好了 IRQ 芯片,我们只需要创建它即可使用:
1 | vm.create_irq_chip().unwrap(); |
对于需要触发某个中断的需求,我们只需要向其注册一个 EventFd 和对应 IRQ 号:
1 | vm.register_irqfd(&evtfd, 0).unwrap(); |
时钟信号虚拟化
计算机系统中有两类有关时间的设备,一种是时钟,一种是定时器。我们可以通过时钟拿到当前时间信息,如 TSC(Time Stamp Counter)设备;通过定时器我们可以在到相应时间或以固定频率触发中断使 CPU 在执行用户代码的同时能够感知到时间流逝,如 PIT(Programmable Interval Timer)。
PIT 精度较低,仅在系统启动过程中使用;启动完成后将使用 LAPIC Timer,其工作在 CPU 内部,精度更高。
要创建虚拟 PIT 设备,我们只需要利用 KVM 的能力:
1 | let pit_config = kvm_pit_config { |
CPUID 处理
有关 CPU 的一些信息是通过 CPUID 指令获取的,我们需要修改 VM 内看到的 CPUID。我们需要将期望 Guest 看到的 CPUID 信息在最开始的时候告诉 KVM,后续因为 Guest 执行 CPUID 导致 VM_EXIT 的时候 KVM 就可以自行处理,不用丢给用户态 VMM 了。
具体的规范可以看 https://en.wikipedia.org/wiki/CPUID 和 http://www.flounder.com/cpuid_explorer2.htm 。
简单的例子
作为例子,我们可以看 function = 0
对应的寄存器数据:
1 | let mut kvm_cpuid = kvm.get_supported_cpuid(KVM_MAX_CPUID_ENTRIES).unwrap(); |
另一个例子是 0x40000000
,会在寄存器里保存 KVMKVMKVM
字符串,具体可以看这里:https://01.org/linuxgraphics/gfx-docs/drm/virt/kvm/cpuid.html
结构定义
kvm_cpuid_entry2
的结构定义是这样的:
1 |
|
你可能会好奇,执行 CPUID 指令不是只要设置对 EAX/ECX 就好了嘛?这个 function 和 index 哪来的?参考这里 https://elixir.bootlin.com/linux/latest/source/arch/x86/kvm/cpuid.c#L1392 我们可以看到,function 就是 *EAX
得到的,index 就是 *ECX 得到的。所以我们对照前面的规范时,将 function 和 index 映射为 *EAX
, *ECX
即可。
设置 CPUID
这部分主要参考 firecracker 代码,可能某些配置是必要的,某些是不必要的。
参考前面 wiki,我们可以找到 EAX=1
时(由于这个是输入,所以对应我们的 function=1
):
- ECX 的第 31bit 要置 1 表示 hypervisor。
- EBX 的 32:24 bit 设置为 Local APIC ID,多 vcpu 时从 0 开始编号即可。
- EBX 的 15:8 bit 设置为 CLFLUSH line size。x86 下 cacheline 一般是 64 byte,根据 wiki 我们设置的值会 *8 后作为实际值,所以设置为 8 即可。
- EDX 的第 19bit 设置 1 表示启用 CLFLUSH,配置时 CLFLUSH line size 设置才生效。TODO:为啥几个参考项目都没设置这个?
- EBX 的 23:16 bit 设置为单个 physical package 中的 logical processors 数量,通常设置为其 vCPU 数目向上取二次幂(当然不取也没关系)。
- EDX 的第 28 bit 设置 1 表示启用 hyper threading,前面那条 logical processors 数量设置才生效。通常 vCPU > 1 时设置。
- ECX 的第 24 bit 设置 1 表示启用 tsc-deadline。
EAX=4
主要涉及缓存和 core 相关,比如一个 socket 上有多少 core:
- 省略不写了。
EAX=6
风扇和电源管理:
- ECX 第 3bit 置 0,关闭 Performance-Energy Bias capability。
- EAX 第 1bit 置 0,关闭 Intel Turbo Boost Technology capability
EAX=10
性能监控:
- 全部置 0 关掉。
EAX=11
Extended Topology Entry:
- 省略不写了。
EAX=0x8000_0002..=0x8000_0004
CPU 型号信息:
- 可以自己编。
简单处理
事实上不处理 cpuid 直接扔出去也能用:
1 | let kvm_cpuid = kvm.get_supported_cpuid(KVM_MAX_CPUID_ENTRIES).unwrap(); |
这里我们先 workaround 一下,后续再来处理这部分。
设置 TSS
TODO: TSS 科普 & 讲清楚为啥 KVM 要搞这个
1 | const KVM_TSS_ADDRESS: usize = 0xfffb_d000; |
加载内核和 initrd
我们需要把 bootloader 的活干了,把内核和 initrd、启动参数加载到内核,并将一些必要的信息放在内存中以传递给 kernel。
1 | // load linux kernel |
创建启动参数并写入内存:
1 | // crate and write boot_params |
其中可用内存信息正常应当由 bootloader 通过 bios 中断(中断号 0x15,AX=0xE820,所以对应结构得名 e820 entry)拿到,这里我们手动将可用内存表示成多个 e820 entry 传给 kernel。
TODO:内存布局
创建输入输出设备
通常有两种输入输出设备类型:PortIO 和 mmap IO。我们这里只关注 PortIO 通信。
PortIO 有 64K 的 Port 地址空间,其典型地址有(参考链接):
- COM1: I/O port 0x3F8, IRQ 4
- COM2: I/O port 0x2F8, IRQ 3
- COM3: I/O port 0x3E8, IRQ 4
- COM4: I/O port 0x2E8, IRQ 3
而 Linux 中的 /dev/ttyS{0/1…} 对应 COM{1/2…}。所以我们要通过 PortIO 得到 Linux 的 console 输入输出,只需要处理 COM1(0x3F8,IRQ 4)并在启动参数中指定 console=ttyS0
。
这里创建一个 EventFd 并将其注册到 IRQ 4 上。当我们需要触发 COM1 做 PortIO IN 时,则我们可以通过这个 EventFd 向 VM 注入中断,之后 guest vm 内的驱动程序会执行 PIO IN,继而触发 VM EXIT。
实现上,我们使用 vm_superio
提供的模拟串口实现,使用这个 EventFd 作为其 Trigger,并使用 stdout 作为输出。
1 | // initialize devices |
为了适配它的接口,我们需要额外做两个结构:EventWrapper
和 DummySerialEvent
。主要目的是实现 Trigger
和 SerialEvents
。SerialEvents
这部分代码并不重要,只是为了满足其接口约束;而 Trigger
只需要写入 eventfd 即可。
1 | struct EventWrapper(EventFd); |
在遇到 VcpuExit::IoIn
和 VcpuExit::IoOut
时,我们可以拿到对应的 PortIO addr 和 data,这时我们在判断后转交给 stdio_serial 处理即可。输出时 stdio_serial 直接向 stdout 输出;输入时需要我们自己处理。
vCPU Run
同我们在上一小节说的一样,我们需要将 IoIn 和 IoOut 事件转交给 Serial 处理。
另外,由于我们需要消耗一个 thread 运行 vcpu,同时我们也要与终端交互,所以这里需要两个线程。我们需要一定的跨线程通信手段来实现正常退出,即 vCPU 模拟停止后能够通知主线程退出:这里使用另一个 eventfd。方便起见,我们复用了前面的 EventWrapper
结构(这并不是必要的,完全可以直接使用 eventfd)。
1 | // run vcpu in another thread |
在该线程结束运行时,我们可以通过 exit_evt
得到通知,这样我们的主线程就可以在等待 stdin 的同时等待 vcpu 退出事件。
Stdin 处理
Serial 设备需要我们自行处理输入数据,而我们在等待用户侧 stdin 的同时还需要等待 vcpu 退出,这样可以在 vm 停止时退出主线程。你可能已经猜到了,我们这里使用 epoll 作为多路复用机制即可(因为 KVM 已经是 linux only 的了,所以也不用考虑跨平台问题)。
这里使用 vmm_sys_util 封装的 PollContext
。
对于 stdin 的处理我们需要使用 raw mode,因为我们需要转发类似 ctrl+c 之类的键入。
1 | // process events |
完整代码
1 | use std::{ |
运行起来:
1 | [ 0.000000] Linux version 4.14.174 (@57edebb99db7) (gcc version 7.5.0 (Ubuntu 7.5.0-3ubuntu1~18.04)) #2 SMP Wed Jul 14 11:47:24 UTC 2021 |