1. 从一个让人抓狂的缺页异常说起很多做底层开发的朋友第一次接触MMU大概率不是在什么高大上的架构评审会上而是在一个深夜——系统跑着跑着突然抛出一个缺页异常日志里一堆十六进制地址你盯着屏幕完全不知道从哪下手。我当年也是这样明明代码逻辑没问题指针也没越界可程序就是崩在某个莫名其妙的地址上。后来才慢慢意识到问题根本不在代码本身而在于我对MMU二级页表这套机制的理解一直是模糊的。MMU也就是内存管理单元是CPU内部负责地址翻译的硬件模块。它做的事情说起来很简单把程序看到的虚拟地址翻译成物理内存真正认识的物理地址。但真正让这套机制变得复杂的是它采用了多级页表的结构而在绝大多数主流架构上这个多级就是两级。为什么是两级而不是一级为什么不是三级二级页表到底怎么拆分地址、怎么查表、怎么省内存这些问题如果只停留在知道有这么回事的层面一旦遇到性能问题或者内存异常你根本无从下手。这篇内容我打算把MMU二级页表从硬件原理到软件操作完整拆一遍。适合谁看如果你写过驱动、做过操作系统课程实验、调过内存相关的性能问题或者单纯对虚拟地址到底怎么变成物理地址这件事好奇那这篇就是写给你的。我会尽量用生活化的类比把硬件行为讲清楚同时给出可以实际动手验证的步骤和参数计算过程让你不只是看懂而是能自己算、自己查、自己调。在正式开始之前先明确一个前提不同架构的页表级数、页大小、地址位宽都不一样但二级页表的核心思想是相通的。我会以最常见的两级结构为主线来讲涉及具体参数时说明清楚假设条件你迁移到自己平台时按同样的逻辑换算即可。2. 为什么一级页表会被淘汰从内存浪费说起2.1 一级页表的理想与现实的差距要理解二级页表存在的意义得先搞清楚一级页表为什么不够用。假设我们有一个32位地址空间的系统页大小是4KB那么整个虚拟地址空间被切成了2的20次方也就是大约一百万个页。如果只用一级页表那就需要一百万个页表项每个页表项假设占4个字节光页表本身就要占4MB内存。4MB听起来好像还能接受问题在于这4MB必须是连续的物理内存而且不管你的进程实际用了多少内存这4MB都得老老实实待在那里。一个进程可能只用了几个MB的物理内存但为了管理它系统先得掏出4MB来放页表。如果系统里跑着几十个进程每个进程都来一份4MB的页表内存还没开始干活就被页表吃掉一大半。这在内存宝贵的年代是致命的浪费。更麻烦的是一级页表要求连续。物理内存经过一段时间运行后本来就碎片化了想找一块连续的4MB越来越难。所以一级页表在工程上几乎不可行不是理论不行是实际代价太高。2.2 二级页表用分而治之解决浪费二级页表的思路非常朴素既然一次性分配一大块连续内存很浪费那我就把它拆成两层按需分配。还是用32位、4KB页大小的例子把虚拟地址拆成三段前10位做一级索引中间10位做二级索引最后12位做页内偏移。一级页表这时候只需要2的10次方也就是1024个页表项。每个页表项指向一个二级页表。二级页表同样有1024个页表项每个页表项最终指向一个物理页框。关键在于一级页表必须常驻但它只有1024项占4KB二级页表则按需创建进程用到哪块虚拟地址才为对应的那一项分配二级页表。这里有个数字很能说明问题。一个进程如果只用了低地址的几MB空间那它可能只需要一两个二级页表就够了总共占用也就几KB到十几KB而不是一级页表方案里的4MB。省下来的内存是实打实的。这就是二级页表最核心的价值用时间换空间用多一次查表换来内存利用率的大幅提升。2.3 地址拆分的具体计算过程很多人看资料时对10位、10位、12位这种拆分一知半解我把它算一遍你就清楚了。32位地址页大小4KB4KB等于2的12次方所以页内偏移占12位这部分不用查表直接拼在物理地址末尾。剩下的20位用来做页表索引。如果只用一级这20位直接索引一百万个页表项。用二级的话把20位对半分前10位和后10位各管一级。前10位能表示0到1023正好索引一级页表的1024个项后10位同样表示0到1023索引某个二级页表里的1024个项。所以一个虚拟地址的翻译路径是先取高10位找到一级页表项从里面拿到二级页表的基地址再取中间10位找到二级页表项从里面拿到物理页框的基地址最后把物理页框基地址和低12位偏移拼起来得到最终物理地址。整个过程硬件自动完成软件只需要维护好这两级表的内容。注意这里假设的是32位地址、4KB页、每项4字节的经典配置。如果你用的是64位系统或者更大的页位数拆分完全不同但高位索引一级、中位索引二级、低位做偏移这个逻辑不变。3. 二级页表查表的完整硬件流程3.1 从虚拟地址到物理地址的每一步我们把查表过程拆成具体的步骤这样你在调试时能对应上硬件行为。假设CPU要访问虚拟地址0x12345678页大小4KB两级页表各10位索引。第一步拆分地址。0x12345678写成二进制高10位是0x048中间10位是0x345低12位是0x678。注意这里的数值只是示意实际拆分要按位操作不能直接取十六进制位。第二步查一级页表。CPU里有个寄存器保存着一级页表的基地址通常叫页表基址寄存器。用高10位0x048乘以页表项大小4得到偏移加上基地址就定位到对应的一级页表项。读出这个页表项的内容它包含二级页表的物理基地址以及一些权限和状态标志位。第三步查二级页表。用中间10位0x345乘以4加上刚才拿到的二级页表基地址定位到二级页表项。读出内容得到物理页框的基地址同样带着权限和状态位。第四步拼接物理地址。把物理页框基地址和低12位偏移0x678拼起来就是最终的物理地址。硬件拿这个地址去访问内存。整个过程涉及两次内存访问查两级页表加上最后的数据访问一共三次。这就是为什么二级页表比一级慢——多了一次查表。但现代CPU都有TLB也就是地址翻译缓存把最近用过的翻译结果缓存起来绝大多数访问都能命中TLB根本不用走完整的查表流程。所以实际性能损失远没有理论上的多一次内存访问那么夸张。3.2 页表项里到底存了什么页表项不是只存一个地址那么简单。一个典型的页表项包含物理基地址、有效位、读写权限位、用户/内核权限位、访问位、脏位、缓存控制位等。这些位各有用途理解它们对排查问题很关键。有效位表示这个页表项是否有效。如果CPU访问一个有效位为0的页表项就会触发缺页异常操作系统接管后决定是分配新页、从磁盘换入还是直接报错终止进程。读写权限位控制这个页能不能写用户权限位控制用户态能不能访问。访问位和脏位主要给操作系统做页面置换算法用硬件在访问或写入时自动置位。我踩过的一个坑是手动构造页表做实验时忘了设置有效位结果一访问就缺页查了半天以为是地址算错了其实是标志位没配对。所以调试页表问题时先把页表项的标志位打印出来看一遍能省很多时间。3.3 TLB在中间扮演的角色TLB是理解二级页表性能的关键。它本质上是一个小容量的相联存储器存放最近使用的虚拟页到物理页框的映射。CPU每次访问虚拟地址先查TLB命中就直接拿到物理地址完全跳过两级页表。只有TLB未命中时才走完整的查表流程然后把结果填回TLB。TLB容量有限通常几十到几百项。当工作集超过TLB容量就会频繁未命中性能明显下降。这也是为什么有时候代码逻辑没变只是数据访问模式改了性能就天差地别——因为访问模式影响了TLB命中率。做性能优化时让相关数据在虚拟地址上尽量集中能显著提升TLB命中率这个技巧比很多微优化都管用。4. 亲手操作页表从观察到修改4.1 在操作系统里观察当前进程的页表光看原理不够得动手看真实数据。在Linux环境下你可以通过/proc文件系统观察进程的内存映射。比如查看某个进程的maps文件能看到它的虚拟地址区间和权限。但这只是高层视图看不到页表项本身。想看页表项可以用一些调试工具或者自己写内核模块。更简单的办法是在支持硬件调试的平台上用调试器直接读页表基址寄存器然后按地址拆分逻辑手动计算页表项位置读出内容。这个过程能让你对地址怎么拆、表怎么查有肌肉记忆。我建议的练习方式是写一个简单的用户态程序申请一块内存打印出虚拟地址然后通过调试手段找到对应的物理地址再手动走一遍两级查表流程验证你算出来的物理地址和实际一致。这个练习做一遍比看十篇文章都管用。4.2 手动构造一个最小页表如果你在做操作系统实验或者裸机开发可能需要自己构造页表。核心步骤是分配一块对齐的内存作为一级页表清零为需要映射的虚拟地址区间分配二级页表把二级页表的物理地址填进对应的一级页表项并设置有效位和权限在二级页表项里填入物理页框地址和标志位最后把一级页表基地址写入页表基址寄存器并刷新TLB。这里有几个容易出错的点。第一页表本身必须按页对齐因为硬件用基地址加偏移定位低12位会被忽略不对齐就会指向错误位置。第二填完页表一定要刷新TLB否则CPU可能还在用旧的缓存映射。第三权限位要匹配你的访问模式内核态访问的页和用户态访问的页权限设置不同。4.3 用代码验证地址翻译下面这段伪代码展示了软件层面模拟二级查表的过程帮你把逻辑固化下来// 假设页大小4KB页表项4字节两级各10位索引 #define PAGE_SHIFT 12 #define INDEX_MASK 0x3FF // 从虚拟地址提取各级索引 unsigned int vaddr 0x12345678; unsigned int l1_index (vaddr 22) INDEX_MASK; unsigned int l2_index (vaddr 12) INDEX_MASK; unsigned int offset vaddr 0xFFF; // 查一级页表拿到二级页表基地址 unsigned int l2_base l1_table[l1_index] 0xFFFFF000; // 查二级页表拿到物理页框基地址 unsigned int paddr_base l2_table[l2_base l2_index] 0xFFFFF000; // 拼接最终物理地址 unsigned int paddr paddr_base | offset;这段代码里掩码0xFFFFF000是为了去掉低12位的标志位只保留地址部分。实际硬件行为比这复杂但核心逻辑就是这样。你可以把这段代码跑一遍对照调试器里的真实翻译结果验证自己的理解。5. 那些文档不会告诉你的实战经验5.1 页表对齐问题导致的诡异崩溃页表基地址必须页对齐这是硬性要求。我见过一个案例开发者用malloc分配页表内存没做对齐处理结果页表基地址低12位不是0硬件查表时直接跑飞。崩溃地址看起来毫无规律因为硬件把基地址的低位当成了偏移的一部分。解决办法是用对齐分配函数或者手动向上取整到页边界。这个坑很隐蔽因为malloc返回的地址大多数时候碰巧对齐偶尔不对齐就出问题让人误以为是随机故障。5.2 权限位配错引发的安全异常二级页表项的权限位控制着访问行为。如果内核态代码访问了一个只允许用户态访问的页或者反过来都会触发异常。更麻烦的是有些架构的权限位是两级叠加的一级页表项和二级页表项都有权限控制最终权限是两者共同决定的。我曾经因为只改了一级的权限忘了二级也有权限位导致修改不生效排查了很久。记住改权限要两级一起看。5.3 大页映射对二级页表的影响很多架构支持大页比如2MB或1GB的页。用大页时页表结构会简化可能只需要一级页表就够了因为一个大页覆盖的范围大不需要那么多二级页表。但大页也有代价内存内部碎片增加分配不灵活。什么时候用大页当你的应用需要大块连续内存且对TLB命中率敏感时大页能显著减少TLB项占用提升性能。但如果是零散的小内存分配大页反而浪费。这个取舍要根据实际负载来定。5.4 多核环境下的TLB一致性多核系统里每个核有自己的TLB。当你修改了页表比如取消某个映射必须通知其他核刷新对应的TLB项否则其他核可能还在用旧映射访问已经失效的地址。这个操作通常叫TLB shootdown代价不小。所以频繁修改页表映射的应用性能会受影响。设计时尽量批量修改减少跨核通知次数。6. 从二级页表延伸出去的知识点6.1 三级、四级页表为什么会出现64位地址空间太大两级页表不够用了。以48位虚拟地址为例如果还用两级每级索引位数太多页表项数量爆炸。所以出现了三级、四级甚至五级页表把地址拆得更细每级页表更小按需分配的粒度更细。但级数越多查表越慢TLB未命中代价越大。所以现代CPU拼命加大TLB就是为了掩盖多级查表的延迟。理解了二级页表多级页表只是多套几层逻辑完全一样。6.2 页表与内存保护的关系页表不只是地址翻译工具它还是内存保护的基础。通过设置页表项的权限位操作系统能实现代码段只读、数据段可读写、内核空间用户态不可访问等保护机制。进程隔离本质上就是每个进程有独立的页表虚拟地址相同但映射到不同物理页互相看不见。理解这一点你对操作系统安全机制的理解会上一个台阶。6.3 页表遍历的性能优化思路页表遍历是内存访问的关键路径优化手段主要有几个方向。一是提高TLB命中率通过数据布局优化让访问集中。二是使用大页减少页表层级和TLB项占用。三是预取页表项硬件或软件提前把可能用到的页表项加载进缓存。四是减少页表修改频率避免频繁TLB刷新。这些手段在实际系统中往往是组合使用的具体选哪种要看瓶颈在哪。我在实际调优中发现很多时候性能问题不是页表本身慢而是TLB未命中导致的连锁反应。用性能计数器看一下TLB未命中率如果明显偏高优先考虑数据布局和大页而不是去优化页表遍历代码——因为遍历是硬件做的你改不了能改的是访问模式。7. 我踩过的几个典型坑和排查思路第一个坑是地址拆分算错。有次调试时发现翻译出来的物理地址总是偏一个固定值查了半天发现是我把中间10位的起始位置算错了应该是从第12位开始我算成了从第10位开始。这种错误用打印二进制的方式最容易发现把虚拟地址和各级索引都打成二进制一眼就能看出拆分对不对。第二个坑是忘了刷新TLB。修改页表后没刷新CPU继续用旧映射现象是修改不生效。排查方法是修改后主动刷新TLB再验证如果刷新后生效说明就是缓存问题。这个坑在多核环境下更常见因为还要考虑其他核的TLB。第三个坑是页表内存被回收。手动管理页表时如果页表内存被意外释放或覆盖查表就会读到垃圾数据行为完全不可预测。解决办法是把页表内存标记为保留不让通用分配器碰它。这个在裸机或内核开发中要特别注意。排查页表问题的通用思路是先确认地址拆分对不对再确认页表项内容对不对然后确认TLB状态最后确认权限位。按这个顺序走大部分问题都能定位到。
阅读完成 · 觉得有帮助?