第2章 虚拟内存
地址翻译与页表
章节导读
如果说缓存是硬件工程师给程序员的礼物,那么虚拟内存就是操作系统给程序员的礼物——而且是一份更宏大、影响更深远的礼物。想象一下没有虚拟内存的世界:每个程序必须知道自己被加载到物理内存的哪个位置,所有指针都是物理地址;两个程序不能同时使用同一块内存区域,除非小心翼翼地手工划分;一个野指针可以直接改写操作系统的内核数据,整个系统随之崩溃。1960 年代的程序员确实生活在这个世界里,那时的「内存冲突」是系统崩溃的头号原因。虚拟内存的构想诞生于 1965 年前后的 Multics 项目——这个由 MIT、贝尔实验室和通用电气联合开发的雄心勃勃的系统,虽然商业上失败了,却留下了两项改变计算历史的遗产:分时系统和虚拟内存。1969 年的 GE 645 机器第一次在硬件层面支持了按页的虚拟内存,此后这一机制被 Unix、Windows 和所有现代操作系统继承。
| 翻译组件 | 位置 | 容量 | 命中延迟 | 未命中代价 | 作用 |
|---|---|---|---|---|---|
| TLB (L1) | CPU 内部 | 64-128 条目 | 0 周期 (命中) | 查 L2 TLB | 缓存最近翻译 |
| TLB (L2) | CPU 内部 | 1024-2048 条目 | ~5 周期 | 遍历页表 | L1 未命中后备 |
| 一级页表 (PGD) | 主存 | 512 条目/页 | ~300 周期 | - | 指向 PUD |
| 二级页表 (PUD) | 主存 | 512 条目/页 | ~300 周期 | - | 指向 PMD (或 1GB 大页) |
| 三级页表 (PMD) | 主存 | 512 条目/页 | ~300 周期 | - | 指向 PTE (或 2MB 大页) |
| 四级页表 (PTE) | 主存 | 512 条目/页 | ~300 周期 | - | 指向物理页帧 |
| 完整页表遍历 | 主存 | 4 级 | ~1200 周期 | TLB 未命中时 | 最坏情况翻译 |
虚拟内存的核心思想出奇地简单:在程序和物理内存之间插入一个「翻译层」。程序看到的每个地址都是虚拟地址,CPU 中的 MMU(内存管理单元)在每次访问时把虚拟地址翻译成物理地址。这个看似简单的机制带来了四大革命性优势。第一是隔离:每个进程拥有独立的地址空间,进程 A 的指针 0x1000 和进程 B 的指针 0x1000 映射到完全不同的物理页,一个进程的越界访问不可能伤害另一个进程——这是现代操作系统安全模型的基石。第二是共享:需要共享时,操作系统可以让多个进程的虚拟页映射到同一个物理页,共享库的代码段就是这样被上百个进程共享而只占一份内存的。第三是抽象:程序员看到一个连续的、从固定地址开始的巨大地址空间,不需要关心物理内存的碎片化和实际布局,链接器也因此可以假设固定的加载地址。第四是按需分配:程序可以申请远超物理内存的虚拟空间,页面只在第一次真正访问时才分配物理内存(缺页异常),这使得内存使用率大幅提升,也让「分配 1GB 数组但只用前 1MB」这样的操作几乎零成本。
但天下没有免费的午餐。每次内存访问都要翻译地址,如果翻译本身需要访问内存中的页表,那么一次数据访问就变成了两次甚至五次内存访问,性能将不可接受。硬件和操作系统为此发展出了一整套精密的加速机制:多级页表把翻译结构组织成按需分配的树,TLB 把最近的翻译结果缓存在 CPU 里,大页把翻译的粒度从 4KB 提升到 2MB 甚至 1GB。Drepper 在论文中特别强调,TLB 是程序员最容易忽视又最容易踩坑的组件——一个在缓存层面表现完美的程序,可能因为 TLB 未命中率过高而性能腰斩。本章将沿着「地址空间布局 → 页与页表 → 翻译过程 → TLB → 缺页异常 → mmap 与 COW → 交换空间」的线索,把虚拟内存的完整图景展开在你面前。
核心概念
- 虚拟地址
- 物理地址
- 页表
- TLB
- 大页
要点精析
虚拟内存的历史是一部「用抽象换效率」的演进史。Multics(1965)首次提出分段加按页的虚拟内存,但硬件复杂、性能糟糕,遭到广泛质疑——1970 年代甚至有一篇著名论文题为《虚拟内存的五个反对意见》。转折点是 1978 年的 VAX-11/780,它证明了虚拟内存在商用机器上的可行性;1980 年代 Unix(BSD 4.2)全面采纳,此后成为所有通用操作系统的标配。Linux 的虚拟内存子系统经历了从简陋到精密的演进:早期只支持 32 位 x86 的两级页表,2003 年 x86-64 引入四级页表,2017 年前后为支持 5 级页表(57 位虚拟地址)再次重构。理解这段历史的意义在于:虚拟内存的每个设计决策(页大小为什么是 4KB、页表为什么分级、TLB 为什么存在)都是特定时代硬件约束下的权衡产物,而这些权衡在今天的大内存服务器上正在被重新审视——大页的兴起就是最明显的例子。
x86-64 的虚拟地址空间布局是理解 Linux 内存行为的前提。x86-64 使用 48 位虚拟地址(最新的 5 级页表扩展到 57 位),理论上 256TB,但硬件要求高 16 位必须是低 48 位的符号扩展,因此地址空间被劈成两半:低半部分 0x0000_0000_0000_0000 到 0x0000_7FFF_FFFF_FFFF 给用户空间,高半部分给内核空间,中间是一个巨大的「非规范地址空洞」,访问它会立即触发异常。用户空间从低地址到高地址依次是:代码段(从 0x400000 附近开始,PIE 程序则随机化)、数据段(已初始化全局变量)、BSS 段(未初始化全局变量,不占文件空间)、然后是巨大的空闲区,堆通过 brk/mmap 向上生长,内存映射区(共享库、mmap 文件)在中间偏上位置,栈从最高用户地址向下生长,两者相向而行。物理地址方面,x86-64 支持 52 位物理地址(4PB),实际服务器 CPU 通常实现 46-48 位。用 cat /proc/self/maps 可以直接观察自己进程的完整布局。
页是虚拟内存管理的基本单位,Linux/x86-64 支持三种页大小:4KB 的标准页、2MB 的大页(Huge Page)和 1GB 的巨页。4KB 这个数字是 1980 年代确定的历史遗产——当时物理内存以 MB 计,4KB 页在内部碎片和页表开销之间取得了平衡。但今天服务器动辄几百 GB 内存,4KB 页的问题暴露无遗:256GB 内存需要 6700 万个页表项,仅页表本身就占几百 MB;更致命的是 TLB 只有几百到几千个表项,覆盖范围仅几 MB 到几十 MB,大工作集程序的 TLB 未命中率飙升。2MB 大页把 TLB 覆盖范围扩大 512 倍,1GB 巨页扩大 26 万倍。Linux 提供两种大页机制:传统的 hugetlbfs(需要预留,通过 mmap 挂载的文件系统使用)和 2.6.38 引入的 THP(Transparent Huge Pages,内核自动合并 4KB 页为大页,对应用透明但有碎片整理开销)。数据库、JVM、科学计算等大内存应用通常能从大页获得 10%-30% 的性能提升。
x86-64 的页表是四级树状结构:PGD(页全局目录)→ PUD(页上级目录)→ PMD(页中间目录)→ PTE(页表项)。48 位虚拟地址被切分为 4 个 9 位的索引加 12 位的页内偏移:最高 9 位索引 PGD(512 项),依次索引 PUD、PMD、PTE,每级目录恰好占一个 4KB 页,最后 12 位是页内偏移。翻译一次地址,最坏情况需要访问 4 级页表加 1 次数据访问,共 5 次内存访问——这就是为什么必须有 TLB。分级设计的精妙之处在于按需分配:一个只用了少量内存的进程,只需要一个 PGD 页和寥寥几个下级页表,未使用的大片地址空间对应的子树根本不存在。PUD 和 PMD 级别的表项还可以直接指向 1GB 和 2MB 的大页,此时翻译提前终止,少走一到两级。Linux 5 级页表(LA57)在 PGD 之上再加一级 P4D,把虚拟地址扩展到 57 位,用于支持超大内存的服务器和未来的持久内存。
地址翻译的完整过程值得逐步走一遍。CPU 执行 mov rax, [rbx] 时,MMU 拿到虚拟地址后:第一步查 TLB,命中则直接得到物理地址,整个翻译只需不到 1 个周期;第二步 TLB 未命中,硬件页表遍历器(Page Table Walker)从 CR3 寄存器取得当前进程 PGD 的物理地址,依次用虚拟地址的各段索引读出 PUD、PMD、PTE,每级读取本身也是内存访问(好在页表页通常都在缓存里),最终 PTE 给出目标物理页的页框号;第三步检查权限位——PTE 中的 R/W、U/S、NX 位决定这次访问是否合法,用户态写只读页会触发段错误,执行数据页会触发 NX 保护异常;第四步把翻译结果填入 TLB,供后续访问复用。软件层面,操作系统在进程切换时更新 CR3 指向新进程的页表,并通过 ASID/PCID 机制避免切换时全量刷新 TLB。整个流程中,程序员能感知到的性能变量是:TLB 命中率、页表遍历的缓存命中率、以及权限检查失败引发的异常。
TLB(Translation Lookaside Buffer)是专门缓存「虚拟页号 → 物理页框号」翻译结果的高速缓存,是虚拟内存性能的生命线。现代 x86 CPU 的 TLB 也是分级的:L1 dTLB 通常 64-72 项(全相联),L1 iTLB 128 项左右,L2 统一 TLB 1024-2048 项;4KB 页和 2MB 大页各有独立的 TLB 表项池。TLB 命中的代价几乎为零(与 L1 缓存访问并行进行),而 TLB 未命中需要硬件页表遍历,代价 10-30 个周期;如果页表本身也不在缓存中,代价可达上百周期;最坏情况触发缺页异常,代价是百万周期量级。减少 TLB 未命中的方法按优先级排列:使用大页(效果最显著,一项顶五百项);改善数据访问的页局部性(让热数据集中在少数页内,比如把频繁一起访问的字段放进同一个结构体、对数据结构按页对齐分配);减小工作集(循环分块同样适用于 TLB——分块大小不超过 TLB 覆盖范围);避免随机跳跃访问大数组。perf stat -e dTLB-load-misses 可以直接测量。
缺页异常(Page Fault)是虚拟内存「按需分配」承诺的兑现机制。当访问的虚拟页在页表中标记为「不在内存中」时,MMU 触发异常,控制权交给内核的缺页处理程序。内核首先判断这次访问是否合法:如果虚拟地址不在任何 VMA(虚拟内存区域)内,或者违反了权限(写只读页),发送 SIGSEGV,进程崩溃;如果是合法访问,内核分配一个物理页框,如果是文件映射页则从磁盘读入内容(匿名页则清零),更新页表项,重新执行触发异常的指令——应用程序对这一切毫无感知。缺页分为三类:次缺页(Minor Fault,页已在内存中,只需建立映射,如 COW 页的复制、页缓存命中,代价约 1-2 微秒)、主缺页(Major Fault,需要从磁盘读页,代价约 5-10 毫秒,是次缺页的数千倍)、无效缺页(非法访问,进程被杀)。用 /usr/bin/time -v 或 getrusage 可以统计进程的缺页次数,主缺页数量是诊断 I/O 性能问题的关键指标。
mmap 是虚拟内存机制暴露给用户态的最强大接口,它把文件(或匿名内存)直接映射进进程的地址空间,之后读写文件就像读写数组一样,无需 read/write 系统调用。其原理是:mmap 只在进程的 VMA 列表中登记一个区域并设置页表权限,不实际读入任何数据;第一次访问某页时触发缺页异常,内核才从 page cache(页缓存)建立映射;如果该页已在页缓存中(比如刚被 read 过,或被其他进程映射过),这是次缺页,几乎免费。mmap 的应用场景包括:大文件的高效随机访问(数据库存储引擎的标配,如 LMDB、RocksDB 的早期版本)、进程间共享内存(MAP_SHARED 匿名映射或映射同一文件)、动态库加载(内核加载 ELF 时就是用 mmap 映射代码段和数据段)、以及大块内存分配(glibc 的 malloc 对超过 128KB 的请求直接用 mmap)。需要注意的坑:写 MAP_SHARED 映射后需要 msync 或依赖内核回写才能保证落盘;映射文件被截断后访问会收到 SIGBUS;随机访问大文件时 mmap 的缺页开销可能不如带缓冲的 read。
COW(Copy-on-Write,写时复制)是虚拟内存「共享」能力的经典应用,也是 fork 系统调用高效的关键。fork 创建子进程时,内核并不复制父进程的物理内存——那将极其昂贵——而是让父子进程的页表指向相同的物理页,同时把这些页全部标记为只读。之后任何一方尝试写入某页时,触发保护异常,内核在异常处理程序中分配一个新物理页,复制原页内容,更新写入方的页表指向新页并恢复可写权限,然后重新执行写指令。这样,只有真正被修改的页才付出复制代价,从未修改的页(尤其是代码段和大部分数据)永远共享。COW 的现代意义远超 fork:Redis 的 BGSAVE 依赖 COW 实现快照期间的一致性视图;KSM(内核同页合并)用 COW 合并虚拟机的相同内存页;容器运行时的高效启动也受益于 COW。COW 的性能陷阱在于:fork 后父子进程如果都大量写入,缺页复制的开销会集中爆发,且页表本身的复制(大内存进程可达 GB 级)在 fork 时是同步完成的,这就是大内存 Redis 实例 fork 卡顿的根源。
交换空间(Swap)是虚拟内存「容量超过物理内存」承诺的支撑。当物理内存紧张时,内核的页面回收机制(kswapd 和直接回收路径)会挑选不活跃的匿名页(堆、栈等无文件后备的页)写入磁盘上的交换分区或交换文件,腾出物理页框;被换出的页在页表中标记为「换出」并记录交换槽位置,下次访问时触发主缺页,从磁盘换回。挑选牺牲页的依据是双链表 LRU(active/inactive)加最近访问位扫描,文件页则优先直接丢弃(干净页)或回写(脏页),因为有文件后备不需要占用交换空间。swappiness 参数(0-100)控制内核在匿名页和文件页回收之间的倾向。现代实践对 swap 的态度已经演变:数据库服务器通常设置 swappiness=1 或禁用 swap,因为主缺页的毫秒级延迟对在线服务是灾难;而桌面系统和容器宿主机保留适量 swap 可以提高内存利用弹性。zswap/zram 用压缩内存代替磁盘交换,把换页代价从毫秒降到微秒,是近年来的折中方案。
代码示例
这段代码演示用 mmap 高效处理大文件:统计一个 1GB 文件中某字节的出现次数。与 read 循环相比,mmap 版本没有系统调用开销,没有用户态缓冲区复制,页面通过缺页异常按需从页缓存映射进来,代码却简单得多——就是一个指针遍历。关键 API 细节:PROT_READ 表示只读映射,MAP_PRIVATE 表示私有映射(写入会触发 COW,不影响文件),第三个参数 fd 是被映射文件的描述符。fstat 用于获取文件大小作为映射长度。munmap 释放映射。注意两个实践要点:第一,如果随后还要用其他程序处理该文件,映射访问会把文件内容拉进页缓存,客观上起到预读作用;第二,对顺序访问模式,可以用 madvise(addr, len, MADV_SEQUENTIAL) 提示内核启用激进的预读,实测能让大文件扫描提速 30% 以上;反过来随机访问时用 MADV_RANDOM 关闭预读,避免浪费带宽。
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main(int argc, char **argv) {
int fd = open(argv[1], O_RDONLY);
struct stat st;
fstat(fd, &st);
char *p = mmap(NULL, st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
madvise(p, st.st_size, MADV_SEQUENTIAL);
long count = 0;
for (off_t i = 0; i < st.st_size; i++)
if (p[i] == 'a')
count++;
printf("count = %ld\n", count);
munmap(p, st.st_size);
close(fd);
return 0;
}这段代码演示 COW 机制在 fork 中的行为,以及如何观察它。父进程分配一个 64MB 的匿名映射并全部写入(此时 16384 个物理页已全部分配),然后 fork。子进程遍历整个区域读取每个页的第一个字节——由于 COW,父子共享所有物理页,子进程的读操作不触发任何复制;接着子进程写入每个页的第一个字节——每次写入触发一次 COW 缺页,内核为子进程复制该页。运行前后对比 /proc/PID/status 中的 RssAnon 字段或用 /usr/bin/time -v 观察 Minor page faults:你会看到子进程写入阶段产生约 16384 次次缺页,RSS 从接近 0 涨到 64MB。这个实验把抽象的 COW 变成了可测量的数字。延伸思考:如果父进程 fork 后只读不写,两个进程可以永远共享这 64MB——这正是 Redis BGSAVE 期间的工作模式,也解释了为什么写负载越重,fork 后的内存翻倍问题越严重。
#include <sys/mman.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#define SIZE (64 * 1024 * 1024)
int main(void) {
char *p = mmap(NULL, SIZE, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
memset(p, 1, SIZE);
pid_t pid = fork();
if (pid == 0) {
for (long i = 0; i < SIZE; i += 4096)
p[i] = 2;
_exit(0);
}
wait(NULL);
printf("child done, COW copies triggered\n");
return 0;
}这段代码演示如何使用 Linux 的 hugetlbfs 或 THP 获取 2MB 大页,并对比 TLB 覆盖范围。方式一是显式大页:先由管理员预留大页(echo 512 > /proc/sys/vm/nr_hugepages),然后 mmap 时指定 MAP_HUGETLB 标志。方式二是透明大页:普通匿名映射后,用 madvise 的 MADV_HUGEPAGE 提示内核尽量用大页支撑该区域(需要系统开启 THP)。分配后写入整个区域,再用 grep -i huge /proc/self/smaps 查看该 VMA 实际使用了多少 AnonHugePages。性能对比实验:写一个随机访问 4GB 数组的循环,分别在 4KB 页和大页模式下用 perf stat -e dTLB-load-misses 测量——4KB 模式下 TLB 只能覆盖约 8MB(2048 项 × 4KB),未命中率极高;大页模式覆盖 4GB(2048 项 × 2MB),未命中率接近零,随机访问性能提升可达 2-3 倍。这就是为什么 Oracle、MySQL InnoDB 缓冲池、JVM 大堆都强烈建议使用大页。
#include <sys/mman.h>
#include <stdio.h>
#include <string.h>
#define GB (1024UL * 1024 * 1024)
int main(void) {
char *p = mmap(NULL, 2 * GB, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB, -1, 0);
if (p == MAP_FAILED) {
p = mmap(NULL, 2 * GB, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
madvise(p, 2 * GB, MADV_HUGEPAGE);
}
memset(p, 1, 2 * GB);
printf("check /proc/self/smaps for AnonHugePages\n");
getchar();
return 0;
}这段代码演示如何读取并解析 /proc/self/maps,观察自己进程的虚拟地址空间布局——这是把「地址空间」从概念变成眼见为实的最好方法。maps 文件每行描述一个 VMA:起止地址、权限(r 读 w 写 x 执行 p 私有 s 共享)、文件偏移、设备号、inode 和映射的文件路径。运行这个程序,你会看到教科书式的布局:最低处是 r-xp 权限的可执行文件代码段,紧跟着 r--p 的只读数据段和 rw-p 的可写数据段(BSS),中间是大片没有列出的空洞(未映射区域),然后是 r-xp 的 libc 代码段映射,靠近顶部的是 [stack],还有 [vdso](内核提供的虚拟系统调用页,用于加速 gettimeofday 等)。动手实验:在程序中调用 malloc(1<<20) 前后各打印一次 maps,观察堆区或新 mmap 区的出现;再 mmap 一个文件,观察新 VMA 的插入位置。理解 maps 输出是诊断内存泄漏、段错误地址定位(对照崩溃地址落在哪个 VMA)的基本功。
#include <stdio.h>
#include <stdlib.h>
int main(void) {
FILE *f = fopen("/proc/self/maps", "r");
char line[512];
while (fgets(line, sizeof(line), f))
fputs(line, stdout);
fclose(f);
return 0;
}实验笔记
本章实验围绕「让虚拟内存可见」展开,共四个任务。任务一「绘制自己的地址空间」:编写程序依次执行 malloc 小块内存、malloc 1MB、mmap 一个文件、创建线程,在每个动作后解析 /proc/self/maps 并输出差异,最终画出一张完整的进程地址空间演化图,标注代码段、数据段、堆、mmap 区、栈、vdso 的位置和权限。任务二「测量缺页成本」:用 clock_gettime 精确计时,分别测量三种操作的耗时——首次写入一个新 mmap 区域的每个页(触发次缺页)、写入一个已被 MADV_DONTNEED 释放的区域(再次触发次缺页)、读取一个未预热的磁盘文件映射(触发主缺页),计算单次缺页的平均纳秒数,并解释为什么主缺页比次缺页慢三个数量级。任务三「TLB 覆盖范围实验」:实现一个随机访问 N 个页的基准程序(N 从 64 到 65536 倍增),测量每访问延迟随 N 的变化曲线,曲线的拐点就是 L1/L2 TLB 的有效容量;然后在开启大页的模式下重复实验,对比覆盖范围的提升。任务四「COW 与 fork 开销」:分配不同大小(1MB 到 4GB)的内存并写入,测量 fork 本身的耗时随父进程内存大小的增长关系(页表复制是同步的),再测量子进程全量写入时 COW 缺页的总开销,最后用 /proc/PID/status 的 VmRSS 验证父子进程的物理内存共享与分裂过程。每个任务都要求提交数据表格和一段解释性分析,重点不是数字本身,而是数字与页表、TLB、缺页处理机制之间的因果联系。
延伸阅读
- Drepper 原论文第 2、3 章:详细讲解了虚拟内存的硬件基础(MMU、页表格式、TLB 结构)以及 Linux 特有的实现细节,特别是 2.3 节对虚拟地址空间类型的分类(匿名页、文件页、共享页)非常清晰。
- CSAPP 第 9 章「虚拟内存」:从程序员视角讲解 VM 的最佳入门材料,9.8-9.11 节的内存映射、动态内存分配与垃圾收集内容把 VM 概念直接落到 C 编程实践。
- 《Understanding the Linux Virtual Memory Manager》(Mel Gorman):Linux 内存管理子系统的深度剖析,涵盖 VMA、伙伴系统、slab、页面回收和交换的完整实现,是内核方向的必读经典,全文可在线免费获取。
- Linux 内核文档 Documentation/admin-guide/mm/ 目录:关于 THP、hugetlbfs、swappiness、KSM 的官方文档,配置大页和调优内存回收参数时应首先查阅。
- Peter Denning 的论文《Virtual Memory》(ACM Computing Surveys, 1970):虚拟内存概念的历史文献,Denning 后来提出的工作集理论(Working Set Theory)是理解缺页行为的理论基石。
- Brendan Gregg《Systems Performance》第 7 章 Memory:展示如何用 perf、/proc、eBPF 工具在生产系统上诊断虚拟内存问题,包含 TLB 分析、缺页追踪和 swap 抖动的实战案例。
- 《深入理解计算机系统》配套实验 Malloc Lab:亲手实现一个动态内存分配器,虽然主题偏向分配器,但会迫使你深入理解 sbrk/mmap 与虚拟内存的交互方式。