Appearance
虚拟内存
- 写作时间:
2026-03-04 首次提交,2026-07-13 最近修改 - 当前字符:
10062
内核设计把虚拟内存概括为内核建立地址翻译、分配与保护的机制,地址空间与分页又建立了多级页表和页表遍历的静态结构:进程发出虚拟地址,MMU 根据页表把它翻译成物理地址。但静态结构还没有说明映射在运行过程中怎样变化。
来看一个映射了 1 GiB 数据、当前却只访问其中 8 MiB 的进程。内核没有必要让整个范围立即占用 RAM,但 CPU 访问尚未驻留的页面时必须补齐映射;物理内存不足后,内核还要决定先回收哪些驻留页。如果选择失误,机器可能把大量时间耗在换页而不是计算上。
这一课沿着这条运行时链条继续分析。TLB 缓存最近使用的地址翻译,避免每次访存都重新遍历页表;但翻译缓存不决定页面是否驻留,因此系统还需要 按需分页,只在访问确实需要某页时才分配或装入它;无法直接满足的访问会触发 缺页异常,内核在异常路径中检查访问是否合法,并在可能时补齐映射后重试指令;物理内存不足时,页面替换 决定回收哪些页帧;如果所有活跃进程的 工作集 无法同时留在物理内存中,系统便可能陷入频繁换页的抖动。五个概念共同描述了虚拟内存在运行时如何维持地址翻译与页面驻留。
TLB
TLB 是处理器地址翻译硬件中缓存“虚拟页号到物理页帧号”翻译结果的高速缓存。
地址空间与分页展示过,x86-64 的一次页表遍历需要逐级读取 PGD、PUD、PMD 和 PTE,最后才能得到物理页帧号。没有 TLB 时,一次普通访存之前还要增加多次页表读取,页表本身不在处理器缓存中时尤其昂贵。TLB 保存近期使用过的翻译结果,使大多数访存不必再次完成整段页表遍历。
TLB 命中时,CPU 直接得到物理页帧号,拼上页内偏移后访问目标数据。TLB 未命中时,x86-64 的硬件页表遍历器执行页表遍历;如果页表项存在且权限满足本次访问,硬件把翻译结果填入 TLB 并继续访问;如果遍历结果表明映射不存在或权限不允许,处理器才触发一种表示“页表无法直接满足本次访问”的同步异常,也就是缺页异常。
这里必须区分两个事件:TLB 未命中不等于缺页。 页面可以已经驻留,只是翻译结果尚未缓存在 TLB 中;这时硬件多执行一次页表遍历,不进入内核。只有页表本身不能提供满足权限的映射时,处理器才触发缺页异常。
可以把一次普通的内存访问想成三层判定:
- TLB 里有没有这条翻译。
- 如果没有,页表里能不能把它翻出来。
- 如果页表也不能直接满足,这才进入缺页异常。
第一层在 TLB 中完成,第二层主要由硬件遍历页表,第三层才把控制权交给内核。这个边界区分了“地址翻译因 TLB 未命中而变慢”和“页表无法满足访问,必须进入内核处理”两类事件。
TLB 是按虚拟页组织的缓存,所以它和进程地址空间直接相关。两个进程都可能使用虚拟页号 0x12345,但它们的映射通常不同。一种处理方式是在切换地址空间时把旧翻译作废;另一种方式是给 TLB 项附带地址空间标识符(Address Space Identifier, ASID),只有虚拟页号和 ASID 同时匹配才算命中。x86-64 使用CPU 调度介绍过的 PCID 实现这类标记。操作系统正确使用 PCID 后,切换地址空间不必每次都清空全部 TLB 项,再次切回原地址空间时也可能继续命中旧的有效翻译。
多核机器上的问题比“切换时是否清空”更复杂。假设内核已经修改某个页表项,例如把页面改成只读、解除映射,或者换成另一个物理页帧;正在其他 CPU 上运行的线程可能仍在本地 TLB 中保留旧翻译。内核必须让所有可能使用旧翻译的 CPU 失效相应 TLB 项,这个过程称为 TLB 跨核失效(TLB shootdown)。Linux 通常通过处理器间中断(Inter-Processor Interrupt, IPI)通知目标 CPU;目标 CPU 中断当前执行流,完成失效操作后再继续运行。
为什么 TLB shootdown 往往很贵?
从单核视角看,失效一条 TLB 项只是一条指令;但一旦放到多核系统里,真正贵的不是“删掉缓存项”本身,而是协调所有可能持有旧映射的 CPU 核。
内核首先要知道哪些 CPU 正在使用这个地址空间,然后向这些 CPU 发送 IPI。收到 IPI 的 CPU 必须先中断当前执行流,进入内核,执行 invlpg 或同类失效操作,再返回原来的任务。也就是说,一次页表修改会被放大成多次跨核打断。页表如果更新得很频繁,系统开销就不再只是“改一项 PTE”,而是“让一组 CPU 核同步观察到这次修改”。
这也是为什么内核会尽量减少不必要的 TLB 失效,为什么 ASID/PCID 很有价值,以及为什么某些高频内存管理操作在多核机器上会显著变贵。
TLB 的有效覆盖范围称为 TLB 覆盖范围(TLB reach),它同时取决于条目数量和每个条目对应的页大小。使用 4 KiB 页时,一条翻译覆盖 4 KiB;使用 2 MiB 大页(huge page)时,一条翻译覆盖 2 MiB。为了只比较页大小的影响,假设某个 TLB 对两种页都能保存 512 个条目:
| 页大小 | 512 个 TLB 条目能覆盖的地址空间 |
|---|---|
| 4 KiB | 2 MiB |
| 2 MiB | 1 GiB |
这个简化计算说明了大页降低 TLB 压力的原因:条目数量不变时,每条翻译覆盖的内存更多。真实处理器通常为不同页大小配置不同数量或不同层级的 TLB 条目,因此表格不是某一款 CPU 的容量规格。
大页也有代价。一次缺页可能需要分配并清零更大的物理范围,未被实际使用的部分会形成更多内部碎片,寻找连续物理内存还可能触发回收或紧凑。Linux 中的 HugeTLB 和透明大页(Transparent Huge Pages, THP)也不能混为一谈:预留池中的持久 HugeTLB 页不能在内存压力下换出,而 THP 支持更多普通虚拟内存功能,并可在必要时拆分为更小的页。因此,不能笼统地把所有大页描述成“换入换出的粒度更大”;是否适合大页,要结合访问范围、缺页代价和内存碎片共同判断。
按需分页
按需分页(demand paging)是只在一次访问确实需要某个页面时,才为它分配物理页或从后备存储装入内容,而不是在映射建立时让所有页面立即驻留。
如果没有按需分页,程序启动或建立映射时就要为整段范围准备物理内存,并把所有文件内容读入 RAM。许多页面在程序整个生命周期中可能从未被访问,这会增加启动延迟并浪费页帧。按需分页先在地址空间中记录地址范围、访问权限和数据来源,再把物理页的分配或内容装入推迟到实际访问发生时。被页面回收机制移出的内容,以后再次访问时也可以通过同一机制重新装入,因此按需分页不只发生在“第一次访问”。
匿名内存(anonymous memory)是没有文件作为直接后备来源的内存,常见的堆和栈页面都属于这一类。栈扩大、堆扩展或程序申请新的匿名地址范围时,内核通常不会立即为每一页分配独立页帧,而是在具体页面首次访问时建立映射。只读访问可能先映射内核共享的零页,首次写入才需要私有的清零页帧。文件映射则先记录虚拟地址范围对应文件的哪一段,访问发生后再从页缓存或存储设备取得内容。页缓存是内核按文件及文件内偏移缓存内容的内存区域;内存映射会完整展开这两类映射及其共享关系。
mmap() 是让进程建立一段虚拟地址范围,并把它关联到文件内容或匿名内存规则的系统调用。调用成功只表示内核已经记录范围、权限与后备来源,不表示范围内每一页都已经分配页帧或读入 RAM;后续首次访问仍可通过缺页异常按需补齐页面。内存映射会继续分析它的共享、私有和权限参数,这里只需先区分“建立映射规则”与“页面已经驻留”。
按需分页最重要的时间顺序是:先建立合法的虚拟地址范围和访问规则,再按实际访问补充物理映射。 “虚拟地址属于某个有效范围”与“该地址当前已有驻留的物理页”是两件事。页表项可以暂时处于 Present 为 0 的状态,内核仍能根据 VMA 判断缺页是否可以被合法处理。
按需分页把“取得一段虚拟地址”和“让其中每一页获得物理页帧”分开。以 malloc(1 MiB) 为例,用户态分配器可能复用已有堆空间,也可能向内核取得新的地址范围;如果它取得的是尚未驻留的新范围,而程序只写入前 8 KiB,通常只有被触及的页面需要独立物理页。其余地址虽然属于合法范围,却没有占用等量 RAM。进程当前驻留在物理内存中的页面集合称为驻留集(resident set),常用驻留集大小(Resident Set Size, RSS)表示,因此虚拟地址空间很大并不意味着 RSS 同样大。
按需分页带来的直接收益有两类。第一类是启动速度:程序不必等整套地址空间都装好才能开始执行,只要先把会立即碰到的那几页准备好即可。第二类是内存占用:地址空间可以比实际占用的 RAM 大得多,进程“拥有”一段虚拟地址,不代表这段范围的每一页都已经在物理内存里。
缺页异常
缺页异常是处理器发现页表不存在所需映射或映射权限不满足本次访问时,转入内核处理的同步异常。
“无法直接满足”有两种常见情况。第一种是映射不存在:PTE 的 Present 位为 0,或者上层页表条目为空,说明当前页表没有可用的物理映射;非 Present 条目也可能编码交换位置等内核状态。第二种是权限不允许,例如用户态代码写一个只读页,或者访问只允许内核使用的映射。两种情况都会进入缺页异常路径,但处理结果不同。前者可能需要补上页面,后者需要判断是否属于 COW 等可修复情形;如果访问不合法,内核最后会给进程发送 SIGSEGV 等信号。
一次典型的缺页处理大致沿着这条链路发生:
- CPU 访问某个虚拟地址,发现页表项缺失或权限冲突,触发缺页异常。
- 处理器把故障地址和错误码交给内核。x86 把故障地址保存在
CR2中,错误码区分映射不存在或保护违规、读或写、用户态或内核态等情况。 - 内核用故障地址查找当前进程的 VMA,根据这段范围的权限与后备来源判断访问是否合法。
- 如果访问合法但页面尚未驻留,内核分配并初始化匿名页,或者从文件、交换空间等后备来源恢复内容,然后更新页表项。
- 如果需要,内核失效相关 TLB 项,让 CPU 下次访问时看到新映射。
- 内核返回用户态,CPU 重新执行刚才那条发生异常的指令。这一次因为页已经准备好,访问就能成功完成。
这条链最容易忽略的是最后一步。缺页被成功处理后,CPU 不是从下一条指令继续,而是重新执行刚才未完成的指令。程序只执行了一次 *p = 42,源代码中看不到“触发异常、补齐映射、重试指令”的过程。按需分页和 COW 正是利用这条异常与重试路径完成延迟工作。
这里最容易误解的一点是:缺页异常不等于“程序犯错”。对按需分页来说,第一次访问一个尚未装入的页本来就是正常路径。真正代表程序错误的是“访问了根本不属于自己的地址”或者“访问方式违反了权限规则”。
Linux 的资源统计把缺页分为次要缺页(minor fault)和主要缺页(major fault)。两者进入的处理入口可以相同,区别在于处理过程是否需要 I/O 活动。
次要缺页可以在不发生 I/O 活动的情况下完成。例如匿名页首次写入时,内核分配一个物理页并清零;文件页已经存在于页缓存、只是当前进程尚未建立 PTE 时,也可能形成次要缺页。主要缺页则需要 I/O 活动,例如从文件系统重新读取已不在页缓存中的数据,或者从交换设备取回匿名页。这里的分类标准是 I/O,而不是固定的时间阈值,也不能简单等同于“次要缺页只访问内存,主要缺页一定读取机械磁盘”。
这个区分与CPU 调度直接相连。次要缺页通常可以在当前执行流中完成;主要缺页需要等待 I/O 时,当前线程会睡眠,调度器转而运行其他可运行线程。一次普通数组访问可能因此引出主要缺页、I/O 等待和上下文切换,缺页也就不只是地址翻译事件。
进程生命周期介绍的 COW 也是缺页异常路径上的一个分支。fork() 后,父子进程起初可以共享标记为只读的物理页;任一方首次写入时,处理器因写权限不满足而触发异常。内核检查出这是合法的 COW 写入后,为写入方准备独立页面,复制原内容,修改其 PTE 并恢复可写权限,然后让写指令重试。普通存储(store)指令由此成为实际执行复制的触发点。
页面替换
页面替换(page replacement)是在物理内存不足时选择牺牲页,并回收其页帧供其他页面使用的机制。
按需分页让进程不必一次性让所有页驻留,但程序持续扩大访问范围后,空闲页帧仍可能不足。此时内核既要选择牺牲页,也要按页面类型决定其去向:干净的文件页可以直接移出页缓存,以后从文件重新读取;脏文件页包含尚未写回文件的修改,通常要先执行回写;匿名页没有文件可供恢复,若内容仍需保留,就要写入交换空间或压缩交换缓存。不能安全恢复且不能保存的页面不能直接丢弃。内存映射会进一步解释文件页、匿名页、页缓存和交换空间的关系。
最朴素的替换算法按 FIFO 顺序选择最早进入内存的页。它实现简单,但会出现一个反直觉现象:分给进程的页帧变多,缺页次数反而可能上升。这就是 Bélády 异常(Bélády's anomaly)。用一组具体的页引用序列可以直接看到它发生的过程。假设引用序列是 1 2 3 4 1 2 5 1 2 3 4 5:
| 页帧数 | 缺页次数 |
|---|---|
| 3 帧 | 9 次 |
| 4 帧 | 10 次 |
4 帧时,前四次访问填满 1 2 3 4,随后命中 1、2 不会改变 FIFO 的进入顺序。访问 5 会淘汰最早进入的 1,接下来的 1 2 3 4 5 便连续缺页,总计 10 次。3 帧时,5 进入后,1 和 2 仍在页帧中,接下来的两次访问都命中,最终只有 9 次。页帧更多反而缺页更多,原因正是 FIFO 只记录进入顺序,不记录近期访问。
“最早进入内存”不等于“近期不会再访问”。假设一个循环反复访问 1、2、3、4,而页 1 只是恰好最早进入。FIFO 可能刚回收页 1,下一次访问又把它装回。页面替换真正想估计的是页面近期再次被访问的可能性,后面的理论最优算法与访问历史算法会用不同信息逼近这个目标。
最优页面替换(Optimal, OPT)是理论上的最优算法:永远替换未来最长时间内不会再被访问的页。它给出了缺页次数的下界,但因为内核不可能预知未来访问序列,所以 OPT 只能作为分析基准,不能直接实现。
最近最少使用(Least Recently Used, LRU)把“近期没有使用的页,短期内再次访问概率较低”这一局部性假设编码进算法。它比 FIFO 使用了更多访问历史,但精确 LRU 要维护全局访问顺序:每次访问一个页,都要更新它在顺序中的位置。这在真实系统里代价很高,尤其是在多核高并发下,维护精确全局顺序本身就可能比替换决策更贵。
为什么精确 LRU 很少直接实现?
LRU 直接利用访问局部性,但“最近”在精确实现中意味着一条全局时间线,而这条时间线需要随每次内存访问持续更新。
问题在于,内存访问是最频繁的硬件事件之一。要在每次访问时都修改某个内核维护的排序结构,代价远远超过替换本身。即使把“页被访问过”这件事交给硬件的 Accessed 位去记录,内核也只能周期性地采样和清理这些位,无法得到一个严格精确的全局先后次序。
所以现实系统通常实现近似 LRU:利用采样到的访问历史区分相对冷热,而不维护精确到每次访问的绝对顺序。
时钟算法(Clock,也称 Second-Chance)是经典的近似方案。它把页组织成环,并让扫描指针按固定方向循环移动:如果某页的 Accessed 位已经被硬件设置,说明它近期被访问过,算法清除该位并继续检查下一个;如果某页经过一轮后 Accessed 位仍为 0,它就是更合适的回收候选。
Linux 长期使用的思路是活跃链表(active list)和不活跃链表(inactive list)。最近频繁访问的页会被提升到 active list,冷页先落到 inactive list,被回收时主要从 inactive list 里挑目标。这个设计已经是 LRU 的近似版:内核并不维护每一页的精确全局顺序,而是只保留“更热”和“更冷”的分层信息。
多代 LRU(Multi-Gen LRU, MGLRU)是 Linux 提供的另一种 LRU 实现。它把访问时间离散成多个代(generation),较新的访问进入较年轻的代,回收时优先处理最老的代。MGLRU 仍然不是精确 LRU,但比只有 active/inactive 两档保留了更多年龄信息,也能减少一部分无效扫描。它是否启用取决于内核配置和运行时设置,不能把它当成所有 Linux 系统必然采用的回收算法。
工作集
工作集(working set)是进程在给定的近期时间窗口或内存引用窗口内访问过的页面集合。
窗口长度决定“近期”的范围:窗口太短会漏掉周期稍长但仍活跃的页面,窗口太长又会把已经不用的页面继续算作活跃。工作集关注的不是进程总共申请了多少地址空间,而是当前阶段反复访问哪些页。页面替换算法无法直接预知未来,只能根据近期访问记录尽量保留工作集,并优先回收窗口之外的冷页。
来看一个数据库进程。它映射了 20 GiB 文件,但当前只反复扫描其中 200 MiB 的热索引区,堆上还有少量频繁访问的哈希表和连接状态。整个映射都属于该进程的虚拟地址空间,当前工作集却可能只有热索引页和热点堆页。若这些页面保持驻留,后续访问可以继续命中;若它们被误判为冷页并回收,下一轮扫描就会连续触发缺页。
一旦系统里所有活跃进程的工作集总和超过可用物理内存,刚被回收的页就可能很快再次访问,而刚装入的另一页又因压力被回收。CPU 大量时间耗在缺页处理、页面回收、I/O 等待和 TLB 重新填充上,真正执行程序计算的时间反而变少。这种状态叫抖动(thrashing)。
抖动不要求 CPU 完全空闲,也不要求存储设备达到满带宽。更直接的特征是缺页频率升高、回收线程频繁扫描、进程反复睡眠和唤醒,而有效计算吞吐显著下降。这些调度和等待现象可能被误判为调度器或锁的问题,实际原因却是可用内存无法容纳活跃工作集。
工作集模型给出了虚拟内存有效运行的条件:程序在一段时间内必须具有足够强的访问局部性。只要当前活跃的工作集能留在 RAM 中,按需分页和页面替换就能用有限的页帧支持远大于物理内存的虚拟地址范围;一旦工作集总量长期超过可用内存,系统便会把大量时间耗在回收和重新装入页面上。
页面替换算法只是内存压力控制的一部分。内存映射将说明匿名页、文件页、交换空间和 OOM 处理,内核内存分配则继续分析页帧分配、内核对象缓存、大页与 NUMA 如何影响物理内存的供给和回收。
小结
| 概念 | 说明 |
|---|---|
| TLB | 缓存虚拟页到物理页帧的翻译结果,避免每次访问都重新做页表遍历 |
| ASID / PCID | 给 TLB 项附加地址空间标识,让进程切换时不必总是全量清空 TLB |
| TLB 跨核失效 | 页表更新后通知其他 CPU 核失效旧 TLB 项的跨核同步过程 |
| 大页 | 扩大单条翻译的覆盖范围,同时承担更大的分配、清零与碎片代价 |
| 按需分页 | 只在访问确实需要页面时才分配页帧或从后备存储装入内容 |
mmap() | 建立文件或匿名虚拟地址规则,成功返回不等于所有页面已经驻留 |
| 次要缺页 / 主要缺页 | Linux 缺页统计中的 I/O 区分:前者不需要 I/O 活动,后者需要 |
| 缺页异常 | 页不存在或权限不符时进入内核处理的异常路径 |
| 页面替换 | 在物理内存不足时选择哪些驻留页应被移走,为新页腾空间 |
| 时钟算法 | 用环形扫描和 Accessed 位近似 LRU 的经典页面替换方案 |
| MGLRU | 把页面按访问新旧分成多代,减少无效扫描和误回收的近似 LRU 改进 |
| 工作集 | 一段时间内进程真正频繁使用、必须尽量保住的那组页面 |
| 抖动 | 工作集总和超过可用内存后,系统陷入高频换页和低效忙碌的状态 |
虚拟内存的关键不在于给进程一个很大的地址空间,而在于把地址翻译、按需装入和页面回收连成动态控制链,根据访问局部性把有限 RAM 优先留给活跃页面。
源码与文档入口:
arch/x86/mm/tlb.c:x86 TLB 刷新与 shootdown 路径arch/x86/mm/fault.c:x86 缺页异常入口mm/memory.c:缺页处理与页表更新核心逻辑mm/vmscan.c:页面回收路径- Transparent Hugepage Support:THP 的收益、代价与拆分行为
- HugeTLB Pages:HugeTLB 预留页与不可换出属性
- Multi-Gen LRU:MGLRU 的代际模型与启用方式
getrusage(2):minor fault 与 major fault 的统计定义