操作系统 内存(5)虚拟内存机制
目标:
1.理解按需调页、页面置换、缺页中断
2.为什么虚拟内存能让程序“超出”物理内存?LRU、Clock 算法如何实现?
非常好 👍,你现在到了操作系统内存管理中最“灵魂”的部分——虚拟内存机制(Virtual Memory)。
这是理解现代计算机“如何让有限的物理内存运行看似无限程序”的关键。
我们慢慢展开讲,像之前那样:从问题出发 → 原理 → 机制 → 细节(LRU、Clock)。
🧩 一、问题起点:为什么需要“虚拟内存”?
在分页机制(paging)出现后,我们已经能做到:
- 把程序分成页(Page),内存分成页框(Frame),按页加载;
- 逻辑地址 → 页号 + 页内偏移 → 页表映射到物理地址。
这很好,但仍然有两个重大限制:
-
程序必须完全在内存中才能运行。
这意味着:- 程序大 → 必须占大量内存;
- 内存小 → 程序无法运行;
- 多程序并行时,很快就会内存不够。
-
用户看见的内存 = 真实物理内存。
你在代码里开了一个 4GB 数组,如果物理内存才 2GB,那直接崩溃。
🧠 二、核心思想:让“假”内存看起来像“真”的
虚拟内存的关键就是:
程序以为自己拥有一个完整、连续的大内存空间,
实际上只是部分装进了物理内存,其余部分临时放在磁盘上(swap space)。
我们用一张图感受一下这个“虚假但有效的幻觉”:
虚拟地址空间(程序视角) 物理内存 + 磁盘(系统视角)
┌──────────────────┐ ┌───────────┬─────────────┐
│ Code Segment │ ---> │ Frame 1 │ │
│ Data Segment │ ---> │ Frame 2 │ │
│ Stack Segment │ ---> │ Frame 3 │ │
│ Heap Segment │ ---> │ ... │ Disk Swap │
└──────────────────┘ └───────────┴─────────────┘
当程序访问某个还未加载进内存的页时,就会触发:
→ 缺页中断(Page Fault)
→ 操作系统从磁盘把这页调入内存(按需调页)。
→ 如果内存满了,就得换出一个旧的页(页面置换)。
很好👌,那我们现在进入虚拟内存中最精妙、也最体现“操作系统与硬件协作”的部分——
🧠 缺页中断(Page Fault)全过程
从 CPU 执行一条指令开始,到缺页、调页、恢复执行为止。
(1)问题起点:CPU 访问到一个“不在内存中的页”
想象一个场景:
程序执行到一句代码:
int x = array[10000];
在逻辑上,它只是在访问自己的内存空间,但在底层,这一步会发生:
-
CPU 根据虚拟地址 VA 去查页表;
-
页表项(PTE, Page Table Entry)告诉 CPU:
- 该页是否在内存中;
- 在内存中时,对应的物理页框号;
- 是否可写、是否在用户态可访问等权限位。
现在如果 PTE 中的“存在位(Present bit)”是 0 ——
意味着这个页还没被加载到内存中。
于是 CPU 就会喊出一句话:
“Page Fault!”
(2)缺页中断触发机制(硬件)
CPU 一旦发现页不存在,就会:
- 暂停当前指令执行;
- 触发缺页中断(Page Fault Exception);
- 硬件自动保存现场(例如程序计数器、寄存器状态);
- 跳转到操作系统内核的中断处理程序。
⚙️ 注意:这时 CPU 并不知道怎么处理,它只是“告诉 OS:你得来帮我拿内存”。
(3)操作系统接管:缺页处理流程(软件)
现在内核登场。
它会按以下步骤工作:
CPU 发现页不存在↓
触发 Page Fault 中断↓
操作系统接管↓
根据页表找到对应的虚拟页↓
判断缺页原因:├─ 是否非法访问(越界、权限错误)└─ 是否只是“该页还未加载”↓
如果合法但未加载:→ 选一个物理页框→ 若内存满:执行页面置换算法(LRU / Clock)→ 从磁盘中调入该页内容(I/O操作)→ 更新页表(将该页标记为存在)↓
恢复 CPU 上下文↓
重新执行被中断的那条指令
整个过程大致如下图:
┌──────────────────────────────┐
│ 程序访问虚拟地址 VA │
│ 页表查找:P=0 → 缺页中断 │
└───────┬──────────────────────┘│▼
┌──────────────┐
│ OS 中断处理程序 │
└──────────────┘│▼[检查缺页合法性]│▼[如合法 → 选页框]│▼[如需置换 → 写回旧页]│▼[从磁盘调入新页]│▼[更新页表、标记P=1]│▼[恢复现场,重新执行指令]
(4)一次缺页的代价
这个过程虽然优雅,但代价很大:
- CPU 访问内存:纳秒级(~100ns);
- 从磁盘读取页面:毫秒级(~10ms)。
也就是说:
一次缺页中断的时间成本 = 普通内存访问的十万倍!
所以系统必须:
- 尽量减少缺页率;
- 用良好的局部性(locality)和算法;
- 并在硬件上加速(比如 TLB 缓存最近访问的页表)。
(5)缺页的类型
不是所有的“页不在内存”都一样,OS 会区分几种情况:
| 类型 | 含义 | 处理方式 |
|---|---|---|
| 初次调页(Demand Zero) | 页第一次被访问,还未分配实际物理页 | 分配一个空白页,初始化为 0 |
| 文件页缺页(File-backed) | 页对应程序文件(代码段、数据段) | 从可执行文件中读入 |
| 匿名页缺页(Anonymous) | 动态内存、栈等 | 分配空页或从 swap 区加载 |
| 写时复制页(Copy-on-Write) | 多进程共享页,写入时触发 | 分配新页并复制数据 |
这让虚拟内存不只是“假内存”,
而是一整套“高效调度物理资源的机制”。
⚙️ 三、机制细节:按需调页 + 页面置换
🧠 TLB(Translation Lookaside Buffer)与地址转换加速
(1)为什么要有 TLB?
前面我们说过:
CPU 执行一条内存访问指令时,要把虚拟地址(VA)→物理地址(PA)。
这个映射靠的是页表(Page Table)。
但是——
假如每次访问内存都要查一遍页表,那就太慢了。
因为页表本身存在于内存中。
让我们算一下:
- 访问一次数据 = 查页表一次 + 访问数据一次
- 每次访问都多一次内存读操作
相当于:
原本 1 次内存访问的代价 → 变成 2 次(甚至更多)
那程序的性能几乎直接砍半。
于是——
硬件工程师灵机一动:
能不能把最近常用的地址映射缓存起来?
这就诞生了:
👉 TLB(Translation Lookaside Buffer)翻译后备缓冲器。
(2)TLB 是什么?
TLB 是 CPU 内部的一个高速缓存(在芯片上),
专门用来存放最近使用过的虚拟页号 → 物理页号的映射。
| 项目 | 页表 | TLB |
|---|---|---|
| 存放位置 | 主存(内存) | CPU 内部 |
| 存取速度 | 慢(~100ns) | 快(~1ns) |
| 存储内容 | 所有页的映射 | 最近访问的部分映射 |
| 大小 | 几千~上百万项 | 几十~几百项 |
所以,TLB 就像是“页表的缓存”。
(3)CPU 访问内存的过程(有了 TLB 之后)
我们重新看一下 CPU 访问一条地址的完整过程:
虚拟地址(VA)↓
查 TLB↓
┌──────────────┐
│ 命中(hit) │
│ → 得到物理页号 │
└──────────────┘↓
拼接页内偏移,得到物理地址↓
访问内存成功
如果没命中(miss):
虚拟地址(VA)↓
查 TLB↓
┌──────────────┐
│ 未命中(miss)│
│ → 查页表(内存)│
│ → 得到物理页号 │
│ → 更新 TLB 缓存 │
└──────────────┘↓
访问内存成功
(4)TLB 命中率的重要性
TLB 通常很小(几十到几百项),
但局部性原理帮了大忙:
程序通常在一小段地址空间中反复访问数据。
因此,TLB 的命中率往往能达到 95%~99%。
这意味着——绝大多数访问根本不需要查页表。
假设:
- 命中开销 1ns;
- 未命中需要查页表+访问 200ns;
- 命中率 99%。
平均访问时间:
Tavg = 1ns * 99% + 200ns * 1% ≈ 2ns
比原来的 200ns 快了 100 倍以上。
(5)TLB 与多级页表的配合
现代系统通常使用多级页表(如 4 级页表)。
如果没有 TLB:
- 每次地址转换要查 4 次内存(每一级页表都要访问一次);
- 再加上实际访问数据 1 次;
→ 一次访问 = 5 次内存读。
有了 TLB:
- 命中时只查一次;
- 极大减少开销。
所以 TLB 是“虚拟内存性能的救命稻草”。
(6)TLB 的刷新与上下文切换问题
问题来了:
TLB 里存的是“虚拟页号 → 物理页号”,
但不同进程的虚拟页号会重复。
如果两个进程都运行,TLB 不清空会出错。
于是有两种机制:
- 上下文切换时清空 TLB(简单但慢);
- 带 ASID(Address Space ID)标识的 TLB:
每个进程有唯一 ID,TLB 项带上它,
这样不用清空,也能区分不同进程的地址空间。
现代 CPU(如 x86-64, ARM)基本都支持 ASID 或 PCID 技术。
(7)TLB 的层次结构
和缓存一样,TLB 也分层:
| 类型 | 位置 | 特点 |
|---|---|---|
| L1 TLB | 每个核心独立 | 极快(十几项) |
| L2 TLB | 共享或更大 | 稍慢(几百项) |
访问顺序通常是:
L1 TLB → (miss) → L2 TLB → (miss) → 查页表
有时你可能听到术语:
“TLB shootdown”
指多核 CPU 中,某个核心修改页表时,要让其他核的 TLB 失效(同步更新)。
页面置换(Page Replacement)
如果内存已满,又来了一个新的页:
- 系统必须选择一个旧页换出(写回磁盘);
- 然后把新页装进来;
- 更新页表映射关系。
这就是 页面置换算法 的用武之地。
我们看两个最常考、也最有代表性的:
🧮 (1) LRU(Least Recently Used)
-
思想:最近没被用的页,未来也可能不会被用。
-
实现方式:
- 用时间戳记录每个页最近访问时间;
- 缺页时,选择最久未被访问的页置换。
-
优点:近似最优;
-
缺点:时间戳维护成本高(硬件或额外表支持)。
举个例子:
假设内存可容 3 页,访问序列为:
7, 0, 1, 2, 0, 3, 0, 4
模拟 LRU:
7 -> [7]
0 -> [7,0]
1 -> [7,0,1]
2 -> 缺页 -> 替换最久未用的 7 -> [0,1,2]
0 -> 已存在
3 -> 替换最久未用的 1 -> [0,2,3]
0 -> 已存在
4 -> 替换最久未用的 2 -> [0,3,4]
🕰️ (2) Clock(近似 LRU)
-
思想:维护一个环形队列和一个“使用位”。
-
过程:
-
每页有一个 use_bit(访问位)。
-
当访问页时,置 use_bit = 1。
-
置换时,从“指针”开始扫描:
- 如果 use_bit = 0,换出;
- 如果 use_bit = 1,设为 0,继续走一圈。
-
这就像一个“时钟的秒针”,扫过一圈,找出“最冷门”的页。
它比 LRU 更高效(无需时间戳),又能逼近效果。
💡 四、虚拟内存的关键意义
-
让程序“超越”物理内存运行
程序只需局部驻留内存即可运行,系统可同时支持多个大型程序。 -
进程隔离与安全性
每个进程都有独立的虚拟地址空间,不会互相访问越界。 -
内存利用最大化
不活跃的页可换出磁盘,内存专注服务活跃部分。
🧩 五、总结思维图
虚拟内存机制
│
├── 目的:让程序看似有大内存
│
├── 核心机制:
│ ├─ 按需调页(只加载需要的页)
│ ├─ 缺页中断(访问未加载页时触发)
│ └─ 页面置换(内存满时替换页)
│
├── 常用算法:
│ ├─ LRU(最久未使用)
│ └─ Clock(近似 LRU)
│
└── 结果:├─ 支持超大程序├─ 提高并发性└─ 隔离安全
