☰
Linux内核OOPS调试实战:从日志解读到问题修复
2026/10/15 8:46:50 网站建设 项目流程

1. 遇到OOPS先别慌:先搞清楚内核到底在抱怨什么

做内核相关开发的人,几乎都会在某个深夜和Linux kernel的OOPS打过照面。第一次看到满屏寄存器、栈回溯和十六进制字节时,很多人第一反应是“完了,系统崩了,赶紧重启”。其实OOPS并不是世界末日,它是内核在发生严重错误但还能勉强维持运行、或者说至少还能把现场证据打印出来时,向你发出的一份“事故报告”。我个人的看法是:与其怕它,不如把它当成内核在主动自证清白——它告诉你“我在这里踩了坑,这是我的身份证、血型和脚印,你去查吧”。

OOPS全称虽然没什么标准展开,业内一般就把它叫Oops,意思是内核访问了不该访问的内存、执行了非法指令、或者触发了某种异常状态。和panic不同,OOPS出现后进程可能被杀掉,但系统内核本身可能还活着,你甚至还能敲命令抢救日志;而panic是整个内核彻底放弃,直接挂起或重启。理解这个差别很重要,因为遇到OOPS时,很多老手的第一动作不是重启,而是赶紧把屏幕上的日志完整拍下来,或者从串口、netconsole、kdump里把现场保存好。现场一旦丢了,排查难度直接翻倍。

适合看这篇内容的人,我默认是已经在写内核模块、做驱动开发,或者被生产环境OOPS日志折磨过的同学。就算你只是维护内核、升级系统时偶然撞上,这篇也能帮你把日志里那些看似天书的字段逐个翻译成人话。内核调试是个细活,但方法论是通用的:先读日志,再找线索,最后定位代码。下面我按这个思路展开。

1.1 OOPS是什么,和panic有什么区别

内核运行时遇到无法恢复的异常,会根据严重程度走两条路:一条是OOPS,另一条是panic。你可以把OOPS理解成内核里的“软件中断异常处理”——CPU触发了一个异常(比如page fault、general protection fault),内核的异常处理函数捕获到之后,检查当前上下文是不是在内核态、有没有持有锁、能不能安全继续。如果判断还能继续,就打印OOPS信息,杀掉当前触发出错的任务,内核继续运行;如果判断已经没法保证一致性,就直接panic。

