ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

虚存技术全解:从页表、TLB到缺页处理与页面置换

虚存技术全解:从页表、TLB到缺页处理与页面置换

翻开操作系统教材,第五章往往是整门课的“分水岭”。在此之前,你学的进程、调度、同步,好歹还能在黑板上画个队列图、手算几个时间片;到了虚存技术这里,脑子里要同时装着硬件寄存器、内存管理单元、页表结构、磁盘I/O,任何一个环节没接上,后面的置换算法、工作集模型就全成了空中楼阁。

我这几年面试系统研发相关岗位,几乎每次都逃不开虚存相关的问题。问得最多的不是“虚拟内存是什么”,而是“缺页之后CPU到底做了什么”“TLB miss和cache miss有什么区别”“Linux为什么把每个进程的地址空间画成那样一张图”。能把这些讲透的候选人,大概率是真的动手折腾过内存管理,而不是只背了第五章的考试重点。这篇文章就把虚存技术这条线从头捋一遍,结合教材内容和实际操作系统里的落地机制,把那些容易混淆、容易踩坑的地方逐个说清楚。

先说清楚一个底层逻辑:虚存技术解决的不是“内存不够用”这一个问题,而是三个问题的打包解决方案——内存不够、地址冲突、内存保护。

早期计算机没有虚存,程序直接写物理地址。两个程序同时在内存里跑,要么靠编译器把地址提前分配好,要么靠“重定位”在装载时把地址掰过来。这种方案有两个致命伤:第一,程序必须一次性全部装入内存,装不下就换人跑;第二,程序之间完全裸奔,谁都能读谁的地址空间,谈不上隔离和保护。虚存技术的核心思路是把“程序看到的地址”和“物理内存里实际的地址”彻底解耦,让每个进程拥有一套连续的、私有的、大小接近处理器地址位宽的逻辑地址空间,再由操作系统和硬件配合,按需把逻辑页映射到物理页框。

这套机制有四个核心要素,整章内容都是围绕它们展开的:逻辑地址空间、页表、地址翻译硬件(MMU/TLB)、缺页处理与页面置换策略。理解这四者的关系,比背下任何一个单独的算法都重要。


1. 虚存技术要解决的根本矛盾:物理内存永远不够用

1.1 从一道课堂实验说起:为什么程序能跑得比物理内存还大

我当年做课程实验时写过一个数值计算的程序,数据规模大概需要4GB的内存。那是在一台物理内存只有2GB的机器上,按说这个程序直接malloc会失败,可它在Linux下居然老老实实跑完了。我当时的第一反应是“内核肯定偷偷用了swap分区”,但后来仔细看现象才发现问题没那么简单——程序里真正被随机访问的核心数组只有几百兆,剩下的数据大部分时间只是被顺序扫过,根本不会常驻内存。

教科书对此的表述是:进程的局部性原理让虚存成为可能。程序在一段时间内的访存往往集中在某个较小的区域(时间局部性和空间局部性),因此我们完全不需要把整个程序都放在物理内存里,只需要把当前正在活跃的部分放进去,其余部分留在磁盘上。当程序访问到不在物理内存中的页面时,硬件触发缺页异常,操作系统去磁盘把该页换进来,再重新执行那条触发异常的指令。

这就回答了“程序为什么能比物理内存大”这个问题:物理内存只是逻辑地址空间的一个缓存。缓存嘛,装满不奇怪,关键是命中率得高。虚存设计的成败,本质上就取决于“按需换入”和“淘汰谁”这两件事做得好不好。

1.2 逻辑地址空间不是“磁盘空间换的”,它本身就是产品

很多人有个误解,觉得“虚拟内存=物理内存+swap分区”。这个说法不能说错,但会引偏方向。严格讲,即使机器完全没有swap,虚拟内存机制照样存在。因为虚存先解决的是“地址空间抽象”的问题,其次才是“容量扩展”的问题。

