news 2026/4/16 16:58:56

aarch64平台虚拟机监控器设计从零实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
aarch64平台虚拟机监控器设计从零实现

aarch64裸机VMM手把手实战:从异常向量表到虚拟中断的硬核闭环

你有没有试过,在没有任何Linux内核、没有KVM、甚至没有C库的环境下,让一个CPU真正“相信”自己正在运行一台虚拟机?不是QEMU里敲几行命令就跑起来的那种,而是亲手把HCR_EL2寄存器的第0位设为1,看着eret指令一跳,EL2特权级真正接管控制权——那一刻,你不是在调用API,而是在和ARM硬件对话。

这不是理论推演,也不是文档复读。这是我在QEMU+virt平台上,用纯汇编+裸C一行行写出来、一级级调试通的最小VMM原型。它不依赖任何Hypervisor框架,不封装任何寄存器访问,所有异常入口、页表构建、中断重定向,都暴露在你眼前。下面,我们就从第一个字节开始,走完这条从复位向量到客户Linux启动的完整链路。


从复位开始:EL3降级不是自动的,是“骗”进去的

很多资料说“系统上电进EL3”,然后轻描淡写一句“跳转到EL2”。但真实情况是:EL3不会主动交出控制权。你得先告诉它:“我要去EL2,并且永远不再回来”。

关键就在SCR_EL3ERET的配合:

// arch/aarch64/entry.S —— EL3 entry point reset: // 禁用EL3中断,避免干扰 msr daifset, #0xf // 设置目标:Non-Secure + AArch64 mov x0, #0x301 // NS=1, RW=1 msr scr_el3, x0 // 准备返回地址(即EL2入口) ldr x0, =el2_entry msr elr_el3, x0 // 清空SPSR_EL3:DAIF=0, M=0x3c9 (EL2h) mov x0, #0x3c9000000 msr spsr_el3, x0 eret // 这才是真正的“交权”

注意这里两个细节:
-SCR_EL3.NS=1是开关,不设它,eret会卡死在Secure World;
-SPSR_EL3.M=0x3c9中的c9表示EL2h(即使用SP_EL2栈),不是c0(EL2t)。如果填错,CPU会在EL2立即触发同步异常——因为栈指针根本没初始化。

这一步卡住的人太多。别怪手册写得晦涩,怪你没亲手把SPSR_EL3每一位都掰开看过。


异常向量表:不是贴个地址就行,得对齐、只读、分栈

VBAR_EL2寄存器只存一个地址,但这个地址背后藏着整个VMM的“神经反射弧”。很多人初始化完VBAR_EL2就以为万事大吉,结果HVC调用不进中断处理函数——问题往往出在向量表本身。

