Minos 虚拟化: 内存虚拟化

本文介绍 Minos Hypervisor 的内存虚拟化机制。

0. 地址翻译全景:两级翻译架构

一个 guest 进程访问内存,地址要过 两层翻译

1
2
3
4
5
6
guest 进程 VA ──(guest 自己的页表, Stage1)──> guest IPA

┌───────────────┴──────────────────┐
│ ↓ Stage2 (hypervisor controls) │
│ IPA ──> real physical PA │
└──────────────────────────────────┘
  • Stage1:由 guest 自己管理(Linux 的页表),VA→IPA。
  • Stage2:由 hypervisor(minos)管理,IPA→PA。guest 看不见也改不了 Stage2 页表——这是内存虚拟化的隔离基础。

minos 里对应的两套页表代码:

页表 文件 作用 对应寄存器
Hypervisor 自身页表 arch/aarch64/core/stage1.c + mem_map.S 映射 minos 内核自身(VA→PA) TTBR1_EL2(boot.S 同写入 TTBR0_EL2)
Guest Stage2 页表 arch/aarch64/virt/stage2.c 每 VM 一份,IPA→PA VTTBR_EL2 + VTCR_EL2

注意区别stage1.c 的”stage1”是 minos 自己的 EL2 stage1 翻译,不是 guest 的 stage1。guest 的 stage1 是 guest 内核自己维护的,minos 不碰。minos 只维护 guest 的 stage2


1. Stage2 的需求背景

1.1 直接物理访问的问题

如果 guest 直接访问物理内存(像裸机一样),会产生两个问题:

  1. 无法隔离:guest 可以访问/破坏其他 guest 或 hypervisor 的内存。
  2. 地址空间冲突:每个 guest 都认为自己”从 0 地址开始”,但它们不能共用物理 0 地址。

1.2 Stage2 的机制:重映射与权限控制

Stage2 翻译让每个 guest 看到自己的假物理地址空间(IPA),hypervisor 在背后把 IPA 映射到真实物理地址(PA)

1
2
3
4
5
guest 看到的内存布局(IPA)         真实的物理内存(PA)
┌──────────────────┐
│ 0x8000_0000 │ ← IPA 实际可能映射到任意物理地址
│ guest RAM │ ──────▶ 0x1000_0000, 0x2000_0000...
└──────────────────┘
  • 隔离:guest 只被映射到分配给它的物理页,其余地址 → fault(trap 到 EL2)。
  • 欺骗:每个 guest 都能从 0x8000_0000 开始放 RAM(GVM_NORMAL_MEM_START),互不冲突。
  • Stage2 的权限属性(读/写/执行、Device/Normal)可被 hypervisor 强制,guest 无法绕过。

1.3 Stage2 的启用时机

只有 HCR_EL2.VM=1 才使能 Stage2 翻译(Phase 3 学过,arch_virt.c:188)。每个 vCPU 切换进 guest 前,arch_vcpu_state_init 里设置 HCR_EL2.VM


2. Hypervisor 自身的 Stage1 页表

源码:arch/aarch64/core/stage1.c(538 行)+ arch/aarch64/core/mem_map.S

2.1 职责概述

minos 内核自己跑在 EL2,也需要分页映射自己的代码/数据。这套页表由 stage1.c 动态管理(映射/解映射/属性修改),mem_map.S 在启动早期用汇编建初始映射。

为什么需要它:minos 内核启用 MMU 后,所有代码都要有 VA→PA 映射才能执行。早期汇编 mem_map.S 把内核镜像各段映射好,之后 stage1.c 提供动态 API。

2.2 启动建表

map_boot_mem(mem_map.S:64)中,汇编逐段建立映射:

起始符号 属性 对应宏
保留内存 minos_start__code_start RW BOOTMEM_DATA_ATTR
代码段 __code_start__code_end RX BOOTMEM_CODE_ATTR
init 段 __init_start__init_end RWX(启动后释放) BOOTMEM_INIT_ATTR
数据段 __data_start__data_end RW BOOTMEM_DATA_ATTR
RO 数据 __rodata_startminos_bootmem_base RO BOOTMEM_DATA_RO_ATTR
UART CONFIG_UART_BASE Device BOOTMEM_IO_ATTR
剩余 4K 页 minos_bootmem_base → 2M 边界 RW BOOTMEM_DATA_ATTR
RAM 主体(2M block) minos_bootmem_base(2M 对齐) → CONFIG_MINOS_RAM_SIZE(64MB) RW BOOTMEM_DATA_BLK_ATTRloop_pmd_blk,mem_map.S:306-319)

关键机制

  • build_page_table(mem_map.S:237):建 PGD/PUD/PMD 骨架,并把 [minos_bootmem_base, CONFIG_MINOS_RAM_SIZE) 整段用 2M block 映射(loop_pmd_blk)——这是 minos RAM 的主体,一页 PMD 覆盖 512 个 2M 块。
  • build_normal_pte_table:对内核镜像各段逐 4K 页填 PTE。
  • build_io_pte_table:UART 等设备映射成 Device 内存。
  • 页表页从 pagetable_base(bootmem 区域)依次分配。

2.3 动态映射 API:stage1.c

2.3.1 定位与三个核心 API

2.2 的 map_boot_mem(mem_map.S)只在启动早期一次性建好初始页表,MMU 一开就再也不用它了。但 hypervisor 运行期仍需要临时映射/解除映射内存,例如 copy_from_guest(把 guest 内存临时映射进 host 拷完就拆)、IOMMU/设备配置。这就需要运行期动态改页表的 API 层,即 arch/aarch64/core/stage1.c

1
2
3
4
int arch_host_map(struct vspace *vs, unsigned long start, unsigned long end,
unsigned long physical, unsigned long flags); // 建映射
int arch_host_unmap(struct vspace *vs, unsigned long start, unsigned long end); // 删映射
phy_addr_t arch_translate_va_to_pa(struct vspace *vs, unsigned long va); // VA→PA
  • arch_host_map:把 [start, end) 映射到 physical,带属性 flags
  • arch_host_unmap:解除映射(并释放页表页)。
  • arch_translate_va_to_pa:软件翻译 VA→PA(和 Stage2 的 arch_translate_guest_ipa 同一套路,只是查自己的表)。

