Skip to content

地址空间与分页

  • 写作时间:2026-03-23
  • 当前字符:20072

上一章讨论的是并发:多个执行流怎样共享数据,怎样避免竞态,怎样在事件循环或内核里组织并发。但这些讨论一直默认了一件更底层的事情:线程能读写变量,进程有自己的地址空间,锁保护的是一块明确可寻址的内存。现在要再往下追一层:这些地址本身是从哪里来的?

要回答这些问题,需要追溯内存管理的演进。程序中的地址在编译、加载或运行时与物理内存建立关联,这个过程叫地址绑定。最简单的方案是给每个进程分配一块连续的物理内存,这就是连续内存分配,但它会产生无法利用的碎片。分段允许程序的不同部分分散存放,但仍然没有消除碎片。分页以固定大小为单位管理内存,消除了碎片。但为整个地址空间记录映射需要的存储量会超过物理内存本身。多级页表只为进程实际使用的地址范围建立映射,把这个开销降到几十 KB。

内存管理这一章会沿着这条问题链继续展开。本课先建立地址空间与分页的基本模型,回答虚拟地址怎样被翻译成物理地址。只有页表和地址空间的骨架立住了,下一课才能讨论虚拟内存,也就是缺页、换页和页面替换这些动态行为。再往后,我们会看到这套地址翻译机制怎样支撑 内存映射,把文件和匿名内存统一进同一套地址空间语义里。最后再进入内核内存分配,看内核自己怎样管理页帧、对象缓存和小块内存。

地址绑定

程序运行时,CPU 最终要访问物理内存,而物理内存中的每个位置由一个物理地址(physical address)标识。但程序中的地址并不是物理地址:源代码用变量名和函数名,编译后变成数字地址,这些数字地址仍然不一定对应物理内存中的真实位置。从程序中的地址到物理地址,必须在某个环节完成转换。这个转换过程叫做地址绑定(address binding)。三种绑定方式本质都是在解决同一个问题:什么时候将程序中的地址"翻译"成真正能起作用的物理地址。程序从源代码到运行经历编译、加载、执行三个阶段,翻译可以发生在其中任何一个阶段,时机的不同决定了绑定方式的不同。最终我们会看到,主流的操作系统都选择了在运行时进行地址绑定。而更值得深思的是:内核为什么要提供这样一套运行时翻译地址的机制?

编译时绑定(compile-time binding)在编译阶段就确定程序使用的物理地址。编译器直接生成包含绝对物理地址的代码,因此程序只能加载到编译时预设的内存位置,否则所有地址都是错的。早期的 MS-DOS 程序和嵌入式系统固件就是这样工作的:编译器知道程序会被加载到固定地址(比如 0x0000),因此直接在代码中使用该地址。这种方式的局限性很明显:如果有两个程序都被编译为加载到地址 0x0000,它们就不能同时运行。

objdump 反汇编一个编译好的程序,可以直接看到编译时绑定的效果1

c
// hello.c
#include <stdio.h>

int main(void) {
    printf("hello\n");
    return 0;
}
bash
$ gcc -o hello hello.c
$ objdump -d hello | 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,%eax

main 函数的地址 0x401126 在编译时就已经确定,写死在了二进制文件中。0x402004(字符串 "hello" 的地址)也是写死的绝对地址。这个程序必须被加载到这些地址对应的内存位置,否则所有引用都是错的。这就是编译时绑定:地址在编译阶段就已经决定了,没有任何调整余地。

加载时绑定(load-time binding)在程序加载到内存时确定地址。编译器不再生成绝对地址,而是生成相对于程序起始位置的偏移量。加载器在把程序载入内存时,根据实际加载位置修正所有地址引用,这个修正过程叫重定位(relocation)。用 -fPIE -pie 编译同一个程序,可以看到区别:

bash
$ gcc -fPIE -pie -o hello_pie hello.c
$ objdump -d 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>

main 的地址变成了 0x1139。对比前面编译时绑定版本的 0x401126,这个数字小得不正常,因为它不是一个真正的内存地址,而是相对于程序起始位置的偏移量。程序实际运行时,加载器会选择一个基址(比如 0x555555554000),然后把偏移量加上去:0x555555554000 + 0x1139 = 0x555555555139,这才是 main 在内存中的真正地址。每次运行时加载器选的基址可能不同,所以同一个 PIE 程序每次运行 main 的地址都会变。加载时绑定比编译时绑定灵活:同一个程序可以加载到不同的内存位置。但加载完成后,程序在运行期间不能再移动。如果操作系统想把一个正在运行的进程移到另一块物理内存(比如做内存紧凑),就必须重新修正所有地址,开销很大。

