同一个可写属性,在虚拟化栈的每一层长得都不一样:
- QEMU(Quick EMUlator,虚拟机监控器的用户态部分)用 mmap 的
PROT_WRITE申请 - host 内核把它记成
vma->vm_flags里的VM_WRITE位 - KVM 在 fault(缺页/权限异常)时把它翻译成软件枚举
KVM_PGTABLE_PROT_W - 编码器最终把它写成 stage-2 PTE(Page Table Entry,页表项)第 7 位的
S2AP_W
把这条链走通,会发现一个反直觉的事实:KVM
内部没有任何一处持久保存可写属性。arm64 的
struct kvm_arch_memory_slot 是空结构体,memslot 也只存
flags。
真正持久的只有两处事实源。写权限因此分三层表示:
- 持久事实源:host 进程的 VMA(Virtual Memory
Area,虚拟内存区域)与
kvm_memory_slot.flags(KVM_MEM_READONLY、KVM_MEM_LOG_DIRTY_PAGES),建页权限全部从这两层现算 - 瞬态软件枚举:
enum kvm_pgtable_prot,KVM 内部传递权限的统一语言,单次 fault 内存活,从不持久化 - 硬件 PTE 位:stage-2 PTE 上的
S2AP_W,编码器写入,wrprotect 可清,是投影而非事实源
各章知识点的依赖关系如下,全文沿这张地图展开:
主链本身则压缩成四个阶段:
两张图各司其职,图0 标注章节依赖,图1
压缩主链为四阶段。全文沿下传方向自顶向下展开:先辨析软件枚举的两个名字(第
1 节),再依次追两个事实源怎么装进 host VMA(第 2 节)与 memslot(第 3
节),然后看 fault 时两层事实源在哪个函数里合成枚举(第 4
节)、枚举怎么编码进 PTE(第 5 节),最后横向对照各层写位的形态(第 6
节),并回答两个延伸问题:复合宏 KVM_PGTABLE_PROT_RW
到底给谁用(第 7 节)、为什么日常环境里见不到它的使用者(第 8
节)。fault 主链内部的两层查证细节、权限真值表、relax 放权与 dirty
logging(脏页日志)拉锯见前篇,memslot
从 QEMU 的 MemoryRegion 树到 ioctl 的完整诞生链路见VFIO
篇一。代码取自主线 Linux(约 7.3)与 openEuler QEMU。
1. 两个名字:PROT_W 与 PROT_RW
先解决命名。arm64 KVM 传递权限的软件枚举定义如下:
1 | /* linux/arch/arm64/include/asm/kvm_pgtable.h */ |
两个名字的关系:
KVM_PGTABLE_PROT_W:枚举成员,单独一个写位。guest stage-2 的建页路径只用它KVM_PGTABLE_PROT_RW:宏,R|W 的语法糖。guest stage-2 路径从不使用,它只服务 hyp 自身映射与 pKVM(protected KVM,保护虚拟化;第 7 节展开)
为什么 guest 路径不用复合宏?因为写位是条件位。dirty logging 迭代期读 fault 必须故意建只读映射,让未来的写再 fault 出来标脏(真值表见前篇第 4 节),所以 prot 只能逐次现算,不存在恒 RW 的静态选择。实际的建页代码全部是 R 打底、条件加 W 的写法:
1 | /* linux/arch/arm64/kvm/mmu.c, kvm_s2_fault_compute_prot() */ |
读是恒置基线,写是逐 fault 判定。map_writable
从哪来,就是接下来三节的主线。
2. 事实源①:host VMA,写权限的原始源头
先澄清一个容易混淆的问题:guest 也有自己的 VMA,但它不在本文的链路里。guest 是完整的 Linux 内核,guest 用户进程的 mmap 照常创建 VMA、填 Guest S1 PTE(GVA→GPA),这些结构对 KVM 完全不可见,KVM 眼中 guest 只是一个 GPA 地址空间加一份 memslot 台账。写权限因此是两道独立的门,各管各的事实源:
- 第一道门 Guest S1:归 guest 管,权限来自 guest VMA。fault(缺页、写时复制、权限不足)由 guest 内核自己处理,写失败就是给 guest 进程发 SIGSEGV,KVM 察觉不到
- 第二道门 Guest S2:归 KVM 管,权限来自 host VMA 与 memslot flags,与 guest VMA 没有任何关系
写访问要两道门都放行。唯一沾到 guest 页表的例外是嵌套虚拟化(nested virtualization):L1 给 L2 自建的 guest S2 表会被 KVM 走查并取权限交集,但那是 L1 的页表,同样不是 VMA。
本文剩余部分的 VMA 全部指 host VMA。它里面存的是 HVA 区间加权限,HPA 是 Host S1 PTE 懒填充后才出现的(lazy 语义见 VFIO 篇一)。可写性最原始的源头在 QEMU 为 Guest RAM 发起的那次 mmap:QEMU 为每个 RAMBlock 匿名映射一段 host 内存,prot 直接给读写。
1 | /* qemu/system/physmem.c, ram_block_add() */ |
mmap 进入内核后,通用路径把 PROT_WRITE 翻译成 VM_WRITE:
1 | /* linux/include/linux/mman.h, calc_vm_prot_bits() */ |
普通 RAM 的 VMA 是匿名映射,没有驱动 mmap 回调,VM_WRITE
完全由这条通用路径写入。有回调的场景(如 VFIO 直通的 BAR(Base Address
Register,PCI
基地址寄存器))驱动也只做增量修饰,不碰写位:vfio_pci_core_mmap()
只追加 VM_IO|VM_PFNMAP 等 IO 标志并把
vm_page_prot 改成非缓存,写位原样保留(BAR 的完整 mmap
链路见 VFIO 篇一)。
调用栈全景:
1 | [QEMU] qemu_ram_alloc_internal() /* system/physmem.c, RAMBlock 创建 */ |
至此事实源①就位:vma->vm_flags 里有
VM_WRITE,vma->vm_page_prot 携带含写的页属性。Host S1
PTE(宿主机自己的一级页表)此刻还是空的,按需填充:QEMU 首次写 RAMBlock
或 GUP(get_user_pages,内核抓页接口)首次抓页时,内核缺页处理沿
vma->vm_page_prot 补填(匿名页走
do_anonymous_page())。这一步为第 4
节的写权限查证埋下伏笔:KVM 将来读到的写位,就来自这个由 VMA 派生的
PTE。
3. 事实源②:memslot,注册时不存可写
VMA 是 host 进程的账本,KVM 还需要一份自己的登记。QEMU 在 FlatView 变更后把每个叶子 ram MR 注册成 memslot(链路见 VFIO 篇一第 5 节),注册时携带的权限信息只有 flags:
1 | /* qemu/accel/kvm/kvm-all.c, kvm_mem_flags() */ |
flags 为 0 意味着这个 slot 对 KVM 完全可写。ioctl 到达内核后,arm64 的 prepare 钩子只做合法性校验,不做权限记录:
1 | /* linux/arch/arm64/kvm/mmu.c, kvm_arch_prepare_memory_region() */ |
调用栈:
1 | [QEMU] kvm_region_commit() /* accel/kvm/kvm-all.c */ |
两个事实形成了闭环:其一,struct kvm_memory_slot
里与权限相关的字段只有 flags 与 dirty_bitmap,arm64 的
kvm_arch_memory_slot 是空结构体,可写性在 KVM
侧没有任何持久字段;其二,注册时也不填 stage-2 PTE,arm64 是
demand-paged(按需分页),等 Guest 首次访问触发 fault
才建页。所以可写性的判定只能发生在 fault 时刻,从 VMA 与 memslot flags
两层现算。
4. fault 时刻:两层事实源现算成枚举
Guest 首次访问 GPA 触发 stage-2
fault,kvm_handle_guest_abort() 先用 memslot 做初筛,再进入
user_mem_abort() 三步走(主链分诊见前篇第 3 节):
1 | [KVM] kvm_handle_guest_abort() /* arch/arm64/kvm/mmu.c */ |
写权限的查证在 kvm_s2_fault_pin_pfn()
里完成,输出一个布尔量 map_writable,它就是第 1 节
kvm_s2_fault_compute_prot()
里那个条件的第一项。两层各把守一道门:
- memslot 层:
kvm_follow_pfn()发现 slot 带KVM_MEM_READONLY,直接把map_writable置 false,后续不再尝试可写。写 fault 配只读槽在更早的初筛就被拦下,根本到不了建页 - VMA 层:写 fault 的请求天然带
FOLL_WRITE,GUP(get_user_pages)拿不到可写页就说明 VMA 没有
VM_WRITE(KVM 不直接读
vma->vm_flags,以 GUP 成败为准)。读 fault 也会用 FOLL_WRITE 探测一次可写并升级引用,细节见前篇第 4 节
map_writable 的溯源到此闭合:GUP 读到的写位来自 Host S1
PTE,那个 PTE 由内核按 vma->vm_page_prot
补填,vm_page_prot 又源自 QEMU 的
PROT_WRITE,可写性判定本质是读回了第 2
节那条链的产物。VFIO 直通 BAR 是同一条 VMA 层查证的变体:它的
VMA 带 VM_IO|VM_PFNMAP,GUP 改由
hva_to_pfn_remapped() 读回 Host S1 PTE 的写位,写 fault
配只读直接返回 KVM_PFN_ERR_RO_FAULT(链路见 VFIO
篇一)。
回到 kvm_s2_fault_pin_pfn()
本身,它还有一个旁支判定:VMA 带 VM_PFNMAP|VM_MIXEDMAP
且页帧号不在正常内存区(pfn_is_map_memory()
为假)时,s2vi->device 置真,这决定 compute_prot 里
device 页绕过 dirty logging 直接给写位(真值表见前篇第 4 节)。普通 RAM
的 VMA 没有这些标志,device 恒为 false,权限完全由
map_writable 与 dirty logging 真值表决定。
5. 落进 PTE:编码器与两条写入路径
map_writable 与真值表在
kvm_s2_fault_compute_prot()
合成出枚举后,kvm_s2_fault_map() 按 mapping_size
与权限故障粒度是否相等分叉(分叉判据见前篇第 5 节):相等走 relax_perms
放权,不等走 map 建页。
map 路径(缺页或需换映射尺寸)先由编码器把枚举翻译成硬件位。读写翻译是一对一的,没有任何附加条件:
1 | /* linux/arch/arm64/kvm/hyp/pgtable.c, stage2_set_prot_attr() */ |
随后走完整的建页 walker:
1 | [KVM] user_mem_abort() /* 起点见第 4 节 */ |
kvm_init_valid_leaf_pte()
把物理地址、属性、页表层级类型、VALID 位拼成完整
PTE,stage2_try_break_pte() 与
stage2_make_pte() 执行
BBM(break-before-make,先断后建)保证多核可见性。并发的权限位竞态由
stage2_pte_needs_update() 的 PERMS 过滤让出,返回的 -EAGAIN
在 kvm_s2_fault_map() 末尾转成 0,vCPU
恢复运行等指令重试(机制见前篇第 6 节)。
relax 路径(权限 fault 且粒度相等)不重建映射,只对已建 PTE 做权限位原子置位:
1 | /* linux/arch/arm64/kvm/hyp/pgtable.c, kvm_pgtable_stage2_relax_perms() */ |
stage2_attr_walker() 里就是一句
pte = (pte & ~clr) | set 的 cmpxchg 更新加 TLBI(TLB
无效化)。两条路径汇合后,kvm_s2_fault_map() 对
writable && !ret 的页调用
mark_page_dirty_in_slot() 记脏,写 fault 的 relax
顺带完成脏页记账(闭环见前篇第 7、8 节)。
6. 各层写位形态对照
四层走完,把每一层可写的具体形态排成一张表:
| 层 | 载体 | 可写的表示 | 位 | 极性 |
|---|---|---|---|---|
| QEMU → syscall | mmap 参数 | PROT_WRITE |
无位概念 | 正 |
| VMA | vma->vm_flags |
VM_WRITE |
bit 1 | 正 |
| Host S1 PTE | pte | PTE_RDONLY(AP[2])清零,配合
PTE_WRITE(同 DBM 位) |
bit 7 / bit 51 | 反 |
| KVM 软件枚举 | enum kvm_pgtable_prot |
KVM_PGTABLE_PROT_W |
BIT(2) | 正 |
| Guest S2 PTE | pte | S2AP_W |
bit 7 | 正 |
连接各层的翻译点只有两个:VMA 到 Host S1 PTE 由 mm
核心完成(内核缺页处理沿 vm_page_prot 填页,匿名页走
do_anonymous_page()),枚举到 S2AP_W 由
stage2_set_prot_attr() 完成。
这张表里藏着一个容易踩坑的巧合:Host S1 和 Guest S2 的** bit 7 位置相同、语义相反**。stage-1 描述符里 bit 7 是 AP[2],置 1 表示只读;stage-2 描述符里 bit 7 是 S2AP_W,置 1 表示可写。手工解 PTE 或对照 ptdump(debugfs 页表转储,前篇第 11 节)输出时,同一个 bit 在两种页表里必须反着读。
7. PROT_RW 的归宿:hyp stage-1 与 pKVM
第 1 节说复合宏只服务 hyp 自控映射,现在展开。这里的 hyp 指 KVM 运行在 EL2(Exception Level 2,异常级 2)的那小部分代码,与 guest stage-2 是两套完全不同的页表。
hyp stage-1 是什么。KVM 代码分两半:host
内核里的大半(arch/arm64/kvm/*.c)和 EL2 上的 hyp
镜像(arch/arm64/kvm/hyp/,世界切换入口、TLB 操作等)。EL2
有自己的翻译机制(TTBR0_EL2),只做一级翻译,描述符是
stage-1 格式(AP/AttrIndx/XN 那套位),没有 stage-2 叠加,所以这套表叫
hyp stage-1。它只映射 KVM 自用的一小块:hyp
代码段(R+X)、.data/.bss 与 EL2 栈(R+W)、host 与 hyp
共享的数据结构。
编码器是另一个。hyp stage-1 用的不是第 5 节的
stage2_set_prot_attr(),而是
hyp_set_prot_attr(),同一个枚举喂进去,落成的是 S1 AP
字段而不是 S2AP:
1 | /* linux/arch/arm64/kvm/hyp/pgtable.c, hyp_set_prot_attr() */ |
注意第 2 点:hyp 自身映射强制写与执行互斥,guest stage-2 则允许 RWX 的正常内存映射。hyp 的安全边界比 guest 严格。
KVM_PGTABLE_PROT_RW 的全部使用点:
| 消费点 | 场景 | 映射对象 |
|---|---|---|
PAGE_HYP 别名 |
hyp stage-1 建映射 | hyp .data/.bss、EL2 栈、percpu、host↔︎hyp
共享结构(init_hyp_mode()、kvm_share_hyp()、create_hyp_stack()、nVHE
初始化) |
PKVM_HOST_MMIO_PROT 别名 |
pKVM host stage-2 | host 尚未 idmap(恒等映射)的 MMIO 区 |
kvm_pgtable_hyp_pte_prot() |
hyp stage-1 解码器 | 全树对 KVM_PGTABLE_PROT_RW 的唯一直接引用 |
解码器那处直接引用读的正是 S1 AP 字段:
1 | /* linux/arch/arm64/kvm/hyp/pgtable.c, kvm_pgtable_hyp_pte_prot() */ |
pKVM 那一栏属于另一层表。protected KVM 模式下,hypervisor 给整个 host 内存套一层 host stage-2,host 首次访问未映射区域时按性质补映射:
1 | /* linux/arch/arm64/kvm/hyp/nvhe/mem_protect.c, host_stage2_idmap() */ |
为什么这些场景敢把 RW 写死。三个共性:
- 映射对象是 KVM/hyp 自己拥有并完全控制的内存,没有 VMA(不依赖 QEMU 的 mmap 账本)
- 没有 dirty logging(hyp 自身不参与热迁移,不存在读 fault 建只读的语义)
- 权限在映射时一次定死,没有运行期收权放权的拉锯
对照 PAGE_HYP
家族看得更清楚,权限是逐类精确选的:PAGE_HYP_EXEC 是 R|X 无
W(代码段),PAGE_HYP_RO 是纯
R(rodata),PAGE_HYP 才是
R|W(数据段),PAGE_HYP_DEVICE 是 RW 加设备属性(EL2
下访问的 MMIO)。
pKVM 的 host stage-2 还有一个与本文主题呼应的细节:源码注释明说这层页表是相关状态的唯一软件存储,所以映射强制 page 级,避免表级操作意外抹掉状态。这是事实源下沉到 PTE 的特例,普通 VM 的事实源在 VMA 与 memslot,pKVM host 的事实源直接活在 PTE 里。
8. VHE 与 nVHE:为什么日常看不到 hyp stage-1
第 7 节的 PAGE_HYP 调用点日常调试几乎碰不到,原因是现代
ARM 默认跑 VHE(Virtualization Host Extension,虚拟化宿主扩展)。
VHE 下 hyp stage-1 不存在。host 内核直接运行在 EL2,复用同一套内核页表,hyp 映射全部退化为 no-op:
1 | /* linux/arch/arm64/kvm/mmu.c, create_hyp_mappings() */ |
arch/arm64/kvm/hyp/vhe/
目录里只剩世界切换、TLB、寄存器保存恢复的胶水代码,没有任何地址空间建立逻辑。第
4、5 节的 fault 处理链在 VHE 下也因此极其扁平,整条链在 host
内核一个执行流里完成:
三个扁平化的来源:
- GUP 走 Host S1 是纯软件遍历,不切异常级不换表基址
pgtable.c直接链接进内核镜像- TLBI 的
kvm_call_hyp()在 VHE 下就是普通函数调用
唯一的模式切换是处理完 eret 回 Guest。Guest 运行时硬件只用 Guest S1 与 Guest S2 两套表,Host S1 仅在 fault 处理时被软件遍历(三套表的分工见 VFIO 篇一第 2 节)。
nVHE 一笔带过。老硬件或保护虚拟化下,EL2
是独立编译的 hyp 镜像,hyp/ 目录里 pgtable.o
与 pgtable.nvhe.o
同源双编译并存,前者进内核,后者进镜像;镜像有自己的 VA 空间与 hyp
stage-1(第 7 节那批 PAGE_HYP 映射的归宿),TLB 操作经
hcall(hypercall,超级调用)陷入镜像执行。但 guest stage-2 的建表仍由
host 侧调用同一份 pgtable.c 完成,本文第 2 到 5
节的下传链路在 nVHE 下逐字不变。
9. 总结:投影与兜底
整条下传链可以压缩成三句话:
- 事前:可写属性写进两个持久事实源,VMA 的 VM_WRITE(源自 QEMU 的 PROT_WRITE)与 memslot 的 flags
- fault 时:
kvm_s2_fault_pin_pfn()查证两层得map_writable,kvm_s2_fault_compute_prot()以 R 打底条件拼 W,枚举是单次 fault 的局部变量 - 落表:map 路径经
stage2_set_prot_attr()编码成 S2AP_W 全量建页,relax 路径对已建 PTE 原子置位,写 fault 顺带记脏
因为 PTE 只是投影,写权限永不锁死:dirty logging 的 wrprotect 只清
PTE 的 S2AP_W,不碰 VMA 与 memslot,下轮写 fault 从事实源现算出
RW,relax 放权恢复(拉锯全周期见前篇第 8 节)。反向也成立,memslot 换成
KVM_MEM_READONLY 或 VMA 失去 VM_WRITE 后,无论 PTE
上残留什么权限,下一次 fault
都会按事实源收敛。理解了事实源与投影的分层,也就理解了 KVM
权限体系全部收放操作的边界在哪。