☰
内核page fault排查实战:从oops日志到vmalloc越界与use-after-free定位
2026/10/8 22:16:14 网站建设 项目流程

1. 从一次深夜告警说起:page fault 为什么会让内核直接"躺平"

凌晨两点收到一台线上服务器的告警,业务进程全部失联,SSH 连不上,带外控制台只剩一行反复刷屏的日志:unable to handle page fault for address: ffff8xxx...。这种场景对做内核或者系统运维的人来说并不陌生——它不是普通的用户态段错误,而是内核自己在访问某个虚拟地址时触发了缺页异常,并且异常处理路径没能把它兜住,最终走到oops甚至直接 panic。

先把概念理清楚。page fault(缺页异常)本身不是错误,它是虚拟内存机制正常运转的一部分。进程访问一个尚未建立物理映射的虚拟页时,CPU 触发缺页异常,内核的缺页处理程序负责分配物理页、填充内容、建立页表项,然后重新执行那条指令。用户态的缺页绝大多数都能被优雅处理,最多是 SIGSEGV。但一旦这个异常发生在内核态,情况就完全不同了:内核代码访问的地址如果落在非法区域,或者对应的页表项根本不该缺失,缺页处理程序会发现"这个地址我处理不了",于是打印unable to handle page fault,接着就是寄存器 dump 和调用栈。

这里有个关键点很多人会混淆:unable to handle page fault和kernel NULL pointer dereference经常一起出现,但它们的含义有细微差别。前者强调的是"缺页处理程序无法为该地址建立映射",后者强调的是"访问了一个空指针"。实际上内核空指针解引用往往就表现为一次无法处理的缺页,因为地址 0 附近没有映射。所以看到这条日志,第一反应应该是:内核代码访问了一个没有有效映射的虚拟地址,接下来要做的就是搞清楚这个地址是什么、从哪来、为什么没有映射。

我处理过的这类问题里,触发源大致可以归成几类:驱动代码里对vmalloc返回指针的越界访问、结构体成员偏移算错导致访问到野地址、释放后使用(use-after-free)让指针指向了已经归还的虚拟区间、以及页表项被意外清除。这几类的排查手法差别很大,但入口都是同一份 oops 日志。下面我按实际排查顺序,把整个链路拆开讲。

2. 读懂 oops 日志:从寄存器到调用栈的完整信息提取

2.1 先看那行地址,它决定了排查方向

unable to handle page fault for address: ffff8xxx这行里的地址是出错的虚拟地址,是整个排查的锚点。拿到它之后第一件事是判断它属于哪一类虚拟地址区间。x86_64 上内核空间的布局大致是这样的:

地址区间用途典型特征
ffff8880_00000000起直接映射区(direct map)物理内存的线性映射,地址连续
ffffc900_00000000起vmalloc 区非连续内存,模块、大块分配走这里
ffffea00_00000000起vmemmapstruct page 数组
ffffffff_80000000起内核代码段内核镜像本身

如果出错地址落在 vmalloc 区,那基本可以锁定是vmalloc/vzalloc相关的分配出了问题;如果落在直接映射区但明显越界,可能是数组越界或者指针运算错误;如果是个很小的值比如0x18、0x2a,那几乎可以确定是空指针加偏移。这一步判断能帮你砍掉一大半无关代码。

2.2 寄存器 dump 里藏着"谁在访问"

oops 日志里会打印一堆寄存器,其中最关键的是RIP(出错指令地址)、CR2(触发缺页的线性地址,和上面那行地址一致)、以及各个通用寄存器。RIP告诉你内核正在执行哪条指令时出的错,配合Code:那行反汇编字节,可以精确定位到源码行。我习惯的做法是:

  1. 记下RIP的值,用gdb vmlinux或者addr2line -e vmlinux -f -i <RIP>反查源码位置。
  2. 看Code:那行的字节,对照反汇编确认是哪条访存指令(mov、cmp之类)。
  3. 看通用寄存器里哪个寄存器的值接近出错地址,那个寄存器大概率就是"肇事指针"。

举个我实际遇到的例子:RIP指向一个mov 0x18(%rax), %rbx,而RAX的值是ffffc90001234000,出错地址是ffffc90001234018。这就非常清楚了——代码拿了一个 vmalloc 区的指针,然后访问它的第 0x18 字节偏移,但这个指针指向的内存已经被释放或者根本没分配那么大。偏移 0x18 对应结构体的某个成员,顺着结构体定义就能找到是哪一行代码。

2.3 调用栈要"从下往上"读

调用栈(Call Trace)是从当前函数往调用者方向打印的,最上面是出错点,最下面是调用链的根。读的时候要注意两点:一是内联函数可能不显示,二是尾调用优化会让栈看起来"断"了。如果栈里有?或者地址看起来不对,说明栈可能被破坏了,这时候要结合RSP和栈内存 dump 一起看。

我一般会先把调用栈里出现的函数名全部记下来,然后对照源码画出调用关系。如果出错函数是个通用函数(比如memcpy、kfree),那真正的问题在调用它的业务代码里,需要往上一层甚至上两层找。这一步不要偷懒,很多新手看到memcpy出错就以为是memcpy的问题,其实memcpy只是受害者。