从日志上区分也很简单。OOPS日志通常在开头会有类似“BUG: unable to handle page fault for address”或者“Oops: 0000 [#1] SMP”这样的字样,结尾会看到“Kernel panic - not syncing: Fatal exception”只是其中一种走向。很多生产故障实际上先出现OOPS,然后因为错误处理阶段又触发了新的异常,才升级成panic。所以你不能只盯着“panic”三个字,前面的OOPS才是真正的病根。

为什么内核不干脆都panic?因为很多错误是进程相关的。比如某个驱动里空指针解引用只影响当前进程,内核其他部分(尤其是调度器、内存管理)还健康,杀掉这个任务就能保住整机。这种设计对服务器可用性是有意义的。但也正因为这样,OOPS后的系统状态是不确定的,任务被杀、资源泄漏、锁没释放都可能发生。我的建议是:测试环境遇到OOPS可以继续观察,生产环境哪怕只是OOPS没有panic,也应尽快安排重启和转储,别赌“没事”。

1.2 一次典型的OOPS长什么样

我在模拟项目X里复现过不少OOPS,拿一个典型的空指针解引用的日志片段来说,它大致长这样:

BUG: unable to handle page fault for address: 0x0000000000000000 #PF error: [normal kernel read] Oops: 0000 [#1] SMP PTI CPU: 2 PID: 1234 Comm: kworker/2:1 Not tainted 5.10.0-custom #19 RIP: 0010:my_driver_ioctl+0x2f/0x100 [my_driver] Code: 48 8b 03 48 85 c0 74 05 48 8b 40 10 48 89 45 f8 ... RSP: 0018:ffffc9000012bd20 EFLAGS: 00010246 RAX: 0000000000000000 RBX: ffff888123456000 RCX: 0000000000000000 RDX: 0000000000000001 RSI: 0000000000000000 RDI: 0000000000000000 ... Call Trace: my_driver_ioctl+0x2f/0x100 [my_driver] do_vfs_ioctl+0x55/0x90 __x64_sys_ioctl+0x1c/0x20 do_syscall_64+0x3f/0x80 entry_SYSCALL_64_after_hwframe+0x44/0xa9 RIP: 0033:0x7f1c2d3a1234 Code: ...

第一行告诉你是读地址0导致page fault,也就是典型的空指针解引用。Oops: 0000 [#1]里的0000是错误标志,#1表示这是第1次OOPS(同一内核生命周期里可以累计)。RIP指向的my_driver_ioctl+0x2f/0x100就是我们驱动里出错的指令位置:函数基地址加偏移0x2f,函数总大小0x100。Call Trace给出了从系统调用一路下来的调用链,这是我们定位问题最直接的导航图。

日志里的Code:行是出错指令附近的机器码,可以用来反汇编确认具体是哪条指令在访问空地址。下面会细讲怎么用。总之,第一眼信息量很大,但别慌,按字段拆解就好。

2. 五个必读字段:从日志里提取关键信息

很多人拿到OOPS日志习惯从后往前看,先看“Kernel panic”,再看“Call Trace”,这样也能抓到重点。但严格来说,要想把问题定位准,至少要把下面这几个字段读懂。我总结成五个必读项,按优先级排。

2.1 出错的函数与调用链

RIP这行是重中之重。RIP: 0010:my_driver_ioctl+0x2f/0x100 [my_driver]里的0010是代码段选择子,不用太纠结;关键是函数名、偏移和所属模块。my_driver_ioctl+0x2f表示出错位置在函数my_driver_ioctl开始后的第0x2f个字节处。有了模块名[my_driver],就能在/sys/module/my_driver/sections/.text里查到模块加载基址,进而算出绝对地址,配合objdump或addr2line翻译成源码行号。

Call Trace是第二重要的信息。它告诉我们“这条路是怎么走来的”。有时候RIP指向的代码本身没问题,是上层传进来的参数不对,这时候看调用链才能找到真正的源头。比如上面的日志,my_driver_ioctl是从do_vfs_ioctl进来的,那就要去看用户态传入的cmd和arg是不是合法。调用链里的地址如果带+0xXX/0xYY的偏移,含义和RIP一样,都可以用来定位。

还有一个容易忽略的点:Comm字段显示当前进程名。如果是kworker、ksoftirqd,说明问题是在内核线程或中断上下文触发的,可能和驱动卸载、延迟工作有关;如果是某个用户进程名,说明是进程调用系统调用时触发,可以先看用户态入口。PID则方便你回去翻那个进程的日志和行为。

2.2 寄存器、栈和代码段

寄存器行不只是摆设。RAX经常是函数返回值,比如返回了NULL然后被解引用;RBX、RDI等通常保存指针参数或this指针。像前面日志里RAX: 0000000000000000,而RIP处指令是48 8b 03(mov (%rbx),%rax),那你就能猜到是先从rbx指向的结构里取出某个成员放到rax,然后后续指令解引用rax时发现是0。寄存器组合能还原出错误发生的完整数据流。

RSP是栈指针,EFLAGS里的PF位(bit 2)表示page fault。栈内容一般不在默认日志里,但RSP指向的栈地址配合crash工具能看。Code:行需要仔细处理:它打印出错指令前后若干字节,通常是出错前多少字节到出错后多少字节。官方文档说会打印出错指令前若干字节,但实际数量与内核配置有关。用objdump反汇编出模块或vmlinux对应地址的代码,然后看日志里Code:行的字节序列,找到匹配的指令流,就能知道具体是哪条指令触发了异常。这里有个小坑:日志里的Code:字节顺序不是标准反汇编顺序,需要把出错位置前面的字节和后面的字节分组看清,建议直接用scripts/decodecode脚本自动解码,内核源码里有现成的。

RSP: 0018:ffffc9000012bd20里的0018是栈段选择子,后面是栈地址。结合crash的bt命令能看到更完整的内核栈帧。栈回溯信息里如果出现“stack segment”或“stack overflow”字样,基本就是栈溢出了,这类问题后面单独说。

2.3 错误码和类型说明

Oops: 0000 [#1]里的四位数字是x86异常错误码,含义取决于异常类型。对于page fault(#PF),这四位数字被解析为bit0=present(0表示访问不存在的页)、bit1=write(1表示写操作)、bit2=user(1表示来自用户态)、bit3=reserved bit violation、bit4=instruction fetch。所以0000表示:访问了一个不存在的页、读操作、来自内核态。这正好和BUG: unable to handle page fault for address: 0x0000000000000000吻合——内核态读地址0,页表里根本没映射。如果是0001则说明页在但权限不够,可能是写只读页,这类错误常和copy_to_user用错指针有关。

第一行BUG: unable to handle page fault for address其实是内核用WARN或者BUG宏输出的标题,后面紧跟真正的异常类型。常见的有:

  • unable to handle page fault:地址非法,映射不存在或权限不对。
  • unable to handle kernel NULL pointer dereference:其实就是页错误地址为0的特例。
  • general protection fault:通常是内核访问了越界内存、用了错误的选择子、或者未对齐访问,原因更复杂,有时和栈损坏/链表破坏有关。
  • kernel stack overflow:栈溢出。
  • BUG: scheduling while atomic:在原子上下文里调用了可能睡眠的函数。

#PF error那行后面的[normal kernel read]是额外解释,告诉你这是普通的内核读,不是用户态复制操作。如果是[bad address from syscall]之类,说明地址来自系统调用参数,这时重点查用户态传参是否合法。这些细节在排查时非常有价值。

3. 最常见的几类OOPS成因

看多了OOPS日志,你会发现高频的成因就那么几类。我按实际遇到的比例排个序,并说说每类日志的特征和排查方向。

3.1 空指针与野指针解引用

这是最常见的一种,尤其在内核模块开发初期。特征就是日志里出现NULL pointer dereference、address: 0x0000000000000000之类。但注意,空指针不只是字面意义上的NULL。很多时候指针本身不是0,而是偏移后的结果,比如结构体某个成员是NULL,取成员时计算出一个很小的地址(0x10、0x28等),这种日志会显示类似BUG: unable to handle page fault for address: 0x0000000000000010。看到非零但极小的故障地址,第一反应就应该是:某个对象指针是NULL,然后你在它基础上做了字段偏移。

排查空指针的核心是找到“哪个指针是NULL”。寄存器里通常有线索。RIP处的指令如果是mov 0x10(%rax), %rbx,而RAX是0,那就是RAX指向的对象为NULL。为什么RAX是NULL?回溯调用链,看看这个对象是什么时候获取的,有没有判空。最常见的漏判是kmalloc、kzalloc返回值没检查就直接用。虽然内存分配失败概率低,但在内存压力大时真的会发生。另外,file->private_data、platform_get_drvdata()这类从上下文取出来的指针,也可能在设备状态不对时是NULL。

野指针比空指针更难查,因为地址看起来是随机的,比如0xffff9a1234567890这种。野指针通常是已经释放的内存被重新使用,或者指针变量被越界写坏了。这类问题需要配合slub debug、KASAN(内核地址消毒器)来查,单看日志很难直接定位。后面实操部分会提。

3.2 越界访问与释放后使用

越界访问分两种:数组越界和缓冲区溢出。数组越界往往表现为访问了某个相邻对象,日志里fault address看起来是合法的内核堆地址,但和你预期访问的对象对不上。比如你只分配了4个元素的数组,结果越界访问了第5个,而第5个正好落在另一个对象上,报错时你看到的寄存器可能是那个对象的地址,会让人摸不着头脑。

缓冲区溢出更隐蔽,因为现场往往不在溢出发生的地方,而在下一次使用那块内存的地方才暴露。我调试过一个模拟的字符设备驱动,在copy_from_user里用了错误的长度参数,把数据多写了几字节到堆缓冲区,结果整个对象的引用计数被踩坏,最后释放时触发OOPS,日志的Call Trace指向的是kfree函数,而不是真正写越界的代码。这种问题用常规日志很难追溯,最好开启slub_debug=U(检测未初始化)或KASAN。KASAN能直接打印越界访问的调用栈,省掉大量猜测。

释放后使用(UAF)也常见于驱动卸载和并发场景。一个对象被释放后,仍有另一个执行路径持有它的指针。当这个指针被再次解引用时,如果内存已被重新分配给别的对象,现场就会非常诡异:你明明访问的是自己的结构体,寄存器打印出来的内容却像是另一个对象。查UAF,内核里有KASAN、SLAB_TYPESAFE_BY_RCU、kmemleak等手段;而最简单实用的习惯是:在释放对象后马上把对应指针置NULL,虽然治标不治本,但至少能让UAF变成好查的空指针。

3.3 内核栈溢出与原子上下文问题

内核栈一般只有8KB或16KB(x86 64位下通常16KB,某些配置或架构不同),远小于用户态栈。如果函数调用层级过深,或者单个函数内的局部变量太大(比如一个几十KB的数组),就会导致栈溢出。栈溢出的日志类型通常不是page fault,而是kernel stack overflow或者BUG: stack guard page was hit。这类日志的调用链常常是重复递归或者深度调用。

还有一种特殊的栈相关问题是BUG: scheduling while atomic。虽然它不一定直接叫OOPS,但经常伴随OOPS出现。它表示内核在原子上下文(持有自旋锁、RCU读锁、中断禁用、或者处于中断/软中断上下文)里调用了schedule()或者可能睡眠的函数。常见的坑包括:在自旋锁临界区里调用kmalloc(..., GFP_KERNEL)(可能睡眠)、在中断处理函数里调用mutex_lock、在定时器回调里调用usleep_range等等。这类问题的修复原则是:检查临界区里的每个函数是否能睡眠,不能的话要么改成原子版本(如GFP_ATOMIC、spin_lock_bh、workqueue),要么把临界区缩小。

4. 实操:手把手定位和修复一个OOPS

光会读日志还不够,真到了“日志看懂了,但不知道改哪行代码”的阶段,就需要动手做逆向定位。下面用一个模拟的内核模块案例,完整走一遍定位流程。这个案例是我在本地复现过的,代码是一个简单的字符设备,在ioctl里访问了一个没判空的指针。

4.1 环境准备与复现思路

建议准备一台虚拟机,内核版本和出问题环境保持一致。编译内核时打开以下选项,能省很多事:

  • CONFIG_DEBUG_INFO:生成DWARF调试信息,addr2line才能定位到源码行号。
  • CONFIG_KALLSYMS:默认打开,保证日志里能看到符号名。
  • CONFIG_KASAN:内存越界/UAF的高效检测器,只是性能开销大,适合开发环境,生产不建议常开。
  • CONFIG_DEBUG_KERNEL和CONFIG_SLUB_DEBUG:用于检测slab对象问题。

模块编译时用make -C /lib/modules/$(uname -r)/build M=$PWD,加-g选项保留调试符号,但注意:调试符号只会存在.ko文件里,加载进内核的只是一段代码。日志打印的符号信息依赖模块的.symtab和内核kallsyms。所以定位时一定保留编译出的.ko文件,最好和出问题那台机器上的模块完全同版本,否则偏移对不上。

复现思路:我写的模拟驱动里,ioctl中从private_data取出一个自定义结构体指针,然后直接解引用。而我在测试流程中故意在某个分支里把private_data设为NULL再调用ioctl,立刻触发OOPS。复现的步骤很简单:加载模块、创建设备节点、写一个几十行的C程序调用ioctl。如果不想写C程序,也可以用ioctl命令行工具,但字段设置不如自己写方便。

4.2 用addr2line和objdump还原现场

拿到OOPS日志后,先找到出错的模块加载基址:

cat /sys/module/my_driver/sections/.text

假设输出0xffffffffc0000000,而日志里RIP: 0010:my_driver_ioctl+0x2f/0x100 [my_driver]。函数my_driver_ioctl的地址在模块内等于日志偏移0x2f,但若要换算成整个内核地址,需要用/proc/kallsyms找模块符号地址(注意kallsyms要开CONFIG_KALLSYMS_ALL才包含所有符号)。不过更常见的做法是直接把模块反汇编,找偏移0x2f处的指令。

objdump -d -S my_driver.ko | grep -A20 "<my_driver_ioctl>"

反汇编输出里能直接看到函数入口到偏移0x2f的指令流。配合addr2line,我们可以把模块内虚拟地址转成源码行号。addr2line需要一个地址,模块文件里的函数符号地址是相对的,直接用编译出的符号地址即可:

addr2line -e my_driver.ko -f 0x2f

这里会有个细节:objdump显示的偏移是相对于整个.ko文件的,而addr2line需要的是相对于符号的偏移。如果addr2line直接给0x2f,它解读为“符号my_driver_ioctl加0x2f”,这是可以的,因为addr2line支持符号名加偏移的写法:

addr2line -e my_driver.ko -f my_driver_ioctl+0x2f

我实践下来这样最准。它会输出类似:

my_driver_ioctl /path/to/my_driver.c:108

第108行就是出错的C代码。再用objdump -S查看该行对应的汇编,会发现一条mov指令访问了空地址。整个定位过程不到一分钟,前提是编译时保留了-g。

4.3 用kprobe/ftrace追踪调用路径

如果日志的调用链不足以判断问题,比如你怀疑某个函数被谁调用、或者某些变量是什么时候被改坏的,可以用kprobe动态插桩。最简单的方式是perf probe:

perf probe --add 'my_driver_ioctl file=%di mode=%dx'

但perf probe往往需要root和相应内核配置,而且对模块符号支持要看kallsyms。另一个更通用的方法是直接写一个小kprobe模块,在目标函数入口和出口打印寄存器或变量。比如我为了确认private_data是不是在某个位置被置空,就在my_driver_open出口和my_driver_ioctl入口各打了一个探针:

static int handler_pre(struct kprobe *p, struct pt_regs *regs) { pr_info("ioctl enter, file=%px, arg=%px\n", (void *)regs->di, (void *)regs->rsi); return 0; }

通过对比两次探针的输出,很快能发现file->private_data在open之后变成NULL的具体路径。kprobe适合快速打点,不用重新编译整个内核。缺点是如果探针函数本身有bug,可能引发二次故障,所以测试环境使用更稳妥。

ftrace的function_graph可以看调用关系树,对于栈溢出或递归类问题特别好用:

echo function_graph > /sys/kernel/debug/tracing/current_tracer echo my_driver_ioctl > /sys/kernel/debug/tracing/set_ftrace_filter echo 1 > /sys/kernel/debug/tracing/tracing_on

复现一次故障后,把trace文件里该函数的进出记录拉出来,就能看到完整的前后文调用。不过ftrace对模块函数的支持依赖CONFIG_FUNCTION_TRACER和模块编译时的-pg标志,如果模块没开这个标志,就只能用kprobe。这也是我经常先选kprobe的原因。

4.4 修复补丁与验证

定位到my_driver.c第108行后,我看到的代码是:

static long my_driver_ioctl(struct file *file, unsigned int cmd, unsigned long arg) { struct my_data *data = file->private_data; size_t len =>

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

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

立即咨询