加载器怎么知道 0x1139 是偏移量而不是绝对地址?

objdump 输出的 0x4011260x1139 看起来都只是十六进制数字,格式上没有任何区别。CPU 执行指令时也不区分"这是偏移量"还是"这是绝对地址",它拿到什么地址就访问什么地址。

既然 CPU 不区分,那谁来区分?答案是加载器。编译器在生成二进制文件时,会在 ELF 文件头中写入一个类型标记。用 file 命令可以直接看到这个区别:

bash
$ file hello
hello: ELF 64-bit LSB executable, x86-64, ...
$ file hello_pie
hello_pie: ELF 64-bit LSB pie executable, x86-64, ...

executable 表示文件中的地址是最终的虚拟地址,加载器直接把程序放到 ELF 头指定的位置。pie executable 表示文件中的地址是偏移量,加载器需要自己选择一个基址,然后加上偏移量得到真正的虚拟地址。加载器完成这些调整之后,CPU 执行指令时看到的已经是正确的虚拟地址了,整个过程对 CPU 透明。

运行时绑定(run-time binding)把地址翻译推迟到程序执行的每一条指令。前两种绑定方式都在程序开始执行之前就确定了最终的物理地址,而运行时绑定不同:程序运行过程中,CPU 发出的每一个内存地址都不是物理地址,而是虚拟地址(virtual address),也叫逻辑地址(logical address)。虚拟地址和物理地址之间的翻译在每次内存访问时实时发生。

虚拟地址是 CPU 执行指令时生成的地址。程序中的所有地址,无论是指针值、函数地址还是全局变量地址,都是虚拟地址。物理地址(physical address)是内存硬件(DRAM 芯片)实际使用的地址,标识物理内存条上的具体位置。两者不是同一个东西:程序看到的地址 0x401126 在物理内存中可能存储在完全不同的位置。

负责翻译的硬件叫做 MMU(Memory Management Unit,内存管理单元)。MMU 位于 CPU 和物理内存之间,CPU 发出虚拟地址后,MMU 查询一张映射表把它翻译成物理地址,然后用物理地址去访问内存。这个翻译过程对程序完全透明,程序不知道也不需要知道自己的虚拟地址对应哪个物理地址。

CPU ──虚拟地址──→ [ MMU ] ──物理地址──→ 物理内存

                 映射表

MMU 的映射表由操作系统维护,后面会逐步深入这张表的设计。操作系统可以给不同进程配置不同的映射表,这样两个进程使用相同的虚拟地址(比如都在 0x401126 处有 main 函数),但 MMU 会把它们翻译成不同的物理地址,各自指向各自的代码。这就是进程地址空间隔离的硬件基础。

运行时绑定的核心优势在于:操作系统可以在程序运行过程中自由调整映射关系。要把一个进程的数据移到另一块物理内存,只需要复制数据并修改映射表,进程继续使用同样的虚拟地址,完全不知道底层发生了什么。回忆一下进程生命周期一课介绍的 Copy-on-Write,它正是利用了这个能力:内核在不改变虚拟地址的前提下,通过修改映射关系来控制物理内存的共享和分离。

为什么现代系统都选择运行时绑定?

编译时绑定要求预知加载地址,多个程序无法共存。加载时绑定允许灵活加载,但程序运行后不能移动。只有运行时绑定允许操作系统在程序运行过程中自由调整物理内存的使用:可以把不常用的页换出到磁盘(虚拟内存),可以在 fork 后共享物理页直到写入才复制(COW),可以让不同进程的同一份共享库代码只在物理内存中保留一份。

运行时绑定的代价是需要硬件支持。每次内存访问都要做地址翻译,如果靠软件完成,开销不可接受。MMU 把翻译做到了硬件中,配合 TLB 缓存,把每次翻译的开销降到接近零。这个硬件投入换来了整个虚拟内存体系:进程隔离、按需分页、共享内存,全部建立在运行时绑定之上。

地址绑定的三种时机是递进关系:编译时绑定最简单但最不灵活,加载时绑定增加了灵活性但仍有限制,运行时绑定最灵活但需要硬件支持。确定了运行时绑定是现代系统的选择,接下来的问题是:运行时绑定的硬件怎么设计?最简单的方案是给每个进程分配一块连续的物理内存。

连续内存分配

连续内存分配(contiguous memory allocation)是为每个进程分配一块连续的物理内存区域,进程的所有代码和数据都存放在这一块区域之中。

