☰
从xvisor源码剖析虚拟化:CPU、内存与中断全解析
2026/10/31 1:11:32 网站建设 项目流程

1. 为什么我会推荐用xvisor入门虚拟化,而不是KVM或Xen

先说说我自己的经历。几年前我想系统性学一下虚拟化原理,第一反应就是去啃KVM的源码。结果大家都知道——virt/kvm目录下面那一大堆结构体、回调、ioctl处理逻辑,配合Linux内核本身的复杂调度和内存管理,一个新手没几个月根本理不清头绪,最后大概率变成"看了一堆代码但说不清虚拟机到底怎么跑起来的"。后来我换了思路去看Xen,结果又被它的dom0/domU架构和那套为了服务器虚拟化准备的复杂工具链带跑偏了。

真正让我把虚拟化的核心逻辑弄明白的,反而是xvisor这个相对小众的嵌入式开源hypervisor。

xvisor的全称是eXtended Versatile hypervisOR,Type-1型虚拟化方案,官方定位是面向嵌入式场景的轻量级VMM。它的代码量相比KVM和Xen小了一个数量级,架构清晰程度却一点不差:VM管理、vcpu调度、stage-2内存虚拟化、虚拟中断控制器、MMIO设备模拟,全都有独立模块。最关键的是一点——它不依赖Linux内核,是直接跑在硬件上的裸机hypervisor,所以你能在没有宿主机系统干扰的前提下,完整地观察一台虚拟机的生命周期。

这篇文章不是我翻译文档,是我自己读xvisor源码、在QEMU上实跑之后的记录和思考。内容会深入到具体代码路径,也会分享一些我踩过的坑。适合对虚拟化有一定概念、想通过源码理解"一台虚拟机是如何被创造和运行的"的读者。如果你是零基础,前两章也能帮你建立整体框架。

先说一个反直觉的结论:xvisor的源码可读性其实优于KVM,不在于它的代码写得有多花哨,而在于它把虚拟化最核心的三件事——CPU虚拟化、内存虚拟化、中断虚拟化——用最直白的方式摆在明面上,没有历史包袱,也没有为了性能做的大量优化分支。这恰恰是它作为教学样例的巨大价值。

2. xvisor源码结构解剖:从构建脚本到架构隔离

2.1 顶层目录:一眼识别模块边界

我建议第一次看xvisor源码时不要直接点进某个.c文件,先把顶层目录逛一遍。xvisor的源码结构保持了非常纯粹的分层:

  • arch/:存放不同CPU架构的实现,主要看arm/和arm64/,也有x86/、riscv/。
  • core/:平台无关的核心逻辑,包括vm.c、vcpu.c、mm.c、sched.c、pci.c等。
  • drivers/:各类驱动,按功能分子目录,如uart、interrupt、timer、virtio、block。
  • include/:全局头文件,很多关键数据结构的定义在这里。
  • libs/:基础组件库,实现list、bitmap、malloc等底层设施。
  • scripts/:构建辅助脚本,负责生成配置、处理链接脚本等。

最值得留意的是arch/和core/的边界。xvisor尽量把与CPU架构相关的操作收拢到arch/目录,而core/里的逻辑大多是平台无关的。比如你去看core/vm.c,里面管的是VM的增删改查、状态机切换、guest内存布局,这些逻辑在所有架构下都一样;真正和硬件打交道的部分,比如如何切换页表、如何进入guest模式,则在arch/arm/arm/对应的实现中。

这种隔离模式在学习时非常舒服——你可以先在core/建立整体认知,再下沉到arch/看具体实现。

2.2 Kbuild构建系统与平台配置

xvisor的构建系统长得很像Linux内核的Kbuild,但简化了很多。源码根目录下有几个关键入口:

  • Makefile:最外层入口,负责分发到子目录。
  • Kbuild:各级目录下的构建描述文件。
  • arch/<arch>/configs/:各平台默认配置,比如vexpress-a15_defconfig、virt32_defconfig。

编译的第一步是配置。我常用的是make ARCH=arm vexpress-a15_defconfig,生成.config后可以用make menuconfig再做细调,比如选择串口驱动、启用GICv2还是GICv3等。这个配置流程和内核几乎一模一样,熟悉Linux内核构建的读者不会有任何陌生感。

一个容易忽略的点是:xvisor对编译工具链有要求。ARM32平台建议直接使用交叉编译器arm-linux-gnueabihf-,但要注意版本不能太老。我曾经用gcc 8以下的老工具链编译时,链接阶段报出奇怪的__aeabi_*符号缺失问题。后来换了较新的工具链才顺利通过。这个我们放到第6章的实操部分细说。

