Lecture 12: 虚拟内存:细节 (Virtual Memory: Details)

目录 · ← l11 · l13 →

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 虚拟内存系统) 关联 LabL5 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 的直接理论前提:只有知道 sbrkmmap 各自把堆伸到哪里,才能理解分配器为什么必须”不关心”应用如何使用内存。

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 物理基址),虚拟地址

\[\text{VA} = \texttt{0x00007F4A2B8C9A48}\]

第一步:逐位拆分(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$)

级别页表基址索引条目地址条目内容取出的下一级物理基址
L1PGD0xA2F0000x0FE=2540xA2F000+0x7F0 = 0xA2F7F00x0000000000A3B0670xA3B000
L2PUD0xA3B0000x128=2960xA3B000+0x940 = 0xA3B9400x000000000B7C0670xB7C000
L3PMD0xB7C0000x15C=3480xB7C000+0xAE0 = 0xB7CAE00x000000000C4E0670xC4E000
L4PT0xC4E0000x0C9=2010xC4E000+0x648 = 0xC4E6480x000000000A3C067PPN = 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_endvm_flagsVM_READ / VM_WRITE / VM_EXEC / VM_SHARED)、vm_protvm_filevm_pgoffmm_struct 持有 pgd(一级页表物理地址,即 CR3 的内容)、mmap(VMA 链表头)、start_brk/brkstart_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 命中);dmesgsegfault 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);

protPROT_READ/PROT_WRITE/PROT_EXEC/PROT_NONEflags 必须含 MAP_SHAREDMAP_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;
}

【代码做什么?】

  1. 造一个 4096 字节的文件,首 25 字节是 AAA-original-page-content,其余填 '.'
  2. 同一 fd、同一偏移 0 建立两个映射:MAP_SHAREDMAP_PRIVATE,内核给它们分配不同的虚拟地址区间。
  3. sh[0]='S'(共享)与 pr[0]='P'(私有)。
  4. pread 绕过映射直接读文件,判断谁真正改了文件。
  5. 打印 /proc/self/maps 中该文件两行,展示 rw-srw-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;
}

【代码做什么?】

  1. .dataglobal_varst)、堆(*heap)、栈(local)各放一个变量,打印地址与值。
  2. /proc/self/stat 读出 min_flt(次要缺页)与 maj_flt(主要缺页)计数。
  3. fork 出子进程:先打印地址与值(应与父进程完全相同),再写这三个变量,然后重读缺页计数。
  4. 父进程 waitpid 后重新读值——地址和子进程一模一样,值却仍是自己的

【底层机制透视】

  • 子进程打印的 &global_var&localheap 与父进程逐位相同。这毫不神秘:fork 复制了页表,所以虚拟地址不变;变化的是 PTE 指向的物理页由”先共享、写时复制”决定。
  • min_flt 的跳变就是 COW 的指纹:子进程 fork 后未写时只有 14;写 3 个变量后变成 17,恰好多 3 次——每次写一页触发一次保护异常 → 复制一页 → 更新 PTE。父进程随后写 global_varmin_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_forkcopy_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=388parent 写后 min_flt=403),但“写 3 个变量 +3 次缺页”和”父子地址完全相同、值各自独立”这两个结论每次都成立

12.3.3 示例 C:用 malloc 观察 brkmmap 的分界

代码 (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 匿名映射freemunmap 归还。理由很实际: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

RssSize 之差就是”已映射但尚未触碰”的部分;Private_DirtyShared_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---pPROT_NONE 保护页(让越界一字节立即段错误);[heap][stack] 之间的”空洞”宽约 47 TB;malloc(1MiB) 的匿名区文件名列为空,正是 12.3.3 所说的 mmap 区。

pmapvmstat 可直接观察 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

vmstatsi/so 是换入/换出速率(本机 swpd 约 4 GB);pmap -xKbytes虚拟大小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 封装)拿货,返回新扩展区域起始地址,必须处理失败:返回 NULLmm_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 铺垫)
    1. 应用不知道也不关心分配器如何管理内存。它只知道 malloc/free/realloc 的语义契约与对齐要求;内部用隐式、显式还是分离链表,完全是自由的实现细节。
    2. 分配器不能假定数据的位模式。返回的内存可能是任意字节(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 还是 sstrace -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(),或 forkfflush(NULL)./a.out \| cat 更容易看出重复。
  • 栈溢出 vs 跳过增长窗口ulimit -s 默认 8 MB(本机 8192 KB)。%rsp 一次下降多页再写(如 char big[2*1024*1024] 直接写末尾)可能越过内核栈增长窗口而段错误。调试ulimit -sgdbbtinfo registers rsp
  • 误以为虚拟地址相同就是同一变量fork 后父子变量地址相同、值独立(见 12.3.2)。调试gdbset follow-fork-mode child/parent;打印 /proc/self/statmin_flt 看 COW 次数。
  • [heap] 里找不到大块malloc(1MiB)mmap,不在 [heap]调试strace -e trace=brk,mmap,munmapmallinfo2().hblkhd 报告 mmap 段字节数(实测 free 后为 0)。
  • 不看 si_addr 就乱猜 SIGSEGV 原因si_addr 在 VMA 内外、权限如何,决定是保护异常还是段错误。调试:用 SA_SIGINFO 打印 si_addr,结合 /proc/<pid>/mapsgdbx/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 位是权限而非地址PR/WU/SADPSGXD——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 走查,四级索引依次为 0x0FE0x1280x15C0x0C9,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_SHAREDmmapshmget)。

题 4(辨析题):有人说”malloc 返回的指针是页对齐的,所以我能按 mmapoffset 规则反推它,甚至直接 munmap 它来省内存”。错在哪?

:错两处。第一,malloc 返回的是分配器内部块的 payload 指针,位于 [heap] 中 16 字节对齐的偏移上(实测 0x1eb62b00x1941490),不是页对齐,不能反推页基址;分配器还在它前后放了块头/块尾,擅自 munmap 会连元数据一起抹掉。第二,只有大于等于 M_MMAP_THRESHOLD(默认 128 KB)的请求才由 glibc 用 mmap 单独映射(实测 malloc(256KiB) = 0x7f1f0eb5c010;而它前面的 malloc(64KiB) 都落在 0x1eb...[heap] 内)。释放内存唯一正确的接口是 free;想主动还内存给内核可用 malloc_trim(0),而非自己 munmap