虚拟内存

一句话本质:虚拟内存是硬件(MMU)+ 操作系统共同提供的一块“每人独享整片地址空间”的幻觉——程序以为自己霸占了从 0 到 4GB/256TB 的全部内存,实际上物理内存被所有进程切片共享,地址翻译由 MMU 在每条指令取数时悄悄完成。

CS61C 站在机器/架构视角看虚拟内存:我们关心的是「一条 lw 指令里的地址是怎么变成内存条上某个字节的物理位置的」「为什么要分页」「TLB 为什么能让翻译几乎免费」。至于缺页时操作系统怎么换页、怎么选受害者页,那是 CS162 的主场(本文只在末尾点到为止并附对照)。

1. 为什么需要虚拟内存

没有虚拟内存的裸机世界里,所有程序直接操作物理地址,会带来三个老大难:

  1. 隔离性为零:进程 A 写错一个指针就可能踩烂进程 B 的数据,甚至改掉内核。
  2. 地址空间碎片:程序加载时得去找一块足够大的连续物理内存,内存用久了就「东一块西一块」。
  3. 无法超配(overcommit):物理内存只有 8GB,程序却想用 16GB?直接没门。

虚拟内存用一层地址抽象同时解决这三点:

  • 每个进程拿到一个独立的虚拟地址空间(virtual address space),互相看不到对方;
  • 虚拟地址到物理地址的映射可以离散(一页一页散落在物理内存各处),碎片问题消失;
  • 暂时不用的页可以换到磁盘,物理内存被「放大」成看起来很大的空间。
物理内存与虚拟内存对照 左侧裸机物理内存由多进程交错共享、无隔离;右侧每个进程拥有独立且各自从 0 开始的虚拟地址空间,离散映射回同一片物理内存。 物理内存(裸机,无 VM) 进程 A 数据 | 进程 B 数据(交错) 内核 kernel 进程 A 代码 ↑ 无隔离:写错指针直接踩到别人 虚拟内存(每进程独享) 进程 A 0x0000 0x1000 0x2000 各从 0 开始 ... 进程 B 0x0000 另一套映射 互不影响 物理内存(所有进程切片共享,离散映射) LEGEND 物理内存页框 虚拟地址空间 物理内存

2. 地址翻译:VA → PA 的核心路径

CPU 发出的地址是虚拟地址(VA),内存条只认物理地址(PA)。中间的翻译官叫 MMU(Memory Management Unit),它查一张由 OS 维护、硬件读取的表——页表(page table)

VA 到 PA 的翻译路径 CPU 发出的虚拟地址经 MMU 查页表翻译成物理地址,再访问物理内存;页表由操作系统填充并存放于内存,MMU 每次翻译都向其查询。 VA = [ 高位 VPN 查表 ] + [ 低 12 位 offset 直拼 ] CPU lw x5, 0x2004 发出虚拟地址 VA MMU 页表查询 VPN→PFN + offset 物理内存 实际数据 0x7004 虚拟地址 VA 物理地址 PA Page Table 由 OS 填充,存于内存 查表

翻译是「按页(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 // 4KB 一页
#define NUM_PAGES 16 // 4 位 VPN -> 16 个虚拟页
#define VPN_BITS 4
#define OFFSET_BITS 12 // 12 位 offset -> 4096 字节

/* 模拟物理页框号(PFN),-1 表示未映射(会触发缺页) */
int page_table[NUM_PAGES] = {
3, -1, 7, 1, -1, 12, 0, 9,
5, 2, -1, 8, 6, -1, 11, 4
};

/* 返回 1 翻译成功,返回 0 缺页 */
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 的二级/三级/四级)把页表本身也分页,只为「真的用到的那段虚拟地址」分配下一级表。

两级页表地址翻译 32 位虚拟地址拆为 PDE 索引、PTE 索引、offset 三段;PDE 指向页目录,页目录再指向只为用到的段分配的二级页表,最终得到 PFN 拼接 offset 访问物理页框。 32 位虚拟地址(4KB 页,两级页表为例) PDE 索引(10 位) PTE 索引(10 位) offset(12 位) 页目录(1 张,4KB) PDE → 二级表基址 二级页表 仅用到的段才分配 PFN PFN + offset 物理页框 省内存:只用 8MB 的进程,一级页表要 4MB,二级只需 ~1 张目录 + 几张二级表。 64 位地址空间靠 4/5 级页表才撑得住 —— 多级页表的收益随地址空间指数级放大。

空间账算一笔:一个只用 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 个周期,缺失才去查页表(甚至触发缺页)。

TLB 翻译三分支 CPU 发出的虚拟地址先查 TLB:命中则一个周期得到物理地址;缺失则查内存页表并回填 TLB;若页表项无效则触发缺页异常交由操作系统处理。 CPU 发 VA(取数) MMU 查 TLB TLB 命中? 得 PA(PFN+offset) 命中 1 cycle 查页表(内存) 回填 TLB 再得 PA PTE 有效? 缺页异常 → OS 处理 命中 缺失 有效 无效

下面模拟一个 4 表项全相联、FIFO 替换 的 TLB,访问序列 0,2,3,5,0,7,8,2,0,3,15,5,9,9,9,2

1
2
3
/* 4 表项全相联 TLB,FIFO 替换,仅缓存已映射 VPN */
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 的缺页处理程序:

  1. OS 检查这个虚拟页是否合法(属于该进程?越界则段错误);
  2. 合法则找一个空闲物理页框,从磁盘(交换区/page file)把内容读进来;
  3. 更新 PTE 的有效位与 PFN,刷新 TLB 对应项;
  4. 回到出错的指令重新执行——对用户态程序完全透明。

「找哪个物理页框让出来」就是页面置换算法(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;
}

典型布局(地址从高到低):

进程虚拟地址空间布局 进程地址空间自高地址向低地址依次为栈、空洞或 mmap 区、堆、全局与 BSS/data 段、代码段;栈向下生长、堆向上生长,全部地址均为虚拟地址。 高地址 0x7ff… 低地址 0x004… TOP 栈 stack local ↓ 向低地址 空洞 / mmap 映射区 堆 heap heap ↑ 向高地址 全局 / BSS / data global 代码 text main

所有这些地址都是虚拟的——两个进程里 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 个数。🐾