
1. 从虚拟地址到物理内存页表到底在解什么题我最早接触linux内核页表是在一次线上服务性能排查的场景里。当时一个内存密集型的服务在高峰期出现了明显的卡顿free 看内存还有不少余量但程序就是慢top 里 CPU 的 sy 占比一直居高不下。后来查了半天才发现问题出在大页配置缺失导致的 TLB 抖动上而不是简单的内存容量不足。从那时候起我就意识到页表不只是内核源码里一个抽象的数据结构它直接决定了一个进程怎么访问内存、访问得有多快、能隔离到什么程度。我们平时写的 C 代码里malloc返回的是一个虚拟地址程序读写的也是虚拟地址。CPU 拿到这个虚拟地址之后不能直接拿它去访问物理内存因为物理内存条上的地址是另外一套编号。把虚拟地址翻译成物理地址就是页表干的活。每个进程都有自己独立的一套页表所以进程 A 的地址 0x400000 和进程 B 的地址 0x400000实际指向的物理内存完全是两回事。这就是内存隔离的基础也是操作系统能同时跑几百个进程还互不干扰的底层保障。这篇内容适合正在学 linux 内核的人、做嵌入式开发需要移植内核的工程师、写驱动要搞清楚内存映射的底层开发者以及准备内核相关面试的人。我会从页表的基本概念、多级页表的数学设计、不同架构的差异一直讲到缺页异常的处理路径和实际排查工具尽量把这条链路讲透。2. 为什么不用一个大数组存映射关系非要搞多级页表2.1 一次性映射表的开销算给你看最直观的页表设计想法当然是开一个大数组数组的每个下标对应一个虚拟页号数组里存的是物理页帧号和权限位。这样虚拟地址到物理地址的翻译就是一次数组下标访问逻辑非常清楚。但只要你把数字算一遍就知道这个方案在 64 位系统上根本不可行。以最常见的 4KB 页、48 位虚拟地址空间为例。虚拟地址一共 48 位其中低 12 位是页内偏移剩下的 36 位是页号。这意味着需要 2 的 36 次方个页表项也就是大约 687 亿项。每个页表项即使只算 8 字节也需要 512GB 的内存来放一套页表。注意这是一套而已如果系统里跑 100 个进程那就是 50TB。现实的物理内存根本扛不住就算内存白菜价也扛不住。那 32 位时代为什么感觉页表没那么夸张32 位地址空间按 4KB 页分页号是 20 位也就是 100 万个页表项乘 4 字节是 4MB。一个进程一套 4MB 的页表在当时 1GB 内存的服务器上虽然有点浪费但还能接受。到了 64 位时代线性数组的思路就彻底死了必须引入多级结构。2.2 多级页表的本质是时间换空间的字典树多级页表的思路和字典树很类似。不再为整个地址空间一次性建表而是按需建。虚拟地址的高位先查第一级表拿到下一级表的物理地址再往下走直到最后一级才拿到真正的物理页帧号。这样绝大多数进程实际用到的内存范围是很小的只有用到的虚拟地址区域才需要往下挂表没用到的区域一级表项直接置空不分配下级表。以 x86_64 的四级页表为例48 位虚拟地址被分成 5 段9 位 PGD 索引、9 位 P4D 索引、9 位 PUD 索引、9 位 PMD 索引、9 位 PTE 索引低 12 位是页内偏移。每级表正好 512 项撑满一个 4KB 物理页。CR3 寄存器指向顶级页表 PGD 的物理地址每次进程切换的时候CR3 被换成新进程的 PGD 地址整个地址空间就跟着切换了。这样做的好处是一个刚 fork 出来的进程即使虚拟地址空间理论上巨大无比实际分配的页表内存也只有它实际用到的那部分。建一个空白页表需要的内存只有 4KB因为顶级表只分配一页里面的项全是空的。这和线性数组方案形成了非常鲜明的对比。2.3 四级缓存命中率是怎么算出来的多级页表的代价是一次地址翻译可能要访问 4 次内存比数组一次访问慢多了。但实际硬件用 TLB 把这个开销抵消掉了。TLB 是 CPU 内部的页表缓存翻译过一次的虚拟页到物理页的映射会被缓存起来。对于大多数程序来说工作集内被频繁访问的页就那么几十个到几百个TLB 命中率可以到 99% 以上。我做过一个粗略的估算假设 TLB 命中率为 99%一次 TLB 命中延迟约 1 纳秒未命中时需要 4 次内存访问每次约 100 纳秒。平均访存翻译开销就是 0.99 1 0.01 400大约是 4.99 纳秒。如果 TLB 命中率降到 90%平均开销就到了 40.9 纳秒直接差出 8 倍。这就是为什么我前面说线上服务卡顿最后查到大页配置缺失上——使用 2MB 大页之后TLB 能覆盖的内存范围大大增加命中断崖式改善。大页本质上也和多级页表有关。2MB 大页下PMD 这一级不再指向下一级 PTE 表而是直接指向一个 2MB 的物理页帧相当于少走了一级。TLB 的一个条目能映射 2MB而不是 4KB。1GB 巨页下PUD 这级直接指向 1GB 物理页TLB 覆盖范围就更大了。理解了这个机制你在配置 HugePages 的时候就不会只是机械地敲 echo 命令而是知道为什么它能让性能起飞。3. x86_64 与 ARM64 页表的关键差异以及 PTE 里每个位是干什么的3.1 两种处理器的页表层级折叠x86_64 在没有启用 LA575 级页表的时候用的是 4 级页表PGD、PUD、PMD、PTE。虽然内核源码里也有 P4D 这一级但在 4 级模式下它是一个虚级直接折叠到 PGD 上所以实际的页表遍历路径是 PGD → PUD → PMD → PTE。当你打开 CONFIG_X86_5LEVEL 并且 CPU 支持 LA57 时虚拟地址变成 57 位P4D 这一级才真正被展开变成 5 级。这个折叠机制是理解内核源码里那些p4d_offset()调用为什么有时候看起来多此一举的关键。ARM64 的情况有些不同。它通过 TCR_EL1 里的 T0SZ 字段来控制虚拟地址空间大小。当 T0SZ 配置成 48 位地址空间时同样使用 4 级页表配置成 39 位时PUD 这级会被折叠掉PGD 直接索引到 PMD配置成 36 位时PMD 也会被折叠只剩下两层配置成 42 位等中间态时对应的中间级也会折叠。ARM64 的 T0SZ 是一个 6 位字段IA 的取值范围是 16 到 52所以地址空间粒度是 2 的幂次倍变化。你改 TCR_EL1 的配置实际就是在告诉硬件页表有几级。3.2 PTE 项里那些标志位到底在管控什么不管是什么架构页表项本身都不只是一个物理地址还包含了一堆标志位。我以 x86_64 的 PTE 为例把关键位拆开讲。第 0 位是 Present 位。这一位为 0表示该页不在物理内存中。此时 CPU 访问该页就会触发缺页异常内核在异常处理里判断到底是从磁盘换入、是从文件系统读入还是进程访问了非法地址直接发 SIGSEGV。Present 位为 0 的时候页表项的其余位可以被内核自由使用比如在 swap 场景下高 52 位就用来记录 swap 条目号。第 1 位是 Read/Write 位控制页是否可写。第 2 位是 User/Supervisor 位控制页是否允许用户态访问。这两个位组合起来就是页级权限控制的基础。如果用户态程序试图写一个只读页CPU 会触发保护异常内核检查后要么做写时复制要么直接杀进程。第 3 位是 Page-Level Write-Through第 4 位是 Cache-Disable这两位控制缓存策略。第 5 位是 Accessed每次 CPU 访问该页时硬件会置 1。第 6 位是 Dirty对该页进行写操作时硬件会置 1。这两个位交给内核用在页面回收算法上——判断一个页到底能不能直接回收还是要先写回磁盘。第 7 位是 Page Size 标志x86 上叫 PS 位。如果该位为 1则当前页表项直接映射一个大页下一级页表就不存在了。第 8 位是 Global 位置 1 的页表项在 CR3 切换时不会被 TLB 刷掉常用于内核空间的全局映射比如内核镜像所在的页。这样做的好处是每次进程切换不至于把内核地址空间的 TLB 全部冲刷一遍对性能的影响是实打实的改善。在 Linux 内核里还有软件定义的位比如_PAGE_BIT_SOFTW1和_PAGE_BIT_SOFTW2这些位硬件不关心纯粹是内核自己约定含义。比如 x86 上_PAGE_BIT_SOFTW1常用来标记_PAGE_SPECIAL表示这个页是特殊的、不能走常规的页面回收路径。这些细节在写驱动或者调试内核问题的时候会用到。3.3 ARM64 的 PTE 标志位与 x86 的差异ARM64 的 PTE 也有一套类似的标志体系但格式是按 ARM 的规范来的。它的低 2 位用于区分页表项类型比如是不合法项、块描述符还是表描述符。第 1 位如果是页描述符则指向 4KB 或 16KB 的页面如果是块描述符则指向 2MB 或 1GB 的大页块类似 x86 的 PS 位。ARM64 的 PTE 里读写权限的控制方式和 x86 略有不同。x86 是 R/W 位和 U/S 位分开ARM64 则通过 AP 字段组合控制AP[2:1] 决定 EL1内核态和 EL0用户态的访问权限。此外还有一个 PXNprivileged execute-never和 UXNuser execute-never位分别控制特权模式和非特权模式的执行权限。这和 x86 的 NX 位不可执行异曲同工都是用来做执行权限隔离、防止代码注入攻击的。在页表遍历的硬件行为上x86 和 ARM64 也有差异。x86 的 MMU 会按照 CR3 指向的 PGD 一路向下走中间某一级找不到就触发缺页异常ARM64 的 MMU 则通过 TTBR0_EL1 和 TTBR1_EL1 区分用户空间和内核空间的页表基址。用户空间地址使用 TTBR0_EL1内核空间地址使用 TTBR1_EL1。这样在异常级别切换时硬件就不需要刷新整个 TLB因为两套地址空间的页表本身就是分开的。4. 缺页异常从 CPU 触发到内核处理的完整生命线4.1 缺页异常是怎么产生的当 CPU 执行一条访存指令MMU 开始翻译虚拟地址。如果在页表遍历的过程中某一级页表项的 Present 位为 0或者访问权限不满足比如内核态去读一个用户态页但权限位不允许MMU 就会触发一个异常。x86 上这个异常向量号是 14叫做 Page Fault。CPU 会自动把出错的虚拟地址放到 CR2 寄存器里同时把触发异常的错误码压到内核栈上。错误码里有几个关键位P 位表示是否是因为页不存在W/R 位表示是读还是写U/S 位表示是在用户态还是内核态访问。ARM64 的缺页异常处理入口是do_mem_abort出错地址存放在 FAR_EL1 寄存器里。异常类型不同处理的路径也不同——有翻译错误、权限错误、对齐错误等。内核把这些信息封装成struct fault_info的数组每个条目对应一种异常类型和对应的处理函数。这个抽象比 x86 的错误码要更结构化。4.2 内核处理缺页的核心路径以内核在 x86_64 上的处理路径为例。入口是do_page_fault它做的第一件事是读取 CR2 拿到出错的虚拟地址。接着调用find_vma在当前进程的地址空间mm_struct里查找包含这个地址的 VMA。这里有一段非常经典的逻辑。如果找不到对应的 VMA说明进程访问了一个完全没有映射关系的地址直接走bad_area给进程发送 SIGSEGV让进程自我了断。如果 VMA 存在但出错原因是内核态访问了一个用户态页而且属于内核的 bug那也直接 oops打印警告信息。如果 VMA 存在且访问是合理的内核就开始判断这次缺页应该走哪条处理分支。这里我用表格列一下最常见的几种情况场景触发原因内核处理路径匿名页首次访问进程堆或栈第一次触碰新页do_anonymous_page分配一个物理页并清零文件缺页映射的文件内容还没读入内存do_fault从文件系统读页写时复制fork 之后父子进程共享只读页一方写入do_wp_page复制物理页并重映射换出页访问页被换到 swap 分区do_swap_page从交换区换入用户态栈增长栈访问超过了当前的 vma 范围do_page_fault根据地址判断是否扩展栈区域4.3 写时复制缺页的完整现场还原我把写时复制这一个分支单独拿出来讲因为它是面试里出现频率最高、也最能检验你是否真懂页表的场景。fork 一个子进程时内核不会把父进程的所有物理页复制一遍而是把父子进程的页表都设置为只读并且物理页引用计数加一。这时候子进程的虚拟地址和父进程一样指向同一个物理页但页表项的写权限位是 0。当子进程修改这块内存时CPU 发现写权限位为 0触发保护异常进入页面错误处理。内核看到错误码里有写标志且该 PTE 是只读的就会进入do_wp_page路径。它会检查这个物理页的引用计数如果引用计数大于 1说明还有其他人共享这个页必须分配一个新物理页把旧页内容复制过去再把新页映射到当前进程的页表并加上写权限。如果引用计数等于 1说明已经没有人共享了直接在自己的页表上把写权限位加上就行连复制都省了。这个优化叫 lazy copy能大幅减少 fork 后无谓的内存拷贝。我实际调试过一个问题某次一个多线程服务频繁 fork 子进程内存占用看着不高但 CPU 的 sy 很高。后来查了 page fault 统计发现pgfault和pgmajfault都异常高。用perf top看热点排在前面的就是copy_page和do_wp_page。这就是典型的写时复制机制在大量触发优化方案是改用线程模型或者精简单子进程再初始化时的内存写入。理解了页表机制排查这种问题就能很快锁定方向。5. 实操用内核提供的工具观察页表活在当下的状态5.1 通过 pagemap 读取进程的页表映射内核为每个进程提供了一个/proc/{pid}/pagemap文件里面记录了进程每个虚拟页对应的物理页帧号和页表标志位。这个文件以二进制方式暴露给 root 用户通常的读取方法是按虚拟页号做偏移每个映射条目占 8 字节。解析时需要手工处理标志位第 63 位是页面是否在内存中第 0 到 54 位是物理页帧号 PFN。我自己写过一个简单脚本把一个进程的堆区虚拟地址解析成物理地址确认和页表预期一致。不过要提醒一句生产环境强制开启内核地址随机化或者使用容器时pagemap 中某些 PFN 字段会被掩盖为 0这是安全设计。做实验的时候用本机测试环境最稳妥。如果你不想手写二进制解析内核源码的 tools/vm 目录下有个page-types工具可以用来遍历系统中的页表项统计每个页的标志位分布。这个工具是排查页面类型问题的利器比如你想知道系统里有多少页是匿名页、多少是文件页、多少被标记为 unevictable一条命令就能看到全局统计。5.2 一个真实的页表调试案例我碰到过一个问题某个程序在启动阶段大量访问内存但性能远低于预期。用perf stat -e dTLB-loads,dTLB-load-misses,itlb_misses统计发现 dTLB load misses 占 dTLB loads 的比例超过了 5%对内存密集程序来说已经很高。进一步用page-types查看发现进程的映射都是 4KB 小页没有任何大页。然后我做了两件事。第一给这个程序配置了 2MB 的 HugePages用hugetlbfs挂载一个大页文件系统改代码让关键内存区域通过mmap映射到大页上。第二把内核的透明大页模式从madvise改成always让 THP 有机会自动为大块匿名内存分配 2MB 大页。最终 dTLB misses 的比例从 5% 降到 0.3% 左右程序运行时间缩短了接近 20%。这个收益就来自于多级页表层级减少带来的 TLB 覆盖能力提升。需要补充一点THP 不是万能的它对某些写入密集型程序可能会引入额外的延迟因为分配 2MB 页比分配 4KB 页耗时更长而且碎片化严重时可能失败。所以生产环境里我一般推荐按需使用madvise或者干脆手动配置 HugePages而不是粗暴地把always打开否则可能得不偿失。5.3 用内核源码里的 helper 检查页表结构写内核模块的时候我们经常要直接操作页表项。内核提供了一组标准的 helper 函数路径在 arch/x86/include/asm/pgtable.h 和 arch/arm64/include/asm/pgtable.h。最常用的是follow_page()给定mm_struct、虚拟地址和访问标志它会返回对应的struct page指针。还有get_user_pages()可以在内核态把用户空间的虚拟地址映射成一组物理页并增加引用计数防止它们在操作期间被换出。我调试过一个驱动问题用户态程序通过 mmap 映射了一段 DMA 缓冲区到内核驱动里用follow_page去查物理页地址却返回了 NULL。最后定位发现这段缓冲区对应的 VMA 设置了VM_IO或者VM_PFNMAP标志这类页不按常规的 struct page 管理follow_page会拒绝返回。解决方法是改用remap_pfn_range或者检查 VMA 标志后特殊处理。这个坑如果没踩过翻内核文档也是找不到答案的。6. 页表相关的性能调试心得和面试高频问题6.1 系统级调试时我常用的三招第一招先看/proc/vmstat里的 pfault 类字段。pgfault记录总缺页数pgmajfault记录需要磁盘 I/O 的严重缺页数。如果pgmajfault增长过快说明内存不足页面在反复换入换出性能必然受影响。这比直接看 free 判断内存够不够更接近真相。第二招用perf stat统计 TLB miss 数据。x86 平台上的事件名因具体 CPU 而异但现代 Intel 和 AMD 都有 dTLB-load-misses、dTLB-store-misses 这些通用事件。观察它们占访问总数的比例如果持续超过 1%就值得检查页表层级了。第三方工具如topplot和ocperf还能把原始事件翻译成人类可读的形式。第三招看/proc/buddyinfo判断物理内存碎片化程度。大页分配失败往往因为连续的空闲页块不足。如果低阶页块空闲很多但高阶页块空闲很少说明内存碎片化严重可以启用内存规整或者使用echo 1 /proc/sys/vm/compact_memory手动触发一次规整。配合 hugetlb 的预留页设置可以在系统启动阶段就预留大页避免运行时分配失败。6.2 面试里页表题目的答法我在面试别人内核岗位的时候通常会把页表相关的题目分成三个层次。第一层问概念比如多级页表为什么存在页表项里有哪些关键位。这一层只要认真读过材料都能答上。第二层问机制联动比如fork 之后父子进程内存怎么变缺页异常怎么区分交换页和文件页。这一层考察的是你对整个内存管理子系统的串联理解。第三层就是开放问题比如一个程序频繁触发 COW怎么用工具确认原因并优化。这一层考察的是实战排障能力需要真的在系统里处理过类似问题才能答得漂亮。如果准备面试我建议除了概念之外一定要把do_page_fault的处理流程画一遍把find_vma、handle_mm_fault、do_anonymous_page、do_wp_page这几个关键函数的调用关系理清楚。然后配合实验验证比如在用户态代码里触发一次匿名页缺页在系统里用perf抓到对应的 fault 事件你就能把理论和实践对上号。我面试的时候最看重的就是这个能不能对上号。根据我个人经验页表不是一个孤立的知识点它是 CPU 架构、内存管理子系统、设备驱动和性能调优的交汇点。真正理解页表之后再回头看那些内存相关的性能问题你会发现自己有了一个全新的坐标系从虚拟地址到物理页帧号从权限位到缺页异常从 TLB 命中率到大页配置整条链路清清楚楚。这也是我为什么建议内核学习者花时间把这一块啃透性价比非常高。