想象一下:小区里每个住户都收到一个统一的虚拟门牌号(比如”3 栋 2 单元 501”),但真正去敲门时,物业得靠一张住户登记表,把”3-2-501”翻译成”北区第 5 排第 3 块地”这个真实位置。
这篇文章讲的就是这张”登记表”——内存页表(Page Table)。它负责把进程眼里的虚拟地址翻译成真实的物理地址。我会讲清它是什么、怎么用、底层怎么实现,配架构图和时序图,最后带你动手看一眼自己的页表。
〇、一句话和一张全景图
页表是操作系统维护的一张”对照表”,把进程的虚拟地址翻译成真实的物理地址。每个进程各有一份,所以大家能共享同一块物理内存、却互不干扰。
flowchart LR
VA["进程眼里的虚拟地址<br/>连续、独立、是"假地址""]
PT["页表 Page Table<br/>虚拟页号 → 物理页号"]
PA["真实的物理内存<br/>分散的一块块物理页"]
VA -->|"1. 查表翻译"| PT
PT -->|"2. 找到对应物理页"| PA
一、为什么需要页表:从”内存碎片”和”隔离”说起
最早期,程序是直接写死物理地址的:代码里说”把数据放到物理地址 0x1000”,就真的写到 0x1000。这样有三个大问题:
- 容易撞车:两个程序都想用 0x1000,谁先谁后、会不会互相踩,完全没保障;
- 不隔离:一个程序写坏了自己的内存,可能顺带把别的程序也搞崩;
- 有碎片:内存用一段空一段,程序又要求”必须占一段连续的物理内存”,很快就被切得七零八落。
解决办法就是虚拟内存 + 页表:给每个进程一个假地址空间,这个空间看起来又大又连续、而且只属于它自己;真正访问时,再由硬件和操作系统一起,通过页表把假地址翻译成真地址。
这一下同时解决了上面三个问题:隔离(每个进程的假空间互不相通)、不浪费(物理内存可以按页分配、拼起来就行,不要求连续)、按需分配(用到哪页才给哪页,后面会细讲)。
flowchart TB
A["进程 A 访问虚拟地址 0x1000"] --> PTA["进程 A 的页表"]
PTA -->|"0x1000 → 物理页 200"| MEM["物理内存"]
B["进程 B 也访问虚拟地址 0x1000"] --> PTB["进程 B 的页表"]
PTB -->|"0x1000 → 物理页 500"| MEM
看这张图:两个进程都用虚拟地址 0x1000,但各自的页表把它翻译到了不同的物理页(A 到 200,B 到 500),所以互不干扰——这就是”隔离”的由来,也是”页表负责查找虚拟页号 → 物理页号”最直观的样子。
二、页表长什么样:虚拟页号 → 物理页号
先认识”页”
操作系统把物理内存切成一块块固定大小的小块,每块叫一个页(page),Linux 上通常是 4KB(4096 字节)。分配内存就按”页”为单位给。
地址被分成两段
一个虚拟地址可以拆成两段:
- 高位是”虚拟页号 VPN”:说明”我要访问第几页”;
- 低位是”页内偏移 offset”:说明”在这一页里的第几个字节”。
因为是 4KB = 2¹² 字节,所以 offset 占最低 12 位,剩下的高位全是页号。页表要做的事,就是把”虚拟页号”翻译成”物理页号”(也叫物理页框号 PFN);offset 保持不变,最后拼起来就是物理地址。
flowchart LR
V["虚拟地址"] --> V1["虚拟页号 VPN(高位)"]
V --> V2["页内偏移 offset(低 12 位)"]
V1 -->|"页表查询"| P1["物理页框号 PFN"]
P1 --> PA["物理地址 = PFN + offset"]
V2 --> PA
举一个具体例子:查一次”虚拟页号 → 物理页号”
(先说明:为了先让你看清本质,这里用一张”平表”举例;真实进程用的是下一节的多级页表,但查找思路完全一样。)
整个过程用四条公式就能算清楚:
- 页内偏移:
offset = 虚拟地址 mod 页大小(即取低 12 位) - 虚拟页号:
VPN = 虚拟地址 ÷ 页大小(整数除法,即高位) - 查页表:
PFN = 页表[VPN](用 VPN 当”行号”,去表里找物理页框号) - 拼出物理地址:
物理地址 = PFN × 页大小 + offset
假设某个进程的页表长这样(每一行就是一个”虚拟页号 → 物理页号”的对应关系):
| 虚拟页号 VPN | 物理页框号 PFN | 存在位 present |
|---|---|---|
| 0 | 100 | 1 |
| 1 | 7 | 1 |
| 2 | 88 | 1 |
| 3 | 41 | 0 |
表里三列分别是什么、有什么用:
- 虚拟页号 VPN:是”查表的入口”,也就是虚拟地址高位那一截。把它当成行号——程序要访问某个虚拟页,就拿着这个 VPN 去表里找对应的那一行。
- 物理页框号 PFN:是”查表的结果”,表示这个虚拟页被放在物理内存的第几页(第几框)。查到它之后,拼上低 12 位的 offset,就得到真正的物理地址。
- 存在位 present:是一个开关。1 表示这一页真的在物理内存里,可以直接访问;0 表示这一页还没分配(或被换出到磁盘了),一旦去访问它,就会触发后面”缺页异常”那一节讲的事情。
所以这张表的读法是:拿着 VPN 当行号 → 找到对应的 PFN → 再看 present 是不是 1。上面的例子里,VPN=3 那行 present=0,意味着第 3 个虚拟页目前还不能访问(访问就会缺页)。
现在程序要访问虚拟地址 0x1234(十进制 4660):
- 先拆:
VPN = 4660 ÷ 4096 = 1,offset = 4660 mod 4096 = 564(即0x234); - 拿着
VPN = 1去页表查第 1 行,得到PFN = 7; - 最后拼起来:
物理地址 = 7 × 4096 + 564 = 29236,即0x7234。
注意结果:0x1234 → 0x7234,低 12 位的 0x234 原封不动,只有高位的”页号”从 1 换成了 7——这就是”offset 不变、只换页号”。
flowchart LR
VA["虚拟地址 0x1234<br/>VPN=1,offset=0x234"]
TBL["进程页表<br/>0→100,1→7,2→88,3→41"]
PA["物理地址 0x7234<br/>PFN=7 + offset=0x234"]
VA -->|"用 VPN=1 查第 1 行"| TBL
TBL -->|"查到 PFN=7"| PA
一句话:页表本质就是一张”虚拟页号 → 物理页号”的映射表,像字典一样查。
三、为什么是多级页表:一张大表根本放不下
能不能只做”一张大表”,一个页号对应一行?理论上行,但太浪费了。
假设虚拟地址有 48 位、每页 4KB,那一共可能有 2³⁶(约 680 亿)个页。如果每行条目 8 字节,这张表得有 500GB,而且每个进程都要建一份,大部分行还根本没用到。这显然不现实。
于是用多级页表:像一本大字典分”卷 → 章 → 节”,只把真正用到的部分建出来,没用的部分整块省略。以 x86-64 常见的四级页表为例,地址里的页号被切成四段,从上往下逐级查:
flowchart TB
ADDR["虚拟页号 VPN 切成四段(每段 9 位)"]
subgraph LEVEL["四级页表"]
direction TB
PGD["① PGD 页全局目录<br/>按第 1 段定位"]
PUD["② PUD 页上级目录<br/>按第 2 段定位"]
PMD["③ PMD 页中级目录<br/>按第 3 段定位"]
PTE["④ PTE 页表项<br/>按第 4 段定位到最终物理页"]
end
ADDR --> PGD --> PUD --> PMD --> PTE
PTE --> PA["得到物理页框号 PFN<br/>再拼上 offset 就是物理地址"]
再举一个真实的多级查找例子。48 位虚拟地址被切成五段,假设每段取值如下:
虚拟地址(48 位):
[ PGD 索引 9 位 | PUD 索引 9 位 | PMD 索引 9 位 | PTE 索引 9 位 | offset 12 位 ]
[ 0 | 1 | 2 | 3 | 0x567 ]
CPU 里的 CR3 寄存器存着 PGD 表的物理地址,一切从它开始,一级一级往下跳:
- 读 CR3 找到 PGD 表,用
PGD 索引 = 0取出第 0 个条目,里面存着 PUD 表的物理地址; - 到 PUD 表,用
PUD 索引 = 1取出第 1 个条目,里面存着 PMD 表的物理地址; - 到 PMD 表,用
PMD 索引 = 2取出第 2 个条目,里面存着 PTE 表的物理地址; - 到 PTE 表,用
PTE 索引 = 3取出第 3 个条目,里面存着最终物理页框号 PFN(假设 =0xABCDE); - 拼出物理地址:
物理地址 = PFN × 4096 + offset = 0xABCDE × 4096 + 0x567 = 0xABCDE567。
flowchart TB
CR3["CR3 寄存器"] --> PGD2["PGD 表(第 0 项)"]
PGD2 -->|"第 0 项存着 PUD 地址"| PUD2["PUD 表(第 1 项)"]
PUD2 -->|"第 1 项存着 PMD 地址"| PMD2["PMD 表(第 2 项)"]
PMD2 -->|"第 2 项存着 PTE 地址"| PTE2["PTE 表(第 3 项)"]
PTE2 -->|"第 3 项存着 PFN=0xABCDE"| PA2["物理地址<br/>PFN×4096 + offset<br/>= 0xABCDE567"]
每一级都只做”取一个下标、跳到下一张表”,直到最后一级才真正拿到物理页号——这就是真实进程页表查一次地址的完整过程。
好处很明显:没被用到的中间目录整棵不建,每个进程的页表小得多;缺的只是”多查几级”的时间成本,而这点成本被下一节的 TLB 大幅抵消。
四、一次地址翻译的完整过程(时序图)
CPU 每次访问内存,都要经历下面这个过程。注意主角是 MMU(内存管理单元)——页表查询由它这个硬件来做,操作系统只在”缺页”时介入:
sequenceDiagram
autonumber
participant CPU as CPU
participant TLB as TLB 快表
participant MMU as MMU
participant OS as 操作系统内核
CPU->>TLB: 访问内存:先拿虚拟地址查 TLB
alt TLB 命中
TLB-->>CPU: 直接返回物理地址(最快)
else TLB 未命中
TLB->>MMU: 让 MMU 逐级查页表
MMU->>MMU: PGD → PUD → PMD → PTE
alt 页存在(present = 1)
MMU-->>CPU: 拼出物理地址,并回填 TLB
else 页不存在(缺页)
MMU->>OS: 触发缺页异常 page fault
OS->>OS: 分配一页物理内存、填好页表项
OS-->>MMU: 处理完成
MMU-->>CPU: CPU 重新执行刚才那条指令
end
end
五、TLB:翻译过程的”加速缓存”
从上图能看出,一次完整的页表查询要连查四级、每级都是一次内存访问,太慢了。于是 CPU 里放了一个高速小缓存,叫 TLB(Translation Lookaside Buffer,快表),专门记住”最近用过的虚拟页 → 物理页”的翻译结果。
大部分内存访问都会先命中 TLB,不用真的去走四级页表。这就像查字典前先看手边的”生词小本本”:查过的词都记在小本本上,下次直接翻小本本,快得多。
flowchart LR
CPU["CPU 要访问内存"] --> TLB["查 TLB"]
TLB -->|"TLB 命中"| FAST["直接拿到物理地址(快)"]
TLB -->|"TLB 未命中"| SLOW["逐级查页表<br/>得到物理地址后,顺手存进 TLB"]
FAST --> MEM["物理内存"]
SLOW --> MEM
一句话:先查 TLB,命中就直接用;没命中才去查多级页表,查完再回填 TLB,下次访问同样的地址就快了。
六、缺页异常与按需分配:为什么”碰一下”才真正给内存
页表里的每一项(PTE)都带几个标志位,最关键的是 present(存在位):1 表示这一页已经在物理内存里,0 表示”还没分配”或”被换出到磁盘了”。
当 CPU 访问到一个 present=0 的页,就触发缺页异常(page fault)。内核被叫进来,现场分配一页物理内存、把页表项填好、再让 CPU 重新执行刚才的指令——这次就能访问了。整个过程对程序是透明的,程序根本不知道自己”等了一下”。
flowchart TB
CPU["CPU 访问某虚拟地址"] --> CHK["查页表,看 present 位"]
CHK -->|"present = 1"| OK["正常访问物理内存"]
CHK -->|"present = 0"| FAULT["触发缺页异常 page fault"]
FAULT --> OS["内核分配一页物理内存"]
OS --> FILL["把物理页框号填进页表项<br/>并把 present 置 1"]
FILL --> RETRY["CPU 重新执行刚才那条指令"]
RETRY --> OK
这张图就是一次缺页的完整闭环:查页表发现 present=0 → 缺页异常 → 内核分配物理页、填页表 → CPU 重新执行,这次就成功了。
这就是按需分配(demand paging):mmap 申请一大块内存时,操作系统常常只是”登记”了这段虚拟地址,一个物理页都没真给;等你第一次读/写某个页,才通过缺页异常真正分配。好处是不用的页不占内存。
七、页表什么时候创建?谁在维护它?
先纠正一个常见误解:页表不是”操作系统一直只有一张”,也不是”程序启动时一次性建好一整张”。准确说是两类:
- 内核自己的页表:开机(内核启动)时就建立,映射内核代码、数据和全部物理内存,系统运行期间一直存在;
- 每个进程的页表:在进程创建时由内核新建,之后随运行动态增补,进程退出时销毁。
具体到时间点,进程的页表是这样来的:
fork()创建进程时:内核给子进程建一张新页表,先复制父进程的页表(很多页用”写时复制 copy-on-write”先共享,真正被写时才各自复制一份);exec()换成新程序时:内核给这个进程换一张新页表,按 ELF 可执行文件重新映射代码段、数据段、栈、堆等;- 运行过程中:
mmap申请内存、栈/堆增长、发生缺页时,页表被一页一页地填上——这正是上一节”按需分配”,所以页表从创建起就一直在变,不是一次建好的; - 进程退出时:这张页表被销毁,属于它的物理页被回收。
一句话记忆:“内核的页表开机就有;每个进程的页表 fork/exec 时建、运行中动态填、退出时销毁。”
flowchart TB
BOOT["开机:内核页表建立(一直存在)"] --> FORK["fork:复制父进程页表,建出子进程页表"]
FORK --> EXEC["exec:按新程序重建页表"]
EXEC --> RUN["运行中:mmap / 栈堆增长 / 缺页<br/>页表被一页页填上"]
RUN --> EXIT["进程退出:页表销毁、物理页回收"]
八、怎么用 / 怎么观察:亲手看一眼页表在起作用
页表是操作系统内部自动维护的,程序员一般不直接”操作”它,但可以观察它。先看两个现成命令:
getconf PAGE_SIZE # 查看系统页大小(Linux 上通常是 4096 字节 = 4KB)
cat /proc/self/maps | head -n 5 # 查看当前进程自己的虚拟内存映射表
/proc/self/maps 每一行是一条映射,例如 00400000-00452000 r-xp ... /usr/bin/cat:最左边是虚拟地址范围,第二位是权限(r=读、w=写、x=执行、p=私有)。这些”看起来连续”的区间,背后正是靠页表映射到物理内存的。
再写一个 C 程序,直接看到”按需分配”的效果——mmap 只预留虚拟地址,真正碰某页才触发缺页、分配物理内存:
#include <stdio.h> // printf、perror
#include <sys/mman.h> // mmap、munmap、MAP_ANONYMOUS、MAP_FAILED
#include <unistd.h> // sysconf、_SC_PAGESIZE
#include <sys/resource.h> // getrusage、struct rusage
int main(void) {
long pagesize = sysconf(_SC_PAGESIZE); // 取系统页大小(通常 4096 字节)
size_t total = (size_t)pagesize * 4; // 一共映射 4 个页的大小
// 匿名映射 4 页:这一步只是"预留"虚拟地址,还没真正分配物理内存
// MAP_ANONYMOUS 表示不关联文件;MAP_PRIVATE 表示私有;fd 用 -1
char *p = mmap(NULL, total, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (p == MAP_FAILED) { perror("mmap"); return 1; } // 映射失败就报错退出
printf("页大小=%ld,映射了 %zu 字节\n", pagesize, total);
printf("虚拟地址起始=%p\n", (void *)p); // 打印这段内存的虚拟地址
// 记录动手前后的缺页次数(minor 缺页 + major 缺页)
struct rusage before, after;
getrusage(RUSAGE_SELF, &before);
p[0] = 'a'; // 第一次碰第 1 页 → 触发一次缺页,内核才分配物理页
p[pagesize] = 'b'; // 第一次碰第 2 页 → 再触发一次缺页
p[pagesize * 3] = 'd'; // 碰第 4 页 → 触发缺页;第 3 页没碰过,不分配
getrusage(RUSAGE_SELF, &after);
long faults = (after.ru_minflt - before.ru_minflt)
+ (after.ru_majflt - before.ru_majflt);
printf("刚才触发缺页次数约=%ld 次\n", faults); // 大约等于 3(碰了几个不同的页就缺几次)
munmap(p, total); // 解除映射,释放这段虚拟地址
return 0; // 程序正常结束
}
编译运行:gcc pagefault.c -o pagefault && ./pagefault。你会看到缺页次数大约等于 3——正是我们碰过的 3 个不同的页;第 3 页(下标 pagesize*2)从头到尾没碰过,所以一次缺页都没为它发生。这就是”用到哪页、才给哪页”。
九、总结
把整篇文章收成几句话:
- 虚拟内存给每个进程一个独立、连续的假地址空间,页表负责把假地址翻译成真地址;
- 页表本质是”虚拟页号 → 物理页号“的映射,地址 = 页号(高位)+ 页内偏移(低 12 位);
- 因为全量页表太大,所以用多级页表(x86-64 是 PGD/PUD/PMD/PTE 四级),只建用到的部分;
- 翻译时先查 TLB 快表,未命中再逐级查页表;查不到就触发缺页异常,内核按需分配物理页;
- 这套机制一石三鸟:隔离、不浪费、按需分配。
理解了这套框架,再去看 mmap、/proc/pid/maps、或者 Linux 内核里 vm_area_struct 和四级页表的结构,就会觉得豁然开朗——它们不过是在把这套”翻译字典”做得更高效、更精细而已。