每个用户态进程在32位系统里拥有4GB的逻辑地址空间,在64位系统里理论上拥有巨大到用不完的地址范围。这套地址空间和物理内存是两套坐标系统:程序里的指针、变量地址,全都是逻辑地址;硬件再通过页表把逻辑地址翻译成物理地址。翻译不命中时,才轮到磁盘上场。

提示:虚存机制的核心收益不是“撑爆物理内存”这种极端表演,而是让每个进程拥有独立、连续、受保护的地址空间。即便物理内存完全够用,这套机制也必须存在——因为进程隔离和简化程序编写都依赖它。这就像快递柜给你分配了一个逻辑柜号,你不需要关心自己的包裹被放到了哪个物理格子弹,只要报柜号就能取件。

1.3 地址空间隔离是怎么实现的

两个进程可以拥有完全相同的逻辑地址,比如都从0x400000开始放代码段,但它们对应的页表项指向的物理页框完全不同。MMU在每次访存时都拿“当前进程的页表基址”来做翻译,进程切换时只要换掉页表基址寄存器,整个地址空间就跟着切走了。硬件是分时复用的,而地址空间是相互隔离的。

这也顺带解释了为什么用户程序永远碰不到内核地址:内核所在的那段逻辑地址空间,在用户态的页表项里被标记为“特权级不允许访问”。一旦用户程序去访问,MMU在翻译阶段就会发现权限位不匹配,直接产生保护异常,连“能不能读”这一步都到不了。


2. 分页机制:虚存技术的地基工程

2.1 页、页框和逻辑地址三段式切分

分页机制为虚存提供颗粒度管理单位:逻辑地址按固定大小切成“页”(page),物理内存按同样大小切成“页框”(page frame)。一个经典的配置是4KB一页。为什么是4KB而不是更大的尺寸?这是权衡的结果:页太小,页表膨胀得厉害;页太大,内部碎片和传输开销又上去了。即使放到现在,4KB依然是大部分系统默认值,足以说明这个尺寸在绝大多数场景下的均衡性。

逻辑地址被切分成三部分来看(在经典单级页表模型下):页目录号、页表号、页内偏移。为什么不是直接“页号+偏移量”就完了?因为页表本身也可能很大,需要用多级结构来组织,最前面几级是索引,最后一层才指向真实页框,偏移量直接穿透到物理地址的低12位。这是x86架构上最常见的设计。

2.2 地址翻译全过程:MMU在背后做了什么

一条取指指令“mov (%ebx), %eax”,当CPU去访问ebx寄存器里的地址时,真正的流程是这样的:

  1. CPU把逻辑地址交给MMU。
  2. MMU根据页表基址寄存器定位当前进程的页目录。
  3. 逻辑地址的“页目录字段”作为索引,从页目录中取出页表地址。
  4. “页表字段”作为索引,从页表中取出页框号。
  5. 页框号与“页内偏移”拼接,得到物理地址。
  6. 如果每个层级中对应的表项都不在TLB里,则逐级查内存中的页表,这是慢路径。
  7. 如果表项显示该页不在物理内存(PTE的存在位为0),则触发缺页异常,交给内核处理。

这个过程在教科书上画成一张一步接一步的流程图,看起来平平无奇,但真正的性能问题都藏在第6步里。我没见过哪个新手第一次就能理解“一次普通的内存访问,居然会触发多次额外内存访问”。每访问一个字节,可能先花几十个纳秒去查页表——这才是TLB存在的意义:把最近用过的页表项缓存起来,让翻译过程变成一次并行查找。

2.3 TLB:快表是虚存性能的胜负手

TLB是位于CPU内部的一个硬件缓存,存放“逻辑页号→物理页框号”的映射。由于它容量极小,通常只有几十到几千项,设计上必须靠局部性原理吃饭:程序不断访问的活跃页就那么几个,TLB命中率高了,地址翻译的开销才能被掩盖掉。

