第6章:虚拟内存——内存的幻术
元数据卡
| 属性 | 值 |
|---|---|
| 难度 | (深入) |
| 前置 | 第5章(缓存体系) |
| 关键词 | Virtual address · Page table · TLB · Page fault · mmap · Overcommit |
| C 标准 | POSIX (Linux) |
| 配套实验 | lab/vm-explorer/ — 页表遍历与 TLB 失效分析 |
你的进度
"你已经学会了缓存怎样弥合 CPU 和内存的速度差距。但地心还有一个更神奇的幻术——虚拟内存。每个程序都觉得自己拥有整片地址空间,互不干扰。这是操作系统为 CPU 制造的魔法。"
这是你的进程看到的内存布局:
0x0000000000000000 ─┬─── .text (代码段)
├─── .rodata (只读数据)
├─── .data (初始化全局变量)
├─── .bss (未初始化全局变量)
├─── Heap (堆 · malloc 区域)
│ ↓ 增长方向
├─── mmap 区域 (共享库、内存映射文件)
│ ↑ 增长方向
├─── Stack (栈)
0x00007fffffffffff ─┘但这全是幻觉。你的进程看到的是一个个独立的 48-bit 虚拟地址空间(约 256 TB)。所有进程加起来,如果物理内存只有 16 GB——怎么做到每人 "256 TB" 的?
答案:操作系统的内存管理单元(MMU)+ 页表在作弊。
你的任务
本章分层
- 必读:虚拟地址 vs 物理地址的核心概念;多级页表如何实现地址翻译(直觉理解树形结构);页表条目中的关键标志位
- 选读:TLB 的作用与失效代价;mmap 的三大优势(延迟加载/零拷贝/共享内存)
- 进阶:Clock 页面置换算法的实现细节;Overcommit 的原理与风险、OOM killer 本章不会要求你掌握
- TLB 的详细结构(局部 TLB、全局 TLB 等)
- 页面置换算法的具体实现与性能对比
- 透明巨型页的 defragmentation 机制
- 理解虚拟地址到物理地址的翻译过程
- 掌握多级页表的原理和为什么它节省内存
- 理解 TLB 的作用和失效代价
- 掌握页面置换算法:FIFO / LRU / Clock
- 理解缺页中断(Page fault)的类型和处理流程
- 掌握 mmap 和内存映射文件
- 理解 Overcommit 的原理和风险
遭遇战:为什么每个进程都以为自己是老大?
遭遇 1 · 地址翻译初体验
/* 查看虚拟地址映射 */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>
int global_var = 42;
int main() {
int stack_var = 10;
int *heap_var = malloc(sizeof(int));
*heap_var = 20;
printf("PID: %d\n" getpid());
printf("Code (.text): %p\n" (void*)main);
printf("Global (.data): %p\n" (void*)&global_var);
printf("Heap: %p\n" (void*)heap_var);
printf("Stack: %p\n" (void*)&stack_var);
/* 查看 /proc/PID/maps */
printf("查看完整映射: cat /proc/%d/maps\n" getpid());
free(heap_var);
/* 同一个程序跑两遍,地址一样吗? */
getchar(); /* 停住让你 /proc 对比 */
return 0;
}用两个终端分别运行一次,对比输出——虚拟地址完全相同!但两进程的物理内存完全不同。
进程 A 的 0x5555555546aa 和进程 B 的 0x5555555546aa —— 名称相同,地址不同物理位置。
核心洞察:虚拟内存让每个进程都看到一个从 0 开始的连续私有空间。它不需要知道物理内存在哪里,甚至不需要知道物理内存有多大。这就像每个人都得到一个"想象中的豪宅"的设计图——你可以在上面自由规划房间布局。真正建造时(分配物理页),系统才去库房里找砖头。
三个关键作用:
- 隔离性:进程 A 无法访问进程 B 的物理内存——页表不让它看见。C 语言里常见的"野指针"往往只崩溃当前进程,不会损坏其他进程的数据,这就是虚拟内存在保护你。
- 简化内存管理:每个进程看到连续空间,不需要关心物理内存碎片的分配
- 共享与优化:同一个物理页可以映射到多个进程的虚拟地址空间(共享库、内核代码)。你用
ls时,libc.so的代码只有一份物理内存拷贝,但 200 个进程各自看到它在自己的空间里——这是虚拟内存的省钱绝招
获得技能 · 虚拟地址 → 物理地址翻译
虚拟地址 (48-bit)
┌─────────┬──────────┬────────────┐
│ VPN │ VPN │ Page │
│ (L4) │ (L3/2/1) │ Offset │
│ 9-bit │ 27-bit │ 12-bit │
└─────────┴──────────┴────────────┘
│ │ └── 页内偏移 (4KB 大小页面)
│ └── 多级页表查询
└── 四级页表索引 (x86-64)翻译步骤(以 4KB 页面 + 4 级页表为例):
虚拟地址: 0x7f3a4b5c6d00
Step 1: 分解地址
VPN[0] (L4) = (0x7f3a4b5c6d00 >> 39) & 0x1FF → 页表顶级索引
VPN[1] (L3) = (0x7f3a4b5c6d00 >> 30) & 0x1FF → 二级索引
VPN[2] (L2) = (0x7f3a4b5c6d00 >> 21) & 0x1FF → 三级索引
VPN[3] (L1) = (0x7f3a4b5c6d00 >> 12) & 0x1FF → 页表条目
Offset = 0x7f3a4b5c6d00 & 0xFFF → 页内偏移
Step 2: 查页表
CR3 寄存器 → 指向 L4 页表基地址
L4[VPN[0]] → 给出 L3 页表物理地址
L3[VPN[1]] → 给出 L2 页表物理地址
L2[VPN[2]] → 给出 L1 页表物理地址
L1[VPN[3]] → 给出物理页帧号 (PFN)
Step 3: 合成的物理地址 = PFN << 12 | Offset获得技能 · 多级页表为什么省内存?
直觉问题:线性页表需要 2²⁰ 个条目(×8 字节 = 8MB)每个进程!如果有 200 个进程 = 1.6 GB 仅用于页表。
为什么非要 4 级?不能 2 级吗?
如果只用 2 级:
- 页面大小 4KB(12-bit offset)
- 虚拟地址 48-bit,剩余 36-bit
- L1 页表需要 2³⁶ = 687 亿个条目(×8 字节 = 5.5 TB!)
显然,2 级页表本身就需要天文数字的物理内存。所以我们需要一个树形结构——每一级缩小一次地址范围。4 级是最佳平衡点:每级索引 9-bit(512 个条目),一个页表页正好占 4KB(512 × 8 字节)。
多级页表的妙招:不需要的级可以被设为 "not present"。
线性页表:
[0][1][2]...[N-1] ← N = 2²⁰,全部存在
多级页表 (2 级为例):
L1: [P][P][NP][P]...[NP] ← 只要 4 个顶级条目 (NP = Not Present)
│ │ │
▼ ▼ ▼
L2: [...] [...] (不需要分配!)实际效果:
- 一个进程使用 100 MB 堆 + 8 MB 栈 + 几 MB 代码 → 总共 ~150 个 4KB 页面
- 线性页表:8 MB(全部预分配)
- 4 级页表:~36 KB(只在需要时分配 L4/L3/L2/L1 页表页)
省了 200 倍以上。
实际地址翻译的完整示例(x86-64 四级页表):
虚拟地址 0x7f3a4b5c6d00
│
├── Level 4: 索引 = (0x7f3a4b5c6d00 >> 39) & 0x1FF = 0x0FC
│ CR3 → PML4T[0x0FC] → 找到 Level 3 页表基址
│
├── Level 3: 索引 = (0x7f3a4b5c6d00 >> 30) & 0x1FF = 0x1D4
│ Level3[0x1D4] → 找到 Level 2 页表基址
│
├── Level 2: 索引 = (0x7f3a4b5c6d00 >> 21) & 0x1FF = 0x15A
│ Level2[0x15A] → 找到 Level 1 页表基址
│
├── Level 1: 索引 = (0x7f3a4b5c6d00 >> 12) & 0x1FF = 0x2C9
│ Level1[0x2C9] 中存储了物理页帧号 (PFN) + 权限位
│
└── 物理地址 = (PFN << 12) | (0x7f3a4b5c6d00 & 0xFFF)
= PFN << 12 | 0xD00页表条目(PTE)结构(x86-64):
63 ────────── 52 ────── 12 ─────── 9 ──── 8 ── 7 ── 6 ── 5 ── 4 ── 3 ── 2 ── 1 ── 0
┌─────────────────┬──────────┬────┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ 物理页帧号 │ 可用位 │ NX │ IG│ PS│ A │ D │ A │ PCD│PWT│U/S│ R/W│ P │
│ (PFN) │ (AVL) │ │ │ │ │ │ │ │ │ │ │ │
└─────────────────┴──────────┴────┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘关键标志位:
- P (Present): 物理页是否在内存中 → 决定是否触发缺页中断
- R/W: 读/写权限 → 实现写时复制(COW)的基础
- U/S: 用户/超级用户 → 内核态与用户态隔离
- A (Accessed): 页面是否被访问 → 供置换算法使用
- D (Dirty): 页面是否被写入 → 决定要不要写回磁盘
- NX (No-Execute): 禁止执行 → DEP(数据执行保护)的基础
常见陷阱
进阶排障:TLB 是性能排障中的重要环节——当你的程序有大量跨页访问时,TLB 失效可能是性能瓶颈。以下内容在实际排障中才会用到,初次阅读了解即可。
TLB —— 地址翻译的加速缓存
每次地址翻译都走 4 级页表 = 4 次内存访问?那是灾难。
TLB(Translation Lookaside Buffer)是页表的缓存:
CPU 发虚拟地址
│
├── TLB 命中? ──→ 直接得到物理地址 (1 cycle)
│
└── TLB 失效? ──→ 走 4 级页表 (几十到上百 cycle)
│
└── 更新 TLB ← 下一次就快TLB 的终极问题:它很小!(L1 TLB 通常 64-128 条目)
/* 触发 TLB 失效的代码 */
/* 步长 = 4MB(跨越大量页面) */
int *big = malloc(256 * 1024 * 1024); /* 256 MB */
for (size_t i = 0; i < 256 * 1024 * 1024 / sizeof(int);
i += 1024 * 1024) { /* 每 4MB 跳一次 */
sum += big[i]; /* 每次都触发 TLB miss! */
}巨型页(Huge Pages / 2MB pages):用 2MB 页面代替 4KB 页面,一次 TLB 覆盖 2MB,大幅减少 TLB 失效。
# 启用透明巨型页
echo always > /sys/kernel/mm/transparent_hugepage/enabled页面置换算法
物理内存满了,要加载新页面——踢谁出去?
FIFO(先进先出)
页面: [A] [B] [C] [D]
↑ ↑
最早进入 新页进来踢 A问题:可能踢掉高频使用的页面。而且会发生诡异的 Belady 异常——增加物理内存帧数,缺页率反而上升!这在直觉上完全违反常识,但 FIFO 确实会这样:
引用序列: 1 2 3 4 1 2 5 1 2 3 4 5
3 帧 FIFO: 9 次缺页
4 帧 FIFO: 10 次缺页 ← 更多帧,更多缺页!这是因为 FIFO 对引用模式有"盲区",完全不管页面被访问的频率。
LRU(最近最少使用)——理论最优
访问顺序: A B C A D C B
↑
踢掉 LRU(上次用是 3 步前)问题:精确 LRU 需要在每次访问时更新链表——代价太高!每个内存访问都需要硬件记录时间戳,在 4KB 页上做这事的开销比页面置换省下的时间还多。因此 LRU 更多作为理论基准——衡量其他算法离最优有多远。
LRU 的 Stack 性质:LRU 有一个数学上的优点——它产生一个"包含性栈",即 n+1 帧的驻存集始终是 n 帧驻存集的超集。这意味着增加帧数永远不会增加缺页次数。这避免了 Belady 异常,也是它"最优"的证据之一。
Clock 算法(近似 LRU)——实际使用
Linux 内核实际用的算法:
每个页面有个"使用位"(reference bit):
┌───[A:1]───[B:0]───[C:1]───[D:0]───┐
│ ↑ (时钟指针) │
└────────────────────────────────────┘
选页时:
如果使用位 = 1 → 清 0,移向下一位
如果使用位 = 0 → 淘汰它
效果:最近用过的页面得到"第二次机会"多数现代 Linux 用 改进版 Clock(NRU / 第二次机会) + 页面老化机制。不仅看使用位,还看修改位。
缺页中断(Page Fault)
缺页不都是"出错了"——实际上它是虚拟内存正常的工作方式。
三种缺页:
| 类型 | 原因 | 处理方式 |
|---|---|---|
| Major | 页不在物理内存且不在任何缓存中 | 从磁盘读入(最慢!几 ms) |
| Minor | 页在内存中但页表标记为"未就绪" | 只需更新页表(几十 μs) |
| Segfault | 访问非法地址(未映射区域) | 信号 SIGSEGV → 进程崩溃 |
/* 演示 Minor page fault */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main() {
/* malloc 只是预订地址空间,未真正分配物理页 */
char *buf = malloc(1024 * 1024 * 100); /* 100 MB */
/* 首次写入 → 触发 Minor page fault,分配物理页 */
for (size_t i = 0; i < 100 * 1024 * 1024; i += 4096) {
buf[i] = 1; /* 每 4KB 触一次缺页 */
}
/* 第二次写 → 不触缺页,因为物理内存已分配 */
buf[i] = 2; /* 全部在内存中,无缺页 */
}
free(buf);
return 0;
}用 perf stat ./demo 观察第一次循环的 page-faults 事件——几百次 Minor faults,无 Major faults。
mmap —— 内存映射文件
mmap 让你把文件直接映射到虚拟地址空间,像访问内存一样读写文件。
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
int main() {
int fd = open("large_file.bin" O_RDWR);
if (fd < 0) return 1;
off_t file_size = lseek(fd 0 SEEK_END);
/* 映射整个文件到虚拟内存 */
char *mapped = mmap(NULL file_size
PROT_READ | PROT_WRITE
MAP_SHARED fd 0);
if (mapped == MAP_FAILED) return 1;
/* 像数组一样访问文件! */
printf("First byte: %d\n" mapped[0]);
/* 修改 → 自动写回文件(由内核管理) */
mapped[0] = 'A';
/* 不需要 write() 系统调用!*/
/* msync(mapped file_size MS_SYNC); */ /* 强制同步 */
munmap(mapped file_size);
close(fd);
return 0;
}mmap 三大优势:
- 延迟加载:文件页不在物理内存时才缺页读取(类似 Demand paging)
- 避免内核→用户拷贝(读路径):
mmap把内核页缓存的物理页直接映射到进程虚拟地址空间。读文件时read()需要把数据从内核缓冲区拷贝到用户缓冲区,mmap免掉了这层拷贝。但要注意这不是完全零拷贝——write()或memcpy()时仍然存在从映射区到目标位置的拷贝 - 共享内存:
MAP_SHARED让多进程共享同一映射,修改立即可见
mmap vs read:
| 操作 | read + write | mmap |
|---|---|---|
| 系统调用次数 | 每次 I/O 必须系统调用 | 首次缺页后无需系统调用 |
| 读路径拷贝 | 内核→用户缓冲区拷贝(read 系统调用触发) | 无额外拷贝(直接访问映射的页缓存) |
| 随机访问 | 需 lseek + read | 直接数组索引 |
| 大文件 | 容易 OOM(需分块读) | 按需缺页,虚存无限 |
进阶排障:Overcommit 是 Linux 系统调优中容易踩坑的配置项。以下是面向系统管理员的进阶内容,不需要作为普通读者的通关任务。
tdlib.h>
int main() { /* 申请 100 TB */ void *p = malloc(100ULL * 1024 * 1024 * 1024 * 1024);
if (p == NULL) { printf("malloc failed\n"); return 1; }
printf("malloc 100TB succeeded! p = %p\n" p); /* 但还没用物理内存! */
/* 真正写的时候: / for (size_t i = 0; i < 100ULL * 1024 * 1024 * 1024 * 1024; i += 4096) { ((char)p)[i] = 1; /* 这行会触发 OOM killer */ }
return 0; }
**Overcommit 原理**:你: malloc(100TB) 内核: "好!" (记录在 VMA,不分配物理页) 你: 写入数据 内核: "哦豁,物理内存不够了" → OOM killer 杀掉某个进程
**控制级别**(`/proc/sys/vm/overcommit_memory`):
| 值 | 模式 | 行为 |
|----|------|------|
| 0 | Heuristic(默认) | 看总申请是否 < overcommit_ratio × swap |
| 1 | Always | 永远允许,死到临头再说 |
| 2 | Never | 禁止过载,malloc 超过限度就返回 NULL |
**为什么默认 Overcommit?**
- `fork()` 后 `exec()` 前,子进程会**复制父进程的全部地址空间**(COW)
- 如果每个 fork 都要保证物理内存够用,大型系统没法 fork
- COW(Copy-on-Write)页只在写入时才分配——内核赌你不写
** 风险**:在生产系统上,`overcommit_memory = 2` 更安全——malloc 失败比进程被 OOM killer 杀掉更可控。
```bash
# 将生产服务器设为不超卖
echo 2 > /proc/sys/vm/overcommit_memory
echo 50 > /proc/sys/vm/overcommit_ratio # 最多用 50% 的 swap通关挑战
进阶实验(系统程序员可选实验):
挑战 1:mmap 拷贝 vs 传统拷贝
写一个比较程序:
read() + write()拷贝 1 GB 文件mmap+ memcpy 拷贝同样文件
/* 伪代码 */
void copy_via_readwrite(const char *src const char *dst) {
char buf[65536];
int sfd = open(src O_RDONLY);
int dfd = open(dst O_WRONLY | O_CREAT 0644);
ssize_t n;
while ((n = read(sfd buf sizeof(buf))) > 0)
write(dfd buf n);
}
void copy_via_mmap(const char *src const char *dst) {
int sfd = open(src O_RDONLY);
int dfd = open(dst O_RDWR | O_CREAT 0644);
off_t size = lseek(sfd 0 SEEK_END);
ftruncate(dfd size);
void *s = mmap(NULL size PROT_READ MAP_PRIVATE sfd 0);
void *d = mmap(NULL size PROT_WRITE MAP_SHARED dfd 0);
memcpy(d s size); /* 内核帮你完成页对页拷贝 */
}预期结果:mmap 版本通常比 read+write 快,但具体快多少取决于文件大小和访问模式(缺页开销 vs 系统调用开销的权衡)。mmap + memcpy 省的是系统调用和读路径的内核→用户拷贝,但 memcpy 本身仍有拷贝成本,且 MAP_SHARED 的脏页落盘时机不可控。对于大文件连续拷贝,sendfile() 可能更适合。
进阶实验(仅在理解页面置换算法后尝试):
进阶实验(仅在理解页面置换算法后尝试):
挑战 2:模拟 Clock 页面置换
实现一个 Clock 算法模拟器:
#define FRAMES 4
#define REFS 20
int refs[REFS] = {7 0 1 2 0 3 0 4 2 3 0 3 2 1 2 0 1 7 0 1};
/* Clock 算法会淘汰哪个页面?记录每次缺页 */输出: 对比 FIFO / LRU / Clock 三种算法在这个序列上的缺页次数。
验收标准
- [ ] 能解释虚拟地址翻译的完整过程(CR3 → 4 级页表 → 物理地址)
- [ ] 能说明多级页表如何比线性页表节约内存
- [ ] 能解释为什么 TLB 失效比缺页中断快了 5-6 个数量级
- [ ] 能比较 FIFO / LRU / Clock 三种置换算法
- [ ] 能用 mmap 代替 read/write 实现文件拷贝
- [ ] 能区分 Major page fault 和 Minor page fault
常见卡点
| 卡点 | 澄清 |
|---|---|
| "64-bit CPU 不就是 64 位地址空间吗?" | 不对。x86-64 只用了 48-bit 虚拟地址(256 TB),高 16 位是符号扩展。未来有 57-bit(512 PB 级别)的 5 级页表。 |
| "进程地址空间是连续的吗?" | 虚拟地址看是连续的,物理内存完全不连续。页表将分散的物理页帧映射为连续的虚拟空间。 |
"malloc 到底做了什么?" | 小对象从 heap(brk)分配,大对象用 mmap 分配。两者都只是记录 VMA,不分配物理页。物理页在首次写时分配。 |
| "OOM killer 杀谁?" | 内核给每个进程一个"坏评分"(oom_score)。杀得分最高的——通常是大内存泄漏的那个倒霉蛋。echo -17 > /proc/PID/oom_adj 可豁免某个进程。 |
现在不需要理解
- swap 分区 / 交换:把物理内存页换到磁盘,虚拟内存的"最后防线"
- KSM(Kernel Same-page Merging):合并多个进程相同的匿名页(KVM 场景用)
- ASLR 细粒度布局:地址空间布局随机化的各种策略(PIE、PIC、guard page)
- 透明巨型页的 defragmentation:自动把 4KB 页合并为 2MB 巨型页
旅人笔记
虚拟内存是整个计算机系统里最精妙的谎言。每个进程都觉得自己拥有 256 TB 的连续内存——但实际上它连物理内存的碎片都看不到。页表是这个幻术的幕后机器,TLB 是它加速的关键齿轮。
mmap 改变了我对文件 I/O 的认知——原来文件不需要"读"进来,把它映射到地址空间就能当内存用,缺页时操作系统自动加载。这是"零拷贝"精神的体现。
而 Overcommit 是计算机界的"次贷危机"——内核假装物理内存无限,直到一个进程真的去用,然后大家一起完蛋。在关键系统上始终配置
overcommit_memory=2,让 malloc 在绝境中礼貌地拒绝你,而不是让 OOM killer 莽撞地杀掉你身边的进程。本章最重要的实操收获:理解虚拟地址翻译的成本。每次指针解引用背后可能藏着 4 次内存访问(四级页表)、1 次 TLB 查找、以及可能的缺页中断。这意味着一次毫秒级的磁盘 I/O 可能被隐藏在你的
p->field这个看似无害的代码里。当你优化性能时,别只看算法复杂度——问问自己数据到底在哪:L1?DRAM?还是已经被换出到 swap 了?
→ 下一站预告
第7章:异常与系统调用——用户态的边界
地心的平静被一声警报打破了。你写的程序触发了系统异常——也许是除以零,也许是非法内存访问。从用户态到内核态,中间隔着一道不可见的墙。下一章,你将穿越这道墙。