1. 为什么我要啃 xvisor 这套源码
第一次接触 xvisor 是在一个嵌入式项目里,客户要求在一块 ARM 开发板上跑多个相互隔离的实时系统,同时还得兼顾一个 Linux 通用系统。当时团队里有人提议用 KVM,但 KVM 依赖完整的 Linux 内核,对于资源受限的嵌入式场景来说太重了。后来翻资料翻到了 xvisor,一个 Type-1 型的开源 Hypervisor,代码量比 Xen 小得多,架构也清爽,最关键的是它本身就支持 ARM 和 RISC-V 架构,正好对上我们的需求。
xvisor 是什么?简单说,它是一个轻量级的裸机 Hypervisor,直接跑在硬件上,不需要宿主操作系统。它负责管理底层的 CPU、内存、中断这些资源,然后把这些资源切分给上面跑的一个个虚拟机(VM)。每个 VM 里可以跑不同的 Guest OS,比如 Linux、RTOS,甚至另一个 Hypervisor。它解决的问题就是:在一块物理板上,让多个系统互不干扰地共存,同时保证实时性和安全性。
这套东西适合谁看?如果你已经写过 Linux 驱动,了解基本的 ARM 架构概念,想往虚拟化方向深入,那 xvisor 的源码是一个非常好的切入点。它不像 KVM 那样和 Linux 内核深度耦合,也不像 Xen 那样庞大,核心代码几万行,一个人花时间能啃下来。我前后花了大概两个月,把 vcpu 调度、内存管理和中断注入这几块核心逻辑过了一遍,下面把过程中的理解和踩过的坑整理出来。
2. xvisor 整体架构与核心设计思路
2.1 Type-1 Hypervisor 的定位与取舍
虚拟化方案大致分两类:Type-1 和 Type-2。Type-2 就是 VMware Workstation、VirtualBox 那种,跑在操作系统之上的,宿主系统先启动,Hypervisor 作为应用运行。Type-1 则是 Hypervisor 直接跑在硬件上,它本身就是最底层的软件层,Guest OS 跑在它上面。
xvisor 选择了 Type-1 路线,这个选择背后的逻辑很清晰:嵌入式场景下,资源有限,你不可能先跑一个完整的 Linux 再在上面跑 Hypervisor,那样开销太大。Type-1 直接管理硬件,省掉了宿主系统的开销,中断延迟也更可控。代价就是 xvisor 自己得实现一套完整的驱动框架,包括串口、定时器、中断控制器这些,工作量不小。
我读源码时的一个感受是,xvisor 在“轻量”和“功能完整”之间做了很多权衡。比如它没有实现复杂的设备模型,而是提供了直通(passthrough)和半虚拟化(para-virtualization)两种方式让 Guest 访问设备。直通就是把物理设备直接分配给某个 VM,性能好但灵活性差;半虚拟化则是 Guest 通过 Hypervisor 提供的抽象接口访问设备,灵活但需要 Guest 配合改驱动。
2.2 源码目录结构与模块划分
xvisor 的源码目录组织得很规整,初次接触的话建议按这个顺序看:
core/:Hypervisor 的核心逻辑,包括 vcpu 管理、调度器、内存管理、中断处理。这是最核心的部分。arch/:架构相关代码,分arm/、arm64/、riscv/等子目录,包含启动代码、页表操作、寄存器定义。drivers/:设备驱动,比如串口、定时器、中断控制器。libs/:通用库函数,字符串操作、链表、锁机制等。emulators/:设备模拟器,用于半虚拟化场景。tests/:测试用例,对理解功能很有帮助。
我建议的阅读顺序是:先从arch/arm64/的启动代码入手,看 Hypervisor 是怎么从 bootloader 接管 CPU 的;然后跳到core/vcpu.c和core/sched.c,理解 vcpu 的创建和调度;接着看core/vmm.c,搞清楚 VM 的生命周期管理;最后深入core/mm.c和arch/arm64/mm.c,把内存虚拟化的逻辑吃透。
2.3 与 KVM、Xen 的架构差异
很多人会拿 xvisor 和 KVM、Xen 对比。KVM 是 Type-2 思路的典型代表,它把 Linux 内核变成了 Hypervisor,利用内核已有的调度、内存管理机制,代码复用好,但依赖内核。Xen 是 Type-1,功能强大,支持 PV 和 HVM 两种模式,但代码量大,学习曲线陡。
xvisor 的定位介于两者之间:它像 Xen 一样是 Type-1,但代码量小得多,架构更简单。它没有 Xen 那么复杂的域管理机制,也没有 KVM 那样和内核的深度绑定。对于想理解虚拟化核心原理的人来说,xvisor 的代码量刚好,不会一上来就被淹没。
一个具体的差异体现在 vcpu 调度上。KVM 直接用 Linux 的 CFS 调度器,vcpu 就是一个普通进程。xvisor 则自己实现了一个简单的调度器,支持 round-robin 和优先级调度。这个调度器代码在core/sched.c,不到一千行,但把 vcpu 的上下文切换、时间片管理都涵盖了。
3. VCPU 管理与调度:源码级拆解
3.1 vcpu 数据结构与生命周期
vcpu 是虚拟化的核心抽象,每个 vcpu 代表一个虚拟 CPU,Guest OS 看到的 CPU 就是这个。在 xvisor 里,vcpu 的数据结构定义在include/vcpu.h,核心字段包括:
id:vcpu 在 VM 内的编号。state:当前状态,比如VCPU_STATE_READY、VCPU_STATE_RUNNING、VCPU_STATE_BLOCKED。regs:保存 Guest 的寄存器上下文,包括通用寄存器、系统寄存器。sched_info:调度相关信息,比如优先级、时间片。arch:架构相关的私有数据,ARM64 下会保存 EL2 的一些状态。
vcpu 的生命周期从vmm_vcpu_create()开始,这个函数在core/vcpu.c里。它会分配 vcpu 结构体内存,初始化寄存器上下文,把 vcpu 状态设为VCPU_STATE_READY,然后挂到所属 VM 的 vcpu 列表上。创建完成后,通过vmm_vcpu_run()让 vcpu 开始运行。
我读这块时踩过一个坑:vcpu 的寄存器上下文初始化。ARM64 下,Guest 运行在 EL1,Hypervisor 运行在 EL2。当从 Hypervisor 切换到 Guest 时,需要恢复 Guest 的寄存器,包括ELR_EL2(异常返回地址)、SPSR_EL2(保存的程序状态)、SP_EL1(Guest 的栈指针)等。这些寄存器的初始值决定了 Guest 从哪里开始执行。xvisor 在arch/arm64/vcpu.c的vcpu_arch_init()里做了这些设置,默认把 Guest 的入口设为 VM 镜像的加载地址。
3.2 调度器的实现与调度策略
xvisor 的调度器在core/sched.c,实现了一个基于优先级的调度算法。每个 vcpu 有一个priority字段,数值越小优先级越高。调度器维护一个就绪队列,每次从队列里选优先级最高的 vcpu 运行。
调度器的核心函数是sched_pick_vcpu(),逻辑大致是:
- 遍历当前 CPU 上的就绪 vcpu 列表。
- 找出优先级最高的那个。
- 如果优先级相同,用 round-robin 方式轮转。
- 返回选中的 vcpu。
上下文切换在sched_switch()里完成。这个函数会保存当前 vcpu 的上下文,恢复下一个 vcpu 的上下文,然后通过arch_vcpu_switch()跳转到架构相关的切换代码。ARM64 下,arch_vcpu_switch()会操作TPIDR_EL2(保存当前 vcpu 指针)、ELR_EL2、SPSR_EL2等寄存器,最后执行eret指令返回 Guest。
这里有个细节值得注意:xvisor 的调度器是 per-CPU 的,每个物理 CPU 有自己的就绪队列和调度器实例。这样做的好处是减少了锁竞争,但代价是 vcpu 迁移需要跨 CPU 操作。xvisor 支持 vcpu 迁移,通过vmm_vcpu_migrate()实现,但迁移过程中需要暂停 vcpu,确保上下文一致。
3.3 上下文切换的关键代码路径
上下文切换是虚拟化里最微妙的部分之一。我以 ARM64 为例,把关键代码路径梳理一下。
当 Hypervisor 决定切换到某个 vcpu 时,调用链是:
sched_switch() -> arch_vcpu_switch() -> arm64_vcpu_switch() -> 保存当前 vcpu 的 callee-saved 寄存器到 vcpu->arch.regs -> 恢复下一个 vcpu 的 callee-saved 寄存器 -> 设置 TPIDR_EL2 指向新 vcpu -> 设置 ELR_EL2 为新 vcpu 的 PC -> 设置 SPSR_EL2 为新 vcpu 的 PSTATE -> ereteret指令是 ARM64 的异常返回指令,它会从ELR_EL2读取返回地址,从SPSR_EL2读取处理器状态,然后跳转到 EL1(Guest 的运行级别)。这一跳之后,CPU 就正式进入 Guest 上下文了。
我实测时遇到过一个现象:Guest 第一次启动时,eret之后直接跑飞了。排查后发现是SPSR_EL2的初始值没设对,导致 Guest 运行在错误的异常级别。正确的做法是把SPSR_EL2的M[3:0]字段设为0b0101,表示 EL1h 模式,同时确保DAIF位正确设置,允许中断。
注意:上下文切换代码对寄存器操作极其敏感,任何一位设错都可能导致 Guest 跑飞或 Hypervisor 崩溃。调试时建议在
eret前打印ELR_EL2、SPSR_EL2、SP_EL1的值,确认无误后再继续。
4. 内存虚拟化:从 Stage-2 页表到地址转换
4.1 ARM64 的 Stage-2 翻译机制
内存虚拟化是 Hypervisor 最复杂的部分之一。ARM64 提供了硬件辅助的虚拟化支持,核心是 Stage-2 地址翻译。简单说,Guest 里的虚拟地址(VA)先经过 Stage-1 页表翻译成 Guest 物理地址(IPA),然后 IPA 再经过 Stage-2 页表翻译成真正的物理地址(PA)。
Stage-1 页表由 Guest OS 自己维护,Hypervisor 不直接干预。Stage-2 页表由 Hypervisor 维护,决定了 Guest 能看到哪些物理内存。xvisor 在arch/arm64/mm.c里实现了 Stage-2 页表的管理。
Stage-2 页表的基地址存放在VTTBR_EL2寄存器里。当 CPU 在 EL1 执行时,MMU 会自动用 Stage-1 和 Stage-2 两级翻译。如果 Stage-2 翻译失败,会产生一个 Stage-2 异常,陷入 EL2,Hypervisor 可以捕获这个异常,决定是分配内存还是报错。
4.2 xvisor 的内存管理实现
xvisor 的内存管理分两层:物理内存管理和虚拟内存管理。物理内存管理在core/mm.c,用了一个简单的伙伴系统分配器。虚拟内存管理在arch/arm64/mm.c,负责 Stage-2 页表的创建、修改和销毁。
创建 VM 时,xvisor 会为 VM 分配一段 IPA 空间,然后建立 Stage-2 页表映射。映射的粒度支持 4KB、2MB、1GB 三种,分别对应三级页表。xvisor 默认用 4KB 粒度,因为灵活,但 TLB 命中率低。对于大块连续内存,可以用 2MB 或 1GB 映射,减少页表层级,提升性能。
我读这块时的一个体会是:Stage-2 页表的修改需要特别小心 TLB 一致性。修改页表后,必须执行TLBI指令刷新 TLB,否则 CPU 可能用到旧的映射。xvisor 在arch/arm64/mm.c里封装了arm64_mm_flush_tlb(),内部用TLBI VMALLS12E1指令刷新所有 Stage-1 和 Stage-2 的 TLB 条目。
4.3 内存映射的实操与调试
实际调试内存虚拟化时,我建议从最简单的场景开始:给 VM 分配一段固定内存,建立 1:1 映射(IPA 等于 PA),然后让 Guest 访问这段内存。如果 Guest 能正常读写,说明 Stage-2 翻译基本正确。
然后逐步增加复杂度:改成非 1:1 映射,观察 Guest 是否能正确访问;增加多个内存区域,测试页表切换;最后测试内存权限控制,比如把某段内存设为只读,看 Guest 写入时是否触发异常。
xvisor 提供了一个vmm_mm_map()接口,用于建立 Stage-2 映射。调用时需要指定 IPA、PA、大小和权限。权限包括读、写、执行,以及是否允许 EL0 访问。我踩过的一个坑是权限位设错,导致 Guest 内核能访问但用户态访问触发异常。排查时用vmm_mm_dump()打印页表内容,逐项核对权限位。
提示:调试 Stage-2 页表问题时,可以在
arch/arm64/mm.c的arm64_mm_fault()里加打印,输出触发异常的 IPA 和错误码。错误码的 bit 6 表示写操作,bit 5 表示执行操作,bit 4 表示访问权限不足,这些信息对定位问题很有帮助。
5. 中断虚拟化与设备直通
5.1 中断注入的基本流程
中断虚拟化是另一个核心模块。物理中断到来时,CPU 先陷入 EL2,Hypervisor 的中断处理程序判断这个中断应该发给哪个 VM,然后注入到对应的 vcpu。
xvisor 的中断处理在core/irq.c和arch/arm64/irq.c。物理中断号通过GIC(通用中断控制器)获取,然后查中断路由表,找到目标 vcpu。注入中断时,xvisor 会设置 vcpu 的VIRQ寄存器,然后触发一个虚拟中断异常,让 Guest 的异常处理程序接管。
ARM64 下,虚拟中断通过HCR_EL2的IMO和FMO位控制。设置IMO后,物理 IRQ 会路由到 EL2,Hypervisor 可以拦截。设置FMO后,FIQ 也会路由到 EL2。xvisor 在初始化时会把这两个位设上,确保所有中断都先经过 Hypervisor。
5.2 设备直通与半虚拟化的取舍
设备访问有两种方式:直通和半虚拟化。直通是把物理设备直接分配给 VM,VM 里的驱动直接操作设备寄存器。这种方式性能好,但设备不能被多个 VM 共享,而且需要 IOMMU 支持地址隔离。
半虚拟化则是 VM 通过 Hypervisor 提供的抽象接口访问设备。比如网络设备,VM 里的驱动不直接操作网卡寄存器,而是把数据包发给 Hypervisor,由 Hypervisor 转发到物理网卡。这种方式灵活,支持设备共享,但需要 Guest 配合修改驱动,性能也有一定损失。
xvisor 两种方式都支持。直通通过vmm_passthrough_add()接口配置,半虚拟化则通过emulators/目录下的设备模拟器实现。我实际项目中用的是半虚拟化串口,因为串口不需要高性能,而且多个 VM 共享一个物理串口很方便。
5.3 中断调试中的常见陷阱
中断调试最容易遇到的问题就是中断丢失或重复注入。我遇到过两种情况:一种是 Guest 收不到中断,排查后发现是HCR_EL2的IMO位没设,物理中断直接路由到了 EL1,Hypervisor 根本没拦截到。另一种是 Guest 收到重复中断,原因是中断注入后没有正确清除VIRQ状态,导致下次中断到来时又触发了一次。
解决这类问题的关键是理解中断的生命周期:物理中断到来 -> Hypervisor 拦截 -> 查路由表 -> 注入虚拟中断 -> Guest 处理 -> 清除虚拟中断状态。每一步都要确认状态正确。xvisor 在core/irq.c里提供了irq_dump()接口,可以打印当前中断状态,调试时很有用。
注意:中断注入的时序很关键。如果 Guest 正在处理上一个中断,新的中断注入可能会被覆盖。xvisor 的做法是维护一个 pending 中断列表,Guest 处理完当前中断后,Hypervisor 再注入下一个。这个逻辑在
arch/arm64/irq.c的arm64_irq_inject()里。
6. 常见问题与排查技巧实录
6.1 启动阶段问题速查
xvisor 启动阶段的问题主要集中在 CPU 初始化、页表建立和串口输出上。我整理了一个速查表:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 串口无输出 | 串口驱动未初始化或波特率错误 | 检查drivers/serial/下的初始化代码,确认波特率与硬件一致 |
| 启动后卡死 | 页表未建立或 MMU 配置错误 | 在arch/arm64/start.S里加打印,确认 MMU 使能前后的 PC 值 |
| 进入 EL2 失败 | 启动模式不支持或寄存器配置错误 | 检查CurrentEL寄存器的值,确认 CPU 确实在 EL2 |
| Guest 启动跑飞 | vcpu 上下文初始化错误 | 打印ELR_EL2、SPSR_EL2、SP_EL1,逐项核对 |
6.2 运行阶段问题排查
运行阶段的问题更复杂,常见的有 vcpu 调度异常、内存访问错误、中断丢失。我的排查思路是:
- 先确认 Hypervisor 本身是否正常。用
vmm_dump()打印 VM 和 vcpu 状态,看是否与预期一致。 - 如果 vcpu 卡住,检查调度器状态。用
sched_dump()打印就绪队列,看目标 vcpu 是否在队列里。 - 如果内存访问出错,用
vmm_mm_dump()打印 Stage-2 页表,核对映射关系。 - 如果中断异常,用
irq_dump()打印中断状态,确认注入和清除逻辑。
我踩过的一个坑是 vcpu 优先级设置错误,导致高优先级 vcpu 一直占用 CPU,低优先级 vcpu 饿死。xvisor 的调度器没有实现优先级老化机制,如果高优先级 vcpu 一直就绪,低优先级 vcpu 永远得不到运行。解决办法是在 Guest 里合理设置 vcpu 优先级,或者修改调度器加入时间片轮转。
6.3 性能优化经验
xvisor 的性能优化主要从三个方面入手:减少上下文切换开销、提升 TLB 命中率、优化中断处理路径。
减少上下文切换开销的方法是尽量让 vcpu 长时间运行,避免频繁切换。可以通过调整调度器的时间片大小来实现。xvisor 默认时间片是 10ms,对于实时性要求高的场景可以调小,对于吞吐量优先的场景可以调大。
提升 TLB 命中率的方法是使用大页映射。对于大块连续内存,用 2MB 或 1GB 页映射,可以显著减少 TLB 条目数量,提升命中率。xvisor 支持在vmm_mm_map()时指定页大小,我实测下来,2MB 映射比 4KB 映射的 TLB 命中率提升了约 30%。
优化中断处理路径的方法是减少 Hypervisor 里的中断处理时间。xvisor 的中断处理分上半部和下半部,上半部只做最紧急的事,比如记录中断号,下半部做路由和注入。如果中断频率很高,可以考虑把部分处理逻辑下放到 Guest,减少 Hypervisor 的介入。
7. 从源码分析到实际移植的体会
把 xvisor 移植到一块新板子上,工作量主要集中在串口驱动、定时器驱动和中断控制器驱动上。串口是调试的基础,没有串口输出,后面什么都做不了。定时器是调度器的基础,没有定时器中断,vcpu 调度就无从谈起。中断控制器是设备虚拟化的基础,没有中断,Guest 的设备驱动就跑不起来。
我移植时的顺序是:先调通串口,确保能打印;然后调通定时器,确保调度器能工作;接着调通中断控制器,确保中断能注入;最后调通内存管理,确保 Guest 能正常访问内存。每一步都写一个简单的测试用例,确认功能正确后再进行下一步。
xvisor 的源码里有很多BUG_ON()和WARN_ON()宏,这些在调试时非常有用。遇到问题时,先看有没有触发这些宏,触发了就顺着调用栈往上找,通常能快速定位到问题所在。
最后分享一个小技巧:xvisor 支持通过vmm_vcpu_dump()打印 vcpu 的寄存器状态,包括 PC、SP、PSTATE 等。Guest 跑飞时,第一时间打印这些信息,能帮你判断是 Guest 自己的问题还是 Hypervisor 的问题。如果 PC 指向的地址在 Guest 镜像范围内,那大概率是 Guest 的问题;如果 PC 指向 Hypervisor 的代码,那就是 Hypervisor 的 bug 了。