Skip to content

虚拟内存

  • 写作时间:2026-03-23
  • 当前字符:9226

上一课把地址空间、多级页表和页表遍历的骨架立住了:进程看到的是虚拟地址,MMU 和页表把它翻译成物理地址。但如果事情只停在“有一张页表”,系统仍然不够灵活。来看这样一个场景:一个程序明明只碰了很小一部分数据,却可以先拿到一大段合法地址;另一台机器明明还有空闲 CPU,却会在内存紧张时突然变得非常卡。页表回答了“地址怎么翻译”,但还没有回答“哪些页此刻真的在内存里”“不在时怎么补进来”“内存不够时先赶走谁”。

这一课沿着这条动态链条继续往下推。TLB 先把最近用过的地址翻译缓存下来,否则每次访问都要重新走一遍页表遍历;但缓存只解决翻译速度,不解决页是否驻留,所以系统还需要 按需分页,把暂时用不到的页留在外部存储,等第一次访问时再装入;访问一个当前不在内存里的页会触发 缺页异常,内核在异常路径里补齐映射并让指令重试;物理内存终究有限,所以内核必须用 页面替换 决定谁留下、谁被换出;当进程的 工作集 总和超过可用内存时,系统就会陷入频繁换页的抖动。页表是静态骨架,虚拟内存则是这套骨架在运行中的动态行为。

TLB

TLB(Translation Lookaside Buffer) 是 MMU 内部用于缓存”虚拟页号 → 物理页帧号”翻译结果的硬件结构。

上一课已经看到,x86-64 的一次页表遍历需要逐级读取 PGD、PUD、PMD、PTE,最后才能拿到物理页帧号。没有 TLB 的话,一次普通的内存访问前面还要多出几次额外的内存访问,延迟会被页表遍历放大。TLB 的作用就是把最近访问过的页表项缓存下来,让大多数地址翻译在硬件里直接命中。

TLB 命中时,CPU 直接得到物理页帧号,拼上页内偏移后继续访问目标数据。TLB 未命中时,硬件触发 page table walk,按页表层级去内存里把映射找出来;如果页表项合法,硬件把结果回填进 TLB,再继续本次访问;如果页表项显示这个页当前不在内存,或者权限不允许访问,才会进一步触发缺页异常。

这里有一个非常值得先钉死的顺序关系:TLB 未命中不等于缺页。 很多时候,页明明已经在内存里,只是“这次翻译结果还没缓存到 TLB”而已。这样的一次访问只会多走一遍页表遍历,不会进入内核。只有硬件沿着页表走到最后,发现“这个页当前没有合法映射”或者“权限不允许”,才会从“缓存里没有”升级成“映射本身有问题”,进而触发缺页异常。

可以把一次普通的内存访问想成三层判定:

  1. TLB 里有没有这条翻译。
  2. 如果没有,页表里能不能把它翻出来。
  3. 如果页表也不能直接满足,这才进入缺页异常。

这三层要分开记。第一层在 CPU 缓存里解决,第二层主要靠硬件遍历页表解决,第三层才把控制权交给内核。后面讲 page fault 时,如果脑子里没有这三层,就很容易把“地址翻译慢了一次”和“系统必须补一张页”混成同一件事。

TLB 是按虚拟页组织的缓存,所以它和进程地址空间直接相关。最直接的问题是:两个进程都可能有虚拟页号 0x12345,TLB 怎么区分它们?一种做法是在上下文切换时把旧进程的 TLB 项全部作废。更高效的做法是给 TLB 项再带一个地址空间标识符,只有虚拟页号和标识符同时匹配才算命中。这个标识符通常统称为 ASID(Address Space Identifier),在 x86-64 上对应的具体机制叫 PCID(Process-Context Identifier)。有了 ASID/PCID,进程切换时就不必总是把整个 TLB 清空,切回来时还能复用之前的翻译缓存。

