3、Oops信息分析:Oops信息结构解析、寄存器与栈回溯分析、通过PC指针定位代码行

内核崩溃了,屏幕上刷出一堆乱糟糟的十六进制数字——这就是我们常说的Oops。说实话,我刚入行那会儿,看到这玩意儿就头大。但后来我发现,Oops其实是个宝藏,它把问题出在哪、怎么出的,都写得明明白白。

今天我就带你把这堆"天书"拆开看看。你想想看,内核都死给你看了,总得死个明白吧?

3.1 Oops信息结构解析

一个典型的Oops信息,大概长这样:

Unable to handle kernel NULL pointer dereference at virtual address 00000000
pgd = c0004000
[00000000] *pgd=00000000
Internal error: Oops: 5 [#1] PREEMPT SMP ARM
Modules linked in: my_driver(O)
CPU: 0 PID: 123 Comm: insmod Tainted: G           O 4.19.0
Hardware name: MyBoard (DT)
PC is at my_function+0x14/0x30 [my_driver]
LR is at another_func+0x8/0x20
pc : [<bf000014>]    lr : [<bf000008>]    psr: 60000013
sp : c7827d80  ip : c7827d98  fp : c7827d94
r10: 00000000  r9 : c7827e00  r8 : bf000000
r7 : 00000001  r6 : c7827e00  r5 : 00000000  r4 : c7827e00
r3 : 00000000  r2 : 00000001  r1 : 00000000  r0 : 00000000
Flags: nZCv  IRQs on  FIQs on  Mode SVC_32  ISA ARM  Segment user
Control: 10c5387d  Table: 4a6a806a  DAC: 00000055
Process insmod (pid: 123, stack limit = 0xc7826000)
Stack: (0xc7827d80 to 0xc7828000)
7d80: c7827e00 bf000000 00000001 c7827e00 c7827e00 bf000014
7da0: c7827dc0 c0108a4c c7827dc0 bf000000 c7827e00 00000001
...

嗯,看着确实吓人。但别慌,我们一层层剥开它。

第一行是错误类型Unable to handle kernel NULL pointer dereference。说白了就是访问了空指针。我在项目中遇到过,十次Oops有七八次都是这个原因。

接着是CPU和进程信息CPU: 0 PID: 123 Comm: insmod。这告诉我们,是insmod这个进程在CPU 0上触发的。如果你有多个CPU,这个信息能帮你判断是不是并发问题。

PC和LR寄存器:这是最关键的。PC指向了my_function+0x14/0x30,意思是程序执行到my_function函数偏移0x14字节处挂掉了,这个函数总长0x30字节。LR是返回地址,告诉你谁调了它。

核心要点:PC指针 + 偏移量 = 问题代码的精确位置。这是定位bug的命门。

3.2 寄存器与栈回溯分析

寄存器值能告诉我们什么?我举个例子:

r0 : 00000000  r1 : 00000001  r2 : 00000001
r3 : 00000000  r4 : c7827e00  r5 : 00000000

r0是0,r3也是0。结合第一行的"NULL pointer dereference",基本可以断定:代码试图通过一个空指针访问内存。为什么会这样?

我个人习惯先看r0r3,因为ARM的ATPCS调用约定里,r0通常是第一个参数或返回值。如果它是0,十有八九是传了个NULL进来。

再看栈回溯部分:

Stack: (0xc7827d80 to 0xc7828000)
7d80: c7827e00 bf000000 00000001 c7827e00 c7827e00 bf000014
7da0: c7827dc0 c0108a4c c7827dc0 bf000000 c7827e00 00000001

栈里存的是函数调用链。每个bf000xxx这样的地址,都指向一个函数入口。配合vmlinux或模块的符号表,就能还原出调用路径。

小技巧:用addr2line工具把地址翻译成文件名和行号。比如:addr2line -e vmlinux bf000014。我曾经靠这招,五分钟定位了一个藏了三天的bug。

3.3 通过PC指针定位代码行

好了,重头戏来了。怎么用PC指针找到具体的代码行?

假设PC是bf000014,函数是my_function+0x14。我们需要知道my_function在模块里的偏移量。

步骤一:获取模块基址

从Oops信息里找Modules linked in: my_driver(O),然后看寄存器r8 : bf000000——这就是模块的加载基址。

步骤二:计算函数内偏移

PC - 基址 = 0xbf000014 - 0xbf000000 = 0x14。嗯,就是函数内偏移0x14字节。

步骤三:反汇编找对应行

objdump -d my_driver.ko反汇编模块,找到my_function

00000000 <my_function>:
   0:   e52db004    push    {fp}
   4:   e28db000    add fp, sp, #0
   8:   e24dd00c    sub sp, sp, #12
   c:   e50b0008    str r0, [fp, #-8]
  10:   e51b3008    ldr r3, [fp, #-8]
  14:   e5932000    ldr r2, [r3]      <-- 这里崩了!
  18:   e1a00002    mov r0, r2
  1c:   e24bd000    sub sp, fp, #0
  20:   e49db004    pop {fp}
  24:   e12fff1e    bx lr

看到没?偏移0x14处是ldr r2, [r3],它试图从r3指向的地址读数据。而r3是0——空指针!

步骤四:关联源码行

addr2line -e my_driver.ko -f 0x14,或者查编译时生成的.dwarf调试信息,就能直接得到:

my_function
/path/to/my_driver.c:42

第42行!打开源码一看:

40: int my_function(struct my_data *data)
41: {
42:     int val = data->value;  // data是NULL!
43:     ...
44: }

真相大白。调用者传了个NULL指针进来,而我们没做检查。

避坑指南:我曾经在调试一个USB驱动时,PC指针指向的地址在模块范围之外。后来发现是模块加载时地址重定位出了问题。这种情况下,别死磕PC,先检查模块是否正常加载。

3.4 实战流程图

下面这张图,是我自己总结的Oops分析流程。每次遇到内核崩溃,我就按这个步骤走,基本没失手过:

Oops信息分析流程图 步骤1:捕获Oops信息 dmesg / console 日志 步骤2:解析错误类型 NULL指针 / 页错误 / 对齐异常 步骤3:提取PC指针和寄存器值 PC、LR、r0-r3、栈指针 模块驱动? 模块驱动 objdump -d my_driver.ko 内核原生 addr2line -e vmlinux 步骤4:计算函数内偏移 PC - 基址 = 函数内偏移 步骤5:定位源码行 → 修复bug

这张图把整个分析流程串起来了。你按这个顺序走,从捕获Oops到定位源码行,每一步都有章可循。

3.5 常用工具速查表

工具 用途 示例命令
addr2line 地址转源码行号 addr2line -e vmlinux -f bf000014
objdump 反汇编查看指令 objdump -d my_driver.ko
gdb 离线分析vmcore gdb vmlinux vmcore
decode_stacktrace 美化栈回溯 ./scripts/decode_stacktrace.sh

个人经验:我习惯在编译内核时开启CONFIG_DEBUG_INFO,这样addr2line能给出更精确的行号。另外,CONFIG_KALLSYMS一定要开,否则你看到的PC地址全是裸的十六进制,根本不知道对应哪个函数。

好了,Oops分析的核心思路就这些。说白了就是三步:看错误类型、抓PC指针、反汇编找源码。你多练几次,就会发现这玩意儿比想象中简单得多。


公众号:蓝海资料掘金营,微信deep3321