注意start/end/physical 都是 host 地址(hypervisor 自己的 VA/PA),不是 guest 地址。

2.3.2 页表结构与映射流程

页表结构(标准命名,勿与 mem_map.S 混淆):

说明
级数 S1_PGTABLE_LEVELS=3 PUD→PMD→PTE
地址空间 S1_VIRT_MAX=1<<39 = 512G 只映射 512G
PUD 表 512 项(PTRS_PER_S1_PUD=5121 页 4KB 每项覆盖 1G
PMD 表 512 项 每项覆盖 2M
PTE 表 512 项 每项覆盖 4K

命名注意stage1.c标准层级命名(PUD/PMD/PTE 各就各位),跟 mem_map.S 的错位命名(page_table 实为 PUD、ttb0_pud 实为 PMD、ttb0_pmd 实为 PTE)不同,读代码时勿混淆。

为什么 PUD 只要 1 页?因为只映射 512G,PUD 索引只需 9 位(512 项)。对照 stage2(40-bit IPA 1TB)需 1024 项 = 2 页。⚠️ stage1.c:24-27 顶部注释”40bit…need 2 pages”是抄 stage2.h 的过时注释。

映射流程stage1_map_pud_range,stage1.c:396):

1
2
3
4
5
6
7
8
9
pud = stage1_pud_offset(vs->pgdp, start);   // ① 根表(PUD)→ 定位 PUD 项
do {
next = stage1_pud_addr_end(start, end); // ② 本 PUD 项覆盖范围的终点
if (stage1_pud_none(*pud)) {
pmdp = stage1_get_free_page(flags); // ③ 分配一张 PMD 表
stage1_pud_populate(pud, pmdp, flags); // ④ PUD 项 → 指向这张 PMD 表
}
ret = stage1_map_pmd_range(vs, pmdp, start, next, physical, flags); // ⑤ 递归下一级
} while (pud++, physical += size, start = next, start != end); // ⑥ 下个 PUD 项

和 2.2 的 build_page_table 逻辑同构(按需分配下一级表 + 递归填),差别只在:build_page_table 用寄存器传参一次性铺 6 段;stage1.c 用 C 函数按 start/end/physical/flags 动态处理任意区间。

2.3.3 属性生成、huge page 与使用场景

属性stage1_pmd_attr / stage1_pte_attr(stage1.c:188, 227)根据 flags 组合:

flags(VM_TYPE_MASK → 页表类型
__VM_NORMAL Normal
__VM_NORMAL_NC Normal 不可缓存
__VM_IO Device
__VM_WT Write-through
flags(VM_RW_MASK → AP 权限
__VM_RO S1_AP_RO
其他 S1_AP_RW

附加规则:!(flags & __VM_EXEC) → 加 S1_XN | S1_PXN(不可执行)。

huge page(stage1.c:341):若区间 2M 对齐且 flags & __VM_HUGE_2M,直接填 PMD 为 block(2M 大页),省一张 PTE 表 + 512 个 PTE,TLB 命中率更高。

与 Phase 3 的联系arch_host_map 在 Phase 3 里见过——copy_from_guest(vmm.c:500,内部 create_host_mappingarch_host_map)临时映射 guest 内存到 host 空间,拷完就拆。这就是为什么 minos 要能动态改自己页表。

2.4 启动建表 vs 动态映射的区别

2.2(mem_map.S 静态建表)和 2.3(stage1.c 动态映射)操作的是同一张页表__stage1_page_table),但两者在时机、方式、用途上差别很大:

对比项 2.2 启动建表(mem_map.S) 2.3 动态映射(stage1.c)
时机 启动早期,MMU 未开 运行期,MMU 已开
次数 一次性(只跑一次) 反复(按需增删)
语言 汇编 C 函数
传参方式 寄存器(vaddr/size/pte_attr) 函数参数(start/end/physical/flags)
映射对象 minos 自身内核各段 + RAM 主体 + UART guest 相关地址(ramdisk/内核/DTB)等临时区域
性质 永久映射(铺底) 临时映射(用完即拆)
页表根 __stage1_page_table __stage1_page_table(同一个)
页表级数 3 级(PUD→PMD→PTE) 3 级(PUD→PMD→PTE)
页表页来源 bootmem(pagetable_base,镜像尾) memory.c 页池(page_base,RAM 顶向下切)

最核心的区别一句话:静态建表是一次性把基础铺好(MMU 一开就能跑),动态映射是运行期临时加映射、用完拆掉(比如拷 guest 内核、DTB)。

为什么要分两套?

  1. 启动时 C 环境还没就绪(栈/堆/锁都没初始化),只能用汇编硬铺。
  2. 运行期基础页表已固定,只需在临时要访问 guest 内存时动态加映射。
  3. 静态的 64MB 内核区域永不动,动态只碰 64MB 之外的 guest 相关地址。

各级页表:在哪分配、复用还是新建、管多大空间

页表 静态分配位置 动态时 覆盖空间
PUD 表 内核镜像内 0x4002_b000(链接脚本预留 4KB,TTBR 指向) 复用同一张,永不新建 512G(每项 1G,512 项)
PMD 表 4 张 0x4005_7000 ~ 0x4005_b000(bootmem 分配,PUD[0..3] 已挂好) 复用这 4 张,不新建 每张 1G(每项 2M,512 项),4 张共 4G
PTE 表 1 张 0x4005_b000(镜像区,给内核 4K 页用) 新建(从页池切,RAM 顶向下) 每张 2M(每项 4K,512 项)
  • PUD 表:只有一张,__stage1_page_table,静态铺好后动态只往里面填项(不会也不该新建——TTBR 只认这一个根)。
  • PMD 表:静态 4 张已挂满 PUD[0..3],动态地址都在 4G 内,直接复用(往空的 PMD 项里填 PTE 表地址或 block)。位置始终 0x4005_7000
  • PTE 表:是唯一会被动态新建的一级。每个需要 4K 映射的动态区间(如拷 guest 内核/DTB)分配一张,从 memory.c 页池 page_base(0x44000000)向下切页。

页表页的两种来源:静态从 bootmem 的 pagetable_base(镜像尾 0x4005_7000 起,向上分配);动态从 memory.c 页池 page_base(RAM 顶 0x44000000,向下分配)。两者都在 minos 的 64MB RAM 内,只是从两头切、互不冲突。

为什么静态不新建 PMD 表、动态却能复用? 因为 PUD[0..3] 只覆盖 4G,而动态映射的 guest 地址(0x44000000 之后)也在这个范围内——PUD 项早已挂好 PMD 表,动态只需复用并填充。


3. Guest 的 Stage2 页表

源码:arch/aarch64/virt/stage2.c(500 行)+ stage2.h

3.1 数据结构:mm_struct

每个 VM 一个 mm_struct(内含 pgdp 指向 Stage2 页表根)。Stage2 翻译把 guest 的 IPA → PA,是内存虚拟化的核心。

1
2
3
4
5
6
struct mm_struct {      // include/virt/vmm.h:45
void *pgdp; // Stage2 页表根(L1/PUD 表,无 PGD 级,见 3.2)
spinlock_t lock;
struct list_head vmm_area_free; // 空闲地址区间
struct list_head vmm_area_used; // 已用地址区间
};

3.2 页表结构:40-bit IPA / 4K granule / 3 级

minos 的 Stage2 用 3 级页表(stage2.h:65 STAGE2_PGTABLE_LEVELS=3):PUD → PMD → PTE。VTCR_EL2.SL0=01 让硬件 从 Level 1 开始 walk,根本不存在 Level 0 表,VTTBR 直接指向 Level 1(即 PUD)表,所以 mm->pgdp 就是 PUD 表:

级别 名称 shift 每项覆盖 表内项数 覆盖地址范围
L1(初始) PUD 30 1G 1024 1TB(=40bit IPA)
L2 PMD 21 2M 512 1G
L3 PTE 12 4K 512 2M

重要:PUD 表需要 1024 项 × 8 字节 = 8192 字节 = 2 页,所以 stage2.h:66 S2_PAGETABLE_SIZE=8192arch_alloc_guest_pgd(stage2.c:25)分配 2 页并清零。这就是 STAGE2_PGTABLE_LEVELS=3 且注释说”need 2 pages”的原因。

arch_alloc_guest_pgd__get_free_pages(2, 2):第二参数 align=2 表示按 2 页(8KB)对齐分配(memory.c:318-332 只接受 1/2/4/8)——这个 8KB 对齐正是 VTTBR_EL2.BADDR 对”2 页拼接 L1 表”的对齐要求(BADDR 必须按拼接表总大小对齐)。

stage2.h:73-74 定义的 GUEST_PGD_PAGES=2/GUEST_PGD_PAGE_ALIGN=2 并未被 arch_alloc_guest_pgd 使用(它硬编码 __get_free_pages(2,2)),属于冗余宏。

PTRS_PER_S2_PGD=512S2_PGD_SHIFT=39 这些宏是”为 40-bit 之上更大的 IPA 保留”,40-bit IPA 下映射代码实际从 stage2_pud_offset(vs->pgdp, ...) 起(即把 pgdp 当 PUD 表用)。

descriptor 位定义(stage2.h:6-60):

1
2
3
4
5
6
7
8
9
10
11
12
13
Stage2 Table Descriptor(L0/L1/L2 指向下一级表):
┌──────────┬────────────┬─────┬─────┬──────┬───────────────┬──────────┐
│ [51:12] │ [11:10] │[9:8]│[7:6]│[5:2] │ │ │
│ next lvl │ RES0(no AF)│RES0 │RES0 │RES0 │ │ │
└──────────┴────────────┴─────┴─────┴──────┴───────────────┴──────────┘
[1:0] = 0b11 (Table) ← 只有地址 + 类型位有意义,其余 RES0

Stage2 Page/Block Descriptor(L1/L2 block 或 L3 page):
┌─────────────────┬────┬─────┬─────┬────────┬────────────┬───────────┬─────────┐
│ [51:12] addr │[10]│[9:8]│[7:6]│[5:2] │[54] XN │[55-58] sw │[1:0] │
│ │ AF │ SH │ AP │ MemAttr│ exec denied│ use │ 01 block│
└─────────────────┴────┴─────┴─────┴────────┴────────────┴───────────┴─────────┘
L1/L2 block 的 [1:0] = 0b01;L3 page 的 [1:0] = 0b11

位段对应 stage2.h 宏:AP=S2_AP_*<<6(bit 7:6)、MemAttr=S2_MEMATTR_*<<2(bit 5:2)、SH=S2_SH_*<<8(bit 9:8)、AF=S2_AF<<10(bit 10,仅叶描述符有)、XN=S2_XN<<54(bit 54)。Table 描述符没有 AF/SH/MemAttr,只有地址位和 [1:0]=0b11。

关键宏:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#define S2_DES_FAULT   (0b00 << 0)   // 无效项
#define S2_DES_BLOCK (0b01 << 0) // L1/L2 块(2M/1G huge page)
#define S2_DES_TABLE (0b11 << 0) // 指向下一级表
#define S2_DES_PAGE (0b11 << 0) // L3 页

#define S2_XN (1UL << 54) // 不可执行
#define S2_AF (1UL << 10) // Access Flag
#define S2_AP_NON (0b00 << 6) // 无访问权限
#define S2_AP_RO (0b01 << 6) // 只读
#define S2_AP_WO (0b10 << 6) // 只写
#define S2_AP_RW (0b11 << 6) // 读写
#define S2_SH_INNER (0b11 << 8) // Inner Shareable
#define S2_MEMATTR_NORMAL_WB (0b1111 << 2) // Normal WB
#define S2_MEMATTR_NORMAL_NC (0b0101 << 2) // Normal NC
#define S2_MEMATTR_DEV_nGnRE (0b0001 << 2) // Device

// bit 55-58 是软件自定义(非 ARM 规范位)
#define S2_PFNMAP (1UL << 55) // 直接映射物理内存
#define S2_DEVMAP (1UL << 56) // 设备直通映射
#define S2_SHARED (1UL << 57) // 共享内存(不释放)

3.3 属性映射:VM flags → Stage2 descriptor

这是学习计划点名要掌握的映射关系(stage2.c:128, 177)。VM 的 flags(memattr.h 定义)在映射时翻译成 Stage2 descriptor:

VM flags → Stage2 属性 说明
__VM_NORMAL S2_BLOCK_NORMAL / S2_PAGE_NORMAL 普通内存(Normal WB)
__VM_NORMAL_NC S2_BLOCK_NORMAL_NC / S2_PAGE_NORMAL_NC 普通不可缓存
__VM_IO S2_BLOCK_DEVICE / S2_PAGE_DEVICE Device 内存
__VM_WT S2_BLOCK_WT / S2_PAGE_WT 写穿通
__VM_RO S2_AP_RO 只读
__VM_RW S2_AP_RW 读写
__VM_WO S2_AP_WO 只写
无 EXEC S2_XN 不可执行(注意 S2_BLOCK_DEVICE/S2_PAGE_DEVICE 宏本身已含 XN,IO 天然不可执行)
__VM_PFNMAP S2_PFNMAP 软件位:物理直通
__VM_DEVMAP S2_DEVMAP 软件位:设备直通
__VM_SHARED S2_SHARED 软件位:共享

关于 MemAttr 编码:Stage2 页表没有 MAIR 间接索引,MemAttr 直接编码在 descriptor 的 bits[5:2](S2_MEMATTR_*<<2)。minos 用的 0b1111(WB)/0b0001(Device nGnRE) 都是合法的 Stage2 编码(DDI 0487 stage2 MemAttr 表);但 S2_MEMATTR_NORMAL_NC=0b0101 实际是”Outer NC + Inner WB”而非全 NC,读它表示的是混合属性,需留意。

3.4 映射流程

入口 arch_guest_map(stage2.c:480)→ stage2_map_pud_range

1
2
3
4
5
6
7
int arch_guest_map(struct mm_struct *vs, unsigned long start, unsigned long end,
unsigned long physical, unsigned long flags)
{
ASSERT((start < VMM_VIRT_MAX) && (end <= VMM_VIRT_MAX));
ASSERT(physical < S2_PHYSICAL_MAX);
return stage2_map_pud_range(vs, start, end, physical, flags);
}

stage2_map_pud_range(stage2.c:397)逐级下钻:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static int stage2_map_pud_range(struct mm_struct *vs, unsigned long start,
unsigned long end, unsigned long physical, unsigned long flags)
{
pud = stage2_pud_offset(vs->pgdp, start); // ① 根表(L1/PUD)→ 定位 PUD 项
do {
next = stage2_pud_addr_end(start, end); // ② 本 PUD 覆盖范围的终点
size = next - start;
old_pud = *pud;

if (stage2_pud_huge_page(...)) { // ③ 1G huge page 条件满足?
stage2_set_pud(pud, attr | (physical & S2_PUD_MASK)); // 直接填 block
} else {
if (stage2_pud_none(*pud)) {
pmdp = stage2_get_free_page(flags); // ④ 分配 PMD 表页
stage2_pud_populate(pud, pmdp, flags);// ⑤ PUD 指向 PMD 表
}
ret = stage2_map_pmd_range(vs, pmdp, start, next, physical, flags); // ⑥ 递归
}
} while (pud++, physical += size, start = next, start != end);
}

huge page 优化stage2_pmd_huge_page stage2.c:325,stage2_pud_huge_page stage2.c:385):

  • 条件:PMD 级 2M block 由 flags 带 __VM_HUGE_2M __VM_HUGE_1G 触发(stage2.c:328 的 flags & (__VM_HUGE_2M | __VM_HUGE_1G));PUD 级 1G block 仅 __VM_HUGE_1G 触发。且 start/phy/size 都对应对齐
  • 满足则直接填 block descriptor(S2_DES_BLOCK),省去一级表 + 512 个 PTE,TLB 命中率更高。