多核机器上的问题比“切换时要不要清空”更棘手。假设进程的某个页表项已经被内核改掉了,比如把一个页设成只读、解除映射,或者把物理页帧换成别的页帧;此时正在别的 CPU 核上运行的线程,可能仍然在自己的 TLB 里保留着旧映射。为了不让旧映射继续生效,内核必须通知相关 CPU 核失效对应的 TLB 项,这个过程叫 TLB shootdown。它通常通过跨核中断(IPI)完成:一个核心发通知,别的核心停下来执行失效操作,然后再继续原来的工作。

为什么 TLB shootdown 往往很贵?

从单核视角看,失效一条 TLB 项只是一条指令;但一旦放到多核系统里,真正贵的不是“删掉缓存项”本身,而是协调所有可能持有旧映射的 CPU 核。

内核首先要知道哪些 CPU 正在使用这个地址空间,然后向这些 CPU 发送 IPI。收到 IPI 的 CPU 必须先中断当前执行流,进入内核,执行 invlpg 或同类失效操作,再返回原来的任务。也就是说,一次页表修改会被放大成多次跨核打断。页表如果更新得很频繁,系统开销就不再只是“改一项 PTE”,而是“让一组 CPU 核同步观察到这次修改”。

这也是为什么内核会尽量减少不必要的 TLB 失效,为什么 ASID/PCID 很有价值,以及为什么某些高频内存管理操作在多核机器上会显著变贵。

TLB 的价值不仅取决于命中率,还取决于“每个条目能覆盖多大范围的内存”。默认 4KB 页时,一条 TLB 项只能覆盖 4KB;如果改用 2MB 的大页(Huge Page),同样数量的 TLB 项就能覆盖大得多的地址空间。假设某级 TLB 能缓存 512 个条目:

页大小512 个 TLB 条目能覆盖的地址空间
4KB2MB
2MB1GB

这就是大页提升 TLB 效率的核心原因:不是 TLB 条目变多了,而是每条条目“管”的内存变大了。代价是页越大,内部碎片越明显,换入换出的粒度也更大;因此大页适合热点内存范围大、访问局部性稳定的场景,不适合所有工作负载。

按需分页

按需分页(demand paging) 是把页面装入内存的时机推迟到“第一次真正访问它”时,而不是在进程启动时一次性全部装入。

如果没有按需分页,程序一启动就必须把代码段、数据段、共享库、栈,甚至那些将来可能会访问到的大块地址范围全部读进 RAM。这种做法既慢又浪费,因为很多页在程序生命周期里根本不会被访问。按需分页把这个问题倒过来处理:先建立地址空间里的映射关系和权限规则,但把实际物理页的分配或装入延后,等访问发生时再处理。

对普通的读写内存来说,按需分页常见的形态是“延迟分配”。例如栈向下增长、堆向外扩展,或者程序向内核申请到一段新的虚拟地址范围时,内核往往不会立刻给每一页都分配物理页帧,而是在第一次读写到具体页时,再补上一张真正的物理页。对“把文件内容当成一段地址范围来使用”的情况来说,按需分页更直接:内核先记住“这段地址将来对应哪部分文件”,而不是立刻把整个文件都读进内存。

这里先抓住按需分页最重要的时间顺序:地址先变成合法可用,物理页后面才补。 换句话说,内核可以先记住”这段地址将来该怎么解释”,真正的页通常要等第一次访问时才进入内存。下一课我们再把”哪些地址范围对应文件、哪些只是普通内存”这些差异展开。

如果把时间轴压得更细一点,按需分页其实是在把“申请地址”和“获得物理页”拆开。比如程序 malloc(1MB) 之后,用户态分配器可能已经拿到了一段看起来连续可用的虚拟地址;但如果程序此刻只碰了前 8KB,那么真正被分配物理页帧的,往往也只有这前两页。剩下的大部分地址只是“合法、可解释、未来可用”,并不等于“已经占用了同样大小的 RAM”。这就是为什么一个进程的虚拟地址空间可以很大,而驻留集却很小。

按需分页带来的直接收益有两类。第一类是启动速度:程序不必等整套地址空间都装好才能开始执行,只要先把会立即碰到的那几页准备好即可。第二类是内存占用:地址空间可以比实际占用的 RAM 大得多,进程“拥有”一段虚拟地址,不代表这段范围的每一页都已经在物理内存里。

缺页异常

