1. 先弄明白为什么要有分页存储
1.1 连续分配方式到底差在哪
学操作系统内存管理,绕不开一个老问题:程序加载进内存时,内存空间怎么分?早期用连续分配,也就是给一个进程一整块连续的内存区域。这种方式听着简单,实际用起来让人头大。两个比较典型的场景:
第一个是外部碎片。进程来了又走,内存被切得零零碎碎,明明总剩余空间够大,却找不出一块连续区域装下一个新进程。就好比一个停车场空着十几个散位,却停不下一辆需要连续两个车位的大巴。这种碎片在进程退出、换入换出频繁的系统里特别严重,最终的结果是内存利用率虚高但实际能用的进程数很少。
第二个是进程地址空间和物理内存的绑定问题。连续分配下,程序编译时就得知道自己会被加载到哪个物理地址区间,程序里用的地址和真实内存地址脱不开关系。于是出现了重定位、动态重定位、界地址寄存器等一系列补丁式的解决方案,每个都是能跑但别扭。
业界后来思考的方向很直接:别追求连续了,允许一个进程散落在内存各处,把“能不能装下”的判断粒度变小。分页存储的核心就是干这件事——把物理内存切成固定大小的页框,把进程的逻辑地址空间切成同样大小的页,映射关系交给页表,进程就不需要再找连续空间了。
1.2 分页的核心理念:把“大”拆成“小”
分页的思路不复杂:逻辑地址空间按固定大小等分,每一份叫一“页”或“页面”;物理内存按同样大小等分,每一份叫一“页框”或“页帧”,也有人叫“物理块”。页面大小通常取2的整数次幂,常见的4KB在经典体系中被广泛使用。由于页和页框大小一致,任意一个页面都可以放到任意一个空闲页框中,映射关系由页表记录。这样进程的逻辑地址空间是连续的,物理分布却是离散的,外部碎片直接被消除,内部碎片最多浪费一页以内的空间——按4KB算,平均浪费约2KB每进程,远远好于连续分配的不可控碎片。
这里有一个容易被忽略的点:为什么页面大小必须是2的整数次幂?因为只有当页面大小是2^n时,才能把逻辑地址的低n位直接当作页内偏移量,高若干位当作页号,地址拆分完全靠位运算完成,硬件实现极其高效。如果页面大小不是2的幂,地址转换时就得做乘除运算,电路复杂度和时延都会明显上升。所以看一个体系结构的页面大小,往往就能推断出地址格式的设计思路。这也是为什么学分页存储,要先从地址结构入手,而不是上来就背那一堆算法。
2. 分页存储的核心概念与数据结构
2.1 页面、页框、页表、逻辑地址
分页涉及几个最基本的概念,先把它们串起来。逻辑地址由两部分组成:页号P和页内偏移量W。例如某系统页面大小4KB,逻辑地址32位,那么低12位是页内偏移量,高20位是页号。物理地址也由两部分组成:页框号F和页内偏移量W,偏移量与逻辑地址中的偏移量完全一致,因为页面和页框大小相同,映射前后页内偏移不变。地址翻译要做的事其实只有一件:把逻辑地址中的页号P,通过页表换成物理地址中的页框号F,偏移量原封不动透传。
页表是每个进程独立的一张表,表项记录了页号到页框号的映射关系。页表本身需要存放在内存中,操作系统为每个进程维护一个页表基址寄存器,指向该进程页表的起始地址。每次进程切换时,这个寄存器会被更新为另一个进程的页表基址,从而实现进程地址空间的隔离——一个进程的页号就算相同,也对应着完全不同的物理页框,谁也不能越界访问别人的物理内存。
页表项里除了页框号,通常还包含一些标志位:有效位表示该页是否在内存中,如果为0说明页面可能在磁盘上或尚未分配,访问时会产生缺页异常;读写位控制权限;访问位和修改位用于页面置换算法收集信息;还有些体系结构加入禁止缓存位、全局位等。这些标志位看着不起眼,但在缺页处理、页面置换、内存保护、性能优化里都起着关键作用。
2.2 地址翻译过程:一次完整的手工推演
拿一个例子完整推演一次地址翻译。假设页面大小4KB,逻辑地址0x3A2B。4KB = 2^12,所以页内偏移占低12位,页号占剩余高位。0x3A2B换算成二进制是 0011 1010 0010 1011,低12位是 1010 0010 1011 = 0xA2B,高4位(假定地址位宽16位)是 0011 = 3,因此页号P = 3,页内偏移W = 0xA2B。如果页表中第3项的页框号F = 7,则物理地址 = 7 * 0x1000 + 0xA2B = 0x7000 + 0xA2B = 0x7A2B。整个过程里页表只做了一次查表替换,偏移量没有经过任何加工。
实际系统中硬件访问一个逻辑地址通常需要两次内存访问:第一次查页表取页框号,第二次真正访问目标数据。这会带来性能损失,所以引入了后面要讲的TLB加速机制。页表的设计目标之一就是把这两次访问尽可能变快,多级页表的引入主要就是为了缓解页表本身占用内存过大的问题,而这些在经典教科书里往往是先讲单级页表、再讲多级页表的递进结构,理解顺序别乱。
2.3 单级页表的问题与多级页表
32位系统、页面4KB时,逻辑地址高20位是页号,意味着一个进程最多有2^20 = 1,048,576个页面。每个页表项假设占4字节,页表大小就是4MB。一个进程4MB页表听起来还能接受,但换成64位系统、同样页面大小,页号位数巨大,单级页表的大小直接变成天文数字,为每个进程分配这么大的连续内存是不现实的。于是有了多级页表:把页表本身再分页并建立索引,未被使用的页目录项可以不分配对应的二级页表,从而按需分配。比如经典的两级页表结构,一级页目录项指向二级页表,二级页表项指向物理页框。很多进程实际只用到地址空间很小一部分,层级结构让内存占用大幅下降。
虚拟内存、按需调页在引入多级页表之后真正走向了可用。它带来的代价是每一次地址翻译要多查几级,如果没有硬件缓存,性能会更难看。现代CPU的MMU里集成了TLB和多级页表遍历的硬件,软件侧看到的多级页表其实已经是硬件配合处理过的产物。
3. 地址转换的完整流程与局部性原理
3.1 从逻辑地址到物理地址的完整路径
分页地址转换的基本路径可以概括为三步。第一步,从逻辑地址中提取页号P和页内偏移W。第二步,用页表基址寄存器加上P乘以页表项大小,找到对应页表项;检查有效位,若页面不在内存,触发缺页异常并进入操作系统处理流程。第三步,从页表项中取得页框号F,与W拼接成物理地址,访问内存。
这个流程听着简单,但里面藏着很多细节。比如页表基址加上“P 乘以页表项大小”时,操作系统要确保页号P没有越界——否则一个非法逻辑地址可能直接算出页表范围之外的地址,造成不可预期的访问。所以很多体系结构在页表基址寄存器之外还有页表长度寄存器,对比页号是否超出范围,这属于分页保护机制的一部分,不能省。另一个细节是缺页异常的处理:缺页时要先看内存中是否有空闲页框,如果有就直接调入;如果没有,就要根据置换算法选择一个牺牲页框,把旧页面换出(必要时写回磁盘),再将新页面换入,并更新页表和TLB。这一整串过程在经典操作系统教材里是重点,在实际系统里也确是性能关键点。
3.2 TLB为什么能把两次内存访问变成接近一次
纯软件实现的分页地址翻译理论上需要两次内存访问,开销翻倍。实际系统中,几乎所有页表访问都具有时间局部性,也就是说程序在短时间内反复访问的页面数量很少,可能集中在几个页面内。CPU的MMU因此加入了一个高速缓存,专门缓存最近用过的页号到页框号的映射,叫做TLB(Translation Lookaside Buffer)。TLB的容量通常很小,几十到几千项,但命中率非常高。
TLB工作原理是:地址翻译先查TLB,如果命中直接拿到页框号,一次内存访问完成;如果未命中,走完整页表查询,同时把新映射填入TLB。TLB满时也需要替换,常见策略有LRU类置换和随机置换,有些体系结构还支持软件控制的TLB重填,把替换策略交给操作系统。这里有一个常见误区:TLB只缓存页号到页框号的映射,不缓存页内偏移,因为页内偏移不需要翻译,直接透传。TLB命中时得到的是一整个物理页框号,再和偏移拼接就行。
我倾向把TLB理解为“页表的热点缓存”。它的存在说明了一个操作系统设计的一般规律——能用缓存解决的问题,优先用缓存,而不是让每次操作都走重重逻辑。读内核代码时经常能看到flush_tlb之类的函数,它们的作用正是让TLB中无效映射失效,避免缓存了旧的页框号导致数据访问到错误内存。在调试内存相关问题时,观察TLB相关统计信息(命中率、未命中率、重填次数)往往比凭空猜代码更快。
4. 页面置换算法:物理内存不够时的博弈
4.1 缺页、抖动与工作集
分页存储配合虚拟内存后,物理内存可以比进程的逻辑地址空间小,进程用到的页面并不需要全部驻留内存,这就是按需调页。按需调页的前提是运行过程中访问到不在内存的页面时能当场调入,这就是缺页中断。如果缺页频繁到一定程度,系统大部分时间都在处理缺页和页面换入换出,CPU利用率和吞吐量反而急剧下降,这种现象就叫“抖动”。
工作集概念在这里很有用:一个进程在一段时间内实际访问的页面集合叫工作集。如果物理内存大小足以容纳工作集,缺页率就低;如果装不下,缺页率就会飙升。所以页面置换算法的目标,本质上是尽量保证每个进程的工作集能放到内存中。很多现代系统即使没有显式做工作集模型,背后用的LRU类近似算法本质上还是在跟踪工作集。
4.2 经典算法逐个拆
最佳置换算法OPT在理论上最优,换掉未来最长时间不会被访问的页面,但它需要预知未来,现实中不可实现,只作为衡量其他算法的基准。先进先出FIFO实现最简单,维护一个队列,换出最早进入的页面。FIFO的缺陷很明显,它不考虑页面是否被频繁使用,滚动播放一个经常用到的页面可能反复被换出换入,性能不稳定。更糟的是可能出现Belady异常——分配的物理块增多时,缺页次数反而增加,原因就是FIFO只看进入顺序,不管访问模式。
最近最久未使用LRU是更符合直觉的算法:换出最长时间没有被访问的页面。理论上LRU性能接近OPT,但完全实现LRU需要记录每个页面最后一次访问时间,并快速找到最小值,硬件成本和软件复杂度都不小。因此工业界更多使用LRU的近似实现,比如时钟算法Clock。时钟算法把内存中的页面放在一个环形链表上,每个页面带一个访问位,指针循环扫描。遇到访问位为0的页面就换出,遇到访问位为1的页面清零继续往下走。改进版时钟算法还会考虑修改位,优先换出既未被访问也未被修改的页面,减少磁盘写回开销。
实际情况下,LRU和Clock的差距取决于访问模式的局部性强弱。局部性越强,两者差距越大;随机访问模式下差距变小。我见过一些开发者试图自己实现一套“完美LRU”,结果复杂度和性能一塌糊涂,还不如直接用内核提供的近似算法。先把Clock的改进策略吃透,再考虑是否自行扩展,是一条稳妥的路线。
4.3 页面分配策略与局部性
置换算法不是孤立运行的,它要配合页面分配策略。每个进程最少需要分配多少页框,决定了系统整体能容纳多少进程。分配太少的页框会导致频繁缺页,分配太多又挤占其他进程空间。经典做法有三种:平均分配、按比例分配、按优先级分配。按比例分配按进程大小分配,但进程大小通常和实际活跃页面数不完全成正比,实践中效果未必好。优先级分配则是把更多页框给高优先级进程,适合交互式系统和实时性要求不高的场景。
另一个容易被忽视的点是局部性。页面置换算法能生效的根本原因就是局部性原理。时间局部性指最近访问的内容很可能很快再次访问;空间局部性指访问了某个地址后,周围地址也容易在不久后被访问。操作系统通过页表项的访问位来捕捉这种局部性,Clock算法正是利用访问位来近似LRU。理解了局部性原理,才能真正理解TLB为什么高效、预调页(prefetching)为什么有帮助、页面大小为什么会影响性能。页面太小,页表爆炸;页面太大,内部碎片和空间局部性收益下降,通常4KB到16KB是一个平衡区间。
5. 常见问题与排查经验
5.1 页表太大怎么办
很多新手第一次遇到的问题是:32位系统4KB页、4MB页表,看着还好;但换成64位系统页面4KB时,页表大小根本没法用单级结构容纳。解决办法是多级页表,通过只分配实际用到的页表层级来降低内存占用。但多级页表也带来一个新问题:地址翻译要查多级,没TLB时性能更差。现代系统的做法是同时结合TLB、多级页表,并通过硬件遍历页表来加速。在实操中,如果一个进程页表本身占用异常大,优先检查是不是地址空间碎片化导致大量稀疏页目录项被分配,而不是盲目调大物理内存。
大页(HugePages)是另一种应对页表过大的手段。把页面大小从4KB提升到2MB甚至1GB,页表级数和项数大幅降低,TLB能覆盖的内存范围变大。对数据库等追求稳定低时延的应用来说,大页能显著减少TLB miss。但大页的缺点是内部碎片更明显,分配粒度更大,对小内存场景反而浪费。Linux下的透明大页(THP)有自动管理机制,不过在使用时要留意它在某些场景下可能引入不稳定的性能波动,例如分配大页时发生阻塞。建议先在小范围试点采集数据,再决定是否在生产环境开启。
5.2 为什么完全LRU很难落地
标准LRU需要维护每个页面的最后访问时间,并在置换时选取最早的那个。硬件级实现通常靠一个巨大的计数器,每次访问都要更新时间戳,置换时线性扫描所有页框,这在页面数多时完全没有扩展性。软件实现可以做,比如维护一个链表,但每次访问都要在链表中移动节点,开销依然很大。所以工业界普遍接受Clock这类近似算法,它们牺牲一定精确性换取O(1)级别的实现成本和硬件友好度。
我在实际项目中见过一个奇怪现象:某些内存压力下,系统缺页率并不高,但响应变慢。查到最后发现是页表项“访问位”更新策略导致TLB频繁失效。访问位通常由硬件置位、软件定期清零,如果内核清得太频繁,就会把访问位信息破坏掉,后续置换算法误判页面最近使用情况。这种问题从表面看很像LRU算法失效,实际上是TLB和页表标志位交互的陷阱,调内核参数比改应用代码更有效。
5.3 对照速查:分页存储典型问题
| 问题现象 | 可能原因 | 常见处理思路 |
|---|---|---|
| 缺页率居高不下 | 分配的页框数过少、工作集大于物理内存 | 增加内存、调整进程优先级、减小进程工作集 |
| TLB命中率低 | 页表太小、访问模式局部性差、没开大页 | 开启大页、优化数据结构布局、减少跳转 |
| 页表内存占用过大 | 单级页表、稀疏地址空间 | 改用多级页表、开启大页 |
| 换出时磁盘写回频繁 | 修改位信息导致页面反复写回 | 使用改进时钟算法、增大换出缓冲 |
| 程序运行性能突降 | 抖动 | 检查分配给进程的页框数、降低多道程序度 |
这个表格基本可以当排查清单用。在动手优化前,先确认问题到底出在页表结构、TLB、置换策略还是进程本身的内存访问模式上,别上来就改参数。很多性能问题看着像分页的锅,实际是应用程序数据布局导致缓存失效。
5.4 实操排查思路
假设系统报出的结果是内存充足但缺页频繁。我一般按三步走。第一步看进程的驻留集大小(RSS)是否明显小于工作集,可以用系统自带的统计工具观察性能计数器和缺页数据。如果RSS太小,优先给进程多分页框。第二步看页面置换对象是什么,如果大量换出的是频繁修改的页面,说明算法对修改位处理不当,要优先调整置换策略;如果换出的页面大多是只读数据,磁盘I/O和写回压力会小很多。第三步看TLB相关的性能计数器,若TLB miss比例高于正常水平,考虑开大页或改善数据局部性。
有一类坑是调试器里看到的逻辑地址和物理地址混淆。很多排查工具给出的是进程逻辑地址,并不直接等于物理内存位置。想确认物理页框号,得从页表里解析。当年我在看一个内存越界问题时,一直盯着逻辑地址找规律,后来把页表手动解析出物理页框,才定位到是页表项标志位置错导致的错误映射。善用系统提供的页表映射查询机制,比硬读代码高效得多。
6. 分页存储的局限、优化与现代实践
6.1 分段与段页式:分页的互补方案
分页并不是内存管理唯一的选择。分段的思路是把程序按逻辑单位分段,每段是独立地址空间,长度可以不同。分段很符合程序员视角,比如代码段、数据段、栈段可以独立进行权限控制和共享,但分段保持了段的整体性,容易产生外部碎片。分页解决了外部碎片和离散分配,却对“逻辑模块”没有天然支持,一个函数的代码可能被拆成多个页面,给共享和保护带来不便。
段页式结合了两者:先按段划分逻辑地址空间,每一段内部再按页划分。程序员看到的还是段,操作系统在幕后为每段建页表,实际分配以页为单位。段页式结构灵活,但代价是查表次数增多,地址翻译硬件更复杂。现代主流操作系统很少单独用纯分段,往往在虚拟内存层面以分页为主、以类似段的功能补充权限和共享等需求。理解段页式的意义,更多在于看清分页的优势和局限,不至于把某个单一机制当成银弹。
6.2 现代操作系统的分页实践
Linux和Windows在核心内存管理上都采用分页机制。Linux下每个进程拥有独立的页表,进程切换时切换页表基址;系统同时区分内核态和用户态映射,有些体系结构用独立地址空间标识符(ASID)标记页表项,减少进程切换时清空TLB的代价。现代x86和ARM都支持多级页表,Linux针对不同硬件架构定义了几种页面大小,配合透明大页和显式大页,给用户不同的性能取舍。
另一个现代趋势是硬件辅助虚拟化中的影子页表或两阶段地址翻译。虚拟机场景中,客户机操作系统的页表不能直接用于宿主机,因为客户机管理的是客户物理地址,不是真实物理地址。经典做法是影子页表:由虚拟机监视器维护从客户虚拟地址到宿主物理地址的映射;硬件支持的两阶段翻译则让客户页表映射到客户物理地址,再由第二阶段映射到宿主物理地址。看这类机制时,会发现分页的基本原理并没有变,只是在多了一层地址空间后,页面映射关系变得更复杂,缓存和失效的处理也更讲究。
6.3 优化角度:从页面大小到数据布局
写应用层面代码的人,可能觉得分页是操作系统的事,段页式、多级页表离自己很远。其实不然。程序访问模式直接影响TLB命中率、缺页率、工作集大小,从而影响整体性能。一个典型的优化是把热数据在内存中紧凑排列。假如一个结构体的字段散布在多页上,每次遍历都可能触发新的页面访问,TLB miss率和缺页率都会上升;把相关字段放在同一页内,局部性变好,性能能明显提升。这类优化在数据库、搜索引擎、游戏引擎中都屡见不鲜。
再比如大页的取舍:大页降低页表压力和TLB miss,但分配和回收粒度大,内存碎片化加剧。有些服务对启动时间敏感,而分配一个大页需要更长的连续物理内存。生产实践中开的做法是:先用常规页面跑基线,再切大页跑对照,观察性能和稳定性的差值,再决定是否长期使用。内存管理没有通用的最优配置,只有当前场景下的合理选择;把分页机制吃透,才能真正理解这些权衡,而不是跟着网上的参数模板走。
我个人做了多年内存相关的开发和调优,最大的体会是分页存储并不只是教科书里的一章知识点,它几乎是理解所有现代计算性能问题的钥匙。从页表到TLB,从置换算法到工作集,每一步都在和局部性、成本、复杂度做博弈。把这个机制的基本盘理解透,后续学虚拟化、容器隔离、数据库缓冲池、文件系统页缓存,都会变得顺理成章。建议学习时多画地址格式图,动手把几组逻辑地址翻译成物理地址,再写点模拟页面置换的小程序记录缺页率变化,远比只看概念来得实在。