3.5 解映射流程

与映射对称(stage2.c:279-301),并会释放页表页

  • 若是 PMD 级 huge page:直接 stage2_pmd_clear(stage2.c:265-266)。
  • 若是页表:递归清 PTE,is_pud_range/is_pmd_range 判断整段解完后释放页表页 free_pages
  • 结束 flush_all_tlb_mm(vs) 刷 TLB(用该 VM 的 VMID)。

⚠️ 1G(PUD)huge page 的 caveatstage2_unmap_pud_rangestage2_ipa_to_pa 都不识别 PUD 级 block——会把 block descriptor 的地址位误当下一级表指针(stage2.c:289 的 stage2_pmd_table_addr(*pud))。1G 在 qemu 平台其实有实际使用:platform/qemu/qemu.c:49,55 用 __VM_HUGE_1G 映射 HVM 的 0x8000_0000_00 PCIE 窗口(512G 起、1G 对齐),因此启动即会构造 1G block。只是该区域(vm0)不走 unmap/ipa_to_pa,所以 bug 潜伏而未爆。

3.6 软件翻译:IPA 到 PA

arch_translate_guest_ipa(stage2.c:474)→ stage2_ipa_to_pa(stage2.c:441),纯软件遍历页表把 IPA 翻译成 PA:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static inline int stage2_ipa_to_pa(struct mm_struct *vs, unsigned long va, phy_addr_t *pa)
{
pudp = stage2_pud_offset(vs->pgdp, va); // ① 根表(L1/PUD)→ 定位 PUD 项
if (stage2_pud_none(*pudp)) return -EFAULT;
pmdp = stage2_pmd_offset(ptov(stage2_pmd_table_addr(*pudp)), va); // ② PUD → PMD
if (stage2_pmd_none(*pmdp)) return -EFAULT;
if (stage2_pmd_huge(*pmdp)) { // ③ 若是 huge page
*pa = ((*pmdp) & S2_PHYSICAL_MASK) + pmd_offset; // block 地址 + 页内偏移
return 0;
}
ptep = stage2_pte_offset(ptov(stage2_pte_table_addr(*pmdp)), va); // ④ PMD → PTE
phy = *ptep & S2_PHYSICAL_MASK;
if (phy == 0) return -EFAULT;
*pa = phy + pte_offset;
return 0;
}

与硬件 walk 的区别:这个函数是软件手动走页表,用于 hypervisor 自己需要知道”某 IPA 实际在哪”的场景(如 vm_mmap、调试、IOMMU 配置)。guest 正常访问时不经过它——硬件 MMU 用 VTTBR/VTCR 自动 walk。


4. 硬件侧的 Stage2 配置:VTCR_EL2 与 VTTBR_EL2