2.3 快速定位代码的实用方法

xvisor代码量不大,但直接grep也容易迷失。我整理了自己的定位方法:

  • 想了解VM管理入口,优先看core/vm.c,从vm_create、vm_add这类函数开始。
  • 想了解vcpu怎么被调度和运行,看core/vcpu.c和core/sched.c。
  • 想了解内存虚拟化,先看core/mm.c和arch/arm/arm/mm.c(不同架构后缀略有差异),再追guest_map相关函数。
  • 想了解中断虚拟化,先看drivers/interrupt/arm-gic.c,再配合core/vdevices.c看虚拟中断控制器注册。

我的习惯是使用cscope建立索引,比纯grep高效得多。第一次做全量索引大概十几秒,之后跳转函数定义、查找调用关系都很方便。如果是在vscode里看,装个C/C++插件配合CTags也能凑合。

3. guest虚拟机从出生到运行的完整代码路径

3.1 VM创建:guest_add背后发生了什么

xvisor里创建虚拟机的入口不是凭空vm_new,而是先要有一份"虚拟机描述"。它采用了一种类似配置文件的机制:用户在xvisor的shell中用guest_add命令加载一个guest镜像时,会解析一个VM配置,通常是一个.dts或结构化的guest描述文件,里面定义了虚拟CPU数量、内存大小、设备树等。

顺着这条线往下追,核心是core/vm.c中的vm_create函数。它会做这么几件事:

  1. 分配并初始化struct vmm_vm结构体。
  2. 初始化该VM的物理内存映射描述(vm_phys_addr到host_phys_addr的映射关系)。
  3. 注册该VM的虚拟设备列表,比如虚拟UART、虚拟GIC、virtio-blk。
  4. 创建VM级别的锁、调度实体、事件通道等基础设施。

这里很容易混淆的一个概念是:xvisor中的"VM"既是资源的集合,也是隔离的边界。它并不像Linux进程那样用task_struct这种单一结构描述,而是用struct vmm_vm挂载一堆子系统对象——内存映射、中断路由、设备树、vcpu列表。

我在阅读时建议你重点看struct vmm_vm的定义,它在include/xvisor/guest_types.h附近。你会发现它的成员基本就是整篇源码分析的目录索引:vcpu_array、vm_mmu、vdevices、guest_os等。

接着调用链进入guest_add对应的命令实现,位于core/cmds.c或平台相关命令文件中。这里会解析命令行参数,把guest镜像放到约定的物理地址,再调用vm_add把VM注册到全局链表。

3.2 vcpu运行与退出:一段ERET引发的旅程

VM创建成功之后,用户通过guest_start命令启动它,核心动作是让每个vcpu进入"可运行"状态。以ARM32为例,xvisor使用ARM虚拟化扩展的Hyp模式。

当vcpu被调度器选中运行时,会执行vcpu_run一类的核心函数,最终调用架构相关的切入函数。这一步的实质是:把vcpu上下文(寄存器组、状态)保存到结构体中,修改HCR(Hypervisor Configuration Register)等系统寄存器,然后用一条ERET指令从Hyp模式退回到guest的普通世界模式。

这条ERET之后发生了什么?

  • CPU进入guest模式,执行guest代码。
  • guest访问的虚拟地址经过stage-1(guest OS的页表)和stage-2(xvisor控制的页表)双重翻译。
  • 当guest触发异常、访问未映射的内存、执行特定敏感指令,或收到物理中断时,CPU会重新陷入Hyp模式。

回到Hyp模式后,中断向量表会把控制权交给xvisor的异常处理入口。这个入口在arch/arm/arm/exception.S之类的汇编文件中,代码很短——保存上下文、识别异常类型、跳转到C语言的vcpu_exception_handler。

我曾经在这段汇编代码上花了不少时间,后来才意识到不用纠结太多细节,只要抓住两个关键点就行:

  • 异常发生时,CPU自动保存一部分现场到struct vmm_vcpu_state中。
  • xvisor所有的异常处理最终会回到C代码里统一分发。

3.3 异常分发:hypervisor的事件循环

异常分发逻辑是理解xvisor运行模型的关键。所谓的"虚拟机运行",本质上就是一个事件循环:guest在跑、遇到事件、陷入hypervisor、处理完后再切回guest。

异常类型大致分四类:

  • 数据/指令异常(通常是stage-2缺页或MMIO访问)。
  • 系统寄存器访问陷入(如guest写CP15某些寄存器)。
  • 虚拟中断(即guest的中断被xvisor拦截后注入)。
  • hypercall(guest主动调用xvisor提供的服务)。

