拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

xvisor源码深度剖析:从零构建Type-1 Hypervisor的入门实践

xvisor源码深度剖析:从零构建Type-1 Hypervisor的入门实践 虚拟化这摊事儿刚上手的时候我也觉得玄乎。什么Hypervisor、Type-1、特权级、VM Exit一堆名词砸过来头都大了。但真把xvisor的源码翻过一遍之后我发现它其实是所有开源Hypervisor里最适合拿来入门的一个——代码量可控、架构清晰、没有历史包袱而且它走的是纯Type-1路线不像KVM那样寄生在Linux内核里你得先搞懂半个内核才能看懂它的实现。这篇文章就是把我啃xvisor源码的过程完整记录下来。从它为什么这么设计、核心数据结构怎么组织、vCPU怎么调度、内存怎么虚拟化到实际编译跑起来、加自己的调试代码、踩过的那些坑我都会掰开揉碎讲清楚。不管你是刚接触虚拟化的学生还是想从KVM转过来看看别的架构的工程师或者单纯对Type-1 Hypervisor怎么从零搭起来感兴趣这篇应该都能给你一些实在的参考。1. 为什么选xvisor来啃源码1.1 市面上的开源Hypervisor各有各的门槛想学虚拟化源码第一步就是选一个合适的项目下手。KVM功能强、生态好但它的代码分散在Linux内核的arch/x86/kvm/、virt/kvm/等多个目录里你得先理解Linux内核的调度、内存管理、中断子系统才能看懂KVM在干什么。Xen历史悠久、功能完整但代码量巨大而且PV和HVM两套路径交织在一起初学者很容易迷路。QEMU严格来说不是Hypervisor它是设备模拟器配合KVM用的时候你看到的更多是设备模型的代码。xvisor不一样。它是一个独立的、轻量级的Type-1 Hypervisor整个项目从零写起不依赖任何宿主操作系统。代码结构非常扁平核心目录就那么几个core/放的是Hypervisor核心逻辑arch/放的是体系结构相关代码drivers/放设备驱动libs/放通用库函数。你打开源码树一眼就能看明白哪块是干嘛的。1.2 xvisor的代码规模适合通读我统计过一下xvisor的核心代码不算驱动和第三方库大概在几万行量级。这个规模意味着你花两三周业余时间是有可能把主要流程通读一遍的。相比之下KVM光x86部分就远超这个数字Xen更是几十万行的体量。更重要的是xvisor的设计目标就是可理解、可裁剪。它没有为了兼容各种历史硬件而堆积大量条件编译也没有为了性能把代码写得极其晦涩。很多关键函数比如vCPU的创建、内存映射的建立逻辑链路都很短你顺着调用关系追下去很快就能看到全貌。1.3 Type-1架构让你看清虚拟化的本质用KVM的时候你其实是在Linux内核之上又套了一层。很多虚拟化的核心动作比如VM Entry和VM Exit被内核封装得很深你看到的是ioctl接口。xvisor是裸机上直接跑的它自己就是最高特权级的那层软件。这意味着你能直接看到Hypervisor如何接管CPU、如何配置VT-x或AMD-V、如何处理Guest的每一次陷入。这种从裸机到Guest的完整视角是学KVM很难获得的。如果你之前只用过VMware Workstation或者VirtualBox这类桌面虚拟化软件建议先补一下x86保护模式、分页机制、中断处理这些基础知识不然看源码会很吃力。2. xvisor的整体架构与启动链路2.1 从Bootloader到Hypervisor入口xvisor的启动过程是这样的Bootloader比如U-Boot或者GRUB把xvisor的镜像加载到内存跳转到它的入口点。在RISC-V架构上入口是arch/riscv/cpu/entry.S在ARM上类似。入口代码做的事情很有限设置初始栈指针、清零BSS段、保存Bootloader传过来的设备树地址或者启动参数然后跳到C语言的初始化函数。这个初始化函数通常叫arch_init()或者cpu_init()它会依次完成几件事初始化控制台让你能看到打印信息、初始化物理内存管理、初始化中断控制器、初始化定时器。这几步做完Hypervisor本身就算活过来了但还没有任何Guest在跑。2.2 核心初始化流程拆解我把xvisor的初始化流程大致分成三个阶段第一阶段是体系结构相关初始化。这部分代码在arch/目录下做的是CPU级别的设置。比如在RISC-V上要设置stvec寄存器指向trap处理入口要配置PMP物理内存保护区域要初始化页表。在ARM上要设置VBAR_EL2指向异常向量表要配置HCR_EL2来使能虚拟化扩展。第二阶段是核心子系统初始化。这部分在core/目录下包括内存管理器初始化建立物理页帧分配器、vCPU管理器初始化建立vCPU池、Guest管理器初始化建立Guest链表、调度器初始化、模拟器初始化用于处理MMIO和特殊指令。第三阶段是驱动和设备初始化。根据设备树或者配置文件加载对应的驱动比如串口驱动、中断控制器驱动、定时器驱动。这些驱动是给Hypervisor自己用的不是给Guest用的。Guest看到的虚拟设备是另一套东西由模拟器模块负责。2.3 关键数据结构vmm_guest和vmm_vcpuxvisor里最核心的两个数据结构是struct vmm_guest和struct vmm_vcpu。前者代表一个虚拟机实例后者代表一个虚拟CPU。每个Guest下面挂着一串vCPU每个vCPU有自己的寄存器上下文、页表、定时器状态。struct vmm_vcpu里有一个非常关键的成员叫regs它保存了Guest的通用寄存器值。当发生VM Exit的时候Hypervisor把Guest的寄存器状态保存到这里当要VM Entry的时候再从这里恢复。这个结构的设计直接决定了上下文切换的效率。还有一个成员叫context保存的是Hypervisor自己在这个vCPU上运行时的上下文。这两套上下文是分开的理解这一点对看懂调度逻辑很重要。3. vCPU的创建、调度与上下文切换3.1 创建一个vCPU经历了什么创建vCPU的入口通常是一个叫vmm_vcpu_create()的函数。它做的事情包括从vCPU池里分配一个空闲的vCPU结构、初始化它的寄存器上下文比如把PC指向Guest的入口地址、初始化它的页表、初始化它的定时器、把它的状态设为可用。这里有个细节值得注意xvisor的vCPU创建并不是在Hypervisor初始化时就全部做好的而是在Guest启动过程中动态创建的。这意味着vCPU的数量是可以配置的你可以根据实际需求决定给一个Guest分配几个vCPU。创建完成后vCPU处于未调度状态。要让它真正跑起来还需要把它加入到调度队列里。3.2 调度器怎么决定下一个跑谁xvisor的调度器比较简单它维护一个就绪队列所有处于可运行状态的vCPU都挂在这个队列上。调度的时候从队列头取一个vCPU如果它的时间片还没用完就继续跑如果用完了就放到队列尾取下一个。这种轮转调度对于学习来说足够清晰但实际生产环境中可能需要更复杂的策略比如基于优先级的调度、基于负载的调度。xvisor的调度器框架是可扩展的你可以注册自己的调度策略。上下文切换发生在两个地方一是vCPU主动让出CPU比如执行了WFI指令二是时间片用完被动切换。切换的时候Hypervisor先保存当前vCPU的上下文然后恢复下一个vCPU的上下文最后执行VM Entry指令跳回Guest。3.3 VM Entry和VM Exit的完整路径这是虚拟化最核心的机制也是最容易看晕的地方。我以RISC-V为例讲一下完整路径。VM Entry的触发点通常在调度器决定要运行某个vCPU之后。代码会调用一个叫vmm_vcpu_run()的函数它最终会执行一条mret或者sret指令取决于Guest运行在什么特权级把CPU的控制权交给Guest。Guest运行过程中如果遇到了需要Hypervisor介入的情况——比如访问了未映射的内存、执行了特权指令、发生了外部中断——CPU会自动陷入到Hypervisor的trap处理入口。这个入口在entry.S里定义它会保存Guest的寄存器到vCPU的regs结构然后根据陷入原因分派到不同的处理函数。处理函数做完该做的事情之后可能会修改Guest的寄存器值比如模拟一条指令的执行结果然后返回到调度器调度器再决定是继续跑这个vCPU还是切换到别的vCPU。整个路径的关键在于Guest和Hypervisor之间的切换是硬件辅助的不需要软件去模拟每一条指令。这也是Type-1 Hypervisor性能好的根本原因。4. 内存虚拟化从Stage-2页表到MMIO模拟4.1 Guest物理地址到主机物理地址的转换内存虚拟化是Hypervisor里最复杂的部分之一。Guest里面看到的物理地址并不是真正的物理地址而是Guest物理地址GPA。Hypervisor需要把GPA转换成真正的主机物理地址HPA。这个转换在ARM上叫Stage-2翻译在RISC-V上叫G-stage翻译在x86上叫EPT或者NPT。xvisor为每个Guest维护一套Stage-2页表。当Guest访问一个GPA时如果硬件支持两阶段翻译CPU会自动查Stage-2页表完成转换如果不支持Hypervisor需要自己模拟这个转换过程。Stage-2页表的建立通常是在Guest启动时完成的。xvisor会根据Guest的配置把Guest的物理地址空间映射到Hypervisor的物理内存上。这个映射关系可以是1:1的也可以是不连续的。4.2 MMIO模拟当Guest访问了不存在的设备Guest访问MMIO区域时如果这个地址没有对应的真实设备CPU会产生一个页错误或者访问异常陷入到Hypervisor。Hypervisor检查陷入地址发现它落在某个虚拟设备的MMIO范围内就会调用对应的模拟函数。xvisor里有一套设备模拟框架每个虚拟设备注册自己的MMIO处理函数。当发生MMIO访问时框架根据地址找到对应的设备调用它的读或写函数。读函数返回一个值Hypervisor把这个值写回Guest的寄存器写函数接收Guest写入的值更新设备状态。这套机制让Guest以为自己真的在访问硬件实际上所有的访问都被Hypervisor拦截并模拟了。串口、定时器、中断控制器这些设备通常都是这样模拟的。4.3 页表隔离与安全边界Hypervisor和Guest运行在不同的地址空间里这是通过页表隔离实现的。Hypervisor有自己的页表Guest有Guest的页表Stage-2页表负责两者之间的映射。这种隔离保证了Guest不能访问Hypervisor的内存也不能访问其他Guest的内存。即使Guest内核被攻破攻击者也无法直接读写Hypervisor的数据结构。这是虚拟化安全性的基础。xvisor在建立页表的时候会仔细设置每个页面的权限位。Hypervisor的代码段是只读可执行的数据段是可读写的但不可执行Guest的页面根据Guest的配置设置权限。这些权限位由硬件强制执行软件无法绕过。5. 中断与定时器虚拟化的时间管理5.1 物理中断如何注入到Guest当外部设备产生中断时中断信号先到达Hypervisor的中断控制器驱动。Hypervisor判断这个中断应该发给哪个Guest然后通过虚拟中断控制器注入到Guest。在ARM上这个注入过程是通过设置GIC的虚拟中断寄存器完成的。在RISC-V上是通过设置PLIC或者APLIC的虚拟中断位完成的。xvisor把这些体系结构相关的操作封装成了统一的接口上层代码不需要关心底层是哪种中断控制器。注入中断的时机很关键。如果Guest正在处理一个更高优先级的中断新中断需要排队等待。如果Guest关闭了中断新中断也需要挂起。xvisor维护了每个vCPU的中断挂起队列确保中断不会丢失。5.2 虚拟定时器的实现Guest里的定时器是虚拟的。Guest设置一个定时器Hypervisor把这个定时器转换成物理定时器的一个事件。当物理定时器到期时Hypervisor产生一个虚拟定时器中断注入到Guest。xvisor的定时器子系统支持多种定时器源比如ARM的Generic Timer、RISC-V的SBI定时器。它把这些硬件定时器抽象成统一的接口上层代码只需要调用vmm_timer_event_start()之类的函数。定时器的精度和延迟直接影响Guest的时间感知。如果Hypervisor的定时器处理不够及时Guest里看到的时钟就会走偏。xvisor在这方面做了优化比如使用高精度定时器、减少中断处理路径上的延迟。5.3 中断注入的延迟问题与优化中断注入的延迟是虚拟化性能的一个重要指标。延迟太大Guest的响应就会变慢网络吞吐和磁盘IO都会受影响。xvisor减少延迟的手段包括在中断处理路径上尽量少做事情、使用直接注入而不是软件模拟、在调度器里优先调度有中断挂起的vCPU。这些优化措施的效果在实际测试中是可以量化的比如用网络基准测试工具对比优化前后的吞吐量。6. 动手编译与调试xvisor6.1 环境准备与工具链选择编译xvisor需要一个交叉编译工具链。如果你是在x86主机上编译RISC-V版本的xvisor需要安装riscv64-unknown-elf-gcc或者riscv64-linux-gnu-gcc。如果是编译ARM版本需要aarch64-linux-gnu-gcc。除了编译器还需要一些辅助工具设备树编译器dtc、镜像打包工具比如mkimage、调试工具gdb配合QEMU。这些工具在大多数Linux发行版的包管理器里都有。我建议在Ubuntu或者Debian上做这件事因为xvisor的文档和社区讨论大多基于这两个发行版。其他发行版也能用但可能需要自己解决一些依赖问题。6.2 编译配置与常见编译错误xvisor使用Kconfig作为配置系统和Linux内核类似。你可以用make menuconfig来配置要编译哪些功能、支持哪些平台。配置文件保存在.config里。常见的编译错误包括工具链路径不对、缺少某个头文件、某个配置选项依赖没打开。遇到错误的时候先看错误信息里提到的文件和行号再去检查对应的配置选项。大多数情况下错误信息是准确的只是需要你理解它背后的依赖关系。还有一个坑是xvisor的某些版本对工具链版本有要求。比如某个版本可能要求GCC 10以上你用GCC 9就会编译失败。遇到这种情况要么升级工具链要么切换到xvisor的另一个版本。6.3 在QEMU中运行并加调试输出编译完成后你会得到一个xvisor的二进制镜像。用QEMU加载这个镜像就可以在模拟环境里运行xvisor了。QEMU的命令行参数需要指定CPU类型要支持虚拟化扩展、内存大小、串口输出等。加调试输出是最直接的调试手段。xvisor有一个vmm_printf()函数用法和标准C的printf()类似。你可以在关键路径上插入打印语句观察程序的执行流程。比如在vCPU创建、调度切换、VM Exit处理这些地方加打印就能清楚地看到Hypervisor在做什么。更高级的调试手段是用GDB连接QEMU设置断点、单步执行、查看寄存器和内存。这种方式适合定位复杂的逻辑错误但需要你对汇编和体系结构比较熟悉。7. 源码阅读中容易卡住的几个点7.1 体系结构相关的汇编代码xvisor的entry.S和上下文切换代码是用汇编写的而且涉及很多体系结构相关的细节。如果你对RISC-V或者ARM的汇编不熟悉这部分会很痛苦。我的建议是先不要试图逐行理解汇编而是先搞清楚它的大致流程。比如entry.S做的事情就是保存寄存器、调用C函数、恢复寄存器、返回。你把这个框架记住了再去看具体的寄存器保存顺序和栈操作就会容易很多。7.2 配置宏与条件编译的迷宫xvisor支持多种体系结构和多种硬件平台代码里充满了#ifdef和配置宏。这导致同一个函数在不同配置下行为可能完全不同读起来很费劲。应对方法是先确定你要读的是哪个平台、哪个配置下的代码然后只关注这个配置下会走的分支。其他分支可以先跳过等需要的时候再回来看。用make menuconfig生成的.config文件可以帮你确认当前配置下哪些宏是打开的。7.3 设备树与硬件描述的对应关系xvisor用设备树来描述硬件这部分的代码和硬件手册的对应关系需要花时间建立。比如设备树里一个interrupt-parent属性对应到代码里就是中断控制器的某个数据结构。我通常的做法是拿一份具体的设备树文件比如QEMU virt平台的设备树对照xvisor的驱动代码一个节点一个节点地看。看到某个节点的时候去代码里找对应的驱动看它是怎么解析这个节点的属性的。这样一遍下来设备树和代码的对应关系就清楚了。8. 从xvisor源码中能学到的通用虚拟化知识8.1 特权级切换的本质虚拟化说到底就是特权级的切换。Guest运行在非特权级Hypervisor运行在特权级。当Guest需要做特权操作时CPU自动切换到特权级Hypervisor处理完后再切回去。这个机制在x86上叫Ring级别切换在ARM上叫Exception Level切换在RISC-V上叫Privilege Mode切换。虽然名字不同但本质是一样的硬件提供了一种机制让低特权级的代码可以陷入到高特权级高特权级处理完后再返回。理解了这一点你就理解了虚拟化的核心。剩下的都是围绕这个核心做的工程优化。8.2 硬件辅助虚拟化的演进早期的虚拟化是纯软件模拟的性能很差。后来Intel和AMD在CPU里加了虚拟化扩展VT-x和AMD-VARM加了Virtualization ExtensionsRISC-V加了Hypervisor Extension。这些硬件扩展让VM Entry和VM Exit变得非常高效。xvisor充分利用了这些硬件扩展。比如在ARM上它使用HCR_EL2寄存器来控制Guest的陷入行为在RISC-V上它使用hstatus和hedeleg寄存器来配置陷入委托。这些寄存器的具体用法在各自的体系结构手册里都有详细说明。8.3 虚拟化方案的取舍逻辑不同的虚拟化方案有不同的取舍。KVM选择寄生在Linux内核里好处是能复用内核的调度器和内存管理坏处是代码分散、依赖内核版本。Xen选择独立实现好处是灵活可控坏处是驱动生态需要自己维护。xvisor选择轻量级独立实现好处是代码清晰、适合学习坏处是功能相对有限。理解这些取舍逻辑比记住某个具体实现更重要。因为在实际工作中你可能会遇到需要自己设计虚拟化方案的情况这时候这些取舍逻辑就是你的决策依据。9. 我在实际调试中踩过的坑第一次跑xvisor的时候我卡在串口没输出这个问题上整整一个下午。编译没问题QEMU也启动了但就是看不到任何打印信息。后来发现是QEMU的命令行参数里串口配置不对xvisor默认用的串口地址和QEMU模拟的串口地址对不上。改了一下设备树里的串口节点问题就解决了。还有一次是vCPU调度不工作。Guest启动后一直卡在某个地方不动加打印发现调度器根本没有切换到vCPU。排查后发现是vCPU的状态标志设置错了创建的时候没有把它标记为可调度。这个错误很隐蔽因为代码逻辑看起来是对的只是少设了一个标志位。内存映射的问题也遇到过。Guest访问某块内存时总是触发异常检查后发现是Stage-2页表里那块内存的权限位设错了。Guest需要写权限但页表里只给了读权限。这种问题用打印很难定位最后是用GDB连上QEMU查看页表项的内容才找到原因。这些坑让我意识到虚拟化调试和普通应用调试不一样。普通应用出问题通常有明确的错误信息和调用栈。虚拟化出问题往往是什么都没发生——没有输出、没有异常、就是不动。这时候你需要对底层机制有足够的理解才能猜到问题可能出在哪里。10. 后续可以继续深入的方向把xvisor的基本流程跑通之后有几个方向可以继续深入。一是研究它的设备模拟框架看看一个完整的虚拟设备是怎么实现的比如虚拟网卡或者虚拟块设备。二是研究它的实时性优化xvisor在实时场景下有一些特殊的调度策略这部分代码值得细看。三是把它移植到一个新的硬件平台上这个过程会让你对体系结构相关的代码有更深的理解。另外如果你对安全虚拟化感兴趣可以研究xvisor的隔离机制看看它是如何防止Guest之间互相干扰的。这部分涉及到页表权限、中断隔离、设备直通等多个方面是一个很有深度的方向。我个人在实际操作中的体会是读源码这件事光看是不够的一定要动手改、动手调。哪怕只是加一行打印、改一个配置你对代码的理解都会比纯看要深得多。xvisor的好处是它足够小你改坏了重新编译也就几分钟的事试错成本很低。这种低成本试错的环境对学习来说太重要了。
返回列表