MIT 6.S081 lazy 前置篇(lab5):缺页异常、惰性分配、写时复制、页面换出
理解虚拟内存的四大核心概念:缺页异常、惰性分配、写时复制与页面换出
学习操作系统时,页表、缺页异常、写时复制、惰性分配、页面换出……这些名词往往会一股脑涌过来,让人眼花缭乱。但其实它们之间有着清晰的层级关系:缺页异常是硬件提供的基础机制,而惰性分配、写时复制、页面换出,都是操作系统利用这个机制实现的内存管理策略。
本文从最底层的硬件机制讲起,一层层往上走,把这四个概念彻底讲透。
页面错误异常:一切的基础
在讲所有高级功能之前,必须先理解一件事:缺页异常不是"错误",而是硬件和内核之间的一种通信机制。
什么时候会触发缺页异常?
程序运行时,CPU 的 MMU 会自动查页表,把虚拟地址翻译成物理地址。翻译过程中一旦发现问题,MMU 就会暂停当前指令,触发缺页异常,把控制权交给内核。
常见的触发场景有三种:
- PTE 有效位为 0:这个虚拟地址根本没有建立映射
- 权限不匹配:比如往只读页面写数据、用户态程序访问内核页面
- 页面不在内存中:页面被换出到磁盘上了(后面会讲)
触发之后发生了什么?
缺页异常发生时,CPU 会做几件事:
- 把出问题的虚拟地址存到
stval寄存器 - 把异常原因(读错误 / 写错误 / 执行错误)存到
scause寄存器 - 保存当前执行上下文,跳转到内核的异常处理入口
内核拿到控制权后,就可以根据地址和原因判断该怎么处理。
最关键的一点:内核可以选择"修复"
很多人以为缺页异常就等于程序崩溃,其实不是。内核拿到控制权后,有很多选择:
- 分配一个物理页,建立映射,然后返回用户态重新执行刚才出错的指令
- 从磁盘把页面读回来,重新建立映射
- 复制一个页面,修改权限后返回
从用户程序的视角看,那条指令就像正常执行了一样,只是稍微慢了一点——程序完全感知不到中间内核偷偷做了什么。
这就是所有高级内存管理功能的底层原理:利用缺页异常,在程序运行时动态地、按需地管理内存。后面三个概念,全都是这个机制的具体应用。
惰性分配:等真用了再给内存
惰性分配是最简单、最直观的缺页异常应用,也是 xv6 lab5 要实现的功能。
问题背景:sbrk 的浪费
传统的sbrk(n)是怎么实现的?进程申请 n 字节堆内存,内核就立刻分配对应的物理页,建立页表映射,然后返回新的堆顶地址。
但现实中,程序经常申请一大块内存,却不会立刻全部用到,甚至有些内存永远都不会被访问。比如程序调用malloc(1MB),但实际只用了前 4KB,剩下的 996KB 就白白占着物理内存,造成了浪费。
惰性分配的思路
惰性分配的思路非常简单,就一句话:申请的时候啥也不干,等真用了再分配。
具体来说,sbrk(n)调用时:
- 不分配任何物理页
- 只是把进程的堆顶大小
sz增加 n - 直接返回
就这么简单?对,申请的时候只改个数字。
真正的分配发生在缺页异常里
那内存什么时候分配呢?答案是:程序真的去访问那段"申请了但没分配"的内存时。
整个流程是这样的:
- 程序访问新申请的内存地址
- MMU 查页表发现 PTE 无效,触发缺页异常
- 内核的异常处理函数被调用
- 内核检查:这个地址是不是在堆的合法范围内?
- 如果是,当场分配一个物理页,填好 PTE,建立映射
- 返回用户态,重新执行刚才出错的指令
程序继续正常运行,完全不知道中间内核偷偷帮它分配了内存。
为什么叫"惰性"?
"惰性"这个词非常形象:内核能拖就拖,不到万不得已绝不干活。你申请了我先记着,但不给你,等你真要用的时候我再给你。
这种做法的好处很明显:
- 节省物理内存:用多少给多少,不用的不占
sbrk变得极快:只是改个数字,不用分配和映射- 对程序完全透明,不需要修改任何应用代码
写时复制:等真写了再复制
写时复制(Copy On Write,简称 COW)是 fork 场景下的经典优化,思路和惰性分配一脉相承:能不复制就不复制,真要改了再复制。
问题背景:fork 的浪费
传统的 fork 要复制父进程的整个地址空间:每一页物理内存都要复制一份给子进程,然后建立子进程的页表映射。
但现实是,绝大多数情况下,fork 之后马上就会调用 exec,子进程的整个地址空间会被新程序替换掉。那刚才辛辛苦苦复制的那一大坨内存,直接就被扔了——纯纯浪费时间和内存。
写时复制的思路
COW 的思路是:fork 时先不复制,父子共享内存,等真有人要写了再复制。
具体来说,fork 时:
- 不复制任何物理页
- 子进程的页表直接指向父进程的物理页(和父进程共享所有页面)
- 把所有共享页面的 PTE 都设为只读(去掉写权限)
这样,父子进程共享同一份物理内存,大家都只能读,不能写。读的时候完全没问题,因为数据是一样的。
真正的复制发生在缺页异常里
那什么时候复制呢?答案是:有人要写的时候。
后来,不管父进程还是子进程,只要尝试往某个共享页面写数据:
- MMU 发现页面是只读的,触发缺页异常
- 内核检查:这是一个 COW 共享页面吗?
- 如果是,当场分配一个新的物理页,把原页面的内容完整复制过去
- 把出错进程的页表项指向新页面,恢复写权限
- 返回用户态,重新执行写入指令
这样,只有真正被修改的页面才会被复制,没被修改的页面一直共享,省了大量内存和时间。
一个关键细节:引用计数
因为多个进程可能共享同一个物理页,所以每个物理页需要一个引用计数:
- 页面被共享时,计数加 1
- 页面被复制时,计数减 1
- 只有计数归 0 时,页面才能真正被释放
如果没有引用计数,一个进程退出了,把共享页面释放了,另一个进程还在用,就会直接崩溃。
页面换出:内存不够时挪到磁盘
页面换出(Page Swap / Paging)是虚拟内存最经典、最强大的功能,也是最复杂的。它让程序觉得物理内存"用不完"。
问题背景:物理内存是有限的
物理内存是硬件,容量是固定的,但进程的虚拟地址空间可以非常大。如果所有进程加起来要用的内存超过了物理内存总量,怎么办?
总不能让新进程直接启动失败吧?页面换出就是解决这个问题的。
页面换出的思路
物理内存不够用时,操作系统会选一些最近不常用的页面,把它们的内容写到磁盘上(这个过程叫"换出"),然后:
- 释放对应的物理页,给更需要的进程用
- 把对应 PTE 的有效位设为 0,但在 PTE 里记录下"这个页面在磁盘的哪个位置"
注意:PTE 虽然无效了,但页面不是真的没了,只是被挪到磁盘上存着了。数据还在,只是不在内存里了。
换入:缺页异常时拿回来
程序后来又要访问这个被换出去的页面时,会发生什么呢?
- MMU 发现 PTE 无效,触发缺页异常
- 内核检查:这个页面是被换出到磁盘了吗?
- 如果是,分配一个新的物理页
- 从磁盘把页面内容读回到物理页
- 重新建立页表映射
- 返回用户态,重新执行指令
这个过程叫"换入"(page in)。程序完全不知道自己的页面曾经跑到磁盘上遛了一圈。
核心问题:选谁换出去?
页面换出有一个核心问题:物理内存满了,选哪个页面换出去?
理想情况当然是选未来最久不会被用的那个,但我们没法预知未来。所以实际中用的是各种近似算法:
- LRU(最近最少使用):选最近最久没被访问的页面,理论上效果最好
- 时钟算法(Clock):LRU 的简单近似,用一个指针循环扫描页面,性价比高
如果换出算法选得不好,会出现一种糟糕的情况:频繁地换入换出,大部分时间都在等磁盘 IO,系统变得巨卡。这种状态叫"抖动"(thrashing)。你有时候开太多程序电脑变卡,很大概率就是内存不够了,系统在疯狂换页。
总结:统一的模式
四个概念讲完了,我们回头看,会发现它们遵循着完全一样的模式:
| 概念 | 本质 | 触发时机 | 核心动作 |
|---|---|---|---|
| 缺页异常 | 硬件机制 | 地址翻译失败 | 陷入内核,交给操作系统处理 |
| 惰性分配 | 内存分配策略 | 访问未映射的堆地址 | 分配物理页,建立映射 |
| 写时复制 | fork 优化策略 | 写入共享只读页 | 复制页面,恢复写权限 |
| 页面换出 | 内存扩容策略 | 访问被换出到磁盘的页 | 从磁盘读回,重新映射 |
统一的模式就是:利用「缺页异常」这个硬件钩子,在程序访问内存的瞬间,由内核动态地做一些"幕后工作",然后让程序继续跑,仿佛什么都没发生过。
这就是虚拟内存的魅力——程序以为自己用的是一大块连续、充足的内存,实际上内核在背后各种精打细算、拆东墙补西墙。而这一切的基础,就是页表那一层间接,和缺页异常那个钩子。
理解了这个统一的模式,再去看各种虚拟内存的实现,就会发现万变不离其宗。