每一部分只回答同一组问题:资源与状态由谁控制、关键路径怎样执行、何时需要 hypervisor 介入、介入的代价何在。四个维度(CPU / 内存 / 中断 / 设备)是本文的工程分析框架,不是 Popek–Goldberg 原文的分类——原文的形式模型明确排除了 I/O 与中断。
二者是实现同一目标的两条路径,且长期共存:QEMU 既能独立提供整机模型,也能把 CPU 执行交给 KVM,自己保留内存后备、设备模型与整机编排。比较的是同一维度上两种机制的取舍,不是替代关系。
第 3 章问「缺乏架构支持时怎样把多层语义做对」,第 4 章问「已经能嵌套后怎样削减跨层往返」。两问先后相接,性能不可横向排名。
演进的分水岭有两条:一条是「谁执行 guest 指令」(翻译 → 原生执行),另一条是「谁承担跨层成本」(陷入转发 → 副本与就地处理)。
敏感指令集合 ⊆ 特权指令集合 ⇒ 可构造出满足上述三性质的 VMM。
陷阱-模拟只是该条件下的一种实现,不是必要条件。
机器模型只含处理器模式与 relocation-bounds 内存,显式假设无 I/O 与中断;递归虚拟化还要求无时间依赖,且不承诺低开销。
P&G 要求「敏感 ⊆ 特权」:能观测或改变机器状态的操作都必须陷入。x86 恰好在这里破了个口子——这就是「x86 不可虚拟化」的来历。
pushf ; EFLAGS → 栈;IF = bit 9
popf ; 栈 → EFLAGS:想关中断
; CPL > IOPL ⇒ IF 静默丢弃
cli ; 对照:受 IOPL 保护
; CPL > IOPL ⇒ #GP,陷入 VMM
两条在 ring 3 直接执行、不陷入;cli / sti 却会陷入——VMM 想抓它,抓不到。
让 IF 生效,guest 就改到了宿主的开/关中断;不让它生效,guest 读回的又是宿主的 IF、写 IF 静默失效。两种情形都与裸机可区分。
trap-and-emulate 抓不到这条指令,VMware 改用二进制翻译把它改写成对 VMM 的调用;直到 2005 年 VT-x 把 IF 放进 VMCS 的 guest 状态,才从架构上补上。
; IN · guest 反汇编
100e8 li t0,3
100ea li t1,4
100ec add t2,t0,t1
100f0 rdcycle t5
; OP · TCG IR
add_i64 x5/t0,$0x0,$0x3
add_i64 x6/t1,$0x0,$0x4
add_i64 x7/t2,x5/t0,x6/t1
call csrr,$0x0,$1,x30/t5,env,$0xc00
mov_i64 pc,$0x100f4
exit_tb $0x0
; OUT · x86-64 机器码
+0x00 mov -0x10(%rbp),%ebx
+0x05 jl tb_exit
+0x0f movq $0x3,0x28(%rbp)
+0x1f movq $0x7,0x38(%rbp)
+0x2b mov $0xc00,%esi
+0x30 mov %rbp,%rdi
+0x33 call helper_csrr
+0x3f movq $0x100f4,0x1230(%rbp)
+0x4a jmp tb_exit
; IN · guest 反汇编
100f4 auipc t3,1
100f8 addi t3,t3,24
100fc lw t4,0(t3)
10100 add a0,t2,t4
10104 li a7,93
10108 ecall
; OP · TCG IR
mov_i64 x28/t3,$0x110f4
add_i64 x28/t3,x28/t3,$0x18
qemu_ld_i64 x29/t4,loc5,noat+un+lesl,0
add_i64 x10/a0,x7/t2,x29/t4
add_i64 x17/a7,$0x0,$0x5d
call raise_exception,$0x8,$0,env,$0x8
; OUT · x86-64 机器码
+0x0f movq $0x1110c,0xe0(%rbp)
+0x1f movslq (%rbx),%rbx
+0x22 mov %rbx,0xe8(%rbp)
+0x29 mov 0x38(%rbp),%r12
+0x2d add %r12,%rbx
+0x30 mov %rbx,0x50(%rbp)
+0x34 movq $0x5d,0x88(%rbp)
+0x43 movq $0x10108,0x1230(%rbp)
+0x56 call raise_exception
vCPU 之于物理 CPU,正如线程之于 CPU:都是把一份硬件分时复用,靠保存与恢复现场。 KVM 决定此刻哪个 vCPU 在哪个物理 CPU 上跑,而这件事落地只用到两条 primitive。
; vmentry —— __guest_enter 的尾部
ldp x0, x1, [x29, #CPU_XREG_OFFSET(0)]
...
restore_callee_saved_regs x29
eret ; 按 SPSR_EL2/ELR_EL2 回 EL1/EL0
; vmexit —— VBAR_EL2 → __kvm_hyp_vector
el1_sync: ; guest 同步异常
mrs x0, esr_el2
mov x0, #ARM_EXCEPTION_TRAP
b __guest_exit ; 存回 vcpu->arch.ctxt
el1_irq: / el1_fiq: / el1_error:
两级各自维护页表,最终生效的是两阶段权限的交集;guest 页表自身的访存也要过 Stage-2——页表项躺在 IPA 上,读表项本身就要再翻译一次。
# 建映射(软件):VFIO 把 guest 内存锁进设备 DMA 域
def arm_smmu_attach_and_map(master, dom, iova, pa, prot):
ste = stream_table[master.streamid]
ste.config = S2_TRANS # 只做 stage-2
ste.s2vmid = dom.s2_cfg.vmid # 所属 VM
ste.s2ttb = dom.pgtbl_ops.vttbr
dom.pgtbl_ops.map_pages(iova, pa, prot)
# 运行时(硬件):设备 DMA 查表,软件不参与
def smmu_translate_dma(streamid, addr):
ste = stream_table[streamid]
pa = walk(ste.s2ttb, addr) # IPA -> PA
check_permission(ste, pa) # 越界则 fault
一例:网卡 StreamID=0x10、s2vmid=7,IPA 0x4000_0000 → PA 0x1_2000_0000;描述符填 0x4000_1000 就走到 0x1_2000_1000,填域外地址则 translation fault。
| 维度 | QEMU 软件(只展开 CPU 维) | Arm + KVM 硬件辅助 | 仍是软件的责任 |
|---|---|---|---|
| CPU | TCG 翻译为宿主代码,TB 缓存复用,敏感操作退 helper | EL1/EL0 原生执行,EL2 用 HCR_EL2 选择性陷入 | 陷入配置、vCPU 上下文与调度 |
| 内存 | 软件 MMU 与软件 TLB 解释 guest 页表 | Stage-1 + Stage-2,MMU 做缓存与遍历 | memslot 换算、S2 补表、TLB 失效 |
| 中断 | 控制器模型维护 pending,经架构异常投递 | GICv3 虚拟 CPU 接口;GICv4 直注 vLPI | 映射配置、驻留切换、doorbell 唤醒 |
| 设备 | MemoryRegion 回调响应 MMIO,后端完成请求 | SMMU 约束真实 DMA;模拟 / virtio / 直通并存 | 设备模型、复位、可迁移性、路由 |
# L0 处理 L1 的 VMX 指令:两条路
# ① 改状态:L1 --exit--> L0 --entry--> L1
def handle_vmwrite(vcpu, field, val):
vmcs12.field = val
nested_vmx_succeed(vcpu) # 清 CF/ZF
# 改三处:结果寄存器 / RFLAGS / GUEST_RIP
# ② 换环境:L1 --exit--> L0 --entry--> L2
def handle_vmlaunch(vcpu):
prepare_vmcs02(vcpu, vmcs12) # 合成
vmx_vcpu_run(vcpu) # 不返回 L1
术语边界:Turtles 所说的「VMCS shadowing」是 L0 在软件里影子维护 VMCS12,与 Intel Haswell 之后的同名硬件特性不是一回事。
前两栏的共同点:L1 用影子页表跑 L2,于是 L2 的每次页故障与页表写都要 L1 处理并经 L0 转发。本节只展开第 ③ 栏。
# P = L2-PA,M = L1 帧,H = 机器地址
EPT12: P->M EPT01: M->H EPT02: P->H
def handle_l2_ept_fault(P, access):
if ept12_lookup(P).present:
install_ept02_from_ept12(P) # 自补
resume_l2(); return
# EPT12 也缺 → 传统路径进 L1
if hyperupcall_ept_fault(P) == OK:
install_ept02_from_ept12(P)
resume_l2() # 不进 L1
else:
inject_ept_fault_to_l1(P)
resume_l1() # 慢路径
而是这条指令的效果落在哪里: trap-and-emulate 下 L1 执行 vmread/vmwrite 必然退出,根因是处理器手上没有一份能替 L1 接收这次访问的控制结构。一旦硬件能提供副本,同一条指令就能原地执行。
L0 在真实 VMCS01 的 link pointer 里填入另一块 shadow VMCS,并准备两张各 4 KB 的 VMREAD/VMWRITE 位图,每个字段编码一位:位为 0 的字段不退出,直接读写副本。
筛选粒度:可写字段 24 项、只读字段 12 项(vmcs_shadow_fields.h)。影子里的改动由 copy_shadow_to_vmcs12() 在 L1 进 L2、vmclear/vmxoff、导出状态三个时机拷回。
NV(v8.3)把 EL1 访问「只在 EL2 定义的寄存器」从 UNDEFINED 改成陷入 EL2;NV2(v8.4)再进一步:HCR_EL2.NV=1 且 NV2=1 时,硬件把访问改写成对 VNCR_EL2 所指页面的 load/store。
「读一个 EL2 寄存器」从一次异常变成一次普通访存;页面仍在 L0 手里,建立、失效与同步由软件负责。
两者不宜推成同构——一边让字段访问落到影子控制结构,一边把寄存器访问改写成内存访问,指令、状态与失效规则都不同。
能说的是同一件事:只要架构能提供一份 L0 仍能约束的副本,退出就能从关键路径上挪走。代价没消失,只是转成了副本一致性维护——VMEntry/VMExit、TLB 失效与中断投递仍要回 L0。
L1 经 hypercall 把编译后的 eBPF 交给 L0,经 KVM 与用户态组件验证、加载,再 link 到 EPT fault hook。load 与 link 拆成两个 hypercall,同一套 map 实例可挂多个 hook。
预分配帧池(4096 帧)、QEMU 页表根 cr3、两条日志(hyperupcall / page fault),加一把双方都能取的锁。
bpf_probe_read_hyperupcall 只读有效、已映射且属于发起它的 L1 的物理内存:verifier 管程序形态,helper 管地址授权。
def hyperupcall_ept_fault(P, access):
if not try_lock(shared): return FALLBACK
if fault_log.is_full(): return FALLBACK
V = qemu_va_for(P) # memslot 关系换算
M = walk_qemu_page_table(shared_cr3, V)
if M is not None: # 复用已有帧
ept12_install(P, M, access)
fault_log.append(P); return SUCCESS
if page_fault_log.contains(P):
return FALLBACK # L1 正并发处理
M = frame_pool.pop()
if M is None: return FALLBACK
ept12_install(P, M, access)
fault_log.append(P); return SUCCESS
一致性靠延迟补偿:L1 下次因其他 VM-exit 进入时消费 fault log 补自己的页表;shmem 缺页路径则包一层,先查 EPT12 是否已被填好。锁、双日志、帧池与原有 L1 路径共同兜住回退。
图中原始图元引自 OSDI '10 Fig. 3/4、ATC '25 Fig. 1/4、IHI 0069D Fig. 6-4 与 IHI 0070E.a。本文未做性能复现。