跟完MIT 6.S081前十八讲,我心里一直有个潜在的疑惑:操作系统真的是“直接管理硬件”的软件吗?如果是,那云计算里那些在一台机器上同时跑几十个操作系统的虚拟机,又是怎么在“硬件之上”再套出一层“硬件”的?LEC 19 Virtual Machines这一讲,正好把这个问题彻底讲透了。
这一讲没有引入什么全新的、只有虚拟化才用的孤立概念。它只是把前面学过的trap、页表、进程调度、设备中断、文件系统,全部重新组合了一遍,组合出一个让人拍大腿的结果:所谓虚拟机,本质上就是“用一个用户态程序,模拟一台裸机给另一个操作系统用”。你甚至可以把它理解成操作系统这门课的期末大综合。
这篇文章会围绕课程的核心方案trap and emulate展开,仔细拆解一个最小hypervisor是怎么在xv6之上把另一个xv6跑起来的,也会解释纯软件方案为什么慢、现代硬件辅助虚拟化又是怎么把这些问题逐一解决的。无论你是正在刷6.S081的学生,还是对KVM/QEMU内部机制好奇的工程师,读完后应该都能在脑子里建立起一条完整的、从裸机到虚拟化的主线。
1. 为什么这一讲放在最后:虚拟化是操作系统的“递归”
1.1 前十八讲是怎么把硬件“藏”起来的
6.S081这门课的节奏很有意思,它不是按“从底层到高层”的教科书顺序展开的,而是按“如何把一台裸机变成开发者熟悉的接口”这个主线一步步剥进去。前面十几讲,你从fork/exec开始,慢慢接触系统调用、trap、页表、进程调度、锁、文件系统、缓冲区、驱动,最后甚至做到网卡驱动。到第十七、十八讲时,你会觉得自己已经把xv6摸透了:它不过是一个运行在QEMU模拟出的RISC-V机器上的、有完整抽象层的操作系统。
这个阶段我最大的感觉是“我能解释每个子系统为什么存在”。但是LEC 19出现的瞬间,我才意识到前面这些抽象只是前半场。因为它们全都是在“一台真实的机器”上做的,而虚拟化回答的问题是:如果我把这台机器本身也变成一个软件对象,会怎样?
递归在CS里的含义是一个函数调用它自己。操作系统领域的递归,就是“一个操作系统运行在另一个操作系统之上”:宿主操作系统(host)把自己对硬件的接口重新模拟一遍,提供给另一个操作系统(guest)使用。guest OS像一条在鱼缸里游的鱼,它认为自己正在大海里游,而鱼缸(hypervisor/VMM)负责模拟海流、海底和氧气。这个比喻虽然朴素,但非常贴切——guest永远不知道自己不是在一台真机器上跑。
这一讲的demo在我看来是全课最精彩的部分:让xv6作为宿主操作系统,在上面再跑一个xv6。你能在终端里看到第二个xv6完整地完成开机、跑shell、执行简单命令。注意这不是在QEMU里开了两个实例,而是xv6自己当了hypervisor,在内存里虚拟出一台机器,让另一个xv6在这台虚拟机器上运行。它把“抽象”这个词玩到了极致。
1.2 三种虚拟化路线,这一讲属于哪一条
在深入实现之前,有必要先把市面上几种“虚拟化”摆清楚。因为它们共享同一个目标,但路线完全不同,课程里反复出现的术语也比较多。
| 方案 | guest是否感知自己是虚拟机 | 核心机制 | 典型代表 |
|---|---|---|---|
| 全虚拟化(trap-and-emulate / 硬件辅助) | 完全无感知 | VMM软件模拟敏感指令,或CPU硬件提供专门虚拟化支持 | 早期QEMU、KVM、VMware Workstation |
| 半虚拟化(paravirtualization) | 知晓自己是虚拟机 | guest主动通过hypercall与hypervisor协作,使用高效虚拟设备协议 | Xen、virtio |
| 操作系统级虚拟化(容器) | 不虚拟化硬件 | 多个隔离用户空间共享同一个宿主内核 | Docker、LXC |
课程demo属于第一种里的纯软件流派,也就是最经典的trap-and-emulate。它不依赖任何CPU虚拟化扩展,guest完全不知道自己是guest,所有的特权操作都依赖VMM在软件层面兜住。这套方案的性能劣势很明显,但它却是理解后面一切方案的地基。学透了纯软件VMM,你再去看硬件辅助虚拟化、virtio,会发现所有设计动机都清清楚楚。
我个人的观点是:这一讲放在课程末尾,一方面是因为实现VMM需要用到前面几乎所有知识,另一方面是因为它提供了一个“上帝视角”。当你站在VMM的角度看guest时,你才真正理解一个操作系统向硬件索取了什么、依赖了什么。这不是一个新主题,而是对整个课程的一次递归式复习。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. trap and emulate:纯软件虚拟机的引擎
2.1 降特权级:让guest以为自己是内核,其实只是个用户进程
最朴素的想法是:想模拟一台机器,是否只要把guest内核的指令逐条执行一遍?但问题马上就来了——guest内核有特权指令,比如改页表寄存器satp、写中断入口stvec、执行wfi等待中断。这些指令一旦被执行,就会直接影响宿主机器的真实状态。一台机器上要同时跑多个guest,任何一个guest改了真实页表,整个系统瞬间就崩了。
所以VMM必须保证两件事。第一,guest永远无法真正修改硬件状态;第二,guest确实需要修改时,由VMM代替它安全地修改。做法叫做降特权级(de-privileging)。
在RISC-V里,宿主xv6内核运行在S mode(supervisor mode),课程demo把guest内核也放到了低特权级运行,具体来说是U mode(user mode)。RISC-V的硬件规则是:U mode下执行特权指令会触发illegal instruction异常,CPU会陷入运行在S mode的VMM的trap handler。这一步就是“trap”的来源——把真正的特权操作变成一次异常,交给更高层去解释执行。
这里有个很容易被初学者忽略的点:guest内核里绝大多数指令都是普通算术、访存、函数调用,它们和特权级无关。真正有影响的只是一小撮访问CSR、操作页表、开关中断的指令。所以“降级”并没有让guest的内核逻辑寸步难行,反而让VMM能精准地在敏感操作发生时才介入。对guest自己来说,它仍然以为自己运行在S mode,因为VMM在内存里为它维护了一套模拟的CSR状态,任何读CSR的请求都由VMM返回“看起来正确”的值。
2.2 敏感指令的捕获与模拟:从一条写satp开始
选一个最典型的场景展开:guest切换地址空间时,内核会执行一条指令把新的根页表物理地址写入satp寄存器。在真实机器上,这一条指令就完成了页表切换;但在虚拟机上,这条指令在U mode执行会非法,陷入VMM。
到这里,VMM面临一个关键选择:它能不能直接把guest给的这个值写进真实的satp?
不能。原因在于地址空间。guest维护的页表,其页表项里填的“物理地址”其实是guest自己认为的物理地址(gPA),而不是宿主机的真实物理地址(hPA)。VMM在初始化虚拟机时,会从宿主机申请一段内存当作guest的物理内存,guest以为自己在物理地址0x80000000处放了内核,实际上这段内存可能在宿主机的0x2000c000处。所以guest写的页表根地址,对真实MMU来说是一个无意义甚至有害的值。
正确做法是shadow page table(影子页表)。VMM截获guest写satp的请求后,不会把guest给的值写进真实satp,而是遍历guest页表指向的内存,为guest建立一张新的页表:把guest每个页表项里的gPA改写成对应的hPA,同时保留权限位。这张新页表就叫shadow page table,VMM把它的根物理地址写进真实的satp。之后guest的访存由真实MMU直接翻译完成。
这就完成了一次“地址翻译的翻译”:guest逻辑上需要GVA→GPA的映射,hypervisor补上GPA→HPA的映射,而shadow page table把两步合并成一步GVA→HPA,交给硬件去用。
除了写satp,guest还有一些敏感操作同样需要被兜住。例如:
- 执行sfence.vma刷TLB时,VMM必须跟进刷新真实的TLB,否则旧映射残留会导致访问错误内存。
- 读写stvec、sstatus等CSR时,VMM要读写vCPU结构体里的模拟值,而不是真实CSR。
- 执行wfi(等待中断)指令时,VMM可以直接把它模拟成空操作,因为CPU调度权在宿主内核手里,guest没必要也无权睡在真实CPU上。
trap and emulate的核心其实就是这四件事:识别trap、判断是不是敏感操作、模拟执行该操作、返回guest继续跑。
2.3 RISC-V能行,x86为什么不行
到这里你可能会想:既然trap and emulate这么“纯软件”,为什么今天的虚拟化实现没怎么看到这种方案?答案和架构有关。
trap and emulate要成立,有个硬条件:所有“敏感指令”都必须是“特权指令”。也就是说,guest在低特权级执行任何会破坏系统状态或泄露宿主机信息的指令时,硬件都要干净利落地抛异常,不能静默忽略,也不能悄悄成功。这个条件在RISC-V上基本成立,但在x86上并不成立。
x86上最经典的例子是POPF指令。POPF本身只是一条普通指令,用来从栈里弹出值写入EFLAGS寄存器。问题在于,EFLAGS里有几个位是和中断、I/O权限相关的。在低特权级下执行POPF时,硬件不会让它trap到hypervisor,而是静默地忽略那些高特权位的更新,剩下的位照常写入。对guest来说,它期望“改中断标志位”是一项特权操作,结果这条指令“成功”执行了,但真实EFLAGS里的关键位却没变——guest的状态和硬件的真实状态就此分叉。
更麻烦的是SGDT、SIDT、SMSW这类指令。它们只读取全局描述符表或机器状态字,不算特权指令,在低特权级下能直接执行并拿到真实数据。guest一执行,就能读到宿主机的内存布局信息,这不仅是虚拟化能否实现的问题,还直接关系到隔离安全。
这个问题在学术界有个很出名的名称:Popek/Goldberg虚拟化准则。一个架构能被高效虚拟化的充分条件,是敏感指令集合恰好等于特权指令集合。x86在很长一段时间里不满足这个条件,所以早年的VMware只能靠二进制翻译(binary translation)在运行前把guest代码里的敏感指令扫描出来,动态替换成安全的指令序列。直到Intel和AMD在CPU里加入VT-x和AMD-V硬件支持,x86上的全虚拟化才重新成为主流方案。而RISC-V由于设计时间晚,可以吸取教训,在指令集层面让敏感指令和特权指令高度重合,所以课程里能用最朴素的trap and emulate做demo。
3. 复现课程demo:在xv6里把另一个xv6跑起来
3.1 vCPU结构与VMM主循环
如果让我用一句话概括VMM,那就是:一个无限循环,循环体的前半段从host切换到guest,后半段从guest的trap返回并处理原因,然后继续切回guest。听起来很像内核的进程调度循环,对吗?没错,从host视角看,guest就是一个特殊的进程,只不过这个进程运行的是另一个完整操作系统。
要为guest建立“模拟的CPU”,VMM需要为每个虚拟CPU维护一套完整状态。下面这个结构示意基本说明了它要管什么:
c复制// 示意结构:VMM为每个vCPU维护的状态
struct vcpu {
uint64 x[32]; // 通用寄存器
uint64 sepc; // guest被打断时的PC
uint64 sstatus; // 模拟的sstatus
uint64 stvec; // guest内核设置的trap入口
uint64 satp; // guest写的页表寄存器,物理地址是gPA
uint64 scause; // 最近一次异常的来源
uint64 stval; // 最近一次异常的附加信息,如出错地址
// 指向宿主进程结构,用于内存分配与调度
struct proc *host_proc;
};
这些字段的作用是:guest读写CSR时,VMM不直接碰真实CSR,而是读写这个结构体。真实CSR只有在VMM自己运行、或者切换回guest之前需要设置时才会被改动。
VMM主循环的骨架同样非常简短,真实实现里的复杂度全藏在handle_trap里面:
c复制while (1) {
// 把vCPU里的寄存器写入真实寄存器,然后sret进入guest
vcpu_enter(vcpu);
// 从guest返回,此时scause等记录了trap原因
handle_trap(vcpu);
}
handle_trap从真实scause判断发生了什么:
- 如果是illegal instruction异常,就去sepc指向的地址取指令,反汇编出guest想干什么,然后模拟执行。
- 如果是guest的ecall,可能是在请求一次系统调用或SBI服务,VMM需要决定是转发给guest自己的处理流程,还是代为完成。
- 如果是page fault,可能是guest自己的缺页,也可能是guest访问了MMIO设备区域,需要区分处理。
- 如果是时钟中断,可能意味着该向guest注入一次虚拟时钟中断了。
这一套分发逻辑,和xv6内核里trap.c根据scause分发到syscall、设备中断、缺页处理的思路完全同构。你甚至可以把VMM理解成一个“专门处理特权指令的库操作系统”。
3.2 shadow page table:地址翻译的翻译
shadow page table是整个VMM里最容易让我绕晕、也最值得仔细讲的部分。
guest启动时会自己建立内核页表,把根页表地址写入satp。VMM截获这个操作后,必须遍历guest页表指向的内存,为guest创建一张shadow page table。所谓“遍历”,核心动作是:拿到guest页表的每个页表项(PTE),把里面记录的gPA翻译成hPA,再写入shadow页表中对应位置,权限位尽量照搬。
这期间要小心的细节很多。最典型的是guest可能随时修改自己的页表,比如fork时复制页表、mmap时添加映射、或者对页表页做权限调整。VMM没法提前知道guest什么时候会改页表。经典做法是把guest的页表页在shadow页表中映射成只读。一旦guest尝试写页表页,就会触发store page fault,陷入VMM。VMM检查发现它要改的正好是某个页表页,于是先允许guest更新原始页表,再同步刷新shadow页表中的对应映射,最后把该页在shadow里重新映射成可写,让guest继续执行。
这套机制和xv6课程里COW fork的实现思路如出一辙,都是“把写共享页变成一次可拦截的页错误”。理解了COW的实验,理解shadow page table其实只是换了个场景。
还有一个容易忽略的硬性要求:每次guest切换页表、或者shadow页表发生更新之后,VMM必须执行sfence.vma刷新TLB。如果不刷,真实MMU的TLB里可能还缓存着旧进程的地址映射,guest的进程一切换,立刻就会访问到错误的内存位置。这个坑几乎每个手写VMM的人都会踩,跑出来的现象就是guest进程的内存内容对不上,或者在完全没有指针错误逻辑的地方出现神秘的page fault。
3.3 MMIO与设备模拟:guest打印字符背后的折腾
设备模拟是VMM另一块大头,也是理解“为什么虚拟机里的I/O这么贵”的关键。
拿最直观的console举例子。真实xv6启动时,会往UART控制器的MMIO地址写字符,从而打印出“xv6 kernel is booting”。在VMM里,guest对UART寄存器地址的访问要怎么落到真实终端上?
VMM在初始化时会为guest安排一段虚拟的物理内存,其中某个区域被规划成设备MMIO区,比如0x10000000附近模拟UART。在shadow页表中,这个区域的PTE被标记为不可读、不可写。这样guest一访问它,就会触发page fault,陷入VMM。
VMM拿到trap之后,从stval读出出错地址,再去sepc指向的指令地址取指令、解析编码,判断这是一条load还是store、目标或源寄存器是谁、偏移量是多少。比如guest执行了一条sd指令,想把0x41写入0x10000000(UART的发送寄存器),VMM解码出这个意图后,就用宿主机的write系统调用把这个字符打印到终端上。
也就是说,guest里每一次console输出,背后都是一次page fault、一次指令解码、一个真实的write系统调用。你现在知道为什么纯软件虚拟机里打印是个相对“贵”的操作了。
除了console,另一个绕不开的设备是定时器。guest要维护自己的时间片、调度进程,需要周期性的时钟中断。在VMM里,这个中断不能凭空产生,必须由VMM驱动。最粗糙的实现是:VMM自己配置宿主定时器,每次收到真实的定时器中断时,就向guest注入一次supervisor timer interrupt。至于“注入中断”的细节,其实就是在vCPU的模拟CSR里设置好scause、stval,并把guest的执行入口改为它自己设置的stvec,让guest像真实收到中断一样保存现场、执行自己的中断处理函数。
磁盘和网络设备的模拟同理,但复杂度会高很多。课程demo通常不会把完整磁盘模拟做进去,因为那意味着要在MMIO模拟的基础上再实现一个磁盘控制器的状态机。这里值得记住的一句话是:设备模拟的每一项能力,最终都会落到“guest一次访存→trap→VMM模拟→返回guest”这个循环里。
4. 纯软件方案为什么慢,硬件又做了什么
4.1 每一次特权操作的代价
trap and emulate最大的问题是“什么都靠trap”。guest每次写satp要trap一次,每次开关中断要trap一次,每次设备访问要trap一次。而trap本身不便宜:保存恢复全套寄存器、切换页表、清TLB,一次下来几百个周期就没了。
如果guest是一个频繁切换进程的操作系统,那它每切换一次进程都可能触发好几次CSR写相关的trap,大量CPU时间都耗在宿主机的异常分发上。要知道,真实操作系统里的进程切换已经够贵了,虚拟化之后这个成本还要再叠一层。再加上shadow page table的维护:guest的每次mmap、fork都可能带来一遍shadow重建,页表越大,重建开销越离谱。课程demo能稳定跑出结果,已经是做了大量简化的结果。生产环境如果完全靠这套纯软件方案,性能会惨不忍睹。
另一个隐性开销是异常注入。向guest注入一次中断,VMM要修改一整套vCPU虚拟寄存器,再执行一次完整的上下文切换。如果guest每秒要接收上百次时钟中断,哪怕每次注入只有几百个周期,累计起来也是不小的损耗。
4.2 硬件辅助:VT-x/AMD-V与RISC-V H扩展
硬件辅助虚拟化把“guest能不能碰特权资源”这个判断从软件挪进了CPU。
以Intel VT-x为例,处理器新增了VMX root和VMX non-root两种操作模式。VMM运行在root模式,guest运行在non-root模式。在non-root模式里,guest以为自己有完整的特权级,它执行敏感操作时CPU会自动发生一次VM exit,把完整的上下文保存到VMCS(虚拟机控制结构),然后跳回VMM。VMM处理完后再执行VM entry,硬件自动恢复guest上下文。
最妙的是地址翻译这件事。VT-x的EPT(Extended Page Tables)直接在硬件里支持GPA→HPA的第二层地址翻译,真实MMU本身就能完成“guest虚拟地址→guest物理地址→真实物理地址”的两级查询,VMM不再需要软件维护shadow page table。cpu上还有专门的缓存来加速EPT查询,性能比纯软件方案不知道高到哪里去了。
RISC-V的H扩展也是类似思路,提供vsatp和stage-2页表,让硬件原生支持两层地址翻译。所以你会发现LEC 19课堂上用软件硬做的那些事情——shadow page table、指令解码模拟、软件注入异常——在硬件方案里全部被CPU原生支持了。概念上没有本质变化,只是从软到硬,思路完全一脉相承。
4.3 半虚拟化:与其模拟,不如合作
还有一个从guest侧下手的思路,叫半虚拟化。既然设备模拟很贵,那就让guest知道自己跑在虚拟机上,主动使用一套高效的虚拟设备协议。
virtio是最有名的例子。guest里安装virtio驱动,通过共享内存环形队列和hypervisor交换数据,一次提交能够批量传输多个请求,省去了每字节都解码MMIO的开销。现代云服务器上的磁盘和网卡几乎都走virtio,原因就是性能。虚拟机里的每个数据包、每次磁盘读写,如果都要靠单独的MMIO trap来模拟,网络和存储性能会立刻成为瓶颈。
从半虚拟化反向推导,你能更深刻地理解全虚拟化的设计取舍。它为了“guest完全无感知”这个目标,付出了巨大的性能代价。而生产环境里的做法往往是混合的:CPU和内存用硬件辅助虚拟化保证基本性能,设备I/O用virtio这类半虚拟化协议协作,必要时再做设备直通(passthrough),让guest直接访问真实硬件,彻底绕开模拟。
5. 学完之后值得做的三个复盘练习
5.1 把xv6的trap流程和VMM的trap流程对照着读一遍
课程demo看完了,最值得做的第一件事不是写新代码,而是回头再读一遍xv6自己的trap路径:kernelvec.S、trap.c、syscall.c。
你会惊喜地发现,xv6内核和VMM的trap处理分发表几乎是对称的。xv6根据scause判断是系统调用、缺页还是设备中断,然后分发到对应handler;VMM同样根据scause判断是非法指令、ecall还是page fault,然后分发到模拟指令、转发异常、模拟MMIO等handler。这两个分发表一对照,“操作系统和虚拟机是同一类软件”这个感觉会变得非常强烈。
这里有一个经常让人卡住的细节:当guest里的用户程序执行ecall发起系统调用时,这个trap会先到VMM,而不是guest内核。VMM必须决定是直接模拟这次系统调用,还是把异常转交给guest内核自己的syscall处理流程。课程demo为了简化,很可能选择让guest内核自己处理。想清楚这一步,你对“trap handler之前还有一层handler”的理解就会上一个台阶。
5.2 用“VM exit”眼光重新看KVM/QEMU
学完LEC 19后再去看KVM,你会发现它就是一个工业化的VMM主循环。
KVM的编程模型很简单:打开/dev/kvm,创建VM、添加vCPU,然后循环调用ioctl(KVM_RUN)。每次ioctl返回后,你需要检查struct kvm_run里的exit_reason,判断这次VM exit是因为外部中断、I/O访问、还是CPUID指令,然后针对原因做处理,再继续调用KVM_RUN回到guest。
你可以把ioctl(KVM_RUN)理解成前面伪代码里的vcpu_enter,把exit_reason理解成scause,把后续的switch分发理解成handle_trap。这套结构从教学demo到生产级hypervisor,几乎没有变过。理解了6.S081的demo,你再去看KVM的文档和内核代码,至少不会迷路。
如果有条件,可以在虚拟机里跑一些I/O密集任务,然后用kvm_stat看看VM exit的次数和分布。你会直观地看到哪些操作在持续制造trap,哪些已经被硬件辅助优化掉了。这种观测比读十篇性能对比文章都有用。
5.3 动手扩展:给guest加一个“虚拟时间设备”
最后,强烈建议动手做个极小的扩展:在VMM的MMIO模拟地址段里划出一小块区域,让guest读它时能拿到宿主机的当前时间。
大致步骤是:
- 在VMM管理的MMIO模拟区里选一个空闲地址,比如0x40000000。
- 在shadow页表中把这个地址映射成不可访问。
- 在VMM的page fault处理逻辑里识别到这个地址后,解析触发异常的load指令,把宿主机的时间戳写入guest的指定寄存器。
- 在guest里写一个用户程序,直接读该地址,打印出结果,假装这是“虚拟机提供的硬件时钟”。
这个实验很小,但足以把MMIO模拟、指令解析、异常注入三个知识点串起来。我当时加完这个设备后,对设备模拟的理解一下子具体了很多,因为亲手实现了从guest读寄存器到数据返回的完整链路。
如果你准备自己动手复现整个demo,我的建议是不要上来就追求完整的磁盘和网络模拟,先让guest能起来、能打印、能接收时钟中断就够了。跑通那一步时,你看到屏幕上第二个xv6的shell提示符,会觉得前面那些关于trap、页表、中断的课余时间全都没白费。虚拟化不神秘,它只是把操作系统这门课里的知识,原封不动地递归到了下一层。