我做过一个不算严谨但很直观的对比实验:同一个数据遍历程序,在开启大页和普通4KB页两种配置下分别跑一次,观察运行时间的差距。大页配置下TLB可以覆盖更大的地址范围,整体耗时有明显下降。原因很简单:大页减少了页表遍历次数,更重要的是同样大小的TLB能覆盖的内存范围变大了。这在内存密集型的科学计算负载上体现得非常直接。

TLB miss和cache miss是两个完全不同的概念。我见过不少人在面试里把这俩混在一起。TLB miss是“地址翻译失败”,说明页表项没缓存;cache miss是“翻译完了,但数据不在缓存里”。翻译在前,取数在后,本来就不是一个层面的东西。搞混一次,后面关于内存层级的问题基本就全乱了。

2.4 多级页表:不只为省钱,还为“不需要存在”

教科书喜欢用“64位系统下一页表多大”这个例子来引入多级页表。一个进程如果使用4KB页和4GB逻辑地址空间,单级页表需要约1百万个页表项,按每项4字节算就是4MB。这只是一个进程的页表,100个进程就是400MB。64位系统下如果还把所有逻辑地址空间都映射出来,那个数字大到整个磁盘都塞不下。

多级页表的核心思路是“按需创建中间层页表”:如果一个顶层页目录项对应的整块地址空间都没被使用,那这个目录项就是空的,不必为它分配下级页表。这就像一本词典按字母拆成26本分册,如果“Z”开头的词一个都没收录,你就不需要硬造一整本“Z分册”。

在x86-64的四级页表(PML4→PDPTE→PDE→PTE)里,绝大多数上层页目录项都是空的。进程的地址空间中实际上只有少数字段是常驻的:内核映射、用户代码数据、栈、堆、mmap区域。其余都是“空洞”的地址范围,在多级索引下根本不会分配到真正的页表页。


3. 缺页异常与页面置换:虚拟内存从“纸上谈兵”走向“血拼物理内存”

3.1 缺页处理的完整路径:从硬件陷阱到内核恢复

当MMU发现PTE里的存在位为0时,会发起一个缺页异常。异常和中断的差别在于:异常是同步的,由当前指令直接引发,处理完了要回到下一条“肇事指令”重新执行。所以内核在缺页处理结束后的职责很明确——确保引发缺页的指令有了合法的物理页框,然后让该指令重跑一遍。

教科书版本的流程:

  1. 检查引发缺页的地址是否合法(是否在某个VMA区间内,权限是否匹配)。
  2. 合法,则进入“按需分配”或“从swap换入”的路径;不合法,则发送SIGSEGV给进程。
  3. 分配(或找到)一个空闲页框,把页面内容装进来,可能是读文件、可能是从交换区换入、也可能是全零页。
  4. 更新PTE,设置存在位和权限位。
  5. 刷新相关TLB条目。
  6. 恢复进程继续执行。

这里有个经常被忽略的细节:“页面内容从哪来”并不总是swap。可执行文件的代码页第一次被访问时,缺页处理程序往往只是搭建好映射关系,把PTE指向文件在磁盘上的位置,并没有真正的内存页框内容可读——这叫按需调页。而当你用mmap映射了一个文件再访问它,同样也是先缺页、再读文件。并不只有swap才产生磁盘I/O。

3.2 页面置换算法:该淘汰谁,这是个策略问题

当物理内存不足、系统需要腾出页框给新页面时,页面置换算法登场。这部分的经典算法清单不长:OPT(最优置换)、FIFO(先进先出)、LRU(最近最久未使用)、Clock(时钟置换,也叫第二次机会)、LFU(最不经常使用)。但每个算法背后的思想值得展开。

OPT是理论天花板,它需要知道未来的访问序列,现实中不可实现,但作为benchmark用来衡量其他算法的性能。FIFO实现最简单,维护一个队首队尾就行,却可能发生Belady异常——增加页框数反而导致缺页率上升。原因在于FIFO完全无视访问频率,一个经常用到的页面早晚会被队列里的新页面挤出。