源码:arch/aarch64/virt/arch_virt.c:131, 158

4.0 本章要解决什么问题

前情回顾:第 3 章讲了 Stage2 页表怎么在软件侧建立(arch_guest_map 把 IPA 映射到 PA),页表的内容已经准备好了。但还有一个根本问题没讲:页表建好了,硬件(MMU)怎么知道这张表在哪、怎么用它做翻译?

第 3 章是”造表”,本章是”告诉硬件怎么用这张表“。硬件要正确 walk Stage2 页表,需要两个寄存器的配置:

  1. VTTBR_EL2(Virtualization Translation Table Base Register):告诉硬件页表根在哪(物理地址)、是哪个 VM 的(VMID)。
  2. VTCR_EL2(Virtualization Translation Control Register):告诉硬件怎么翻译(IPA 多大、几级、granule 大小、内存属性)。

打个比方:第 3 章造好了”地址翻译字典”(Stage2 页表),本章是**告诉翻译官(硬件 MMU)”字典放哪、怎么查”**——VTTBR 说”字典在书架这格”,VTCR 说”用中文查、每页索引规则”。

本章还回答一个关键问题:为什么切 VM 只要换 VTTBR 就行? 因为每个 VM 有独立的 Stage2 页表和独立的 VMID,切 VM 时写进新的 VTTBR(新页表根 + 新 VMID),硬件就用新表翻译、旧 TLB 条目被 VMID 隔离——这就是 VM 内存隔离的硬件基础。

章节脉络

1
2
3
4
4.1 VTCR_EL2:翻译规则(IPA 大小/级数/granule)
4.2 VTTBR_EL2:页表根 + VMID(每 VM 一套)
4.3 上下文切换:切 VM 时换这两个寄存器
4.4 TLB 维护:映射改了怎么刷缓存

记住主线:第 3 章造表(软件侧)→ 本章告诉硬件表在哪、怎么查(寄存器配置)→ 第 5 章填表的内容(内存分配)。三章合起来才是完整的 Stage2 虚拟化。

4.1 VTCR_EL2:翻译控制寄存器

generate_vtcr_el2(arch_virt.c:131)为每个 vCPU 生成 VTCR_EL2 值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static inline uint64_t generate_vtcr_el2(void)
{
uint64_t value = 0;

value |= (24 << 0); // T0SZ=24 → 40-bit IPA(64-24=40)
value |= (0x01 << 6); // SL0=01 → 从 Level 1 开始(4KB)
value |= (0x1 << 8); // IRGN0: Normal, Inner WBWA
value |= (0x1 << 10); // ORGN0: Normal, Outer WBWA
value |= (0x3 << 12); // SH0: Inner Shareable
value |= (0x0 << 14); // TG0=00 → 4KB granule
value |= (2 << 16); // PS=010 → 物理地址 1TB
value |= (0x0 << 19); // VMID=8bit
return value;
}

VTCR_EL2 各字段(ARM DDI 0487 VTCR_EL2 布局):

名字 含义
[5:0] T0SZ 24 IPA 大小 = 64-24 = 40-bit(1TB)
[7:6] SL0 01 初始翻译级别 Level 1(4KB granule)
[9:8] IRGN0 01 内层缓存属性 WBWA
[11:10] ORGN0 01 外层缓存属性 WBWA
[13:12] SH0 11 Inner Shareable
[15:14] TG0 00 Translation Granule = 4KB
[18:16] PS 010 物理地址大小 = 1TB(40-bit PA)
[19] VS 0 VMID 宽度:0=8bit,1=16bit
[31:20] RES0 0 保留(ARM 规范 bit31 实际为 RES1,KVM 会置 1;minos 写 0 且 QEMU 容忍)

value |= (0x0 << 19) 正是把 VS 位(bit 19)置 0 → 8-bit VMID,源码注释”vmid – 8bit”准确无误。

4.2 VTTBR_EL2:页表基址与 VMID

generate_vttbr_el2(arch_virt.c:158):

1
return (base | ((uint64_t)vmid << 48));   // BADDR[47:1] + VMID[63:48]
  • BADDR[47:1]:Stage2 页表物理地址mm->pgdp 是 VA,写入 VTTBR 前要 vtop(mm->pgdp) 转成 PA,见 arch_virt.c:230)。
  • **VMID[63:48]**:8-bit VMID,用于 TLB 区分不同 VM 的条目,避免切 VM 时全刷 TLB。

为什么 VTTBR 切 VM 就能隔离:每次切到某 VM,写它的 VTTBR(arch_virt.c:318 切入时写入),硬件 walk Stage2 时用这个基址 + 这个 VMID。不同 VM 即使 IPA 相同,物理映射完全不同。

4.3 上下文切换

arch_virt.c 的 vmodule 在 vCPU 切入时:

1
2
write_sysreg(context->vtcr_el2, VTCR_EL2);   // arch_virt.c:317
write_sysreg(context->vttbr_el2, VTTBR_EL2); // arch_virt.c:318

切出时保存回去(arch_virt.c:272-273)。

4.4 TLB 维护

flush_all_tlb_mm(arch_virt.c:38-65)用 VTTBR 切换 + 当前 VMID 全刷 实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void flush_all_tlb_mm(struct mm_struct *mm)
{
struct vm *vm = container_of(mm, struct vm, mm);
uint64_t vttbr = vtop(mm->pgdp) | ((uint64_t)vm->vmid << 48);
unsigned long flags;
uint64_t old_vttbr;

local_irq_save(flags); // ① 关中断(防止被抢占/被切走)
old_vttbr = read_sysreg(VTTBR_EL2);
write_sysreg(vttbr, VTTBR_EL2); // ② 切到目标 VM 的 VTTBR(拿它的 VMID)
flush_all_tlb_guest(); // ③ tlbi vmalls12e1is:按当前 VMID 全刷
write_sysreg(old_vttbr, VTTBR_EL2); // ④ 恢复原 VTTBR
local_irq_restore(flags);
}

原理:TLB 条目带 VMID 标签。flush_all_tlb_guest 执行 tlbi vmalls12e1is,只刷”当前 VTTBR 的 VMID”对应的 Stage1+Stage2 条目——所以要先切 VTTBR 再刷,才能精准刷掉目标 VM 的 TLB 而不影响其他 VM。关中断是因为这期间 VTTBR 指向别的 VM,不能被中断打断。


