第一次在一台 64 位 ARM 开发板上把两套实时系统塞进同一个 SoC,我踩的第一个坑不是驱动,也不是中断,而是设备树里两段内存区域的地址撞在了一起——第二台虚拟机启动到一半直接卡死,串口一个字都不吐。那次折腾让我意识到,zvm 这类轻量级 hypervisor 的架构设计,本质上不是"写一个调度器"这么简单,而是用设备树把硬件资源切分清楚,再让 vCPU 调度器在这套切分好的契约上做时间维度的复用。这篇内容我打算把 zvm 从设备树配置到多虚拟机调度的完整链路拆开讲:它解决什么问题、设备树里的每个字段为什么这么填、vCPU 和物理核怎么映射、内存与中断虚拟化在哪里最容易翻车,以及一套可以照着复现的落地步骤。适合已经能独立编译内核/RTOS 镜像、想上手嵌入式虚拟化的开发者,也适合正在评估"要不要上 hypervisor"的架构同学参考。文中涉及的配置字段以常见实现习惯为依据做逻辑补全,具体字段名请以你手上那份 binding 文档为准。
1. zvm 到底在解决什么问题:把"两颗芯片"压回"一颗芯片"
嵌入式项目里有个很典型的分裂:一套系统要跑硬实时控制环(周期几百微秒,抖动要求极严),另一套要跑带网络协议栈、文件系统、甚至图形界面的富操作系统。传统做法是板子上放两颗芯片,或者一颗多核芯片上做 core isolation——物理隔离,互不干扰,代价是 BOM 成本和板面积。zvm 这条路走的是另一条:一颗核(或一组核)上跑一个 Type-1 hypervisor,把物理资源切分成若干份,每份给一台虚拟机(VM)。虚拟机看到的是"我独占了一台机器",实际背后由 zvm 在 EL2 层做资源仲裁。
1.1 为什么"双系统裸跑"这条路走不通
有人会想,既然两个系统都是我自己写的,为什么不能让它们直接跑在同一颗核上,靠时间片轮转切换?实测下来这条路在一周内就会崩。原因在于特权级和 MMU 配置是全局的:两个系统各自要配自己的页表基址寄存器、自己的异常向量表、自己的中断控制器配置。A 系统正在跑一个中断处理函数,被切到 B 系统,B 系统一改 VBAR(异常向量基址),等切回来 A 的异常入口就飞了。更致命的是内存保护——两个系统都以为自己从 0x40000000 开始能用,谁先写谁赢。
hypervisor 的价值就在这儿:它把"改全局寄存器"这件事收归到 EL2,guest 运行在 EL1,任何涉及全局状态的指令(写页表基址、改异常向量、访问中断控制器物理寄存器)都会触发 trap,由 EL2 代为处理或者转发。guest 之间的隔离不是靠"大家自觉",而是靠硬件翻译层(stage-2)和 trap 机制强制执行的。
1.2 Type-1 和 Type-2 的分界线,以及 zvm 站在哪边
Type-2 是寄居型,hypervisor 本身是宿主操作系统上的一个进程,靠内核模块做虚拟化支持。它的好处是生态现成,坏处是实时性被宿主内核的调度器绑架——你的实时虚拟机能不能按时跑,取决于宿主内核这一轮有没有把时间片给你的 hypervisor 进程。
Type-1 是裸机型,hypervisor 直接在最底层,自己就是"操作系统"。zvm 属于这一类,而且它走的是更极致的路线:主体构建在一个几百 KB 量级的轻量 RTOS 之上,没有完整的进程模型、没有虚拟内存管理的一大套东西,只保留最必要的线程、中断、内存池。这样做的好处是启动时间可以压到毫秒级、镜像体积小到能塞进片上 SRAM,缺点是你要用的驱动、协议栈都得自己挑着往里塞。
我用一句话概括选型判断:如果你的实时侧延迟预算在百微秒量级、且不允许被任何"非实时世界的抖动"污染,那就只有 Type-1 这一条路。如果只是想让一个 Linux 和一个小应用共存、延迟要求毫秒级,寄居方案省事得多。
1.3 zvm 的三层骨架:EL2 内核、资源契约、vCPU 实体
把 zvm 拆开看,它是三层结构,这三层对应三种完全不同的思维方式,混淆了就会写出很别扭的代码。
| 层次 | 职责 | 出问题时的典型症状 |
|---|---|---|
| EL2 内核层 | 异常向量、trap 分发、上下文保存恢复、物理中断接管 | 整机卡死、无日志、复位循环 |
| 资源契约层(设备树) | 定义每台 VM 拿到多少内存、哪个核、哪些设备 | 某台 VM 起不来、设备访问异常、内存踩踏 |
| vCPU 实体层 | 每台 VM 的执行上下文,被调度器排队 | 某台 VM 饿死、延迟抖动超标 |
第一层是"不可见的",你平时感受不到它的存在,直到它出错。第二层是"你天天要改的",绝大多数启动失败都出在这里。第三层是"决定性能上限的",架构设计阶段就要想清楚。
新手最容易犯的错是:把第三层的问题当成第二层的问题去调。比如某台 VM 响应慢,第一反应是"给它多分点内存",实际上内存跟调度延迟毫无关系,真正该调的是优先级和时间片。这种误判我在项目里见过不止一次,浪费的时间都是以天计的。
2. 设备树不是配置文件,是 zvm 和 guest 之间的启动契约
很多人对设备树的理解停留在"内核启动时读一下硬件信息",所以在 zvm 项目里也下意识把它当成一个"参数文件",随手改改试试。这个认知偏差会带来很长的调试周期。在 zvm 里,设备树是 hypervisor 构建资源视图的唯一输入:它在启动早期被解析,解析结果直接决定 stage-2 页表怎么建、vCPU 有几个、物理中断怎么路由。写错了不是"某个功能不生效",而是"整个资源视图是错的"。
2.1 hypervisor 自己的节点:谁在描述"我是一台虚拟机管理器"
设备树的根下需要有一个描述 hypervisor 自身的节点,作用有两个:一是让构建系统/bootloader 知道这个镜像期望的运行级别,二是给下面的 VM 子节点提供一个容器。这个节点本身通常不需要太多属性,但有几个关键点值得注意。
第一是compatible 字符串。它决定了 zvm 启动时会不会认领这棵设备树。有些实现会在里面带上版本号,如果你的镜像和 DTS 版本不匹配,表现是"启动后什么都没发生"——因为匹配失败,代码走了另一条分支悄悄返回了。
第二是物理内存的描述。hypervisor 自己也要吃内存,它的堆、页表、每台 VM 的上下文结构体都从这里分配。这段内存必须和后面分配给 guest 的内存完全不重叠。我见过最隐蔽的一个 bug 是:hypervisor 堆区尾部正好压在 VM1 内存区头部,启动时一切正常,跑到第 40 秒左右 hypervisor 自己的一个队列节点被 guest 写花,整机静默死机,日志什么都没有。这种问题靠读代码是查不出来的,只能靠启动时打印内存布局图核对。
第三是控制台输出设备。这个必须直通或者半虚拟化给 hypervisor 自己用,否则你连启动日志都看不到。调试期我建议无条件保留一路串口给 hypervisor,哪怕最终产品要把它藏起来。
/ { compatible = "zvm,hypervisor-root"; #address-cells = <2>; #size-cells = <2>; /* hypervisor 自用内存:堆、页表、vCPU 上下文 */ hyp_mem: hyp-memory@40000000 { reg = <0x0 0x40000000 0x0 0x00200000>; /* 2MB */ }; /* hypervisor 的控制台 */ hyp_console: serial@9000000 { reg = <0x0 0x09000000 0x0 0x1000>; status = "okay"; }; };注意:上面这段是按常见实践构造的示意配置,字段命名不一定和你手上的实现一致。改动前先翻一遍源码里的 binding 描述文件,那里才是唯一权威。
2.2 guest 节点的字段逐个拆:内存、vCPU、优先级、镜像
每台虚拟机对应一个子节点。字段不多,但每一个背后都有一套运行时的语义,理解错了就是"能启动但行为不对"这种最难查的问题。
内存区间。一般用起始地址加长度表达。这里有两个硬约束:一是必须按 stage-2 翻译的颗粒度对齐(用 4KB 页就 4KB 对齐,用块映射就按块大小对齐,常见是 2MB);二是不能和 hypervisor 自用区、其他 VM 的区间有任何字节重叠。别指望 hypervisor 帮你检查重叠——大多数轻量实现为了省代码根本不做这个校验,它只会老老实实建两张指向同一段物理内存的 stage-2 页表,然后你就拥有了一个"两台 VM 互相踩内存"的诡异系统。
vCPU 数量与亲和性。这里说的是虚拟核的数量,以及它倾向于跑在哪个物理核上。在单核板上这个字段基本没得选,多核板上就要认真规划了。经验值是:实时 VM 的 vCPU 数量不要超过它实际需要的并发度,多给不会更快,只会增加上下文切换和缓存污染。
优先级。这是调度器的核心输入,数值含义各实现不同(有的数值小优先级高,有的大),一定要看文档。它和通用操作系统里的 nice 值不是一回事,zvm 这类系统的优先级通常是严格的、可抢占的,低优先级 VM 在跑时,高优先级 VM 的事件一到就能立刻抢占。
镜像加载信息。有的实现把 guest 镜像地址写在节点里,有的靠构建脚本打包成一个大镜像再由 hypervisor 定位。前者灵活、适合调试期反复替换;后者启动快、适合量产。
vm_rt: vm-rt { compatible = "zvm,guest-vm"; /* 物理核 0,实时侧独占 */ vcpu-affinity = <0>; vcpu-count = <1>; /* 数值越小优先级越高——务必核对你手上的文档 */ priority = <1>; /* 128MB,起址 0x50000000,2MB 对齐 */ memory = <0x0 0x50000000 0x0 0x08000000>; entry = <0x0 0x50000000>; /* 直通串口 1 给这台 VM */ passthrough = <&uart1>; status = "okay"; }; vm_rich: vm-rich { compatible = "zvm,guest-vm"; vcpu-affinity = <1>; vcpu-count = <1>; priority = <5>; /* 起址与上面严格错开:0x50000000 + 0x08000000 = 0x58000000 */ memory = <0x0 0x58000000 0x0 0x10000000>; entry = <0x0 0x58000000>; status = "okay"; };上面两个节点的内存边界是刻意算过的:0x50000000 + 0x08000000 = 0x58000000,正好是第二台的起始地址,首尾相接但不重叠。这种"手算边界"的做法在只有两三台 VM 时完全够用,VM 数量上去之后建议写成脚本生成,避免人工算错。
2.3 直通设备和虚拟设备:同一棵树里的两种世界观
设备树里最容易被误解的概念就是"设备节点属于谁"。在 zvm 的语境下,一个设备节点出现在 guest 子节点下,意味着这台 VM 拿到了这个设备的物理访问权,hypervisor 会在 stage-2 里把这段 MMIO 区域映射过去,并且不再拦截对它的访问。这叫直通。
直通的好处是零开销、性能拉满;坏处是这个设备从此只属于这一台 VM,别的 VM 想用只能通过 VM 间通信。而且直通之后 hypervisor 失去了对这个设备的观测能力,出了问题只能去 guest 侧看日志。
与之相对的是虚拟设备:hypervisor 截获 guest 对某段 MMIO 的访问,代为处理,再把结果写回。串口、块设备、网络这些常用外设一般都会提供虚拟化版本。虚拟设备的开销取决于实现——简单的字符设备转发可能只有几微秒,复杂的块设备如果每次都陷入再陷入,开销会很难看。
选择原则很朴素:实时侧的关键外设一律直通,非关键外设尽量虚拟化。比如实时 VM 的 PWM、ADC、定时器必须直通,否则你每次采样都要陷入一次 EL2,延迟预算直接被吃掉;而调试串口、日志输出这类东西虚拟化完全没问题。
2.4 一份能跑起来的最小配置长什么样
把前面几块拼起来,一份最小的、能让两台 VM 起来的 DTS 大致是这样组织的(这里只列结构,具体属性名请对照你的 binding):
/ { compatible = "zvm,hypervisor-root"; #address-cells = <2>; #size-cells = <2>; hyp_mem: hyp-memory@40000000 { reg = <0x0 0x40000000 0x0 0x00200000>; }; zvm_vms { compatible = "zvm,vm-container"; vm_rt: vm-rt { /* 实时侧,见上文 */ }; vm_rich: vm-rich { /* 富系统侧,见上文 */ }; }; };编译时用dtc或者构建系统自带的封装脚本把它编成 DTB,和 hypervisor 镜像一起打包。这里有个小细节值得提:不同阶段的设备树可以不同。bootloader 传给 hypervisor 的是完整硬件树,hypervisor 传给每台 guest 的应该是裁剪过的、只包含它自己那份资源的树。有些实现会直接在原树上改status属性,有些会重新生成一棵。前者省事但容易残留不该给 guest 看到的节点;后者干净但代码量大。我倾向于后者,因为 guest 里那些"看到了却访问不了"的节点会让驱动开发者抓狂。
3. 多虚拟机调度的核心:vCPU 和物理核的映射博弈
设备树描述的是空间维度的切分,调度器处理的是时间维度的复用。这两件事的耦合点在vCPU上——每台 VM 有一个或多个 vCPU 实体,每个 vCPU 有一个完整的寄存器上下文(通用寄存器、系统寄存器、浮点/SIMD 状态),调度器的工作就是在物理核上轮流"装上"这些上下文。
3.1 静态绑核、动态抢占,以及为什么大多数场景该选前者
zvm 这类系统的调度策略通常介于两个极端之间。一个是纯静态绑核:VM1 永远在核 0,VM2 永远在核 1,谁也不抢谁的。另一个是完全动态:所有 vCPU 放在一个就绪队列里,按优先级和时间片抢占。
静态绑核的确定性极强,代价是资源利用率低——核 0 上的 VM 空闲时,核 1 上的 VM 再忙也只能干瞪眼。动态抢占利用率高,但要处理优先级反转、时间片耗尽、抢占延迟这些麻烦事。
我在实际项目里的选择是混合:实时 VM 绑死在独立核上(如果核数够),富系统 VM 共享剩下的核并采用动态抢占。这样实时侧的抖动只受自己的代码影响,富系统侧则充分享受多核调度带来的吞吐。
如果板子只有两个核,而你又必须跑两台 VM,那就得老老实实做动态抢占,这时候调度的细节就变成了成败关键。
3.2 调度周期和时间片:参数怎么算出来
假设调度周期定为 1ms(这个量级在实时场景里比较常见),两台 VM 的优先级分别是 1 和 5(数值小者优先)。一个典型的时间片分配策略是:
- 高优先级 VM 在周期内优先执行,事件到来即可抢占低优先级
- 低优先级 VM 只能在高优先级 VM 主动让出(如进入空闲等待)或者时间片耗尽后运行
- 每个周期结束时重新仲裁,防止低优先级被永久饿死(这也是为什么需要时间片而不是纯优先级)
具体的时间片长度怎么定?给一个我常用的估算方法:
| 参数 | 取值 | 依据 |
|---|---|---|
| 周期 T | 1 ms | 低于实时任务的最小周期 |
| 高优先级侧最坏执行时间 | 300 μs | 实测加 30% 余量 |
| 低优先级侧最小时间片 | 200 μs | 低于此值上下文切换开销占比过高 |
| 切换开销 | 约 5–15 μs | 取决于寄存器保存量和缓存热度 |
| 可分配余量 | 1000 - 300 - 200 - 2×10 = 480 μs | 留给中断处理和抖动 |
这个表的关键不是数值本身,而是把切换开销显式列出来。很多人算时间片时忘了这项,结果配了一个 50μs 的时间片,切换开销 10μs,20% 的 CPU 时间直接蒸发在保存恢复寄存器上。切换开销的实测方法很简单:在一个空的 vCPU 切换钩子里翻转一个 GPIO,用示波器量脉宽,比任何理论估算都准。
3.3 上下文切换里那些"没人告诉你"的细节
寄存器保存这件事,教科书上写的是"保存通用寄存器即可"。真实情况要复杂得多。
系统寄存器必须逐个判断。不是所有 EL1 的系统寄存器都需要在切换时保存——有些是每个 vCPU 私有的(如虚拟定时器的比较值),有些是全局共享的(如某些控制寄存器)。搞错了的表现是:VM 单独跑没问题,两台 VM 交替跑就出怪事,比如定时器偶尔提前触发,或者 MMU 配置莫名其妙被改了。
浮点和 SIMD 状态要不要存。如果两台 VM 都会用浮点,那必须保存;如果都不会用,可以关闭以省时间。麻烦的是怎么知道它们会不会用——靠约定。我建议在项目文档里明确写死"guest 不得使用浮点",然后在编译选项里禁掉,比运行时检测可靠得多。
缓存和 TLB 的污染。切换 vCPU 不刷 TLB,因为 stage-2 页表跟着 VMID 走,硬件能区分。但 I-cache 和 D-cache 是物理索引的,两台 VM 交替运行时会互相挤占。实测下来,一台 VM 刚启动时的性能会明显低于稳定运行后,就是缓存预热的原因。做性能测试时一定要跑够时间,别拿前几秒的数据下结论。
3.4 优先级反转:一个真实出现过的场景
低优先级的富系统 VM 里跑着一个文件系统操作,恰好持有某个共享资源的锁;高优先级的实时 VM 也访问这个资源(通过 VM 间通信),于是被阻塞;此时中等优先级的第三个 VM 抢占了富系统 VM,导致它迟迟不释放锁,实时 VM 被无限期挂起。这就是教科书上的优先级反转,在虚拟机场景里一样会发生,而且更隐蔽——因为跨越了 VM 边界,你在任何一方的日志里都看不到全貌。
缓解手段有三种:优先级继承(被阻塞时临时提升持有者的优先级)、优先级天花板(访问共享资源时直接提到最高)、以及最朴素的避免跨 VM 共享可阻塞资源。我在项目里用的是第三条:VM 间通信只走无锁的单生产者单消费者环形队列,任何一方都不阻塞,只是拿到"没有数据"的返回值。牺牲了一点实时性,换来了确定性。
4. 内存与中断:虚拟化里真正容易翻车的两块
调度器写得再漂亮,内存和中断这两块处理不好,系统照样跑不起来或者跑起来随机崩。这两块的共同点是:错误的表现和原因之间隔得很远,你看到的是"某台 VM 挂死",根因可能在几十行之外的页表配置。
4.1 stage-2 页表:guest 眼中的地址不是物理地址
guest 里的代码操作的是一个中间地址(IPA),硬件先经过 stage-1(guest 自己的页表)翻译到 IPA,再经过 stage-2(hypervisor 的页表)翻译到真正的物理地址(PA)。两层翻译串起来,hypervisor 就获得了"完整控制 guest 能看到哪些物理内存"的能力。
配置 stage-2 的关键寄存器是翻译表基址和翻译控制寄存器。用 4KB 页做细粒度映射最灵活,但页表项数量大、TLB 命中率低;用 2MB 块映射效率高,但要求内存区间必须 2MB 对齐,且不能做细粒度保护。我的建议是:主内存区用块映射,设备 MMIO 区用 4KB 页映射,兼顾性能和灵活性。
内存分区的几个实操约束,都是踩过坑才记住的:
- hypervisor 堆区和所有 guest 区之间留至少一个大页的隔离带,防止越界写悄无声息地发生
- guest 内存起址必须按映射颗粒度对齐,否则你会得到一张"最后一页映射不上"的诡异页表
- 如果 VM 之间要共享内存,那段物理内存的 stage-2 映射属性要两边一致,一边 cacheable 一边 non-cacheable 会导致读到脏数据,而且这种 bug 在小数据量测试时根本复现不出来
共享内存属性不一致这个坑我印象很深。早期测试用 64 字节的消息来回传,一切正常;压力测试改成 4KB 大块之后,接收方偶尔会读到"半新半旧"的数据。查了两天才发现是两边映射属性不一致,硬件缓存行为和内存屏障假设对不上。修复方法很简单——两边都用 non-cacheable,配合显式的内存屏障,牺牲一点带宽换确定性。
4.2 中断虚拟化:物理中断怎么"看起来像"虚拟中断
guest 里也有自己的中断控制器配置、自己的中断处理流程。zvm 要做的是:物理中断到来时,在 EL2 截获,判断该中断属于哪台 VM,然后注入到那台 VM 的虚拟中断控制器里。
ARMv8 的 GICv3 提供了一套虚拟化扩展,核心是列表寄存器——一组硬件寄存器,每个可以承载一条待注入的虚拟中断。hypervisor 把中断写进列表寄存器,guest 的虚拟 CPU 接口就会看到它。这套机制的麻烦之处在于列表寄存器的数量是有限的(常见配置十几个),一旦中断密集到来,寄存器很快用完。
用完之后怎么办?硬件会触发一个维护中断,通知 hypervisor"我没地方放了"。此时 hypervisor 必须把剩下未注入的中断暂存到软件队列里,等 guest 处理完一批、列表寄存器空出来之后再补进去。这个"软件暂存队列"是中断虚拟化的核心数据结构,也是性能瓶颈所在。
我踩过的坑是:维护中断本身也需要被及时处理,如果 hypervisor 当时正在跑一段关中断的临界区,维护中断被延迟,guest 那边就会表现为"中断丢了"。表现是串口偶尔丢字符,或者网络偶发超时。修复方向有两个:一是缩短 EL2 里的临界区,二是把暂存队列做成无锁的,减少关中断的需求。
提示:调试中断虚拟化问题时,在维护中断的处理入口和出口各翻转一个 GPIO,用示波器看频率和占空比,比在 guest 里加打印有效得多。guest 的打印本身就会引入中断,观测行为会干扰被观测对象。
4.3 VM 间通信:共享内存加通知机制的组合拳
两台 VM 要交换数据,最直接的办法是共享内存加一个通知机制。共享内存负责数据搬运,通知机制负责"我放好了,你来取"。通知一般通过 hypervisor 提供的陷入指令实现:guest 执行一条特定指令,陷入 EL2,hypervisor 记录事件并注入一个虚拟中断给目标 VM。
这个模型看起来简单,实现时有几个细节决定成败。
环形队列的索引必须是原子更新的,而且要区分生产者和消费者各自的索引,避免两边同时改同一个变量。单生产者单消费者模型下,只需要保证索引读写是原子的(对齐的字长访问天然原子),不需要加锁。
内存屏障不能省。写数据、写屏障、更新索引,这个顺序少一步,编译器或者 CPU 就可能把索引更新提前到数据写入之前,接收方拿到索引却读到旧数据。这类 bug 在单核模拟环境里测不出来,一上多核必现。
背压处理。队列满了怎么办?阻塞等待会让实时侧失去确定性,我倾向于返回"队列满"让调用方自己决定——实时侧可以丢掉非关键数据,富系统侧可以退避重试。把策略交给调用方,hypervisor 只负责搬运。
5. 从配置到跑通:一套可以照着走的落地流程
前面讲的都是原理,这一段给的是流程。第一次上手时按这个顺序走,能省掉大量"改一处崩三处"的时间。
5.1 分阶段验证,每一步都要有明确的观测点
不要一上来就配两台 VM 跑完整功能。我建议分四个阶段,每个阶段有独立的成功判据:
| 阶段 | 配置内容 | 成功判据 | 常见失败表现 |
|---|---|---|---|
| 一 | 只有 hypervisor,无 guest | 串口打印出版本和内存布局 | 无输出(多半是 compatible 不匹配) |
| 二 | 一台 VM,最小镜像 | VM 能打印第一行日志 | 卡在 entry(地址或页表问题) |
| 三 | 两台 VM,无通信 | 两台都能独立跑循环 | 第二台起不来(内存重叠) |
| 四 | 加通信和直通设备 | 数据能双向流动 | 偶发丢数据(缓存属性或屏障) |
阶段一那一步千万别跳过。我见过有人为了"节省时间"直接上两台 VM,结果卡了三天,最后发现问题在第一步——设备树的 compatible 字符串和镜像里的不匹配,hypervisor 压根没认领这棵树,安安静静地返回了。
5.2 启动日志该看什么
hypervisor 的启动日志信息量很大,但要看对地方。我通常按这个顺序扫:
先看内存布局。日志里应该打印出 hypervisor 自用区起止、每台 VM 的起止。用眼睛核一遍有没有重叠、有没有对齐问题。这一步花 30 秒,能避免后面几小时的困惑。
再看每台 VM 的初始化结果。成功的会打印 vCPU 数量、入口地址、页表建立结果;失败的通常只有一行错误码,甚至什么都没有。如果什么都没有,往回退到阶段二,只留一台 VM 试。
最后看中断控制器的初始化。GIC 的初始化如果失败,系统能启动但所有中断都不会来,表现为"VM 跑起来了但串口没反应"。这类问题在日志里通常有一行不起眼的警告。
# 打包和启动的典型流程(具体命令按你的构建系统调整) make dtb # 编译设备树 make zvm-image # 编译 hypervisor 并打包 VM 镜像 # 通过调试器或 bootloader 加载,然后盯串口 picocom -b 115200 /dev/ttyUSB05.3 一份按症状索引的排查表
下面这张表是我从实际项目的问题记录里整理出来的,按"你看到什么"来索引,比按"可能是什么原因"索引好用得多。
| 症状 | 高概率原因 | 验证方法 |
|---|---|---|
| 串口完全无输出 | compatible 不匹配、串口未直通给 hyp | 在 hyp 入口第一行加裸寄存器输出 |
| 输出到某一行停住 | 该行之后的初始化失败,多半是页表 | 二分注释掉页表建立代码 |
| 第二台 VM 起不来 | 内存区间重叠或未对齐 | 打印两台的起止地址核对 |
| VM 起来了但无中断 | GIC 初始化失败或 LR 耗尽未处理 | 统计维护中断次数 |
| 偶发数据错误 | 共享内存 cache 属性不一致 | 两边都改 non-cacheable 复测 |
| 运行几十秒后静默死机 | 堆区被越界写、栈溢出 | 内存区之间加隔离带并填充魔数 |
| 定时器偶尔提前/延后 | 虚拟定时器偏移未按 vCPU 设置 | 检查每个 vCPU 的偏移寄存器 |
| 切换频繁时性能骤降 | 时间片过短、切换开销占比过高 | GPIO 测切换耗时 |
表里"填充魔数"这个技巧值得展开说一句:在每段内存区的尾部填一段固定 pattern(比如 0xDEADBEEF 重复),定期检查有没有被改写。这个成本极低,但能在一秒钟内告诉你"是谁越界了",比任何调试器都快。
5.4 那些文档里不会写的经验
镜像的链接地址和加载地址必须一致。这是新手最常踩的坑,而且症状极具迷惑性——镜像能被加载,甚至能跑几条指令,然后在某个绝对地址跳转时飞掉。检查方法:看链接脚本里的起始地址,和 DTS 里写的 entry 逐字节比对。
guest 的异常向量表要在 EL1 正确设置。hypervisor 把控制权交给 guest 时,guest 是从 EL1 开始跑的,它需要自己配置 VBAR_EL1。如果 guest 是从一个裸机程序改过来的,很可能漏了这一步,表现是"一有异常就复位"。
串口直通之后,hypervisor 就看不到那路日志了。调试期我的做法是给 hypervisor 单独留一路串口,两路同时接,用两个终端看。虽然桌上线多点,但排查效率高一个数量级。
不要在 EL2 里做耗时操作。hypervisor 是全局的,它在跑的时候所有 VM 都停着。任何一次内存拷贝、任何一次字符串格式化,都是所有 VM 的共同延迟。日志输出尤其要注意——我一般只在不影响时序的路径上打日志,实时路径上宁可什么都不打。
6. 关于扩展方向的一点个人看法
这套架构跑通之后,往上加东西的路径其实挺清晰。想让富系统侧更强,就在它的 vCPU 上做多核调度,把动态抢占的粒度从 VM 级细化到 vCPU 级;想提升实时侧的确定性,就做 CPU 隔离加缓存分区,把共享资源减到最少;想做设备虚拟化的进阶,就上 virtio 那一套,用共享内存加通知替代陷入式模拟,性能差距是数量级的。
我自己在这条路上花时间最多的地方,反而不是写代码,而是把每一层的边界想清楚。设备树负责什么、调度器负责什么、页表负责什么,这三者的职责一旦混在一起,代码就会变成一坨谁也看不懂的东西。我现在给自己定的规矩是:任何"这台 VM 能访问什么"的问题,答案必须能在设备树里找到;任何"这台 VM 什么时候能跑"的问题,答案必须能在调度器参数里找到;两者都不该出现在对方的领域里。
最后分享一个排查习惯:每次修改配置之后,先只跑阶段一和阶段二,确认基础链路没问题,再去测完整场景。这个习惯让我避开了至少一半的"改了个小地方结果全线崩"的情况——因为我能确定崩的是新改的那部分,而不是某个一直潜伏的老问题。