分发函数会根据异常原因寄存器的值,决定走哪条处理路径。比如ARM32的HCR、DFAR、IFAR会记录异常详情,xvisor再调用mmio_emulate、vcpu_reg_emulate等函数去模拟相应设备的行为。

这整套逻辑和KVM中的kvm_vcpu_run、kvm_arch_vcpu_ioctl_run有很强的对应关系,但xvisor的实现明显更薄、更直接。读完xvisor再回去看KVM,你会有一种"啊,原来KVM是在这个基础上加了一堆宿主Linux的适配层"的恍然感。

4. stage-2内存虚拟化:地址翻译与缺页处理源码实现

4.1 两阶段地址转换的硬件基础

现代CPU虚拟化都离不开两阶段地址转换。这个概念理解起来其实像在快递柜里套了一个小快递柜:

  • stage-1:guest OS把自己的虚拟地址(VA)翻译成中间物理地址(IPA),这一层由guest OS自己管理页表。
  • stage-2:hypervisor把IPA翻译成真实的物理地址(PA),这一层的页表由hypervisor维护,guest OS完全无感知。

在ARM架构上,开启虚拟化扩展后,MMU会自动执行这两级翻译。xvisor作为hypervisor,它的核心职责之一就是维护好每个VM的stage-2页表,保证各个VM的物理地址空间互不重叠、互不可见。

ARMv7的LPAE和ARMv8的stage-2页表本质上都是多级页表,只是粒度和级数略有差异。xvisor在arch/arm/arm/mm.c和arch/arm/arm64/mm.c中实现了底层的页表操作函数:创建页表、插入映射、删除映射、查询映射等。

有一件事我觉得值得单独拎出来说:stage-2页表的映射粒度。xvisor内部会以2MB大页为常用映射单位来降低页表深度,但在需要精细控制的地方会拆成4KB小页。这不仅影响页表内存占用,还直接影响缺页处理的频率。你调试时如果发现guest频繁缺页,十有八九是映射粒度设置得不合理,这是一个非常实在的性能调优点。

4.2 IPA映射的建立与回收

guest物理内存的映射路径在xvisor中对应guest_map、guest_unmap这一组接口。核心实现在core/guest.c中。

当我们给guest分配一段内存时,xvisor会在struct vmm_vm的vm_mmu字段上绑定新的映射关系。以arm32为例,guest_map会调用架构相关函数,把一个IPA区间映射到实际分配的host物理内存区间,同时设置权限位(可读、可写、可执行、设备内存还是普通内存)。

缺页路径是这里最有趣的部分。guest访问一个还没建立stage-2映射的地址时,CPU陷入Hyp模式,xvisor查异常原因,发现是stage-2 translation fault,就进入mmio_emulate或内存映射缺页处理逻辑。

具体分支有两种:

  • 如果该IPA属于普通内存区域,xvisor就分配一块物理页,建立stage-2映射,然后让guest重新执行这条指令。
  • 如果该IPA属于设备MMIO区域,xvisor不会分配物理页,而是把这个访问"翻译"成一次设备模拟调用。例如guest往UART设备地址写一个字节,xvisor捕获后,调用虚拟UART驱动的write回调,把字符输出到真正的串口硬件。

这个"一个地址访问、两种命运"的设计是整个设备虚拟化的基石。理解它之后,你再去看virtio、virtio-blk、virtio-net的实现,就会发现所有设备的交互本质都是基于MMIO模拟的读写回调。

4.3 MMIO模拟与设备陷入

xvisor的设备模拟框架集中在core/vdevices.c里。每个虚拟设备都会注册一个struct vmm_vdevice对象,包含device的基地址、大小、读写回调。

比如虚拟UART,xvisor通常在guest内存的某个固定IPA放置一个串口设备的MMIO区域。guest一旦执行LDR或STR访问该区域,由于stage-2页表中这个区域被标记为设备映射(device memory),CPU会陷入xvisor。xvisor根据访问地址计算出是哪个设备,再调用对应回调。

这套机制让我联想到了内核里mmap和fops的关系——设备驱动的核心逻辑就是处理读写回调,把对特定地址的访问解释成设备行为。xvisor的设计本质上是在地址空间里开了一扇扇小门,每扇门后面都站着一个模拟设备。

做源码分析时,一个很有效的读法是在core/vdevices.c里打上断点或加上trace,然后用guest里的一个简单的hello world程序,去观察每一次串口输出是如何经过"guest写地址 -> 陷入hyp -> 回调vUART -> 真实串口输出"这条链路的。把这条链路跑通,你对整台虚拟机的IO路径基本就有直觉了。