运行时地址翻译的最简单实现是两个硬件寄存器:基址寄存器(base register)和限长寄存器(limit register)。基址寄存器存放进程在物理内存中的起始地址,限长寄存器存放进程的地址空间长度。从物理内存的角度看,进程占据 [base, base+limit) 这段区域;但进程自身看到的虚拟地址从 0 开始,所以硬件只需做两件事:第一,检查虚拟地址是否小于 limit(越界则触发异常);第二,把虚拟地址加上 base,得到物理地址。

c
// 基址/限长寄存器的地址翻译和保护检查(伪代码)
void translate(uint32_t virtual_addr, uint32_t base, uint32_t limit) {
    if (virtual_addr >= limit) {
        trap(SEGMENTATION_FAULT);       // 越界:触发异常
        return;
    }
    uint32_t physical_addr = base + virtual_addr;
    access_memory(physical_addr);       // 合法:访问物理地址
}

// 进程 A: base = 0x10000, limit = 0x5000
// 虚拟地址 0x1234 → 物理地址 0x10000 + 0x1234 = 0x11234 ✓
// 虚拟地址 0x6000 → 0x6000 >= 0x5000,触发异常 ✗

这就是运行时绑定的最简单形式。每个进程有自己的 base 和 limit 值,上下文切换时内核切换这两个寄存器。进程 A 的虚拟地址 0x1234 指向物理地址 0x11234,而进程 B 的同一个虚拟地址 0x1234 会指向不同的物理位置,具体取决于 B 的 base 值。

连续分配需要操作系统管理一组大小不等的空闲内存块。当一个新进程需要内存时,操作系统从空闲块中选择一个足够大的分配给它。选择策略有三种。

首次适配(first-fit)从头扫描空闲块列表,分配第一个够大的块。速度最快,因为找到就停。但靠前的大块容易被小请求切碎,产生很多小碎片。

最佳适配(best-fit)扫描所有空闲块,选择能满足请求的最小块。目标是尽量减少浪费。但它每次都要遍历整个列表,而且切出来的剩余部分非常小,产生大量几乎不可用的微小碎片。

最差适配(worst-fit)选择最大的空闲块。理由是切完之后剩余的块还足够大,可以满足后续请求。同样需要遍历整个列表。

进程不断创建和退出后,内存中会出现很多小的空闲块,散布在已分配的块之间。这些空闲块加起来总量可能足够,但因为不连续,无法满足一个需要大块连续内存的请求。这就是外部碎片(external fragmentation)。

已分配    空闲     已分配      空闲     已分配     空闲
[  A  ] [  8KB  ] [  B  ] [  12KB  ] [  C  ] [  4KB  ]

新进程需要 20KB 连续内存
空闲总量 = 8 + 12 + 4 = 24KB > 20KB
但没有任何一块连续空闲区域 >= 20KB → 分配失败

外部碎片是连续分配方案的根本缺陷。无论使用哪种适配算法,只要进程的内存大小不同,创建和退出的顺序不确定,碎片就会不断累积。既然问题出在“空闲块被打散了”,一个直接的补救办法就是把已分配的块往一端挪,把分散的空闲区域重新并成一个大块,这个过程叫紧凑(compaction)。

紧凑前:[A] [空] [B] [空] [C] [空]
紧凑后:[A] [B] [C] [      大空闲块      ]

紧凑的问题是开销很大。移动进程的内存意味着复制大量数据,而且在移动过程中必须更新所有指向这块内存的引用(base 寄存器、进程内部的指针等)。如果使用运行时绑定,只需要修改 base 寄存器就行,因为进程的虚拟地址不变。但即使如此,物理内存的复制开销仍然很难接受。也就是说,紧凑只能缓解连续分配的后果,却没有消除“必须连续”这个约束本身,所以它很难成为真正令人满意的解法。

与外部碎片对应的还有内部碎片(internal fragmentation):分配给进程的内存块大于进程实际需要的大小,多出来的空间在块内部被浪费。内部碎片在后面分页一节还会再次出现。

最佳适配为什么不一定最好?

最佳适配的目标是最小化每次分配的浪费。但从全局来看,这个局部最优策略往往导致全局最差的结果。

每次分配后切出来的剩余块都很小(因为选的是刚好够大的块)。这些微小碎片太小,几乎不可能被后续请求使用。经过一段时间,内存中充满了这些不可用的碎片。相比之下,首次适配虽然每次可能浪费更多,但剩余块往往足够大,还能满足后续分配。

实际测量表明,首次适配和最佳适配在内存利用率上的差距不大,但首次适配的分配速度更快(不需要遍历完整列表)。这是一个典型的"看起来最精确的策略不一定最有效"的案例:系统的整体行为取决于分配和释放的时间序列,而不是单次分配的精确度。

