Lecture 11: 虚拟内存:概念 (Virtual Memory: Concepts)
Lecture 11: 虚拟内存:概念 (Virtual Memory: Concepts)
讲义对应:CMU 15-213 Lecture 11 — Virtual Memory: Concepts(素材:
F25-11-vm-concepts.txt) 教材对应:CS:APP3e 第 9 章 9.1–9.6(物理/虚拟寻址、地址空间、VM 作为缓存/管理/保护工具、地址翻译、TLB、多级页表) 关联 Lab:L5 Malloc Lab(堆的本质就是一段可增长的虚拟地址区间)
11.1 概述
前面几讲我们一直假设”内存就是一块可以直接按地址访问的大数组”,并把注意力放在缓存上。本讲要揭穿这个假象:你写的每一个地址都是虚拟地址(virtual address),它并不是 DRAM 上的真实位置,而是由硬件与操作系统共同维护的一张映射表翻译出来的。这解决了整门课开头就埋下的疑问——一块物理内存怎么同时容纳”正在运行的多个程序各自完整的地址空间”。
虚拟内存(Virtual Memory, VM)是计算机科学中最深刻的思想之一,它同时扮演三个角色:主存的缓存(把 DRAM 当作磁盘上地址空间的缓存)、内存管理的工具(每个进程拥有独立、统一、线性的地址空间)、内存保护的工具(用 PTE 里的权限位挡住越权访问)。本讲先建立这三个视角,再给出地址翻译的硬件数据通路、TLB 与多级页表。它承接 Lecture 10 的缓存思想(页就是”块”,页表就是”映射函数”),并直接为 Lecture 12 的翻译细节、Lecture 13–14 的 malloc 实现以及 Lecture 16 的 fork/execve 奠定基础。
11.2 核心概念与底层机制图解
11.2.1 物理寻址 vs 虚拟寻址(Physical vs Virtual Addressing)
- 定义与目的:物理寻址是 CPU 直接发出物理地址(PA)访问主存;虚拟寻址是 CPU 发出虚拟地址(VA),由内存管理单元(Memory Management Unit, MMU)在片内翻译成 PA。
- 直观解释:物理寻址像直接报出”货架第 37 号格子”;虚拟寻址像报出”我那一箱里的第 5 号物品”,由一位管理员(MMU)实时查表告诉你它在第几号货架上。
- 底层机制图解:
[物理寻址] [虚拟寻址]
+-----+ PA +--------------+ +-----+ VA +-----+ PA +--------------+
| CPU |------->| 主存 (DRAM) | | CPU |------>| MMU |------>| 主存 (DRAM) |
+-----+ | 0: | +-----+ +-----+ | 0: |
(仅用于嵌入式: | 1: | (所有现代服务器 / 笔记本 / | 1: |
汽车、电梯、 | ... | 手机都用虚拟寻址) | ... |
数码相框) | M-1: | | M-1: |
+--------------+ +--------------+
M = 2^m 个物理地址 两者都在 CPU 芯片内
- 与机器码/硬件的对应:MMU 是 CPU 芯片上的专用部件,不是操作系统的一段代码。程序里的
mov (%rdi), %rax里%rdi装的就是 VA;%rdi的值翻译成 PA 的过程对指令本身完全透明,程序无法察觉也无法绕过。
11.2.2 地址空间(Address Space)
- 定义与目的:线性地址空间是连续非负整数地址的有序集合 $\{0,1,2,3,\dots\}$。虚拟地址空间是 $N = 2^n$ 个虚拟地址 $\{0,1,\dots,N-1\}$;物理地址空间是 $M = 2^m$ 个物理地址 $\{0,1,\dots,M-1\}$。
- 直观解释:同一个系统上 $N$ 与 $M$ 可以完全不同——一台 64 位机器给每个进程 $2^{48}$ 字节的虚拟地址空间(256 TB),而物理内存只有 $M = 2^{32}$ 字节(4 GB)。虚拟地址空间可以远大于物理地址空间,这正是”内存不够用也能跑”的秘密。
- 地址翻译的形式化定义:$\mathrm{MAP}: V \to P \cup \{\varnothing\}$。对虚拟地址 $a$,若其数据在物理地址 $a^{\prime}$ 处则 $\mathrm{MAP}(a) = a^{\prime}$;否则 $\mathrm{MAP}(a) = \varnothing$,表示该地址要么无效(invalid),要么存放在磁盘上。翻译粒度由 ISA 规定为”页”,所以它不是任意函数,而必须”简单且高效”——因此用硬件实现,并落在页表(page table)这种 $k$ 叉树上(每个节点恰好 1 页大小)。
11.2.3 虚拟内存的三个作用(Three Roles of VM)
- 作为缓存的工具:把主存(DRAM)当作磁盘上虚拟地址空间的缓存,缓存块就是页(page)。程序分配虚拟地址范围(隐式地通过二进制/库,显式地通过堆/栈),由操作系统决定哪些虚拟页应当驻留(resident)在物理内存中,并管理 DRAM 与磁盘之间的放置与替换策略。
- 作为内存管理的工具:每个进程有自己的虚拟地址空间,可以把它看成一块简单的线性数组;映射函数把地址打散到物理内存各处,选择得当的映射还能改善局部性。因为虚拟页可以映射到任意物理页,且同一虚拟页可在不同时刻位于不同物理页,内存分配被极大简化;把两个进程的虚拟页映射到同一个物理页(如只读库代码 PP 6),就实现了进程间的代码与数据共享。
- 作为保护的工具:在 PTE 上扩展权限位,MMU 在每次访问时检查它们。
11.2.4 页、页帧与页表(Pages, Page Frames, Page Tables)
- 定义与目的:页(page)是虚拟内存的块,页帧(page frame)是物理内存的块,大小同为 $P = 2^p$ 字节(典型 $P = 4\,\mathrm{KB} = 2^{12}$)。页表是一个页表项(page table entry, PTE)数组,把虚拟页映射到物理页;它是每进程的、常驻 DRAM 的内核数据结构。
- 直观解释:页表像一本书的目录——你要找”第 7 章”(VP 7),目录告诉你它在第 3 个书架上(PP 3)。目录本身也要占地方,而且只有你真正要找的内容才需要从仓库(磁盘)搬到书架上(DRAM)。
- 底层机制图解(有效位 + 物理页号或磁盘地址):
页表 (DRAM, 常驻) 物理内存 (DRAM) 虚拟内存 (磁盘)
+----+----+----------+ +--------+ +--------+
|PTE |V | PPN/磁盘 | | PP 0 |<-- VP 2 | VP 0 |
| 0 | 1 | PP 0 | +--------+ | VP 1 |
| 1 | 0 | disk | | PP 3 |<-- VP 1 | VP 2 | (空位=未缓存)
| 2 | 1 | PP 2 | +--------+ | VP 3 |
| ... | | PP 6 |<-- VP 7 | VP 4 |<-- 牺牲页被换出
| 7 | 1 | PP 6 | +--------+ | VP 5 |
+----+----+----------+ | ... | | VP 6 |
V=1: 页在内存中(VPN→PPN) +--------+ | VP 7 |
V=0: 页不在内存(缺页) +--------+
- DRAM 缓存的特殊之处(由巨大的缺失代价驱动):DRAM 比 SRAM 慢约 10 倍,磁盘比 DRAM 慢约 10,000 倍。因此 VM 缓存的组织方式与 Lecture 10 的 SRAM 缓存截然不同:① 页(块)很大,典型 4 KB,有时 4 MB;② 全相联(fully associative),任何 VP 可放在任何 PP;③ 因此需要”很大的”映射函数,与缓存存储器的组相联映射不同;④ 替换算法高度复杂昂贵,复杂开放到无法用硬件实现,交由操作系统软件完成;⑤ 采用写回(write-back)而非写直达。
11.2.5 页命中与缺页(Page Hit / Page Fault)
- 地址翻译的位域划分:VA 拆成 $p$ 位的虚拟页偏移(VPO)与 $(n-p)$ 位的虚拟页号(VPN);PA 拆成 $p$ 位的物理页偏移(PPO)与 $(m-p)$ 位的物理页号(PPN)。
虚拟地址 VA 物理地址 PA
n-1 p p-1 0 m-1 p p-1 0
+------------+------------+ +------------+------------+
| VPN | VPO | | PPN | PPO |
+------------+------------+ +------------+------------+
| ^
| 查页表(基址来自 PTBR,x86 中即 CR3) | PPN 直接拼上 VPO
+-------------------> [ 有效位 | PPN ] -------------------+
V=1 → 页命中;V=0 → 缺页(page fault)
★ 核心不变量:VPO = PPO,页内偏移在翻译前后一字不变
- 页命中:CPU 把 VA 交给 MMU → MMU 用 VPN 作索引访问 PTE 的地址 PTEA(Page Table Entry Address,由页表基址寄存器 PTBR/CR3 加上 VPN 算出)→ 取回 PTE,有效位为 1 → MMU 把 PPN 拼上 VPO 得到 PA 交给缓存/内存 → 数据字返回 CPU。共 5 步。
- 缺页(page fault):这是一个异常(exception),不是错误返回。① MMU 发现有效位为 0,触发缺页异常;② 缺页处理程序(page fault handler)在内核中运行,挑选一个牺牲页(victim page);③ 若牺牲页是脏的(dirty),先把它换出(page out)写回磁盘;④ 换入(page in)新页到刚空出的物理页,并更新内存中的 PTE;⑤ 处理程序返回到原进程,重新执行(restart)引发缺页的那条指令——这一次是页命中。
缺页处理流程(7 步数据通路)
CPU MMU Cache/Memory Disk
|-- VA ------->| |
| |-- PTEA -->| (内存中的页表) |
| |<-- PTE ---| |
| | V=0 → 触发异常 |
|<-- 异常 ------| |
| [内核] 缺页处理程序: 选牺牲页 ---(若脏)写回 ------->|
| 换入新页 <---------------------|
| 更新 PTE, 返回 |
|-- 重新执行原指令(此时页命中) |
11.2.6 PTE 位域与权限位(PTE Bits and Permission Bits)
- 定义与目的:一个 PTE 除了”有效位 + 物理页号/磁盘地址”外,还携带权限位:
SUP(仅内核可访问)、READ、WRITE、EXEC。MMU 在每次访问时检查。
PTE 逻辑结构(CS:APP 简化模型)
+-------+----------+------+------+------+
| Valid | PPN | SUP | READ | WRITE| (+EXEC)
+-------+----------+------+------+------+
1 bit 剩余位 1 bit 1 bit 1 bit
两个进程的 PTE 对照(Physical Address Space: PP2/PP4/PP6/PP8/PP9/PP11)
Process i: VP0 -> PP6 READ=Yes WRITE=No SUP=No EXEC=Yes
VP1 -> PP4 READ=Yes WRITE=Yes SUP=No EXEC=Yes
VP2 -> PP2 READ=Yes WRITE=Yes SUP=No EXEC=No
Process j: VP0 -> PP9 READ=Yes WRITE=No SUP=No EXEC=Yes
VP1 -> PP6 READ=Yes WRITE=Yes SUP=No EXEC=Yes <-- 与 i 共享 PP6
VP2 -> PP11 READ=Yes WRITE=Yes SUP=Yes EXEC=No <-- 内核页
- 直观解释:这就像图书馆的借阅权限标签:同样是”这本书在 3 号书架”,标签却写着”仅馆员可借”“只许看不许改”。“共享同一物理页”与”拥有相同权限”是两件独立的事——进程 i 与 j 共享 PP6 的读权限,但只有 j 能写它。
- 与”段错误”的关系:违反权限位(或访问未映射页)时 MMU 抛出异常,Unix 内核把它转成
SIGSEGV——这就是”段错误(segmentation fault)”这个古老名字在现代系统上真正的含义。
11.2.7 按需分页、局部性与抖动(Demand Paging, Locality, Thrashing)
- 按需页面调度(demand paging):等到缺失才把页复制到 DRAM。
execve分配.text/.data的虚拟页并创建标记为无效的 PTE,各段由 VM 系统按页、按需从可执行文件复制进来——所以刚启动的程序”占了 0 字节物理内存”。 - 局部性再次救场:VM 看起来极其低效(每次缺页都要访问磁盘),但它能工作完全靠局部性。任意时刻程序倾向于访问一组活跃的虚拟页,称为工作集(working set);时间局部性越好的程序,工作集越小。
- 若(工作集大小 < 主存大小):除强制缺失(compulsory miss)外表现良好,一个进程可稳定运行。
- 若($\sum$ 各进程工作集 > 主存大小):抖动(thrashing)——页面被连续换入换出,性能雪崩。
- 共享库、
fork与mmap(VM 作为管理工具的具体落地):- 链接(linking)简化:每个程序拥有相似的虚拟地址空间,代码、数据、堆总是从相同地址开始(如
0x400000),链接器不必关心最终物理位置。 - 共享库:多个进程把各自的虚拟页映射到同一份物理页,物理内存里只有一份
libc代码。 fork与写时复制(Copy-On-Write, COW):父进程的页表被复制,父子共享全部物理页并标记为只读;任一方写入时触发保护异常,内核才真正复制那一页并恢复可写。fork因此几乎是”免费”的。execve与mmap:新程序的可执行文件被mmap进地址空间,PTE 初始无效,之后按需换入。
- 链接(linking)简化:每个程序拥有相似的虚拟地址空间,代码、数据、堆总是从相同地址开始(如
11.2.8 地址翻译与 TLB(Address Translation and the TLB)
- 问题:PTE 本身也像普通内存字一样被缓存在 L1 里,但可能被其他数据引用驱逐,且即便命中也要付一次小的 L1 延迟。地址翻译若每次都查页表,性能无法接受。
- 解法:翻译后备缓冲器(Translation Lookaside Buffer, TLB)——MMU 内的小型组相联硬件缓存,把 VPN 映射到 PPN,存放少量页的完整 PTE。
- 位域划分:MMU 用 VPN 访问 TLB,VPN 再拆成 TLB 标记(TLBT)与 TLB 索引(TLBI),共 $T = 2^t$ 个组,TLBI 选组、TLBT 匹配组内行的标记。
TLB 命中路径(省掉一次内存访问) TLB 未命中路径(多一次内存访问取 PTE)
CPU --VA--> MMU CPU --VA--> MMU
| 1. VPN 查 TLB | 1. VPN 查 TLB -> 未命中
| 2. 命中, 直接得 PTE | 2. 访问页表(内存), PTEA
| 3. 拼出 PA | 3. 取回 PTE
v | 4. 填入 TLB, 拼出 PA
Cache/Memory <-- 4 访问 Cache/Memory <-- 5 访问
| 5. 返回数据字 | 6. 返回数据字
v v
CPU CPU
★ 一次 TLB 命中消除一次内存访问;未命中则额外付一次取 PTE 的内存访问
★ TLB 未命中很罕见——为什么?因为局部性:活跃页数远小于 TLB 项数
- TLB 与 $k$ 级页表的关系:无论页表有多少级,TLB 缓存的始终是完整的 VPN → PPN 映射(这是讲义强调的关键结论)。这让多级页表的层层查找只在 TLB 未命中时才发生。
11.2.9 多级页表与 x86-64 的 4 级页表(Multi-Level Page Tables)
- 为什么需要多级:设 4 KB($2^{12}$)页大小、48 位地址空间、8 字节 PTE,则单级页表需要 $2^{48} \times 2^{-12} \times 2^3 = 2^{39}$ 字节 = 512 GB——每进程一张,显然不可行。
- 解决办法:多级页表。第 1 级表的每个 PTE 指向一张页表(始终常驻内存),第 2 级表的每个 PTE 指向一个页(像普通数据一样换入换出)。关键是让未分配的虚拟页不必存在对应的下级页表:32 位示例中,2K 已分配页 + 6K 未分配 + 1023 未分配 + 1 页栈,两级页表只需约 3 张 4 KB 页表,而非 4 MiB 的单级表。
- x86-64 的 4 级页表:48 位虚拟地址划分为 9 + 9 + 9 + 9 + 12,VPN 共 36 位。
x86-64 (Core i7) 48 位虚拟地址位域划分
47 39 38 30 29 21 20 12 11 0
+-----------------------+--------------+--------------+--------------+-----------------+
| VPN 4 (9) | VPN 3 (9) | VPN 2 (9) | VPN 1 (9) | VPO (12) |
+-----------------------+--------------+--------------+--------------+-----------------+
| | | | |
| CR3 | | | |
v v v v |
+------------------+ +----------------+ +---------------+ +-----------+ |
| L4 PT (PGD) | | L3 PT (PUD) | | L2 PT (PMD) | | L1 PT | |
| 512 项, 4 KB |->| 512 项, 4 KB |->| 512 项, 4 KB |->| 512 项 | |
| 每项覆盖 512 GB | | 每项覆盖 1 GB | | 每项覆盖 2 MB | | 每项 4 KB | |
+------------------+ +----------------+ +---------------+ +-----------+ |
| PPN (40) |
v v
+----------------+---------------+
| PPN (40) | PPO (12) |
+----------------+---------------+
物理地址 PA (52 位)
★ CR3 寄存器保存 L4 页表的物理基址(每个进程一个页表树,切换进程即换 CR3)
★ 每级页表恰好 512 项 × 8 B = 4096 B = 1 页,天然页对齐
- Core i7 的真实配置:4 级页表、每进程一个页表树;MMU 中有 L1 d-TLB 64 项 4 路(指令侧 L1 i-TLB 128 项 4 路)与统一的 L2 TLB 512 项 4 路;L1 d-TLB 的 64 项/4 路 = 16 组,故 TLBI 占 4 位、TLBT 占 36 − 4 = 32 位。页命中时,TLB 与 L1 缓存可并行工作(决定 L1 组索引的位在 VA 与 PA 中相同),进一步掩盖翻译延迟。
11.3 代码示例与底层机制分析
11.3.1 示例 1:观察进程的虚拟地址空间布局
代码 (C):/tmp/vm_layout.c
/* vm_layout.c - 展示进程的虚拟地址空间布局:段、堆、栈、mmap 区 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int g_init = 42; /* .data : 已初始化的全局变量 */
int g_bss; /* .bss : 未初始化全局变量(不占文件) */
static const char *g_ro = "rodata"; /* .rodata: 只读数据 */
static void dump_maps(int n) {
FILE *f = fopen("/proc/self/maps", "r");
char line[512];
if (!f) { perror("fopen"); return; }
printf("\n=== /proc/self/maps 前 %d 行 ===\n", n);
while (n-- > 0 && fgets(line, sizeof line, f))
fputs(line, stdout);
fclose(f);
}
int main(void) {
int local = 7; /* 栈上的局部变量 */
int *heap = malloc(64); /* 小对象:走 brk 堆 */
int *big = malloc(1 << 20); /* 1 MiB:glibc 改用 mmap 分配 */
static int s_bss; /* .bss */
const size_t PS = (size_t)sysconf(_SC_PAGESIZE);
if (!heap || !big) return 1;
*heap = 0xabc;
big[0] = 0xdef;
printf("=== 各类对象所在段的地址 ===\n");
printf("main() = %p (.text)\n", (void *)main);
printf("字符串字面量 = %p (.rodata)\n", (void *)g_ro);
printf("&g_init = %p (.data)\n", (void *)&g_init);
printf("&g_bss / &s_bss = %p / %p (.bss)\n", (void *)&g_bss, (void *)&s_bss);
printf("heap (malloc 64) = %p (堆)\n", (void *)heap);
printf("big (malloc 1MB) = %p (mmap 区)\n", (void *)big);
printf("&local (&heap) = %p / %p (栈)\n", (void *)&local, (void *)&heap);
printf("\n=== 常量 ===\n");
printf("PAGE_SIZE = %zu 字节 = 2^%d\n", PS, (int)__builtin_ctzl(PS));
printf("sizeof(void*) = %zu,sizeof(long) = %zu\n",
sizeof(void *), sizeof(long));
/* 同一页内的两个地址共享同一个 VPN,页偏移不同 */
unsigned long base = (unsigned long)&g_init & ~0xfffUL;
printf("\n=== 页对齐:页的划分与地址翻译的位域 ===\n");
printf("%#lx: VPN = %lu, VPO = %lu\n", base, base >> 12, base & 0xfff);
printf("%#lx: VPN = %lu, VPO = %lu <- 同一页\n",
base + 0xfff, (base + 0xfff) >> 12, (base + 0xfff) & 0xfff);
printf("%#lx: VPN = %lu, VPO = %lu <- 下一页\n",
base + 0x1000, (base + 0x1000) >> 12, (base + 0x1000) & 0xfff);
dump_maps(14);
free(heap); free(big);
return 0;
}
编译与运行:gcc -g -Wall -std=c11 vm_layout.c -o vm_layout && ./vm_layout
【实测输出】
=== 各类对象所在段的地址 ===
main() = 0x40125d (.text)
字符串字面量 = 0x402008 (.rodata)
&g_init = 0x404078 (.data)
&g_bss / &s_bss = 0x404094 / 0x404098 (.bss)
heap (malloc 64) = 0x13512a0 (堆)
big (malloc 1MB) = 0x7effab6ff010 (mmap 区)
&local (&heap) = 0x7fffe2493634 / 0x7fffe2493628 (栈)
=== 常量 ===
PAGE_SIZE = 4096 字节 = 2^12
sizeof(void*) = 8,sizeof(long) = 8
=== 页对齐:页的划分与地址翻译的位域 ===
0x404000: VPN = 1028, VPO = 0
0x404fff: VPN = 1028, VPO = 4095 <- 同一页
0x405000: VPN = 1029, VPO = 0 <- 下一页
=== /proc/self/maps 前 14 行 ===
00400000-00401000 r--p 00000000 08:03 173710 /tmp/vm_layout
00401000-00402000 r-xp 00001000 08:03 173710 /tmp/vm_layout
00402000-00403000 r--p 00002000 08:03 173710 /tmp/vm_layout
00403000-00404000 r--p 00002000 08:03 173710 /tmp/vm_layout
00404000-00405000 rw-p 00003000 08:03 173710 /tmp/vm_layout
01351000-01372000 rw-p 00000000 00:00 0 [heap]
7effab6ff000-7effab800000 rw-p 00000000 00:00 0
7effab800000-7effab828000 r--p 00000000 08:05 3221225831 /usr/lib64/libc.so.6
7effab828000-7effab99d000 r-xp 00028000 08:05 3221225831 /usr/lib64/libc.so.6
...
【代码做什么?】
- 定义位于
.data/.bss/.rodata的各类对象,并在main里在栈上取局部变量、在堆上取两块内存(64 B 与 1 MiB)。 - 打印各对象地址,立即能看出四段地址区间:低地址
0x400000附近是可执行文件映射的段,0x1351000是brk堆,0x7eff...是mmap区,0x7fff...是栈。 - 用掩码
& ~0xfffUL把地址按 4 KB 对齐,手工算出 VPN 与 VPO,验证同一页内地址 VPN 相同、VPO 从 0 到 4095。 - 打开
/proc/self/maps打印前 14 行,把上一步的”地址”与这一段”区间 + 权限 + 文件偏移”对应起来。
【底层机制透视】
/proc/self/maps 是内核暴露的虚拟内存区域(VMA, Virtual Memory Area)清单,每行格式为:
起始地址-结束地址 权限 文件偏移 主:次设备号 inode 文件路径
- 权限字段
r--p/r-xp/rw-p/---p正对应 PTE 的 READ / WRITE / EXEC / SUP 位(p表示 private,s表示 shared,即 MAP_PRIVATE / MAP_SHARED)。 - 同一个可执行文件被映射成多行,因为它按段拆分且每段必须页对齐:第一行
r--p是 ELF 头(偏移00000000),第二行r-xp是.text(偏移00001000),后两行是.rodata与.data。 malloc(1MiB)落在mmap区而不是[heap]:glibc 对超过M_MMAP_THRESHOLD(默认 128 KB)的请求直接用mmap,free时可直接munmap归还内核。[heap]区间是brk指针推出来的,malloc的”堆”本质上就是这段虚拟区间——这正是 L5 Malloc Lab 要操作的对象。
【内存布局 / 数据结构图解】
0x7fffe2493634 栈(向下增长) <- r-- 到 rw- 的 [stack] 区
...
0x7effab6ff010 mmap 区(1 MiB malloc / 共享库)
...
0x01351000 [heap] 起始(brk) <- malloc 的战场(L5)
...
0x00404000 .data / .bss (rw-p)
0x00402000 .rodata (r--p)
0x00401000 .text (r-xp)
0x00400000 加载基址:链接器写死,每个进程都一样 → 简化链接
【与汇编 / 硬件的对应】:地址只是数值,翻译发生在访存时。用 objdump -d -M intel 可看到 main 里对字符串字面量的访问是 lea rax,[rip+0x...]——RIP 相对寻址,因为链接器假定地址是”相对的、可预测的”,这也是虚拟地址空间统一布局带来的便利。
11.3.2 示例 2:用 mmap 观察按需分页(Demand Paging)
代码 (C):/tmp/vm_mmap.c(节选核心逻辑,完整文件含文件映射部分)
/* vm_mmap.c - 用 mmap 映射匿名内存与文件,并观察"按需分页" */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/resource.h>
static long minflt(void) { /* 次缺页(minor page fault)计数 */
struct rusage ru;
getrusage(RUSAGE_SELF, &ru);
return ru.ru_minflt;
}
int main(void) {
const size_t PS = (size_t)sysconf(_SC_PAGESIZE);
const size_t NP = 1024; /* 1024 页 = 4 MiB */
const size_t LEN = NP * PS;
/* 1) 匿名映射:只保留地址空间,不分配物理页 */
long f0 = minflt();
char *p = mmap(NULL, LEN, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (p == MAP_FAILED) { perror("mmap"); return 1; }
long f1 = minflt();
printf("[1] mmap 之后: p = %p, 次缺页增量 = %ld\n", (void *)p, f1 - f0);
/* 2) 只写每页第一个字节:触发按需分配 */
for (size_t i = 0; i < NP; i++) p[i * PS] = (char)i;
long f2 = minflt();
printf("[2] 触碰 %zu 页后: 次缺页增量 = %ld\n", NP, f2 - f1);
/* 3) 再顺序读一遍:应当不再产生缺页 */
long sum = 0;
for (size_t i = 0; i < NP; i++) sum += p[i * PS];
long f3 = minflt();
printf("[3] 再读一遍后: 次缺页增量 = %ld, checksum = %ld\n", f3 - f2, sum);
/* 4) madvise 归还物理页(MADV_DONTNEED):VMA 保留、物理页回收 */
if (madvise(p, LEN, MADV_DONTNEED) == 0) puts("[4] MADV_DONTNEED 成功");
/* 5) 文件映射(完整版含此段,见磁盘写回验证) */
munmap(p, LEN);
return 0;
}
编译与运行:gcc -g -Wall -std=c11 vm_mmap.c -o vm_mmap && ./vm_mmap
【实测输出】
PAGE_SIZE = 4096, 映射长度 = 4194304 字节 (1024 页)
[1] mmap 之后: p = 0x7f977fa00000, 次缺页增量 = 0 <- 尚未触碰任何页
[2] 触碰 1024 页后: 次缺页增量 = 1024 <- 每页一次缺页
[3] 再读一遍后: 次缺页增量 = 0 <- 已驻留,全部页命中
checksum = -512
[4] MADV_DONTNEED 成功:物理页被回收,VMA 保留
[5] 文件映射 /tmp/vm_mmap_demo.bin: q = 0x7f977f600000
mmap 后缺页增量 = 0 (写第 0 页 +0, 写第 1 页 +1)
回读文件 offset 0 = 'A', offset 4096 = 'B' <- 已写回磁盘
【代码做什么?】① 申请 4 MiB 匿名映射;② 每页只写 1 字节,制造 1024 次缺页;③ 再读一遍,确认零缺页;④ 用 madvise(MADV_DONTNEED) 把物理页交还内核;⑤ 把 4 MiB 的真实文件映射进地址空间,只改两页并 msync,再从文件里读回验证落盘。
【底层机制透视】
- 第 [1] 步的”零缺页”是全篇最重要的实测证据:
mmap只创建 VMA 与页表结构,不分配任何物理页。这正是 11.2.7 的 demand paging——内核把页的换入推迟到第一次访问。 - 第 [2] 步恰好 1024 次缺页:每页第一次触碰触发一次次缺页(minor fault),内核填一个零页(或从 page cache 取页)并建立 PTE。粒度严格是”页”,因为映射的最小单位就是页。若触碰同一页的多个字节,只会有 1 次缺页——局部性直接转化为缺页次数。
- 第 [3] 步的零缺页对应页表里的有效位已经是 1:此后每次访问都是”页命中”,由 MMU 用 TLB/页表直接翻译,不再陷入内核。
- 第 [5] 步的文件映射:
MAP_SHARED让写入通过 page cache 反映到文件;msync强制写回(对应 VM 缓存的 write-back 策略)。注意写入第 0 页时缺页增量显示为+0,因为第 0 页在此之前已被写的路径预热(页表已建立),只有第 1 页产生了 1 次次缺页——这说明缺页出现在”页第一次被触碰”的时刻,而不是每个字节。
【内存布局 / 数据结构图解】
mmap(NULL, 4 MiB, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0)
VA 0x7f977fa00000
+------------------+ VMA 已建立,PTE = 无效
| 页 0 (未驻留) | <- 仅存在于虚拟地址空间
| 页 1 (未驻留) | 物理内存占用 = 0
| ... |
| 页 1023(未驻留) |
+------------------+
触碰每页首字节后:
页 0 -> PP x, 页 1 -> PP y, ... PTE 有效位 = 1(对应 1024 次次缺页)
strace -e trace=mmap,madvise ./vm_mmap 的真实系统调用:
mmap(NULL, 4194304, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f0451c00000
madvise(0x7f0451c00000, 4194304, MADV_DONTNEED) = 0
mmap(NULL, 4194304, PROT_READ|PROT_WRITE, MAP_SHARED, 3, 0) = 0x7f0451800000
★ 注意:整个 4 MiB 只有 3 个系统调用,缺页全在内核里"悄悄"发生
【与汇编 / 硬件的对应】:调用点由 PLT 跳转完成:
401292: call 401060 <mmap@plt> # 4 MiB 匿名映射
4013c7: call 4010b0 <madvise@plt> # 归还物理页
mmap 的六个参数依次放在 %rdi,%rsi,%rdx,%rcx,%r8,%r9,与 x86-64 调用约定一致;系统调用号写入 %rax 后执行 syscall。
11.3.3 示例 3:权限位与段错误
代码 (C):/tmp/vm_prot.c
/* vm_prot.c - 保护演示:对只读页写入 -> SIGSEGV(段错误) */
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <signal.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
static void handler(int sig) {
/* 注意:write() 是异步信号安全的,printf() 不是 */
const char *m = "\n[信号处理程序] 捕获到 SIGSEGV:MMU 的权限检查失败!\n";
ssize_t r = write(STDOUT_FILENO, m, strlen(m));
(void)r;
_exit(128 + sig); /* 打印后直接退出,不再返回故障指令 */
}
int main(void) {
struct sigaction sa;
memset(&sa, 0, sizeof sa);
sa.sa_handler = handler;
sigemptyset(&sa.sa_mask);
if (sigaction(SIGSEGV, &sa, NULL) != 0) { perror("sigaction"); return 1; }
char *ro = (char *)"rodata-literal"; /* 位于只读段 */
printf("ro 指向 %p,内容 = \"%s\"\n", (void *)ro, ro);
printf("尝试写入只读页 ...\n");
fflush(stdout);
ro[0] = 'X'; /* ⚠️ 仅供演示:写只读段 -> SIGSEGV */
printf("这行永远不会执行\n");
return 0;
}
编译与运行:gcc -g -Wall -std=c11 vm_prot.c -o vm_prot && ./vm_prot; echo "exit=$?"
【实测输出】
ro 指向 0x40205a,内容 = "rodata-literal"
尝试写入只读页 ...
[信号处理程序] 捕获到 SIGSEGV:MMU 的权限检查失败!
exit=139
【底层机制透视】:地址 0x40205a 落在 vm_layout 示例中 .rodata 所在的 00402000-00403000 r--p 区间。该区间对应的 PTE 里 WRITE 位为 0,MMU 在写访问时检测到违规,抛出异常;内核识别出这是用户态越权访问已映射页,于是向进程递送 SIGSEGV(退出码 139 = 128 + 11)。“段错误”从来不是”内存不存在”,而是”这次访问不满足 PTE 里的权限位”——同样的地址读没问题,写才出错,恰恰证明了保护是按访问类型逐次检查的。
【与汇编 / 硬件的对应】:gcc -O0 -S 得到真实的存储指令(AT&T 语法):
movq $.LC2, -8(%rbp) # ro = "rodata-literal"
movq -8(%rbp), %rax
movb $88, (%rax) # ro[0] = 'X' -> 88 是 'X' 的 ASCII 码
movb $88, (%rax) 是一条普通的存储指令:CPU 并不知道页是只读的,拒绝发生在 MMU 翻译 %rax 时。这解释了为什么编译器无法在编译期发现这个错误(若用 const char * 则编译期会警告,但字符串字面量的类型是 char[])。
11.4 实验关联
L5 Malloc Lab 的全部操作对象就是本讲定义的虚拟地址空间中的 [heap] 区间:
- 堆不是”内存”,而是一段虚拟地址区间。
malloc内部通过sbrk/brk或mmap向内核申请虚拟页;这些页在被首次触碰前并不驻留物理内存。所以”申请 1 MB”其实只是把brk指针往上推 1 MB,代价几乎为零。 - 对齐要求来自页与字长:块的对齐通常是 8 或 16 字节;”页对齐”(
PAGE_SIZE = 4096)只在你需要把映射边界做到页粒度时才重要。用getconf PAGE_SIZE确认本机是 4096。 mmap与sbrk两条路:大块分配走mmap可以整块munmap归还,物理页立即回收;小分配走brk堆则只能通过”空闲链表合并”复用。这正是 L5b 讨论”显式/隐式空闲链表 vs 分离存储”的物理依据。- 典型坑:在
mmap得到的区间外访问会得到SIGSEGV而不是”分配到垃圾数据”;堆里的野指针若落在未映射的页上同样崩溃,但若落在已映射的页上则静默破坏数据——后者才是 malloc lab 里最难查的 bug。 - 调试利器:
valgrind ./mdriver、gdb里info proc mappings与x/16gx $rsp,以及/proc/<pid>/maps对照堆区间边界。
本讲也为 Lecture 12(fork 的 COW、execve 的内存映射、TLB 与缓存联合工作)和 Lecture 16(进程)提供理论前提。
11.5 常见错误与调试技巧
- 把虚拟地址当物理地址:以为
printf("%p", p)打印的是内存条上的位置。调试:cat /proc/self/maps、gdb -p <pid>后info proc mappings看 VA 区间;用/proc/<pid>/pagemap(需 root)查实际 PPN。 - 以为”指针有效”就等于”页已驻留”:
malloc返回非空不代表物理内存已分配。调试:getrusage(RUSAGE_SELF, &ru)观察ru_minflt/ru_majflt增量;见 11.3.2 的实测(mmap后缺页增量为 0)。 - 对字符串字面量写入:
char *s = "abc"; s[0]='x';编译通过、运行SIGSEGV。调试:gcc -Wall -Wwrite-strings,或声明为const char *s;用valgrind会直接报告 “Invalid write of size 1”。 - 越界访问落在已映射页内:不崩溃但破坏数据,是 malloc lab 最阴险的 bug。调试:
gcc -fsanitize=address -g、valgrind --leak-check=full --track-origins=yes。 - 误判”内存泄漏”为”缺页太多”:
top里的 RSS 高可能只是工作集大,未必是泄漏。调试:/usr/bin/time -v(若可用)或getrusage看ru_maxrss;valgrind --leak-check=full定位未释放块。 - 认为 TLB 未命中会崩溃:TLB 未命中只是多一次内存访问去查页表,与缺页(需要磁盘 I/O)是完全不同的量级。调试:
perf stat -e dTLB-loads,dTLB-load-misses,page-faults ./prog。 - 混淆
VPO与VPN的对齐掩码:写代码时把& 0xfff(页内偏移)误用作>> 12(页号)。调试:用 11.3.1 的打印逐位验证;注意 $P = 2^p$ 时掩码是(1<<p)-1,页号是>> p。 munmap后继续使用指针:地址空间被撤销,访问触发SIGSEGV。调试:gdb中x/4xb ptr,错误信息会给出故障地址;/proc/self/maps确认该地址已不在任何区间内。
11.6 关键要点
- 虚拟内存的本质是一层”地址间接”(indirection):CPU 只发出 VA,MMU 用页表把它翻译成 PA;程序无法观测、也无法绕过这层翻译,所有内存保护、隔离、共享都建立在此之上。
- 页是翻译与缓存的最小单位,$P = 2^p$(典型 4 KB);翻译的核心不变量是 $\mathrm{VPO} = \mathrm{PPO}$——页内偏移在翻译前后一字不变,因此翻译只需把 VPN 换成 PPN。
- VM = 缓存 + 管理 + 保护三位一体:作为缓存(页表有效位 = 命中/缺页)、作为管理工具(每进程独立地址空间、共享物理页、COW、
mmap)、作为保护工具(PTE 权限位 +SIGSEGV)。 - DRAM 缓存是全相联 + 大块 + 写回,替换策略由操作系统软件实现——缺失代价太大(磁盘比 DRAM 慢约 10,000 倍),硬件做不了;而它能工作完全依赖局部性,工作集超过主存就会抖动。
- TLB 是关键的性能设施:它缓存完整的 VPN → PPN 映射(与页表级数无关),命中省掉一次内存访问,未命中才走页表;TLB 未命中之所以罕见,是因为活跃页数远小于 TLB 项数。
- x86-64 用 4 级页表把 48 位 VA 划成 9/9/9/9/12:单级页表需 512 GB,多级页表让未分配区域不必存在下级页表;CR3 指向 L4 页表(PGD),每进程一棵页表树,切换进程即换 CR3。
11.7 思考题(带答案)
题 1(计算题:地址翻译手算) 某系统虚拟地址 14 位($n = 14$)、页大小 4 KB($p = 12$)、物理地址 14 位($m = 14$)。页表内容为:PTE0 = 有效、PPN = 2;PTE1 = 无效(页在磁盘上);PTE2 = 有效、PPN = 5;PTE3 = 有效、PPN = 7。请把虚拟地址 0x3A5C、0x1A5C、0x30A4 翻译成物理地址,并指出哪些会缺页。
答:先算位域。$n - p = 14 - 12 = 2$,故 VPN 占高 2 位、VPO 占低 12 位(= 3 个十六进制位)。
| VA | 二进制 | VPN | VPO | PTE | 结果 |
|---|---|---|---|---|---|
0x3A5C | 11 1010 0101 1100 | 3 (0x3) | 0xA5C | PPN = 7 | PA = 0x7A5C |
0x1A5C | 01 1010 0101 1100 | 1 (0x1) | 0xA5C | 无效 | 缺页(需换入并更新 PTE 后重执行) |
0x30A4 | 11 0000 1010 0100 | 3 (0x3) | 0x0A4 | PPN = 7 | PA = 0x70A4 |
验证:0x3A5C >> 12 = 0x3,0x3A5C & 0xFFF = 0xA5C;PA = (7 << 12) \| 0xA5C = 0x7000 + 0xA5C = 0x7A5C。注意 0x3A5C 与 0x30A4 的 VPN 相同(同页)、VPO 不同,因此 PPN 相同——这就是”$\mathrm{VPO} = \mathrm{PPO}$”的直接体现。0x1A5C 与 0x3A5C 的 VPO 一模一样,仅 VPN 差 1,却一个命中一个缺页,说明缺页只与页号有关,与页内偏移无关。
题 2(计算题:页表大小与页数) (a) 在 48 位虚拟地址、4 KB 页、8 字节 PTE 的机器上,单级页表需要多大?(b) x86-64 的 4 级页表中,每一级页表有多少项、多大?(c) 若一个进程只使用了 .text/.data/堆/栈/一个共享库(5 个区域),说明多级页表为什么远小于单级页表。
答:(a) 虚拟页数 $= 2^{48}/2^{12} = 2^{36}$,每项 8 字节,故 $2^{36} \times 8 = 2^{39}$ 字节 = 512 GiB(讲义给出的正是这个数字)。(b) 每级页表的索引字段 9 位,故 512 项 × 8 B = 4096 B = 恰好 1 页,天然页对齐。(c) 这 5 个区域分散在地址空间各处,4 级页表只需为被触碰到的路径分配下级页表:至多 $5 \times 4 = 20$ 张页表 ≈ 80 KB,而非 512 GiB。关键洞察:多级页表把”为整个地址空间预留”变成”只为你真正用到的地址付费”,代价是 TLB 未命中时要走 4 次访存。
题 3(计算题:TLB 位域与命中率) Core i7 的 L1 d-TLB 为 64 项、4 路组相联,VPN 共 36 位。求组数、TLBI 位数与 TLBT 位数。若某循环每轮访问 8 个不同的 4 KB 页共 10000 轮,在理想替换下 TLB 命中率是多少?若改为访问 32 个不同页呢?
答:组数 $= 64/4 = 16$,故 TLBI = 4 位,TLBT = 36 − 4 = 32 位。访问 8 个页:64 项 TLB 足以全部容纳,除强制缺失(compulsory miss)的 8 次外全部命中,命中率 $= (80000-8)/80000 = 99.99\%$。访问 32 个页:仍全部容纳(32 < 64),命中率同样接近 100%。若工作集涨到远超 64 项,则每轮都要重新换入换出,命中率急剧下降——这正是”工作集小于 TLB 容量则性能良好”的微观版本,与 11.2.7 的工作集/抖动是同一规律在两个层级上的体现。
题 4(辨析题:直观但错误的想法) 有同学说:”p = malloc(1000) 之后,*p 一定不会出错,因为内存已经被分配了;如果程序在 *p 处崩溃,那说明 malloc 有 bug。”这个想法错在哪?另有一位同学说:”两个进程共享同一个物理页 PP6,所以它们对 PP6 的权限必然相同。”这又错在哪?
答:第一句混淆了虚拟地址空间分配与物理页驻留。malloc 只保证那段 VA 区间在你的堆里(VMA 与页表项被创建,但 PTE 的有效位可能还是 0);*p 引发的缺页是正常流程,由内核换入后重新执行指令,不会崩溃。真正会崩溃的情形是:p 已被 free 且堆被 munmap/收缩后越界访问、或者 p 指向了从未映射的地址——那是页表里根本没有有效映射或权限位不满足,与 malloc 实现无关。第二句混淆了映射与权限:PTE 的 PPN 与权限位是彼此独立的字段。前面 11.2.6 的例子中,进程 i 与 j 都映射了 PP6,但两者可以有不同(甚至相反)的 READ/WRITE/SUP/EXEC 组合——这正是”共享只读库代码”能安全实现的原理:多个进程共享同一物理页,却各自只被授予读权限(r-xp / r--p)。