先看硬件要求:
✅ 必须128字节对齐(.balign 128
✅ 推荐放在RODATA段(防止被客户OS意外覆写)
✅ 每个向量项128字节,不是跳转指令长度,是预留空间

再看栈模式陷阱:
ARMv8严格区分SP_EL0SP_EL2两条栈路径。客户OS在EL1跑,用的是SP_EL0;VMM在EL2,必须用SP_EL2。如果你的向量表里混用了b el2_sync_sp_el0,那第一次HVC就会因栈切换失败而死锁。

这才是我们精简后的向量表核心:

.section ".vectors", "ax", %progbits .balign 128 vector_table: b el2_sync_sp_el2 // 同步异常(HVC、Data Abort等) b el2_irq_sp_el2 // 物理IRQ(GIC发来的) b el2_fiq_sp_el2 // FIQ(极少用,可留空) b el2_serror_sp_el2 // 系统错误(总线错误等) // 后续12项同理,全部指向_sp_el2变体 el2_sync_sp_el2: sub sp, sp, #256 // 预留256字节保存x0-x30 stp x0, x1, [sp, #0] stp x2, x3, [sp, #16] // ... 保存全部30个通用寄存器 mrs x0, esr_el2 lsr x0, x0, #26 cmp x0, #0x15 // EC == HVC? b.eq handle_hvc b handle_data_abort // 其他同步异常走统一处理 handle_hvc: mrs x0, elr_el2 // 客户OS调用HVC时的返回地址 mrs x1, spsr_el2 // 调用前状态(含DAIF、M等) bl do_hvc_dispatch // C函数分发:hvc_console_write / hvc_vm_create ldp x0, x1, [sp, #0] ldp x2, x3, [sp, #16] // ... 恢复全部寄存器 add sp, sp, #256 eret // 返回EL1,原子切换

重点来了:eret不是简单的ret。它同时完成三件事:
1. 将ELR_EL2载入PC
2. 将SPSR_EL2载入PSTATE
3. 切换SP指针回EL1使用的栈(SP_EL0)

少做任何一步,客户OS就再也回不来。


Stage-2页表:别再背“L0/L1/L2”了,画张图就懂

网上铺天盖地讲Stage-2页表层级,却没人告诉你:你根本不需要实现完整的4级页表。对于QEMU-virt这种4GB内存的测试平台,两级足矣。

我们用最直白的方式重建映射逻辑:

客户物理地址(IPA)映射目标(PA)属性
0x41000000–0x41FFFFFF0x41000000–0x41FFFFFFNormal WB, R/W, Shareable
0x09000000–0x0900FFFF0x09000000–0x0900FFFFDevice-nGnRE, R/W

对应页表结构:

VTTBR_EL2 → PGD (L0) @ 0x40800000 ↓ [0] → PUD (L1) @ 0x40801000 ← 覆盖0x41000000起始的512MB ↓ [0] → PAGE DESC → PA=0x41000000, ATTR=0x404 (Normal, RW, S)

代码里不用纠结术语,就按这个逻辑填:

// mm/stage2_pgtable.c void stage2_map_2mb_block(uint64_t ipa, uint64_t pa, uint32_t attr) { uint64_t *pgd = (uint64_t*)vttbr_base; int pgd_idx = (ipa >> 39) & 0x1FF; // L0索引 // 分配L1页表(PUD) uint64_t *pud = alloc_page(); pgd[pgd_idx] = ((uint64_t)pud) | 1; // Block descriptor, bit0=1 // 填L1项:2MB块映射(bit1=1表示block,非table) int pud_idx = (ipa >> 30) & 0x1FF; pud[pud_idx] = pa | attr | 3; // Valid=1, Table=0, Block=1 // TLB刷新必须四连发,缺一不可 asm volatile("dsb ishst" ::: "x0"); asm volatile("tlbi vmalle1" ::: "x0"); asm volatile("dsb ish" ::: "x0"); asm volatile("isb" ::: "x0"); }

为什么是vmalle1而不是vmalle1is?因为vmalle1作用于当前EL2的TLB,而vmalle1is是inner-shareable范围——在单核QEMU里,前者更精准、更快。


GICv3虚拟中断:VGIC不是模拟器,是寄存器翻译层

很多人以为VGIC要模拟整个GIC寄存器组。错。真正的VGIC本质是寄存器访问拦截+语义翻译

当客户OS执行:

mrs x0, icc_iar1_el1 // 读取待处理中断号

硬件发现ICC_IAR1_EL1被trap,立刻跳转到EL2 IRQ向量。VMM此时要做三件事:

  1. GICD_ICPENDR确认哪个SPI被触发(比如UART是SPI 33)
  2. 查客户vCPU的vgic_lr[]数组,找一个空闲List Register槽位
  3. 往该槽写入格式化vIRQ描述符(含优先级、EOI模式、vINTID)

关键点在于:客户OS永远不知道自己看到的“中断号”是虚拟的。它读icc_iar1_el1拿到33,就以为是物理UART中断;而VMM早已把物理SPI 33映射为vIRQ 33,并确保只有当前vCPU能收到它。

注入代码精简版:

// irq/vgic.c void vgic_inject_spi(int phys_irq, int vcpu_id) { struct vcpu *v = &vcpus[vcpu_id]; int lr_idx = find_free_lr(v); uint64_t lr_val = (1ULL << 63) | // Valid bit ((uint64_t)phys_irq << 32) | // vINTID = phys_irq(直通简化) (0x80ULL << 24) | // Priority = 0x80(中等) (1ULL << 19); // EOI mode = 1(硬件自动EOI) v->vgic_lr[lr_idx] = lr_val; // 触发vCPU中断信号:清PSTATE.I位 uint64_t spsr = read_sysreg(spsr_el1); spsr &= ~(1ULL << 7); // I bit位置7 write_sysreg(spsr, spsr_el1); // 下次eret返回EL1时,硬件自动检查LR并触发IRQ异常 }

注意最后一句:不是VMM主动“调用”客户中断处理函数,而是让硬件在客户OS恢复执行瞬间,自动触发一次IRQ异常。这才是ARM虚拟化的精妙之处——把软件调度,变成硬件事件。


最后一公里:如何让客户Linux真的跑起来?

光有VMM还不够。客户OS得知道自己在虚拟机里。你需要在启动参数里埋下关键线索:

console=ttyAMA0 earlyprintk root=/dev/vda1 init=/sbin/init

但更重要的是:客户内核必须开启ARM64虚拟化支持。否则它连HVC指令都不认识。

编译客户Linux时,务必打开:

CONFIG_ARM64_VA_BITS=39 CONFIG_ARM64_HW_AFDBM=y CONFIG_ARM64_PAN=y CONFIG_ARM64_EPAN=y CONFIG_ARM64_VHE=y // 关键!启用Virtualization Host Extensions CONFIG_ARM64_SVE=n // 关闭SVE,避免复杂寄存器保存

然后,在VMM里为客户OS准备初始状态:

// vmm/vm_create.c void vm_create(struct vm *vm, uint64_t entry_point) { // 设置客户EL1初始状态 vm->spsr_el1 = 0x3c9000000ULL; // EL1h, DAIF=0, SS=0 vm->elr_el1 = entry_point; // 初始化vCPU通用寄存器(x0-x30全清零,符合ARM AAPCS) memset(vm->regs, 0, sizeof(vm->regs)); // 映射客户RAM和设备 stage2_map_2mb_block(0x41000000, 0x41000000, ATTR_NORMAL_RW); stage2_map_page(0x09000000, 0x09000000, ATTR_DEVICE_nGnRE); }

最后,执行终极一跳:

// 切换到客户OS的汇编胶水 mov x0, #0x3c9000000 msr spsr_el2, x0 ldr x0, [x20, #8] // elr_el1 msr elr_el2, x0 eret // GO!

当屏幕打出Booting Linux on physical CPU 0x0,你就知道:
✅ EL2成功退场
✅ Stage-2映射生效
✅ VGIC已接管中断流
✅ HVC通道随时待命


如果你在eret之后看到undefined instruction,一定是SPSR_EL2.M填错了;
如果客户OS卡在Starting kernel ...,大概率是VTTBR_EL2指向的页表没填对;
如果UART输出乱码,检查ATTR_DEVICE_nGnRE是否误写成ATTR_NORMAL
而如果一切正常——恭喜,你刚刚亲手点亮了一台ARM虚拟机。

这条路没有捷径。每一行汇编都是和硬件的契约,每一个寄存器配置都是对架构手册的逐字校验。但当你真正理解HCR_EL2.VM=1为何能让CPU多走一次地址翻译,当你看清VBAR_EL2ERET如何联手构建特权级护城河,你就不再是个调用者,而成了设计者。

虚拟化不是魔法。它只是把CPU里早已写死的电路逻辑,用正确的方式唤醒而已。

如果你在实现过程中遇到了其他挑战,欢迎在评论区分享讨论。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/14 18:43:36

vivado2020.2安装教程:工控开发入门必看指南

Vivado 2020.2安装实战手记&#xff1a;一个工控FPGA工程师的踩坑与破局之路 去年冬天&#xff0c;我在调试一台国产EtherCAT主站控制器时&#xff0c;连续三天卡在“ hw_server 无法识别JTAG链”这个报错上。板子是Zynq-7020&#xff0c;开发机是Windows 10 LTSB&#xff0c…

作者头像 李华
网站建设 2026/4/16 11:03:43

工业设备扩展USB接口的电路设计:全面讲解

工业设备USB接口扩展&#xff1a;不是加个Hub那么简单你有没有遇到过这样的现场场景&#xff1f;一台刚部署的风电变流器远程诊断终端&#xff0c;插上USB转485适配器后通信正常&#xff0c;再接一个U盘做固件升级&#xff0c;系统突然枚举失败&#xff1b;重启后能识别U盘&…

作者头像 李华
网站建设 2026/4/16 12:46:27

水墨风界面太酷了!寻音捉影·侠客行使用体验分享

水墨风界面太酷了&#xff01;寻音捉影侠客行使用体验分享 你有没有过这样的经历&#xff1a;翻遍两小时的会议录音&#xff0c;只为找到老板说的那句“下季度预算翻倍”&#xff1f;或者在几十段采访音频里反复拖动进度条&#xff0c;就为了截取一个关键人名&#xff1f;以前…

作者头像 李华
网站建设 2026/4/16 14:22:44

HBuilderX安装教程:新手入门必看的详细步骤

HBuilderX安装&#xff1a;一个前端新手不该跳过的“底层课”你是不是也经历过这样的场景&#xff1f;刚下载完HBuilderX&#xff0c;双击安装包&#xff0c;一路“下一步”&#xff0c;图标出现在桌面&#xff0c;点开——空白窗口卡住三秒&#xff0c;弹出一行红色报错&#…

作者头像 李华
网站建设 2026/4/16 14:27:06

软件I2C与硬件I2C对比:核心要点一文说清

软件IC与硬件IC&#xff1a;在功率电子与嵌入式音频系统中&#xff0c;到底该把时序交给CPU还是交给硅片&#xff1f; 你有没有遇到过这样的情况&#xff1a; - 一款刚调试通的TWS耳机&#xff0c;在合盖瞬间播放延迟突然跳到80ms&#xff0c;AEC模块直接失锁&#xff1b; - …

作者头像 李华
网站建设 2026/4/16 9:51:51

jlink驱动下载新手教程:零基础快速上手指南

J-Link驱动下载&#xff1a;嵌入式调试链路的底层基石与工程实践深度解析 你有没有遇到过这样的场景&#xff1f; 刚焊好一块STM32H7开发板&#xff0c;接上J-Link&#xff0c;打开Keil&#xff0c;点击“Debug”——按钮灰着&#xff1b;换到VSCodePlatformIO&#xff0c;GDB…

作者头像 李华