1. 问题现场还原与初步判断
1.1 从一行内核报错说起
那是一个再普通不过的下午,测试同学在群里甩了一张截图,上面赫然写着:
Unable to handle kernel paging request at virtual address ffff800012345678紧接着下面还有一行更让人心里一紧的:
Unable to handle page fault for address: ffff800012345678做过内核开发的人看到这两行字,血压基本都会往上走一点。这不是应用层那种“段错误,打印个堆栈就完事”的问题,这是内核在访问一个虚拟地址时,页表里找不到对应的映射,或者映射存在但权限不对,导致 CPU 直接触发了一个 page fault,而内核又没能把这个 fault 处理掉。
我当时的第一个反应是:这个地址落在哪个区域?因为ffff8000开头的地址在 ARM64 架构上通常属于内核线性映射区或者 vmalloc 区,具体落在哪里,直接决定了排查方向。如果是线性映射区(direct map),那大概率是物理内存管理出了问题;如果是 vmalloc 区,那就要重点看 vmalloc 的分配和释放逻辑。
1.2 为什么 page fault 在内核里是“大事”
先给不太熟悉内核的朋友补个背景。用户态的 page fault 是家常便饭,缺页了就从磁盘换入,写时复制就分配新页,内核有一套完整的缺页异常处理机制。但内核态的 page fault 完全是另一回事——内核代码运行在特权级,它访问的地址理论上都应该是已经映射好的、有效的地址。一旦内核触发 page fault,要么是代码有 bug 访问了非法地址,要么是内存管理子系统出了问题,比如页表被意外修改、vmalloc 区域被提前释放、或者硬件层面的问题。
更麻烦的是,内核 page fault 的处理路径非常短,它不像用户态那样可以慢慢悠悠地去磁盘读数据。内核的缺页处理程序(do_page_fault)在判断这个 fault 发生在内核态之后,基本只有两种选择:要么是 vmalloc 区域的延迟填充(这个后面会细说),要么就直接报错并触发 oops。所以看到 “Unable to handle page fault” 这行字,基本可以断定:内核在访问一个它认为应该有效、但实际上无效的地址。
1.3 现场信息收集清单
遇到这种问题,第一件事不是急着去翻代码,而是把现场信息收集全。我当时的清单是这样的:
- 完整的 dmesg 日志:不能只看截图那两行,要看 fault 前后的上下文,包括 fault 发生时正在执行的函数、寄存器状态、栈回溯。
- 内核版本和配置:
uname -a和/boot/config-xxx,确认是不是特定版本才有的问题,以及 vmalloc 相关配置(比如CONFIG_VMAP_STACK)是否开启。 - 复现步骤:是必现还是偶现?是压力测试才出还是正常流程就出?这个信息决定了排查的优先级。
- 硬件平台信息:ARM64 还是 x86?物理内存多大?有没有特殊的外设 DMA 操作?
这些信息看起来琐碎,但每一条都可能成为破案的关键。比如CONFIG_VMAP_STACK如果开了,内核栈本身就是 vmalloc 分配的,栈溢出导致的 fault 和普通的内存访问 fault 表现就不一样。
2. 核心概念拆解:page fault、vmalloc 与页表
2.1 page fault 到底在“fault”什么
Page fault 的本质是MMU 在地址翻译过程中失败了。CPU 要访问一个虚拟地址,MMU 去查页表,发现以下几种情况之一:
- 页表项不存在(PTE 无效),也就是这个虚拟地址根本没有映射。
- 页表项存在但权限不够,比如用户态想写一个只读页,或者内核态想执行一个 NX 页。
- 页表项存在但物理页不在内存里(被换出了),这个主要发生在用户态。
对于内核态来说,最常见的是第一种和第二种。第一种通常意味着代码访问了一个野指针,或者 vmalloc 区域还没有建立映射;第二种则可能是权限位设置错误,比如把某个页错误地标记成了只读。
这里有个关键点:ARM64 的 page fault 异常等级(EL)和 fault 状态寄存器(ESR)会告诉你很多信息。ESR 里的 DFSC(Data Fault Status Code)字段直接编码了 fault 的类型,比如是 translation fault、permission fault 还是 alignment fault。当时我拿到的日志里,DFSC 显示的是 translation fault at level 3,这意味着页表走到第三级(最后一级)时发现 PTE 无效。这就把范围缩小到了“这个地址的 PTE 没有被填充”。
2.2 vmalloc 区域的特殊性
vmalloc 是内核里用来分配“虚拟地址连续、物理地址不连续”内存的机制。它和 kmalloc 最大的区别在于:kmalloc 返回的地址在线性映射区,物理地址和虚拟地址只差一个固定偏移;而 vmalloc 返回的地址在 vmalloc 区,需要单独建立页表映射。
vmalloc 的分配过程大致是这样的:
- 从 vmalloc 地址空间里找一块空闲的虚拟地址范围。
- 分配物理页(可能来自不同的物理内存区域)。
- 为这些物理页建立页表映射,填充 PTE。
- 返回虚拟地址。
关键在第 3 步:vmalloc 的页表映射是“延迟”或者“批量”建立的。在有些实现里,vmalloc 分配时只建立了部分映射,剩下的靠 page fault 来按需填充。这就是为什么内核态 page fault 在 vmalloc 区域是“合法”的——内核的 fault 处理程序会检查 fault 地址是否落在 vmalloc 区域,如果是,就调用vmalloc_fault去填充 PTE。
但这里有个陷阱:如果 vmalloc 区域已经被释放了,但页表没有完全清理,或者有代码还在访问这个地址,就会触发 fault,而 fault 处理程序发现这个地址虽然落在 vmalloc 范围,但对应的 vmalloc 描述符已经没了,就会报 “Unable to handle page fault”。
2.3 页表的三级/四级结构
ARM64 通常使用 4 级页表(PGD -> PUD -> PMD -> PTE),每级 9 位索引,加上页内偏移 12 位,总共 48 位虚拟地址空间。当时出问题的地址是ffff800012345678,我们拆一下:
ffff8000这部分高位决定了它属于内核地址空间。- 具体落在 vmalloc 区还是线性映射区,要看内核的地址空间布局配置。
在典型的 ARM64 配置里,vmalloc 区通常从ffff000000000000附近开始,而线性映射区从ffff800000000000开始。所以ffff8000...这个地址更可能落在线性映射区。但线性映射区是直接映射物理内存的,理论上不应该出现 translation fault,除非:
- 这个物理地址根本不存在(比如内存条没插满,但代码访问了超出实际内存的地址)。
- 页表被意外修改了。
- 这是一个设备内存地址,但没有正确建立映射。
当时我倾向于第一种可能,因为测试环境的内存配置和代码里假设的内存大小可能不一致。
3. 定位过程:从日志到根因的完整推演
3.1 第一步:确认 fault 地址的属性
拿到完整 dmesg 后,我首先看的是 fault 地址附近的寄存器信息。ARM64 的 oops 日志里通常会打印:
PC is at some_function+0xXX/0xYY LR is at some_caller+0xXX/0xYY ... ESR = 0x96000045ESR 的0x45拆开看,低 6 位是 DFSC,0x45 & 0x3F = 0x05,对应 “Translation fault, level 1”。等等,这里和我之前猜的 level 3 不一样。Level 1 translation fault 意味着 PGD 或者 PUD 级别的页表项就是无效的,这说明这个地址的高位部分根本没有对应的页表结构。
这就更有意思了。如果是 level 3 fault,说明页表结构在,只是最后一级 PTE 没填;但 level 1 fault 说明连中间的页表页都没分配。这通常意味着:这个虚拟地址根本就不在任何一个已建立的映射范围内。
3.2 第二步:反查地址来源
接下来要回答的问题是:这个地址是从哪来的?是哪个变量、哪个结构体字段、还是哪个函数参数?
我当时的做法是:
- 看 PC 寄存器指向的指令,反汇编
some_function,确认它是在访问哪个偏移。 - 结合栈回溯,看这个函数是被谁调用的,参数是什么。
- 如果地址来自某个结构体字段,就去查这个结构体是在哪里分配的、有没有可能被释放后重用。
这一步往往是最耗时的,因为内核的优化和 inline 会让反汇编和源码对应变得困难。我的经验是:优先看栈回溯里的函数名,找到最内层的那个“可疑函数”,然后去源码里搜它访问了哪些指针。
当时栈回溯显示 fault 发生在memcpy里,调用者是某个驱动程序的ioctl处理函数。这就很明确了:驱动在ioctl里做了一次memcpy,源地址或者目的地址是那个非法地址。
3.3 第三步:追踪地址的分配与释放
既然知道是驱动的问题,下一步就是看这个地址是怎么来的。我让测试同学把驱动的调试日志打开,重点看ioctl调用前后的地址分配记录。
结果发现:这个地址是之前一次vmalloc分配的,但在ioctl调用之前,驱动已经调用了vfree释放了它。问题在于,释放之后,驱动里还有一个异步任务(workqueue)在访问这块内存。
这就是典型的 use-after-free。vfree之后,vmalloc 区域的页表映射被清理了,但异步任务不知道,还在往那个地址写数据,于是触发 page fault。而 fault 处理程序发现这个地址虽然落在 vmalloc 范围,但对应的 vmalloc 结构已经没了,所以报 “Unable to handle page fault”。
3.4 第四步:验证与修复
定位到 use-after-free 之后,修复思路就很清晰了:
- 方案一:在
vfree之前,确保所有异步任务都已经完成。可以用flush_workqueue或者引用计数来同步。 - 方案二:异步任务里访问内存前,先检查一个标志位,如果内存已经释放,就直接返回。
- 方案三:改用
kref或者refcount_t来管理这块内存的生命周期,确保最后一个引用释放时才真正vfree。
我当时选了方案三,因为驱动的异步任务比较多,用引用计数最稳妥。具体做法是:在分配内存时初始化kref为 1,每次有新的异步任务要访问这块内存时,先kref_get,任务结束后kref_put。kref_put里注册的释放函数负责调用vfree。这样就能保证内存不会在还有任务访问时被释放。
修复后跑了 24 小时压力测试,没有再出现 page fault。
4. 常见问题与排查技巧实录
4.1 内核 page fault 排查速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| Translation fault at level 1 | 地址不在任何映射范围 | 检查地址来源,是否野指针或已释放的 vmalloc |
| Translation fault at level 3 | PTE 未填充 | 检查 vmalloc 延迟映射是否正常,或页表被篡改 |
| Permission fault | 权限位错误 | 检查页表权限设置,是否误设只读/NX |
| 地址在 vmalloc 区但 fault | vmalloc 已释放或未映射 | 检查 vfree 调用时机,是否有 use-after-free |
| 地址在线性映射区但 fault | 物理内存不存在或页表损坏 | 检查内存配置,是否有越界访问 |
4.2 几个容易踩的坑
坑一:只看 fault 地址,不看 fault 类型。同样是 “Unable to handle page fault”,level 1 和 level 3 的排查方向完全不同。一定要看 ESR 里的 DFSC 字段。
坑二:忽略CONFIG_VMAP_STACK的影响。如果这个配置开了,内核栈是 vmalloc 分配的,栈溢出会表现为 vmalloc 区域的 page fault,而不是传统的“栈踩了相邻内存”。排查时要看栈指针是否越界。
坑三:在 fault 处理程序里加打印,结果把系统打挂了。内核 page fault 的处理路径非常敏感,在里面加printk可能导致递归 fault 或者死锁。我的做法是:用trace_printk或者 ftrace 来记录,而不是直接printk。
坑四:以为vfree之后地址就完全不能访问了。实际上,vfree只是清理了页表映射,虚拟地址本身还在 vmalloc 地址空间里。如果代码在vfree之后还访问这个地址,fault 处理程序会先检查这个地址是否在 vmalloc 范围,如果在,但找不到对应的 vmalloc 结构,才会报错。这个检查逻辑在vmalloc_fault里,值得仔细读一读。
4.3 调试工具与手段
/proc/vmallocinfo:查看当前所有 vmalloc 分配,包括地址范围、大小、调用者。如果 fault 地址在这个列表里,说明内存还在;如果不在,说明已经释放了。slub_debug和kasan:如果怀疑是 use-after-free,开启 KASAN 可以在访问已释放内存时直接报错,比 page fault 更早发现问题。ftrace的function_graph:跟踪vmalloc和vfree的调用路径,看是谁在什么时候释放了内存。crash工具:如果有 vmcore,可以用crash分析 fault 时的页表状态,直接看 PTE 的值。
5. 从这次定位中沉淀的经验
5.1 内核问题定位的通用思路
这次问题定位花了大概两天时间,其中大部分时间花在“确认地址来源”上。回过头看,有几个经验值得记下来:
第一,日志要收集全。不要只看报错那两行,要看 fault 前后的所有输出,包括寄存器、栈回溯、模块加载信息。有时候 fault 之前的一条 warning 就是关键线索。
第二,地址属性先判断。拿到 fault 地址,先判断它属于哪个区域(线性映射、vmalloc、模块区域、设备区域),这能直接缩小排查范围。ARM64 的地址空间布局在Documentation/arm64/memory.rst里有详细说明,值得花时间读一遍。
第三,同步问题优先怀疑。内核里大多数“偶现”的 page fault 都和并发有关:释放后访问、竞态条件、缺少内存屏障。如果问题是压力测试才出,基本可以往这个方向靠。
5.2 关于 vmalloc 使用的建议
- 能用 kmalloc 就别用 vmalloc。vmalloc 的页表建立和清理都有开销,而且容易出同步问题。除非确实需要大块内存或者虚拟地址连续,否则优先用 kmalloc。
- vfree 之后把指针置空。这是个老生常谈的建议,但真的能避免很多问题。
vfree(ptr); ptr = NULL;至少能让后续的访问直接触发 NULL 指针解引用,而不是访问一个已经释放的地址。 - 异步任务访问共享内存时,一定要有生命周期管理。引用计数、RCU、完成量,选一个适合场景的机制,不要靠“我觉得应该没问题”来写代码。
5.3 最后分享一个小技巧
如果你也在排查内核 page fault,可以试试在 fault 处理程序里加一个条件打印:只打印 fault 地址落在特定范围(比如你怀疑的模块地址范围)时的信息。这样既能拿到关键日志,又不会因为打印太多把系统拖垮。具体做法是在do_page_fault或者do_translation_fault里加判断,用pr_debug或者trace_printk输出。当然,改内核代码之前记得备份,并且确保你知道怎么恢复。
这个问题的根因虽然是一个驱动的 use-after-free,但排查过程中涉及的 page fault 机制、vmalloc 原理、页表结构、并发同步,都是内核开发里绕不开的基础知识。把这次定位过程记录下来,一方面是给自己留个备忘,另一方面也希望能帮到遇到类似问题的朋友。内核问题虽然看起来吓人,但只要思路对、工具全、耐心够,总能找到根因。