深入理解MMU:从虚拟地址到物理地址的完整转换链路

发布时间:2026/9/9 11:53:11
深入理解MMU:从虚拟地址到物理地址的完整转换链路 先搞清楚MMU 到底在解决什么问题很多人第一次听到 MMUMemory Management Unit内存管理单元这个词是在学 C 语言指针或者看操作系统教材的时候。当时就觉得这是个很高深的东西其实说白了它就是 CPU 内部负责“地址翻译”的一个硬件模块。你写的程序里访问的每一个内存地址都会经过它才能变成真正的物理内存地址。我用一句大白话概括MMU 就是 CPU 和物理内存之间的“翻译官”。没有这个翻译官你的程序直接往物理地址 0x1000 写数据旁边跑着的另一个程序也可能往同一个地方写两个程序就互相踩了。更麻烦的是每个程序编译的时候都不知道自己会被加载到内存的哪个位置操作系统也没法保证每次都把程序放在同一个地方。MMU 的出现就是为了解决这三个问题隔离、共享、灵活性。那虚拟地址到底是怎么一步一步变成物理地址的这条路径上都有哪些关键环节这篇文章我把整个链条完整拆开讲一遍。不管你是在学操作系统、做嵌入式开发、还是单纯被面试官问过这个问题都能在读完以后把这套机制理得明明白白。1. 为什么不能直接用物理地址—— MMU 存在的底层逻辑1.1 没有 MMU 的世界有多混乱假设你现在写了一个程序里面有一行代码int a 10;编译以后这个变量a会对应一个内存地址。如果没有 MMU这个地址就是物理地址也就是说你在代码里写死了“我要访问物理内存的第 N 个字节”。问题来了你的电脑上同时开着浏览器、编辑器、微信、音乐播放器每个进程都有成千上万个变量。如果大家都直接操作物理地址你怎么保证编辑器用到的地址跟微信用到的地址不冲突就算编译的时候能算好进程数量一变、内存占用一变这个布局就得重新来。而且更致命的是一个恶意程序完全可以直接读取另一个程序的内存数据——只要它知道对方的数据在哪个物理地址。这就是为什么现代操作系统必须引入虚拟地址的概念。每个进程看到的内存空间都是独立的、从 0 开始编址的“假地址”谁也碰不到谁。这就像一个酒店有 100 个房间每个客人手里拿的房卡上都写着“101”“102”但不同楼层的“101”其实是完全不同的物理房间——中间做映射的就是 MMU。1.2 虚拟地址的三重收益把地址“虚拟化”之后收获的东西比大多数人想象的要多得多。第一重收益是隔离。每个进程都有自己的虚拟地址空间我的进程访问 0x1000 和你的进程访问 0x1000在物理上完全是两码事。一个进程崩溃、越界、写坏了自己的地址空间操作系统可以精确地把这个进程“处决”掉而不会波及旁边毫不知情的其他进程。第二重收益是内存共享变得更加可控。你可能希望两个进程共享同一份数据比如动态库的代码段。没有 MMU 的情况下你得让两个进程约定好都用同一个物理地址这非常难受。有了 MMU你可以让两个进程的虚拟地址不同但在页表里映射到同一个物理页框——这样两个进程都能用自己的方式访问同一份物理内存互不干扰。第三重收益是“假装内存很大”。程序可以声明一个 4GB 的数组哪怕你的物理内存只有 1GB。因为虚拟地址空间里的映射是“按需”建立的——你真正访问到哪一页操作系统才把哪一页映射到物理内存。访问不到的部分虚拟地址可以指向磁盘上的交换分区swap。这一整套机制让程序员能够以“内存无限大”的假象来写代码极大地解放了生产力。1.3 MMU 不只是“查表翻译”很多资料把 MMU 简化成“查页表、换地址”这其实低估了它的职责。一个完整的 MMU 包含至少五部分功能页表遍历按照页表基地址寄存器如 x86 的 CR3找到页表一级一级往下查。TLBTranslation Lookaside Buffer查找在查页表之前先看缓存里有没有现成的翻译结果。TLB 是虚拟地址到物理地址的高效缓存性能关键。权限检查读、写、执行权限是否允许“用户态/内核态”是否匹配属性检查这个页是否可缓存是否写回write-back还是写透write-through是否可被 DMA 访问异常上报如果查找失败或者权限不够MMU 会触发一个异常如 page fault把控制权交还给操作系统处理。所以每次访问内存其实背后是一整套流水线在运作。直观一点理解你拿着门禁卡去刷一扇门系统先查你卡有没有权限TLB权限记录不完整再去后台数据库调记录页表遍历查到你没权限就报警把保安叫来异常处理。2. 核心机制拆解虚拟地址到物理地址的完整转换链路x86-64 为例2.1 地址翻译的总体流程这一节是全文的重头戏。以最主流、同时最典型的 x86-64 架构为例我先把完整链路写出来CPU 执行指令访问一个虚拟地址 VA。MMU 先把 VA 拆成几个部分PML4E 索引、PDPTE 索引、PDE 索引、PTE 索引、偏移量。先查 TLB如果命中直接拿到物理地址物理页框号 偏移量走人。如果 TLB 未命中MMU 从 CR3 寄存器读当前进程的页表基址然后一级一级查页表。查到最后一层 PTE取出物理页框号加上页内偏移得到最终的物理地址 PA。同时把翻译结果缓存在 TLB 里下次相同的虚拟地址直接命中。这一套流程看着不长但每一步都有很多值得深入了解的细节。2.2 虚拟地址的格式64 位里其实只用了 48 位很多人以为 x86-64 的虚拟地址是完整的 64 位其实不是。当前主流实现只使用了低 48 位。那么一个 48 位的虚拟地址怎么切分标准切法是低 12 位bit 0-11页内偏移4KB 页面$2^{12} 4096$ 字节bit 12-20PTE 索引共 9 位$2^9 512$ 项bit 21-29PDE 索引9 位bit 30-38PDPTE 索引9 位bit 39-47PML4E 索引9 位为什么要用 4 级页表而不是一级大数组这个问题我当年也困惑了很久。原因是如果用一级页表存所有 48 位地址的映射关系每一项 8 字节那页表本身就是 $2^{36} \times 8 512GB$——这比很多服务器的物理内存还大完全不可接受。而 4 级页表的每个层次只占一页4KB一个页表项 8 字节一页刚好存 512 项。如果某个中间层对应的下级页表完全没用那这层页表项直接置为空指针对应的那整个 1GB 空间就不需要分配任何页表真正做到了“按需分配”。这也是 Linux 里常说“虚拟内存大页表不一定大”的原因。2.3 页表项的组成不只是翻译地址还携带权限和属性页表项是一个 64 位的值每一层页表项的内容都有区别但核心字段是相似的。以最后一级的 PTE 为例它包含物理页框号PFN高 40 位左右指向真正的物理页。PPresent位该页是否在物理内存中。这一位是 0说明这个页不在内存——可能在磁盘交换区里也可能从来没被分配过。访问这种页面会触发 page fault。R/W 位可写/只读。U/S 位用户态是否可访问。AAccessed位页面是否被访问过操作系统定期清理该位用于页面置换算法。DDirty位页面是否被写过写过的页面换出时必须要写回磁盘。NXNo-Execute位这个页面不允许执行代码现代操作系统用它来做 DEP数据执行保护防止缓冲区溢出攻击。中间层页表项PML4E、PDPTE、PDE的结构类似但它们指向的不是物理页框而是下一级页表的基地址。另外 PDE 里有一个PSPage Size位如果 PS1说明这一项直接映射一个大页面2MB 或 1GB后面就不需要再查下级页表了。大页面的设计也很值得多说一句。2MB 的大页可以减少页表级数、降低 TLB miss 的概率。很多高性能数据库和高频交易系统就是靠这个把内存访问延迟压下去的。代价是分配内存的最小粒度变大了如果程序只用了几百 KB用 2MB 大页就是浪费。2.4 一个具体例子手动走一遍翻译过程光讲理论太空了我拿一个具体的数字带你走一遍。假设 4KB 页面模式下虚拟地址 VA 0x00007f123456789aCR3 0x100000指向当前进程的 PML4 页表基址第一步拆地址。0x00007f123456789a换算成二进制切成五段PML4E 索引 (VA 39) 0x1ffPDPTE 索引 (VA 30) 0x1ffPDE 索引 (VA 21) 0x1ffPTE 索引 (VA 12) 0x1ff页内偏移 VA 0xfff第二步读物理地址0x100000处的内存取第 PML4E 索引个表项。拿到这项以后检查它的 P 位是否为 1然后把它的物理页框号左移 12 位得到 PDPT 表的基地址。第三步在 PDPT 表里取第 PDPTE 索引个表项重复检查取下一级表基址。第四步在 PDT 表里取第 PDE 索引个表项。如果这一项的 PS 位是 1跳过第五步直接把这个项里的大页物理地址加上页内偏移就是最终物理地址。第五步在 PT 表里取第 PTE 索引个表项。检查 P 位如果为 1把物理页框号PFN左移 12 位加上页内偏移物理地址出炉。每查一层MMU 都会顺带检查权限位、NX 位、用户态/内核态标志。任何一处不通过就会直接中断当前指令进入 page fault 流程。2.5 Linux 中如何查看页表行为如果你用的是 Linux在/proc文件系统下有一些接口可以直接观察页表的状态做实验的时候非常直观。/proc/self/maps显示当前进程的虚拟地址空间布局每一段地址属于什么映射。/proc/self/pagemap这个文件里每一个 64 位条目记录了一个虚拟页对应的物理页框号和页表标志位需要 root 权限才能读取里面就藏着 P、A、D 这些位的实时状态。我自己做实验的时候经常写一个小程序分配一块内存写入数据然后去/proc/self/pagemap里翻它的物理地址。这个地址拿去跟cat /proc/iomem里报告的内存范围对照就能确认翻译是否合理。这个实验强烈推荐大家自己跟着做一遍比你盯着教材看十遍都管用。3. TLB 与多级页表的性能博弈翻译这件事不是免费的3.1 为什么需要 TLB查 4 级页表的代价不可忽视从上面的流程你可以看到一次虚拟地址翻译需要访问 4 次物理内存4 级页表各一次然后才能用拿到的物理地址真正去访问一次数据。也就是说一次看似普通的内存访问在最坏情况下实际要访问 5 次物理内存。这意味着如果每次都老老实实查页表程序的性能会慢到让人崩溃。TLB 就是解决这个问题的。它把“虚拟页号 → 物理页框号”的映射关系直接缓存起来。通常 TLB 有 64 到 1024 个表项但对绝大多数程序来说已经够用因为程序的局部性原理保证了它在同一段时间内反复访问的页面数量是有限的。我打一个比方查页表就像每次出门前都要翻一遍地图翻 4 次才找到目的地而 TLB 就是你手机里的导航软件存了你最常去的几个地方一输入地址就能直接给你路线不用重新翻地图。3.2 TLB 命中与失效时的行为差异当 TLB 命中的时候CPU 核心可以在一两个时钟周期内完成地址翻译。当 TLB 失效时MMU 需要走到内存里去查 4 级页表这个延迟通常在几十个纳秒如果是内存慢或者更糟——页不在内存里需要去磁盘读——那就是几毫秒甚至更多的开销。这其中的差距是百万数量级的。这里有一个很多人没注意到的关键细节上下文切换process context switch时 TLB 怎么办因为每个进程的虚拟地址空间是不同的A 进程地址 0x1000 和 B 进程地址 0x1000 对应的物理页完全不同。如果切换进程后还沿用旧的 TLB翻译出来的物理地址就是错的。x86 上有两种主流解决方案完全清空 TLB切换进程时把 TLB 所有条目作废。简单粗暴但代价是切换后的程序一开始会频繁 TLB miss性能下降。PCIDProcess-Context Identifier给每个进程一个 ID 写入 TLB 条目中地址翻译时同时检查 PCID 是否匹配。这样即使切换进程只要 TLB 容量够还能保留一部分其他进程的映射避免冷启动。现代操作系统基本都用 PCID 配合 ASIDAddress Space IdentifierARM 上的叫法来优化切换开销。3.3 大页Hugepage是怎么减少 TLB miss 的理解了大页的机制之后再看性能问题就非常清楚了。前面说的 2MB 大页虚拟地址的切分方式从“4 级页表”变成“PML4 → PDPT → PDT → 直接 2MB 页”少了 1 级翻译。更重要的是一个 2MB 的页对应的 TLB 条目覆盖的范围是普通 4KB 页的 512 倍。举个例子你的程序需要访问 2MB 的缓冲区。如果按 4KB 页面分就是 512 个页面、最多需要 512 条 TLB 条目。如果按 2MB 大页分只需要 1 条 TLB 条目。如果 TLB 容量是 64 条前者几乎会占据全部 TLB把其他数据全部挤出去后者只占 1 条剩余空间完全可以缓存其他热点映射。很多追求极致性能的项目比如数据库、Redis、JVM 大内存堆都会主动开启大页。Linux 下设置/sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages然后用mmap加MAP_HUGETLB标志映射就能使用 2MB 大页。实测中某些内存密集型的应用在大页模式下 TLB miss 率能降低一个数量级。4. 实战视角结合操作系统看地址翻译的完整生命周期4.1 从 malloc 到 page fault虚拟内存是“懒汉”现在我们把视角从硬件拉回到操作系统看看一个普通的malloc背后发生了什么。假设你在 C 语言里调用malloc(1024 * 1024)申请 1MB 内存。这个时候操作系统并不会真的给你找 1MB 物理内存。它只是在你进程的虚拟地址空间里划了 1MB 的区域标记成“可读可写”然后返回一个虚拟地址。这 1MB 对应的页表项是什么状态P 位通常为 0没有映射到任何物理页面。第一次往这片内存写数据时CPU 发出一条写指令带着虚拟地址去找 MMU。MMU 查 TLB、查页表发现这一页 P 位为 0于是触发page fault。CPU 进入内核态操作系统接管这个异常。内核的处理流程大概是从空闲物理内存列表里分配一个物理页框把页表项的 P 位置 1、填上物理页框号、设置读写权限和 A/D 位然后返回用户态重新执行那条触发异常的指令。这次 TLB 和页表都能查到写操作顺利完成。这个“用时才分配”的设计叫demand paging按需分页。它极大地降低了程序启动和内存分配的开销申请了大量内存但只用一点点物理内存就一点也不会浪费。4.2 缺页异常的分类不是所有 page fault 都一样page fault 其实分好几种处理路径完全不同。很多人只知道“缺页”但实际上一起来看至少有这四类缺页Major/Minor Fault页表项存在但 P 位为 0。Minor fault 是指这个页的内容其实还在物理内存里比如共享库的代码页已经被另一个进程加载过了那就直接把页表指过去不需要读磁盘。Major fault 是指内容在磁盘上比如可执行文件的代码段必须从磁盘读进来这是最慢的一种往往是程序卡顿的元凶。写保护违例页表项的 R/W 位是 0但程序尝试写。操作系统会判断这是 bug段错误还是“写时复制”机制的一部分——比如 fork 出来的子进程共享父进程的只读页第一次写的时候触发 COW内核复制一个新页再让子进程写。越界访问虚拟地址根本不在进程地址空间的任何映射区域内。这种直接导致 segmentation fault。栈溢出访问的地址在栈顶附近但栈还没扩展到那里。内核检测到这种情况会扩大栈映射。排查程序崩溃的时候用dmesg查看内核日志经常能看到类似segfault at 7f8a1c000000 ip 00007f8a1b123456 rsp 00007f8a1b0fef58 error 4的信息。这里的error字段包含了关键信息bit0 是 P 位0 表示缺页1 表示权限/保护违例bit1 表示是读还是写0 读、1 写bit2 表示是用户态还是内核态。学会读这个字段对定位崩溃原因非常有帮助。4.3 多进程视角页表切换与内核地址空间的巧妙设计每个进程都有自己的页表那页表是怎么切换的Linux 里切换到某个进程时内核会把进程页表基址加载到 CR3 寄存器。这之后所有地址翻译都用新页表。这里有个很精妙的设计用户空间和内核空间共享同一套页表。x86-64 把 48 位虚拟地址空间分成两半高 128GB 给内核用也就是0xffff800000000000以上的区域低 128GB 给用户用。切换到内核态时CR3 不换因为内核空间对应的页表项始终都在。这样每次系统调用、中断处理都不需要切换页表省掉了巨大的 TLB 开销。但这也带来一个问题页表里既有用户空间的映射又有内核空间的映射。现代 CPU 有KPTIKernel Page Table Isolation特性就是在用户态运行时把内核空间的映射从页表里拿走只在进入内核态的时候临时放回来。这是为了防御“熔毁”Meltdown一类侧信道攻击。启用 KPTI 后系统调用的成本会略高因为每次进内核都要切换页表。4.4 用 perf 实测 TLB miss感知性能差距聊了这么多理论和机制最后动手测一下才能有实感。Linux 下perf stat可以直接统计程序运行期间的 TLB miss 和执行指令数perf stat -e dTLB-loads,dTLB-load-misses,dTLB-stores,dTLB-store-misses ./my_program输出大致长这样103,456,821 dTLB-loads 2,345,678 dTLB-load-misses # 2.27% of all dTLB cache hits 56,789,012 dTLB-stores 1,234,567 dTLB-store-misses注意观察 TLB miss 率。如果 miss 率超过 5%说明程序的内存访问模式对 TLB 极不友好通常的优化思路是用大页Hugepage减少条目数量。调整数据布局让热数据集中在更少的页面上。改用更紧凑的数据结构比如数组代替链表、用struct缓存友好化。我自己优化的一个经验是写高并发中间件的时候把每个线程的核心数据结构都按 2MB 对齐配合大页分配器整体 QPS 能提升 5% 到 10%。听起来虚但实际你去看那一列的 TLB miss 曲线差异是很明显的。5. 常见问题与排查技巧实录5.1 为什么程序会报段错误Segmentation Fault段错误是最常见的跟 MMU 相关的问题。本质上就是因为访问的虚拟地址在页表里根本找不到对应映射或者权限不匹配MMU 触发了 page fault操作系统判定这是非法访问直接向进程发送 SIGSEGV 信号。排查段错误我的习惯是先做这三件事看dmesg最近的日志找segfault at ...的错误行它会给出发生的地址和指令指针。用 gdb 重建现场run之后bt打印调用栈通常能直接定位到出错的函数和行号。如果崩溃地址比较古怪比如0xdeadbeef附近大概率是野指针或者释放后再用。还有一类段错误是栈溢出导致的通常表现为访问了栈底以下的地址。此时 gdb 里看调用栈会发现栈帧一层套一层非常深或者递归函数没有终止条件。限制栈大小的ulimit -s直接影响你能开多深的递归。5.2 查看和修改内存映射的调试方法调试内存相关问题时有几个命令非常常用# 查看进程虚拟内存映射 cat /proc/pid/maps # 查看物理内存分区情况 cat /proc/iomem # 查看系统页表统计信息 cat /proc/vmstat | grep pgfault/proc/vmstat里的pgfault统计了系统启动以来所有 page fault 的次数单位是次不是页数。你可以运行一个任务前记录这个值任务结束再看差值就是任务触发的总缺页次数。结合time -v输出的Major (requiring I/O) page faults和Minor (reclaiming a frame) page faults能快速判断程序的性能瓶颈是不是缺页引起的。如果缺页太多优先考虑调整内存分配策略、复用内存池、减少频繁 malloc/free。5.3 大页配置后还是不生效检查这几个地方很多人在 Linux 上配置 Hugepage 后发现程序根本没有用到大页“明明设了 nr_hugepages 啊”。这个时候按顺序排查确认程序真的使用了MAP_HUGETLB或者madvise(MADV_HUGEPAGE)。光改系统参数不会让已有程序自动使用大页。检查/proc/meminfo里的HugePages_Total和HugePages_Free看看大页是否确实分配出来了。看HugePages_Rsvd和HugePages_Surp字段。如果Rsvd很大说明程序请求了但没用上可能是指定了错误的对齐方式。注意透明大页THP和手动大页是两个体系。THP 是内核自动把连续的大块物理内存合并成 2MB 页它的实现路径和手动MAP_HUGETLB不同在某些工作负载下反而会引入延迟不稳定。我之前踩过一个坑程序用了mmap映射大页但mmap的起始地址没有按 2MB 对齐内核干脆静默地回退到普通页整个性能优化白做了。后来加上了MAP_FIXED并手动指定 2MB 对齐的地址才真正用上大页。5.4 学会解读页表手写一个小工具查看 PTE 信息想深入理解页表强烈建议自己动手写一个读取/proc/self/pagemap的小程序。核心逻辑很简单#include stdio.h #include stdint.h #include fcntl.h #include unistd.h int main(void) { uint64_t va (uint64_t)main; int fd open(/proc/self/pagemap, O_RDONLY); if (fd 0) return 1; uint64_t entry 0; // 每个虚拟页对应一个 8 字节条目 off_t offset (va / 4096) * 8; pread(fd, entry, 8, offset); close(fd); uint64_t pfn entry ((1ULL 55) - 1); printf(虚拟地址: %p\n, (void *)va); printf(页表项: 0x%016llx\n, (unsigned long long)entry); printf(物理页框: 0x%llx\n, (unsigned long long)pfn); printf(物理地址: 0x%llx\n, (unsigned long long)(pfn * 4096 (va 0xfff))); return 0; }按 4KB 页面计算/proc/self/pagemap每个条目对应一个虚拟页的映射状态。读取时需要注意 PFN 位是否有效某些情况下页表项是 0说明对应的页还没被分配或者没有读取权限。这个工具是我学习页表时的“启蒙玩具”。把虚拟地址和物理地址打印出来再用 dmidecode 或者/proc/iomem去对比你会实实在在感受到虚拟地址和物理地址之间的差距比看一百遍概念都有用。编译时注意加_GNU_SOURCEpread的原型需要它。5.5 MMU 相关的几个经典面试题最后整理几个和 MMU 相关的高频面试/笔试问题给出简洁的答题思路虚拟地址空间为什么是 128T 而不是 64 位全量因为 x86-64 当前仅实现 48 位虚拟地址且未实现的位必须用符号扩展填充所以用户态空间是 $2^{47} 128T$内核态同样 128T中间剩下的区域是“非规范区”non-canonical无法直接使用。为什么用多级页表而不是一级页表省内存、按需分配。一级页表需要连续的大块物理内存多级页表允许不连续的页表页并且可以懒惰地去创建下级表。fork() 之后父子进程怎么共享页表采用了写时复制COW机制fork 后父子进程的页表项都指向同一批物理页但都标记为只读一旦某一方尝试写触发 page fault内核再复制物理页并重新映射。MMU 在哪个环节检查权限每一级页表项都会检查 P 位、U/S 位和 R/W 位最终 PTE 还会检查 NX 位。也就是说权限不是最后才看的而是逐层预检。虚拟地址翻译过程中谁负责物理内存访问如果是硬件页表遍历Hardware Page Table Walk由 MMU 直接访问物理内存读取页表项如果是软件遍历比如某些早期架构或 KPTI 路径则由内核代码主动读取。这些问题背后考察的都是同一套机制你搞清楚“翻译 缓存 异常 权限”这四个关键词遇到多少次面试都能从原理上答出来。写在最后的一点体会从硬件 MMU 的电路设计到操作系统里那个复杂而优雅的“按需分页”机制再到程序里一行普通的malloc其实是一条完整、自洽的链路。一开始我刚接触这个主题的时候前前后后看了不少书和文档总觉得页表就是“一张表”直到自己动手读了一次/proc/self/pagemap亲眼看到同一个进程里虚拟地址和物理地址的巨大差异才彻底搞明白整个机制的威力。虚拟内存在底层做的远不止“地址翻译”这一件事。它是隔离、安全、共享、性能优化、甚至进程管理这些现代系统能力共同的地基。如果你后续还要看 Linux 内存管理源码、做嵌入式系统移植、或者调优高性能服务这一套知识就是你绕不开的基础。最后再分享一个小技巧遇到地址相关的诡异问题先确认“程序用的地址是虚拟的”这个前提然后一层一层往下剥——看看 TLB、看看页表、看看 page fault——绝大多数问题都会在这一条链路里露出马脚。调试经验多了你会发现所谓“诡异的内存问题”八成都是对地址生命周期某个环节的理解有盲区。把这个盲区补上问题通常就迎刃而解了。