Lecture 12: 虚拟内存:细节 (Virtual Memory: Details)
Lecture 12: 虚拟内存:细节 (Virtual Memory: Details)
讲义对应:CMU 15-213 Lecture 12 — Virtual Memory: Details(素材:
F25-12-vm-details.txt) 教材对应:CS:APP3e 第 9 章 9.7–9.8(地址翻译实例;Linux 虚拟内存系统) 关联 Lab:L5 Malloc Lab(另可参考 recitation:F25-rec08_slides.txt)
12.1 概述
第 11 讲建立了两条抽象:虚拟地址空间(Virtual Address Space)与页表(page table)。本讲把抽象拧到底层,回答一个问题:CPU 拿着一个 48 位虚拟地址,硬件究竟做了什么才得到物理地址? 我们先走查 Intel Core i7 的四级页表与 TLB,给出一个可手算的端到端数值实例;再从硬件回到操作系统,看 Linux 如何用虚拟内存区域(VM area, VMA)描述地址空间、缺页处理程序(page fault handler)如何判定”合法访问 / 保护异常 / 段错误”,以及 mmap、写时复制(copy-on-write)、execve、堆栈按需增长这些机制。这是 L5 Malloc Lab 的直接理论前提:只有知道 sbrk 与 mmap 各自把堆伸到哪里,才能理解分配器为什么必须”不关心”应用如何使用内存。
12.2 核心概念与底层机制图解
12.2.1 四级页表与虚拟地址的位域划分(Four-Level Page Table)
- 定义与目的:单级页表要求”为每个虚拟页放一个 PTE”。48 位 VA、4 KB 页、8 B PTE 下页表项数 $=2^{48}/2^{12}=2^{36}$,总大小 $2^{36}\times 8 = 2^{39}$ 字节 $=512$ GB——比多数机器的内存还大。多级页表用树换空间:只有被使用的子树才分配。
- 直观解释:页表像多卷百科全书。单级页表相当于给每个可能页码都印卡片;四级页表像”总目录 → 卷目录 → 章目录 → 页码表”,第 254 卷不存在则后面三级一字不印。
- 底层机制图解(Core i7 的 VPN 划分:36 位 VPN 拆成 4 个 9 位索引):
47 39 38 30 29 21 20 12 11 0
+---------------------------+-------------+-------------+-------------+-----------------+
| VPN 1 (9 bit) | VPN 2 (9bit)| VPN 3 (9bit)| VPN 4 (9bit)| VPO (12 bit)|
| PGD 索引 512GB/项 | PUD 1GB/项 | PMD 2MB/项 | PTE 4KB/项| 页内偏移 |
+---------------------------+-------------+-------------+-------------+-----------------+
PGD PUD PMD PT
(Page Global Dir.) (Page Upper Dir.)(Page Middle Dir.)(Page Table)
- 与机器码/硬件的对应:走查起点是控制寄存器 CR3,保存一级页表(PGD)物理基址。为何必须是物理地址?因为 MMU 要用它访存取 PTE,若还依赖翻译就陷入无穷递归。每级 PTE 的 bit 12–51 给出下一级页表物理基址(40 位,强制 4 KB 对齐),bit 0 是存在位
P。
12.2.2 Core i7 端到端地址翻译(End-to-End Address Translation)
- 定义与目的:Core i7 的内存系统:L1 d-cache 32 KB/8 路、L1 i-cache 32 KB/8 路、L2 统一 256 KB/8 路、L3 统一 8 MB/16 路(多核共享);TLB 分两级:L1 d-TLB 64 项 4 路(16 组)、L1 i-TLB 128 项 4 路、L2 统一 TLB 512 项 4 路。MMU 夹在 CPU 和 L1 之间,它拿虚拟地址、查 TLB/页表、吐出物理地址。
- 直观解释:TLB 像贴在显示器边上的便利贴——最近用过的”页码 → 物理框号”抄一小条在旁边,绝大多数访问根本不用去翻那本四卷目录。
- 底层机制图解:
CPU 产生 VA
|
v
+-----------------+ 命中(约 99%+) +----------------------------+
| L1 d-TLB |-------------------->| PPN 直接可用,跳过 page walk|
| 64项/4路 => 16组 | +----------------------------+
+-----------------+
| 未命中
v
+-----------------+ +-------+ +-------+ +-------+ +-------+
| L2 统一 TLB |-->| PGD |-->| PUD |-->| PMD |-->| PT |
| 512项/4路 | |[VPN1] | |[VPN2] | |[VPN3] | |[VPN4] |--> PPN
+-----------------+ +-------+ +-------+ +-------+ +-------+
^CR3 ^PPN ^PPN ^PPN
4 次访存,无空间局部性 —— 这就是 page walk 的代价
|
v
PA = PPN:VPO ---> L1 d-cache (64组/8路, 块64B) ---> 命中则出数据
- 与机器码/硬件的对应:TLB 字段:
TLBI(组索引)$=$ VPN 低 4 位(16 组),TLBT$=$ VPN 高 28 位;PPO 与 VPO 相同。L1 的CI$=$ PA bit 6–11(64 组),CO$=$ bit 0–5(64 B 块),CT$=$ 余 28 位。“虚拟索引、物理标记”(virtually indexed, physically tagged):CI来自页内偏移,翻译前后不变,所以 L1 可一边查 TLB 一边用CI选组,PPN 一到只比标记,省下翻译延迟。
12.2.3 一个完整的数值走查:48 位 VA → 40 位 PA
这是必须手算一遍的例子。设 CR3 = 0x0000000000A2F000(PGD 物理基址),虚拟地址
第一步:逐位拆分(9/9/9/9/12)
VA = 0x00007F4A2B8C9A48
二进制 = 0111 1111 0100 1010 0010 1011 1000 1100 1001 1010 0100 1000
\_______/\_______/ \_______/ \_______/ \_________/
VPN1 VPN2 VPN3 VPN4 VPO
011111110 100101000 101011100 011001001 101001001000
= 0x0FE = 0x128 = 0x15C = 0x0C9 = 0xA48
= 254 = 296 = 348 = 201 = 2632
VPN = VA >> 12 = 0x7F4A2B8C9
TLBT = VPN >> 4 = 0x7F4A2B8C (28 bit 标记)
TLBI = VPN & 0xF = 0x9 (4 bit 组索引,L1 d-TLB 16 组)
第二步:逐级查表(每级条目地址 $=$ 该级页表物理基址 $+$ 索引 $\times 8$)
| 级别 | 页表 | 基址 | 索引 | 条目地址 | 条目内容 | 取出的下一级物理基址 |
|---|---|---|---|---|---|---|
| L1 | PGD | 0xA2F000 | 0x0FE=254 | 0xA2F000+0x7F0 = 0xA2F7F0 | 0x0000000000A3B067 | 0xA3B000 |
| L2 | PUD | 0xA3B000 | 0x128=296 | 0xA3B000+0x940 = 0xA3B940 | 0x000000000B7C067 | 0xB7C000 |
| L3 | PMD | 0xB7C000 | 0x15C=348 | 0xB7C000+0xAE0 = 0xB7CAE0 | 0x000000000C4E067 | 0xC4E000 |
| L4 | PT | 0xC4E000 | 0x0C9=201 | 0xC4E000+0x648 = 0xC4E648 | 0x000000000A3C067 | PPN = 0xA3C |
第三步:拼出物理地址
\[\text{PA} = (\text{PPN} \ll 12) \mid \text{VPO} = (\texttt{0xA3C} \ll 12) \mid \texttt{0xA48} = \texttt{0xA3C000} + \texttt{0xA48} = \texttt{0x0000A3CA48}\]第四步:送入 L1 d-cache($CI$ 位在翻译前后不变)
PA = 0x0000A3CA48 -> 40 bit: 0000 0000 0000 0000 1010 0011 1100 1010 0100 1000
\______________ CT (28 bit) ______________/\__ CI __/\_ CO _/
CO (bit 0-5) = 0x08 = 8 -> 块内第 8 字节
CI (bit 6-11) = 0x29 = 41 -> 第 41 组 (共 64 组)
CT (bit 12-39)= 0x0000A3C -> 与组内 8 路的标记比较
整个 page walk 需 4 次额外访存(PTE 本身也可能 miss),且这 4 次之间没有空间局部性——这正是多级页表比单级慢的原因,也是 TLB 存在的理由。TLB 命中时第二步全跳过,直接得到 PPN。
12.2.4 页表项的低 12 位:权限位(Permission Bits)
- 定义与目的:PTE 的 bit 12–51 是物理基址,低 12 位全是标志位——这也是”页表必须 4 KB 对齐”的根本原因:只有对齐,低 12 位才空得出来当标志。
- 底层机制图解:
bit: 63 62 52 51 12 11 9 8 7 6 5 4 3 2 1 0
+-----+--------+------------------------------+-----+-+-+-+-+-+-+-+-+
L1-L3 | XD | Unused | 下一级页表物理基址 (40 bit) | AVAIL|G|PS|A|CD|WT|U/S|R/W|P|
+-----+--------+------------------------------+-----+-+-+-+-+-+-+-+-+
L4 | XD | Unused | 物理页基址 (40 bit) | AVAIL|G|D |A|CD|WT|U/S|R/W|P|
+-----+--------+------------------------------+-----+-+-+-+-+-+-+-+-+
对照条目 0x...067 = 0000 0000 0110 0111b:
bit0 P=1 页/子页表在物理内存 bit5 A=1 引用位(MMU 置, 软件清)
bit1 R/W=1 可写(对所有可达页) bit6 D=1 脏位(仅 L4; COW 靠它)
bit2 U/S=1 允许用户态(0=仅内核) bit7 PS=0 仅 L1: 0=4KB 子表, 1=4MB 大页
bit3/4 PWT/PCD 缓存策略 bit8 G=0 全局页(切任务不逐出 TLB)
- 与机器码/硬件的对应:
P=0时 bit 12–51 不再是物理地址,而记录该页在磁盘(交换区)上的位置——页表兼作磁盘地址簿。访P=0的页触发缺页;R/W=0时写触发保护异常。
12.2.5 Linux 的虚拟内存区域(VM Area / VMA)
- 定义与目的:一个进程的虚拟地址空间不是”一整块”,而是一串同质的区间。Linux 用
vm_area_struct描述其中一个区间,用mm_struct描述整个地址空间。 - 直观解释:VMA 像房产证的附件清单——”0x400000–0x401000:可读,来自 a.out 偏移 0”,而非逐页登记。相邻且权限相同的页被合并成一个 VMA,所以
/proc/pid/maps通常只有几十行。 - 底层机制图解(一个典型 x86-64 Linux 进程的 VMA 布局):
高地址 0xffffffffffffffff
+--------------------------------------+
| 内核虚拟内存(用户不可见) |
0xffff800000000000
+--------------------------------------+
| [vsyscall] --xp 遗留固定映射 |
| [vvar] r--p 只读内核数据 |
| [vdso] r-xp 内核"共享库" |
| [stack] rw-p %rsp 附近, 向下增长 |
| ... 未映射空洞(不可访问) ... |
0x7f.... +--------------------------------------+
| mmap 匿名区 rw-p malloc 大块 |
| libc.so.6 r-xp/r--p/rw-p 共享库 |
| [heap] rw-p brk 指针, 向上增长 |
0x555555554000
+--------------------------------------+
| a.out r--p .init/.plt 私有映射 |
| r-xp .text (代码) |
| r--p .rodata(只读数据) |
| rw-p .data/.bss |
低地址 0x400000
vm_area_struct 的核心字段:vm_start / vm_end、vm_flags(VM_READ / VM_WRITE / VM_EXEC / VM_SHARED)、vm_prot、vm_file、vm_pgoff。mm_struct 持有 pgd(一级页表物理地址,即 CR3 的内容)、mmap(VMA 链表头)、start_brk/brk、start_stack。Linux 还用红黑树按地址索引 VMA,使”该地址落在哪个 VMA 里”能在 $O(\log n)$ 内回答。
- 与机器码/硬件的对应:切换进程时内核把
mm_struct.pgd写进 CR3——这就是”每个进程有自己的地址空间”的硬件实现。VMA 是纯软件结构,MMU 完全不知道它存在。
12.2.6 缺页处理程序的完整逻辑(Page Fault Handler)
- 定义与目的:MMU 查页表发现
P=0或权限不符时触发缺页异常,控制权交给内核。处理程序要回答:地址合法吗?权限对吗?页从哪来? - 底层机制图解:
MMU 访存 -> PTE 无效 / 权限不符 -> 触发 #PF (fault)
|
v
+-----------------------------------------------------+
| 1. 保存现场: 出错 VA 放入 CR2, 压入 error code |
+-----------------------------------------------------+
|
v
+-----------------------------------------------------+
| 2. 合法性判定(软件): VA 落在某个 VMA 内吗? 查红黑树 |
+-----------------------------------------------------+
| 否 | 是
v v
+-----------+ +--------------------------------+
| SIGSEGV | | 3. 权限: VMA 权限与访问类型一致? |
| 段错误 | +--------------------------------+
+-----------+ | 否 | 是
v v
+-----------+ +-------------------------+
| SIGSEGV | | 4. 按 VMA 类型换入页面 |
| 保护异常 | | a) 匿名私有: 全零页 |
+-----------+ | b) 文件映射: 页缓存/磁盘 |
| c) 交换区: 从 swap 换回 |
| d) COW: 复制页并改权限 |
+-------------------------+
|
v
+-------------------------+
| 5. 更新 PTE: 填 PPN, P=1 |
+-------------------------+
|
v
+-------------------------+
| 6. 返回, CPU 重执行该指令 |
| (这次页命中) |
+-------------------------+
- 与机器码/硬件的对应:x86-64 用
CR2保存出错地址,压栈的 error code 区分”页不存在/保护违规、读/写、用户/内核”。do_page_fault()取mmap_lock,用find_vma()找 VMA,再调handle_mm_fault()。硬缺页(major fault)须从磁盘读;软缺页(minor fault)页仍在内存(COW 或 page cache 命中);dmesg的segfault at ... error 4即 error code 十六进制。
12.2.7 内存映射(Memory Mapping, mmap / munmap)
- 定义与目的:
mmap把文件对象(普通文件、匿名内存、共享库、设备)的区间映射进虚拟地址空间成为一段 VMA,把”磁盘 → 内核缓冲区 → 用户缓冲区”的两次拷贝变成用户直读页缓存的零次拷贝。 - 直观解释:读文件本像”把字抄到笔记本再读”;
mmap是”把书页摊在桌上”——翻到哪页,操作系统才把哪页给你(按需分页,demand paging)。 - 底层机制图解(三种用途对应三种 VMA):
(1) 映射普通文件: mmap(..., fd, offset) (2) 匿名内存: MAP_ANONYMOUS
VMA rw-s (MAP_SHARED) VMA rw-p
+--------------+ +--------------+
| 进程页表 PTE | | 进程页表 PTE |
+------+-------+ +------+-------+
v v
页缓存 (page cache) 零页 (首次写时才分配)
v v
磁盘上的文件 物理内存(无文件后端)
写回, 多进程共享 fork 后被 COW 保护
(3) 映射共享库: 同一个 libc.so.6 被 N 个进程映射
进程A 页表 --> PTE r-x --> 同一批物理页(只读代码) <- PTE r-x <-- 页表 进程B
(物理内存里只有一份代码)
- 与机器码/硬件的对应:
mmap系列系统调用:
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);
int mprotect(void *addr, size_t len, int prot);
int msync(void *addr, size_t len, int flags);
prot 取 PROT_READ/PROT_WRITE/PROT_EXEC/PROT_NONE;flags 必须含 MAP_SHARED 或 MAP_PRIVATE 之一,可或上 MAP_ANONYMOUS(忽略 fd)、MAP_FIXED(要求精确地址,危险)、MAP_POPULATE(预先驻留)。offset 必须是页大小的整数倍。返回值是页对齐的地址;失败返回 MAP_FAILED(即 (void*)-1)并置 errno。
12.2.8 fork 与写时复制(Copy-on-Write, COW)
- 定义与目的:
fork语义上要”复制整个地址空间”,实测只要几十微秒。技巧是只复制页表不复制物理页:父子 PTE 都改成只读并打 COW 标记;谁先写谁触发保护异常,由内核复制那一页。 - 直观解释:像两人共用一本图书馆的书。管理员宣布”谁都不许在书上写字”;某人真要写时,管理员才影印一页给他——在此之前一本就够两人读。
- 底层机制图解:
fork() 之后(尚未有人写) 进程 A 写 page X 时
父页表 子页表 1) PTE 只读 -> 保护异常 (#PF, error=7)
+-----+ +-----+ 2) 内核发现是 COW 页, 引用计数 > 1
|RO PTE|-------->|RO PTE| 3) 申请新页, 拷贝原页内容
+--+--+ +--+--+ 4) 写入方 PTE 指向新页, 改回可写
| | 5) 原页留给另一方, 计数减 1
+-------+--------+
v
物理页 P (引用计数=2, 内容相同)
写后:
父页表 子页表
+-----+ +-----+
|RO | |RW | 父仍指 P(只读, 直到它也写)
+--+--+ +--+--+ 子指 P'(私有副本, 可写)
| |
v v
物理页 P 物理页 P' (拷贝)
- 与机器码/硬件的对应:内核做的是
copy_mm()→dup_mmap()→copy_page_range():复制页表项并把双方 PTE 清掉_PAGE_RW,异常走do_wp_page()。x86 的R/W位与CR2是硬件基础。fork后立即execve(shell 的常见模式)不复制任何页,故 shell 能廉价起进程。
12.2.9 execve 加载新程序的过程
- 定义与目的:
execve在保留同一进程(同一 PID)的前提下把地址空间整个换掉。 - 底层机制图解:
execve("./prog", argv, envp)
1. 校验可执行文件格式 (ELF magic) 与权限
2. 释放旧地址空间: 遍历 VMA 逐个 munmap/释放物理页
(打开的文件描述符表保留)
3. 映射私有区域 (MAP_PRIVATE, 按需分页):
.text -> r-xp (文件映射) .rodata -> r--p
.data -> rw-p (写时复制) .bss -> rw-p (匿名零页)
4. 映射共享区域: 把 libc.so.6 等共享库 mmap 进来 (r-xp/rw-p)
5. 设置入口点: 把 ELF 的 e_entry 写入 PCB 保存的下一条指令地址
(通常是 _start, 由它调 __libc_start_main 再调 main)
6. 把 argv/envp 压到新用户栈顶, 设置 %rsp 与 %rdi/%rsi/%rdx
7. 返回用户态, 从入口点执行, 地址空间已完全更换
- 与机器码/硬件的对应:
.text是文件映射且PROT_EXEC,同一程序启动 100 次物理内存里只有一份代码;.bss是匿名映射,靠”零页 + 缺页”实现”初始化为 0”而不占磁盘空间。
12.2.10 栈与堆的按需增长
- 定义与目的:栈从高地址向下增长,堆从低地址向上增长(
brk指针),两者中间是巨大的未映射空洞。它们都不是预先分配的,而是靠缺页驱动扩展。 - 底层机制图解:
[stack] VMA [heap] VMA
0x7ffd2023e000 0x01940000
+-----------+ <- %rsp 不断下移 +-----------+ <- brk 不断上移
| 已用栈帧 | | 已分配块 |
+-----------+ +-----------+
| 未触碰页 | <- push/call 触碰 | 未触碰页 |
+-----------+ 触发缺页, +-----------+
| VMA 边界 | 内核把 vm_start | brk 指针 |
+-----------+ 向下挪一页 +-----------+
| |
继续下探 -> expand_stack() sbrk(n) -> brk() 系统调用
| |
超出 RLIMIT_STACK(默认 8 MB) 超出 RLIMIT_AS 或撞 mmap 区 ->
或撞到其它 VMA -> SIGSEGV brk 返回 -1, malloc 返回 NULL
- 与机器码/硬件的对应:
sbrk(incr)是brk的薄封装,返回旧的 brk 值;brk(addr)直接把堆顶设为addr。栈的自动增长依赖”缺页地址紧邻vm_start之下”——所以跳过一大段直接访问(%rsp减 1 MB 再写)会越过增长窗口而段错误。ulimit -s(8192 KB)限栈,ulimit -v(32000000 KB)限虚拟地址空间。
12.3 代码示例与底层机制分析
12.3.1 示例 A:mmap 文件映射——共享映射 vs 私有映射
代码 (C):
/* mmap_demo.c: 文件映射 共享 vs 私有
* gcc -g -Wall -std=c11 mmap_demo.c -o mmap_demo && ./mmap_demo */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <errno.h>
#define PAGE 4096
static void dump(const char *tag, unsigned char *p, size_t n) {
printf("%-28s: ", tag);
for (size_t i = 0; i < n; i++) printf("%02x ", p[i]);
printf(" | \"%.*s\"\n", (int)n, p);
}
int main(void) {
const char *path = "/tmp/csapp12/seed.txt";
/* 1. 造一页(4096B)的文件 */
int fd = open(path, O_RDWR | O_CREAT | O_TRUNC, 0644);
if (fd < 0) { perror("open"); return 1; }
char buf[PAGE];
memset(buf, '.', sizeof buf);
memcpy(buf, "AAA-original-page-content", 25);
if (write(fd, buf, sizeof buf) != PAGE) { perror("write"); return 1; }
/* 2. SHARED/PRIVATE 映射同一区间 */
unsigned char *sh = mmap(NULL, PAGE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
unsigned char *pr = mmap(NULL, PAGE, PROT_READ | PROT_WRITE, MAP_PRIVATE, fd, 0);
if (sh == MAP_FAILED || pr == MAP_FAILED) { perror("mmap"); return 1; }
printf("sh (MAP_SHARED ) = %p\npr (MAP_PRIVATE) = %p\n", (void *)sh, (void *)pr);
printf("sh == pr ? %s (两个映射不同虚拟地址)\n\n", sh == pr ? "yes" : "NO");
dump("初始 sh", sh, 4); dump("初始 pr", pr, 4);
/* 3. 各写首字节 */
sh[0] = 'S'; /* 共享写: 落到文件 */
pr[0] = 'P'; /* 私有写: 触发 COW */
printf("\n-- 写入 sh[0]='S' (SHARED), pr[0]='P' (PRIVATE) --\n");
dump("写后 sh", sh, 4);
dump("写后 pr", pr, 4);
/* 4. 独立读文件验证落盘 */
int fd2 = open(path, O_RDONLY);
unsigned char disk[4];
if (pread(fd2, disk, 4, 0) != 4) { perror("pread"); return 1; }
dump("文件实际内容(独立读)", disk, 4);
close(fd2);
/* 5. 看该文件的 VMA */
printf("\n-- /proc/self/maps 中 seed.txt 的行 --\n");
fflush(stdout);
char cmd[256];
snprintf(cmd, sizeof cmd, "grep seed.txt /proc/%d/maps", (int)getpid());
int rc = system(cmd);
(void)rc;
munmap(sh, PAGE); munmap(pr, PAGE); close(fd); unlink(path);
return 0;
}
【代码做什么?】
- 造一个 4096 字节的文件,首 25 字节是
AAA-original-page-content,其余填'.'。 - 对同一 fd、同一偏移 0 建立两个映射:
MAP_SHARED与MAP_PRIVATE,内核给它们分配不同的虚拟地址区间。 - 写
sh[0]='S'(共享)与pr[0]='P'(私有)。 - 用
pread绕过映射直接读文件,判断谁真正改了文件。 - 打印
/proc/self/maps中该文件两行,展示rw-s与rw-p。
【底层机制透视】
- 初始时两个映射的 PTE 都指向页缓存里同一批物理页且只读。所以
sh == pr为假——虚拟地址不同、物理页相同,这正是”同一物理数据出现在多个虚拟地址”的体现。 - 写
sh[0]:MAP_SHARED允许写,内核把页缓存该页标脏(置 PTE 的D位)。页缓存就是文件内容,所以文件立刻”变了”(最终由回写线程落盘)。 - 写
pr[0]:MAP_PRIVATE的写触发 COW——内核复制页缓存那一页,把本进程 PTE 指向私有副本。此后该进程看到'P',文件与页缓存仍是'S'。私有映射的修改不写回文件,与fork同机制。 mmap返回的地址一定页对齐,映射长度向上取整到页的整数倍。
【内存布局 / 数据结构图解】(实测输出的地址)
虚拟地址空间 物理内存 / 页缓存
0x7f158834d000 +--------------+ +----------------------+
MAP_SHARED | rw-s seed.txt|------>| 页缓存页 (文件第 0 页) |
+--------------+ +--->| 'S' 'A' 'A' '-' ... |
0x7f1588317000 +--------------+ | +----------------------+
MAP_PRIVATE | rw-p seed.txt|--+ ^ 写 sh[0]='S' 改这里
+--------------+ |
写 pr[0]='P' -> COW 复制出新物理页
+----------------------+
| 私有副本 'P' 'A' ... |
+----------------------+
【实测验证】(真实运行输出):
$ gcc -g -Wall -std=c11 /tmp/csapp12/mmap_demo.c -o /tmp/csapp12/mmap_demo # 无警告
$ ./mmap_demo
sh (MAP_SHARED ) = 0x7f158834d000
pr (MAP_PRIVATE) = 0x7f1588317000
sh == pr ? NO (两个映射不同虚拟地址)
初始 sh : 41 41 41 2d | "AAA-"
初始 pr : 41 41 41 2d | "AAA-"
-- 写入 sh[0]='S' (SHARED), pr[0]='P' (PRIVATE) --
写后 sh : 53 41 41 2d | "SAA-"
写后 pr : 50 41 41 2d | "PAA-"
文件实际内容(独立读): 53 41 41 2d | "SAA-"
-- /proc/self/maps 中 seed.txt 的行 --
7f1588317000-7f1588318000 rw-p 00000000 08:03 305310360 /tmp/csapp12/seed.txt
7f158834d000-7f158834e000 rw-s 00000000 08:03 305310360 /tmp/csapp12/seed.txt
地址与 PID 每次运行都变(ASLR),但权限字段
rw-p/rw-s与”共享写改了文件、私有写只改副本”的结论恒定。
注意 maps 行的格式:起始-结束 权限 文件内偏移 设备 inode 路径。权限字段的第 4 个字符是关键:p 表示私有(MAP_PRIVATE),s 表示共享(MAP_SHARED)。
12.3.2 示例 B:fork + 写时复制
代码 (C):
/* cow_demo.c: fork 写时复制 (COW)
* gcc -g -Wall -std=c11 cow_demo.c -o cow_demo && ./cow_demo */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
static void faults(const char *tag) {
FILE *f = fopen("/proc/self/stat", "r");
if (!f) { perror("stat"); return; }
char line[4096];
if (!fgets(line, sizeof line, f)) { fclose(f); return; }
fclose(f);
char *p = strrchr(line, ')'); /* comm 可能含空格 */
if (!p) return;
p++;
unsigned long minflt = 0, majflt = 0;
int idx = 3; char *tok = strtok(p, " "); /* 第 3 字段起 */
while (tok) {
if (idx == 10) minflt = strtoul(tok, NULL, 10);
if (idx == 12) { majflt = strtoul(tok, NULL, 10); break; }
tok = strtok(NULL, " "); idx++;
}
printf("%-22s min_flt=%lu maj_flt=%lu\n", tag, minflt, majflt);
}
int global_var = 100; /* .data: 私有可写 */
static int st = 7; /* .data */
int main(void) {
int *heap = malloc(sizeof(int));
*heap = 42;
int local = 5; /* stack */
printf("== fork 前 (pid=%d) ==\n", (int)getpid());
printf("&global_var=%p &st=%p &local=%p heap=%p\n",
(void *)&global_var, (void *)&st, (void *)&local, (void *)heap);
printf("global=%d st=%d local=%d *heap=%d\n", global_var, st, local, *heap);
faults("fork 前");
fflush(stdout);
pid_t pid = fork();
if (pid < 0) { perror("fork"); return 1; }
if (pid == 0) {
printf("\n[child ] pid=%d &global_var=%p &local=%p heap=%p\n",
(int)getpid(), (void *)&global_var, (void *)&local, (void *)heap);
printf("[child ] 改前: global=%d local=%d *heap=%d\n", global_var, local, *heap);
faults("child fork 后(未写)");
global_var = 200; local = 50; *heap = 420; /* 写 -> COW */
printf("[child ] 改后: global=%d local=%d *heap=%d\n", global_var, local, *heap);
faults("child 写后(COW)");
fflush(stdout);
_exit(0);
}
int status; waitpid(pid, &status, 0);
printf("\n[parent] pid=%d &global_var=%p &local=%p heap=%p\n",
(int)getpid(), (void *)&global_var, (void *)&local, (void *)heap);
printf("[parent] 子进程改过之后: global=%d local=%d *heap=%d <- 父进程值没变\n",
global_var, local, *heap);
faults("parent 写前");
global_var = 999;
faults("parent 写后(COW)");
free(heap);
return 0;
}
【代码做什么?】
- 在
.data(global_var、st)、堆(*heap)、栈(local)各放一个变量,打印地址与值。 - 从
/proc/self/stat读出min_flt(次要缺页)与maj_flt(主要缺页)计数。 fork出子进程:先打印地址与值(应与父进程完全相同),再写这三个变量,然后重读缺页计数。- 父进程
waitpid后重新读值——地址和子进程一模一样,值却仍是自己的。
【底层机制透视】
- 子进程打印的
&global_var、&local、heap与父进程逐位相同。这毫不神秘:fork复制了页表,所以虚拟地址不变;变化的是 PTE 指向的物理页由”先共享、写时复制”决定。 min_flt的跳变就是 COW 的指纹:子进程fork后未写时只有 14;写 3 个变量后变成 17,恰好多 3 次——每次写一页触发一次保护异常 → 复制一页 → 更新 PTE。父进程随后写global_var,min_flt从 92 涨到 93,又是一次 COW。fork快到什么程度?它不做任何数据拷贝,只复制页表(几 KB)并把所有可写 PTE 改成只读。这就是 shell 里ls能瞬间返回的原因。- 注意
_exit(0)而不是exit(0):exit会刷 stdio 缓冲区,而子进程继承的缓冲区里还有父进程未刷的内容,会造成重复输出。
【内存布局 / 数据结构图解】(实测地址)
父进程虚拟地址 子进程虚拟地址 物理内存
0x7fff16be0920 local == 0x7fff16be0920 local --> 栈页 (COW, 引用计数 2)
0xc772a0 heap == 0xc772a0 heap --> 堆页 (COW, 引用计数 2)
0x4040a0 global_var == 0x4040a0 global_var --> .data 页 (COW, 计数 2)
子进程写之后:
0x7fff16be0920 local == 0x7fff16be0920 local --> 父: 旧栈页 | 子: 新栈页
0xc772a0 heap == 0xc772a0 heap --> 父: 旧堆页 | 子: 新堆页
0x4040a0 global_var=100 == 0x4040a0 global_var=200 --> 各自私有副本
【与汇编 / 硬件的对应】:fork 用户态只是 call fork@plt,魔法在 sys_fork → copy_process() → copy_mm() → dup_mmap() → copy_page_range()。COW 的写保护依赖 PTE 的 R/W 位(清 0 即只读),异常由 #PF 的 error code(写 + 用户 + 保护违规)识别。可用 gdb 验证:
$ gcc -g -Wall -std=c11 cow_demo.c -o cow_demo && gdb -q ./cow_demo
(gdb) break fork
(gdb) run
(gdb) p &global_var
$1 = (int *) 0x4040a0
(gdb) catch syscall fork
(gdb) continue
【实测验证】(真实运行输出):
$ ./cow_demo
== fork 前 (pid=3820916) ==
&global_var=0x4040a0 &st=0x4040a4 &local=0x7ffcdc73f930 heap=0x22bb2a0
global=100 st=7 local=5 *heap=42
fork 前 min_flt=78 maj_flt=0
[child ] pid=3820917 &global_var=0x4040a0 &local=0x7ffcdc73f930 heap=0x22bb2a0
[child ] 修改前: global=100 local=5 *heap=42
child fork 后(未写) min_flt=14 maj_flt=0
[child ] 改后: global=200 local=50 *heap=420
child 写后(COW) min_flt=17 maj_flt=0
[parent] pid=3820916 &global_var=0x4040a0 &local=0x7ffcdc73f930 heap=0x22bb2a0
[parent] 子进程改过之后: global=100 local=5 *heap=42 <- 父进程值没变
parent 写前 min_flt=92 maj_flt=0
parent 写后(COW) min_flt=93 maj_flt=0
地址与缺页计数随 ASLR 与启动路径浮动(另一次运行是
fork 前 min_flt=388、parent 写后 min_flt=403),但“写 3 个变量 +3 次缺页”和”父子地址完全相同、值各自独立”这两个结论每次都成立。
12.3.3 示例 C:用 malloc 观察 brk 与 mmap 的分界
代码 (C):
/* brk_grow.c: 小请求走 brk, 大请求走 mmap
* gcc -g -Wall -std=c11 brk_grow.c -o brk_grow */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main(void) {
printf("起始 brk = %p\n", sbrk(0));
void *ptrs[64];
for (int i = 0; i < 16; i++) { /* 64 KiB < 128 KiB */
ptrs[i] = malloc(64 * 1024);
memset(ptrs[i], i, 64 * 1024); /* 触碰才落页 */
printf("第 %2d 次 malloc(64KiB) = %p brk = %p\n", i, ptrs[i], sbrk(0));
}
void *big = malloc(256 * 1024); /* > 阈值 -> mmap */
memset(big, 1, 256 * 1024);
printf("malloc(256KiB) = %p brk = %p <- brk 不动, 走了 mmap\n", big, sbrk(0));
FILE *f = fopen("/proc/self/maps", "r");
char line[512];
printf("\n-- [heap] --\n");
while (f && fgets(line, sizeof line, f))
if (strstr(line, "[heap]")) fputs(line, stdout);
if (f) fclose(f);
return 0;
}
【底层机制透视】:glibc 的 malloc 有两条路径。请求 $<$ M_MMAP_THRESHOLD(默认 128 KB)走堆(brk/sbrk),否则用 mmap 匿名映射、free 时 munmap 归还。理由很实际:brk 只能从堆顶线性伸缩,大块夹在中间会造成堆顶无法回收,而 mmap 可从任意位置归还。这就是 Malloc Lab 里 sbrk 是主要工具的原因——trace 里绝大多数请求都很小,战场就在 [heap]。
【实测验证】(真实运行输出):
$ gcc -g -Wall -std=c11 brk_grow.c -o brk_grow && ./brk_grow
起始 brk = 0x1eb5000
第 0 次 malloc(64KiB) = 0x1eb62b0 brk = 0x1ed6000
第 1 次 malloc(64KiB) = 0x1ec62c0 brk = 0x1ef7000
第 2 次 malloc(64KiB) = 0x1ed62d0 brk = 0x1ef7000
第 3 次 malloc(64KiB) = 0x1ee62e0 brk = 0x1ef7000
第 4 次 malloc(64KiB) = 0x1ef62f0 brk = 0x1f27000
...
第 15 次 malloc(64KiB) = 0x1fa63a0 brk = 0x1fb7000
malloc(256KiB) = 0x7f1f0eb5c010 brk = 0x1fb7000 <- brk 不动, 走了 mmap
-- [heap] --
01eb5000-01fb7000 rw-p 00000000 00:00 0 [heap]
brk增量为0x21000 = 132 KB(glibc 的M_TOP_PAD后备增量),说明brk并非每次分配都增长;换一次运行地址与增量会不同,但“小请求在[heap]、大请求落在0x7f...且brk不动”这一判据恒定。
观察两点:(1) brk 每次跳 0x21000 = 132 KB,是 glibc 一次 sbrk 的后备增量(M_TOP_PAD),所以 brk 并不每次增长;(2) 256 KiB 的块地址落在 0x7fd...(mmap 区),brk 纹丝不动。
用 strace 可以直接看到这两条系统调用:
$ strace -e trace=brk,mmap,munmap ./strace_demo
brk(NULL) = 0x13bd000
brk(0x13de000) = 0x13de000 <- malloc(64KiB) 走这
mmap(NULL, 1052672, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f68a1aff000
munmap(0x7f68a1aff000, 1052672) = 0 <- free 大块立即归还
a=0x13bd2a0 b=0x7f68a1aff010
12.3.4 示例 D:三类访存异常与内存工具链
mmap 一个区间、只读映射、访问未映射地址,分别对应合法缺页、保护异常、段错误。以下程序故意触发后两种,属演示代码,请勿模仿:
/* fault_demo.c 用法: ./fault_demo <0|1|2>
* ⚠️ 演示用, 勿模仿: mode 1/2 故意触发 SIGSEGV */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/mman.h>
static void handler(int sig, siginfo_t *si, void *ctx) {
(void)ctx; (void)sig;
printf("\n[信号] SIGSEGV, si_addr = %p\n", si->si_addr);
fflush(stdout);
_exit(42);
}
int main(int argc, char **argv) {
int mode = argc > 1 ? atoi(argv[1]) : 0;
struct sigaction sa = {0};
sa.sa_sigaction = handler; sa.sa_flags = SA_SIGINFO;
sigaction(SIGSEGV, &sa, NULL);
if (mode == 0) { /* 合法缺页: 页未驻留 */
char *p = mmap(NULL, 4 * 4096, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
printf("匿名映射 %p .. %p (未驻留)\n", p, p + 4*4096-1);
p[0] = 1; /* 缺页, 内核给零页 */
printf("首次写 p[0] 成功 -> 缺页处理程序已接入\n");
} else if (mode == 1) { /* 权限不对 -> 保护异常 */
char *p = mmap(NULL, 4096, PROT_READ, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
printf("只读映射 %p, 尝试写入...\n", p);
fflush(stdout);
p[0] = 1;
} else { /* 不在任何 VMA 内 -> 段错误 */
char *bad = (char *)0x0000000100000000UL;
printf("写不在任何 VMA 内的 %p ...\n", bad);
fflush(stdout);
*bad = 1;
}
return 0;
}
【实测验证】:
$ ./fault_demo 0
匿名映射 0x7f80d29b9000 .. 0x7f80d29bcfff (未驻留)
首次写 p[0] 成功 -> 缺页处理程序已接入
$ ./fault_demo 1
只读映射 0x7f1d35f6c000, 尝试写入...
[信号] SIGSEGV, si_addr = 0x7f1d35f6c000 <- 地址合法, 权限不符
$ ./fault_demo 2
写不在任何 VMA 内的地址 0x100000000 ...
[信号] SIGSEGV, si_addr = 0x100000000 <- 地址根本不在任何 VMA 内
关键洞察:mode 1 与 mode 2 都得到 SIGSEGV,但原因完全不同——前者”地址合法、权限不符”,后者”地址不在任何 VMA 内”。内核用同一信号表达二者,必须看 si_addr 与 /proc/pid/maps 才能区分。
【VMA 与本机参数实测】:
$ getconf PAGE_SIZE
4096
$ ulimit -v # 虚拟地址空间上限 (KB)
32000000
$ ulimit -s # 栈上限 (KB)
8192
$ grep -E 'MemTotal|MemAvailable|SwapTotal|Committed_AS|PageTables' /proc/meminfo
MemTotal: 527645792 kB
MemAvailable: 310249888 kB
SwapTotal: 4095996 kB
Committed_AS: 270887672 kB
PageTables: 1166892 kB <- 全系统页表本身占 ~1.1 GB!
PageTables: 1166892 kB 是最有说服力的数字:即便有四级页表与 TLB,光页表结构就吃掉 1.1 GB 物理内存。
要知道每个 VMA 究竟驻留了多少物理内存,看 /proc/<pid>/smaps(比 maps 多出逐段统计):
$ head -12 /proc/self/smaps
56513fbd3000-56513fbd5000 r--p 00000000 08:05 2013266890 /usr/bin/head
Size: 8 kB
KernelPageSize: 4 kB
Rss: 8 kB <- 实际驻留
Pss: 8 kB <- 按共享比例摊分后的份额
Shared_Clean: 0 kB
Private_Dirty: 0 kB
Private_Clean: 8 kB
Anonymous: 0 kB <- 匿名页(堆/栈)为 0, 说明这是纯文件映射
Swap: 0 kB
Rss 与 Size 之差就是”已映射但尚未触碰”的部分;Private_Dirty 与 Shared_Dirty 的对比可直接看出 COW 复制与共享写各占多少。
/proc/self/maps 的一个真实片段(运行 vma_demo 时):
00400000-00401000 r--p 00000000 08:03 304575903 /tmp/csapp12/vma_demo <- ELF 头
00401000-00402000 r-xp 00001000 08:03 304575903 /tmp/csapp12/vma_demo <- .text
00402000-00403000 r--p 00002000 08:03 304575903 /tmp/csapp12/vma_demo <- .rodata
00403000-00404000 r--p 00002000 08:03 304575903 /tmp/csapp12/vma_demo <- 重定位页
00404000-00405000 rw-p 00003000 08:03 304575903 /tmp/csapp12/vma_demo <- .data/.bss
01940000-01961000 rw-p 00000000 00:00 0 [heap]
7fa7d0c00000-7fa7d0c28000 r--p 00000000 08:05 3221225831 /usr/lib64/libc.so.6
7fa7d0c28000-7fa7d0d9d000 r-xp 00028000 08:05 3221225831 /usr/lib64/libc.so.6
7fa7d0d9d000-7fa7d0df5000 r--p 0019d000 08:05 3221225831 /usr/lib64/libc.so.6
7fa7d0df5000-7fa7d0df6000 ---p 001f5000 08:05 3221225831 /usr/lib64/libc.so.6 <- 保护页!
7fa7d0dfa000-7fa7d0dfc000 rw-p 001f9000 08:05 3221225831 /usr/lib64/libc.so.6
7fa7d0aff000-7fa7d0c00000 rw-p 00000000 00:00 0 <- malloc(1MiB) 匿名区
7ffee41a0000-7ffee41c4000 rw-p 00000000 00:00 0 [stack]
7ffee41e2000-7ffee41e6000 r--p 00000000 00:00 0 [vvar]
7ffee41e6000-7ffee41e8000 r-xp 00000000 00:00 0 [vdso]
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0 [vsyscall]
对照 12.2.5 的布局图:每一行就是一个 VMA;---p 是 PROT_NONE 保护页(让越界一字节立即段错误);[heap] 与 [stack] 之间的”空洞”宽约 47 TB;malloc(1MiB) 的匿名区文件名列为空,正是 12.3.3 所说的 mmap 区。
pmap 与 vmstat 可直接观察 VMA 与换页:
$ pmap -x <pid>
Address Kbytes RSS Dirty Mode Mapping
000056210bce0000 132 24 24 rw--- [ anon ] <- 堆
00007f62ece00000 2528 156 0 r---- LC_COLLATE
$ vmstat 1 2
r b swpd free buff cache si so bi bo in cs us sy id wa st
9 0 4044000 19040120 24 394830304 0 0 2 20 0 0 3 1 95 0 0
vmstat 的 si/so 是换入/换出速率(本机 swpd 约 4 GB);pmap -x 的 Kbytes 是虚拟大小、RSS 是实际驻留,两者相差一个数量级,正好印证”虚拟内存远大于物理内存”。
12.4 实验关联:L5 Malloc Lab
本讲是 L5 Malloc Lab 的直接理论前提,recitation(F25-rec08_slides.txt)补上了工程细节:
- 为什么
sbrk是主要工具:12.3.3 已实测,trace 里绝大多数请求远小于 128 KB 的MMAP_THRESHOLD,走brk/sbrk路径。分配器从mem_sbrk()(sbrk封装)拿货,返回新扩展区域起始地址,必须处理失败:返回NULL时mm_malloc也返回NULL。 - 堆是连续的虚拟内存:
brk只能线性伸缩,分配器须自己管空闲块(隐式 → 显式 → 分离链表),碎片代价由你承担。 - 契约(contracts)与堆不变量是调试主线。recitation 的排错流程很典型:
$ gcc -g -Wall -std=c11 mm.c mdriver.c -o mdriver
$ ./mdriver -c ./traces/syn-struct-short.rep
ERROR [trace ..., line 16]: block 1 (at 0x8000000a0) has 8 garbled bytes, starting at byte 16
$ gdb --args ./mdriver-dbg1 -c ./traces/syn-struct-short.rep
(gdb) watch *0x8000000a0
(gdb) run
Hardware watchpoint 1: *0x8000000a0
Old value = 129
New value = 32
write_block() at mm.c:333 <- 定位到 write_block() 越界写
$ ./mdriver-dbg2 -c ./traces/syn-struct-short.rep
mdriver-dbg: mm.c:331: void write_block(block_t *, size_t, _Bool):
Assertion `(unsigned long)footerp < ((long)block + size)' failed.
- “Garbled bytes” 的含义:
mdriver写 payload 后校验;被改写说明分配器在已分配块内部写了东西——典型原因是块头/脚部算错,或find_fit返回已占用的块。 - 排错提示:Valgrind 不能用(它模拟堆,会与你的
sbrk冲突);mdriver -D尽早检测乱码,-V定位出错的 trace。只能靠 gdb 硬件观察点 + 自写堆检查器。 - 两条铁律(为 Lecture 13–14 铺垫):
- 应用不知道也不关心分配器如何管理内存。它只知道
malloc/free/realloc的语义契约与对齐要求;内部用隐式、显式还是分离链表,完全是自由的实现细节。 - 分配器不能假定数据的位模式。返回的内存可能是任意字节(
malloc)或全零(calloc),但绝不能认为 8 字节对齐就够:x86-64 ABI 要求malloc返回16 字节对齐(long double/SSE 需要movaps)。最小块大小与填充策略都必须围绕 16 字节设计。recitation 的利用率先修计算即由此而来:malloc(24)在带脚部的旧设计下是”块头 8 + 数据 24 + 脚部 8 = 40 → 取整 48”,装不进 size 32 的块;去掉脚部后变”8 + 24 = 32 → 取整 32”,正好装下。
- 应用不知道也不关心分配器如何管理内存。它只知道
12.5 常见错误与调试技巧
- 把
MAP_PRIVATE当成MAP_SHARED用:改了映射内存但文件没变,或用MAP_SHARED改了别人的数据。调试:grep <文件名> /proc/<pid>/maps看权限第 4 字符是p还是s;strace -e trace=mmap,msync确认 flag。 mmap的 offset 没页对齐:offset必须是sysconf(_SC_PAGESIZE)的整数倍,否则返回MAP_FAILED并置errno = EINVAL。调试:必须写if (p == MAP_FAILED) perror("mmap");——忘记检查就得到”信号 11,想访问 0xffff…ffff”这种迷惑性错误。fork后重复输出:父子共享 stdio 缓冲区,exit()会把父进程未刷的内容再刷一遍。调试:子进程用_exit(),或fork前fflush(NULL);./a.out \| cat更容易看出重复。- 栈溢出 vs 跳过增长窗口:
ulimit -s默认 8 MB(本机 8192 KB)。%rsp一次下降多页再写(如char big[2*1024*1024]直接写末尾)可能越过内核栈增长窗口而段错误。调试:ulimit -s;gdb的bt与info registers rsp。 - 误以为虚拟地址相同就是同一变量:
fork后父子变量地址相同、值独立(见 12.3.2)。调试:gdb的set follow-fork-mode child/parent;打印/proc/self/stat的min_flt看 COW 次数。 - 在
[heap]里找不到大块:malloc(1MiB)走mmap,不在[heap]。调试:strace -e trace=brk,mmap,munmap;mallinfo2().hblkhd报告 mmap 段字节数(实测 free 后为 0)。 - 不看
si_addr就乱猜 SIGSEGV 原因:si_addr在 VMA 内外、权限如何,决定是保护异常还是段错误。调试:用SA_SIGINFO打印si_addr,结合/proc/<pid>/maps与gdb的x/i $pc。
12.6 关键要点
- 多级页表用”树”换空间:48 位 VA、4 KB 页、8 B PTE 的单级页表要 512 GB;四级页表只为真正用到的子树分配,映射一个 4 KB 页最少只需 4 个页表页(16 KB),映射一个 2 MB 区间只要 3 个(12 KB)。
- TLB 是用时间换空间的那一半:Core i7 的 L1 d-TLB(64 项/4 路)几乎吸收了全部翻译请求,命中则完全跳过 4 次无局部性的 page walk。
- 一次翻译的完整链路是
VA → TLB/四级 page walk → PA → L1 d-cache,其中 PPO $\equiv$ VPO 不变,$CI$ 位在翻译前后也不变(虚拟索引、物理标记),因此 L1 可并行做索引与翻译。 - PTE 的低 12 位是权限而非地址:
P、R/W、U/S、A、D、PS、G、XD——P=0时高 40 位转而记录页在磁盘的位置。COW、按需分页、写回全建在这几位上。 - VMA 是纯软件的”地址空间地图”:
vm_area_struct(起止、权限、文件)串成链表/红黑树,mm_struct.pgd写进 CR3 完成进程切换;缺页处理程序拿CR2查这张图,再决定”换页 / SIGSEGV”。 mmap是虚拟内存最通用的接口:文件映射、匿名内存、共享库是同一套机制;fork的写时复制与MAP_PRIVATE的私有写共用do_wp_page()。
12.7 思考题(带答案)
题 1(计算题):某 32 位机器使用两级页表,页大小 4 KB,PTE 4 字节,VA 划分成 10/10/12 位。 (a) 单级页表需要多少内存?(b) 若某进程只用了最低的 4 MB 和最高的 4 MB 虚拟地址空间,两级页表实际需要多少内存?(c) 若 TLBI 借用 VPN 的低 4 位(16 组),一次 TLB 未命中的最坏访存次数是多少?
答:(a) 页数 $=2^{32}/2^{12}=2^{20}$,单级页表 $=2^{20}\times 4\text{ B}=4$ MB。 (b) 最高 4 MB 需 1 个一级页表 + 1 个二级页表(各 4 KB);最低 4 MB 与之共享一级页表,但需自己的二级页表。总计 $3\times 4\text{ KB}=12$ KB,比 4 MB 缩小约 340 倍——这就是多级页表的价值。 (c) 最坏情况:TLB miss $+$ 2 次页表访存(一级、二级 PTE 各一次)$+$ 1 次取数据;若还要查 L2 TLB 则更多。对比 TLB 命中:只需 1 次数据访存。
题 2(计算题):用 12.2.3 的页表,翻译 VA = 0x00007F4A2B8C9A48,并指出它落在哪个 VMA、会触发什么操作。
答:按 12.2.3 走查,四级索引依次为 0x0FE、0x128、0x15C、0x0C9,VPO $=$ 0xA48,PPN $=$ 0xA3C,$\text{PA} = \texttt{0x0000A3CA48}$。若该 VA 落在 r--p 的 .rodata VMA 内,这是合法的读,缺页处理程序只需换入页面并置 P=1;若是写,VMA 权限只读、R/W 不匹配 → 保护异常 → SIGSEGV(mode 1)。若该 VA 不在任何 VMA 内(如 0x0000000100000000,mode 2),则直接段错误。索引 254/296/348/201 都很大,说明这是典型的用户栈或共享库区访问,对应的 PGD/PUD/PMD 条目本来就在。
题 3(辨析题):”既然 fork 之后父子进程的变量地址完全一样,那它们一定共享同一个物理内存单元;我在父进程里改了它,子进程应该也看得到。”这句话错在哪?
答:错在把”虚拟地址相同”等同于”物理页相同”。fork 复制的是页表;父子 PTE 在 fork 瞬间确实指向同一物理页,但双方 R/W 位被清成只读(COW)。任一方写时触发保护异常,内核复制该页、把写入方 PTE 指向副本并恢复可写,另一方仍指原点。因此地址相同、值独立。实测(12.3.2):子进程写后 global=200,父进程读到的仍是 100,且父进程 min_flt 从 92 涨到 93,证明它后来也复制了一次。唯一例外是显式共享内存(MAP_SHARED 的 mmap 或 shmget)。
题 4(辨析题):有人说”malloc 返回的指针是页对齐的,所以我能按 mmap 的 offset 规则反推它,甚至直接 munmap 它来省内存”。错在哪?
答:错两处。第一,malloc 返回的是分配器内部块的 payload 指针,位于 [heap] 中 16 字节对齐的偏移上(实测 0x1eb62b0、0x1941490),不是页对齐,不能反推页基址;分配器还在它前后放了块头/块尾,擅自 munmap 会连元数据一起抹掉。第二,只有大于等于 M_MMAP_THRESHOLD(默认 128 KB)的请求才由 glibc 用 mmap 单独映射(实测 malloc(256KiB) = 0x7f1f0eb5c010;而它前面的 malloc(64KiB) 都落在 0x1eb... 的 [heap] 内)。释放内存唯一正确的接口是 free;想主动还内存给内核可用 malloc_trim(0),而非自己 munmap。