3. vmalloc 区的坑:为什么这块内存最容易出问题

3.1 vmalloc 和 kmalloc 的本质区别

要理解 vmalloc 相关的 page fault,得先搞清楚它和 kmalloc 的区别。kmalloc分配的是物理连续的内存,返回的地址在直接映射区,虚拟地址和物理地址只差一个固定偏移,访问它不需要额外的页表项,TLB 命中率高。vmalloc分配的是虚拟连续但物理不连续的内存,内核需要专门为它建立页表项,把一段虚拟地址映射到分散的物理页上。

这个差异带来几个后果:第一,vmalloc 的分配和释放都要操作页表,开销比 kmalloc 大得多;第二,vmalloc 区的地址范围有限(x86_64 上默认是 32TB 左右,但实际可用受配置影响),大量分配可能耗尽;第三,vmalloc 返回的指针如果被越界访问,很容易踩到没有映射的虚拟地址,因为 vmalloc 区里相邻的虚拟区间之间往往有 guard page 或者干脆没映射。

3.2 一个典型的越界访问案例

我遇到过这样一个问题:某个驱动用vmalloc分配了一块缓冲区,大小是按count * sizeof(struct item)算的,但代码里在填充数据时循环条件写成了i <= count,多访问了一个元素。这个多出来的元素落在缓冲区末尾之后,而 vmalloc 分配的缓冲区末尾通常紧跟着一个未映射的页(guard page),于是访问它直接触发unable to handle page fault。

排查这类问题的关键是确认分配大小和实际访问范围。我会在代码里找到vmalloc调用点,算出分配了多少字节,再找到出错地址相对于返回指针的偏移,两者一对比就知道是不是越界。如果偏移量刚好等于分配大小或者略大于它,那基本可以坐实越界。

提示:vmalloc 分配的缓冲区末尾不一定总有 guard page,取决于内核配置和分配器实现。但越界访问落在未映射区域时,表现就是本文讨论的这种 page fault。

3.3 释放后使用:更隐蔽的一类

比越界更隐蔽的是 use-after-free。vfree之后,那段虚拟地址的页表项被清除,但指针变量如果没置空,后续代码继续用它访问,就会触发缺页。这类问题的难点在于:出错点往往离真正的 bug 点很远,中间可能隔了好几个函数调用、甚至隔了一段时间。

定位 use-after-free 我一般用两个手段:一是打开 KASAN(Kernel Address Sanitizer),它能在释放时做标记,访问时立刻报错并给出释放点的调用栈;二是如果 KASAN 因为性能原因不能常开,就在vfree前后加日志,记录指针值和调用栈,然后和出错时的地址比对。KASAN 的代价是内存占用和性能开销都比较大,生产环境慎用,但在复现环境里它是定位这类问题的利器。

4. 页表层面的排查:从 CR2 到页表项的手工走查

4.1 为什么有时候要看页表

大部分 page fault 通过代码审查就能定位,但有一类问题必须下沉到页表层面:页表项被意外修改或清除。比如某个驱动错误地操作了页表、内存热插拔导致映射变化、或者硬件故障让页表项损坏。这种情况下代码逻辑看起来没问题,但页表状态不对,只能手工走查。

4.2 手工走查页表的步骤

在能进入调试器(比如通过 kdump 拿到 vmcore,或者用 QEMU 调试内核)的前提下,可以按下面的步骤走:

  1. 从 oops 日志拿到CR3(页表基址)和出错地址CR2。
  2. 把CR2按 x86_64 四级页表结构拆成 PGD、PUD、PMD、PTE 的索引。
  3. 依次读取各级页表项,检查 present 位、rw 位、user 位是否符合预期。
  4. 如果某一级页表项的 present 位是 0,说明映射在这一级就断了,问题出在更上层。

x86_64 的地址拆分规则是:bits 47:39 是 PGD 索引,bits 38:30 是 PUD 索引,bits 29:21 是 PMD 索引,bits 20:12 是 PTE 索引,bits 11:0 是页内偏移。用gdb连上 vmcore 后,可以用x/gx命令逐级读取。这个过程比较繁琐,但能给出确定性的结论。

4.3 一个页表项被清除的真实场景

我处理过一个案例:某模块在初始化时用vmalloc分配内存并建立映射,但在某个错误处理路径里调用了vfree,而正常路径下这块内存还在被使用。由于错误处理路径只在特定条件下触发,平时测试根本复现不了,直到线上某个边界条件命中才爆发。用 kdump 拿到 vmcore 后,走查页表发现出错地址对应的 PTE 的 present 位是 0,而相邻地址的映射都正常,这就锁定了是"这块内存被单独释放了"。再结合代码里的vfree调用点,很快找到了那个错误处理路径。

这个案例给我的教训是:错误处理路径的释放逻辑要和正常路径严格对称,任何"提前释放"都要反复确认没有其他引用。后来我在代码审查里加了一条规则:所有vfree/kfree调用点必须能说清楚"谁还持有这个指针"。

