Minos 虚拟化: 内存虚拟化
本文介绍 Minos Hypervisor 的内存虚拟化机制。
0. 地址翻译全景:两级翻译架构
一个 guest 进程访问内存,地址要过 两层翻译:
1 | guest 进程 VA ──(guest 自己的页表, Stage1)──> guest IPA |
- 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 直接访问物理内存(像裸机一样),会产生两个问题:
- 无法隔离:guest 可以访问/破坏其他 guest 或 hypervisor 的内存。
- 地址空间冲突:每个 guest 都认为自己”从 0 地址开始”,但它们不能共用物理 0 地址。
1.2 Stage2 的机制:重映射与权限控制
Stage2 翻译让每个 guest 看到自己的假物理地址空间(IPA),hypervisor 在背后把 IPA 映射到真实物理地址(PA):
1 | guest 看到的内存布局(IPA) 真实的物理内存(PA) |
- 隔离: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_start → minos_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_ATTR(loop_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 | int arch_host_map(struct vspace *vs, unsigned long start, unsigned long end, |
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=512)1 页 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 | pud = stage1_pud_offset(vs->pgdp, start); // ① 根表(PUD)→ 定位 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_mapping → arch_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)。
为什么要分两套?
- 启动时 C 环境还没就绪(栈/堆/锁都没初始化),只能用汇编硬铺。
- 运行期基础页表已固定,只需在临时要访问 guest 内存时动态加映射。
- 静态的 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 | struct mm_struct { // include/virt/vmm.h:45 |
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=8192,arch_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=512、S2_PGD_SHIFT=39这些宏是”为 40-bit 之上更大的 IPA 保留”,40-bit IPA 下映射代码实际从stage2_pud_offset(vs->pgdp, ...)起(即把 pgdp 当 PUD 表用)。
descriptor 位定义(stage2.h:6-60):
1 | Stage2 Table Descriptor(L0/L1/L2 指向下一级表): |
位段对应 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 |
|
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 | int arch_guest_map(struct mm_struct *vs, unsigned long start, unsigned long end, |
stage2_map_pud_range(stage2.c:397)逐级下钻:
1 | static int stage2_map_pud_range(struct mm_struct *vs, unsigned long start, |
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 的 caveat:
stage2_unmap_pud_range与stage2_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 | static inline int stage2_ipa_to_pa(struct mm_struct *vs, unsigned long va, phy_addr_t *pa) |
与硬件 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 页表,需要两个寄存器的配置:
- VTTBR_EL2(Virtualization Translation Table Base Register):告诉硬件页表根在哪(物理地址)、是哪个 VM 的(VMID)。
- 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 | 4.1 VTCR_EL2:翻译规则(IPA 大小/级数/granule) |
记住主线:第 3 章造表(软件侧)→ 本章告诉硬件表在哪、怎么查(寄存器配置)→ 第 5 章填表的内容(内存分配)。三章合起来才是完整的 Stage2 虚拟化。
4.1 VTCR_EL2:翻译控制寄存器
generate_vtcr_el2(arch_virt.c:131)为每个 vCPU 生成 VTCR_EL2 值:
1 | static inline uint64_t generate_vtcr_el2(void) |
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 | write_sysreg(context->vtcr_el2, VTCR_EL2); // arch_virt.c:317 |
切出时保存回去(arch_virt.c:272-273)。
4.4 TLB 维护
flush_all_tlb_mm(arch_virt.c:38-65)用 VTTBR 切换 + 当前 VMID 全刷 实现:
1 | void flush_all_tlb_mm(struct mm_struct *mm) |
原理: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.c、include/virt/vmm.h、include/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 | struct mem_block { // 一块 2M 物理内存 |
三个结构各司其职:
| 结构 | 记录什么 | 对应 |
|---|---|---|
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 | 一段物理内存 [0x40000000, 0x44000000) (举例,64MB) |
字段含义:
| 字段 | 含义 |
|---|---|
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):
- 从
bs_head找到一个free_blocks != 0的 section。 find_next_zero_bit_loop在位图里找一个空闲位。set_bit占用它,free_blocks--。- 返回
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 | |-------------------------| -- 0x0000_0000 |
低 2G 的 IO、126G normal 是 guest VM 的 IPA 布局(guest 从 0x8000_0000 开始放 RAM);HVM_IO_MMAP/HVM_NORMAL_MMAP 是 vm0(host VM) 把 guest 内存映射到自己地址空间用(vm_mmap 的落点)。
记完数据结构,接下来看看物理内存是怎么被切成一块块的——这是整个分配的起点。
5.2 物理内存管理:按 2M 块分配
先明确物理内存有多大:物理内存总量由 device tree 的 memory 节点决定(qemu 配置,qemu-arm64.dts:25-27):
1 | memory@40000000 { |
of_mm.c:70 把它登记为 MEMORY_REGION_TYPE_NORMAL 进 mem_list。所以:
1 | 物理内存 = 1GB(0x4000_0000 ~ 0x1400_0000) |
⚠️ **注意区分两个”空间”**:
- 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)负责初始化:
arch_alloc_guest_pgd()分配 Stage2 页表根(2 页)。vmm_area_init:整段 1TB IPA 空间作为一个大 free vmm_area——此时整个地址空间都是”空闲待划分”的。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_NORMALvmm_area 直接 identity 映射(map_vmm_area(mm, va, va->start),IPA=PA),不经过块分配器。- guest VM(mvm 创建):通过
guest_mm_init→alloc_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 | static int guest_mm_init(struct vm *vm, uint64_t base, uint64_t size) |
第一步 split_vmm_area:在 VM 的 IPA 空间里划分区间——从 vmm_area_free 里切出一段 [base, base+size) 移到 vmm_area_used,标记 VM_GUEST_NORMAL。这时只是登记地址区间,还没有真实物理内存。
第二步 alloc_vm_memory(vmm.c:653)才是分配物理内存:
1 | list_for_each_entry(va, &mm->vmm_area_used, list) { |
__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 | while (block) { |
- 每个 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 | guest 看到的(IPA 侧) 真实物理(PA 侧) |
- 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 | guest 进程访问 VA |
minos 侧完整代码路径:
- VM 创建:
create_guest_vm→guest_mm_init→alloc_vm_memory→map_vmm_area→arch_guest_map→stage2_map_pud_range建好 Stage2。 - vCPU 运行:
arch_vcpu_state_init生成VTCR_EL2+VTTBR_EL2,切入 guest 时写入。 - guest 正常访问:硬件用 Stage2 自动翻译,零 hypervisor 开销(性能关键!)。
- guest 访问未映射/无权限地址:Stage2 fault → trap → hypervisor 处理。
7. 关键知识点
- 两套翻译职责不同:guest Stage1(guest 管,VA→IPA)+ Stage2(hypervisor 管,IPA→PA)。minos 只建/管 Stage2,不碰 guest 自己的页表。
- **VTTBR_EL2 = BADDR[47:1] + VMID[63:48]**:一个 VM 一份页表,VMID 隔离 TLB。
- VTCR_EL2 定义 IPA 大小:T0SZ=24 → 40-bit IPA(1TB),4K granule,Level 1 起始。
- VM flags → S2 descriptor:
stage2_block_attr/stage2_page_attr把__VM_NORMAL/IO/RO/RW等翻译成 S2 的 MemAttr/AP/XN 位(stage2.c:128)。 - huge page 优化:2M/1G 对齐时用 block descriptor,省页表 + 提高 TLB 命中(stage2.c:325)。
- 软件翻译 IPA→PA:
stage2_ipa_to_pa供 hypervisor 查询用;guest 正常访问走硬件 walk,不走它。
8. 验证与实验
- 软件翻译验证:在 QEMU guest Linux 里申请一块内存,调用 minos 的
translate_guest_ipa/arch_translate_guest_ipa路径,验证 IPA→PA 结果正确(对照vm_mm_struct_init分配的物理块)。 - 给 guest 加 RAM:修改 VM 的
vmtag(mem_base / mem_size)或 device tree,观察guest_mm_init→alloc_vm_memory多分配了块,用 gdb 看vmm_area_used链表。 - 读 Stage2 页表:写个调试函数遍历某个 VM 的
mm->pgdp,打印已映射区间,和mem_map.S的 host 页表对照。 - 验证 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 |