31. 虚拟内存与分页

完整拆解虚拟内存机制:为什么需要虚拟地址、页式管理的地址转换过程、多级页表与 TLB 如何把开销压到可接受范围、页表项的权限位与写时复制、缺页异常的完整处理路径、页面置换算法(FIFO/LRU/Clock)与抖动,以及 mmap、共享内存与内存映射文件等工程应用。

1. 为什么需要虚拟内存

1.1 三个原始动机

早期程序直接操作物理地址,带来三类问题:

  1. 地址冲突:两个程序都想用物理地址 0x1000,无法共存。
  2. 缺乏保护:一个程序写越界会破坏另一个程序甚至内核。
  3. 内存不足:程序总大小超过物理内存就无法运行。

虚拟内存用一个中间层解决全部问题:每个进程看到独立的、连续的虚拟地址空间,由 MMU 在运行时翻译成实际物理地址。

1.2 虚拟内存带来的能力

  • 隔离:进程 A 无法寻址进程 B 的物理页,除非显式共享。
  • 超额分配:虚拟地址空间可以远大于物理内存,靠磁盘换页支撑。
  • 简化链接与加载:每个程序都从固定虚拟地址(如 0x400000)开始,链接器不用关心实际落点。
  • 共享:多个进程把同一物理页映射到各自地址空间,实现共享库与共享内存。

2. 地址空间与地址转换

2.1 虚拟地址的组成

在分页机制下,虚拟地址被切成两部分:

虚拟地址 VA = [ 虚拟页号 VPN | 页内偏移 Offset ]
                  ↑ 查页表          ↑ 直接透传

物理地址 PA = [ 物理页框号 PFN | 页内偏移 Offset ]

关键性质:页内偏移不变,只有页号被翻译。页大小通常 4KB(x86-64 也支持 2MB/1GB 大页)。

2.2 MMU 的角色

MMU(内存管理单元)是 CPU 内部的硬件,负责:

  1. 收到 CPU 发出的虚拟地址。
  2. 查页表(经 TLB 加速)得到物理页框号。
  3. 拼接偏移得到物理地址。
  4. 检查权限位,越权则触发异常。
CPU ──VA──▶ MMU ──PA──▶ 内存
              │
              └─ 查 TLB → 命中直接翻译
                 TLB 未命中 → 查页表(可能多次访存)
                 页表项无效 → 触发缺页异常

3. 分页机制

3.1 分页 vs 分段

维度分页分段
划分依据固定大小逻辑单元(代码段/数据段)
碎片类型内部碎片外部碎片
地址一维二维(段号 + 偏移)
现代使用主流x86 保留但多被扁平化

x86-64 实际上把分段「关掉」了(所有段基址为 0),完全依赖分页。

3.2 页表结构

页表是从虚拟页号到物理页框号的映射表,每个进程一张。最简单的单级页表:

虚拟页号:   0      1      2      3    ...
页表项:   [PFN=5][无效][PFN=9][PFN=2] ...

单级页表的问题:32 位地址、4KB 页时页表有 2^20 项,每项 4 字节,一个进程就要 4MB 页表,且大部分是空的。64 位地址下完全不可行。

4. 多级页表与 TLB

4.1 多级页表

把页号再切分成若干级索引,只为用到的区域分配子表:

x86-64 四级页表(4KB 页)
VA: [47:39] [38:30] [29:21] [20:12] [11:0]
      PGD     PUD     PMD     PTE     Offset
      9 位    9 位    9 位    9 位     12 位

一次地址翻译需要最多 4 次内存访问(外加最后一次取数据,共 5 次)。这显然太慢——TLB 就是为此而生。

4.2 TLB

TLB(Translation Lookaside Buffer)是页表项的高速缓存,通常几十到几百项,全相联或组相联。

  • 命中:1 个周期完成翻译。
  • 未命中:走页表遍历(page walk),硬件自动完成(x86)或由软件处理(MIPS)。
  • 命中率:得益于空间局部性,实际可达 99% 以上。
# 观察 TLB 未命中导致的性能问题
perf stat -e dTLB-load-misses,dTLB-loads ./app

4.3 大页优化

2MB 或 1GB 的大页可显著减少页表级数与 TLB 压力:

# 查看透明大页状态
cat /sys/kernel/mm/transparent_hugepage/enabled
# 显式申请大页
echo 20 > /proc/sys/vm/nr_hugepages

数据库(如 PostgreSQL、Oracle)常手动启用大页来降低 TLB miss。

5. 页表项与权限

5.1 关键标志位

位含义
P 存在位页是否在物理内存中
R/W 读写位只读还是可写
U/S 用户位用户态可否访问
A 访问位是否被访问过(供置换算法)
D 脏位是否被写过(决定是否需写回磁盘)
NX 执行位是否可执行(防代码注入)

5.2 写时复制 COW

