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 在没有启用 LA57(5 级页表)的时候,用的是 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(用户态)的访问权限。此外还有一个 PXN(privileged execute-never)和 UXN(user 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 命中率到大页配置,整条链路清清楚楚。这也是我为什么建议内核学习者花时间把这一块啃透,性价比非常高。