LRU是很理想化的策略:利用局部性,淘汰最长时间没被访问的页面。表面上看我们只需要按“最后一次访问时间”排序即可,但真正的难点在于每次访问都要更新这个顺序。硬件级别几乎不可能为每一页都维护一个精确时间戳且实时排序,所以系统只能退而求其次,用近似LRU。

Clock算法是实际系统里最常见的基础方案。每个页表项维护一个访问位,页面被访问时由硬件置位;操作系统需要一个维护指针,像表盘在环形链表上滑动。当需要淘汰页面时,指针依次检查访问位:是1就清0并跳过,是0就淘汰。这就是“第二次机会”的由来——近期被访问过的页面会获得一次免死机会。

我打过一个比方:Clock算法就像一群人在一间房里排队,房屋管理员每隔一轮清空所有人帽子上的标记,谁在走过去这轮里被点到名(页面被访问),帽子上的标记会重新亮起;管理员只挑帽子上没标记的人请出去。它用极低的硬件成本,实现了对LRU一定程度上的逼近。

3.3 工作集模型与系统抖动:给置换算法安上“防抖”保险

光有好的置换算法并不够,更关键的问题是:系统应该给每个进程分配多少页框?分配少了,缺页率飙升,系统大部分时间在换页,CPU利用率反而掉下去,这就是抖动(thrashing)。

工作集模型给出的答案是:每个进程活跃使用的页面集合大小,是动态的。只要系统能保证每个活跃进程的驻留集不小于它的工作集,CPU就不会因为缺页而频繁空转。教科书上那张“CPU利用率—多道程序度”曲线值得反复看:多道程序度一高,内存分给每个进程的页框就不够,所有进程都在换页,CPU利用率骤降。

实际系统的解决思路是控制多道程序度:当检测到缺页率过高时,暂时挂起一些进程调低并发,把它们的页框让给正在运转的程序。这和后端服务里的“限流”逻辑一模一样——与其让所有请求都在排队超时,不如只处理能处理的那部分。


4. 虚存在真实系统中的落地:进程地址空间到底是什么样

4.1 一张进程地址空间图背后的所有映射关系

从Linux的角度看虚拟内存的实现,最直观的切入点是每个进程对应的内存描述结构。现代内核为每个进程维护一个逻辑地址空间视图,从最低地址到最高地址,典型布局是:

  • 代码段(text):只读可执行,从文件映射进来。
  • 数据段(data):已初始化全局变量,可读写。
  • BSS段:未初始化的全局变量,不占磁盘空间,首次访问时由零页补充。
  • 堆(heap):通过break系统调用向上增长,malloc大块内存时经常配合mmap。
  • 内存映射区(mmap区):共享库、mmap映射的文件、匿名映射。
  • 栈(stack):向下增长,带伸缩规则和访问保护。
  • 内核地址空间固定映射在相对高地址区域。

教科书把这部分表述为“进程地址空间图解”,但真正有经验的人看到这张图的第一反应应该是:每一个段都是一个VMA(虚拟内存区域)。vm_area_struct描述了一段连续的逻辑地址空间里“同一类后备存储映射”的范围。判断一个逻辑地址是否合法,本质上就是判断它是否落在某个VMA范围内。

4.2 malloc、mmap与缺页:用户态分配的底层真相

我在给新人讲malloc时,总会先澄清一个反直觉的事实:malloc成功分配内存,绝不等于物理页已经分配好了。在Linux的默认配置下,malloc分配大块内存时通常走mmap匿名映射,返回的逻辑地址只是往页表里登记一个映射关系,缺页时才真正分配物理页框。

