Lecture 11: 虚拟内存:概念 (Virtual Memory: Concepts)

目录 · ← l10 · l12 →

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、多级页表) 关联 LabL5 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(仅内核可访问)、READWRITEEXEC。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)等到缺失才把页复制到 DRAMexecve 分配 .text/.data 的虚拟页并创建标记为无效的 PTE,各段由 VM 系统按页、按需从可执行文件复制进来——所以刚启动的程序”占了 0 字节物理内存”。
  • 局部性再次救场:VM 看起来极其低效(每次缺页都要访问磁盘),但它能工作完全靠局部性。任意时刻程序倾向于访问一组活跃的虚拟页,称为工作集(working set);时间局部性越好的程序,工作集越小。
    • 若(工作集大小 < 主存大小):除强制缺失(compulsory miss)外表现良好,一个进程可稳定运行。
    • 若($\sum$ 各进程工作集 > 主存大小):抖动(thrashing)——页面被连续换入换出,性能雪崩。
  • 共享库、forkmmap(VM 作为管理工具的具体落地):
    • 链接(linking)简化:每个程序拥有相似的虚拟地址空间,代码、数据、堆总是从相同地址开始(如 0x400000),链接器不必关心最终物理位置。
    • 共享库:多个进程把各自的虚拟页映射到同一份物理页,物理内存里只有一份 libc 代码。
    • fork 与写时复制(Copy-On-Write, COW):父进程的页表被复制,父子共享全部物理页并标记为只读;任一方写入时触发保护异常,内核才真正复制那一页并恢复可写。fork 因此几乎是”免费”的。
    • execvemmap:新程序的可执行文件被 mmap 进地址空间,PTE 初始无效,之后按需换入。

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
...

【代码做什么?】

  1. 定义位于 .data/.bss/.rodata 的各类对象,并在 main 里在栈上取局部变量、在堆上取两块内存(64 B 与 1 MiB)。
  2. 打印各对象地址,立即能看出四段地址区间:低地址 0x400000 附近是可执行文件映射的段,0x1351000brk 堆,0x7eff...mmap 区,0x7fff... 是栈。
  3. 用掩码 & ~0xfffUL 把地址按 4 KB 对齐,手工算出 VPN 与 VPO,验证同一页内地址 VPN 相同、VPO 从 0 到 4095
  4. 打开 /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)的请求直接用 mmapfree 时可直接 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] 区间

  1. 堆不是”内存”,而是一段虚拟地址区间malloc 内部通过 sbrk/brkmmap 向内核申请虚拟页;这些页在被首次触碰前并不驻留物理内存。所以”申请 1 MB”其实只是把 brk 指针往上推 1 MB,代价几乎为零。
  2. 对齐要求来自页与字长:块的对齐通常是 8 或 16 字节;”页对齐”(PAGE_SIZE = 4096)只在你需要把映射边界做到页粒度时才重要。用 getconf PAGE_SIZE 确认本机是 4096。
  3. mmapsbrk 两条路:大块分配走 mmap 可以整块 munmap 归还,物理页立即回收;小分配走 brk 堆则只能通过”空闲链表合并”复用。这正是 L5b 讨论”显式/隐式空闲链表 vs 分离存储”的物理依据。
  4. 典型坑:在 mmap 得到的区间外访问会得到 SIGSEGV 而不是”分配到垃圾数据”;堆里的野指针若落在未映射的页上同样崩溃,但若落在已映射的页上则静默破坏数据——后者才是 malloc lab 里最难查的 bug。
  5. 调试利器valgrind ./mdrivergdbinfo proc mappingsx/16gx $rsp,以及 /proc/<pid>/maps 对照堆区间边界。

本讲也为 Lecture 12(fork 的 COW、execve 的内存映射、TLB 与缓存联合工作)和 Lecture 16(进程)提供理论前提。

11.5 常见错误与调试技巧

  • 把虚拟地址当物理地址:以为 printf("%p", p) 打印的是内存条上的位置。调试cat /proc/self/mapsgdb -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 -gvalgrind --leak-check=full --track-origins=yes
  • 误判”内存泄漏”为”缺页太多”top 里的 RSS 高可能只是工作集大,未必是泄漏。调试/usr/bin/time -v(若可用)或 getrusageru_maxrssvalgrind --leak-check=full 定位未释放块。
  • 认为 TLB 未命中会崩溃:TLB 未命中只是多一次内存访问去查页表,与缺页(需要磁盘 I/O)是完全不同的量级。调试perf stat -e dTLB-loads,dTLB-load-misses,page-faults ./prog
  • 混淆 VPOVPN 的对齐掩码:写代码时把 & 0xfff(页内偏移)误用作 >> 12(页号)。调试:用 11.3.1 的打印逐位验证;注意 $P = 2^p$ 时掩码是 (1<<p)-1,页号是 >> p
  • munmap 后继续使用指针:地址空间被撤销,访问触发 SIGSEGV调试gdbx/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。请把虚拟地址 0x3A5C0x1A5C0x30A4 翻译成物理地址,并指出哪些会缺页。

:先算位域。$n - p = 14 - 12 = 2$,故 VPN 占高 2 位、VPO 占低 12 位(= 3 个十六进制位)

VA二进制VPNVPOPTE结果
0x3A5C11 1010 0101 11003 (0x3)0xA5CPPN = 7PA = 0x7A5C
0x1A5C01 1010 0101 11001 (0x1)0xA5C无效缺页(需换入并更新 PTE 后重执行)
0x30A411 0000 1010 01003 (0x3)0x0A4PPN = 7PA = 0x70A4

验证:0x3A5C >> 12 = 0x30x3A5C & 0xFFF = 0xA5C;PA = (7 << 12) \| 0xA5C = 0x7000 + 0xA5C = 0x7A5C。注意 0x3A5C0x30A4 的 VPN 相同(同页)、VPO 不同,因此 PPN 相同——这就是”$\mathrm{VPO} = \mathrm{PPO}$”的直接体现。0x1A5C0x3A5C 的 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)。