Appearance
地址空间与分页
- 写作时间:
2026-03-04 首次提交,2026-07-13 最近修改 - 当前字符:
18867
线程、同步原语与内核同步机制讨论了多个执行流怎样共享数据并避免竞态,但一直默认了一件更底层的事情:线程能读写变量,进程有自己的地址空间,锁保护的是一块明确可寻址的内存。现在要再往下追一层:这些地址本身是从哪里来的?
要回答这些问题,需要追溯内存管理的演进。程序中的地址在编译、加载或运行时与内存位置建立关联,这个过程叫地址绑定。最简单的运行时方案是给每个进程分配一块连续的物理内存,这就是连续内存分配,但它会产生无法利用的碎片。分段允许程序的不同部分分散存放,但仍然没有消除外部碎片。分页以固定大小为单位管理内存,消除了外部碎片,却要为每个虚拟页记录映射。若把整张页表平铺展开,存储量可能超过物理内存本身;多级页表只为实际出现映射的地址范围分配下级页表页,使开销随地址空间的使用范围增长。
内存管理这一章会沿着这条问题链继续展开。本课先建立地址空间与分页的基本模型,回答虚拟地址怎样被翻译成物理地址。页表骨架建立后,虚拟内存再讨论缺页、回收和页面替换这些动态行为;内存映射把文件与匿名内存放进同一套地址空间语义;内核内存分配则转向内核自身,说明页帧、对象和特殊内存区域由谁分配。
地址绑定
地址绑定(address binding)是把程序使用的符号地址或相对地址,逐步确定为运行时可访问地址的过程。
源代码先用变量名和函数名表示位置,编译器和链接器再把它们转换成数字地址或待修正的引用,加载器把可执行文件放进进程地址空间,CPU 最终还要把虚拟地址翻译成物理地址。教材把“最终内存位置何时确定”概括为编译时、加载时和运行时三种绑定;现代 Linux 往往同时经历链接、加载和 MMU 翻译,因此必须先分清每一层绑定的是哪种地址。
编译时绑定(compile-time binding)要求构建工具在生成程序时就知道它将占用的最终内存位置,并把相应绝对地址写进指令或数据。若装入位置改变,程序必须重新构建。它适合地址布局固定的固件和简单专用系统,却无法让多个都假定相同物理位置的程序同时驻留。
现代 Linux 可执行文件中的固定地址不是物理地址。位置无关可执行文件(Position-Independent Executable, PIE)允许加载器选择装入位置;下面先显式关闭 PIE,用生成的非 PIE 程序观察“固定虚拟布局”1:
c
// hello.c
#include <stdio.h>
int main(void) {
printf("hello\n");
return 0;
}下面的构建与反汇编采用 x86-64 工具链。在项目根目录执行 just linux-x86 可以进入专用容器,项目会挂载到容器内的 /project。readelf 用来读取 ELF 文件头,objdump -d 则把机器指令反汇编成人能阅读的汇编指令:
bash
$ gcc -O0 -fno-pie -no-pie -o /tmp/hello_exec course/memory/code/hello.c
$ readelf -h /tmp/hello_exec | grep 'Type:'
Type: EXEC (Executable file)
$ objdump -d /tmp/hello_exec | grep -A5 '<main>:'
0000000000401126 <main>:
401126: 55 push %rbp
401127: 48 89 e5 mov %rsp,%rbp
40112a: bf 04 20 40 00 mov $0x402004,%edi
40112f: e8 fc fe ff ff call 401030 <puts@plt>
401134: b8 00 00 00 00 mov $0x0,%eaxELF 类型 EXEC 表示链接器已经为这个文件确定了固定虚拟布局,main 的链接时虚拟地址是 0x401126。加载器需要把相应段映射到进程中的这些虚拟地址,否则绝对引用无法成立。但页表仍可把 0x401126 映射到任意合适的物理页帧,所以这段输出只能证明虚拟地址固定,不能证明程序在编译时绑定到了物理地址。
加载时绑定(load-time binding)允许构建工具保留可重定位的地址引用,由加载器选定装入位置并完成重定位(relocation),也就是按实际位置修正需要调整的引用。装入完成后,程序若没有运行时地址翻译支持,仍不能随意搬到另一个位置。
Linux 的 PIE 可以展示“虚拟布局可在加载时选择”这一层区别,但它仍会同时使用 MMU 做运行时的虚拟到物理翻译:
bash
$ gcc -O0 -fPIE -pie -o /tmp/hello_pie course/memory/code/hello.c
$ readelf -h /tmp/hello_pie | grep 'Type:'
Type: DYN (Position-Independent Executable file)
$ objdump -d /tmp/hello_pie | grep -A5 '<main>:'
0000000000001139 <main>:
1139: 55 push %rbp
113a: 48 89 e5 mov %rsp,%rbp
113d: 48 8d 05 c0 0e 00 00 lea 0xec0(%rip),%rax
1144: 48 89 c7 mov %rax,%rdi
1147: e8 e4 fe ff ff call 1030 <puts@plt>PIE 在 ELF 中使用 DYN 类型。objdump 显示的 0x1139 是相对于装入偏移(load bias)理解的链接时虚拟值,不是物理地址。加载器为文件选择装入偏移,实际 main 虚拟地址由装入偏移与该值共同确定。大部分位置无关代码使用相对寻址,仍有需要加载器处理的动态重定位项。地址空间布局随机化(Address Space Layout Randomization, ASLR)还可以在不同运行中选择不同装入位置。
固定虚拟地址为什么仍属于运行时绑定?
非 PIE 中的 0x401126 已在链接时固定,容易让人误以为物理位置也已确定。但 ELF 链接器与 MMU 处理的是两个不同映射:前者确定进程看到的虚拟布局,后者在每次访问时把这个虚拟地址翻译成物理地址。
ELF 文件头中的 e_type 告诉加载器文件采用哪种装入模型:
bash
$ readelf -h /tmp/hello_exec | grep 'Type:'
Type: EXEC (Executable file)
$ readelf -h /tmp/hello_pie | grep 'Type:'
Type: DYN (Position-Independent Executable file)EXEC 要求按既定虚拟布局装入,DYN 允许加载器结合装入偏移解释段和符号地址。两种程序运行后都只向 CPU 发出虚拟地址,页表可以把同一个虚拟页重新指向另一个物理页帧,而不修改程序中的指针值。因此,固定虚拟地址与运行时物理绑定可以同时成立。
运行时绑定(run-time binding)让 CPU 执行期间产生逻辑地址,并由硬件依据当前映射把它翻译成物理地址;映射可以在程序运行过程中改变。现代 Linux 通常把逻辑地址称为虚拟地址(virtual address)。
虚拟地址是 CPU 执行普通访存指令时使用的地址。用户程序中的指针值、函数地址和全局变量地址都位于它的虚拟地址空间。物理地址(physical address)是地址翻译后交给处理器内存系统的地址;普通 RAM 访问最终由内存控制器把它送往相应存储位置,物理地址空间也可以包含设备的 MMIO 区域。两者不是同一个东西:程序看到的地址 0x401126 可以映射到完全不同的物理位置。
特权边界介绍过内存管理单元 MMU。CPU 发出虚拟地址后,MMU 根据当前地址空间的映射把它翻译成物理地址,并在翻译过程中检查访问权限,然后用物理地址访问内存。这个过程对普通程序透明,程序不需要知道自己的虚拟地址对应哪个物理地址。
CPU ──虚拟地址──→ [ MMU ] ──物理地址──→ 物理内存
↑
映射表MMU 的映射表由操作系统维护,本课接下来会从连续分配、分段和分页逐步构造这张表。操作系统可以给不同进程配置不同的映射表,这样两个进程使用相同的虚拟地址(比如都在 0x401126 处有 main 函数),但 MMU 会把它们翻译成不同的物理地址,各自指向各自的代码。这就是进程地址空间隔离的硬件基础。
运行时绑定的核心优势在于:操作系统可以在程序运行过程中调整映射关系。要迁移一页数据,内核复制内容、更新页表并使旧的地址翻译缓存失效,进程仍使用同一个虚拟地址。进程生命周期介绍的 COW 也利用这个能力:内核不改变虚拟地址,只在写入发生时改变物理页的共享关系。
为什么通用操作系统选择运行时绑定?
编译时绑定要求预知加载地址,难以让装入位置未知或变化的多个程序灵活共存。加载时绑定允许灵活加载,但程序运行后不能移动。只有运行时绑定允许操作系统在程序运行过程中调整物理内存的使用:可以把暂时不用的页移到磁盘并在访问时取回,可以在 fork() 后共享物理页直到写入才复制,还可以让不同进程映射同一份共享库代码。
运行时绑定的代价是每次内存访问都依赖地址翻译。MMU 用硬件完成页表遍历,CPU 调度介绍的 TLB 再缓存近期翻译,使多数访问无需重新遍历页表。进程隔离、按需分页和共享内存都建立在这套运行时映射能力之上。
地址绑定的三种时机是递进关系:编译时绑定最简单但最不灵活,加载时绑定增加了灵活性但仍有限制,运行时绑定最灵活但需要硬件支持。确定了运行时绑定是现代系统的选择,接下来的问题是:运行时绑定的硬件怎么设计?最简单的方案是给每个进程分配一块连续的物理内存。
连续内存分配
连续内存分配(contiguous memory allocation)是为每个进程分配一块连续的物理内存区域,进程的所有代码和数据都存放在这一块区域之中。
运行时地址翻译的最简单实现是两个硬件寄存器:基址寄存器(base register)和限长寄存器(limit register)。基址寄存器存放进程在物理内存中的起始地址,限长寄存器存放进程的地址空间长度。从物理内存的角度看,进程占据 [base, base+limit) 这段区域;但进程自身看到的虚拟地址从 0 开始,所以硬件只需做两件事:第一,检查虚拟地址是否小于 limit(越界则触发异常);第二,把虚拟地址加上 base,得到物理地址。
翻译规则可以直接写成两个步骤:若 virtual_addr >= limit,本次访问越界,硬件触发异常;否则按 base = 0x10000、limit = 0x5000,两个地址的结果如下:
| 虚拟地址 | 检查 | 结果 |
|---|---|---|
0x1234 | 0x1234 < 0x5000 | 物理地址 0x11234 |
0x6000 | 0x6000 >= 0x5000 | 越界并触发异常 |
这就是运行时绑定的最简单形式。每个进程有自己的 base 和 limit 值,上下文切换时内核切换这两个寄存器。进程 A 的虚拟地址 0x1234 指向物理地址 0x11234,而进程 B 的同一个虚拟地址 0x1234 会指向不同的物理位置,具体取决于 B 的 base 值。
连续分配需要操作系统管理一组大小不等的空闲内存块。当一个新进程需要内存时,操作系统从空闲块中选择一个足够大的分配给它。选择策略有三种。
首次适配(first-fit)从头扫描空闲块列表,分配第一个够大的块。在线性空闲列表中,它找到后即可停止,通常具有较短的查找路径;但靠前的大块容易被小请求反复切分。
最佳适配(best-fit)选择能满足请求的最小空闲块,目标是减少本次分配留下的余量。若空闲块只按地址排列,它需要扫描全部候选;按大小建立索引可以缩短查找,却会增加索引维护成本。它还容易留下难以复用的微小空洞。
最差适配(worst-fit)选择最大的空闲块,希望切分后仍留下可复用的大块。在线性无序列表中,它也需要检查全部候选。
进程不断创建和退出后,内存中会出现很多小的空闲块,散布在已分配的块之间。这些空闲块加起来总量可能足够,但因为不连续,无法满足一个需要大块连续内存的请求。这就是外部碎片(external fragmentation)。
已分配 空闲 已分配 空闲 已分配 空闲
[ A ] [ 8KiB ] [ B ] [ 12KiB ] [ C ] [ 4KiB ]
新进程需要 20KiB 连续内存
空闲总量 = 8 + 12 + 4 = 24KiB > 20KiB
但没有任何一块连续空闲区域 >= 20KiB -> 分配失败外部碎片是连续分配方案的根本缺陷。合并相邻空闲块可以减少空洞数量,不同适配算法也会改变碎片分布,但只要请求大小和释放顺序变化,它们都不能从规则上消除外部碎片。一个直接的补救办法是把已分配块移向一端,将分散空洞合并成大块,这个过程叫紧凑(compaction)。
紧凑前:[A] [空] [B] [空] [C] [空]
紧凑后:[A] [B] [C] [ 大空闲块 ]紧凑的问题是开销很大。移动进程的内存意味着复制大量数据;如果系统使用基址与限长寄存器做运行时绑定,进程内部保存的虚拟地址不变,内核只需更新该进程的 base,而不必逐个改写内部指针。即使如此,复制整块物理内存以及暂停相关访问的成本仍然很高。紧凑只能缓解连续分配的后果,却没有消除“必须连续”这个约束本身。
与外部碎片对应的还有内部碎片(internal fragmentation):分配给进程的内存块大于进程实际需要的大小,多出来的空间在块内部被浪费。把内存切成固定大小单位时仍会出现这种浪费,本课稍后会量化它的上界。
最佳适配为什么不一定最好?
最佳适配的目标是最小化本次分配留下的余量,但这项局部目标不能保证长期碎片最少。
每次分配后切出来的剩余块都很小(因为选的是刚好够大的块)。这些微小碎片太小,几乎不可能被后续请求使用。经过一段时间,内存中充满了这些不可用的碎片。相比之下,首次适配虽然每次可能浪费更多,但剩余块往往足够大,还能满足后续分配。
在最简单的线性空闲列表中,首次适配找到第一个可用块即可停止,最佳适配则要继续比较候选块;若分配器改用按大小组织的树或分级空闲链表,查找成本又会变化。因此,“最佳”只描述这次选择的剩余空间最小,不表示它在速度、长期碎片或所有请求序列上整体最优。
连续分配真正卡住系统的地方,不只是“会产生碎片”,而是“整个进程必须占据一整块连续物理内存”。分段先放松这个约束:把进程拆成代码、数据、栈等逻辑部分,允许各段分别寻找物理位置;但每个段自身仍须连续,所以外部碎片还会保留。
分段
分段(segmentation)是将进程的地址空间按照逻辑功能划分成若干段(如代码段、数据段、栈段),其中每个段可以独立地映射到物理内存的不同位置。
从程序的视角看,地址空间本来就包含用途不同的逻辑区域。以常见的 x86-64 Linux 进程布局为例,低地址通常有存放机器指令的代码段(text)和单独的只读数据区域;后面是已初始化数据段(data)与 BSS,分别保存有初值和零初始化的全局、静态变量;再往上是堆(heap),供运行时动态分配使用,堆边界扩大时通常从低地址向高地址移动;高地址附近是栈(stack),保存函数调用帧、局部变量和返回地址,栈大小增加时,其边界通常从高地址向低地址移动。实际进程还会包含共享库、文件映射和线程栈等区域,这里先保留足以分析分段的主干布局。
低地址
┌────────┐
│ text │ 代码 / 只读常量
├────────┤
│ data │ 已初始化全局变量
├────────┤
│ BSS │ 未初始化全局变量
├────────┤
│ heap │ 大小沿高地址方向扩展
│ ↑ │
│ ... │
│ ↓ │
│ stack │ 大小沿低地址方向扩展
└────────┘
高地址分段的思路正是顺着这种程序语义走:代码、数据、堆、栈不必再挤在同一块连续的物理内存里,而是每个段分别分配、分别保护,边界也可以各自独立扩展。代码段可以放在物理地址 0x10000,数据段可以放在 0x50000,栈段可以放在 0x80000。这样一来,操作系统看到的不再是“给整个进程找一整块连续内存”,而是“给几个逻辑上不同的段分别找位置”。
为了支持分段,硬件维护一张段表(segment table)。段表的每个条目记录一个段的基址(base)和限长(limit)。虚拟地址被拆分为两部分:段号(segment number)和段内偏移(offset)。
段号只回答一个问题:这次访问落在哪一段。段内偏移则回答另一个问题:它是这一段内部的第几个字节。假设代码段的基址是 0x10000,长度是 0x4000,那么虚拟地址 (代码段, 0x1234) 的意思就是“代码段起点之后 0x1234 字节”。硬件先用段号找到代码段的 base 和 limit,再检查 0x1234 < 0x4000 是否成立;如果成立,物理地址就是 0x10000 + 0x1234 = 0x11234。如果 offset 超过了这一段的 limit,说明访问已经跑出了段边界,硬件就会触发异常。
分段之后,不同进程的代码段不需要放在一起。操作系统只需要保证“每个段自己连续”,不需要保证“所有进程的代码段排成一片”。进程 A 的代码段可以在物理地址 0x10000,进程 B 的代码段也可以在 0x90000,中间完全可以夹着别的进程的数据段、栈段或空洞。每个进程都有自己的段表,所以同样的虚拟地址 (0, 0x1234),在 A 里可以翻译到 0x11234,在 B 里也可以翻译到 0x91234。
一张简化段表可以直接表示为:
| 段号 | 逻辑区域 | base | limit | 权限 |
|---|---|---|---|---|
| 0 | 代码段 | 0x10000 | 0x4000 | 读、执行 |
| 1 | 数据段 | 0x50000 | 0x2000 | 读、写 |
| 2 | 栈段 | 0x80000 | 0x8000 | 读、写 |
硬件先检查段号是否指向有效条目,再检查访问类型是否满足权限、offset < limit 是否成立。检查全部通过后,物理地址才等于 base + offset;任一检查失败都会触发异常。这样既完成地址翻译,也把段边界和读、写、执行权限纳入同一次访问检查。
分段相比连续分配有两个优势。第一,每个段可以独立设置保护属性:代码段只读可执行,数据段可读写,栈段可读写不可执行。第二,各段的逻辑大小可以独立变化。不过,段自身仍要求物理连续;堆段扩大时,只有相邻物理空间空闲才能直接提高 limit,否则仍要移动段或重新安排物理位置。
但分段并没有解决外部碎片。每个段仍然是一块连续的物理内存。段的大小因进程而异、因段而异,段的创建和销毁会在物理内存中留下大小不等的空洞,和连续分配面临的碎片问题完全一样。
分段允许进程的不同部分分散存放,但每个段内部仍然连续,外部碎片依然存在。分页把分配单位从大小不一的整段继续缩小为固定大小的块,才从分配规则上消除对连续物理空洞的依赖。
分页
分页(paging)是将虚拟地址空间和物理内存都划分成固定大小的块,通过页表建立虚拟块到物理块之间的映射关系。
分页首先要解决的是分段留下来的那个问题:每个段仍然要求连续,所以外部碎片还在。分页把分配单位从“整段”继续缩小成固定大小的小块。这样一来,操作系统不再需要给代码段、数据段、堆段各找一整块连续物理内存,而只需要给它们包含的每一页分别找一个空闲页帧。
先把三个最基本的对象立住。进程生命周期已经用页解释过 COW;现在把边界补齐:虚拟地址空间中的固定大小块叫页,物理内存中的同样大小块叫页帧(page frame,也叫物理页)。页和页帧大小相同,x86-64 Linux 的基础页通常为 4 KiB。
只把内存切成页还不够。CPU 还必须知道:当前访问的这个虚拟页,到底对应哪个物理页帧。特权边界已经给出页表的最小定义,这里继续展开其结构。抽象的平铺页表以虚拟页号为下标,每个条目叫做页表项(Page Table Entry, PTE),记录该虚拟页映射到哪个物理页帧以及相关的属性标志。
这样分页最核心的直觉就清楚了:虚拟页和物理页帧之间不要求一一按顺序对应。虚拟页 0 可以映射到物理页帧 5,虚拟页 1 可以映射到物理页帧 2。程序看到的连续虚拟地址,在物理内存里完全可以打散存放。页表正是把这种“可以打散但仍然能找到”的关系保存下来的地方。
虚拟地址在硬件层面被拆分为两部分:高位是虚拟页号(Virtual Page Number, VPN),低位是页内偏移(page offset)。以 4 KiB 页为例,低 12 位是页内偏移(
虚拟地址 0x12345678(32 位示例)
├── 高 20 位: 0x12345 → 虚拟页号,查页表
└── 低 12 位: 0x678 → 页内偏移,保持不变
查阅页表发现: 虚拟页 0x12345 → 物理页帧 0xABCDE
物理地址 = 物理页帧号 << 12 | 页内偏移 = 0xABCDE678分页的关键特性是:虚拟页可以映射到任意物理页帧。虚拟地址空间中相邻的两个基础页,在物理内存中可以完全不相邻。对于按单个基础页建立的进程映射,任何空闲页帧都能满足下一页请求,因而不再因缺少一整块进程或段大小的连续空洞而失败。这消除了该模型中的外部碎片,但内核若另行请求多个连续页帧,仍可能受到物理内存碎片影响。
但分页引入了内部碎片。如果一个连续区域只需要 4097 字节(4 KiB + 1 字节),它至少需要两页(8 KiB),最后一页浪费 4095 字节。不过这里和外部碎片有一个关键区别:在这个基础模型里,内部碎片被限制在每个连续区域的最后一页,最多浪费一页减一字节;而外部碎片的问题是,空闲内存总量明明足够,却可能因为不连续而根本分配不出来。分页不是“没有浪费”,而是把浪费变成边界明确的页内空间代价。
每个 PTE 不仅记录物理页帧号,还包含一组控制属性和状态的标志位。Linux 内核在 arch/x86/include/asm/pgtable_types.h 中给这些 x86 位定义了对应常量:
| 标志 | 作用 |
|---|---|
| Present | 当前条目是否提供有效映射 |
| R/W | 映射是只读还是允许写入 |
| U/S | 用户态能否访问,还是仅限内核态 |
| Accessed | CPU 是否访问过该映射 |
| Dirty | CPU 是否通过该映射写入过页面 |
| PS | 上级条目是否直接映射大页 |
| NX | 启用相应硬件能力时,页面是否禁止执行 |
Present 位表示当前页表项是否提供可用映射。特权边界介绍过缺页异常:访问 Present 为 0 的页会触发缺页异常,访问已映射页却违反读写、用户态或执行权限时也会触发同类异常。内核接手后检查虚拟地址及访问方式;若这是可以补齐的合法访问,就建立或修改映射并重新执行故障指令,否则向进程报告错误。虚拟内存会解释合法页为什么可能暂时没有驻留映射。进程生命周期中的 COW 也使用权限故障:fork() 后共享私有页受到写保护,一方写入时触发缺页异常,内核再为写入方准备独立物理页。
Accessed 位和 Dirty 位在 x86 上由 CPU 根据访问自动置位。读写某页会置 Accessed,写入还会置 Dirty。内核可清除 Accessed 后观察它是否再次被置位,以估计页面近期是否活跃;Dirty 则表示内存内容相对后备存储已经改变,回收文件页时可能需要先写回。两者都参与内存管理,但不能把 Dirty 直接解释成“页面是否冷”。
多级页表
多级页表(multi-level page table)是将平铺页表拆分成多层的树形结构,只为实际使用的虚拟地址范围分配页表节点,未使用的范围不分配内存。
要推导这棵树为什么存在,先把 Linux 里最低限度的三个对象立住:
mm_struct:一个进程整套用户地址空间的总描述符。- 虚拟内存区域(Virtual Memory Area, VMA):由
vm_area_struct记录的一段连续虚拟地址范围,说明这段地址允许什么访问、缺页时应按什么后备对象解释。 pgd/p4d/pud/pmd/pte:真正负责“逐级把虚拟页翻译到物理页帧”的页表树。
这三个对象的职责不能混。vm_area_struct 不是页表页,它回答的是“这段地址应不应该存在、权限是什么”;页表树回答的则是“具体到这一页,此刻映射到哪个物理页帧”。Linux 的 mm_struct 里有一个 pgd 指针,指向内核可访问的顶级页表。任务切换到这个地址空间时,内核把顶级页表的物理基址和相关控制位装入 x86 的 CR3 控制寄存器;CR3 是硬件开始页表遍历(page table walk)时读取的根位置记录。
有了这个骨架,问题才能真正摆出来:如果页表真的按“每个虚拟页一个 PTE”平铺展开,它会大到工程上根本不可行。 先做一个抽象计算。假设虚拟地址宽度是 V 位,页大小是 4 KiB,那么页内偏移固定占 12 位,虚拟页号就占 V - 12 位,所以平铺页表需要
现在再代入 x86-64 最常见的那组参数。这里最容易卡住的问题就是:为什么突然冒出“48 位虚拟地址”?
答案不是“Linux 自己选了 48 位”,而是 x86-64 四级页表硬件格式直接推出来的。先看三个事实:
- 基础页大小是 4 KiB,所以页内偏移固定占 12 位。
- 每个页表项是 8 字节,所以一张 4 KiB 的页表页正好能放
4096 / 8 = 512 = 2^9个条目。 - 传统 x86-64 使用 4 级页表,所以一共能提供
9 × 4 = 36位索引。
于是这套硬件格式总共能直接翻译的虚拟地址位数就是:
text
4 级索引 × 每级 9 位 + 12 位页内偏移
= 36 + 12
= 48 位这就是常说的 LA48。它不是“程序只能写 48 位整数”,而是说在这种页表模式下,硬件真正拿来做地址翻译的是 48 位。更高的第 63-48 位不能随便填,它们必须复制第 47 位,这叫规范地址(canonical address)。后来如果硬件开启五级页表(LA57),可用虚拟地址才会再从 48 位扩到 57 位。
所以,下面这笔账用 48 位来算,是因为这里讨论的是常见 x86-64 四级页表机器能够直接翻译的虚拟地址范围。页大小仍然是 4 KiB(
c
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
int main(void)
{
const uint64_t virtual_address_bits = 48;
const uint64_t page_offset_bits = 12;
const uint64_t pte_size = 8;
const uint64_t num_pages =
UINT64_C(1) << (virtual_address_bits - page_offset_bits);
const uint64_t table_size = num_pages * pte_size;
printf("pages: %" PRIu64 ", table size: %" PRIu64 " GiB\n",
num_pages, table_size / (UINT64_C(1) << 30));
return 0;
}bash
$ gcc -O2 -Wall -Wextra -Werror -o /tmp/page_table_size course/memory/code/page_table_size.c
$ /tmp/page_table_size
pages: 68719476736, table size: 512 GiB2^36 个页表项,每个 8 字节,总共 512 GiB。一个进程的平铺页表就超过了大多数机器的物理内存总量。进程生命周期画过“虚拟页到物理页”的简化对照表,但若为每个可能的虚拟页都预留一项,这种结构在工程上根本行不通。
但一个普通进程实际出现映射的范围远小于
页表本身存储在哪里?
页表是一个数组,数组需要内存来存储。这块内存是物理内存还是虚拟内存?
页表页和其他内核数据一样占用物理页帧。硬件遍历页表时,CR3 和各级页表项提供的是下一级页表页的物理地址,否则翻译页表地址本身又要先查同一张页表。内核修改页表时仍会通过自己的内核虚拟映射访问这些物理页,所以“硬件用物理地址遍历”不等于内核代码只能书写物理地址。
页表存储在内存中也带来性能问题:没有翻译缓存时,访问目标数据前还要读取一级或多级页表。TLB 缓存近期的地址翻译,使多数内存访问不必重新执行完整页表遍历;虚拟内存会继续分析 TLB 命中、失效与刷新。
一张平铺页表的 512 GiB 开销不可接受,但大多数进程只使用了虚拟地址空间的极小部分。如果只为实际使用的地址范围分配页表项,其余部分不分配,页表的大小就能从 512 GiB 降到随映射分布增长的水平。
先从两级页表理解基本原理。把平铺页表的
两级页表的关键不是“恰好两层”,而是“把一张巨大平表改成一棵按需展开的树”。只有某一大片虚拟地址范围里真的出现了映射,才需要为那一支再分配下一级页表页。
现在回到 Linux 和 x86-64。这里还要多讲一句,不然后面看源码时又会卡住。Linux 在源码里统一使用五层命名:
text
PGD -> P4D -> PUD -> PMD -> PTE这几个缩写是 Linux 的页表层级名:PGD 是顶级目录,P4D、PUD 和 PMD 是逐级向下的中间目录,PTE 是已经定义过的页表项。名称来自内核接口,真正存在多少级硬件查表仍由体系结构和启动模式决定。
这样无论底层硬件是四级页表还是五级页表,内核代码都能用同一套接口。在最常见的 x86-64 四级页表机器上,p4d 这一层会被折叠(fold)掉:代码里仍然有 p4d_t 和 p4d_offset(),但硬件真正做页表遍历时仍然是四级。
因此,Linux 代码里会出现五个层级名称,四级分页的硬件文档里则只列出四个实际层级。两边描述的是同一棵树,只是抽象层不同。
下面用 Linux 的层级名称拆分常见的 48 位 x86-64 虚拟地址:
| 位范围 | 字段 | 宽度 | 作用 |
|---|---|---|---|
| 63-48 | 符号扩展 | 16 位 | 必须复制第 47 位,不参与查表 |
| 47-39 | PGD 索引 | 9 位 | 选择顶级条目 |
| 38-30 | PUD 索引 | 9 位 | 选择下一级条目 |
| 29-21 | PMD 索引 | 9 位 | 选择下一级条目 |
| 20-12 | PTE 索引 | 9 位 | 选择末级条目 |
| 11-0 | 页内偏移 | 12 位 | 选择页内字节 |
在这种常见配置里,真正参与查表的是 4 个 9 位索引和最后 12 位页内偏移。每级 9 位索引对应 512 个条目(
从 Linux 数据结构角度看,一次翻译可以先抓住下面这张图:
text
mm_struct
└── pgd ----> 顶级页表页
└── pgd entry -> 下一级页表页
└── p4d entry -> 下一级页表页(四级机型上通常折叠)
└── pud entry -> 下一级页表页
└── pmd entry -> 最后一级页表页
└── pte entry -> 物理页帧号 + 标志位CPU 开始使用某个地址空间时,内核会确保 CR3 指向对应的顶级页表;同一地址空间内的线程切换不需要为此更换页表根。之后地址翻译的过程就是逐级查表:从虚拟地址里取出索引,先查 PGD,再按实际硬件层级查到 PTE,最后取出物理页帧号并拼上页内偏移。
Linux 内核为这件事提供了一组统一的遍历接口。软件查看某个地址的页表时,会依次用 pgd_offset()、p4d_offset()、pud_offset()、pmd_offset() 和 PTE 访问接口取得各级条目;某一级条目为空,表示对应虚拟范围没有下一级页表或有效映射。真实代码还必须检查损坏条目、上级大页叶子、权限、并发修改和锁,不能把这组接口机械拼成一个不带上下文的通用函数。
四级 x86-64 机器上,p4d 往往会被折叠,可以把它理解成 Linux 为统一接口保留的一层名称。每一级都可能为空(pgd_none、p4d_none、pud_none、pmd_none),表示这个虚拟地址范围没有继续展开。空条目对应的下一级页表不占用物理页,这就是多级页表节省空间的原理。
现在估算一个小型进程的页表占用。假设代码、数据、堆和实际已经触碰的栈页面合计约 15 MiB,即 15 MiB / 4 KiB = 3840 个虚拟页。
如果这 3840 个页面集中在少量常见映射区域中,顶层只会使用少量条目,下面也只为这些范围分配 PUD、PMD 和末级页表页。末级至少需要 8 张页表页,分散的代码、堆和栈会再增加少量末级与中间层页面;在这个紧凑布局假设下,总开销通常是几十到数百 KiB,而不是 512 GiB。精确值取决于映射分布、页大小、共享内核页表和体系结构,不能只由“15 MiB 已用页面”唯一确定。
多级页表的代价是未缓存翻译需要逐级查表。TLB 命中时可以直接取得虚拟页到物理页帧的翻译;TLB 未命中时,x86 的页表遍历器才沿顶级到末级读取页表项,页表遍历缓存还可能命中部分中间层。虚拟内存将从这项成本继续推导 TLB 与大页。
为什么是四级而不是更多级?
理论上可以继续增加层级。x86-64 的五级页表把参与翻译的地址宽度从 48 位扩展到 57 位,对应最多 2^57 字节的规范虚拟地址范围。启用 LA57 后,冷的完整页表遍历多一个层级,但 TLB 和页表遍历缓存会改变实际访存次数,因此不能把延迟固定写成增加 25%。
四级与五级不是操作系统对每个进程临时选择的树高,而是处理器能力、内核配置和启动模式共同确定的地址翻译格式。四级模式用四组 9 位索引加 12 位页内偏移;五级模式再增加一组 9 位索引。每组 9 位都让一张 4 KiB 页表页容纳 512 个 8 字节条目,是否启用额外层级则取决于系统是否需要并支持更大的地址范围。
小结
| 概念 | 说明 |
|---|---|
| 地址绑定 | 程序中的地址逐步确定为运行时可访问地址的过程 |
| 连续内存分配 | 为每个进程分配一块连续物理内存,用基址与限长寄存器翻译和保护 |
| 外部碎片 | 空闲内存总量足够但不连续,无法满足连续内存请求 |
| 紧凑 | 移动已分配区域以合并空洞,代价是复制与暂停访问 |
| 内部碎片 | 分配粒度大于实际需求而浪费的块内空间 |
| 分段 | 按逻辑功能拆分地址空间,每段独立映射但内部仍须连续 |
| 分页 | 把虚拟空间和物理内存划成固定大小的页与页帧,通过页表映射 |
| 页表项 | 记录虚拟页到物理页帧的映射和属性标志(Present、R/W、Dirty 等) |
| 多级页表 | 把平铺页表改成按映射范围展开的树形结构 |
| 页表遍历 | 逐级查表,把虚拟地址翻译成物理地址 |
从连续分配到多级页表,每一步都在碎片与管理开销之间重新取舍:连续分配的翻译状态较少但外部碎片严重,分段放松了整个进程连续的要求,分页再用固定粒度消除基础页分配中的外部碎片,多级页表则避免为尚未映射的虚拟范围预留末级条目。
Linux 源码入口:
include/linux/mm_types.h:mm_struct、vm_area_struct定义arch/x86/include/asm/pgtable_types.h:页表项类型定义arch/x86/include/asm/pgtable_64_types.h:四级与五级页表层级宏- 5-level paging:LA57 的地址宽度与兼容性约束
arch/x86/mm/fault.c:x86 缺页异常入口mm/memory.c:页表与缺页处理核心路径
本课的编译、反汇编和页表大小输出已在
just linux-x86启动的 x86-64 Linux 容器中重新执行。验证环境为 Docker 镜像gcc:14、GCC 14.4.0、GNU binutils 2.44。非 PIE 示例显式使用-fno-pie -no-pie,PIE 示例显式使用-fPIE -pie,因此不依赖发行版是否默认启用 PIE。 ↩︎