fork() 不复制物理内存,而是把父子进程的页表都标记为只读并共享同一物理页。任一进程写入时触发保护异常,内核此时才复制一份:

fork 前:  [父] ──▶ 物理页 P (R/W)
fork 后:  [父] ──▶ 物理页 P (只读) ◀── [子]
子进程写 P → 缺页/保护异常 → 内核复制 P 为 P' → 子进程指向 P' (可写)

这使 fork 从「复制整个地址空间」降为「复制页表」,是 shell 与容器高效创建进程的基石。

6. 缺页处理

6.1 完整流程

① CPU 访问 VA → MMU 查 TLB 未命中 → 查页表
② 页表项 P=0 → 触发缺页异常(Page Fault)
③ 陷入内核,保存现场,进入缺页处理程序
④ 判断缺页类型:
     - 首次访问匿名页 → 分配物理页,清零
     - 页被换出到 swap → 从磁盘读回
     - 映射文件页 → 从文件读入(或按需置零)
     - 写只读页(COW) → 复制新页
     - 非法访问 → 发送 SIGSEGV,终止进程
⑤ 更新页表项,置 P=1
⑥ 返回用户态,重新执行触发缺页的那条指令

注意最后一步:异常处理完是重试原指令,不是跳过,因为指令尚未执行完成。

6.2 缺页的成本

一次主缺页(major fault)需磁盘 IO,耗时约 1~10 毫秒,相当于几百万条指令。**次要缺页(minor fault)**只需分配物理页,微秒级。

# 统计进程缺页次数
/usr/bin/time -v ./app        # 看 Minor/Major page faults
ps -o min_flt,maj_flt -p <pid>

7. 页面置换算法

7.1 常见算法

  • OPT(最优):淘汰未来最久不用的页,理论最优但不可实现,用作基准。
  • FIFO:先进先出,简单但可能淘汰热点页,且存在 Belady 异常(页框增多缺页反而增加)。
  • LRU:淘汰最久未使用,效果好但需要精确记录时间戳,硬件开销大。
  • Clock(二次机会):LRU 的近似实现,用环形链表 + 访问位扫描,Linux 实际采用类似思想。
  • LFU:按访问频率,易受历史累积干扰。

7.2 工作集与抖动

工作集(Working Set)是进程在某个时间窗口内实际访问的页集合。当所有进程的工作集之和超过物理内存时,系统陷入抖动(thrashing):CPU 大量时间用于换页而非计算,吞吐断崖式下降。

物理内存不足 → 缺页率飙升 → 换页 IO 饱和 → 有效计算时间骤降 → 抖动

应对:减少并发进程数、增加内存、优化访存局部性。Linux 的 OOM Killer 是最后的兜底手段。

7.3 伪代码

// Clock 算法
while true:
    if page[hand].referenced == 0:
        evict(page[hand]); break
    else:
        page[hand].referenced = 0   // 给第二次机会
        hand = (hand + 1) % n

8. mmap 与共享内存

8.1 mmap 的三种用法

#include <sys/mman.h>

// 1. 匿名映射:大块内存分配,绕过 malloc 的 brk
void *p = mmap(NULL, 1 << 20, PROT_READ | PROT_WRITE,
               MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);

// 2. 文件映射:把文件当内存读写,由内核负责回写
int fd = open("data.bin", O_RDWR);
void *f = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);

// 3. 共享内存:多进程映射同一文件或匿名区
//    MAP_SHARED 使修改对其他映射者可见,是最高效的 IPC

8.2 为什么 mmap 高效

  • 零拷贝:数据不经用户态缓冲区,直接由页缓存映射到进程地址空间。
  • 按需加载:只有真正访问的页才从磁盘读入。
  • 内核统一管理:回写、换出、缓存一致性都由页缓存机制处理。

工程应用:数据库索引文件、日志文件(如 Kafka 的索引)、动态库加载(ld.so 全靠 mmap)。

9. 常见陷阱

  • 认为 malloc 立即分配物理内存:多数分配只是建立虚拟映射,首次触碰才真正分配(lazy allocation)。
  • 忽视大页配置:TLB 密集负载未开大页会白丢性能。
  • 混淆 RSS 与 VSZ:虚拟大小 VSZ 含未映射区域,实际占用看 RSS。
  • 在缺页处理中睡眠过久:缺页路径应尽量快,引入复杂逻辑会放大延迟。
  • 过度使用 swap:SSD 上频繁 swap 会加剧写放大并缩短寿命,服务器常设 vm.swappiness=1。
  • 忘记 munmap 导致地址空间泄漏:长期运行进程反复 mmap 而不释放会耗尽 vm.max_map_count。

参考文章

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「计算机基础」更多文章

  1. 34. 编程范式与类型系统
  2. 33. 分布式系统基础
  3. 32. 加密与安全基础