5. 虚拟中断的软件把戏:vGIC实现原理

5.1 vGIC的架构拆解

中断虚拟化是虚拟化里最容易让人绕晕的模块。不是因为它难,而是因为GIC本身就有两大部分:distributor(分发器)和CPU interface(CPU接口),虚拟化时这两部分的模拟方式完全不同。

xvisor在drivers/interrupt/arm-gic.c中实现了对真实GIC的驱动,而在core/vdevices.c和arch/arm/arm/相关代码中实现了对guest可见的虚拟GIC。

  • 对guest来说,它看到的GIC dist和cpu interface都是MMIO设备,往里面写寄存器、读状态。
  • 对xvisor来说,它必须拦截这些MMIO访问,维持一份"虚拟中断状态",并在合适的时机把中断注入到vcpu。

GIC distributor部分:xvisor为每个VM维护一张虚拟中断表,记录每个中断号当前的状态、优先级、目标cpu。guest通过写GICD_ISENABLER等寄存器来enable中断,这些写操作全被xvisor截获,然后更新软件状态。真正的硬件GIC不会因为你guest写了一个enable位就真的去路由中断。

GIC cpu interface部分:guest会读GICC_IAR确认当前待处理中断,写GICC_EOIR完成中断处理。xvisor模拟这些寄存器时,必须和虚拟中断状态机严格对齐——什么时候返回一个中断号给guest、什么时候在EOIR写入后清掉pending位,直接决定了guest的中断驱动能否正常工作。

5.2 中断注入与物理中断路由

xvisor里中断注入的实现非常典型,核心思路是"物理中断进来,hypervisor决定把它作为虚拟中断送给哪个VM"。

以ARM GICv2为例:CPU收到物理中断后陷入Hyp模式,xvisor读取GICC_HPPIR获得物理中断号。然后判断这个中断源属于哪个VM——通常是通过中断号到VM的路由表查出来的——接着在对应的struct vmm_vcpu上设置一个pending虚拟中断标记,写GIC的List寄存器(在GICv2上)或通过软件注入(在GICv3上)。

这一步的妙处在于:xvisor不是把物理中断原封不动地转交给guest,而是把它翻译成一个虚拟中断事件。物理中断号是17,guest看到的虚拟中断号可能是33,完全由xvisor的映射关系决定。这个翻译层也是实现中断隔离的关键——guest永远不知道真实硬件中断是什么样。

5.3 优先级与亲和性的模拟代价

真实GIC有复杂的优先级抢占逻辑,虚拟化里如果完整模拟,开销会非常大。xvisor做了不少简化——它在单vcpu场景下对GIC的模拟尤其简化,很多时候就是直接按照"谁先pending谁先响应"的顺序来。

但简化也带来坑。我在调试一个双vcpu guest时发现,当中断到来时,xvisor默认把它交给vcpu0,即使guest的CPU亲和性设置里希望vcpu1处理。这个问题在GICv3实现里对亲和性有更好的支持,GICv2的模拟策略则显得粗暴一些。

所以我的建议是:初学阶段,优先看GICv2的模拟代码,因为它逻辑最简单;搞懂之后再看GICv3时,重点看它如何处理GICR(Redistributor)部分的路由和亲和性差异。看这一章时,最好手里准备一份GIC的架构手册,对照着arm-gic.c里的寄存器宏定义去认每一个函数在做什么——这几乎是读中断代码的唯一高效方式。

6. 跑起来看效果:在QEMU上编译运行xvisor并验证结论

6.1 环境准备与源码获取

源码直接clone官方仓库:

git clone https://github.com/xvisor/xvisor.git

建议直接切到最新的稳定分支,不建议用master上特别新的开发版本,因为有些开发版需要特定的工具链或QEMU版本,容易给自己挖坑。

运行环境方面,我的做法是用QEMU的ARM虚拟化平台。之所以选它,是因为QEMU本身支持GICv2且行为稳定,xvisor的vexpress-a15配置在该平台上跑通的先例最多。

配置工具链:

sudo apt install gcc-arm-linux-gnueabihf device-tree-compiler

6.2 编译配置的坑

xvisor的构建命令很直接:

make ARCH=arm vexpress-a15_defconfig make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- -j$(nproc)

构建产物是xvisor这个二进制、xvisor.dtb设备树。实际加载时用的通常是xvisor这个ELF格式文件。

