欢迎光临
我们一直在努力

《深入理解计算机系统》读书笔记10: 虚拟存储器

作者: andylin02
学习章节: 第 9 章 虚拟存储器
关键词: 虚拟内存;页表;MMU;地址翻译;TLB;内存映射;mmap;动态内存分配;malloc;隐式空闲链表;显式空闲链表;分离适配;Malloc Lab


引言:虚拟内存——计算机系统的"终极抽象"

“虚拟内存是硬件异常、硬件地址翻译、主存、磁盘文件和内核软件的完美交互。它为每个进程提供了一个大的、一致的和私有的地址空间。”——CSAPP作者

在第 1-8 章中,我们逐步构建了对计算机系统的理解:从数据的二进制表示,到机器指令的执行,再到异常控制流和进程管理。然而,还有一个核心问题我们尚未完全解答:当几十个进程同时运行时,每个进程都以为自己独占整个内存——这个"魔法"是如何实现的?

答案就是虚拟内存。虚拟内存是本章的核心主题,也是计算机系统最重要的抽象之一。它提供了三个关键能力:

  • 作为缓存工具:将主存视为磁盘的缓存,高效管理物理内存
  • 简化内存管理:每个进程拥有一致的地址空间,简化了链接、加载和共享
  • 保护内存:防止进程间相互干扰,确保系统稳定性
  • 本章结构速览:

    • 9.1 物理和虚拟寻址:从物理寻址到虚拟寻址的演进
    • 9.2 地址空间:理解虚拟地址空间和物理地址空间
    • 9.3 虚拟内存作为缓存工具:页表、缺页异常、页面置换
    • 9.4 虚拟内存作为内存管理工具:简化链接、加载、共享
    • 9.5 虚拟内存作为内存保护工具:权限位与隔离
    • 9.6 地址翻译:MMU、页表、TLB 的完整工作流程
    • 9.7 案例研究:Core i7 地址翻译
    • 9.8 内存映射:mmap 与共享库
    • 9.9 动态内存分配:malloc 与 free 的实现
    • 9.10 垃圾收集:标记-清扫算法
    • 9.11 常见陷阱:内存错误与调试工具
    • 9.12 配套实验:Malloc Lab

    一、物理寻址 vs 虚拟寻址

    1.1 物理寻址

    早期计算机系统使用物理寻址(Physical Addressing) :CPU 直接生成物理地址(Physical Address,PA),发送给主存,主存从该地址取出数据返回 CPU。

    ┌─────────────────────────────────────────────────────────────────────┐
    │ 物理寻址模型 │
    ├─────────────────────────────────────────────────────────────────────┤
    │ │
    │ CPU ─────── 物理地址(PA) ──────→ 内存 │
    │ │ │ │
    │ │←─────── 数据字(Word) ────────┘ │
    │ │
    │ 💡 问题:每个程序都必须知道自己将放在内存的哪个位置, │
    │ 无法实现进程间的内存隔离和共享 │
    │ │
    └─────────────────────────────────────────────────────────────────────┘

    1.2 虚拟寻址

    现代系统使用虚拟寻址(Virtual Addressing) :CPU 生成虚拟地址(Virtual Address,VA) ,通过内存管理单元(MMU,Memory Management Unit) 将虚拟地址转换为物理地址后,再访问内存。

    ┌─────────────────────────────────────────────────────────────────────┐
    │ 虚拟寻址模型 │
    ├─────────────────────────────────────────────────────────────────────┤
    │ │
    │ CPU ─────── 虚拟地址(VA) ──────→ MMU ─────── 物理地址(PA) ──────→ 内存
    │ │ │ │
    │ │←──────── 数据字(Word) ───────┘ │
    │ │
    │ 💡 MMU 使用存放在主存中的**页表**将虚拟地址翻译为物理地址 │
    │ │
    └─────────────────────────────────────────────────────────────────────┘

    二、地址空间

    地址空间(Address Space) 是一个非负整数地址的有序集合。系统中有两种地址空间:

    类型定义大小
    虚拟地址空间 进程可见的一组虚拟地址 N = 2^n(n 为虚拟地址位数)
    物理地址空间 系统实际 DRAM 存储单元对应的一组物理地址 M = 2^m(m 为物理地址位数)

    💡 关键特性:虚拟地址空间通常比物理地址空间大得多。对于所有在该系统上运行的进程,虚拟地址空间的结构是相同的,这使得内存管理大大简化。

    2.1 为什么需要虚拟内存?

    虚拟内存解决了三个核心问题:

    ┌─────────────────────────────────────────────────────────────────────┐
    │ 虚拟内存的三大价值 │
    ├─────────────────────────────────────────────────────────────────────┤
    │ │
    │ 1. 作为缓存的工具 │
    │ └─ 将主存视为磁盘的缓存,只把活动的虚拟页面缓存在物理内存中 │
    │ │
    │ 2. 简化内存管理 │
    │ └─ 为每个进程提供一致的地址空间,隐藏物理内存和磁盘的细节 │
    │ │
    │ 3. 提供内存保护 │
    │ └─ 隔离不同进程的地址空间,防止进程间相互破坏 │
    │ │
    └─────────────────────────────────────────────────────────────────────┘

    三、虚拟内存作为缓存工具

    3.1 虚拟页(VP)与物理页(PP)

    从概念上,虚拟内存可以视为存储在磁盘上的字节序列。虚拟内存系统将地址空间划分为固定大小的虚拟页(VP,Virtual Page) ,物理内存划分为相同大小的物理页(PP,Physical Page) 。这些页面通常为 4KB~2MB。

    虚拟页的状态分为三种:

    状态描述
    未分配 虚拟页尚未被分配,不占用任何存储空间
    未缓存 虚拟页已被分配,但内容尚未加载到物理内存中(仍在磁盘上)
    已缓存 虚拟页已被分配且已加载到物理内存中

    3.2 页表(Page Table)

    页表是虚拟地址到物理地址翻译的核心数据结构。它存放在主存中,每个进程拥有一个独立的页表。页表本质上是一个映射数组,将虚拟页号(VPN)映射到物理页号(PPN),并包含权限和存在信息。

    ┌─────────────────────────────────────────────────────────────────────┐
    │ 页表结构示意图 │
    ├─────────────────────────────────────────────────────────────────────┤
    │ │
    │ VPN (虚拟页号) PPN (物理页号) │
    │ ┌─────────┐ ┌─────────┐ │
    │ │ VP 0 │──────────→│ PP 0 │ 有效位=1(已缓存) │
    │ ├─────────┤ ├─────────┤ │
    │ │ VP 1 │──────────→│ PP 4 │ 有效位=1 │
    │ ├─────────┤ ├─────────┤ │
    │ │ VP 2 │──────────→│ 磁盘 │ 有效位=0(未缓存,在磁盘上) │
    │ ├─────────┤ ├─────────┤ │
    │ │ VP 3 │──────────→│ PP 2 │ 有效位=1 │
    │ ├─────────┤ ├─────────┤ │
    │ │ VP 4 │──────────→│ 无 │ 有效位=0(未分配) │
    │ └─────────┘ └─────────┘ │
    │ │
    └─────────────────────────────────────────────────────────────────────┘

    💡 页表项(PTE,Page Table Entry) 包含物理页号(PPN)、有效位(valid bit)、访问权限位(读/写/执行权限)和其他管理位(脏位、引用位等)。

    3.3 缺页异常(Page Fault)

    当 CPU 引用一个有效位为 0 的虚拟页时,MMU 会触发缺页异常。缺页处理程序会:

  • 选择一个牺牲页面(如果物理内存已满)
  • 将牺牲页面写回磁盘(如果被修改过)
  • 从磁盘加载所需页面到物理内存
  • 更新页表,将有效位设为 1
  • 返回并重新执行引发缺页异常的指令
  • ┌─────────────────────────────────────────────────────────────────────┐
    │ 缺页异常处理流程 │
    ├─────────────────────────────────────────────────────────────────────┤
    │ │
    │ CPU 引用 VP 2(页表中有效位=0) │
    │ │ │
    │ ↓ │
    │ MMU 触发缺页异常,控制权转交给内核的缺页处理程序 │
    │ │ │
    │ ↓ │
    │ 缺页处理程序: │
    │ ① 选择一个物理页作为牺牲页(如果已满) │
    │ ② 将牺牲页写回磁盘(如果脏位=1) │
    │ ③ 从磁盘读取 VP 2 到该物理页 │
    │ ④ 更新页表(PPN + 有效位=1) │
    │ │ │
    │ ↓ │
    │ 返回并**重新执行**引发缺页的指令 │
    │ │ │
    │ ↓ │
    │ 这次页表命中,正常访问 │
    │ │
    └─────────────────────────────────────────────────────────────────────┘

    3.4 DRAM 缓存的特殊设计

    由于 DRAM 缓存(主存)和磁盘之间的速度差异极大(主存访问约 50-200ns,磁盘访问约 5-10ms,相差数万倍),DRAM 缓存的设计需要特殊考虑:

    设计特征原因
    虚拟页较大(4KB~2MB) 利用空间局部性,减少不命中次数
    全相联映射 任意虚拟页可以放置在任何物理页中,最大化灵活性
    更复杂的替换算法 页面置换需要权衡,避免"抖动"
    总是使用写回(write back) 写直达会导致每次写都访问磁盘,性能灾难

    四、虚拟内存作为内存管理工具

    虚拟内存为每个进程提供一致的地址空间,大大简化了链接和加载过程。

    ┌─────────────────────────────────────────────────────────────────────┐
    │ 多个进程共享物理内存的页表映射 │
    ├─────────────────────────────────────────────────────────────────────┤
    │ │
    │ 进程 1 的虚拟地址空间 物理内存 进程 2 的虚拟地址空间│
    │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
    │ │ VP 0 │───────────────→│ PP 0 │←────────│ VP 0 │ │
    │ ├─────────┤ ├─────────┤ ├─────────┤ │
    │ │ VP 1 │───────────────→│ PP 3 │←────────│ VP 1 │ │
    │ ├─────────┤ ├─────────┤ ├─────────┤ │
    │ │ VP 2 │──┐ │ PP 6 │←────────│ VP 2 │ │
    │ ├─────────┤ │ ├─────────┤ ├─────────┤ │
    │ │ VP 3 │ │ │ … │ │ VP 3 │ │
    │ └─────────┘ └────────────→│ PP 9 │←────────│ VP 4 │ │
    │ └─────────┘ └─────────┘ │
    │ │
    │ 💡 不同进程的相同虚拟地址可以映射到不同的物理地址, │
    │ 实现内存隔离;也可以映射到相同的物理地址,实现内存共享 │
    │ │
    └─────────────────────────────────────────────────────────────────────┘

    五、虚拟内存作为内存保护工具

    页表项中的权限位实现了内存保护机制:

    权限位作用
    SUP(Supervisor) 区分内核模式(1)和用户模式(0)访问权限
    READ 控制页面是否可读
    WRITE 控制页面是否可写
    EXEC 控制页面是否可执行(NX 位)

    当程序违反权限规则时(例如向只读页面写入),MMU 会触发保护异常,导致段故障(Segmentation Fault),由内核处理或终止程序。

    六、地址翻译——MMU 的核心工作

    6.1 页命中时的地址翻译

    ┌─────────────────────────────────────────────────────────────────────┐
    │ 页命中时的地址翻译流程 │
    ├─────────────────────────────────────────────────────────────────────┤
    │ │
    │ CPU 生成虚拟地址 VA │
    │ │ │
    │ ↓ │
    │ MMU 提取 VPN(虚拟页号)和 VPO(页内偏移) │
    │ │ │
    │ ↓ │
    │ MMU 查询页表(在主存中):检查 PTE 的有效位 │
    │ │ │
    │ ↓(有效位=1) │
    │ MMU 从 PTE 中取出 PPN(物理页号) │
    │ │ │
    │ ↓ │
    │ 构造物理地址 PA = (PPN << n) + VPO │
    │ │ │
    │ ↓ │
    │ 发送 PA 到主存,获取数据并返回 CPU │
    │ │
    └─────────────────────────────────────────────────────────────────────┘

    6.2 TLB——地址翻译的"加速器"

    TLB(Translation Lookaside Buffer,翻译后备缓冲器) 是 MMU 中的一个硬件缓存,用于缓存最近使用的页表项。由于多级页表查找需要多次内存访问,TLB 能够大幅加速地址翻译过程。

    ┌─────────────────────────────────────────────────────────────────────┐
    │ TLB 工作流程 │
    ├─────────────────────────────────────────────────────────────────────┤
    │ │
    │ CPU 生成虚拟地址 VA │
    │ │ │
    │ ↓ │
    │ TLB 查找(硬件,单周期) │
    │ │ │
    │ ├──→ TLB 命中 → 直接从 TLB 获取 PPN,构造 PA │
    │ │ │
    │ └──→ TLB 未命中 → MMU 访问主存中的页表(需要多次内存访问) │
    │ │ │
    │ ↓ │
    │ 更新 TLB 缓存(逐出旧条目) │
    │ │
    └─────────────────────────────────────────────────────────────────────┘

    💡 TLB 并行查询机制:利用虚拟地址页内偏移量不变的特性,可同步执行缓存访问与地址转换。现代处理器通过地址空间标识符(ASID) 区分不同进程的地址空间,减少任务切换时的 TLB 刷新次数。

    6.3 多级页表

    现代 64 位系统使用多级页表结构,以节省内存空间并支持大地址空间。

    ┌─────────────────────────────────────────────────────────────────────┐
    │ 四级页表结构(x86-64) │
    ├─────────────────────────────────────────────────────────────────────┤
    │ │
    │ VA (48位): [ 9 位 ][ 9 位 ][ 9 位 ][ 9 位 ][ 12 位 ] │
    │ │ │ │ │ │ │
    │ ↓ ↓ ↓ ↓ ↓ │
    │ Level 1 Level 2 Level 3 Level 4 页内偏移 │
    │ (PGD) (PUD) (PMD) (PTE) (12位) │
    │ │ │ │ │ │
    │ ↓ ↓ ↓ ↓ │
    │ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
    │ │表 │→ │表 │→ │表 │→ │表 │→ 物理页 │
    │ └───┘ └───┘ └───┘ └───┘ │
    │ │
    │ 💡 多级页表的优势: │
    │ • 节省内存——未使用的虚拟地址区域无需分配下级页表 │
    │ • 每个进程只需维护顶级页表(在 CR3 寄存器中) │
    │ │
    └─────────────────────────────────────────────────────────────────────┘

    七、案例研究:Intel Core i7 地址翻译

    Intel Core i7 处理器采用四级页表结构,支持 48 位虚拟地址和 52 位物理地址。

    ┌─────────────────────────────────────────────────────────────────────┐
    │ Intel Core i7 地址翻译完整流程 │
    ├─────────────────────────────────────────────────────────────────────┤
    │ │
    │ CPU 生成 48 位虚拟地址 VA(48 位) │
    │ │ │
    │ ↓ │
    │ ┌─────────────────────────────────────────────────────────────┐ │
    │ │ TLB 查找(32 条目 L1 dTLB + 64 条目 L2 TLB) │ │
    │ │ │ │ │
    │ │ ├── 命中 → 直接得到物理地址 │ │
    │ │ │ │ │
    │ │ └── 未命中 → 硬件页表遍历(Page Walker) │ │
    │ └─────────────────────────────────────────────────────────────┘ │
    │ │ │
    │ ↓ │
    │ 硬件页表遍历: │
    │ • 从 CR3 寄存器读取 L1 页表基址 │
    │ • 依次访问 L1→L2→L3→L4 页表 │
    │ • 每次访问可能触发缺页异常 │
    │ │ │
    │ ↓ │
    │ 得到物理地址,访问 L1/L2/L3 缓存,最后访问主存 │
    │ │
    └─────────────────────────────────────────────────────────────────────┘

    八、内存映射(Memory Mapping)

    8.1 mmap 系统调用

    内存映射将虚拟内存区域与磁盘上的对象关联起来。Linux 提供了 mmap 系统调用来创建内存映射:

    #include <sys/mman.h>

    void *mmap(void *addr, size_t len, int prot, int flags, int fd, off_t offset);

    参数说明:

    参数说明
    addr 建议的映射起始地址(通常设为 NULL)
    len 映射区域的长度
    prot 保护位(PROT_READ、PROT_WRITE、PROT_EXEC)
    flags 映射标志(MAP_SHARED、MAP_PRIVATE、MAP_ANONYMOUS)
    fd 文件描述符
    offset 文件偏移量

    8.2 mmap 使用示例

    // mmap 示例:将文件映射到内存
    #include <stdio.h>
    #include <stdlib.h>
    #include <sys/mman.h>
    #include <sys/stat.h>
    #include <fcntl.h>
    #include <unistd.h>

    int main() {
    int fd = open("test.txt", O_RDWR);
    if (fd == 1) {
    perror("open");
    exit(1);
    }

    struct stat sb;
    if (fstat(fd, &sb) == 1) {
    perror("fstat");
    exit(1);
    }

    // 将文件映射到内存
    char *data = mmap(NULL, sb.st_size, PROT_READ | PROT_WRITE,
    MAP_SHARED, fd, 0);
    if (data == MAP_FAILED) {
    perror("mmap");
    exit(1);
    }

    // 直接访问内存中的数据(如同操作数组)
    printf("File content: %s\\n", data);

    // 修改映射区域(会写回文件)
    data[0] = 'X';

    // 同步到磁盘
    msync(data, sb.st_size, MS_SYNC);

    // 解除映射
    munmap(data, sb.st_size);
    close(fd);
    return 0;
    }

    8.3 共享内存与匿名映射

    共享内存映射(MAP_SHARED) :写操作立即反映到文件,其他进程可见,适用于进程间通信。

    私有映射(MAP_PRIVATE) :写操作在内存中进行,不写回文件,通过写时复制(Copy-on-Write,COW) 实现。

    匿名映射(MAP_ANONYMOUS) :没有对应的文件,初始化为零,常用于分配大块内存。

    // 匿名映射示例(父子进程共享内存)
    #include <sys/mman.h>
    #include <unistd.h>
    #include <stdio.h>

    int main() {
    // 创建匿名映射(共享)
    int *shared = mmap(NULL, sizeof(int),
    PROT_READ | PROT_WRITE,
    MAP_SHARED | MAP_ANONYMOUS,
    1, 0);
    if (shared == MAP_FAILED) {
    perror("mmap");
    return 1;
    }

    *shared = 42;

    pid_t pid = fork();
    if (pid == 0) {
    // 子进程:修改共享内存
    printf("Child: shared = %d\\n", *shared);
    *shared = 100;
    printf("Child changed shared to %d\\n", *shared);
    } else {
    // 父进程:等待子进程,读取共享内存
    wait(NULL);
    printf("Parent: shared = %d\\n", *shared);
    }

    munmap(shared, sizeof(int));
    return 0;
    }

    九、动态内存分配(malloc)

    动态内存分配器管理进程的堆(heap),这是 malloc 实验的核心内容。

    9.1 基本概念

    动态内存分配器维护着进程的虚拟内存区域——堆。堆是一段连续的虚拟内存空间,起始位置由 brk 指针指向。

    ┌─────────────────────────────────────────────────────────────────────┐
    │ 堆结构 │
    ├─────────────────────────────────────────────────────────────────────┤
    │ │
    │ 低地址 高地址 │
    │ ┌─────────────────────────────────────────────────────────────┐ │
    │ │ 已初始化数据 │ 未初始化数据 │ 堆 ↓ │ 空闲 │ ↑ 栈 │ │
    │ │ (.data) │ (.bss) │ │ │ │ │
    │ └─────────────────────────────────────────────────────────────┘ │
    │ ▲ ▲ │
    │ │ │ │
    │ └── program break (brk) └── 堆顶 │
    │ │
    └─────────────────────────────────────────────────────────────────────┘

    分配器将堆视为一组不同大小的块(block)的集合。每个块要么是已分配的(allocated),供应用程序使用;要么是空闲的(free),可用于未来的分配。

    9.2 块结构设计

    ┌─────────────────────────────────────────────────────────────────────┐
    │ 内存块结构 │
    ├─────────────────────────────────────────────────────────────────────┤
    │ │
    │ ┌─────────┬─────────────────────┬─────────┐ │
    │ │ 头部 │ 载荷 │ 尾部 │ │
    │ │ (4字节) │ (payload) │ (4字节) │ │
    │ └─────────┴─────────────────────┴─────────┘ │
    │ ↑ ↑ │
    │ └── 块大小 + 分配位 └── 块大小 + 分配位 │
    │ │
    │ 💡 头部和尾部内容完全一致。引入尾部是为了实现常数时间复杂度的 │
    │ 反向访问——合并空闲块时,需要知道前一个块的状态 │
    │ │
    └─────────────────────────────────────────────────────────────────────┘

    头部/尾部的位设计:

    #define WSIZE 4 // 字大小(字节)
    #define DSIZE 8 // 双字大小(字节)

    // 打包:将大小和分配位合并到一个字中
    #define PACK(size, alloc) ((size) | (alloc))

    // 读取头部或尾部
    #define GET(p) (*(unsigned int *)(p))
    #define PUT(p, val) (*(unsigned int *)(p) = (val))

    // 提取大小和分配位
    #define GET_SIZE(p) (GET(p) & ~0x7) // 低3位清零
    #define GET_ALLOC(p) (GET(p) & 0x1) // 最低位

    💡 为什么低位可以用于分配位:由于块需要 8 字节对齐,块大小的低 3 位必定为 0,可以安全地用于存储其他信息(如分配位)。

    9.3 空闲块组织结构

    ① 隐式空闲链表(Implicit Free List)

    隐式空闲链表将空闲块通过块头部中的大小字段隐含地连接在一起。每次分配时,需要遍历所有块(包括已分配块)来找到合适的空闲块。

    ┌─────────────────────────────────────────────────────────────────────┐
    │ 隐式空闲链表 │
    ├─────────────────────────────────────────────────────────────────────┤
    │ │
    │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
    │ │ 头│ 载荷 │ │ 头│ 载荷 │ │ 头│ 载荷 │ │ 头│ 载荷 │ │
    │ │ 尾│ │ │ 尾│ │ │ 尾│ │ │ 尾│ │ │
    │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
    │ ▲ ▲ ▲ ▲ │
    │ │ │ │ │ │
    │ └──────────────┴──────────────┴──────────────┘ │
    │ 通过顺序扫描遍历所有块 │
    │ │
    │ 💡 特点: │
    │ • 实现简单 │
    │ • 分配时间与堆块总数成线性关系 │
    │ • 适合作为 baseline │
    │ │
    └─────────────────────────────────────────────────────────────────────┘

    ② 显式空闲链表(Explicit Free List)

    显式空闲链表在空闲块中增加 prev 和 next 指针,将所有空闲块单独串联起来。这样分配时只需遍历空闲块,无需遍历已分配块。

    ┌─────────────────────────────────────────────────────────────────────┐
    │ 显式空闲链表 │
    ├─────────────────────────────────────────────────────────────────────┤
    │ │
    │ ┌─────────────────────────────────────────────────────────────┐ │
    │ │ 空闲块 │ │
    │ │ ┌──────┬──────┬──────┬──────────────────┬──────┐ │ │
    │ │ │ 头部 │ prev │ next │ 载荷 │ 尾部 │ │ │
    │ │ └──────┴──────┴──────┴──────────────────┴──────┘ │ │
    │ │ ↑ ↑ ↑ │ │
    │ │ │ └── 指向前一个空闲块 └── 指向后一个空闲块 │ │
    │ │ └── 大小 + 分配位 │ │
    │ └─────────────────────────────────────────────────────────────┘ │
    │ │
    │ 空闲块通过指针连接: │
    │ ┌─────┐ ┌─────┐ ┌─────┐ │
    │ │空闲1│←──→│空闲2│←──→│空闲3│ │
    │ └─────┘ └─────┘ └─────┘ │
    │ │
    │ 💡 特点:分配时间与空闲块数量成正比,效率更高 │
    │ │
    └─────────────────────────────────────────────────────────────────────┘

    ③ 分离适配(Segregated Fit)——C 标准库的实现方案

    分离适配是现代分配器(如 glibc malloc)使用的主流方案。它维护多个空闲链表,每个链表与一个大小类相关联。

    ┌─────────────────────────────────────────────────────────────────────┐
    │ 分离适配结构 │
    ├─────────────────────────────────────────────────────────────────────┤
    │ │
    │ 大小类 → 空闲链表 │
    │ │
    │ ┌─────────────┐ │
    │ │ size < 16 │──→ 空闲块1 ──→ 空闲块2 │
    │ ├─────────────┤ │
    │ │ 16 ≤ size < 32 │──→ 空闲块1 ──→ 空闲块2 ──→ 空闲块3 │
    │ ├─────────────┤ │
    │ │ 32 ≤ size < 64 │──→ 空闲块1 │
    │ ├─────────────┤ │
    │ │ … │ │
    │ └─────────────┘ │
    │ │
    │ 分配策略: │
    │ • 先根据请求大小找到对应的大小类 │
    │ • 在该链表中查找合适的空闲块 │
    │ • 如果找不到,从更大类中分配 │
    │ │
    │ 💡 结合了显式链表(快速查找)和最佳适配(减少碎片)的优势 │
    │ │
    └─────────────────────────────────────────────────────────────────────┘

    9.4 放置策略

    策略描述优缺点
    首次适配(First Fit) 从头开始搜索,选择第一个足够大的空闲块 速度快,但容易在链表头部产生小碎片
    下一次适配(Next Fit) 从上一次搜索结束的位置开始搜索 速度稍快,但空间利用率较差
    最佳适配(Best Fit) 搜索所有空闲块,选择最小的足够大的块 空间利用率最好,但时间开销大

    9.5 合并策略

    当释放一个块时,需要检查相邻块是否为空闲块,并进行合并(coalescing)。

    合并策略:

    • 立即合并:释放后立即与相邻空闲块合并(实现简单)
    • 延迟合并:在找不到合适块时再进行合并(吞吐率可能更高)

    9.6 内部碎片 vs 外部碎片

    碎片类型描述
    内部碎片 分配块大于实际需求时产生的浪费(如对齐要求、头部开销)
    外部碎片 分配器无法利用的小空闲块,分散在内存各处

    9.7 malloc 常见陷阱

    // 陷阱1:忘记检查 malloc 返回值
    int *p = malloc(n * sizeof(int));
    *p = 0; // 如果 p == NULL 则崩溃

    // 陷阱2:内存泄漏
    p = malloc(100);
    p = malloc(200); // 第一个 100 字节的指针丢失,无法释放

    // 陷阱3:读未初始化的内存
    int *p = malloc(100);
    int x = *p; // p 指向的内存未被初始化

    // 陷阱4:缓冲区溢出
    int *p = malloc(4 * sizeof(int));
    p[4] = 0; // 写入超出分配边界

    // 陷阱5:使用已释放的内存
    free(p);
    int x = *p; // 悬垂指针,未定义行为

    // 陷阱6:重复释放
    free(p);
    free(p); // double free,可能导致程序崩溃

    十、配套实验:Malloc Lab

    Malloc Lab 是 CSAPP 中难度最高的实验之一。它要求实现一个动态内存分配器,包含以下四个核心函数:

    函数功能
    mm_init 初始化分配器,创建初始堆
    mm_malloc 分配指定大小的内存块,返回 8 字节对齐的指针
    mm_free 释放内存块,合并相邻空闲块
    mm_realloc 调整已分配块的大小

    10.1 核心代码框架

    // mm.h 中的定义
    #include <stdio.h>
    #include <stdlib.h>
    #include <assert.h>
    #include <unistd.h>
    #include <string.h>
    #include "mm.h"
    #include "memlib.h"

    /* 基本常量 */
    #define WSIZE 4 // 字大小(字节)
    #define DSIZE 8 // 双字大小(字节)
    #define CHUNKSIZE (1<<12) // 堆扩展大小(4096 字节)

    /* 宏定义 */
    #define MAX(x, y) ((x) > (y) ? (x) : (y))
    #define PACK(size, alloc) ((size) | (alloc))
    #define GET(p) (*(unsigned int *)(p))
    #define PUT(p, val) (*(unsigned int *)(p) = (val))
    #define GET_SIZE(p) (GET(p) & ~0x7)
    #define GET_ALLOC(p) (GET(p) & 0x1)
    #define HDRP(bp) ((char *)(bp) WSIZE)
    #define FTRP(bp) ((char *)(bp) + GET_SIZE(HDRP(bp)) DSIZE)
    #define NEXT_BLKP(bp) ((char *)(bp) + GET_SIZE(((char *)(bp) WSIZE)))
    #define PREV_BLKP(bp) ((char *)(bp) GET_SIZE(((char *)(bp) DSIZE)))

    static char *heap_listp; // 指向堆的起始位置

    /* 辅助函数声明 */
    static void *extend_heap(size_t words);
    static void *coalesce(void *bp);
    static void *first_fit(size_t asize);
    static void place(void *bp, size_t asize);

    10.2 初始化实现

    int mm_init(void) {
    // 创建初始堆空间(对齐到双字边界)
    if ((heap_listp = mem_sbrk(4 * WSIZE)) == (void *)1)
    return 1;

    PUT(heap_listp, 0); // 对齐填充
    PUT(heap_listp + (1 * WSIZE), PACK(DSIZE, 1)); // 序言块头部
    PUT(heap_listp + (2 * WSIZE), PACK(DSIZE, 1)); // 序言块尾部
    PUT(heap_listp + (3 * WSIZE), PACK(0, 1)); // 结尾块

    heap_listp += (2 * WSIZE); // 指向序言块载荷起始位置

    // 扩展堆空间
    if (extend_heap(CHUNKSIZE / WSIZE) == NULL)
    return 1;
    return 0;
    }

    static void *extend_heap(size_t words) {
    char *bp;
    size_t size = words * WSIZE; // 转换为字节数

    // 扩展堆
    if ((long)(bp = mem_sbrk(size)) == 1)
    return NULL;

    // 设置新空闲块的头部和尾部
    PUT(HDRP(bp), PACK(size, 0));
    PUT(FTRP(bp), PACK(size, 0));
    PUT(HDRP(NEXT_BLKP(bp)), PACK(0, 1)); // 新的结尾块

    // 合并相邻空闲块
    return coalesce(bp);
    }

    10.3 malloc 实现

    void *mm_malloc(size_t size) {
    size_t asize; // 调整后的块大小
    size_t extendsize; // 需要扩展的大小
    char *bp;

    if (size == 0)
    return NULL;

    // 调整块大小(包含头部+尾部,对齐到双字边界)
    if (size <= DSIZE)
    asize = 2 * DSIZE;
    else
    asize = DSIZE * ((size + DSIZE + (DSIZE 1)) / DSIZE);

    // 查找合适的空闲块
    if ((bp = first_fit(asize)) != NULL) {
    place(bp, asize);
    return bp;
    }

    // 未找到合适块,扩展堆
    extendsize = MAX(asize, CHUNKSIZE);
    if ((bp = extend_heap(extendsize / WSIZE)) == NULL)
    return NULL;
    place(bp, asize);
    return bp;
    }

    10.4 free 与合并实现

    void mm_free(void *bp) {
    size_t size = GET_SIZE(HDRP(bp));

    // 标记为空闲
    PUT(HDRP(bp), PACK(size, 0));
    PUT(FTRP(bp), PACK(size, 0));

    // 合并相邻空闲块
    coalesce(bp);
    }

    static void *coalesce(void *bp) {
    size_t prev_alloc = GET_ALLOC(FTRP(PREV_BLKP(bp)));
    size_t next_alloc = GET_ALLOC(HDRP(NEXT_BLKP(bp)));
    size_t size = GET_SIZE(HDRP(bp));

    // 四种情况
    if (prev_alloc && next_alloc) {
    // 前后都已分配,无需合并
    return bp;
    } else if (prev_alloc && !next_alloc) {
    // 仅与后一个空闲块合并
    size += GET_SIZE(HDRP(NEXT_BLKP(bp)));
    PUT(HDRP(bp), PACK(size, 0));
    PUT(FTRP(bp), PACK(size, 0));
    } else if (!prev_alloc && next_alloc) {
    // 仅与前一个空闲块合并
    size += GET_SIZE(HDRP(PREV_BLKP(bp)));
    PUT(FTRP(bp), PACK(size, 0));
    PUT(HDRP(PREV_BLKP(bp)), PACK(size, 0));
    bp = PREV_BLKP(bp);
    } else {
    // 前后都是空闲块,合并三者
    size += GET_SIZE(HDRP(PREV_BLKP(bp))) + GET_SIZE(FTRP(NEXT_BLKP(bp)));
    PUT(HDRP(PREV_BLKP(bp)), PACK(size, 0));
    PUT(FTRP(NEXT_BLKP(bp)), PACK(size, 0));
    bp = PREV_BLKP(bp);
    }
    return bp;
    }

    10.5 优化建议

    Malloc Lab 的性能评估基于两个指标:

    指标定义优化方向
    吞吐率 每单位时间完成的最大请求数 减少遍历时间,使用更高效的数据结构
    空间利用率 有效数据与堆总大小的比值 减少碎片,选择合适的内存块

    优化技巧:

  • 使用显式空闲链表:减少遍历空闲块的时间
  • 分离适配(Segregated Fit):将块按大小分类管理,提高查找速度
  • 选择更好的放置策略:首次适配、最佳适配的组合使用
  • 优化块大小:CHUNKSIZE 的选择影响内存利用率,512 或 4096 往往是较优选择
  • 实现 realloc:优化情况:若相邻块空闲且合并后足够大,可直接扩展而非重新分配复制
  • 评分公式:

    [
    P = wU + (1 – w) \\times \\min\\left(1, \\frac{T}{T_{\\text{libc}}}\\right)
    ]

    其中 U 为空间利用率,T 为吞吐率,w 默认取 0.6。评分机制平衡了内存利用率和执行速度。

    十一、本章知识点思维导图

    第 9 章 虚拟存储器

    ├── 1. 虚拟内存基础
    │ ├── 物理寻址 vs 虚拟寻址
    │ ├── 地址空间(虚拟地址空间、物理地址空间)
    │ └── 虚拟内存三大功能(缓存、简化管理、保护)

    ├── 2. 虚拟内存作为缓存
    │ ├── 虚拟页(VP)与物理页(PP)
    │ ├── 页表(Page Table)
    │ ├── 缺页异常(Page Fault)
    │ └── DRAM 缓存特性(大页、全相联、写回)

    ├── 3. 地址翻译
    │ ├── MMU(内存管理单元)
    │ ├── TLB(翻译后备缓冲器)
    │ ├── 多级页表(四级页表结构)
    │ └── Core i7 案例

    ├── 4. 内存映射
    │ ├── mmap 系统调用
    │ ├── 共享映射(MAP_SHARED)
    │ ├── 私有映射(MAP_PRIVATE,写时复制)
    │ └── 匿名映射(MAP_ANONYMOUS)

    ├── 5. 动态内存分配
    │ ├── 堆与 brk 指针
    │ ├── 块结构(头部、尾部、载荷)
    │ ├── 空闲块组织结构
    │ │ ├── 隐式空闲链表
    │ │ ├── 显式空闲链表
    │ │ └── 分离适配
    │ ├── 放置策略(首次/下一次/最佳适配)
    │ ├── 合并策略(立即/延迟合并)
    │ └── 碎片(内部/外部)

    └── 6. Malloc Lab
    ├── 实验要求(mm_init, malloc, free, realloc)
    ├── 辅助函数(extend_heap, coalesce, first_fit, place)
    └── 优化技巧(显式链表、分离适配、realloc 优化)

    十二、本章小结

    第 9 章深入计算机系统最核心的内存管理机制,从虚拟地址到物理地址的翻译,从 malloc 到垃圾收集,构建了完整的内存管理知识体系:

  • ✅ 虚拟内存的三个角色:作为磁盘缓存(管理主存)、简化内存管理(一致地址空间)、保护内存(进程隔离)
  • ✅ 地址翻译机制:理解了 MMU、页表、TLB 如何协作完成虚拟地址到物理地址的转换
  • ✅ 内存映射:掌握了 mmap 的使用,理解了共享映射和私有映射(写时复制)的区别
  • ✅ 动态内存分配:从隐式链表到显式链表再到分离适配,理解了三代分配器设计的演进
  • ✅ Malloc Lab 实验:通过实现内存分配器,将虚拟内存管理理论转化为实践
  • 💡 本章最核心的三个洞察:

  • 虚拟内存是计算机系统最成功的抽象之一——它完美地整合了硬件(MMU、TLB)、操作系统(异常处理、页面置换)和应用程序的需求
  • 分配器的本质是平衡空间与时间——更好的空间利用率往往意味着更复杂的数据结构(隐式→显式→分离适配),牺牲部分速度
  • TLB 是地址翻译的性能关键——多级页表虽然节省内存,但增加了翻译时间,TLB 的存在让这一切变得可行
  • 🔜 下一篇预告

    下一章我们将进入 第 10 章:系统级 I/O。

    这一章将揭开操作系统输入/输出(I/O)系统的底层机制:

    • 📌 Unix I/O 基础:文件描述符、open/close/read/write 系统调用
    • 📌 文件类型与权限:普通文件、目录、符号链接、套接字等
    • 📌 重定向:dup2 与文件描述符的复制
    • 📌 标准 I/O 库:stdio 与 Unix I/O 的关系与区别
    • 📌 RIO 包:Robust I/O 的实现
    • 📌 文件元数据:stat、access、lseek 等
    • 📌 I/O 重定向:shell 中的管道和重定向机制

    第 10 章将帮助我们理解 I/O 系统的本质,区分标准 I/O 与系统调用的差异,并掌握编写健壮 I/O 代码的技巧。

    敬请期待!


    本文为个人学习笔记,仅用于知识分享。如有错误,欢迎指正。
    👍🏻 点赞 + 收藏 + 分享,让更多开发者看到这篇深度解析!❤️ 如果觉得有用,请给个赞支持一下作者!

    赞(0)
    未经允许不得转载:171主机测评 » 《深入理解计算机系统》读书笔记10: 虚拟存储器
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址