缺页异常(page fault) 是 CPU 在访问某个虚拟页时发现当前页表无法直接满足这次访问,于是转入内核处理的异常机制。

“无法直接满足”有两种常见情况。第一种是页不存在:PTE 的 Present 位为 0,或者上层页表条目为空,说明这个页当前还没有可用的物理页帧。第二种是权限不允许:例如用户态代码写一个只读页,或者访问一个本该只允许内核使用的映射。两种情况都会进入缺页异常路径,但处理结果不同。前者往往是“补上这个页”,后者更多是“确认这是合法的吗”;如果不合法,内核最后会给进程送出 SIGSEGV 一类的错误信号。

一次典型的缺页处理大致沿着这条链路发生:

  1. CPU 访问某个虚拟地址,发现页表项缺失或权限冲突,触发 page fault trap。
  2. 处理器把 fault address 和错误码交给内核。x86 上故障地址保存在 CR2,错误码里会区分 present/not-present、read/write、user/kernel 等信息。
  3. 内核根据 fault address 在当前进程记录的地址范围里查找这次访问落在哪段区域,同时检查权限是否匹配。
  4. 如果这是一个合法但尚未驻留的页,内核分配物理页帧,或者从原始数据来源把内容补回来,再更新页表项。
  5. 如果需要,内核失效相关 TLB 项,让 CPU 下次访问时看到新映射。
  6. 内核返回用户态,CPU 重新执行刚才那条发生异常的指令。这一次因为页已经准备好,访问就能成功完成。

这条链最反直觉的地方在最后一步。缺页返回后,CPU 不是“从下一条指令继续”,而是重新执行刚才那条失败的指令。对程序来说,自己只是写了一次 *p = 42,源代码里完全看不到“先失败一次,再让内核补页,再重试一次”的过程。也正因为如此,按需分页和 Copy-on-Write 才能把大量运行时工作隐藏在原本普通的 load/store 指令背后。

这里最容易误解的一点是:缺页异常不等于“程序犯错”。对按需分页来说,第一次访问一个尚未装入的页本来就是正常路径。真正代表程序错误的是“访问了根本不属于自己的地址”或者“访问方式违反了权限规则”。

Linux 会把 page fault 再细分成次要缺页(minor fault)和主要缺页(major fault)。这个区分不是”异常种类不同”,而是”代价不同”。

minor fault 指内核虽然要补页,但不需要等待慢速外部存储。最常见的例子是匿名页的首次写入:程序 malloc() 拿到地址后第一次写,内核只需分配一张物理页并清零,整个过程在内存里就能完成。major fault 则意味着这次处理需要真正等待较慢的数据来源。典型场景是一个 mmap() 的文件页长时间没有被访问,已经被内核回收;当程序再次读到它时,内核必须重新从磁盘把内容读回来,线程在等待期间会进入睡眠。

这个区分和调度一章其实是连着的。minor fault 的处理仍然在内核里快速完成,线程很快就能拿回 CPU 继续执行;major fault 则常常会把当前线程送进睡眠,因为它必须等待磁盘 I/O。page fault 不只是内存管理事件,它还会直接改变线程是否继续占有 CPU。很多程序明明看上去只是”访问了一次数组”,真正慢下来的原因却是背后藏着一次 major fault 和一次调度切换。

Copy-on-Write 也是缺页异常路径上的一个经典分支。上一章讲 fork() 时看到,父子进程最初共享同一批只读物理页。只要谁先写,CPU 就会因为“向只读页写入”触发异常;内核在异常处理中分配新页、复制旧数据、把当前进程的 PTE 改成指向新页并恢复可写,然后重新执行那次写操作。写入这件事看起来像“普通的 store 指令”,背后却借了一次 page fault 才真正完成复制。

页面替换

页面替换(page replacement) 是在可用物理内存不足时,从当前驻留的页面中挑出一部分移走,以便给新页面腾出空间的机制。

按需分页让进程不必一次性把所有页都装入 RAM,但只要程序持续运行,访问范围仍然会逐步扩大。当空闲页帧不够时,内核就不能只管“装进来”,还必须决定“先把谁赶走”。被替换的目标页有的可以直接丢掉,因为以后还能从原始来源重新恢复;有的则必须先把当前内容保存下来,否则一旦回收就丢失状态。具体不同类型页面各自会走哪条去路,后面再展开。