连续分配真正卡住系统的地方,不只是“会产生碎片”,而是“整个进程必须占据一整块连续物理内存”。下一节先看一个过渡方案:把进程拆成代码、数据、栈这些逻辑部分,允许它们分别放到不同的物理位置。这就是分段。它确实放松了“整个进程必须连续”的约束,但我们也会马上看到,它仍然没有真正消除外部碎片。

分段

分段(segmentation)是将进程的地址空间按照逻辑功能划分成若干段(如代码段、数据段、栈段),其中每个段可以独立地映射到物理内存的不同位置。

从程序的视角看,地址空间本来就不是一整块均质的内存,而是天然带着不同的逻辑区域。低地址通常是代码段(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

c
// 段表结构与地址翻译(伪代码)
struct segment_entry {
    uint32_t base;       // 段的物理起始地址
    uint32_t limit;      // 段的长度
    uint8_t  protection; // 权限:读/写/执行
};

struct segment_entry segment_table[] = {
    [0] = { .base = 0x10000, .limit = 0x4000, .protection = RX  }, // 代码段
    [1] = { .base = 0x50000, .limit = 0x2000, .protection = RW  }, // 数据段
    [2] = { .base = 0x80000, .limit = 0x8000, .protection = RW  }, // 栈段
};

// 翻译:虚拟地址 = (段号, 段内偏移)
void translate_segmented(uint16_t seg_num, uint32_t offset) {
    struct segment_entry *seg = &segment_table[seg_num];
    if (offset >= seg->limit)
        trap(SEGMENTATION_FAULT);
    uint32_t physical_addr = seg->base + offset;
    access_memory(physical_addr);
}

分段相比连续分配有两个优势。第一,每个段可以独立设置保护属性:代码段只读可执行,数据段可读写,栈段可读写不可执行。第二,段可以独立增长:堆段需要更多内存时,操作系统只扩展堆段的 limit,不影响其他段。

但分段并没有解决外部碎片。每个段仍然是一块连续的物理内存。段的大小因进程而异、因段而异,段的创建和销毁会在物理内存中留下大小不等的空洞,和连续分配面临的碎片问题完全一样。

x86 分段的历史遗产

Intel 8086 处理器(1978 年)使用 16 位寄存器,但需要访问 20 位的地址空间(1MB)。解决方案是分段:把 16 位的段寄存器左移 4 位加上 16 位的偏移量,得到 20 位的物理地址。CS(Code Segment)、DS(Data Segment)、SS(Stack Segment)、ES(Extra Segment) 这四个段寄存器就是那时引入的。

Intel 80386(1985 年)引入了保护模式和分页机制,分段和分页可以同时工作。但到了 x86-64(AMD64,2003 年),处理器实际上取消了传统分段的功能:在 64 位长模式(long mode)下,CS、DS、SS 等段寄存器的基址被硬件强制为 0,段限长被忽略。也就是说,段号 + 段内偏移 = 偏移本身,分段翻译等于不翻译。

不过 FS 和 GS 两个段寄存器是例外。x86-64 允许它们保持非零基址。线程一课讲过,Linux 利用 FS 段寄存器存放 TLS 基址,让 fs:[offset] 直接定位到当前线程的线程本地变量。GS 段寄存器则被内核用于存放 per-CPU 数据的基址,基础与概览最后一课「系统调用」里 entry_SYSCALL_64 入口的 swapgs 指令就是在切换 GS 基址。

分段允许进程的不同部分分散存放,但每个段内部仍然是连续的,外部碎片依然存在。要真正摆脱这个问题,就得把分配单位从“整段”继续缩小到固定大小的块。下一节要看的分页,就是沿着这个方向继续推进。

分页

分页(paging)是将虚拟地址空间和物理内存都划分成固定大小的块,通过页表建立虚拟块到物理块之间的映射关系。

分页首先要解决的是分段留下来的那个问题:每个段仍然要求连续,所以外部碎片还在。分页把分配单位从“整段”继续缩小成固定大小的小块。这样一来,操作系统不再需要给代码段、数据段、堆段各找一整块连续物理内存,而只需要给它们包含的每一页分别找一个空闲页帧。

先把三个最基本的对象立住。虚拟地址空间被划分成的固定大小块叫做页(page),物理内存被划分成的同样大小块叫做页帧(frame,也叫物理页)。页和页帧的大小相同,x86-64 上默认为 4KB。

只把内存切成页还不够。CPU 还必须知道:当前访问的这个虚拟页,到底对应哪个物理页帧。记录这种对应关系的数据结构,就是页表(page table)。页表是一个数组,以虚拟页号为下标,每个条目叫做页表项(Page Table Entry, PTE),记录该虚拟页映射到哪个物理页帧以及相关的属性标志。

这样分页最核心的直觉就清楚了:虚拟页和物理页帧之间不要求一一按顺序对应。虚拟页 0 可以映射到物理页帧 5,虚拟页 1 可以映射到物理页帧 2。程序看到的连续虚拟地址,在物理内存里完全可以打散存放。页表正是把这种“可以打散但仍然能找到”的关系保存下来的地方。

虚拟地址在硬件层面被拆分为两部分:高位是虚拟页号(page number),低位是页内偏移(page offset)。以 4KB 页为例,低 12 位是页内偏移(2^12 = 4096),其余高位是虚拟页号。可以先看一个 32 位地址的简化示例:

虚拟地址 0x12345678(32 位示例)
├── 高 20 位: 0x12345  → 虚拟页号,查页表
└── 低 12 位: 0x678    → 页内偏移,保持不变

查阅页表发现: 虚拟页 0x12345 → 物理页帧 0xABCDE

物理地址 = 物理页帧号 << 12 | 页内偏移 = 0xABCDE678

分页的关键特性是:虚拟页可以映射到任意物理页帧。虚拟地址空间中相邻的两个页,在物理内存中可以完全不相邻。这彻底消除了外部碎片:任何空闲的物理页帧都可以分配给任何需要的虚拟页,不需要连续。

但分页引入了内部碎片。如果一个连续区域只需要 4097 字节(4KB + 1 字节),它至少需要两页(8KB),最后一页浪费了 4095 字节。不过这里和外部碎片有一个关键区别:内部碎片被限制在每个连续区域的最后一页,最多浪费一页减一字节;而外部碎片的问题是,空闲内存总量明明足够,却可能因为不连续而根本分配不出来。也就是说,分页不是“没有浪费”,而是把浪费变成了一个边界明确、不会阻止分配继续发生的代价。

每个 PTE 不仅记录物理页帧号,还包含一组标志位,控制该页的属性和状态。Linux 内核中 x86 的 PTE 标志位定义在 arch/x86/include/asm/pgtable_types.h

c
// arch/x86/include/asm/pgtable_types.h (selected flags)
#define _PAGE_BIT_PRESENT     0   // P:    页在物理内存中
#define _PAGE_BIT_RW          1   // R/W:  0=只读, 1=可读写
#define _PAGE_BIT_USER        2   // U/S:  0=仅内核态, 1=用户态可访问
#define _PAGE_BIT_ACCESSED    5   // A:    页被访问过(读或写)
#define _PAGE_BIT_DIRTY       6   // D:    页被写入过
#define _PAGE_BIT_PSE         7   // PS:   大页(2MB/1GB)
#define _PAGE_BIT_NX         63   // NX:   不可执行

#define _PAGE_PRESENT   (1UL << _PAGE_BIT_PRESENT)
#define _PAGE_RW        (1UL << _PAGE_BIT_RW)
#define _PAGE_USER      (1UL << _PAGE_BIT_USER)
#define _PAGE_ACCESSED  (1UL << _PAGE_BIT_ACCESSED)
#define _PAGE_DIRTY     (1UL << _PAGE_BIT_DIRTY)

Present 位是最关键的标志。基础与概览的「特权边界」一课介绍过缺页异常(Page Fault, #PF):当 CPU 访问一个 Present 位为 0 的页时,硬件触发 #PF 异常,内核接管处理。在这一课里,你先把它理解成:CPU 发现这次访问需要的页此刻还不能直接使用,于是把控制权交给内核。内核接手后会先检查这个地址是否合法;如果合法,就补齐这次访问需要的映射,再让那条指令重新执行;如果不合法,就把它当成真正的错误处理。为什么一个合法的页会“暂时不能直接使用”,下一课讲虚拟内存时再展开。进程生命周期一课介绍的 Copy-on-Write 也依赖 PTE 标志:fork 后共享页被标记为只读(R/W = 0),写入时触发 #PF,内核在异常处理中复制物理页。

Accessed 位和 Dirty 位由 CPU 硬件自动设置。CPU 读或写某一页时设置 Accessed 位,写入时额外设置 Dirty 位。内核会周期性地检查并清除这些标志位,以此跟踪哪些页最近仍在被访问、哪些页已经冷下来。这两个位的具体用途在下一课讲到页面回收时会变得更清楚。

如果这里只停在“页表是一个数组”,后面一碰到 Linux 源码里的 mm_structpgd_tpmd_t,读者还是会立刻失去抓手。这里要先把 Linux 里最低限度的三个对象立住:

  1. mm_struct:一个进程整套用户地址空间的总描述符。
  2. vm_area_struct:其中一段连续虚拟地址范围的语义记录,说明这段地址允许什么访问、以后该按什么对象解释。
  3. pgd/p4d/pud/pmd/pte:真正负责“逐级把虚拟页翻译到物理页帧”的页表树。

这三个对象的职责不能混。vm_area_struct 不是页表页,它回答的是“这段地址应不应该存在、权限是什么”;页表树回答的则是“具体到这一页,此刻映射到哪个物理页帧”。Linux 的 mm_struct 里有一个 pgd 指针,指向顶级页表。CPU 切换到这个进程时,会把这个顶级页表的物理地址装进 CR3,之后硬件就从这里开始做页表遍历(page table walk)。

有了这个骨架,问题才能真正摆出来:如果页表真的按“每个虚拟页一个 PTE”平铺展开,它会大到工程上根本不可行。 先做一个抽象计算。假设虚拟地址宽度是 V 位,页大小是 4KB,那么页内偏移固定占 12 位,虚拟页号就占 V - 12 位,所以平铺页表需要 2^(V - 12) 个 PTE。

现在再代入 x86-64 最常见的那组参数。这里最容易卡住的问题就是:为什么突然冒出“48 位虚拟地址”?

答案不是“Linux 自己选了 48 位”,而是 x86-64 四级页表硬件格式直接推出来的。先看三个事实:

  1. 页大小默认是 4KB,所以页内偏移固定占 12 位。
  2. 每个页表项是 8 字节,所以一张 4KB 的页表页正好能放 4096 / 8 = 512 = 2^9 个条目。
  3. 传统 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 四级页表机器上,硬件到底能直接翻译多大的虚拟地址空间”。页大小仍然是 4KB(2^12 字节),每个 PTE 8 字节:

c
// page_table_size.c — 计算平铺页表的大小
#include <stdio.h>

int main(void) {
    unsigned long virt_bits = 48;                       // x86-64 虚拟地址宽度
    unsigned long pte_size  = 8;                        // 每个 PTE 8 字节
    unsigned long num_pages = 1UL << (virt_bits - 12);  // 2^36 个虚拟页
    unsigned long table_size = num_pages * pte_size;    // 2^36 × 8 = 512 GB

    printf("pages: %lu, table size: %lu GB\n",
           num_pages, table_size / (1UL << 30));
    return 0;
}
$ gcc -o page_table_size page_table_size.c && ./page_table_size
pages: 68719476736, table size: 512 GB

2^36 个页表项,每个 8 字节,总共 512GB。一个进程的页表就超过了大多数机器的物理内存总量。进程生命周期一课画的那张“虚拟页 → 物理页”的对照表,在工程上根本行不通。

但一个普通进程实际使用的虚拟地址空间远小于 2^48。一个简单的 "hello world" 程序可能只用几 MB 的代码和数据加上 8MB 的栈。为 256TB 虚拟地址空间中仅使用了几 MB 的进程维护一张 512GB 的页表,是极端的浪费。

页表本身存储在哪里?

页表是一个数组,数组需要内存来存储。这块内存是物理内存还是虚拟内存?

页表本身存储在物理内存中。原因很直接:MMU 翻译虚拟地址时需要查页表,如果页表本身使用虚拟地址,MMU 就需要先翻译页表的虚拟地址才能读取页表,而翻译这个虚拟地址又需要查页表,形成无限递归。所以页表的基址必须是物理地址。CPU 调度一课提到的 CR3 寄存器就存放着当前进程页表的物理基址。

但页表存储在物理内存中带来了新问题:每次内存访问都需要先查页表(至少一次额外的物理内存访问),再访问目标数据。没有优化的情况下,每次内存访问的实际开销翻倍。对于多级页表,一次虚拟地址翻译需要多次内存访问,开销更高。这个性能问题的解决方案是 TLB(Translation Lookaside Buffer),下一课会详细介绍。

反转页表(Inverted Page Table)

前面讨论的页表是按虚拟地址空间组织的:每个进程有一张独立的页表,以虚拟页号为下标。反转页表换了一个组织维度:整个系统只有一张页表,以物理页帧号为下标。每个条目记录"这个物理页帧当前被哪个进程的哪个虚拟页使用"。

反转页表的大小取决于物理内存而非虚拟地址空间。一台 16GB 内存的机器只需要约 400 万个条目(16GB / 4KB),远小于平铺页表的 2^36 个条目。IBM PowerPC 和 HP PA-RISC 架构使用过反转页表。

但反转页表的查找效率是个问题。给定一个虚拟地址,需要搜索整张表才能找到对应的物理页帧。实际实现用哈希表加速查找:对虚拟页号做哈希,映射到反转页表的某个位置,哈希冲突通过链表解决。x86 架构没有采用反转页表,而是选择了多级页表方案来解决页表大小问题。

一张平铺页表的 512GB 开销不可接受,但大多数进程只使用了虚拟地址空间的极小部分。如果只为实际使用的地址范围分配页表项,其余部分不分配,页表的大小就能从 512GB 降到与进程实际内存使用量成正比的水平。

多级页表

多级页表(multi-level page table)是将平铺页表拆分成多层的树形结构,只为实际使用的虚拟地址范围分配页表节点,未使用的范围不分配内存。

先从两级页表理解基本原理。把平铺页表的 2^20 个条目(以 32 位地址空间为例)分成 1024 组,每组 1024 个 PTE。第一级叫页目录(page directory),有 1024 个条目,每个条目指向一个第二级页表(如果该范围有映射的话)。如果某个页目录条目对应的 4MB 虚拟地址范围完全没有映射,那个条目就标记为“不存在”,对应的第二级页表根本不需要分配。

两级页表的关键不是“恰好两层”,而是“把一张巨大平表改成一棵按需展开的树”。只有某一大片虚拟地址范围里真的出现了映射,才需要为那一支再分配下一级页表页。

现在回到 Linux 和 x86-64。这里还要多讲一句,不然后面看源码时又会卡住。Linux 在源码里统一使用五层命名:

text
PGD -> P4D -> PUD -> PMD -> PTE

这样无论底层硬件是四级页表还是五级页表,内核代码都能用同一套接口。在最常见的 x86-64 四级页表机器上,p4d 这一层会被折叠(fold)掉:代码里仍然有 p4d_tp4d_offset(),但硬件真正做页表遍历时仍然是四级。

所以你在 Linux 代码里会看到五个名字,在硬件文档里常看到四级名字。两边其实说的是同一棵树,只是抽象层不同。

下面先用 Linux 这一侧的名字看常见的 48 位 x86-64 地址拆分:

c
/*
 * x86-64 虚拟地址位域拆分(常见四级页表,48 位寻址,4KB 页)
 *
 * 63    48 47    39 38    30 29    21 20    12 11       0
 * ┌───────┬────────┬────────┬────────┬────────┬─────────┐
 * │ sign  │  PGD   │  PUD   │  PMD   │  PTE   │ offset  │
 * │extend │ 9 bits │ 9 bits │ 9 bits │ 9 bits │ 12 bits │
 * └───────┴────────┴────────┴────────┴────────┴─────────┘
 *    16b     index    index    index    index    页内偏移
 *           into     into     into     into
 *           PGD      PUD      PMD      PT
 *
 * 每级 9 位 → 512 个条目 × 8 字节/条目 = 4KB = 恰好一页
 */

在这种常见配置里,真正参与查表的是 4 个 9 位索引和最后 12 位页内偏移。每级 9 位索引对应 512 个条目(2^9 = 512)。每个条目 8 字节,所以每一级页表恰好占一页(512 × 8 = 4096 = 4KB)。高 16 位(第 48-63 位)不是额外的新索引,而是前面提到的 canonical address 符号扩展。

从 Linux 数据结构角度看,一次翻译可以先抓住下面这张图:

text
mm_struct
  └── pgd  ----> 顶级页表页
         └── pgd entry -> 下一级页表页
               └── p4d entry -> 下一级页表页(四级机型上通常折叠)
                     └── pud entry -> 下一级页表页
                           └── pmd entry -> 最后一级页表页
                                 └── pte entry -> 物理页帧号 + 标志位

CPU 切换到某个进程时,会把它顶级页表的物理地址装进 CR3。之后地址翻译的过程就是逐级查表:从虚拟地址里取出索引,先查 PGD,再查 P4D/PUD/PMD,最后在 PTE 里取出物理页帧号,拼上页内偏移得到最终物理地址。

Linux 内核里对这件事提供了一组统一的遍历宏。下面是简化后的伪代码:

c
// Linux 通用页表遍历(基于内核宏,简化)
// arch/x86/include/asm/pgtable.h

// 输入:mm_struct(包含 CR3)和虚拟地址 vaddr
// 输出:物理地址

pgd_t *pgd = pgd_offset(mm, vaddr);          // CR3 + PGD 索引 → PGD 条目
if (pgd_none(*pgd))                           // PGD 条目为空 → 地址无映射
    return PAGE_FAULT;

p4d_t *p4d = p4d_offset(pgd, vaddr);          // PGD 条目 + P4D 索引 → P4D 条目
if (p4d_none(*p4d))
    return PAGE_FAULT;

pud_t *pud = pud_offset(p4d, vaddr);          // P4D 条目 + PUD 索引 → PUD 条目
if (pud_none(*pud))
    return PAGE_FAULT;

pmd_t *pmd = pmd_offset(pud, vaddr);          // PUD 条目 + PMD 索引 → PMD 条目
if (pmd_none(*pmd))
    return PAGE_FAULT;

pte_t *pte = pte_offset_kernel(pmd, vaddr);   // PMD 条目 + PTE 索引 → PTE 条目
if (!pte_present(*pte))                        // Present 位为 0 → 缺页
    return PAGE_FAULT;

unsigned long phys = (pte_val(*pte) & PTE_PFN_MASK) | (vaddr & ~PAGE_MASK);
// PTE 中的物理页帧号 + 虚拟地址的低 12 位页内偏移 = 最终物理地址

四级 x86-64 机器上,p4d 往往会被折叠掉,所以你可以把它理解成“Linux 为了统一接口而保留的一层名字”。每一级都可能为空(pgd_none / p4d_none / pud_none / pmd_none),表示这个虚拟地址范围没有映射。空条目对应的下一级页表根本不存在于内存中。这就是多级页表节省空间的原理。

现在来算一个实际进程的页表占用。假设一个普通进程使用了以下地址范围:代码段 2MB,数据段 1MB,堆 4MB,栈 8MB,总共约 15MB,即 15MB / 4KB ≈ 3840 个虚拟页。

这些地址范围在 48 位虚拟地址空间中只会点亮少量分支:顶层只需要少量条目,下面也只会为实际用到的那几片地址范围分配 PUD、PMD 和最终页表页。实际需要的页表页面大约是:1 个顶级页表页,加上少量中间层页表页,再加十几个最终页表页,总共通常也就是几十个 4KB 页面,不到 128KB。和平铺页表的 512GB 相比,开销降低了六个数量级。

多级页表的代价是翻译速度。TLB 未命中时,CPU 需要逐级去内存里查 PGD、P4D、PUD、PMD、PTE,然后才能访问目标数据。这个性能问题由 TLB(Translation Lookaside Buffer) 解决:TLB 缓存最近使用的虚拟页到物理页帧的映射,命中时就能跳过整棵页表树的遍历。TLB 的具体机制和性能影响是下一课的主题。

为什么是四级而不是更多级?

理论上可以用更多级来进一步节省空间。五级页表把虚拟地址空间从 48 位扩展到 57 位(128PB),Linux 5.0 已经支持五级页表(LA57)。但增加层级的代价是每次页表遍历多一次内存访问。四级页表需要 4 次内存访问,五级需要 5 次。TLB 未命中时,翻译延迟增加 25%。

48 位虚拟地址空间(256TB)对绝大多数应用来说已经足够。只有内存数据库、大规模科学计算等需要超大地址空间的场景才会用到五级页表。所以四级是空间和性能之间的平衡点:9-9-9-9-12 的拆分让每级恰好一页(4KB),对齐了硬件的缓存行和内存分配粒度;四次内存访问的翻译开销在 TLB 命中率较高时可以接受。增加到五级只在确实需要更大地址空间时才值得付出额外的翻译开销。

小结

概念说明
地址绑定(address binding)程序中的地址与物理内存建立对应关系的过程,分为编译时、加载时、运行时三种
连续内存分配(contiguous allocation)为每个进程分配一块连续的物理内存,用基址/限长寄存器做翻译和保护
外部碎片(external fragmentation)空闲内存总量足够但不连续,无法满足需要连续内存的请求
分段(segmentation)按逻辑功能把地址空间分成段,每段独立映射到物理内存
分页(paging)虚拟地址空间和物理内存都划分成固定大小的页/页帧,通过页表映射
页表项记录虚拟页到物理页帧的映射和属性标志(Present、R/W、Dirty 等)
多级页表(multi-level page table)树形结构的页表,只为实际使用的地址范围分配节点
页表遍历(page table walk)逐级查表把虚拟地址翻译成物理地址的过程

从连续分配到多级页表,每一步都在碎片与管理开销之间寻找更好的平衡:连续分配没有管理开销但碎片严重,分段允许非连续但仍有外部碎片,分页用固定大小消除了外部碎片,多级页表用按需分配把页表本身的开销也降了下来。


Linux 源码入口


  1. 本课的编译和反汇编输出基于 x86-64 Linux 环境(Docker 镜像 gcc:14,GCC 14.2,GNU binutils 2.43)。该环境下 GCC 默认生成位置相关代码(非 PIE),需要显式指定 -fPIE -pie 才能生成位置无关可执行文件。其他发行版(如 Ubuntu、Fedora)的 GCC 可能默认开启 PIE,输出地址会有所不同。 ↩︎