5. Guest 内存分配与 VM 内存管理

源码:virt/vmm.c(980 行)、virt/vm.cinclude/virt/vmm.hinclude/minos/memattr.h

到这里,我们已经把 Stage2 页表的”骨架”讲完了:第 3 章知道怎么把 IPA 映射到 PA,第 4 章知道硬件靠 VTTBR/VTCR 来查这张表。但是:页表里那些 IPA 到底指向哪块真实的物理内存?这些物理内存是从哪来的?

这正是本章要解决的事。前两章讲了 Stage2 页表怎么建立(映射)和硬件怎么查表(VTTBR/VTCR),本章补上”物理内存从哪来、怎么分给 guest、怎么登记进 Stage2”这一环:先由块分配器管理物理内存,再在 guest 的 IPA 空间里划分区间(vmm_area),最后用 Stage2 页表把两者绑定。

先记住一个核心思路:**”IPA 空间的划分”和”物理内存的分配”是两件独立的事**,guest 创建时分别做,最后在 map_vmm_area 里才绑定到一起。本章就围绕这条主线展开。

5.1 三个数据结构

在动手分配之前,得先认识 minos 用来记账的三个数据结构——它们分别是”物理块”、”IPA 区间”、”块分配器”三张表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct mem_block {          // 一块 2M 物理内存
uint32_t bfn; // 物理块号(Block Frame Number)
struct mem_block *next; // 链表
};

struct vmm_area { // 一个地址区间
unsigned long start; // 起始 IPA
unsigned long end; // 结束 IPA
unsigned long pstart; // 物理起始地址(连续映射时用)
int flags; // 属性 flags
struct list_head list;
struct mem_block *b_head; // 该区间占用的 mem_block 链表(BK 映射)
};

struct block_section { // 一段物理内存的块分配器
unsigned long start, size, end;
unsigned long free_blocks, total_blocks, current_index;
unsigned long *bitmap; // 位图:哪些块被占用
struct block_section *next;
};

三个结构各司其职:

结构 记录什么 对应
mem_block 一块 2M 物理内存(bfn) 物理块
vmm_area IPA 空间里一段区间 IPA 区间
block_section 一段物理内存里哪些块空闲/已用(bitmap) 物理内存分区

其中 vmm_area 是关键——每个 VM 维护两个链表:vmm_area_free(空闲 IPA 区间)和 vmm_area_used(已占用的)。分配内存就是在这两个链表间”搬运” vmm_area。

重点讲一下 block_section

它管的是物理内存本身(和 IPA 空间无关)。把”一整段物理内存”按 2M 切成很多块,然后用一个位图记录每块是否已分配:

1
2
3
4
5
6
7
8
一段物理内存 [0x40000000, 0x44000000)   (举例,64MB)
│ 按 2M 切成 32 块

块号 bfn: 0 1 2 3 ... 31
[2M] [2M] [2M] [2M] [2M]
bitmap: 0 1 0 0 ... 0
↑ ↑
空闲 已分配

字段含义:

字段 含义
start / end / size 这段物理内存的边界(已 2M 对齐)
total_blocks 一共切了几块(= size ÷ 2M)
free_blocks 还剩几块空闲
current_index 上次分配到哪了(下一个从这开始找)
bitmap 位图:第 n 位 = 1 表示第 n 块已占用
next 下一段物理内存(可能有多个 block_section 连成链表 bs_head

为什么叫 “section”(段)? 因为物理内存可能不止一段(device tree 里可以有多个 memory region),每段一个 block_section,用 next 串成链表。

分配动作vmm_alloc_memblock,vmm.c:890):

  1. bs_head 找到一个 free_blocks != 0 的 section。
  2. find_next_zero_bit_loop 在位图里找一个空闲位。
  3. set_bit 占用它,free_blocks--
  4. 返回 mem_block{bfn}——bfn 就是”第几块”。

一句话区分三个结构block_section 管理物理内存的分配状态(谁空闲谁占用);mem_block 代表分配出去的一块物理内存(bfn = 物理块号);vmm_area 划分 guest IPA 空间的用途(这段地址干吗用)。block_section 管的是物理侧vmm_area 管的是虚拟(IPA)侧,两者在 5.4 的 map_vmm_area 才相遇。

VM 的地址空间布局(vm_mmap.h):

1
2
3
4
5
6
7
8
9
10
|-------------------------| -- 0x0000_0000
| 2G IO MEM |
|-------------------------| -- 0x8000_0000 ← GVM_NORMAL_MEM_START
| |
| 126G DRAM/NORMAL MEM |
|-------------------------| -- 0x2000_0000_00
| 2G HVM_IO_MMAP | ← vm0(host VM)专用 IO mmap
|-------------------------| -- 0x2080_0000_00
| HVM_NORMAL_MMAP | ← vm0 专用 normal mmap
|-------------------------| -- 0x1_0000_0000_00 (1TB)

低 2G 的 IO、126G normal 是 guest VM 的 IPA 布局(guest 从 0x8000_0000 开始放 RAM);HVM_IO_MMAP/HVM_NORMAL_MMAPvm0(host VM) 把 guest 内存映射到自己地址空间用(vm_mmap 的落点)。

记完数据结构,接下来看看物理内存是怎么被切成一块块的——这是整个分配的起点。

5.2 物理内存管理:按 2M 块分配

先明确物理内存有多大:物理内存总量由 device tree 的 memory 节点决定(qemu 配置,qemu-arm64.dts:25-27):

1
2
3
memory@40000000 {
reg = < 0x00 0x40000000 0x01 0x00 >; // base=0x40000000, size=1GB
};

of_mm.c:70 把它登记为 MEMORY_REGION_TYPE_NORMALmem_list。所以:

1
2
3
物理内存 = 1GB(0x4000_0000 ~ 0x1400_0000)
块大小 = 2M(MEM_BLOCK_SIZE)
总块数 = 1GB ÷ 2M = 512 块

