虚拟内存
一句话本质:虚拟内存是硬件(MMU)+ 操作系统共同提供的一块“每人独享整片地址空间”的幻觉——程序以为自己霸占了从 0 到 4GB/256TB 的全部内存,实际上物理内存被所有进程切片共享,地址翻译由 MMU 在每条指令取数时悄悄完成。
CS61C 站在机器/架构视角看虚拟内存:我们关心的是「一条 lw 指令里的地址是怎么变成内存条上某个字节的物理位置的」「为什么要分页」「TLB 为什么能让翻译几乎免费」。至于缺页时操作系统怎么换页、怎么选受害者页,那是 CS162 的主场(本文只在末尾点到为止并附对照)。
1. 为什么需要虚拟内存
没有虚拟内存的裸机世界里,所有程序直接操作物理地址,会带来三个老大难:
- 隔离性为零:进程 A 写错一个指针就可能踩烂进程 B 的数据,甚至改掉内核。
- 地址空间碎片:程序加载时得去找一块足够大的连续物理内存,内存用久了就「东一块西一块」。
- 无法超配(overcommit):物理内存只有 8GB,程序却想用 16GB?直接没门。
虚拟内存用一层地址抽象同时解决这三点:
- 每个进程拿到一个独立的虚拟地址空间(virtual address space),互相看不到对方;
- 虚拟地址到物理地址的映射可以离散(一页一页散落在物理内存各处),碎片问题消失;
- 暂时不用的页可以换到磁盘,物理内存被「放大」成看起来很大的空间。
2. 地址翻译:VA → PA 的核心路径
CPU 发出的地址是虚拟地址(VA),内存条只认物理地址(PA)。中间的翻译官叫 MMU(Memory Management Unit),它查一张由 OS 维护、硬件读取的表——页表(page table)。
翻译是「按页(page)对齐」的:虚拟地址被切成两截——高位虚拟页号 VPN(查表用)+ 低位页内偏移 offset(直接拼到物理地址,不参与翻译)。如果页大小是 4KB(4096 字节 = 2^12),那么低 12 位永远是 offset,高位的才是页号。
3. 分页:页表与 PTE
页表是一张数组,下标 = VPN,内容 = PTE(Page Table Entry,页表项)。一个简化 PTE 至少含:物理页框号 PFN(指向物理内存的哪一页)、有效位(这页映射了没)、权限位(可读/可写/可执行)。
下面这段 C 代码完整演示单级页表的地址翻译(含缺页处理),数字与第 1 步的图一一对应:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| #include <stdio.h> #include <stdint.h>
#define PAGE_SIZE 4096 #define NUM_PAGES 16 #define VPN_BITS 4 #define OFFSET_BITS 12
int page_table[NUM_PAGES] = { 3, -1, 7, 1, -1, 12, 0, 9, 5, 2, -1, 8, 6, -1, 11, 4 };
int translate(uint32_t vaddr, uint32_t *paddr) { uint32_t vpn = (vaddr >> OFFSET_BITS) & ((1u << VPN_BITS) - 1); uint32_t offset = vaddr & ((1u << OFFSET_BITS) - 1); int pfn = page_table[vpn]; if (pfn < 0) return 0; *paddr = ((uint32_t)pfn << OFFSET_BITS) | offset; return 1; }
int main(void) { uint32_t tests[] = {0x0000, 0x1000, 0x2004, 0x3001, 0x5000, 0xFABC}; for (int i = 0; i < 6; i++) { uint32_t va = tests[i], pa; if (translate(va, &pa)) printf("VA=0x%04X -> VPN=%2d off=0x%03X -> PFN=%2d -> PA=0x%04X\n", va, (va >> 12) & 0xF, va & 0xFFF, page_table[(va >> 12) & 0xF], pa); else printf("VA=0x%04X -> 缺页 (page fault)\n", va); } return 0; }
|
输出(gcc 实测一致):
1 2 3 4 5 6
| VA=0x0000 -> VPN= 0 off=0x000 -> PFN= 3 -> PA=0x3000 VA=0x1000 -> 缺页 (page fault) VA=0x2004 -> VPN= 2 off=0x004 -> PFN= 7 -> PA=0x7004 VA=0x3001 -> VPN= 3 off=0x001 -> PFN= 1 -> PA=0x1001 VA=0x5000 -> VPN= 5 off=0x000 -> PFN=12 -> PA=0xC000 VA=0xFABC -> VPN=15 off=0xABC -> PFN= 4 -> PA=0x4ABC
|
注意 0x1000:它的 VPN=1,而 page_table[1] = -1,于是触发缺页——这正是「该虚拟页还没映射到物理内存」的信号,硬件会抛异常给 OS 去处理(见第 6 节)。
4. 多级页表:省内存的关键
单级页表有个致命浪费:32 位地址空间、4KB 页 → 有 2^20 ≈ 100 万条 PTE,每条 4 字节就是 4MB;而一个进程通常只用到其中一小撮页。多级页表(如 x86 的二级/三级/四级)把页表本身也分页,只为「真的用到的那段虚拟地址」分配下一级表。
空间账算一笔:一个只用 8MB 内存的进程,一级页表要 4MB;二级页表只需 1 张页目录(4KB) + 8MB/4KB=2048 个二级页表项所在的几张表(约 8×4KB),省下约 3.97MB。地址空间越大(64 位),多级页表的收益越夸张——这正是现代 CPU 用 4/5 级页表的原因。
5. TLB:让翻译几乎免费
每次访存都去内存查页表,访存延迟就 ×2 了。于是 MMU 旁挂一块极小的全相联缓存——TLB(Translation Lookaside Buffer,快表),缓存「最近用过的 VPN→PFN」。TLB 命中只需 1 个周期,缺失才去查页表(甚至触发缺页)。
下面模拟一个 4 表项全相联、FIFO 替换 的 TLB,访问序列 0,2,3,5,0,7,8,2,0,3,15,5,9,9,9,2:
1 2 3
| int TLB[4] = {-1,-1,-1,-1}; int access[] = {0,2,3,5,0,7,8,2,0,3,15,5,9,9,9,2};
|
输出(与 gcc 行为一致):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| VPN= 0 TLB 缺失 -> PFN=3 (TLB 更新=[-1, -1, -1, 0]) VPN= 2 TLB 缺失 -> PFN=7 (TLB 更新=[-1, -1, 0, 2]) VPN= 3 TLB 缺失 -> PFN=1 (TLB 更新=[-1, 0, 2, 3]) VPN= 5 TLB 缺失 -> PFN=12 (TLB 更新=[0, 2, 3, 5]) VPN= 0 TLB 命中 (TLB=[0, 2, 3, 5]) VPN= 7 TLB 缺失 -> PFN=9 (TLB 更新=[2, 3, 5, 7]) VPN= 8 TLB 缺失 -> PFN=5 (TLB 更新=[3, 5, 7, 8]) VPN= 2 TLB 缺失 -> PFN=7 (TLB 更新=[5, 7, 8, 2]) VPN= 0 TLB 缺失 -> PFN=3 (TLB 更新=[7, 8, 2, 0]) VPN= 3 TLB 缺失 -> PFN=1 (TLB 更新=[8, 2, 0, 3]) VPN=15 TLB 缺失 -> PFN=4 (TLB 更新=[2, 0, 3, 15]) VPN= 5 TLB 缺失 -> PFN=12 (TLB 更新=[0, 3, 15, 5]) VPN= 9 TLB 缺失 -> PFN=2 (TLB 更新=[3, 15, 5, 9]) VPN= 9 TLB 命中 (TLB=[3, 15, 5, 9]) VPN= 9 TLB 命中 (TLB=[3, 15, 5, 9]) VPN= 2 TLB 缺失 -> PFN=7 (TLB 更新=[15, 5, 9, 2])
TLB 命中率 = 3/16 = 18.8%
|
这个访问序列跳跃很大(缓存友好度差),所以命中率只有 18.8%。真实程序因空间/时间局部性强,TLB 命中率通常 >99%——这正是「页大小更大(如 2MB 大页)能再降 TLB 缺失率」的根本原因。
6. 缺页与页面置换(硬件视角简述)
当 MMU 查到 PTE 的有效位为 0(该页不在物理内存),或权限不符(比如写只读页),硬件触发 page fault 异常,CPU 跳到 OS 的缺页处理程序:
- OS 检查这个虚拟页是否合法(属于该进程?越界则段错误);
- 合法则找一个空闲物理页框,从磁盘(交换区/page file)把内容读进来;
- 更新 PTE 的有效位与 PFN,刷新 TLB 对应项;
- 回到出错的指令重新执行——对用户态程序完全透明。
「找哪个物理页框让出来」就是页面置换算法(FIFO / LRU / Clock),属于 CS162 与 OS 的舞台;CS61C 只需记住:缺页是「慢路径」,一次可能几毫秒,而 TLB 命中是「快路径」1 纳秒级——所以减少 TLB 缺失、减少缺页,是性能优化的核心杠杆。
7. C 指针视角:虚拟地址到底长啥样
在 C 里,你拿到的每个指针都是虚拟地址。下面这段(Linux 下)直接打印指针值与一段 /proc/self/maps 的片段,能直观看到「进程看到的地址范围」:
1 2 3 4 5 6 7 8 9 10 11 12
| #include <stdio.h> int global = 42; int main(void) { int local = 7; int *heap = malloc(sizeof(int)); printf("代码区附近(函数地址): %p\n", (void*)main); printf("全局变量 global : %p\n", (void*)&global); printf("栈上变量 local : %p\n", (void*)&local); printf("堆上变量 heap : %p\n", (void*)heap); free(heap); return 0; }
|
典型布局(地址从高到低):
所有这些地址都是虚拟的——两个进程里 main 可能都是 0x401xxx,但背后映射到完全不同的物理页。这也解释了「为什么指针不能跨进程直接传」:对方进程里同一个数值指向的是它自己的虚拟页。
8. 与 CSAPP / CS162 的对照
同一块「虚拟内存」,三门课切入角度不同,别混:
| 维度 |
CS61C(本课) |
CSAPP |
CS162 |
| 关注点 |
地址翻译的硬件机制(MMU/TLB/页表格式) |
从程序员视角看地址、链接、缓冲区溢出与 VM 安全 |
操作系统如何管理 VM(缺页处理、置换、共享) |
| 页表 |
单级/多级结构、PTE 字段 |
简要介绍 |
页表创建、切换(CR3/页表基址)、写时复制 |
| 缺页 |
触发异常的硬件条件 |
当作性能/正确性案例 |
完整的处理流程与算法 |
| TLB |
必讲,性能核心 |
提及 |
随 MMU 一并讲 |
| 典型实验 |
算地址翻译、看命中率 |
写 shell、观察 /proc |
实现缺页处理/置换模拟 |
一句话分工:CS61C 管「翻译怎么发生」,CSAPP 管「程序员会踩什么坑」,CS162 管「OS 怎么把这套机制管起来」。
9. 小结
- 虚拟内存 = 独立的虚拟地址空间 + MMU 硬件翻译 + OS 管理的页表,同时解决隔离、碎片、超配三件事。
- 翻译按页对齐:VA 拆成 VPN(查表)+ offset(直拼),低 12 位(4KB 页)永远是 offset。
- 单级页表浪费内存,多级页表靠「只为用到的段分配下一级表」省下大量空间(64 位系统靠 4/5 级页表才撑得住)。
- TLB 是地址翻译的缓存,命中 1 周期、缺失走页表、再缺失触发缺页;真实程序命中率 >99%,局部性是功臣。
- 缺页是 OS 的「慢路径」,对用户态透明;减少 TLB 缺失与缺页是性能关键。
- 你写的每个 C 指针都是虚拟地址,跨进程不能直接传——这是 VM 隔离的直接后果。
- 三门课视角:CS61C 讲机制,CSAPP 讲坑,CS162 讲管理。
下一篇(CS61C 第 7 篇)进入并行与向量化(SIMD)——看 CPU 怎么在一个周期内同时算 4/8/16 个数。🐾