Skip to content

入门:虚拟内存、分页与缺页机制

基于通用操作系统概念 · 核于 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 异常)。