资讯动态

深入理解Linux MMU:从页表翻译到缺页异常的完整指南

发布时间:2026/9/30 18:02:32 来源:尧图企业网站定制
1. 一次越界指针崩溃后我才认真研究MMU两三年前的一次线上事故让我对MMU彻底改观。当时一个C服务会在业务低峰期毫无征兆地崩溃core文件里每次异常地址都不一样有的指向0x7f...附近有的落在0x55...范围还有一次ptr居然是0。那几天我一直在“指针悬空、堆损坏”里打转直到用crash工具看了struct vm_area_struct和页表才意识到真正给我上了一课的是MMU一个段错误背后是CPU和Linux内核在页表上做了一整条决策链。没接触过底层的人常把MMU当成“虚拟地址转物理地址的查表器”。第一次看也许够用但当你追到内核page fault处理时就会发现MMU同时承担了三件事地址翻译、权限检查、隔离保护。它不是一个被动的翻译工具而是CPU和操作系统之间最底层的“合同执行者”。1.1 没有MMU的世界所有进程挤在同一块内存里可以先想想极端情况。假设CPU没有MMU程序里写ptr[0] 1ptr直接就是物理地址。这时候所有进程共享同一个物理内存空间你的进程写0x1000另一个进程的0x1000也被你改了内核代码和用户代码没有任何边界一个野指针就能把整个系统踩崩。早期8位/16位机或者部分MCU就是这么干的裸机开发大家心照不宣地靠“约定”避让内存区域但跑Linux这种多进程系统根本撑不住。MMU最朴素的贡献是给每个进程一张独立的“地址房间平面图”。进程A看到的0x400000进程B看到的0x400000在物理内存里是两套完全不同的页。进程A的野指针即便越界最坏也就落到自己地址空间里没有映射的洞上触发的是进程自己退出而不是把隔壁进程的数据写坏。就冲这一点现代操作系统的稳定性和安全性一大半都建立在MMU之上。1.2 MMU的三重身份翻译、隔离、权限过滤官方的说法是Memory Management Unit实际干活时可以拆成三层来看。翻译CPU发出的每个虚拟地址最终要被换算成物理内存里的某一地址。换算关系不是“地址-地址”的直线函数而是通过页表page table查出来的。查一次至少经过几级内存读取因此TLBTranslation Lookaside Buffer地址变换旁路缓冲才成为性能关键。隔离不同进程的页表根不同。只要内核在进程切换时换掉页表根进程A根本不知道进程B的存在更不可能访问到B的内存。权限过滤页表项里带着可读、可写、可执行、用户/内核态等位。CPU在每次内存访问时会拿当前特权级和访问类型去对比页表项里的权限位发现不匹配直接抛异常不再往物理内存方向走。在内核源码里这三层分别对应地址翻译相关的pagetable与TLB操作进程相关的mm_struct切换以及handle_mm_fault里的access_error检查。理解了这三件事再看Linux的MMU代码很多地方就不会觉得凌乱了。读到这你可能会问那Linux自身在MMU这件事上做了多少事答案是几乎所有事。CPU硬件只负责“按页表翻译和检查”而页表从哪来、怎么建、缺页怎么办、进程退出后怎么回收全是内核在管。所以“Linux的MMU”准确说是Linux在MMU硬件之上建立起来的一整套虚拟内存管理系统。2. 跟着地址走一趟PGD到PTE的完整翻译链路跳过Linux源码讲MMU是耍流氓。但直接贴源码又会把新人吓跑。我这里先把最核心的“地址拆分”讲透再回到代码里对号入座。2.1 多级页表为什么不是一张大表如果只做一层转换最直接的办法是每个进程一张巨大的数组按虚拟地址的每一个4KB页框记录它对应的物理页框号。x86_64虚拟地址空间用户区有128TB按4KB一页需要2^25个条目每条8字节就是256MiB。每个进程一张256MiB的表光建表就把内存吃光了。所以实际设计成多级。以常见的x86_64四级分页为例一个4KB页面被划分成5个区段PGDPage Global Directory索引PUDPage Upper Directory索引PMDPage Middle Directory索引PTEPage Table Entry索引页内偏移地址0xffff888012345678的拆分大概是这样的四级分页、未启用5-level时PGD index (addr 39) 0x1ff PUD index (addr 30) 0x1ff PMD index (addr 21) 0x1ff PTE index (addr 12) 0x1ff offset addr 0xfff每一级索引都是9位也就是512个条目。顶层的PGD只需要一张512条目的表每个进程占用4KB很多进程共享内核空间PGD里还有大量空表项不用的下级表压根不用分配。这才是多级页表能跑起来的核心按需分配。2.2 逐级寻址查表、命中、寻物理页CPU拿到虚拟地址后先从控制寄存器x86的CR3ARM64的TTBR0/TTBR1读出当前进程PGD的物理基址顺着地址拆分得到的索引一级一级往下查。第1步拿到PGD表基址用PGD索引定位到表项里面存放的是PUD表的物理地址。 第2步用PUD索引去PUD表里找得到PMD表物理地址。 第3步用PMD索引去PMD表里找得到PTE表物理地址。 第4步用PTE索引去PTE表里找得到最终的PTE它包含物理页帧号PFN和权限位。 第5步把物理页基址加上页内偏移得到最终的物理地址。每一步之间都隔着一次内存读取如果每次都这么查一条mov指令背后要付出四五次内存访问性能根本无法接受。所以CPU引入了TLB把最近用过的“虚拟页号→物理页框号权限”缓存在一个小型高速缓存里。TLB命中时地址翻译路径就压缩成一次硬件查表几乎不额外花时间。2.3 内核代码里的“五级页表”抽象如果你是4.15之后版本的内核源码读者会看到Linux通用的遍历路径是五级PGD、P4D、PUD、PMD、PTE。x86_64没开5-level分页时P4D这一级被折叠include/asm-generic/pgtable-nop4d.h表现在代码上就是宏定义把p4d_offset直接返回pgd项多出来的“级”在二进制和页表里并不存在。写内核模块遍历页表时我很建议直接用pgd_offset(mm, addr)、p4d_offset(pgd, addr)、pud_offset、pmd_offset、pte_offset_map一路套下去这样既可以跑在常规x86_64上将来遇到开启5-level或者某些奇葩架构时也不用重写。有一类坑经常踩直接按“四级”写死索引偏移交换到ARM64且开了52位地址空间的机器上就崩。另外要理解mm_struct-pgd。进程切换时switch_mm_irqs_off之类的路径会把新进程的pgd物理地址写进硬件寄存器x86的load_cr3则是把pgd所指向的页的物理地址加载到CR3。这也是“每个进程地址空间不同”在硬件层面唯一的标志物。一个值得记住的数字空闲进程的mm结构甚至是共享的init_mm内核线程不关联任何用户进程的mm但它依然需要内核页表。用ps -eLf看到的内核线程其访问地址时CR3里永远是内核页表因此它不能直接碰用户空间。这条边界也是MMU权限模型强加出来的。3. 页表项里的秘密Linux PTE与硬件PTE之间隔着一个抽象层很多人第一次读内核页表代码会惊讶于Linux不是直接搬硬件的PTE格式而是在上面包了一层抽象。这个设计不是多此一举——它让Linux跑在不同CPU上时核心逻辑可以保持一致。3.1 先看硬件PTE长什么样以x86_64的一个PTE8字节为例低12位全部是标志位高52位一般存物理页帧号或下张表的物理地址。关键标志位大致如下位名称作用bit 0Present页是否在内存中bit 1RW0只读1可写bit 2User0仅内核1用户也可访问bit 5Accessed硬件在访问时置1bit 6Dirty硬件在写入时置1bit 7PAT内存属性类型bit 8Global是否在进程切换时保留TLB项bit 63NX禁止执行ARM64的PTE大同小异也包含present、RW、user、dirty等标志但字段布局完全不同。Linux要做的是把架构无关的语义操作pte_present、pte_dirty、pte_wrprotect翻译成各架构下的位移和掩码组合。3.2 Linux PTE的软件位普通文档不告诉你的东西x86的PTE位9到位11是“软件可用位”。Linux会利用它们表示一些硬件不知道的软件状态比如_PAGE_SOFTW1、_PAGE_SOFTW2、_PAGE_SPECIAL。有些页在内存管理中被标记为“特殊页”如PTE映射的IO内存、rdma注册的页硬件跑得好好的但内核需要知道“这页不能走常规的LRU回收/写回路径”。遇到VM_IO或VM_PFNMAP的VMA时special标志或pte_file在旧内核里会起作用。更微妙的是PTE的dirty和access标志因为有延迟写deferred write的存在。硬件访问页时置位accessed/dirty但这些位塞在PTE里页被换出时如果不做处理就会丢。所以Linux在swap换出前会把dirty状态记进swap entry页读回后再从swap entry恢复PTE状态。整个过程在try_to_unmap、do_swap_page这些函数里完成。真正干起来琐碎得很但设计思路就一句话硬件位是易失的软件要负责保管好状态快照。3.3 大页在页表层面是如何“省位子”的现代Linux里THPTransparent HugePage和HugeTLB都改变了页表形状。一个2MB大页在4级页表里可以直接让PMD指向一个物理大页跳过PTE这一级。好处显而易见TLB能覆盖的面积直接扩大512倍。代价是页表项合并/拆分逻辑变复杂以及内存碎片管理变难。判断一个地址是否落在大页里最简单的办法是读/proc/pid/smaps或/proc/kpageflags。实际调优时我经常用THP的madvise模式而非always原因后面会展开THP虽然能降TLB miss但它带来的后台整理和折叠开销在某些延迟敏感服务里是灾难。两个页表级别需要提前记住PMD级别的大页PMD huge page通常是2MB对应thp。PUD级别的大页PUD huge page通常是1GB对应hugetlbfs里的1G页。3.4 页表的建立与销毁一页一页抠出来的开销每次进程mmap一段私有匿名内存内核一般只分配VMA并不直接分配页表。直到某个地址第一次被访问触发缺页异常才在handle_mm_fault里一路pgd_alloc、pud_alloc、pmd_alloc、pte_alloc把需要的页表建立起来。这就是我前面说的“按需分配”在代码里的具象化。页表本身是内核用GFP_PGTABLE标志申请的内存页。这里有个常见的误解以为页表页也会被整个换到swap里。实际上Linux的页表页通常是不可交换的一直驻留在内存但——当某一页PTE的所有条目都空了内核会把这页PTE页直接释放回伙伴系统。真正的“swap换出”发生在其映射的物理页上当一页被换出它的PTE会被改写成swap entry里面记录swap类型和offset而不是物理页号。页表回收同理。一个进程如果mmap了一大块地址但不访问PGD/PUD/PMD全为空内存开销仅仅是VMA本身和顶层页表。等到进程访问过又全部munmap内核会从最底下开始向上释放页表页避免遗留“僵尸页表”。实际项目里我见过一个很典型的反面案例有人为了性能在服务启动时一次性mmap(MAP_NORESERVE)一个大池子然后全量madvise(MADV_WILLNEED)结果把整个页表和物理页在启动期全部加载起停服务时TLB、page cache全部剧烈抖动反而比“懒加载”慢。这个教训后面一起总结。4. 缺页异常MMU最会说实话的时刻地址翻译是MMU的日常真正考验Linux设计的是异常路径。当CPU对某个虚拟地址的翻译发现页表不存在、标记不合适、或者权限不够就会触发page fault。此时硬件把出错地址、错误码、指令地址一股脑丢给内核内核再进入do_page_fault。4.1 先给异常分类别一头扎进错误处理Linux在x86_64的do_page_fault里会根据出错地址与当前进程地址空间的比较区分两种情况出错地址是否位于某个VMA内。如果根本不在直接判为非法访问给进程发SIGSEGV。如果在VMA内再看错误码里的写/执行/保护位决定接下来走哪条处理路径。error_code低位含义是调试时绕不开的bit含义01页存在0页不存在11写操作0读/执行21用户态访问0内核态31保留位异常41指令取指一个“用户态越权写已存在的只读页”典型错误码是error 7bit0bit1bit2都为1。看到7你不需要再看别的直接意识到是用户写一个只读映射。4.2 do_page_fault到handle_mm_fault的主链路VMA找到后内核调用handle_mm_fault(vma, address, flags)。这个函数会检查是不是大页然后逐级找页表如果PGD/PUD/PMD为空说明该段地址还没有页表申请对应页表页。如果PMD指向大页且没实际映射走do_huge_pmd_anonymous_page或do_huge_pmd_numa_page。如果PTE为空走do_anonymous_page分配一个匿名页。如果PTE存在但页不在内存swap entry走do_swap_page换回物理页。如果PTE本身在但权限不匹配走do_wp_page或do_numa_page。看到这里你应该明白了缺页不是“错了”而是“正常到货流程”。mmap后不立即分配物理页等真正读写时再分配这种延迟分配能让大量“映射了但不访问”的内存区域不占物理资源。4.3 写时复制COWfork之后第一次写的门票fork之后父子进程的私有无写共享页怎么处理教科书答案把PTE权限改成只读两个进程共享物理页谁先写谁触发page fault然后复制新页给自己。这一步在MMU层面实现得异常干净。具体发生的事如下fork复制mm但物理页引用计数增加PTE被置为只读并清掉dirty位。父子任一进程执行写指令TLB里还留着旧的可写状态也没关系——硬件会再检查PTE发现只读触发写保护错误。内核进入do_wp_page先判断这个页的映射计数mapcount。如果只有一个映射直接把PTE改回可写零拷贝如果多映射分配新页、copy数据、修改当前进程PTE到新页、原页mapcount减1。很多人问“为什么fork后用gdb单步第一次修改变量很慢”这就是COW缺页开销。一个进程里大量继承内存后首次写会密集中断到内核。这也是为什么Java等大量使用写时复制的大堆服务启动后头一段时间有明显毛刺。4.4 mmap的按需加载读文件也走缺页不只是匿名内存。mmap一个几百MB的文件后内核只是建立了VMA一个物理页都没读。真正读代码/数据时才在缺页异常里通过filemap_fault或ext4_filemap_fault等file system的fault回调把磁盘块读入page cache再建立PTE映射。所以大文件mmap启动快得惊人不代表数据已经在内存里它只是把成本摊到了后续访问。这跟read()系统调用“一次把文件内容读进用户缓冲”完全不同。理解这个就明白为什么顺序读大文件时mmap madvise(WILLNEED)可以预取而随机访问时频繁缺页会成为性能瓶颈。5. TLB管理别让每一次翻译都成为性能灾难5.1 TLB为什么不能用MMU里的普通Cache替代每次地址翻译要查多级页表CPU在各代产品里不断把页表缓存做得更大、更智能但它始终是个容量有限的Cache。普通数据Cache可以做到几十MBTLB却通常只有几百到几千个条目因为每个TLB条目必须保存虚拟页号、物理页号、权限、ASID/PCID等大量标志面积非常昂贵。内存越大工作集越大TLB miss越频繁。在默认4KB页的配置下一个服务要是实际活跃页数超过TLB容量每条指令都可能陷入硬件页表漫游page walk性能急剧下降。用perf stat -e dTLB-load-misses,dTLB-store-misses能看到具体数字。5.2 上下文切换与TLB失效ASID和PCID的降维打击最早的x86做法是每次切换CR3时强制冲刷TLB进程一多就全是性能欠账。后来硬件加入Process Context IDx86的PCIDARM的ASIDTLB条目可以打上“属于哪个进程”的标签。进程切走再切回来如果该进程的TLB条目没被强制失效还能继续命中。内核会启用PCIDE并维护cpu_tlbstate里的状态以及各部门掩码这算MMU高性能配置里非常重要的一环。ARM上对应的是ASIDAddress Space ID每个进程切换时可以复用。CPU会优先匹配ASID和虚拟页号只有两者都一致才认为TLB命中否则即使虚拟地址相同也不会用别的进程的条目。这也是隔离性的一个体现TLB也在做进程隔离。5.3 TLB Shootdown多核系统最痛的通信成本单核系统上改页表后冲刷本CPU的TLB即可。多核系统上进程可能同时跑在多个CPU上修改一个PTE后必须让所有运行了该进程的CPU都刷新对应TLB。x86上的实现是基于IPI的函数调用发起者遍历mm_cpumask给其它CPU发送中断等它们完成flush再返回。听起来很麻烦实际更麻烦。munmap、mprotect、fork后写保护、页迁移、CMA整理……都会触发大范围TLB失效。如果业务大量mprotect或频繁mmap/munmap会在多个核之间制造一长串IPI风暴。内核有一些批处理优化在tlb_gather_mmu到tlb_finish_mmu之间拖延flush时机、尽量合并多次失效但能合并的也就那么有限几次。真实生产里降低TLB shootdown最有效的手段是减少无意义的页表操作频率而不是指望内核魔法。5.4 实测手段用perf看TLB我排查过几次“CPU高但看不出热点”的问题最后都落在TLB miss上。一个简单的观测方法perf stat -e dTLB-load-misses,dTLB-store-misses,itlb_misses.walk_active -p pid sleep 5如果看到dTLB-load-misses占到Load指令比例很高基本可以怀疑“活跃页数超过了TLB容量”。常见的应对换大页hugetlbfs或THP让TLB覆盖面积变大。缩小进程工作集比如分片、批量处理。升级硬件支持更大的TLB条目如LAM、LPA扩展虽能缓解但工程上成本最高。6. 排查MMU问题的实战经验从pagemap到THP调优6.1 /proc/pid/pagemap把虚拟地址翻译成物理帧号调试驱动、RDMA、DPDK时经常想知道某个用户态虚拟地址对应物理页帧号。Linux的/proc/pid/pagemap就干这个。读取方法offset (vaddr 12) * 8 pread(fd, entry, 8, offset) # pfn取entry低55位bit63表示present得到的PFN乘以页大小就是物理地址。注意两点第一需要root能力内核比较新时还需打开/proc/sys/kernel/pagemap的权限开关第二不要拿它做实时性要求高的监控每次pread都要经过VMA查找频繁调用一样贵。有一次我们排查网卡DMA问题就是靠这个接口把用户态缓冲区对应的物理页锁定再对比网卡寄存器里的描述符地址定位到是驱动填写了错误的物理地址导致数据落到别人内存里。6.2 从dmesg的oops里读出MMU现场内核或用户态异常时dmesg里会出现经典Error Code信息。例如BUG: unable to handle page fault for address: ffff888013a26000 #PF: supervisor read access in kernel mode #PF: error_code(0x0000) - not-present pageerror_code为0表示该页根本不存在且是内核态读。此时别急着猜先把出错地址和当前进程mm的VMA范围对一下如果地址落在[vmalloc]区域多半是模块里访问了未初始化的指针如果地址很规整的0xffff8880...多半是对象释放后野指针复用。用户态段错误则常表现为segfault at 7f8abc000000 ip 0000555f12345678 sp 00007ffd... error 4error 4只读、用户态说明你写了一个只读映射把地址拿去cat /proc/pid/maps看对应区间基本能判断是不是往.rodata或只读mmap区域里写。6.3 透明大页的毛刺一次真实调优以前一个低延迟项目把/sys/kernel/mm/transparent_hugepage/enabled设成always结果高峰期偶尔出现几十毫秒的实例卡顿。用perf sched和dmesg都看不出明显原因直到开启THP的/sys/kernel/mm/transparent_hugepage/defrag日志采样才发现进程触发匿名页缺页时内核为了凑2MB大页去执行同步compaction当前线程被迫等待迁移页、回收页。这类问题的通用解法是把THP改成madvise模式只在明确知道要“大块连续访问、生命周期很长”的堆区调用madvise(MADV_HUGEPAGE)。对于生命周期短、随机访问的小内存区永远不要强求大页。真要用标准大页直接用hugetlbfs在启动时锁定好大页池进程通过mmap使用不要依赖自动折叠。6.4 其它几个MMU相关的工程建议避免大量小mmap每次mmap/munmap都要修改页表和TLB高频操作在SMP下的shootdown开销很大。正确做法是维护自己的内存池或者提前申请一大块区域再内部划分。留意madvise(MADV_DONTNEED)之后的页表它会丢PTE和物理页但不回收VMA若理解成“释放内存”会导致内存占用看起来不降。锁页要克制mlock能防止页面被换出也能阻止某些缺页抖动但把大量内存锁死会让kswapd压力变大甚至引发OOM。锁页之前先量化有多少内存是真正“锁了才有收益”的。这一年多里我现场排查过的问题里八成最后都能归结到一句话要么页表状态和业务预期不一致要么TLB失效频率被忽略了。MMU离业务代码看似很远可真出起问题来比业务逻辑难定位得多。所以我的习惯是每接触一个新服务先顺手看一眼/proc/pid/status的VmRSS、/proc/pid/smaps的大页分布再决定要不要为它做页表级别的调优。这比等线上挂了再抓oops成本低太多了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