这里有几个我实测过的坑:

  1. 如果编译时缺libfdt相关的头文件,说明需要安装device-tree-compiler,或者手动指定LIBFDT_DIR。
  2. 在较老版本中,如果你选了debug=1,编译时可能出现某个内联函数未使用的警告被视为错误。可以通过EXTRA_CFLAGS=-Wno-error绕过去。
  3. 链接时如果报undefined reference to memset之类的错误,通常不是代码问题,而是工具链的libgcc路径没找到,适当调整交叉编译器的SYSROOT即可。

我一开始在这上面花了不少时间,最典型的错误就是配置头文件选项时选错了GIC版本,导致QEMU启动后guest一开中断就卡死。建议默认配置不要乱改中断控制器选项,等你把默认配置跑通了再逐个调。

6.3 启动guest并观察虚拟化行为

QEMU启动命令参考如下:

qemu-system-arm -M vexpress-a15 -cpu cortex-a15 -m 512M \ -nographic -kernel xvisor \ -drive file=guest.img,if=none,format=raw,id=hd0 \ -device virtio-blk-device,drive=hd0 \ -append "console=vc:0 vmm.log_level=debug"

关键参数是-kernel xvisor和-append传递的vmm.log_level=debug。启动后你会进入xvisor的shell,这是hypervisor自己的控制台,而不是Linux的shell。

创建并启动一个guest的典型命令:

xvisor# guest add linux -f linux.dts xvisor# guest start linux

如果你用的是现成的Linux guest镜像,需要一份适配xvisor的dts描述文件。官方仓库有示例。没有dts的时候guest无法启动,因为xvisor需要知道guest的内存布局、设备地址、串口地址、中断号映射等信息。

按照我的经验,第一次在xvisor shell里看到guest的Linux内核输出,比第一次直接跑通KVM还要兴奋——因为你清楚地知道,这些输出不是宿主Linux转发打印的,而是xvisor这个裸机hypervisor在没有任何宿主机操作系统的情况下,用软件模拟出来的虚拟串口在输出内容。

调试时最实用的手段是打开vmm.log_level=debug。xvisor的日志系统会输出大量的guest陷入/退出记录,包括MMIO访问地址、中断注入动作。你在第4章和第5章相对枯燥的源码分析,此刻会全部"活"起来:每一个日志行都在对应着你读过的某一行C代码。

7. 和KVM、Xen对比后我的建议

7.1 三种虚拟化方案的本质差异

我以实际接触过三种方案源码的体会做一个对抗性整理:

维度xvisorKVMXen
类型Type-1裸机hypervisorType-1.5/2(依赖Linux内核)Type-1裸机hypervisor
宿主依赖无,直接操作硬件必须是Linux内核模块需要dom0管理域
代码规模中小,适合学习大,且与内核耦合深较大,工具链复杂
典型场景嵌入式、教学、IoT数据中心、云计算数据中心、多云隔离
虚拟化CPU支持ARM/x86/RISC-V以x86/ARM为主以x86为主
设备虚拟化简化MMIO模拟virtio + vhost加速QEMU设备模型
学习曲线平缓陡峭陡峭

这里面最本质的差异,是xvisor的"薄"。它没有KVM那样的"宿主OS进程"模型,也没有Xen的dom0权限域设计,而是把所有虚拟化逻辑塞进了一个相对独立的裸机程序里。这个"薄"既是它对生产场景功能覆盖不够的原因,也是它作为学习素材无比优秀的原因。

7.2 你的学习路径该选谁

如果你是想快速建立虚拟化全景认知的入门者,我强烈建议走这条路线:

  1. 先用xvisor理清四个核心概念:VM生命周期、vcpu切换、stage-2页表、虚拟中断注入。别贪多,能跑通demo、看懂日志就行。
  2. 回到KVM源码时,你会发现之前很多看不懂的代码是因为"不知道在一个完整的虚拟化系统中它们所处的位置"。现在你有了地图,再看KVM时是在已有地图上做标注,而非从零探索。
  3. 如果想深入了解隔离和资源管理,再去看Xen,重点看它如何通过设备模型和安全域设计来解决多租户隔离问题。

我自己在读完xvisor之后,再回看KVM的kvm_vcpu_run函数,第一次有一种"我完全知道这里为什么需要这段代码"的感觉。这种"降维打击"的感觉是很值得追求的——它说明之前建立的基础模型是对的。

最后说一个实践上的小建议:读xvisor源码时,别用看小说的方式从头读到尾,而应该"带着问题看"。比如就问自己三件事:一台VM怎么从一个命令变成一堆可执行上下文?guest读写一个串口地址时,代码路径经过哪些函数?物理中断和虚拟中断之间是如何翻译的?自己画一张调用图,比抄十遍注释都管用。这套方法同样适用于你之后去啃任何一个开源项目。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询