ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

分页存储管理:逻辑地址到物理地址转换、多级页表与TLB详解

分页存储管理:逻辑地址到物理地址转换、多级页表与TLB详解 1. 为什么分页这件事绕不开先搞懂它到底解决了什么麻烦操作系统的内存管理里分页存储管理方式是绕不过去的一道坎。我刚开始接触这块的时候脑子里全是问号——为什么非要搞一套逻辑地址到物理地址的转换直接把程序塞进内存里跑不行吗后来做了几个内存密集型的项目踩了连续分配带来的碎片坑才真正理解分页这套机制的精妙。逻辑地址到物理地址的转换本质上是给每个程序发一张虚拟地图让它以为自己独占整块内存实际上物理内存被切成小块被众多进程共享。这件事听起来抽象但它是现代操作系统能同时跑几十上百个进程的底层支撑。我写这篇东西的目标读者有三类正在啃操作系统课程、被页表计算题折磨的学生想从工程角度理解内存寻址、做性能调优的后端开发者以及准备面试、需要把地址转换讲清楚的求职者。不管你是哪一类我都会从最朴素的为什么讲起一步步推到多级页表和TLB把每一步的计算过程、参数量级、常见坑全部摊开。我不会只给你一个公式了事而是会告诉你页面大小为什么是4KB、页表为什么要分级、缺页和TLB失效到底差在哪。看完之后你手里应该有一套可以自己推导、自己验证的完整方法论。2. 分页的核心思路拆解把内存切成等份的格子2.1 连续分配留下的三个烂摊子在分页出现之前内存分配走的是连续分配路线也就是给一个程序划一块连续的内存区域。这套做法有三个致命问题。第一是外部碎片内存里零散的空闲区域加起来够用但都不连续新来的进程尺寸稍微大一点就塞不进去只能干等或者在空闲区之间来回搬迁紧凑搬迁期间CPU还得停摆。第二是内部碎片为了迁就空闲块的大小往往要往进程里多塞点用不上的空间浪费掉了。第三是难以共享和动态增长一个进程想扩展自己的地址空间连续分配要求它后面的空间必须是空的这个条件太苛刻了。分页的思路很朴素也很暴力既然连续分配这么别扭那我干脆把物理内存提前切成一块块固定大小的小格子每一块叫一个页框页帧、物理块再把程序的逻辑地址空间也切成同样大小的块叫页面。程序运行时它的各个页面被随机地塞进任意空闲页框里根本不需要连续。这样外部碎片直接消失了——因为所有页框都一样大任何空闲页框都能被任意页面使用内部碎片也被限制在最后一页平均浪费半页左右。2.2 逻辑地址的两段式切分分页机制最核心的设计是把一个逻辑地址看成页号 页内偏移两段。为什么这么切因为页面大小是固定的比如4KB那么页内偏移就固定占低12位2的12次方等于4096剩下的高位自然就是页号。这个切分不是人为规定的花哨操作而是二进制天然对齐的结果——只要页面大小是2的整数次幂切分就是纯粹的位操作快得飞起。这也是为什么现实中页面大小几乎都是4KB、2MB、1GB这种2的幂绝不可能是5KB这种数。一个32位系统、页面4KB的情况下逻辑地址的位布局是高20位是页号低12位是页内偏移。页号最大能到2的20次方也就是大约一百万页。这意味着一个进程理论上可以拥有100万个页面每个4KB合起来4GB的逻辑地址空间。这个数量级很关键后面算页表大小时会反复用到。3. 地址转换的核心算法公式、推导与手工演算3.1 三个公式拿下基本转换分页的地址转换说到底就三个公式我建议你把它们背到肌肉记忆里页号 逻辑地址 / 页面大小整数除法等价于右移位页内偏移 逻辑地址 % 页面大小取余等价于按位与低n位物理地址 页框号 × 页面大小 页内偏移第三个公式是灵魂。注意这里页内偏移在转换前后完全不变变的只有页号变成页框号。这背后的道理是页面被搬到物理内存时是整页整体搬的页内的相对位置原封不动只有页号到页框号的映射关系变了。所以转换的本质就是一张查表拿页号去页表里查对应的页框号然后把页内偏移拼回去。这张页表存在内存里每个进程一张页表项PTE里存着页框号以及一些控制位有效位、访问位、修改位、保护位等。页表本身也是数据也占用物理内存这一点很多人第一次听会愣一下——管理内存的东西自己也要占内存这个自指的特性是理解多级页表的钥匙。3.2 一个完整的十进制演算光看公式没感觉我们来算一个。假设页面大小是4KB4096字节逻辑地址是十进制的12058。第一步算页号12058 / 4096 2余数往下走。这里2就是页号说明这个地址落在第2号页面里页号从0开始数。第二步算偏移12058 % 4096 12058 - 2 × 4096 12058 - 8192 3866。所以页内偏移是3866。第三步查页表假设页表里第2号页表项记录着页框号是5。那物理地址 5 × 4096 3866 20480 3866 24346。整个过程就是除、取余、查表、乘加四步。你会发现真正费时间的不是算术而是查表——页表在内存里查一次表就要访问一次内存。这个访存开销才是后面要引入快表TLB的根本原因。3.3 用十六进制验证更快更准十进制演算适合理解但真正做题和调试的时候用十六进制加二进制才是快车道。还是那个地址12058转成十六进制是0x2F18……等一下我改用一个更规整的例子。假设逻辑地址是0x2F1A页面大小4KB也就是低12位为偏移。我们先看十六进制的位0x2F1A是一个16位数低12位是偏移。0x2F1A的二进制展开0010 1111 0001 1010。低12位是1111 0001 1010也就是0xF1A换成十进制是3866。高4位是0010即2这就是页号。你看用二进制切分页号和偏移一眼就能读出来根本不用做除法。已知页号2查表得页框号5物理地址就是5 12 | 0xF1A 0x5000 | 0xF1A 0x5F1A。这个方法我强烈推荐。实际调试内存问题时你手里多半是十六进制地址直接数位数比做除法靠谱得多。步骤十进制方法十六进制方法结果求页号12058 / 4096取高4位2求偏移12058 % 4096取低12位3866 (0xF1A)查页表查第2项查第2项页框号5算物理地址5 × 4096 38660x5000 OR 0xF1A24346 (0x5F1A)3.4 页表本身有多大算一笔账你就清醒了这是新手最容易忽略、面试最爱考的点。32位系统页面4KB页号20位意味着每个进程最多有2^20个页面也就是约104万页。每个页表项假设占4字节足够存下32位页框号加若干标志位那么一张页表就是104万 × 4字节 4MB。乍看4MB不算啥但关键在于这是每个进程一张。系统里跑50个进程光页表就吃掉200MB物理内存。更要命的是这4MB的页表还要求连续存放吗不要求但它仍然要被拆成1024个页来存于是又引出页表本身怎么寻址的问题这就是后面多级页表要解决的死结。我当时第一次算到这一步才明白单级页表在32位下就已经很勉强到了64位根本没法看。提示算页表大小时一定要先确认三个量——逻辑地址位数、页面大小、页表项字节数。这三个数一变页表大小跟着变考试里经常在这三个地方挖坑。4. 从单级到多级页表为什么非要分层不可4.1 单级页表的空间灾难接着上面那笔账往下推。32位系统单级页表要4MB连续逻辑空间这已经偏大。到了64位系统如果还按4KB页面、每项8字节来算页号有52位页表项数就是2的52次方乘8字节……这个数字大到没边——大概是几千万个太字节物理上根本不存在这样的内存。就算只取64位里实际常用的48位虚拟地址页号36位页表项2的36次方约688亿项每项8字节就是550GB。一个进程光页表550GB直接宣告单级页表在64位下死刑。所以多级页表不是优化选项而是唯一出路。它的核心思想是页表本身也分页用一张页目录上级页表来索引下级页表而且让绝大多数下级页表项根本不用创建——因为一个进程实际用到的地址空间只是整个虚拟空间的一小段绝大部分页表项是空的。4.2 两级页表的地址切分实操以32位、4KB页面、两级页表为例。页号一共20位我们把它对半分高10位做页目录号低10位做页表内索引页内偏移还是12位。这样一级页目录有2的10次方1024项每项指向一个二级页表每个二级页表也有1024项。好处在哪一级页目录只需要一项4KB常驻即可二级页表按需创建。一个进程如果只用了几个MB地址空间可能只创建几个二级页表合计几十KB比单级4MB省了百倍。对于稀疏使用的地址空间这是降维打击。我们把地址转换流程拆一遍给定逻辑地址0x2F1A先拆成三段。二进制0010 1111 0001 1010从低位往上数低12位是偏移0xF1A接着10位是页表索引0010 1111 00即0xBC十进制188再往上的10位是页目录号剩下那部分0x00十进制0。先查页目录第0项拿到二级页表基址再查该二级页表第188项拿到页框号最后拼上偏移0xF1A得到物理地址。4.3 64位下为什么是四级甚至五级到了64位故事还要继续。以经典的x86-64架构为例48位虚拟地址、4KB页面。低12位是偏移剩下36位页号。36位怎么分x86-64把它分成四段9位分别是PGD页全局目录、PUD页上级目录、PMD页中间目录、PTE页表。每级512项正好一级页表一页4KB。512 × 8字节 4096字节又是二进制对齐的漂亮结果。为什么要四级因为每级页表都是按需分配的。一个普通进程实际用到的虚拟地址范围可能只有几十MB四级结构下只需要建立少数几个中间目录项稀疏度被利用到了极致。你如果拿四级的每一级去乘以512理论上能覆盖128TB虚拟空间但实际占用的页表内存可能只有几百KB。层级方案页号切分每级项数覆盖能力典型占用单级32位20位约104万4GB4MB连续两级32位10101024×10244GB按需几十KB起四级x86-649999512×4级256TB按需数百KB注意多级页表省内存但代价是一次地址转换要多次访存。四级页表理论最坏情况要访问5次内存4次查页表1次取数据这个开销必须靠TLB来补不然后果很严重。5. 快表TLB与访存开销让转换快到不拖后腿5.1 访存次数这道题必须先算明白分页机制最大的工程代价就是访存次数。最朴素的单级页表一次逻辑地址到物理地址的转换需要先访问内存查页表第1次访存拿到页框号后再访问内存读数据第2次访存。也就是说每访问一次数据内存就要被访问两次CPU执行速度直接砍半。这个问题在早期计算机上尤其致命因为内存比CPU慢得多。多级页表让这个问题雪上加霜四级页表理论上要5次访存。如果CPU每次取指、取数都要这样折腾性能没法看。所以真实系统必须引入快表TLBTranslation Lookaside Buffer。TLB是一个小而快的硬件缓存通常几十到几百项专门缓存最近用过的页号到页框号映射。它一般集成在CPU的MMU里访问速度接近寄存器和内存完全不是一个量级。5.2 TLB命中与失效的处理路径TLB的工作逻辑很直白。CPU给出逻辑地址后先拿页号去TLB里找。**命中Hit**的话几纳秒就拿到页框号直接把物理地址拼出来完全绕开内存里的页表这就是最快路径。**未命中Miss**的话才老老实实去内存里的页表逐级查查到之后要么继续如果页表项无效则触发缺页异常要么把这条映射写回TLB方便下次命中。这里有一个特别容易搞混的点TLB失效和缺页是两回事。TLB失效只是缓存没命中页表里可能有有效映射去内存查一次就好不涉及磁盘IO。而**缺页异常Page Fault**是页表项的有效位是0说明这个页面根本不在物理内存里得从磁盘换入开销大到以毫秒计。两者差了三四个数量级调试性能问题时必须分清楚到底是哪个。5.3 有效访问时间EAT的推导有效访问时间Effective Access Time是衡量分页性能的核心指标面试也爱考。设TLB命中率为pTLB访问时间假设可忽略或记为t内存访问时间为t_m。那么命中时访问时间 TLB时间 一次内存访问未命中时访问时间 TLB时间 多级页表访存时间 一次数据访存以单级页表、内存访问时间100ns、TLB命中率90%、TLB时间忽略为例EAT 0.9 × (100) 0.1 × (100 100) 90 20 110ns对比不用TLB的200ns快了将近一倍。如果命中率提到98%EAT 0.98 × 100 0.02 × 200 98 4 102ns可以看到命中率每提升一点EAT就明显下降这也是为什么TLB容量、替换算法、局部性优化这么被重视。真实程序的页访问局部性极强TLB命中率通常能到99%以上所以分页的性能代价在现实中基本可控。实操心得算EAT题的时候务必先数清楚未命中时到底要访存几次。单级页表未命中是查页表1次 取数1次 2次四级页表未命中就是查4级页表 取数 5次。这个数字错一个整个EAT就全错我在这上面栽过不止一次。6. 常见问题与排查技巧实录6.1 地址转换的高频错误速查表下面这张表是我在带人和做题过程中总结出来的高频翻车点几乎覆盖了90%的错误来源症状根本原因正确做法页号算成了小数忘了整除页号用整数除法偏移用取余物理地址算出来超出内存把页框号当成了字节地址页框号要乘以页面大小才是基址页表大小算小了漏乘页表项字节数项数 × 每项字节数别只算项数多级转换跳错层地址切分位数搞反高位是上级目录低位是下级索引把TLB失效当缺页混淆两种异常TLB失效查内存缺页要调磁盘偏移在转换后变了误以为偏移也参与映射偏移转换前后必须一致6.2 页面大小怎么选一场没有标准答案的权衡页面大小不是拍脑袋定的它是一组矛盾的平衡点。页面越小内部碎片越小最后一页平均浪费半页页越小浪费越少但页表项数越多、页表越大磁盘换入换出时每页的数据量也小IO效率低。页面越大页表越小、IO吞吐越高但内部碎片大而且一次换页搬动的数据多延迟高。4KB是几十年实践下来的折中值几乎成了工业默认。我实际接触过一些数据库和虚拟化场景会用2MB甚至1GB的大页Huge Page。原因很实在TLB条目数有限用4KB页面时TLB只能覆盖几MB地址空间很容易被打满换成2MB页同样数量的TLB条目能覆盖几百MB甚至上GB命中率直接起飞。所以大页在数据库、JVM、虚拟化这类内存访问密集的场景里非常常见。不过大页也有代价——灵活性差、可能造成更多内部碎片、配置起来相对麻烦用之前要评估。6.3 手算地址转换的避坑三步法最后分享一套我总结的三步法练熟了可以做到又快又准第一步确认参数。页面大小、逻辑地址位数、页表层级、每项字节数这四个参数先写在草稿纸上。页面大小决定偏移占几位逻辑地址位数减偏移位数就是页号总位数页号总位数除以层级就是每级索引位数。第二步切分地址。把逻辑地址转成二进制从低往高按位数切。低n位是偏移然后按级别从低到高切出各级索引。切的时候一层一层确认别跳级。第三步逐级查表再拼地址。从顶级目录开始查逐级拿到下一级基址最后拿到页框号页框号左移偏移位数或上偏移就是物理地址。这套流程看着啰嗦但实务中非常稳。我在调试内核态内存映射、排查大内存进程的地址问题时靠的就是这套逐步映射的思路。新手最容易犯的错是图快跳过参数确认直接套公式结果位数一切错后面全崩。宁可慢一步把位数数清楚也不要返工重算。还有一点值得强调逻辑地址和物理地址是两个独立的空间物理地址空间的大小由内存条决定逻辑地址空间的大小由地址位数决定两者没有必然的大小关系。一个32位系统逻辑空间是4GB物理内存可能只有2GB页面在磁盘和物理内存之间倒腾这也是虚拟内存的由来。理解这一点之后你再去看缺页处理、页面置换算法比如LRU、Clock就会有豁然开朗的感觉。分页的地址转换只是地基上面还盖着虚拟内存、页面置换这一整栋楼但这些楼能不能稳全看地基打得牢不牢。
RELATED READING

延伸阅读

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