Skip to content

第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 机制
  1. 理解虚拟地址到物理地址的翻译过程
  2. 掌握多级页表的原理和为什么它节省内存
  3. 理解 TLB 的作用和失效代价
  4. 掌握页面置换算法:FIFO / LRU / Clock
  5. 理解缺页中断(Page fault)的类型和处理流程
  6. 掌握 mmap 和内存映射文件
  7. 理解 Overcommit 的原理和风险

遭遇战:为什么每个进程都以为自己是老大?

遭遇 1 · 地址翻译初体验

c
/* 查看虚拟地址映射 */
#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 开始的连续私有空间。它不需要知道物理内存在哪里,甚至不需要知道物理内存有多大。这就像每个人都得到一个"想象中的豪宅"的设计图——你可以在上面自由规划房间布局。真正建造时(分配物理页),系统才去库房里找砖头。

三个关键作用

  1. 隔离性:进程 A 无法访问进程 B 的物理内存——页表不让它看见。C 语言里常见的"野指针"往往只崩溃当前进程,不会损坏其他进程的数据,这就是虚拟内存在保护你。
  2. 简化内存管理:每个进程看到连续空间,不需要关心物理内存碎片的分配
  3. 共享与优化:同一个物理页可以映射到多个进程的虚拟地址空间(共享库、内核代码)。你用 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 级页表为例):

text
虚拟地址: 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"

text
线性页表:
 [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 四级页表):

text
虚拟地址 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)是页表的缓存:

text
CPU 发虚拟地址

 ├── TLB 命中? ──→ 直接得到物理地址 (1 cycle)

 └── TLB 失效? ──→ 走 4 级页表 (几十到上百 cycle)

 └── 更新 TLB ← 下一次就快

TLB 的终极问题:它很小!(L1 TLB 通常 64-128 条目)

c
/* 触发 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 失效。

bash
# 启用透明巨型页
echo always > /sys/kernel/mm/transparent_hugepage/enabled

页面置换算法

物理内存满了,要加载新页面——踢谁出去

FIFO(先进先出)

页面: [A] [B] [C] [D]
 ↑ ↑
 最早进入 新页进来踢 A

问题:可能踢掉高频使用的页面。而且会发生诡异的 Belady 异常——增加物理内存帧数,缺页率反而上升!这在直觉上完全违反常识,但 FIFO 确实会这样:

text
引用序列: 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 → 进程崩溃
c
/* 演示 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 让你把文件直接映射到虚拟地址空间,像访问内存一样读写文件。

c
#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 三大优势

  1. 延迟加载:文件页不在物理内存时才缺页读取(类似 Demand paging)
  2. 避免内核→用户拷贝(读路径):mmap 把内核页缓存的物理页直接映射到进程虚拟地址空间。读文件时 read() 需要把数据从内核缓冲区拷贝到用户缓冲区,mmap 免掉了这层拷贝。但要注意这不是完全零拷贝——write()memcpy() 时仍然存在从映射区到目标位置的拷贝
  3. 共享内存MAP_SHARED 让多进程共享同一映射,修改立即可见

mmap vs read

操作read + writemmap
系统调用次数每次 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 传统拷贝

写一个比较程序:

  1. read() + write() 拷贝 1 GB 文件
  2. mmap + memcpy 拷贝同样文件
c
/* 伪代码 */
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 算法模拟器:

c
#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章:异常与系统调用——用户态的边界

地心的平静被一声警报打破了。你写的程序触发了系统异常——也许是除以零,也许是非法内存访问。从用户态到内核态,中间隔着一道不可见的墙。下一章,你将穿越这道墙。

Built with VitePress | Software Systems Atlas