这个机制解释了为什么malloc超大内存看起来“几乎不会失败”:只要逻辑地址空间还有富余,内核就不会真的去检查物理内存够不够。后果是,当你真正开始往这片区域写数据时,操作系统开始忙碌地分配物理页面,随时可能触发OOM(内存耗尽)的清理程序。面试时经常问到的“内存明明没超,为什么进程被杀了”,往往就发生在这个场景。

想观察这些行为很简单:在Linux下跑一个程序,用malloc分配几百MB后再sleep,期间查看进程状态和缺页统计,你会看到分配前后物理内存的占用几乎纹丝不动;一旦开始初始化数组,RSS立刻飙升,紧接着响应时间也会出现一个明显毛刺。

4.3 swap、页面回收与OOM:物理内存耗尽时的三重防线

教科书讲swap通常一笔带过,但真实系统比那复杂得多。现代内核内存回收的顺序大致是:先回收干净的text页(不需要写回,直接丢弃即可重新读文件)、再写回脏页、最后才轮到匿名页写回swap。

匿名页没有对应的文件后备存储,所以必须有一个专门的交换空间来承载它。swap分区或swap文件就是干这个的。但swap不等于“很重要”,现代运维实践里很多服务器根本不配置swap,因为磁盘I/O的延迟比内存缺页要高几个数量级,宁可让OOM killer尽早杀掉进程也不愿意整机卡死。所以内核才会在swap使用率还没爆表的情况下就启动激进的内存回收。

另外,OOM killer杀掉某个进程的选择也不是随机点名。它给每个进程算一个累计分,谁占用越多、谁被清理的收益越大,谁就越有可能是那个“牺牲品”。这就是为什么明明系统每个进程都只占了一点内存,最后被杀的往往是那个正在疯狂申请的进程——因为它的内存增长最快、加速因子最高。这类行为其实很符合直觉:头痛砍头,而不是砍脚。


5. 现代系统的虚存进阶:大页、内存压缩、KSM与NUMA感知

5.1 大页与透明大页:Taming 4KB的碎片化代价

上文提到过TLB容量小、局部性好的假设,但现代内存密集型负载会让局部性严重破功。遍历一个1TB的稀疏数组,按4KB一页来算,TLB能覆盖的连续范围实在太少,缺页和TLB miss交替轰炸,性能直线掉。

大页把页尺寸从4KB提升到2MB甚至1GB。一次TLB lookup能覆盖的地图范围大了512倍。代价是内部碎片可能增加,且大页在分配时对物理内存连续性的要求更高。container/虚拟化场景里,guests内存一致地使用大页,配合二代迁移,往往能明显改善性能抖动。

透明大页(THP)是Linux内核提供的一种自动行为,允许应用无需显式配置就能在某些合适的使用场景下拿到大页。听上去很完美,但它带来的运维问题也不少:分配大页的页面迁移和合并动作偶尔会在延迟敏感的延迟关键路径上造成突发毛刺。我的经验是:低延迟服务下手动关闭THP,普通数据分析场景用它往往是划算的。

5.2 KSM(内核同页合并):内存去重与虚拟化的缘分

KSM做的事情是扫描物理页框,找出内容完全相同的页面并将其合并为同一物理页,多个进程共享它,同时把PTE改成指向同一个页框,并标记为写时复制。某进程一旦试图写入,先复制一份再改写。

它对虚拟化场景有天然吸引力:一台宿主机上跑多个相似的操作系统镜像,大量内核代码页完全相同,KSM能把内存占用压下去不少。但KSM的扫描也要消耗CPU,频繁变更的数据页做合并就是纯浪费。所以KSM的调优核心是平衡扫描频率和合并收益。

5.3 NUMA:内存本身就是分布式的,虚存设计得跟着调整

现代多路服务器上,每个CPU访问本地的内存速度远快于访问远端内存。虚存机制不能假装所有物理内存都一样近。内核的内存分配器会优先从发起分配的CPU本地节点分配页面,这叫NUMA感知。