⚠️ **注意区分两个”空间”**:

  • IPA 空间(每 VM 1TB)= Stage2 页表的编址能力(见 5.3);
  • 物理内存(共 1GB)= 真实 RAM。
    1TB 的 IPA 空间里只有映射过的一小段才真正连着物理内存。另外 minos 自己占开头 64MB(CONFIG_MINOS_RAM_SIZE),实际可分配给 guest 的约 960MB。

guest 用的物理内存不是随便给的,得有一个”分配器”统一管理。minos 在启动时用 vmm_init(vmm.c:930)把上面这 1GB 物理内存按 2M block 切成块,用位图记录哪些块空闲:

1
bs->total_blocks = bs->free_blocks = bs->size >> BLOCK_SHIFT;  // BLOCK_SHIFT=21

之后 guest 申请内存时,vmm_alloc_memblock(vmm.c:890)从 bs_head 链表的 block_section 里找空闲块:find_next_zero_bit_loop 查位图 → set_bit 占用 → 返回 struct mem_block{bfn}

到这里物理内存有了分配入口:每块 2M 物理内存由一个 mem_block(带 bfn 物理块号)代表,分配器保证同一块不会给两个 guest

物理内存分配器就绪后,guest 还得知道”我该把内存放在我地址空间的哪里”——这就轮到 VM 自己的地址空间初始化了。

5.3 VM 地址空间初始化

每个 VM 都有一个自己的 mm_struct(含 Stage2 页表根 + vmm_area 链表),vm_mm_struct_init(vmm.c:756)负责初始化:

  1. arch_alloc_guest_pgd() 分配 Stage2 页表根(2 页)。
  2. vmm_area_init:整段 1TB IPA 空间作为一个大 free vmm_area——此时整个地址空间都是”空闲待划分”的。
  3. vm_memory_init:native VM 时把 device tree 的 memory_region 按 VMID 分给对应 VM。

这里有个重要区分——不同 VM 的内存来源不一样

native vs guest 的内存来源差异

  • native VM(vm0,跑主 Linux):物理内存来自 device tree 的 memory_region,映射入口是 vm_mm_init(vmm.c:779)——对每个 VM_NORMAL vmm_area 直接 identity 映射map_vmm_area(mm, va, va->start),IPA=PA),不经过块分配器。
  • guest VM(mvm 创建):通过 guest_mm_initalloc_vm_memory块分配器分配 mem_block 再映射(见 5.4)。

一句话理解:vm0 直接用 device tree 声明内存做 identity 映射,guest 则通过块分配器按块申请。我们关注的是 guest 这条路径。

接下来是核心——创建 guest 时,IPA 区间划分和物理块分配是怎么绑定到一起的

5.4 VM 创建时的内存分配与映射

guest 创建时,guest_mm_init(vm.c:563)把”切 IPA 区间”和”分物理块”两步串起来:

1
2
3
4
5
6
7
static int guest_mm_init(struct vm *vm, uint64_t base, uint64_t size)
{
// ① 从 1TB free 空间切出 [base, base+size) 给 guest(mem_base 默认 0x8000_0000)
split_vmm_area(&vm->mm, base, size, VM_GUEST_NORMAL);
// ② 分配物理内存块并映射到 Stage2
alloc_vm_memory(vm);
}

第一步 split_vmm_area:在 VM 的 IPA 空间里划分区间——从 vmm_area_free 里切出一段 [base, base+size) 移到 vmm_area_used,标记 VM_GUEST_NORMAL。这时只是登记地址区间,还没有真实物理内存。

第二步 alloc_vm_memory(vmm.c:653)才是分配物理内存:

1
2
3
4
5
list_for_each_entry(va, &mm->vmm_area_used, list) {
if (!(va->flags & VM_NORMAL)) continue;
__alloc_vm_memory(mm, va); // ① 给每个 normal vmm_area 分配 2M mem_block 链表
map_vmm_area(mm, va, 0); // ② 把 mem_block 映射进 Stage2
}

__alloc_vm_memory 用 5.2 的块分配器给这段 IPA 申请一串 2M mem_block(挂在 va->b_head 链表上);map_vmm_area 则把每个物理块填进 Stage2 页表,完成 IPA 与物理地址的绑定。绑定靠 vmm_area_map_bk(vmm.c:228)—— BK(Block)映射

1
2
3
4
5
while (block) {
ret = __create_guest_mapping(mm, base, BFN2PHY(block->bfn),
MEM_BLOCK_SIZE, va->flags | VM_HUGE | VM_GUEST);
base += MEM_BLOCK_SIZE; block = block->next;
}
  • 每个 mem_block(2M,bfn)→ 一次 Stage2 映射(BFN2PHY(bfn) 把物理块号转成物理地址)。
  • flags 带 VM_HUGE → 2M huge page 映射。

__create_guest_mapping(vmm.c:42)→ arch_guest_map(stage2.c:480)→ 走第 3.4 节的映射流程,最终在 Stage2 页表里写下 IPA ←→ PA

到这一步,IPA 区间划分(vmm_area)和物理块分配(mem_block)在 map_vmm_area 里绑定到一起:IPA 区间保持不变,但每个 2M 的 IPA 现在都有了对应的物理内存。

关键理解:IPA 连续 ≠ 物理连续

guest 申请几 MB 内存时,__alloc_vm_memory逐个调用 vmm_alloc_memblock 申请很多个 2M block(vmm.c:635-648,注释原文 TBD: get contiguous memory or not contiguous ?)——这些 block 的物理地址可能互不相邻。但这完全没问题,因为:

1
2
3
4
5
6
7
8
guest 看到的(IPA 侧)                真实物理(PA 侧)
[0x80000000 ... +size) bfn=3 bfn=17 bfn=8
连续的一段 不连续!
│ Stage2 分别映射

┌────────┐ ┌────────┐ ┌────────┐
│ block3 │ │ block17│ │ block8 │
└────────┘ └────────┘ └────────┘
  • IPA 连续split_vmm_area 切的一段 + 映射时 base += MEM_BLOCK_SIZE 步进——guest 以为自己有连续内存。
  • 物理不连续:Stage2 把连续 IPA 映射到离散 PA,guest 完全无感知。

这就是虚拟化内存管理和裸机 buddy 分配器最大的不同:裸机必须真找一段连续物理内存;虚拟化只需”一堆块”,连续性由 Stage2 页表伪装

