做系统软件这些年,我一直觉得虚拟化是最有意思、也最容易劝退的方向。KVM动辄几十万行,QEMU从用户态到内核态绕一大圈,如果你刚接触源码,很容易被ioctl和各种helper淹没。后来我翻到一个相对小众、但极度适合阅读的开源方案,就是今天要聊的xvisor。xvisor全称eXtensible Versatile hyperVISOR,是一个GPLv2协议下的Type-1型hypervisor,直接运行在硬件上,不依赖Linux内核,支持ARMv7-A、ARMv8-A、RISC-V和x86等架构。我花了大半个月做源码梳理,也反复在QEMU上跑过ARM guest。这篇文章就围绕xvisor源码分析展开,讲清楚它的架构、启动流程、CPU虚拟化、内存虚拟化、中断虚拟化,以及从源码到编译、运行、调试的完整思路。
无论你是刚开始接触虚拟化,还是已经看过Linux内核里KVM那套实现,xvisor都值得认真读一遍。它代码量不算大,结构清晰,没有复杂的历史包袱,适合用来建立对Hypervisor整体的模型认知。下面进入正题。
1. 从零认识xvisor:为什么说它是嵌入式虚拟化的一个“样板间”
1.1 它和KVM、QEMU到底差在哪:Type-1与Type-2的本质区别
聊xvisor之前,先把虚拟化的几个概念掰扯清楚。
KVM是Linux内核里的一个模块,运行时需要先启动Linux,再利用内核的调度、内存管理、设备驱动能力去创建虚拟机。这种模型叫Type-2,也就是宿主型虚拟化。QEMU负责用户态的设备模拟和CPU模拟,在大部分场景下和KVM配合使用。也就是说,KVM不是完整的hypervisor,它更像“帮Linux内核获得虚拟化能力的那部分”,真正的软硬件协调逻辑要围绕内核来做。
xvisor则是纯正的Type-1裸机型hypervisor。它开机直接跑在硬件上,不依赖任何操作系统,所有虚拟机都由它直接管理。换成通俗的话:KVM像你请了一个秘书,帮老板整理日程;xvisor就是老板自己直接拍板,什么事都亲自管。Type-1的好处是代码路径更短、开销更低、隔离性更强,但代价是所有活都得自己做——中断控制器、定时器、调度器、内存管理、设备模拟,一个都不能少。
这么说起来好像很复杂,但恰恰是xvisor“小而全”的特点,让它成了绝佳的源码学习对象。KVM的代码分散在arch/x86/kvm、virt/kvm、include/uapi/linux/kvm.h等一大堆地方,你很难在短时间里从零拼出完整链路。xvisor是单体式结构,核心代码就在core目录和arch目录里,模块边界非常清楚。
1.2 它适合干什么:不只是服务端虚拟化,更是嵌入式混合关键系统的底座
xvisor主要面向嵌入式场景。ARM处理器上跑一个Linux当功能丰富的宿主,再跑一个RTOS满足实时性要求;或者两个Linux guest互相隔离,其中一个崩溃了不影响另一个。这种需求在汽车域控制器、工业网关、物联网安全产品里越来越常见。
它配置偏静态化,虚拟机列表、资源划分、内存区域都由设备树(Device Tree,FDT)描述,启动时一次性解析完毕。这看起来不如KVM灵活,但对嵌入式产品来说反而是优点:启动过程确定、资源边界固定、出了问题更容易复现。
所以这篇文章适合三类读者:一是刚接触虚拟化、想在源码层面建立全局认知的学生;二是做嵌入式BSP、固件、安全启动相关工作的开发者,想评估在现有ARM平台上加一层hypervisor的代价;三是已经在用KVM、Xen,但想对比学习“无OS裸机型”虚拟化实现的人。如果你只是日常用VMware Workstation跑个Windows,那xvisor可能离你比较远;但如果你好奇“虚拟机管理器到底在干什么”,它比任何闭源商业方案都透明。
2. 源码整体架构拆解:拿到Hypervisor源码先看哪儿
2.1 打开仓库先别急着看代码:目录结构就是一张“地图”
xvisor源码可以从GitHub上的xvisor/xvisor获取。以我梳理过的版本为例,仓库顶层主要分这几块,每个目录承担完全不同的责任。
- include:公共头文件,包括list、fdt、memory、vmm相关的数据结构和接口声明。
- lib:基础库,相当于“精简版/裸机版libc”,提供字符串、链表、哈希表、DTB解析等工具。Hypervisor没法直接用glibc,所以这里的实现都是自己维护的。
- core:整个hypervisor的核心逻辑,包括vmm_init、vmm_host、vmm_guest、vmm_vm、vmm_vcpu、vmm_scheduler等关键模块,对应“虚拟机生命周期管理”。
- arch:架构相关代码。以ARM为例,下面有arm/arm32、arm/arm64,再往下拆分cpu、mmu、gic等文件,全部是直接和硬件寄存器打交道的部分。
- drivers:各种硬件驱动,串口、时钟、中断控制器、VirtIO设备透明后端的实现。
- emulator:设备模拟器,主要给guest提供虚拟设备,比如virtio-blk、virtio-net。
- tests:自测试和示例配置。
- scripts:构建辅助脚本,生成DTS、打镜像、编排文件系统等辅助工作。
刚上手的人最容易犯的错误,就是一头扎进arch目录跟汇编代码死磕。我的建议是反过来,先看core,再回头看arch。因为arch里的代码大多是“怎么把上下文保存到内存”、“怎么切换页表”、“怎么配置GIC”这类具体动作,而这些动作之所以存在,是因为core里某个流程需要它们。先知道为什么,再知道怎么做,源码阅读效率会高很多。
2.2 构建系统与配置参考:defconfig和.config
xvisor的构建风格和Linux内核很像,顶层Makefile负责编译,你需要在编译前指定架构和交叉工具链。最基础的命令是这样:
git clone https://github.com/xvisor/xvisor.git cd xvisor export CROSS_COMPILE=aarch64-linux-gnu- export ARCH=arm64 make defconfig make -j8编译产物一般会生成xvisor、xvisor.bin,以及相关的dtb和guest镜像。第一次拿到源码,建议先用默认配置编译一遍,别急着改功能。这样做的目的是把环境问题先排掉,确保你有一台“能跑的基准”。
xvisor的配置里有很多开关,比如是否开启VirtIO、是否支持某个平台、调试日志级别等。这些配置都在defconfig文件中体现,熟悉Linux内核menuconfig的人会感到很亲切。需要强调一点,xvisor是一个裸机程序,它不链接标准C库,所以它的很多“偏方”,比如自己维护的printf、自己实现的page allocator,你都要从“裸环境”的角度去理解。
2.3 三条主线:CPU虚拟化、内存虚拟化、中断虚拟化
虚拟化这个主题,拆开来看核心就是三条线:CPU虚拟化、内存虚拟化、设备与中断虚拟化。xvisor的源码也是按这个逻辑组织起来的。
- CPU虚拟化:通过vCPU结构描述“一个虚拟CPU的上下文”,包括寄存器组、运行状态、当前guest所属的虚拟机等。核心文件在core/vmm_vcpu.c和相关arch实现里。
- 内存虚拟化:核心对象是vmm_region,表示guest的一段物理内存区域。xvisor通过向guest展示“guest物理地址空间”,再靠硬件MMU的第二阶段翻译把它映射到真实物理地址。
- 中断虚拟化:包括中断控制器直通与虚拟中断注入。ARM上主要是GIC相关代码,RISC-V上是PLIC或APLIC相关代码。这部分的难点在于:一个中断到底该通知哪个guest,以及guest退出后hypervisor如何决定下一步执行什么。
我把这三条主线作为后面几章的分析重点。如果你自己看源码,建议记住一个词:trap。所有虚拟化的核心交互,本质上都是guest在执行某条特权指令或访问某块敏感资源时触发异常,然后陷入到hypervisor,hypervisor处理后返回到guest。理解了trap路径,就等于抓住了整个虚拟化的“经线”。
3. 核心机制解析与源码导读:把trap路径当成主线
3.1 启动流程:从冷启动到guest运行起来
以ARM64为例,xvisor启动顺序大致是:CPU从EL3或EL2入口进入,先执行arch下的启动汇编,初始化基本的异常向量表、栈指针、MMU和页表,然后进入C语言阶段的vmm_init。
vmm_init是整个系统的入口,它会依次做这些事:初始化host资源(内存分配器、页表、CPU)→ 解析设备树(vmm_devtree_parse)→ 创建设备树中定义的虚拟机实例(vmm_guest_init)→ 初始化调度器和定时器(vmm_scheduler_init)→ 最后进入调度循环,让第一个可运行的guest vCPU跑起来。
这个过程可以在QEMU里实际验证。我给读者的建议是把断点设置在vmm_guest_init上,因为这里会读取设备树里的每个guest节点,并给它们创建对应的VM和vCPU。断点一停,你可以看到设备树被解析到什么程度、每个guest分配了哪些内存、串口和VirtIO设备有没有被正确注册。
源码导读到这里,我已经把“骨架”拆出来了。很多人看源码会纠结某个数据结构的字段,这其实是次要的。先跟着启动流程走一遍,知道什么时候创建VM、什么时候创建vCPU、什么时候开始调度,后面那些细枝末节自然就串起来了。
3.2 内存虚拟化:GPA到SPA的两级映射
内存虚拟化是所有虚拟化实现里最绕、也最见功底的地方。先明确概念:真实物理地址叫SPA(System Physical Address),guest眼里看到的地址叫GPA(Guest Physical Address)。guest里的Linux内核会再做一层虚拟地址映射,所以一个guest应用要真正访问内存,至少要经历“guest虚拟地址→guest物理地址→系统物理地址”两级翻译。
xvisor靠硬件MMU的第二阶段翻译来实现GPA到SPA的映射。在ARM上叫Stage-2 MMU,在RISC-V里对应H-extension的stage2地址翻译。xvisor启动时,会为每个guest建立一个独立的Stage-2页表,并把它配置到虚拟化硬件寄存器中。guest访问任意外设或者物理内存,都要经过这层页表的校验与映射。
我在源码分析时重点关注了vmm_region这个结构。它描述了第几个内存区域、起始GPA、物理映射、大小、权限等字段。设备树里每个guest的memory节点,最后都会变成一连串vmm_region。如果guest想访问的GPA没有对应region,MMU翻译不过去,就会产生Stage-2 fault,然后陷入到hypervisor,交由异常处理函数决定是报错退出还是动态映射。
这里有一个典型的坑:guest的内存区域必须按页对齐,最好是2MB或1GB对齐,因为Stage-2页表有可能用大页(block entry)做映射。如果guest内存起始地址没有按2MB对齐,硬件无法使用块描述符,就只能退化成4KB页,页表项数量猛增,TLB命中率也会变差。我在调试时遇到过guest启动极慢的现象,后来发现就是内存起始地址没对齐,导致每次访问都触发Stage-2 fault。
3.3 CPU虚拟化与上下文切换:vCPU就是状态机
从源码角度看,vCPU本质上就是一个结构体,里面保存了通用寄存器、系统寄存器、程序状态寄存器、异常返回地址、运行状态等。ARM64上,guest在EL1运行,当guest执行hvc指令、或访问需要trap的寄存器、或发生中断,硬件会切换到EL2,跳转到xvisor注册的异常向量表。
这时xvisor需要把当前vCPU的上下文保存到内存里,然后根据异常原因决定下一步做什么。如果是定时器中断,就更新定时器状态;如果是VirtIO通知,就处理设备请求;如果guest执行了WFI(等待中断),调度器可能直接把这个vCPU标记为阻塞,把物理CPU让给另一个guest的vCPU运行。
最核心的寄存器有几个:ELR_EL2保存guest被trap时的PC,SPSR_EL2保存guest的程序状态,vbar_el2指向hypervisor异常向量表。上下文切换的代码在arch里通常是一个汇编宏,负责把x0到x30以及spsr、elr批量压栈和恢复。源码阅读建议:先别一行行读汇编,把这个宏当成“黑盒”,理解调用点在哪里,它保存了哪些东西,恢复顺序是什么,就够了。
xvisor的调度器是“协作式+可抢占式”混着的。每个guest都有一个优先级和调度策略,vCPU会被放到运行队列、等待队列。如果你在多核平台上跑两个guest,会看到Linux的smp相关打印,那说明每个guest的多个vCPU正在被xvisor分配到不同的物理CPU上运行。
3.4 中断与设备虚拟化:GIC直通和VirtIO模型
硬件中断虚拟化是嵌入式hypervisor里最看基本功的部分。ARM平台上的GIC提供了虚拟化扩展,可以让某些中断直接路由给guest,而不需要每次让hypervisor插手。xvisor会为每个guest配置中断亲和性和中断号映射,使guest的驱动能够直接操作GIC的CPU接口来enable/disable中断。
不过真正让guest能用起来的,还是设备虚拟化。xvisor不像QEMU那样全套模拟PCI设备和大量外设,它走的是轻量级VirtIO路线。VirtIO是一种半虚拟化标准:guest里装一个前端驱动,hypervisor这边提供后端处理队列,两者共享一块内存区域,通过MMIO或PCI通知机制交互。
以virtio-blk为例,guest里的Linux驱动把块设备请求写入virtqueue,然后通过写一个MMIO寄存器通知hypervisor。这个MMIO地址在guest看来就是一块普通外设地址空间,但实际上访问时会trap到xvisor的异常处理程序,xvisor再去调用后端驱动完成实际操作。设备模型的好处是大幅减少了设备模拟开销,坏处是需要guest支持特定驱动,Linux内核默认就带virtio驱动,所以实际用起来很方便。
源码里可以重点关注emulator和drivers/virtio两个目录。学习路径建议先看virtio-mmio传输层,再往上读blk或net后端,最后再回来看GIC。只要把“数据通过共享内存传递、事件通过MMIO触发”这层理解透,xvisor的设备模型就算摸到了。
4. 实操:从源码到在QEMU上跑起一个完整guest
4.1 交叉编译环境准备:一套能跑的基准
源码分析不能光看不练,最好把xvisor跑起来。没有真实开发板也没关系,用QEMU模拟ARM平台完全够用。
建议准备一个aarch64交叉编译环境。Ubuntu/Debian可以安装gcc-aarch64-linux-gnu和对应的交叉工具链:
sudo apt install gcc-aarch64-linux-gnu sudo apt install device-tree-compiler然后编译xvisor。注意一定要指定ARCH和CROSS_COMPILE,同时建议开启额外的调试日志。启动阶段如果想看详细过程,可以在make的时候加上配置文件相关选项。不同版本的构建选项略有差异,但侧重点一致:先保证交叉工具链正确,再保证defconfig正确。如果编译过程中出现找不到crt0或者找不到main,多半是你用了普通gcc而不是裸机工具链。xvisor是独立二进制,不依赖libc,对工具链的“裸机属性”有要求。
4.2 生成DTS与引导镜像:把guest“钉”在资源上
xvisor把整个系统的静态配置放在设备树里。你需要让xvisor知道:物理内存多大,串口在哪,哪个guest用哪段内存,内核镜像放在哪里,初始化参数是什么。
以我常用的方式为例,给xvisor用的主设备树里会包含这样一个guest节点:
guest { compatible = "xvisor,guest"; device_type = "guest"; #address-cells = <2>; #size-cells = <2>; cpu_num = <2>; memory { device_type = "memory"; guest-phys-addr = <0x0 0x80000000>; host-phys-addr = <0x0 0x80000000>; guest-phys-size = <0x0 0x20000000>; }; vdevices { virtio@0 { compatible = "virtio,mmio"; guest-phys-addr = <0x0 0x01000000>; interrupt = <33>; }; }; };这段配置的意思是:给这个guest分配一段从0x80000000开始的512MB物理内存,并在它的GPA 0x01000000处放一个VirtIO-MMIO设备。guest内核启动参数root、console等放在chosen节点里。这样guest启动后,看到的是一个“拥有自己内存和外设的机器”。
这个过程手动做很繁琐,好在xvisor源码里带了生成guest镜像和DTS的脚本。实际编译时,你可以在Makefile参数里指定guest kernel镜像路径,构建系统会自动把镜像打包进一个initramfs式的image里,并在生成DTS时填好内存和加载地址。
4.3 QEMU启动、日志与实时调试
编译好之后,用QEMU跑起来大概是这个样子:
qemu-system-aarch64 \ -machine virt \ -cpu cortex-a57 \ -m 512M \ -smp 2 \ -nographic \ -kernel xvisor.bin如果你的构建流程生成了完整的guest image,那么启动日志里会先出现xvisor的版本信息和启动信息,然后进入调度,guest的Linux内核开始打印启动日志。看到两套启动日志并存,意味着xvisor已经成功把一个Linux虚拟机拉起来了。
平时我调试时会加几个参数:-s是开启GDB server,-d int是记录中断异常事件,-D log.txt可以保存QEMU日志。xvisor自身也支持日志级别调整,把日志打到perf甚至debug档,能看到大量设备树解析和设备初始化输出。配合GDB断点,我可以在vmm_guest_init里观察guest VM的数据结构,也可以在异常向量表入口处看trap原因。
这一步是我认为整个xvisor学习路线中最重要的一环。因为虚拟化的核心行为就是“guest执行指令→触发trap→hypervisor处理→返回guest”,你边看QEMU日志边断在异常向量表里,整个过程在真实硬件上要调试很久才能厘清,但在模拟器里很容易复现和理解。
5. 源码分析中常见的坑:实打实的排查记录
5.1 问题速查表:先对照看看自己踩了哪个
我把实际踩过的坑整理成一张表。它不能覆盖所有问题,但覆盖了我遇到的高频场景。
| 现象 | 大概率原因 | 排查方向 |
|---|---|---|
| 编译报错找不到标准库 | 使用了普通gcc工具链 | 换成aarch64裸机/独立工具链,确认CROSS_COMPILE生效 |
| 启动后只有xvisor logo,guest没反应 | guest镜像没正确加载,或DTS内存配置错误 | 检查guest的memory节点和host-phys-addr是否真实存在且可用 |
| guest启动后卡在early console | chosen节点bootargs里console参数不对 | 确认串口设备节点兼容字符串和行为,比如pl011还是ns16550 |
| guest访问外设触发异常 | 设备树里vdevices没有正确描述设备 | 查看vmm_devtree解析日志,确认VirtIO-MMIO地址、中断号是否与驱动匹配 |
| 调度器切换卡顿或死锁 | 中断未正确注入guest | 检查GIC相关配置,确认中断路由是给哪个guest |
| 多次启动结果不一致 | 设备树或内存地址没有按页对齐 | 检查内存区域对齐和起始地址,避免page table无法映射 |
这些都是我在分析xvisor源码时反复遇到过的。前两个问题最隐蔽,因为日志都有误导性:编译报错指向某个看似正常的底层函数,实际是工具链不对;guest没反应时,串口只显示xvisor的信息,让人误以为是调度器问题,实际是资源没配上。
5.2 我的三步排查法:当源码报错时别乱改
遇到问题不要立刻改源码。我总结了一个排查顺序,对裸机hypervisor尤其有效。
第一步,看日志等级。xvisor日志有明确分级,默认可能是info,信息太少。先打开perf或debug日志,重跑一次,确认问题出现在设备树解析阶段、host初始化阶段、guest初始化阶段还是运行阶段。
第二步,确认异常发生在哪一段地址空间。用GDB看异常时寄存器的值,尤其是PC和ELR。如果PC落在guest地址范围内,说明是guest执行出问题;如果PC落在xvisor自己的某个函数里,则说明hypervisor侧逻辑出问题。这一步能快速缩小排查范围。
第三步,对着DTS做减法。没有任何客套话,把guest设备树里的设备逐个删掉,直到只能启动一个最小Linux。通过这种“最小化实验”,你能找到是哪个设备或哪段内存导致了问题。
这个方法看似简单,但在源码分析的工作中,能省掉一大半乱翻代码的时间。
5.3 值得继续深入的两个方向:多guest调度与RISC-V支持
xvisor本身还在持续演进。如果想继续深入,我建议两个方向。
第一个,多guest调度实验。xvisor支持多个guest并发运行,你可以让两个Linux guest共享同一组物理CPU,观察调度策略对实时性和吞吐量的影响。打开调度相关的日志,可以看到每个vCPU何时被换入、换出,触发原因是时间片耗尽还是WFE。
第二个,RISC-V架构支持分析。RISC-V的虚拟化扩展相对干净,xvisor对RISC-V的支持代码比较新,但异常流程和MMU关注的思路与ARM一致。对比着看,你会发现xvisor把架构无关部分和架构相关部分拆得很干净,这个设计本身就是很好的源码阅读教材。
6. 最后想分享的一点源码阅读心得
源码阅读这件事,我一直觉得“跑不起来就不要谈读代码”。xvisor这条线尤其如此。它不像普通应用软件,跑起来能看到明确的用户界面,理解你的输入输出对不对。Hypervisor的行为全都在串口日志和异常向量表里体现,如果你不亲手编译、运行、打断点,只看代码,永远理解不了vmm_scheduler到底在等谁。
我自己的习惯是双屏操作:左边源码编辑器,右边QEMU窗口,两边对照着看。遇到一个trap,就在终端看异常信息,在源码里搜对应的handler。这样“从编译到运行、从运行到源码、从源码再回到运行”的闭环建立起来之后,学习速度会非常快。
xvisor不是最强大的商业化hypervisor,但它是理解虚拟化原理的极佳窗口。按目录、按trap路径、按实际运行去拆解,读完之后你对KVM、Xen、乃至商业hypervisor的理解都会上升一个台阶。如果你打算在自己的板卡上移植xvisor,或者想把某个虚拟设备加进去,顺着core和arch两条主线走,比从头看代码要少走很多弯路。