最朴素的替换算法是 FIFO(First In, First Out):谁最早进内存,谁最先出去。它实现简单,但会出现一个反直觉现象:分给进程的页帧变多,缺页次数反而可能上升。这就是 Belady 异常(Belady's anomaly)。用一组具体的页引用序列可以直接看到它发生的过程。假设引用序列是 1 2 3 4 1 2 5 1 2 3 4 5

页帧数缺页次数
3 帧9 次
4 帧10 次

4 帧比 3 帧多了一帧可用空间,缺页次数反而更高。原因在于 FIFO 只记录”进入顺序”,并不知道页面最近是否还在被反复访问。

“最早进来”不等于”现在最不重要”。假设一个循环在 1、2、3、4 这几页之间反复工作,而页 1 只是恰好来得最早。FIFO 看到的只是”1 最老”,却看不到”1 其实马上还要用”。于是它可能刚把 1 换出去,下一次访问立刻又把 1 读回来。页面替换真正想逼近的不是”年龄”,而是”未来短时间内还会不会再碰到它”。OPT、LRU 以及各种近似 LRU,都是围着这个目标做不同程度的逼近。

OPT(Optimal) 是理论上的最优算法:永远替换“未来最长时间内不会再被访问”的页。它给出了缺页次数的下界,但因为内核不可能预知未来访问序列,所以 OPT 只能作为分析基准,不能直接实现。

LRU(Least Recently Used) 试图把“最近没用过的页,将来也不太可能马上用到”这个局部性假设编码进算法里。它的直觉比 FIFO 更符合程序行为,但精确 LRU 要维护全局访问顺序:每次访问一个页,都要把它移动到“最近刚用过”的位置。这在真实系统里代价很高,尤其是在多核高并发下,维护一条精确的全局顺序本身就可能比替换决策更贵。

为什么精确 LRU 很少直接实现?

如果只看定义,LRU 很诱人:最近最少使用,看起来正好抓住了局部性。但“最近”这个词在工程上意味着一条全局时间线,而全局时间线需要被每次内存访问持续更新。

问题在于,内存访问是最频繁的硬件事件之一。要在每次访问时都修改某个内核维护的排序结构,代价远远超过替换本身。即使把“页被访问过”这件事交给硬件的 Accessed 位去记录,内核也只能周期性地采样和清理这些位,无法得到一个严格精确的全局先后次序。

所以现实系统几乎都在做近似 LRU:尽量保留“最近用过的页更值得留下”这个方向,而不去维护一条精确到每次访问的绝对顺序。Clock、Second-Chance、活跃/不活跃链表,以及后来的 MGLRU,都是这种工程折中。

时钟算法(Clock,也叫 Second-Chance)就是经典的近似方案。它把页组织成一个环,扫描指针像时钟一样往前走:如果某页的 Accessed 位已经被硬件设过,说明它最近被碰过,内核把这个位清掉,给它“一次机会”,继续看下一个;如果某页经过一轮后 Accessed 位仍然是 0,它就更像一个可以被替换的冷页。

Linux 长期使用的思路是活跃链表(active list)和不活跃链表(inactive list)。最近频繁访问的页会被提升到 active list,冷页先落到 inactive list,被回收时主要从 inactive list 里挑目标。这个设计已经是 LRU 的近似版:内核并不维护每一页的精确全局顺序,而是只保留“更热”和“更冷”的分层信息。

多代 LRU(Multi-Gen LRU, MGLRU) 则把”冷热”进一步离散成多代。内核不是只分 active/inactive 两档,而是按访问新旧程度把页分进多个 generation,回收时优先扫描更老的一代。它仍然不是精确 LRU,但它更接近“最近性”的真实分布,特别是在多种访问模式和多个工作集交织的场景下,往往能减少无效扫描和误回收。

工作集

工作集(working set) 是一个进程在某段时间窗口内反复使用、如果拿走就会很快再次缺页的那组页面。

这个概念的重点不在“进程总共申请了多少地址空间”,而在“它此刻真正离不开哪些页”。一个进程可以拥有很大的地址空间,但某个时间片里真正被循环访问的,往往只是其中的一小部分。页面替换算法要做的,本质上就是尽量保住工作集,把工作集之外的冷页让出去。

可以用一个很具体的程序想象它。假设一个数据库进程映射了 20GB 文件,但当前只在反复扫描其中一个 200MB 的热索引区;与此同时它的堆上还有一些频繁访问的哈希表和连接状态。对这个进程来说,“整个 20GB 地址空间”当然都属于自己,但它当前真正的工作集,也许只是那 200MB 热索引页加上一些热点堆页。虚拟内存系统如果能把这部分留下来,程序就会觉得“机器很顺”;如果错把这部分当冷页换出去,程序立刻就会在下一轮循环里连续缺页。

一旦系统里所有活跃进程的工作集总和超过了可用物理内存,问题就出现了。内核刚换出一页,进程很快又要把它读回来;刚补进来另一页,下一轮又被迫赶出去。CPU 大量时间耗在 page fault、页回收、I/O 等待和 TLB 重新填充上,真正推进程序计算的时间反而变少。这种状态叫抖动(thrashing)。

抖动的典型症状不是“CPU 完全空闲”,也不是“磁盘一定打满”,而是整个系统表现出一种低效的忙碌:page fault 频率很高,回收线程频繁扫描页,进程反复睡眠和唤醒,用户感觉机器明明没死机,却几乎什么都做不动。很多时候看上去像是“调度器变差了”或者“锁竞争突然严重了”,根子却是内存已经留不住各自的工作集。

工作集模型给了我们一个判断标准:虚拟内存并不是把 RAM “变大”了,而是依赖于程序具有局部性这一前提。只要当前活跃的工作集能装进 RAM,按需分页和页面替换就能让系统看起来像拥有远大于物理内存的地址空间;一旦这个前提被打破,虚拟内存的魔法就会迅速退化成高频换页。

这也是为什么现代系统在内存压力下不会只依赖单一的替换算法。它们还会再叠加更多控制机制,处理不同类型页面的去路和内存压力下的系统行为。后面几课会继续把这些更靠近 Linux 现实实现的部分接起来。

回头看这一整课,其实是在回答同一个问题的五个环节:地址先要翻得快,所以有 TLB;页不必一开始就都在内存里,所以有按需分页;第一次访问不存在的页时,要有缺页异常把它补进来;物理内存不够时,要有页面替换决定谁让位置;而整套机制能否稳定工作,最后取决于活跃工作集是不是还能装进 RAM。把这五个环节串起来,虚拟内存就不再是一组孤立术语,而是一条完整的运行时控制链。

小结

概念说明
TLB缓存虚拟页到物理页帧的翻译结果,避免每次访问都重新做页表遍历
ASID / PCID给 TLB 项附加地址空间标识,让进程切换时不必总是全量清空 TLB
TLB shootdown页表更新后通知其他 CPU 核失效旧 TLB 项的跨核同步过程
Huge Page用更大的页扩大单条 TLB 项的覆盖范围,提升翻译缓存效率
按需分页只在第一次真正访问页面时才分配或装入该页
minor fault / major fault缺页处理的代价区分:minor 不需要等慢速存储,major 需要等待磁盘 I/O
缺页异常页不存在或权限不符时进入内核处理的异常路径
页面替换在物理内存不足时选择哪些驻留页应被移走,为新页腾空间
时钟算法用环形扫描和 Accessed 位近似 LRU 的经典页面替换方案
MGLRU把页面按访问新旧分成多代,减少无效扫描和误回收的近似 LRU 改进
工作集一段时间内进程真正频繁使用、必须尽量保住的那组页面
抖动工作集总和超过可用内存后,系统陷入高频换页和低效忙碌的状态

虚拟内存的关键不在于“给进程一个很大的地址空间”,而在于把地址翻译、按需装入和页面回收连成一条动态控制链,让有限的 RAM 尽可能贴着程序的访问局部性工作。


Linux 源码入口