5. 定位工具链:从 crash 到 ftrace 的实战组合

5.1 crash 工具:分析 vmcore 的主力

crash是分析内核转储的标配工具。拿到 vmcore 和对应的 vmlinux 后,几条常用命令能快速定位问题:

# 打开 vmcore crash vmlinux vmcore # 查看 panic 时的日志和寄存器 crash> log crash> bt # 查看出错地址对应的页表 crash> vtop ffffc90001234018 # 查看某个结构体变量的内容 crash> struct my_struct ffffc90001234000 # 查看调用栈上某个函数的参数 crash> bt -f

vtop这条命令特别有用,它直接帮你走查页表,输出虚拟地址到物理地址的映射关系,如果映射不存在会明确告诉你。比起手工拆页表,它省事得多。

5.2 ftrace 和 kprobe:动态追踪的利器

如果问题能复现但拿不到 vmcore,可以用 ftrace 和 kprobe 做动态追踪。比如怀疑某个函数访问了非法地址,可以在它入口处挂 kprobe,打印参数和调用栈:

# 挂 kprobe 到目标函数 echo 'p:myprobe my_function ptr=%di' > /sys/kernel/debug/tracing/kprobe_events echo 1 > /sys/kernel/debug/tracing/events/kprobes/myprobe/enable cat /sys/kernel/debug/tracing/trace_pipe

ftrace 的function_graphtracer 能画出完整的调用图,配合set_ftrace_filter只看关心的函数,对理清调用关系很有帮助。我通常先用 function_graph 确认调用路径,再用 kprobe 打印关键变量的值,两步下来基本能锁定问题。

5.3 工具选型的取舍

工具适用场景代价局限
crash + vmcore已 panic,需要事后分析需要配置 kdump依赖转储完整性
KASAN内存越界、use-after-free性能内存开销大生产环境慎用
ftrace动态追踪调用路径开销较小需要问题可复现
kprobe打印特定变量值有一定开销需要知道挂载点
gdb + QEMU开发阶段单步调试环境搭建复杂不适合生产

选工具的原则是:能复现就用动态追踪,不能复现就靠 vmcore。两者都没有的话,只能靠代码审查加日志,效率会低很多。

6. 几个容易踩的坑和我的实操心得

6.1 别被"地址看起来正常"骗了

有一次出错地址是ffff888012345678,落在直接映射区,看起来完全正常,我一开始以为是硬件问题。后来仔细算了一下,这个地址对应的物理地址超出了实际安装的内存范围——是代码里用了一个错误的物理地址做phys_to_virt转换。所以地址落在合法区间不代表它指向合法的物理内存,直接映射区也要检查物理地址范围。

6.2 注意编译优化对调用栈的干扰

-O2编译下,函数内联和尾调用优化会让调用栈丢失一些帧,看起来像是"从 A 直接跳到了 C"。遇到这种情况,不要怀疑栈坏了,先看反汇编确认是不是尾调用。我一般会在复现环境里用-O0或者-Og重新编译可疑模块,让调用栈更清晰,定位完再换回优化版本验证。

6.3 日志级别和 printk 的坑

排查阶段经常要加 printk,但要注意 printk 本身可能触发缺页或者死锁。比如在缺页处理路径里加 printk,如果 printk 又要访问某个未映射的地址,就会递归触发异常。另外 printk 的日志级别如果设得太低,可能被 console 的日志级别过滤掉,看不到输出。我的习惯是用pr_err或者pr_emerg保证能打出来,同时避免在原子上下文里做耗时操作。

6.4 复现环境的价值

这类问题最难的是复现。我的经验是:尽量在 QEMU 里搭一个和线上配置接近的环境,用相同的内核版本、相同的模块、相同的参数。QEMU 的好处是可以随时挂 gdb、可以打快照回滚、可以用-s -S从启动第一条指令开始调试。很多在线上只能靠 vmcore 猜的问题,在 QEMU 里单步跟一遍就清楚了。搭环境的投入看起来大,但比起在线上反复试错,性价比高得多。

6.5 记录和复盘

每次定位完这类问题,我都会写一份简短的复盘:出错地址是什么、属于哪个区间、根因是什么、修复方案是什么、有没有类似的代码需要一起检查。这份复盘不只是给自己看,团队里其他人遇到类似问题可以直接参考。内核问题的模式其实就那么几类,积累多了,下次看到 oops 日志基本能猜个八九不离十。

最后分享一个我常用的快速判断技巧:拿到unable to handle page fault的日志,先看地址的低位。如果低位是个很小的值(比如 0x0 到 0x1000 之间),大概率是空指针加偏移;如果地址落在 vmalloc 区且偏移量接近某个分配大小,大概率是越界;如果地址看起来完全随机,那可能是野指针或者内存损坏,这时候要优先怀疑 use-after-free 或者栈溢出。这个技巧不能替代完整分析,但能帮你在第一时间把排查方向缩小到一两个可能性上,省下不少时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询