AkiraZheng's Time.

虚拟化:写权限的下传链路详解

Word count: 4.7kReading time: 20 min
2026/09/06

同一个可写属性,在虚拟化栈的每一层长得都不一样:

  • 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.flagsKVM_MEM_READONLYKVM_MEM_LOG_DIRTY_PAGES),建页权限全部从这两层现算
  • 瞬态软件枚举enum kvm_pgtable_prot,KVM 内部传递权限的统一语言,单次 fault 内存活,从不持久化
  • 硬件 PTE 位:stage-2 PTE 上的 S2AP_W,编码器写入,wrprotect 可清,是投影而非事实源

各章知识点的依赖关系如下,全文沿这张地图展开:

plantuml diagram

主链本身则压缩成四个阶段:

plantuml diagram

两张图各司其职,图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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/* linux/arch/arm64/include/asm/kvm_pgtable.h */
enum kvm_pgtable_prot {
KVM_PGTABLE_PROT_PX = BIT(0), /* 特权执行 */
KVM_PGTABLE_PROT_UX = BIT(1), /* 非特权执行 */
KVM_PGTABLE_PROT_X = KVM_PGTABLE_PROT_PX |
KVM_PGTABLE_PROT_UX,
KVM_PGTABLE_PROT_W = BIT(2), /* 写 */
KVM_PGTABLE_PROT_R = BIT(3), /* 读 */

KVM_PGTABLE_PROT_DEVICE = BIT(4), /* 设备内存属性 */
KVM_PGTABLE_PROT_NORMAL_NC = BIT(5), /* 非缓存正常内存 */

KVM_PGTABLE_PROT_SW0 = BIT(55), /* 软件位, nested 存映射层级 */
//...
};

/* 同文件, 复合宏一栏 */
#define KVM_PGTABLE_PROT_RW (KVM_PGTABLE_PROT_R | KVM_PGTABLE_PROT_W)
#define KVM_PGTABLE_PROT_RWX (KVM_PGTABLE_PROT_RW | KVM_PGTABLE_PROT_X)

#define PKVM_HOST_MEM_PROT KVM_PGTABLE_PROT_RWX /* pKVM host RAM */
#define PKVM_HOST_MMIO_PROT KVM_PGTABLE_PROT_RW /* pKVM host MMIO */
#define PAGE_HYP KVM_PGTABLE_PROT_RW /* hyp 自身数据段 */
#define PAGE_HYP_EXEC (KVM_PGTABLE_PROT_R | KVM_PGTABLE_PROT_X) /* 无 W */
#define PAGE_HYP_RO (KVM_PGTABLE_PROT_R)
#define PAGE_HYP_DEVICE (PAGE_HYP | KVM_PGTABLE_PROT_DEVICE)

两个名字的关系:

  • 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
2
3
4
5
6
7
8
9
10
11
12
/* linux/arch/arm64/kvm/mmu.c, kvm_s2_fault_compute_prot() */
*prot = KVM_PGTABLE_PROT_R; /* 1: 恒可读, 基线 */

/* 2: 可写的三个充分条件 */
if (s2vi->map_writable && (s2vi->device ||
!memslot_is_logging(s2fd->memslot) ||
kvm_is_write_fault(s2fd->vcpu)))
*prot |= KVM_PGTABLE_PROT_W; /* 逐位拼, 不是复合宏 */

/* linux/arch/arm64/kvm/mmu.c, kvm_phys_addr_ioremap() 也是同款模式 */
prot = KVM_PGTABLE_PROT_DEVICE | KVM_PGTABLE_PROT_R |
(writable ? KVM_PGTABLE_PROT_W : 0);

读是恒置基线,写是逐 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 没有任何关系

plantuml diagram

写访问要两道门都放行。唯一沾到 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
2
3
4
5
6
7
8
9
10
11
12
13
14
/* qemu/system/physmem.c, ram_block_add() */
new_block->host = qemu_anon_ram_alloc(new_block->max_length,
&new_block->mr->align,
shared, noreserve);

/* qemu/util/oslib-posix.c, qemu_anon_ram_alloc() */
ptr = qemu_ram_mmap(-1, size, align, qemu_map_flags, 0);

/* qemu/util/mmap-alloc.c, mmap_activate() */
const int prot = PROT_READ | (readonly ? 0 : PROT_WRITE); /* 默认含写 */
int flags = MAP_FIXED;
flags |= fd == -1 ? MAP_ANONYMOUS : 0; /* 普通 RAM 无后端文件 */
flags |= shared ? MAP_SHARED : MAP_PRIVATE; /* 默认非 shared */
/* 返回的 HVA 存入 ram_block->host */

mmap 进入内核后,通用路径把 PROT_WRITE 翻译成 VM_WRITE:

1
2
3
4
5
/* linux/include/linux/mman.h, calc_vm_prot_bits() */
return _calc_vm_trans(prot, PROT_READ, VM_READ) |
_calc_vm_trans(prot, PROT_WRITE, VM_WRITE) | /* VM_WRITE 的唯一来源 */
_calc_vm_trans(prot, PROT_EXEC, VM_EXEC) |
//...

