理解虚拟内存的四大核心概念缺页异常、惰性分配、写时复制与页面换出学习操作系统时页表、缺页异常、写时复制、惰性分配、页面换出……这些名词往往会一股脑涌过来让人眼花缭乱。但其实它们之间有着清晰的层级关系缺页异常是硬件提供的基础机制而惰性分配、写时复制、页面换出都是操作系统利用这个机制实现的内存管理策略。本文从最底层的硬件机制讲起一层层往上走把这四个概念彻底讲透。页面错误异常一切的基础在讲所有高级功能之前必须先理解一件事缺页异常不是错误而是硬件和内核之间的一种通信机制。什么时候会触发缺页异常程序运行时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最近最少使用选最近最久没被访问的页面理论上效果最好时钟算法ClockLRU 的简单近似用一个指针循环扫描页面性价比高如果换出算法选得不好会出现一种糟糕的情况频繁地换入换出大部分时间都在等磁盘 IO系统变得巨卡。这种状态叫抖动thrashing。你有时候开太多程序电脑变卡很大概率就是内存不够了系统在疯狂换页。总结统一的模式四个概念讲完了我们回头看会发现它们遵循着完全一样的模式概念本质触发时机核心动作缺页异常硬件机制地址翻译失败陷入内核交给操作系统处理惰性分配内存分配策略访问未映射的堆地址分配物理页建立映射写时复制fork 优化策略写入共享只读页复制页面恢复写权限页面换出内存扩容策略访问被换出到磁盘的页从磁盘读回重新映射统一的模式就是利用「缺页异常」这个硬件钩子在程序访问内存的瞬间由内核动态地做一些幕后工作然后让程序继续跑仿佛什么都没发生过。这就是虚拟内存的魅力——程序以为自己用的是一大块连续、充足的内存实际上内核在背后各种精打细算、拆东墙补西墙。而这一切的基础就是页表那一层间接和缺页异常那个钩子。理解了这个统一的模式再去看各种虚拟内存的实现就会发现万变不离其宗。