上一篇把地址空间组织成一组 VMA,回答了“这个地址原则上是否属于进程,应该按什么规则处理”。这一篇切到另一层结构:页表。VMA 是内核策略的元数据,页表是 CPU 的 MMU 实际查询的硬件数据结构。两层一致时访问能继续,不一致时进入缺页异常。

核心问题可以压成一句话:

VMA 决定一个地址原则上是否合法,页表决定一个虚拟页此刻能否被 CPU 翻译。

问题从哪里来

很多教材把页表画成“虚拟页号到物理页号的一张大表”。这张表足够回答“地址能不能翻译”,但解释不了几件实际发生的事。

一次 mmap 成功后,VMA 已经登记,可是第一次访问还会触发缺页异常,进程并没有出错。一段映射可能在 maps 里看得到、长期不被访问、/proc/<pid>/pagemappresent=0,进程也没有出错。一个共享文件页可以同时被多个进程访问,每个进程的页表里都有一份 PTE,但物理页只有一份。一个匿名页可能此刻在内存里、PTE present;过一会儿被 swap 出去,PTE 变成 swap 类型;再被访问时通过缺页恢复,PTE 又重新指向 PFN。

把这些现象统一起来,需要把 VMA 和页表彻底分开。VMA 描述区间策略,页表描述硬件可消费的当前翻译状态。VMA 不直接送入 MMU,MMU 也不读 VMA。MMU 只读页表,找不到合法翻译就把异常抛回内核,由内核去查 VMA 才知道该怎么处理。

最小模型

把一次用户态访问压成一条路径:

1
2
3
4
5
virtual address
-> TLB
-> PGD -> P4D -> PUD -> PMD -> PTE
-> PFN
-> physical address

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
policy layer != translation layer

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
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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>

#define PRESENT_BIT (1ULL << 63)
#define SWAPPED_BIT (1ULL << 62)
#define FILE_OR_SHANON (1ULL << 61)
#define SOFT_DIRTY_BIT (1ULL << 55)
#define PFN_MASK ((1ULL << 55) - 1ULL)

static int read_pagemap(int fd, const void *vaddr, uint64_t *out) {
size_t page = (size_t)sysconf(_SC_PAGESIZE);
uintptr_t va = (uintptr_t)vaddr;
off_t offset = (off_t)((va / page) * sizeof(uint64_t));
ssize_t n = pread(fd, out, sizeof(*out), offset);
if (n != (ssize_t)sizeof(*out)) {
return -1;
}
return 0;
}

static void dump(int fd, void *base, size_t npages, const char *stage) {
size_t page = (size_t)sysconf(_SC_PAGESIZE);
printf("[%s]\n", stage);
for (size_t i = 0; i < npages; i++) {
void *vp = (char *)base + i * page;
uint64_t e = 0;
if (read_pagemap(fd, vp, &e) != 0) {
fprintf(stderr, " pread failed at %p: %s\n", vp, strerror(errno));
continue;
}
printf(" va=%p present=%d swap=%d file/shanon=%d soft-dirty=%d pfn=0x%llx\n",
vp,
(e & PRESENT_BIT) ? 1 : 0,
(e & SWAPPED_BIT) ? 1 : 0,
(e & FILE_OR_SHANON) ? 1 : 0,
(e & SOFT_DIRTY_BIT) ? 1 : 0,
(unsigned long long)(e & PFN_MASK));
}
}

int main(void) {
size_t page = (size_t)sysconf(_SC_PAGESIZE);
size_t length = page * 4;

void *base = mmap(NULL, length,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS,
-1, 0);
if (base == MAP_FAILED) {
fprintf(stderr, "mmap failed: %s\n", strerror(errno));
return 1;
}

int fd = open("/proc/self/pagemap", O_RDONLY);
if (fd < 0) {
fprintf(stderr, "open pagemap failed: %s\n", strerror(errno));
munmap(base, length);
return 1;
}

printf("mapped %zu pages at %p (pid=%d)\n", length / page, base, getpid());
dump(fd, base, length / page, "after mmap, no access");

((volatile char *)base)[0] = 1;
dump(fd, base, length / page, "after writing page 0");

((volatile char *)base)[page * 2] = 1;
dump(fd, base, length / page, "after writing page 2");

close(fd);
munmap(base, length);
return 0;
}

编译运行:

1
2
cc -Wall -Wextra -O2 pagemap_demo.c -o pagemap_demo
./pagemap_demo

如果以普通用户运行,PFN 字段会是 0;用 sudo ./pagemap_demo 运行时,写过的页面会显示具体 PFN。两种方式都能观察 present 位的变化。

读取实验结果

预期输出的关键模式是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[after mmap, no access]
va=0x... present=0 swap=0 ... pfn=0x0
va=0x... present=0 ...
va=0x... present=0 ...
va=0x... present=0 ...
[after writing page 0]
va=0x... present=1 ... pfn=0x...(root 下可见)
va=0x... present=0 ...
va=0x... present=0 ...
va=0x... present=0 ...
[after writing page 2]
va=0x... present=1 ...
va=0x... present=0 ...
va=0x... present=1 ...
va=0x... present=0 ...

把这几个观察连起来:

第一,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
2
present=1 -> low bits encode permissions and PFN
present=0 -> low bits encode swap / file / absent semantics

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,频繁读对回收路径不友好。性能问题更适合用 vmstatperfpage_owner 等专门工具。

第五个误解是把 VMA 合法但 PTE 不 present 当成不一致。这是 Linux VM 最典型的正常状态:地址空间已经做出承诺,物理页留给第一次访问触发的缺页路径去兑现。

练习

第一,按文中代码跑 pagemap_demo,分别以普通用户和 sudo 运行,比较 PFN 字段的输出差异。把每个阶段每一页的 presentswappfn 列成表。

第二,把 mmaplength 改成 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,做同样动作,比较差异。

系列导航

参考资料