页表:CPU 能读懂的翻译结构
上一篇把地址空间组织成一组 VMA,回答了“这个地址原则上是否属于进程,应该按什么规则处理”。这一篇切到另一层结构:页表。VMA 是内核策略的元数据,页表是 CPU 的 MMU 实际查询的硬件数据结构。两层一致时访问能继续,不一致时进入缺页异常。
核心问题可以压成一句话:
VMA 决定一个地址原则上是否合法,页表决定一个虚拟页此刻能否被 CPU 翻译。
问题从哪里来
很多教材把页表画成“虚拟页号到物理页号的一张大表”。这张表足够回答“地址能不能翻译”,但解释不了几件实际发生的事。
一次 mmap 成功后,VMA 已经登记,可是第一次访问还会触发缺页异常,进程并没有出错。一段映射可能在 maps 里看得到、长期不被访问、/proc/<pid>/pagemap 报 present=0,进程也没有出错。一个共享文件页可以同时被多个进程访问,每个进程的页表里都有一份 PTE,但物理页只有一份。一个匿名页可能此刻在内存里、PTE present;过一会儿被 swap 出去,PTE 变成 swap 类型;再被访问时通过缺页恢复,PTE 又重新指向 PFN。
把这些现象统一起来,需要把 VMA 和页表彻底分开。VMA 描述区间策略,页表描述硬件可消费的当前翻译状态。VMA 不直接送入 MMU,MMU 也不读 VMA。MMU 只读页表,找不到合法翻译就把异常抛回内核,由内核去查 VMA 才知道该怎么处理。
最小模型
把一次用户态访问压成一条路径:
1 | |
virtual address 是程序里的指针。CPU 拿到这个地址后先查 TLB,命中就直接得到物理页号。TLB 没命中,硬件或微码走一遍页表,这一步叫 page table walk。
中间几层是 Linux 通用代码的抽象层级。当前内核源码以五级页表为参照书写:PGD、P4D、PUD、PMD、PTE。官方文档把这条层级链描述为“currently five levels in height”,并提醒架构代码会把不存在的层级折叠掉。
PFN 是物理页帧号,物理页号乘上页大小再加上页内偏移,就是这次访问的物理地址。从 CPU 角度,硬件流程到这一步就结束了。
这张图里没有 VMA,因为 MMU 不读 VMA。VMA 只在两种场景上场:一是系统调用建立或修改映射时,二是缺页异常发生时,内核拿着出错地址回 mm_struct 查找覆盖该地址的 VMA。日常访问路径上,VMA 不参与。
五级抽象和折叠
Linux 通用 MM 代码把页表抽象成五层:
| 层 | 类型 | 角色 |
|---|---|---|
| PGD | pgd_t |
Page Global Directory,地址空间顶层;进程的 PGD 通过 mm_struct 找到 |
| P4D | p4d_t |
Page Level 4 Directory,引入是为了适配真正的五级硬件页表 |
| PUD | pud_t |
Page Upper Directory,引入是为了适配四级硬件页表 |
| PMD | pmd_t |
Page Middle Directory,紧邻 PTE 上一层 |
| PTE | pte_t |
最底层页表,含 PFN 和权限/状态位 |
这里有一个常被忽略的细节:源码里 pte 的名字虽然单数,但它表示的是最底层那张页表本身,里面装着 PTRS_PER_PTE 个表项,不是“一个 PTE 条目”。同名指代两件事——一张表和表里的一项——读源码时要按上下文区分。
硬件页表层数因架构而不同。多数 x86_64 系统当前实际是四级页表,部分较新平台启用五级页表;32 位系统层级更少。Linux 用“折叠”处理这种差异:当某一层没有意义时,对应的访问函数在编译期直接跳过这一层,落到下一层。通用代码因此可以始终按五层书写而不出错。
“折叠”是抽象代码和硬件实现解耦的一种手段。它保证了一个事实:在源码里看到“访问 P4D”不等于硬件真的多走一次内存,这次访问可能被折叠成空动作。
大页则是另一个方向。官方文档明确指出,高层页表可以直接映射一段更大的物理区间,不再下钻到 PTE。PMD 直接映射 2MB 区间是常见的 THP/HugeTLB 情况,PUD 直接映射 1GB 区间是 1G huge page 的情况。这意味着页表 walk 可以在 PMD 或 PUD 层提前结束。
模式提炼:策略层不等于翻译层
1 | |
VMA 是策略层。它回答的是“这段地址是否属于进程、来自哪里、按什么规则处理”,决定的是“原则上”。页表是翻译层。它回答的是“这个虚拟页此刻是否能被 MMU 翻译,权限和 PFN 是什么”,决定的是“此刻”。
| 维度 | VMA | 页表 |
|---|---|---|
| 谁查 | 内核(系统调用、缺页) | MMU(每次访问) |
| 粒度 | 区间 | 页 |
| 信息 | 来源、操作方法、文件偏移 | PFN、present、权限、accessed、dirty |
| 缺失含义 | 地址不合法 | 触发缺页,需要进一步判断 |
| 修改时机 | mmap / mprotect / munmap |
fault 路径、回收、swap、COW |
把这两层混着读源码,会觉得页表的字段非常少。把它们分开读,每一层只承担属于自己的职责。
PTE 不只是 PFN
PTE 容易被误解成“只是一个物理页号”。Linux page tables 文档对 pteval_t 的描述非常简短,但已经说明它通常是一个 32 位或 64 位的值,高位是 PFN,低位是一组架构相关的位,包含至少权限和脏位。
不同架构定义的低位字段不同,常见的概念性字段大致是:
| 概念字段 | 含义 |
|---|---|
| present | 该 PTE 是否描述一个当前可翻译的物理映射 |
| writable | 这一次翻译是否允许写入 |
| user | 用户态访问是否被允许 |
| accessed | 自上次清零以来是否被 CPU 访问过 |
| dirty | 自上次清零以来是否被写过 |
| NX / XN | 该映射是否禁止执行 |
| huge / large | 该项是否直接映射大页 |
具体 bit 位置随架构变化。任何想引用具体 bit 编号的写法,必须落到目标架构的源码上,例如 arch/x86/include/asm/pgtable_types.h。一般概念解释优先看 Linux page tables 文档,避免在跨架构层面写死位号。
PTE 不只表示“已经映射”这一种状态。它还可以表示:
- 当前页面在 swap 中。此时 PTE 的内容编码了 swap 类型和 swap 偏移。
- 当前页面是文件支持的,但暂时不在内存。需要走 fault 路径通过 page cache 取回。
- 当前页面是 COW 共享,只读权限。任意一方写入会触发写保护异常。
- 当前页面被标记为 uffd-wp 或 soft-dirty 等内核机制使用的特殊状态。
页表项是一种紧凑编码格式,相同的 64 位里既能装 PFN,也能装 swap 描述符。这是为什么 present 位的语义必须先看:present=1 时低位按权限/状态位读,present=0 时低位的语义完全换一组。
一个最小实验:mmap 与 /proc/self/pagemap
/proc/<pid>/pagemap 是内核暴露给用户态观察自身页表的接口。每个虚拟页对应一个 64 位条目。当前官方 pagemap 文档给出的位布局如下(节选最常用的几位):
| 位 | 含义 |
|---|---|
| 63 | page present |
| 62 | page swapped |
| 61 | page is file-page or shared-anon(since 3.5) |
| 56 | page exclusively mapped(since 4.2) |
| 55 | pte is soft-dirty |
| 0–54 | 若 present,是 PFN;若 swapped,则是 swap 类型与偏移 |
注意权限相关的事实。自 Linux 4.0 起,PFN 字段只对持有 CAP_SYS_ADMIN 的调用方可见,原因是文档明确写出的“information about PFNs helps in exploiting Rowhammer vulnerability”。4.0 到 4.1 之间,普通用户连 open() 都会拿到 -EPERM;4.2 之后接口照常打开,但 PFN 字段被零化。present、swapped、soft-dirty 这些状态位通常仍可观察。
下面这个程序映射 4 页匿名内存,查看初始 pagemap 条目,再依次写入若干页面,观察 present 位和 PFN 字段如何变化。
1 | |
编译运行:
1 | |
如果以普通用户运行,PFN 字段会是 0;用 sudo ./pagemap_demo 运行时,写过的页面会显示具体 PFN。两种方式都能观察 present 位的变化。
读取实验结果
预期输出的关键模式是:
1 | |
把这几个观察连起来:
第一,VMA 已经覆盖了 4 页虚拟地址区间,maps 里能看到一段连续匿名映射。但 pagemap 中所有 4 页的 present 都是 0,说明此时还没有为这些虚拟页建立 PTE 翻译。
第二,写入第 0 页后,仅这一页 present=1。这是缺页路径走了一遍 do_anonymous_page,分配匿名页并把它装进 PTE。其他 3 页继续保持 present=0。
第三,跳过第 1 页写第 2 页,第 2 页变成 present=1,第 1 页和第 3 页仍然是 0。VMA 覆盖范围里的页面是按访问按需兑现的,不是 mmap 时一次性建好。
第四,PFN 字段在普通用户下读到 0,并不代表物理页不存在。present=1 已经表明 PTE 已经指向某个物理页,只是 PFN 数值被内核屏蔽。判断“是否已经物理兑现”用 present 位即可,不需要看 PFN。
第五,没有写过的页面 present=0,不等于 VMA 不合法。VMA 合法但 PTE 不 present,这正是缺页异常作为延迟兑现入口的典型状态。
如果实验在打开了 swap 的机器上跑足够长时间或制造内存压力,部分页面可以从 present=1 切到 swapped=1,此时 PFN 字段位置改成 swap 类型和偏移。swap 主题后续单独成篇,这一篇只需要把状态二分先建立起来。
模式提炼:present 决定语义层
1 | |
PTE 是一段紧凑编码。同样 64 位的内容,present 位不同时,低位的解释规则完全不同。读 PTE 永远要先看 present,再决定怎么解释剩下的位。
| present | 低位含义 |
|---|---|
| 1 | PFN + 权限位 + accessed/dirty 等状态位 |
| 0 + swapped=1 | swap 类型与偏移 |
| 0 + swapped=0 | 当前没有任何映射;可能是从未访问、munmap 后或回收后未恢复 |
源码锚点
读这一层时不需要追完所有 fault 路径。先确认几个文件位置和函数入口:
| 入口 | 读它的目的 |
|---|---|
Documentation/mm/page_tables.rst |
五级抽象、折叠规则、pteval_t 描述 |
Documentation/admin-guide/mm/pagemap.rst |
pagemap 位布局和 PFN 可见性规则 |
mm/memory.c |
handle_mm_fault() 等 fault 总入口 |
mm/pagewalk.c |
通用页表遍历框架 |
fs/proc/task_mmu.c |
/proc/<pid>/pagemap 实现 |
arch/x86/include/asm/pgtable_types.h |
x86 PTE bit 定义 |
读 task_mmu.c 时可以带着一个问题前进:用户态读 pagemap 的一次 pread,在内核里是怎样走通 mm 锁、页表 walk 和 PFN 屏蔽这三个动作的。这条路径会把上一篇的 VMA 锁、本篇的页表 walk 和缺页异常涉及的下一篇内容串成一条线。
研究生迁移表
| Linux VM | 一般系统设计 |
|---|---|
| VMA | 策略元数据:来源、权限、操作方法 |
| 页表 | 执行期查表结构 |
| PTE | 执行期缓存/索引记录 |
| TLB | 上一层结构的硬件缓存 |
| 页表折叠 | 通用抽象在不同实现下编译期消解 |
| present 位 | 联合体辨别字段,决定低位语义 |
| page fault | 缓存未命中后的补全路径 |
“策略层 + 翻译层”的二分远不只出现在内核。文件系统里有 inode 与 dentry cache,数据库里有逻辑计划与执行计划,编译器里有 AST 与编译后的代码。每一对都共享同一种结构:一层负责长期不变的语义,另一层负责被频繁查询和动态修改。
常见误解
第一个误解是把页表 walk 当成 CPU 唯一的内存访问路径。事实上 TLB 命中时不走任何一层页表,性能曲线很大程度由 TLB 行为决定,huge page 的价值之一就是减少 TLB miss。
第二个误解是把“五级页表”当成所有硬件都有五层。Linux 用五级抽象写通用代码,但 P4D 和 PUD 在多数平台被折叠。源码里看到访问 P4D,未必对应硬件一次内存访问。
第三个误解是认为 PTE 只编码物理映射。present=0 时同一个 PTE 字段可以编码 swap 描述符或其他状态,必须按 present 位分流。
第四个误解是认为 pagemap 是性能调优工具。它是观察接口,PFN 在普通用户下被屏蔽,读它本身要走 mm 锁和页表 walk,频繁读对回收路径不友好。性能问题更适合用 vmstat、perf、page_owner 等专门工具。
第五个误解是把 VMA 合法但 PTE 不 present 当成不一致。这是 Linux VM 最典型的正常状态:地址空间已经做出承诺,物理页留给第一次访问触发的缺页路径去兑现。
练习
第一,按文中代码跑 pagemap_demo,分别以普通用户和 sudo 运行,比较 PFN 字段的输出差异。把每个阶段每一页的 present、swap、pfn 列成表。
第二,把 mmap 的 length 改成 16 页,写入第 0、4、8、12 页,观察 present 是不是只在被访问的页上变成 1。
第三,把匿名映射换成 MAP_PRIVATE | MAP_ANONYMOUS | MAP_POPULATE,重新运行,比较是否在 mmap 后所有页就已经 present。读 mmap(2) 手册中关于 MAP_POPULATE 的描述,解释 present 差异的来源。
第四,阅读 Documentation/admin-guide/mm/pagemap.rst 中 kpageflags 部分,把里面提到的 COMPOUND_HEAD/COMPOUND_TAIL 和 BUDDY 位结合 huge page 主题画一张状态图。这一张图会成为后续 folio 和 buddy 篇的入口。
第五,在 x86_64 机器上查 arch/x86/include/asm/pgtable_types.h,把概念层的 present、writable、user、accessed、dirty、NX 各对应到 _PAGE_* 宏,记录对应的 bit 位。再换一种架构,例如 arch/arm64/include/asm/pgtable-hwdef.h,做同样动作,比较差异。
系列导航
- 上一篇:地址空间不是数组:
mm_struct和vm_area_struct - 本文:页表:CPU 能读懂的翻译结构
- 下一篇:缺页异常:一次访问如何进入内核