⚠️ 唯一例外是 DMA:如果 guest 有设备做 DMA,设备引擎可不懂”伪装”,它要的物理地址必须真连续。此时靠 IOMMU 兜底——把离散 PA 对设备也伪装成连续(vmm.c:58 iommu_iotlb_flush_all)。

到此,guest 的内存就”有地有证”了。不过运行期还会用到一些辅助 API,我们快速过一遍,它们都是在这套机制上的延伸。

5.5 运行期的其他内存 API

API 作用
create_guest_mapping / destroy_guest_mapping 动态映射/解映射 guest 某段 IPA(运行期临时加/删)
translate_guest_ipa 软件翻译 IPA→PA(调 stage2_ipa_to_pa)
copy_from_guest 从 guest 内存拷数据到 hypervisor(临时映射 + memcpy)
vm_mmap 把 guest 内存映射到 vm0(host VM)地址空间,供共享
release_vm_memory VM 销毁时全释放(解映射 + 释放块 + 释放页表)

这些 API 本质都是在”同一套 vmm_area + mem_block 机制”上做增删改查——理解了 5.1~5.4 的分配主线,它们的用途就一目了然了。


6. 关键链路:一次 guest 内存访问的完整流程

1
2
3
4
5
6
7
8
9
10
guest 进程访问 VA

├─ Stage1(guest 自己的页表,guest 管)
│ VA ──▶ IPA

├─ Stage2(minos 管,VTTBR_EL2 + VTCR_EL2)
│ IPA ──▶ PA(硬件自动 walk,命中则直接访问)

└─ 未命中(IPA 没映射)→ Stage2 fault → trap 到 EL2
└─ dataabort_tfl_handler(Phase 3)→ FAR/HPFAR → MMIO 模拟或关 VM

minos 侧完整代码路径

  1. VM 创建:create_guest_vmguest_mm_initalloc_vm_memorymap_vmm_areaarch_guest_mapstage2_map_pud_range 建好 Stage2。
  2. vCPU 运行:arch_vcpu_state_init 生成 VTCR_EL2 + VTTBR_EL2,切入 guest 时写入。
  3. guest 正常访问:硬件用 Stage2 自动翻译,零 hypervisor 开销(性能关键!)。
  4. guest 访问未映射/无权限地址:Stage2 fault → trap → hypervisor 处理。

7. 关键知识点

  1. 两套翻译职责不同:guest Stage1(guest 管,VA→IPA)+ Stage2(hypervisor 管,IPA→PA)。minos 只建/管 Stage2,不碰 guest 自己的页表
  2. **VTTBR_EL2 = BADDR[47:1] + VMID[63:48]**:一个 VM 一份页表,VMID 隔离 TLB。
  3. VTCR_EL2 定义 IPA 大小:T0SZ=24 → 40-bit IPA(1TB),4K granule,Level 1 起始。
  4. VM flags → S2 descriptorstage2_block_attr/stage2_page_attr__VM_NORMAL/IO/RO/RW 等翻译成 S2 的 MemAttr/AP/XN 位(stage2.c:128)。
  5. huge page 优化:2M/1G 对齐时用 block descriptor,省页表 + 提高 TLB 命中(stage2.c:325)。
  6. 软件翻译 IPA→PAstage2_ipa_to_pa 供 hypervisor 查询用;guest 正常访问走硬件 walk,不走它。

8. 验证与实验

  1. 软件翻译验证:在 QEMU guest Linux 里申请一块内存,调用 minos 的 translate_guest_ipa / arch_translate_guest_ipa 路径,验证 IPA→PA 结果正确(对照 vm_mm_struct_init 分配的物理块)。
  2. 给 guest 加 RAM:修改 VM 的 vmtag(mem_base / mem_size)或 device tree,观察 guest_mm_initalloc_vm_memory 多分配了块,用 gdb 看 vmm_area_used 链表。
  3. 读 Stage2 页表:写个调试函数遍历某个 VM 的 mm->pgdp,打印已映射区间,和 mem_map.S 的 host 页表对照。
  4. 验证 VMID 隔离:两个 VM 相同 IPA,看 VTTBR 的 VMID 不同;guest 写一个地址,另一个 guest 同 IPA 读不到(验证 Stage2 隔离)。

9. 术语速查

术语 含义
IPA Intermediate Physical Address,guest 看到的”物理地址”
Stage1 VA→IPA,guest 自己管理(guest 内核页表)
Stage2 IPA→PA,hypervisor 管理(minos 的 stage2.c)
PGD/PUD/PMD/PTE 页表四级:页全局/上级/中间/页表项
huge page 2M/1G 大页,用 block descriptor 一次映射整块
VMID VM Identifier,VTTBR[63:48],隔离不同 VM 的 TLB
mem_block 2M 物理内存块,块分配器基本单位
bfn Block Frame Number,物理块号
VTTBR / VTCR Stage2 页表基址+VMID / Stage2 翻译配置

10. 寄存器速查

寄存器 全名 作用 关键位段
VTCR_EL2 Virtualization Translation Control Register Stage2 翻译配置(IPA 大小、granule、属性) T0SZ[5:0]=24(40bit)、SL0[7:6]=01、TG0[15:14]=00(4K)、PS[18:16]=010(1TB)、VS[19]=0(8bit VMID)
VTTBR_EL2 Virtualization Translation Table Base Register Stage2 页表基址 + VMID BADDR[47:1]、VMID[63:48]
HCR_EL2 Hypervisor Configuration Register 总开关(VM=1 使能 Stage2) VM[0]
TTBR1_EL2 Translation Table Base Register 1 (EL2) minos 自身 Stage1 页表基址(boot.S 把同一张 __stage1_page_table 同时写入 TTBR0/1_EL2;qemu 配置 CONFIG_PTOV_MASK=0x0 时 VA==PA,两表相同) BADDR
FAR_EL2 Fault Address Register 同步 abort 时记录故障地址(VA 或 IPA,依场景);Stage2 fault 的 IPA 权威来源是 HPFAR_EL2,minos 里 FAR 仅作辅助(trap.c:266 读 FAR,IPA 用 get_faulting_ipa 从 HPFAR 重建,trap.c:224-233) 故障地址
HPFAR_EL2 Hypervisor IPA Fault Address Register Stage2 fault 的 IPA(可靠来源,trap.c:226) 故障 IPA