真正棘手的是线程迁移问题。一个线程在CPU0上分配了大量内存,后来被调度到CPU1上,CPU1访问这些页面全变成远端访问,性能损失明显。系统会尝试通过自动迁移把页框迁移到该线程现在运行所在的节点,但这本身又是昂贵的数据搬移操作。在这个问题上,操作系统和应用的调优目标经常会打架:内核想均衡负载,应用想固定亲和性。

5.4 内存压缩与zram:物理内存不够时的软件自救

除了swap换磁盘外,现代社会系统还有一条路:把内存页压缩后再放。内核的内存压缩机制会优先清理可移动页面并压缩片段,以便形成更紧凑的物理页块来满足大页需求。

zram则是一套把压缩后的数据块存储在内存里的swap设备。换出的页面经过压缩后还能留在RAM里,等再次访问时直接从内存解压,而不必跑一次磁盘I/O。这套设备跟固态硬盘这类swap载体搭配,效果通常不错,因为它砍掉了最贵的“走外部总线”这一环节。其背后的代价是CPU压缩开销,所以不是所有负载都适合无脑开zram。


6. 学习虚存技术常踩的坑与我的建议

6.1 三个最典型的概念混淆

第一,把虚拟地址当成物理地址算大小。曾经有个同学写了一个全局二维数组,然后没头没尾地引用某个越界元素,问为什么没报段错误。原因很简单,进程逻辑地址空间那么空,错几格通常不会越完整个进程的VMA边界,真正触发段错误需要跨过一大段“真空区”。不信你试试在栈里定义一个大数组,再故意越界几百字节,大概率依然“正常跑”。这就是VMA粒度比单片页更细但比“任意错一步都崩”更宽松的原因。

第二,混淆TLB和cache。一个小测验:访问一个全0大数组时,哪个局部性占主导?从延迟曲线上看,大数组访问主要被TLB miss影响,cache反而不是最紧张的。如果接口的名言是“翻译错了怪TLB,取数不存在怪cache”,很多定位问题的思路一下子就能理顺。

第三,把缺页一律理解为磁盘I/O。缺页从来不只是“从swap换入”的代名词,你第一次访问一个mmap文件页、第一次访问匿名页、第一次触发写时复制,都会缺页。这些场景里更多是“分配页框,填页表”的过程,未必有真正的磁盘出入。

6.2 我的课堂内外建议:用Linux亲手验证这套理论

如果只看书不写实验,虚存这章永远学不透。我建议安排这样一组实验:

  1. 写一个程序,分配和访问大数组,在跑的过程中用工具观察内存相关指标:进程状态、缺页次数、页表大小、上下文切换。
  2. 用mmap映射一个几GB的文件,逐步访问,观察同一个指标的走势,能看到明显的“首次访问缺页”与“已映射区域不再缺页”的差异。
  3. 用工具调低可用内存(或者直接开多进程挤压内存),观察系统日志里OOM相关信息的出现顺序,顺便理解内核换页和杀进程的优先级。
  4. 对比开启和关闭透明大页时同一负载的性能差异,注意识别是否有毛刺。

这些实验不需要多高深的工具,大部分发行版自带,但亲手做完,比盯着教科书上的曲线图要有用得多。很多面试里聊起虚拟内存,我能明显感觉到对方是“文本级理解”还是“实验级理解”——后者几乎总是能接着聊下去。

6.3 别把置换算法背得太死:要理解衡量标准

最后一层感悟是:页面置换算法的价值不在公式,而在权衡。为什么系统用Clock而不是LRU?因为LRU在硬件上没法精确实现;为什么大页又回来了?因为TLB太小,4KB的虚拟内存足迹吃掉了太多TLB容量。你会发现OS的设计本质上是一系列工程取舍,不是在真空中做完美方案。理解了这一点,再去看内核源码或做新硬件适配,你才能真正读懂那些看似奇怪的参数和开关背后的人与代价。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进