普通 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
2
3
4
5
6
7
8
9
10
11
12
13
14
[QEMU]   qemu_ram_alloc_internal()                  /* system/physmem.c, RAMBlock 创建 */
+-> ram_block_add()
+-> qemu_anon_ram_alloc() /* util/oslib-posix.c */
+-> qemu_ram_mmap() /* util/mmap-alloc.c */
+-> mmap_reserve() /* PROT_NONE 占坑对齐 */
+-> mmap_activate() /* MAP_FIXED 激活读写 */
+-> mmap(PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_FIXED)
/* syscall 陷入内核, 返回 HVA 存入 ram_block->host */

[kernel] sys_mmap_pgoff() /* mm/mmap.c, mmap 系统调用入口 */
+-> ksys_mmap_pgoff()
+-> do_mmap()
+-> calc_vm_prot_bits(prot) /* PROT_WRITE → VM_WRITE */
+-> mmap_region() /* mm/vma.c, 匿名映射无驱动回调 */

至此事实源①就位: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
2
3
4
5
6
7
8
9
10
11
12
13
/* qemu/accel/kvm/kvm-all.c, kvm_mem_flags() */
static int kvm_mem_flags(MemoryRegion *mr)
{
bool readonly = mr->readonly || memory_region_is_romd(mr);
int flags = 0;

if (memory_region_get_dirty_log_mask(mr) != 0)
flags |= KVM_MEM_LOG_DIRTY_PAGES; /* 脏页跟踪 */
if (readonly && kvm_readonly_mem_allowed)
flags |= KVM_MEM_READONLY; /* 只读槽 */
return flags;
}
/* 普通 RAM MR(如 mach-virt.ram): readonly=false, dirty_log_mask=0 → flags=0 */

flags 为 0 意味着这个 slot 对 KVM 完全可写。ioctl 到达内核后,arm64 的 prepare 钩子只做合法性校验,不做权限记录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* linux/arch/arm64/kvm/mmu.c, kvm_arch_prepare_memory_region() */
do {
vma = find_vma_intersection(current->mm, hva, reg_end);
if (!vma)
break;

if (kvm_has_mte(kvm) && !kvm_vma_mte_allowed(vma)) { /* MTE 标签检查 */
ret = -EINVAL;
break;
}

if (vma->vm_flags & VM_PFNMAP) {
/* IO region dirty page logging not allowed */
if (new->flags & KVM_MEM_LOG_DIRTY_PAGES) {
ret = -EINVAL;
break;
}
//...
}
hva = min(reg_end, vma->vm_end);
} while (hva < reg_end);
/* 全程没有读 VM_WRITE: 注册阶段既不校验也不保存可写性 */

调用栈:

1
2
3
4
5
6
7
8
9
10
[QEMU]   kvm_region_commit()                         /* accel/kvm/kvm-all.c */
+-> kvm_set_phys_mem(section, add=true)
+-> kvm_mem_flags(mr) /* 普通 RAM MR → 0 */
+-> kvm_set_user_memory_region(kml, mem, true)
+-> ioctl(KVM_SET_USER_MEMORY_REGION, {GPA, HVA, size, flags})

[KVM] kvm_set_memory_region() /* virt/kvm/kvm_main.c */
+-> kvm_set_memslot(kvm, old, new, change)
+-> kvm_prepare_memory_region()
+-> kvm_arch_prepare_memory_region() /* 仅校验, 不读 VM_WRITE */

两个事实形成了闭环:其一,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
2
3
4
5
6
7
8
9
10
[KVM]    kvm_handle_guest_abort()             /* arch/arm64/kvm/mmu.c */
+-> gfn_to_hva_memslot_prot(memslot, gfn, &writable) /* 初筛: memslot 只读位 */
+-> user_mem_abort(&s2fd)
+-> kvm_s2_fault_pin_pfn() /* 查证写权限 */
+-> kvm_s2_fault_get_vma_info() /* vma_lookup() 快照 */
+-> __kvm_faultin_pfn() /* virt/kvm/kvm_main.c */
+-> kvm_follow_pfn()
+-> hva_to_pfn() /* GUP / pfnmap 两路 */
+-> kvm_s2_fault_compute_prot() /* ★ 翻译点 */
+-> kvm_s2_fault_map() /* 落 PTE, 第 5 节 */

写权限的查证在 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
2
3
4
5
6
7
8
9
10
11
/* linux/arch/arm64/kvm/hyp/pgtable.c, stage2_set_prot_attr() */
//... MemAttr 三选一 (DEVICE/NORMAL_NC/NORMAL), XN 编码 ...

if (prot & KVM_PGTABLE_PROT_R)
attr |= KVM_PTE_LEAF_ATTR_LO_S2_S2AP_R; /* bit 6 */

if (prot & KVM_PGTABLE_PROT_W)
attr |= KVM_PTE_LEAF_ATTR_LO_S2_S2AP_W; /* bit 7 */

//... SH = Inner-Shareable, AF 恒置, 软件位直通 ...
*ptep = attr;

