入门:虚拟内存、分页与缺页机制
基于通用操作系统概念 · 核于 2026-08
速查
- 虚拟内存(VM):让每个进程独享一片连续、私有的地址空间(如 64 位下 2^48),真实物理内存(RAM)只有几 GB。靠地址翻译(虚拟地址 → 物理地址)实现"假象"。
- 两大价值:①进程隔离(地址空间独立 + 页表权限位,进程间互不可见,崩溃只影响自己);②内存超售(按需调页 + swap,虚拟内存总和远大于物理 RAM)。
- 分页(Paging):把虚拟地址空间切成固定大小的页(Page,通常 4KB),物理内存切成同样大小的帧(Frame),页表记录"页号→帧号"映射。优点:无外部碎片;缺点:内部碎片(最后一页填不满)。
- 页表(Page Table):每个进程一张,存"虚拟页号 → 物理帧号 + 权限位(R/W/X/Present/Dirty)"。MMU 在每次访存时查它翻译地址。
- 多级页表:单级页表占内存太大(64 位下 GB 级),分级后只为实际用到的虚拟地址范围建表,省内存——代价是翻译层级多。
- TLB(Translation Lookaside Buffer):CPU 内的页表项缓存(缓存最近用到的虚拟页→物理帧映射)。命中时一个时钟周期完成翻译;miss 时才查内存里的页表。命中率是 VM 性能的关键(>99%)。
- 缺页中断(Page Fault):访存的页不在物理内存(PTE 的 Present 位=0,可能被换出到 swap/未分配/写时复制)→ 触发 fault 类异常 → 内核把页从磁盘读回物理内存 → 重新执行原访存指令。
- 分段(Segmentation):按逻辑段(代码段/数据段/栈段)切分,段大小可变。优点:符合程序结构、易共享;缺点:外部碎片(空闲块零碎不连续,难分配大块)。
- 段页式(Paged Segmentation):先分段、段内再分页——结合两者优点(逻辑清晰 + 无外部碎片),现代 OS(x86)历史上支持但实际多只用分页。
- 页面置换(Page Replacement):物理帧满时,选一页淘汰到 swap 腾出空间。算法:FIFO(先来先走,可能 Belady 异常)、LRU(最近最少用,最优实用近似)、最优 OPT(理论下界,需预知未来)、Clock(二次机会,LRU 的廉价近似)。
- Belady 异常:FIFO 特有——增加物理帧数,缺页率反而上升的反直觉现象(LRU/最优不会出现)。
- 抖动(Thrashing):内存严重不足,进程频繁缺页,CPU 几乎全在换页而非执行——吞吐崩塌。**工作集(Working Set)**理论指导:给每个进程维持其"近期活跃页集合",帧数不够工作集大小就 thrash。
- 局部性原理(Locality):程序访问有时间局部性(刚用的还会用)和空间局部性(用了地址 A,多半马上访问 A+1)——这是 TLB 有效、请求分页可行、Cache 有用的物理基础。
- 进阶顺序:分页、分段与页表 → 页面置换算法与抖动 → 参考。
一、虚拟内存:每个进程独享连续地址空间
虚拟内存(Virtual Memory)的核心思想:让每个进程以为自己独占一台完整的、连续的、私有内存的机器,而真实物理内存(RAM)是大家共享的、有限的。
进程 A 的虚拟地址空间 进程 B 的虚拟地址空间
┌──────────────┐ ┌──────────────┐
│ 代码段 0x400 │ │ 代码段 0x400 │ ← 两个进程的虚拟地址可以相同
│ 数据段 0x600 │ │ 数据段 0x600 │ (都从 0 开始)
│ 堆 ↑ │ │ 堆 ↑ │
│ ... │ │ ... │
│ 栈 ↓ │ │ 栈 ↓ │
└──────┬───────┘ └──────┬───────┘
│ MMU + 页表翻译 │ MMU + 页表翻译
▼ ▼
══════════════════ 物理内存(RAM)══════════════════
┌─────────┬──────────┬─────────┬─────────┬─────┐
│ A 代码 │ B 代码 │ B 数据 │ A 数据 │ ... │
└─────────┴──────────┴─────────┴─────────┴─────┘- 每个进程有独立的页表:进程 A 的页表把"虚拟 0x400"映射到物理帧 X,进程 B 的页表把"虚拟 0x400"映射到物理帧 Y。两个进程的虚拟地址相同,但物理位置不同——这就是隔离:A 无法访问 B 的内存(A 的页表里根本没有 B 的映射)。
- 按需加载:虚拟地址空间可以很大(64 位下 TB 级),但只有实际访问到的页才真正占物理帧,没访问的页表项 Present=0。这就是为什么一个 1GB 的程序能在 256MB 内存的机器上跑——它真正并发的活跃页可能只有几十 MB。
- swap 交换:物理帧紧张时,把不活跃的页写到磁盘(swap 分区/文件),腾出物理帧给活跃页;等被换出的页再次被访问时,触发缺页中断从磁盘读回。
一句话:虚拟内存 = 隔离(进程互不可见) + 超售(总和可大于物理 RAM) + 按需(用到才装入)。
二、分页 vs 分段:两种切分地址空间的方式
把虚拟地址空间映射到物理内存,有两种主流方式:
| 维度 | 分页(Paging) | 分段(Segmentation) |
|---|---|---|
| 划分单位 | 固定大小的页(Page,4KB) | 可变大小的逻辑段(代码段/数据段/栈段) |
| 碎片类型 | 内部碎片(最后一页填不满,平均浪费页大小一半) | 外部碎片(空闲块零碎不连续,难分配大段) |
| 程序员可见 | 不可见(OS/硬件透明管理) | 可见(段寄存器、段选择子) |
| 共享 | 容易(按页共享) | 容易(按逻辑段共享,如共享代码段) |
| 地址结构 | 页号 + 页内偏移 | 段号 + 段内偏移 |
| 现代 OS | 主流(Linux/Windows/macOS 纯分页) | 淘汰(x86 历史支持,实际用 flat 模式) |
- 为什么分页胜出:分页无外部碎片(所有帧等大,任意空闲帧都能放任意页)、对程序员透明(不必关心段)、易于按需调页(页大小固定,swap 高效)。分段的外部碎片(像 malloc/free 久了内存洞多)在长期运行的系统上是灾难。
- 段页式:先分段、段内再分页——结合两者优点(逻辑清晰 + 无外部碎片)。x86 历史上支持段页式,但现代 64 位 OS(Linux/Windows)都用 flat 模式(所有段基址=0、段限=最大),相当于只用分页,分段机制名存实亡。
三、页表:虚拟到物理的映射表
页表(Page Table)是虚拟内存的核心数据结构——它记录"虚拟页号 → 物理帧号"的映射。CPU 每次访存,MMU(Memory Management Unit)都用虚拟地址查页表翻译:
虚拟地址(如 64 位):
┌────────────────┬──────────────┐
│ 虚拟页号(VPN) │ 页内偏移 │
│ (高位) │ (低位,12位)│ ← 4KB 页 → 偏移 12 位
└────────┬───────┴──────────────┘
│ 查页表
▼
┌────────────────┐
│ 物理帧号(PFN) │ + 页内偏移(不变)
└────────┬───────┘
▼
物理地址(访 DRAM)- 页表项(PTE)的内容:物理帧号 + 权限/状态位——Present(页在内存吗)、R/W(读写权限)、U/S(用户/内核)、Dirty(被写过吗,换出时要写回磁盘)、Accessed(最近访问过吗,供 LRU/Clock 用)。
- 单级页表的问题:假设 32 位地址、4KB 页——有 2^20=100 万个页表项,每项 4 字节,单进程页表就 4MB。100 个进程就是 400MB,全是页表,没法用。
- 多级页表的省内存之道:把页表分级(x86-64 是 4 级)。只有实际用到的虚拟地址范围才建立最底层的页表项——大量未使用的虚拟地址(如堆栈之间的巨大空洞)只需上层一个"不存在"标记,省下海量空间。代价:翻译时要多查几次表(4 级 = 4 次内存访问),所以必须靠 TLB 缓存。
四、TLB:页表项的硬件缓存
多级页表让翻译变慢(4 级 = 4 次内存访问),CPU 受不了。TLB(Translation Lookaside Buffer)是 CPU 内部缓存最近用到的页表项的小容量高速缓存:
CPU 发出虚拟地址
│
▼
┌─ TLB 查询(1 个时钟周期)──┐
│ 命中? 是 → 直接拿物理帧号 │ ← 99%+ 的访存走这条路
│ 否 → 查内存里的页表 │ ← 多级页表要多次访存,慢
└─────────────┬───────────────┘
│ 页表也找不到?
▼
缺页中断(Page Fault)
内核把页从磁盘读回- TLB 命中率是 VM 性能关键:典型程序局部性好,TLB 命中率 > 99%。一旦 miss,多级页表要 4 次内存访问(数十 ns 到上百 ns)。
- TLB 容量小:通常 64-2048 项,每项映射一个 4KB 页(覆盖几百 KB 到几 MB)。**大页(Huge Pages,2MB/1GB)**能让一项覆盖更大范围,减少 TLB 压力——数据库(如 PostgreSQL/Oracle)和 JVM 常开大页。
- TLB flush:进程切换时若不区分页表,要 flush TLB(否则 A 进程用 B 的缓存映射就乱了)。现代 CPU 用 PCID/ASID(地址空间标识符)给 TLB 项打标签,进程切换时不必全 flush,提升上下文切换性能。
五、缺页中断:页不在内存怎么办
当 CPU 访问的虚拟页其 PTE 的 Present 位=0(页不在物理内存——可能被换出到 swap、或还没分配、或写时复制),触发缺页中断(Page Fault)。它是 fault 类异常,流程:
1. CPU 访问虚拟地址 → MMU 查页表 → Present=0
2. 触发 #PF(Page Fault)异常,陷入内核态
3. 内核缺页处理程序:
a. 检查这个虚拟地址合法吗?(在进程地址空间范围内?)
- 不合法 → SIGSEGV(段错误),杀进程
- 合法但页不在内存 → 继续
b. 找一个空闲物理帧(帧池有空闲的?)
- 无空闲 → 调用页面置换算法,淘汰一页腾出帧
- 被淘汰页是 Dirty(写过)→ 先写回磁盘 swap
c. 从磁盘 swap 把页内容读到物理帧
d. 更新页表项:Present=1,帧号=新帧
4. 返回用户态,重新执行那条访存指令(这次页已在内存,成功)- 缺页极慢:一次内存访问约 100ns,一次缺页(要读磁盘)约 几毫秒——慢 5 万倍。所以频繁缺页是性能灾难,极端下陷入抖动(thrashing)。
- 写时复制(COW):
fork()时父子进程共享物理页(页表都映射同一帧,标记只读);任一方写时触发缺页(写只读页),内核才复制一份——延迟复制省内存。
六、局部性原理:虚拟内存可行的物理基础
虚拟内存(请求分页)之所以可行,是因为程序访问有局部性:
- 时间局部性(Temporal Locality):刚被访问的地址,近期很可能再次被访问——如循环体里的变量、热点函数。这是 LRU/Clock 置换算法有效、Cache 有用的基础。
- 空间局部性(Spatial Locality):被访问地址的邻近地址很可能马上被访问——如数组遍历、指令顺序执行。这是预取(prefetch)、页大小 4KB 而非 4B 合理的基础。
如果没有局部性(程序随机访问 100GB 的内存),虚拟内存会完全失效——每次访问都缺页,磁盘转个不停(抖动)。好在真实程序的局部性都很好,活跃页集合(工作集)通常只占虚拟地址空间的极小部分,多数页可以稳稳驻留在物理内存。
下一步
理解了虚拟内存的整体思想后,下一步深入两个核心维度——分页、分段与页表(地址翻译的精确机制与计算)与页面置换算法与抖动(内存满时淘汰谁的算法抉择与 Belady 异常)。