虚拟内存、物理页面与多级页表
从进程为什么需要虚拟内存出发,理解物理页面、映射关系和多级页表。
建议先了解
- 二进制与地址
- 进程基础
1. 这篇笔记解决什么问题
本篇是本次对话中计算机系统部分的核心,回答:
- page 与 physical page frame 是什么;
- 页面管理元数据放在哪里;
- 页表到底是程序还是数据结构;
- 每个进程是否有独立页表;
- 多级页表为什么存在;
- L4/L3/L2/L1 在 RAM 中到底是什么;
- L4 的 512 个 entry 是否全部真实存在;
- 一个 entry 保存什么;
- CR3 是什么;
- CPU、MMU、OS 各自决定什么。
2. 前置知识
- 二进制位、byte、地址;
- 进程的基本概念;
- 编译、链接、Mach-O 装载与虚拟地址布局
待补充:page fault、TLB、cache 的更完整背景。
3. 核心概念
3.1 虚拟页(Virtual Page)
进程虚拟地址空间按固定 page size 划分。
以 4 KiB 为例:
Virtual Page 0: 0x0000 - 0x0FFF
Virtual Page 1: 0x1000 - 0x1FFF
Virtual Page 2: 0x2000 - 0x2FFF
...
3.2 物理页框(Physical Page Frame)
物理地址空间/RAM 也按 page size 组织成 page frame:
Frame 0
Frame 1
Frame 2
...
虚拟页编号与物理页框编号不需要相同,也不要求连续对应。
3.3 页面本身与页面元数据
一个物理页框主要用于存实际内容:
程序代码
程序数据
stack/heap 数据
文件缓存
内核数据
页表本身
...
“这个页属于谁、是否 dirty、引用数多少”等管理信息通常不要求固定塞在 page 自身开头。
本次对话区分了两类重要元数据:
- PTE:描述虚拟地址映射与硬件权限;
- kernel page descriptor:描述物理页框的内核管理状态,例如引用、LRU、allocator 等。
Linux 中可联系到 struct page 概念,但其具体字段强烈依赖版本和用途。
3.4 页表(Page Table)
页表不是一个独立程序,而是:
存放在 RAM 中、由 OS 建立和维护、由 CPU/MMU 按架构规则读取的地址转换数据结构。
核心:
Virtual Page Number (VPN)
↓
PTE
↓
Physical Frame Number (PFN)
3.5 页表项(Page Table Entry,PTE)
一个 entry 通常同时保存:
- 下一层页表或最终物理页框的地址相关 bits;
- Present/Valid;
- Read/Write;
- User/Supervisor;
- Execute/NX;
- Accessed;
- Dirty;
- cache 属性;
- 其他架构规定或 OS 可用 bits。
具体 bit 布局必须看对应 CPU architecture 与 paging mode。
3.6 MMU
内存管理单元(Memory Management Unit,MMU)负责执行地址转换。
职责分工:
CPU / ISA
→ 规定页表格式、层级、地址位拆分、entry bit 语义、page walk 规则
OS
→ 创建页表、填映射、设置权限、处理 page fault、切换地址空间
MMU
→ 按 CPU 规定的规则读取页表并完成 VA → PA
3.7 CR3
CR3 是 x86/x86-64 的特殊控制寄存器,不是普通通用寄存器。
在经典 x86-64 四级页表模型中,可先理解为:
CR3
↓
当前地址空间的顶级 PML4/L4 页表物理地址相关信息
它是特权状态,普通用户态程序不能任意修改。
ARM64 不叫 CR3,会使用类似 TTBR 的 translation table base register;思想相近但格式不同。
4. 直观理解
单级页表最直观:
PageTable[VPN]
CPU 可以直接计算:
优点是快;问题是即使某个 VPN 根本没映射物理页,它对应的数组槽位仍然得存在。
多级页表就是把一个巨大稠密数组变成“按需展开的固定扇出树”。每一层仍可直接用地址位做数组索引,但没用到的分支不创建下一级表。
5. 工作原理
5.1 为什么单级页表太大
经典模型:
48-bit virtual address
4 KiB page
8-byte PTE
因为:
所以虚拟页数:
若单级页表为完整数组:
即使进程实际只用了几十 MiB,也必须为理论所有 VPN 预留 entry,代价不可接受。
5.2 四级页表如何拆分地址
经典 48-bit x86-64 + 4 KiB page:
47 39 38 30 29 21 20 12 11 0
┌──────────┬────────────┬────────────┬────────────┬───────────┐
│ L4 index │ L3 index │ L2 index │ L1 index │ offset │
│ 9 bit │ 9 bit │ 9 bit │ 9 bit │ 12 bit │
└──────────┴────────────┴────────────┴────────────┴───────────┘
每层 9 bit:
若 entry 8B:
所以一张页表正好占一个 4 KiB page。
5.3 每一级覆盖多少地址
一个 L1 entry → 4 KiB
一张 L1 table → 512 × 4 KiB = 2 MiB
一张 L2 table → 512 × 2 MiB = 1 GiB
一张 L3 table → 512 × 1 GiB = 512 GiB
一张 L4 table → 512 × 512 GiB = 256 TiB
5.4 多级页表到底省在哪里
这是本次对话中最重要的纠正。
一张 L4 一旦存在:
512 个 entry 槽位全部真实存在。
例如:
L4
├── entry[0] present=0
├── entry[1] present=0
├── entry[2] present=1 ──→ 一张 L3
├── entry[3] present=0
└── ...
entry[0] 本身的 8B 仍然占内存;真正不存在的是它指向的下一级页表。
如果一个 L4 entry 无效,它对应的整片 512 GiB 虚拟地址范围都可以不创建下方 L3/L2/L1 子树。
因此:
多级页表并不是让“完全填满时”的页表理论规模变小,而是让稀疏地址空间可以按需展开。
5.5 一张真实 L4 在 RAM 中如何存在
假设 L4 物理基地址:
0x0000000123400000
内存中就是一个真实 4096B 页:
offset 内容
+0x000 entry[0] 8B
+0x008 entry[1] 8B
+0x010 entry[2] 8B
...
+0x128 entry[37] 8B
...
+0xFF8 entry[511] 8B
entry 地址:
这就是硬件能够快速直接索引的原因。
5.6 一个 L4 entry 保存什么
简化模型:
63 12 11 0
┌────┬────────────────────────────────────────────┬─────────────────┐
│ NX │ 下一级 L3 页表物理地址相关 bits │ flags │
└────┴────────────────────────────────────────────┴─────────────────┘
假设某 entry 概念值:
0x0000000456700007
可以粗略拆成:
0x0000000456700000 → 下一级页表物理地址部分
0x7 → 一些低位 flags
[!WARNING] 上图只是教学简化。真实 PML4E/PTE 的 reserved bits、地址 bits、NX 等布局必须查对应 Intel/AMD 架构手册,不能从示例直接推导。
5.7 为什么不直接“只创建用到的 PTE”而不用多级页表
如果把所有有效 VPN-PTE 对稀疏存放:
VPN 123 → PTE
VPN 999999 → PTE
...
新的问题是:
MMU 拿到一个 VPN 后,怎么快速找到那个 PTE?
若不是完整数组,就需要 hash/tree/inverted table 等额外查找结构。
多级页表是一种工程折中:
地址 bits 分段
↓
每层仍然是固定数组直接索引
↓
只有需要的下级表才分配
因此兼顾规则硬件查找与稀疏空间节省。
5.8 每个进程的页表
原则上每个进程/地址空间拥有自己的页表体系。
Process A
VA 0x1000 → PageTable_A → PFN 100
Process B
VA 0x1000 → PageTable_B → PFN 928
因此同一个虚拟地址可以同时在不同进程中存在,却映射不同物理页。
内核空间映射是否共享、如何隔离,取决于具体 OS 与安全设计,不能简单说“每一项都完全不同”。
5.9 TLB
完整 page-table walk 很贵,因此 CPU 使用转换后备缓冲区(Translation Lookaside Buffer,TLB)缓存:
VPN → PFN
流程:
Virtual Address
↓
TLB
↙ ↘
hit miss
↓ ↓
PFN L4→L3→L2→L1 walk
6. 示例
示例 1:8 MiB 连续映射
一张 L1 可覆盖 2 MiB,因此 8 MiB 连续地址约需 4 张 L1。
若它们落在相同上级范围内,概念上可能为:
1 × L4
1 × L3
1 × L2
4 × L1
共 7 张 4 KiB 页表:
这个例子说明稀疏/按需分配的价值。
示例 2:同一个 VA
Process A:
CR3 → PageTable_A
VA 0x4000 → Frame 10
Process B:
CR3 → PageTable_B
VA 0x4000 → Frame 900
说明:虚拟地址的含义取决于当前地址空间页表。
7. 代码、命令或公式
4 KiB page:
因此低 12 bit 为 page offset。
最终物理地址可概念化为:
单级数组 PTE 位置:
这直接解释了为什么单级完整数组必须为所有 VPN 保留槽位。
8. 容易混淆的概念
| 概念 A | 概念 B | 核心区别 |
|---|---|---|
| virtual page | physical page frame | 前者属于进程地址空间,后者属于物理地址空间 |
| page | PTE | page 存实际内容,PTE 存映射和权限 |
| page table | 程序 | 页表是数据结构,不是独立运行程序 |
| PTE | kernel page descriptor | 前者给地址翻译/权限用,后者给 OS 管理物理页用 |
| L4 entry 不存在 | L4 entry present=0 | L4 槽位存在,只是值表示无效;下一级表才不创建 |
| CR3 | 普通寄存器 | CR3 是特权控制寄存器,提供页表根相关信息 |
| CPU 决定格式 | OS 决定内容 | CPU 规定页表语法,OS 填具体映射 |
| TLB | page table | TLB 是缓存,page table 是权威映射结构 |
9. 常见误区
误区:L4 中没用的 entry 本身不占空间
错误原因:把“下级表按需创建”误认为“上级数组槽位也动态消失”。
正确理解:一张 L4 固定 512×8B=4KiB,全部槽位存在;未使用 entry 只是在值上标记无效。
如何验证:计算一张页表的固定大小。
误区:多级页表的理论最大内存一定比单级页表小
错误原因:忽略多级页表主要解决稀疏问题。
正确理解:若整个地址空间全部映射,多级页表仍很大;日常收益来自大量无效分支不展开。
误区:完全不需要多级页表,只给有用 VPN 创建 entry
错误原因:没有解决“硬件如何定位稀疏 entry”。
正确理解:稀疏结构必须再引入 hash/tree 等索引;多级页表本身就是硬件友好的稀疏 radix tree。
误区:页表格式是操作系统设计的
错误原因:忽略 MMU 直接按硬件格式读取页表。
正确理解:ISA/CPU 规定 entry 组织、bit 语义和 walk 规则;OS 负责按规则填内容。
误区:物理页的前几个字节一定保存页面元数据
错误原因:把对象 header 模型套到 page frame。
正确理解:页框主要放实际内容;PTE 和 OS 的 page metadata 通常存于其他内存结构。
10. 与其他知识的关系
待建链接:TLB
11. 可以亲手完成的验证
实验 A:手算覆盖范围
- page = 4 KiB;
- entry = 8B;
- table = 4 KiB;
- 每表 entry 数 = 4096/8=512;
- 依次计算 L1/L2/L3/L4 覆盖范围。
预期:
L1 table → 2 MiB
L2 table → 1 GiB
L3 table → 512 GiB
L4 table → 256 TiB
实验不能证明:实际 CPU 当前一定使用 48-bit 四级模式。
实验 B:观察进程虚拟地址
编写程序打印全局、stack、heap 地址,多次运行比较。
预期:现代启用 ASLR 的系统中部分虚拟地址会变化。
实验不能证明:仅打印地址无法直接看到真实 PTE bit 或完整 page walk。
12. 尚未解决的问题
- x86-64 PML4E/PDPTE/PDE/PTE 的完整逐 bit layout 未整理。
- 5-level paging(LA57)未展开。
- ARM64 TTBR 与 4KiB/16KiB page table 模式未展开。
- Apple Silicon/macOS 的具体页大小与页表层级尚未在本笔记核验。
- Linux
struct page的当前字段和不同 union 用途未展开。 - page fault、swap、copy-on-write、mmap、shared memory 尚未系统整理。
- TLB shootdown 与 context switch 的细节未讨论。
13. 自测问题
- 为什么一张 L4 表的 512 个 entry 槽位必须全部存在?
- L4 某个 entry 为 invalid 时,真正省掉了哪些结构?
- 单级页表为什么会产生巨大的元数据开销?
- 多级页表为什么既能直接索引又能稀疏分配?
- CR3 在经典 x86-64 模型中的作用是什么?
- 为什么不同进程可以同时使用相同的虚拟地址?
- 页表格式为什么主要由 CPU/ISA 决定?
- TLB 为什么必要?
参考答案
- 因为 L4 本身就是一张固定 4KiB 数组,512 个 8B entry 正好填满。
- 省掉对应 L3 以及可能进一步存在的 L2/L1 整棵子树。
- 单级数组为了 O(1) 直接索引,必须为所有可能 VPN 保留槽位,即使大多数未使用。
- 地址位直接作为每一级固定数组下标,同时只有用到的上级 entry 才分配下一级表。
- 提供当前地址空间顶级页表的物理地址相关信息,作为 MMU page walk 起点。
- 因为它们拥有不同页表,同一 VA 可以映射到不同 PFN。
- 因为 MMU 硬件直接解析页表,必须遵循硬件规定的 entry bit 和 walk 规则。
- 为缓存 VPN→PFN 结果,避免每次内存访问都完整走多级页表。
14. 一句话总结
多级页表本质上是一棵由 CPU 定义格式、OS 按需构建、MMU 硬件遍历的稀疏 radix tree:顶级表槽位固定存在,但未使用分支无需继续分配,从而兼顾快速索引与稀疏内存占用。