随后走完整的建页 walker:

1
2
3
4
5
6
7
8
9
10
11
12
[KVM]    user_mem_abort()                               /* 起点见第 4 节 */
+-> kvm_s2_fault_map()
+-> kvm_pgtable_stage2_map() /* arch/arm64/kvm/hyp/pgtable.c */
+-> stage2_set_prot_attr() /* 编码: PROT_W → S2AP_W */
+-> kvm_pgtable_walk()
+-> stage2_map_walker()
+-> stage2_map_walk_leaf()
+-> stage2_map_walker_try_leaf()
+-> kvm_init_valid_leaf_pte() /* phys|attr|TYPE|VALID */
+-> stage2_pte_needs_update() /* 仅权限差异 → -EAGAIN */
+-> stage2_try_break_pte() /* BBM-break + TLBI */
+-> stage2_make_pte() /* BBM-make 写入 */

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
2
3
4
5
6
7
8
9
10
11
/* linux/arch/arm64/kvm/hyp/pgtable.c, kvm_pgtable_stage2_relax_perms() */
if (prot & KVM_PGTABLE_PROT_R)
set |= KVM_PTE_LEAF_ATTR_LO_S2_S2AP_R;

if (prot & KVM_PGTABLE_PROT_W)
set |= KVM_PTE_LEAF_ATTR_LO_S2_S2AP_W; /* 只加不减 */

//...
ret = stage2_update_leaf_attrs(pgt, addr, 1, set, clr, NULL, &level, flags);
if (!ret || ret == -EAGAIN)
kvm_call_hyp(__kvm_tlb_flush_vmid_ipa_nsh, pgt->mmu, addr, ...);

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
2
3
4
5
6
7
8
9
10
11
12
13
14
/* linux/arch/arm64/kvm/hyp/pgtable.c, hyp_set_prot_attr() */
u32 ap = (prot & KVM_PGTABLE_PROT_W) ? KVM_PTE_LEAF_ATTR_LO_S1_AP_RW :
KVM_PTE_LEAF_ATTR_LO_S1_AP_RO;

if (!(prot & KVM_PGTABLE_PROT_R))
return -EINVAL; /* 1: R 强制 */

//...
if (prot & KVM_PGTABLE_PROT_X) {
if (prot & KVM_PGTABLE_PROT_W)
return -EINVAL; /* 2: W/X 互斥, 比 stage-2 严格 */
//...
}
attr |= FIELD_PREP(KVM_PTE_LEAF_ATTR_LO_S1_AP, ap); /* 3: 写进 S1 AP 字段 */

注意第 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
2
3
4
5
6
/* linux/arch/arm64/kvm/hyp/pgtable.c, kvm_pgtable_hyp_pte_prot() */
ap = FIELD_GET(KVM_PTE_LEAF_ATTR_LO_S1_AP, pte);
if (ap == KVM_PTE_LEAF_ATTR_LO_S1_AP_RO)
prot |= KVM_PGTABLE_PROT_R;
else if (ap == KVM_PTE_LEAF_ATTR_LO_S1_AP_RW)
prot |= KVM_PGTABLE_PROT_RW;

pKVM 那一栏属于另一层表。protected KVM 模式下,hypervisor 给整个 host 内存套一层 host stage-2,host 首次访问未映射区域时按性质补映射:

1
2
3
/* linux/arch/arm64/kvm/hyp/nvhe/mem_protect.c, host_stage2_idmap() */
prot = is_memory ? PKVM_HOST_MEM_PROT : PKVM_HOST_MMIO_PROT;
/* RAM → RWX, MMIO → RW (设备不可执行) */

为什么这些场景敢把 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
2
3
4
5
6
7
8
/* linux/arch/arm64/kvm/mmu.c, create_hyp_mappings() */
int create_hyp_mappings(void *from, void *to, enum kvm_pgtable_prot prot)
{
//...
if (is_kernel_in_hyp_mode())
return 0; /* VHE: host 内核已在 EL2, 无需另建映射 */
//...
}

arch/arm64/kvm/hyp/vhe/ 目录里只剩世界切换、TLB、寄存器保存恢复的胶水代码,没有任何地址空间建立逻辑。第 4、5 节的 fault 处理链在 VHE 下也因此极其扁平,整条链在 host 内核一个执行流里完成:

plantuml diagram

三个扁平化的来源:

  • 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.opgtable.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_writablekvm_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 权限体系全部收放操作的边界在哪。

CATALOG
  1. 1. 两个名字:PROT_W 与 PROT_RW
  2. 2. 事实源①:host VMA,写权限的原始源头
  3. 3. 事实源②:memslot,注册时不存可写
  4. 4. fault 时刻:两层事实源现算成枚举
  5. 5. 落进 PTE:编码器与两条写入路径
  6. 6. 各层写位形态对照
  7. 7. PROT_RW 的归宿:hyp stage-1 与 pKVM
  8. 8. VHE 与 nVHE:为什么日常看不到 hyp stage-1
  9. 9. 总结:投影与兜底