当Linux内核遇到一些非致命的错误时,它不会让系统立即崩溃,而是会产生一个称为“Oops”的诊断信息。这对于内核开发者,特别是驱动开发者来说,是定位问题的关键线索。面对屏幕上滚过的一大段令人困惑的十六进制和符号,该如何入手呢?本文将带你一步步拆解一个真实的Oops日志,并介绍多种定位问题根因的实用方法。
OOPS信息解读
当我们尝试加载一个有问题的内核模块时,系统输出了以下Oops信息。我们将逐段分析其含义。
root@firefly:~/mnt/module# insmod oops_module.ko
[ 867.140514] Unable to handle kernel NULL pointer dereference at virtual address 00000000
第一行直接指出了问题的核心:内核无法处理一个对虚拟地址 0x00000000 的空指针解引用异常。如果错误是由代码直接调用 BUG() 或 BUG_ON() 触发的,这里可能还会显示源代码中的行号。
[ 867.141279] pgd = ffffffc0f0a65000
[ 867.141582] [00000000] *pgd=0000000000000000, *pud=0000000000000000
这两行显示了试图访问的地址(本例中为0)的页表信息(PGD、PUD)。这里全是0,表明这个地址没有对应的有效页表项。
[ 867.142164] Internal error: Oops: 96000045 [#1] SMP
96000045 表示错误码。方括号 [] 内的数字 #1 表示这是该Oops信息第一次被显示。后面的 SMP 表明当前内核启用了SMP(对称多处理)支持。
注:96000045 这种错误码并不常见,通常见到的是 001、002 等形式。Oops的错误代码根据错误原因有不同的定义,如果发现与常见代码不符,最好去内核源代码中查找对应架构的定义。
[ 867.142592] Modules linked in: oops_module(O+)
Modules linked in 列出了系统当前已加载的模块列表,(O+) 表明 oops_module 是一个外部模块(Out-of-tree module)。
[ 867.143006] CPU: 4 PID: 1163 Comm: insmod Tainted: G O 4.4.194+ #7
[ 867.143649] Hardware name: Firefly-RK3399 Board (Linux Opensource) (DT)
[ 867.144236] task: ffffffc0cdc44380 task.stack: ffffffc00a4fc000
CPU: 4:错误发生在4号逻辑CPU上。
PID: 1163:触发Oops的进程ID是1163。
Comm: insmod:进程名称是 insmod,正是我们执行加载模块的命令。
Tainted: G O:内核污染标志。
G:所有已加载模块都拥有GPL或兼容许可证。
O:系统已加载外部(非主线)模块。
4.4.194+:内核版本号。
Hardware name:硬件平台名称。
task 和 task.stack:分别表示当前进程控制块(task struct)和进程内核栈的起始地址。
内核污染原因(Tainted)的详细说明可以在内核源码 kernel/panic.c 中找到,常见标志如下:
| Tainted |
描述 |
G |
所有模块均有 GPL 或兼容许可证。 |
P |
加载了私有(Proprietary)模块。 |
F |
模块被 insmod -f 强制加载。 |
S |
SMP 内核运行在未经认证为 SMP 安全的硬件上。 |
R |
模块被 rmmod -f 强制卸载。 |
M |
处理器报告了机器检查异常(Machine Check Exception)。 |
B |
页释放函数发现了坏的页引用或意外的页标志。 |
U |
用户或用户应用明确请求设置污染标志。 |
[ 867.144761] PC is at init_oopsdemo+0x24/0x38 [oops_module]
[ 867.145247] LR is at init_oopsdemo+0x18/0x38 [oops_module]
[ 867.145732] pc : [<ffffff8000ef0024>] lr : [<ffffff8000ef0018>] pstate: 40000145
[ 867.146386] sp : ffffffc00a4ffc40
这是最关键的信息之一:
PC is at init_oopsdemo+0x24/0.38:程序计数器(PC)指向了模块 oops_module 中的函数 init_oopsdemo 的 +0x24 偏移处。0x38 是该函数的总大小(56字节)。这意味着错误发生在这个函数的第36字节(0x24)的位置。
- 下面几行是PC、LR(链接寄存器)、SP(栈指针)寄存器在异常发生时的具体值。
接下来的大段内容是异常发生时所有通用寄存器的值(X0-X30)以及围绕PC、LR、SP地址的内存转储。这些信息在深入分析指令流和内存状态时非常有用,但对于初步定位问题,我们更关注上面的函数调用信息。
[ 867.148820] Process insmod (pid: 1163, stack limit = 0xffffffc00a4fc000)
[ 867.148822] Stack: (0xffffffc00a4ffc40 to 0xffffffc00a500000)
stack limit 显示了内核栈的大小(由内核配置选项决定)。Stack 后面是从栈指针开始的部分栈内存内容。
[ 867.148907] Call trace:
[ 867.148911] Exception stack(0xffffffc00a4ffa70 to 0xffffffc00a4ffba0)
...
[ 867.148944] [<ffffff8000ef0024>] init_oopsdemo+0x24/0x38 [oops_module]
[ 867.148953] [<ffffff80080830f8>] do_one_initcall+0x78/0x194
[ 867.148958] [<ffffff800818d2d0>] do_init_module+0x64/0x1c0
[ 867.148962] [<ffffff800813ab5c>] load_module+0x199c/0x1ed0
[ 867.148964] [<ffffff800813b2b4>] SyS_finit_module+0xb0/0xbc
[ 867.148968] [<ffffff8008082f70>] el0_svc_naked+0x24/0x28
栈回溯(Call trace)信息,这是另一个关键部分。它清晰地展示了从系统调用开始到崩溃点的函数调用链:
el0_svc_naked -> SyS_finit_module -> load_module -> do_init_module -> do_one_initcall -> init_oopsdemo。
这证实了错误发生在模块初始化函数 init_oopsdemo 中。
[ 867.148972] Code: 95ca7426 d2800000 528102e1 72a32ec1 (b9000001)
[ 867.148975] ---[ end trace 1983a52768236533 ]---
Code 部分是错误发生时PC指向地址附近的一小段机器码(指令),括号 () 内的是触发异常的那条指令。最后一行表示Oops信息结束。
如何根据OOPS找出Bug
第一步:确定出错位置在内核函数还是驱动模块
首先,我们需要判断Oops发生在内核核心代码还是某个驱动模块中。System.map 文件记录了内核编译后所有符号(函数、变量)的地址。它通常位于内核编译输出目录的根目录。
查看 System.map 文件头尾,可以确定内核函数的地址范围。例如:
ffffff8008080000 T _head
...
ffffff8009880000 B _end
内核函数的地址范围大致是 ffffff8008080000 到 ffffff8009880000。而我们的Oops中PC值是 ffffff8000ef0024,明显不在这个范围内。因此,可以判定不是内核核心函数出错,而是某个驱动模块的问题。
提示:如果把 oops_module.ko 直接编译进内核(而不是作为模块),那么错误就会被认为是内核引起的,PC地址也会落在 System.map 的范围内。
第二步:反汇编驱动文件
Oops信息已经告诉我们错误出在 oops_module 模块的 init_oopsdemo+0x24。如果Oops没有打印模块名,我们可以通过 cat /proc/kallsyms > kallsyms.txt 命令获取系统所有符号地址,然后在其中查找与PC值(ffffff8000ef0024)最接近的符号,从而确定出错的模块。
接下来,反汇编有问题的模块文件:
aarch64-linux-gnu-objdump -D oops_module.ko > oops_module.dis
在反汇编文件 oops_module.dis 中,我们找到 init_oopsdemo 函数:
0000000000000000 <init_module>:
0: a9bf7bfd stp x29, x30, [sp, #-16]!
4: 910003fd mov x29, sp
8: aa1e03e0 mov x0, x30
c: 94000000 bl 0 <_mcount>
10: 58000100 ldr x0, 30 <init_module+0x30>
14: 94000000 bl 0 <printk>
18: d2800000 mov x0, #0x0 // #0
1c: 528102e1 mov w1, #0x817 // #2071
20: 72a32ec1 movk w1, #0x1976, lsl #16
24: b9000001 str w1, [x0] # 将w1寄存器的值存入x0指向的内存
28: a8c17bfd ldp x29, x30, [sp], #16
2c: d65f03c0 ret
根据 PC is at init_oopsdemo+0x24,我们定位到 0x24 偏移处的指令:str w1, [x0]。这是一条存储指令,意图将寄存器 w1 的值写入 x0 寄存器所指向的内存地址。
再看上一条指令 0x18: d2800000 mov x0, #0x0,它将 x0 寄存器设置为 0。于是真相大白:代码试图向内存地址 0 写入数据,导致了空指针解引用。这完美解释了Oops第一行的“NULL pointer dereference at virtual address 00000000”。
其他辅助定位方法
1. 使用GDB
如果你的内核模块编译时包含了调试信息(-g 选项),可以直接用GDB定位。
aarch64-linux-gnu-gdb -q ./oops_module.ko
(gdb) list *init_oopsdemo+0x24
0x4c is in init_oopsdemo (/home/zhongyi/code/module/oops_module/oops_module.c:10).
GDB会直接告诉你,init_oopsdemo+0x24 对应源文件 oops_module.c 的第10行。
2. 使用addr2line
addr2line 工具可以直接将地址转换为文件名和行号,同样需要调试信息。
aarch64-linux-gnu-addr2line -e ./oops_module.ko -p -f 0x24
init_oopsdemo at /home/zhongyi/code/module/oops_module/oops_module.c:10
3. 使用内核脚本 decodecode
Linux内核源码提供了一个 scripts/decodecode 脚本,即使没有源代码或符号表,也能将Oops中的机器码反汇编。你需要将Oops日志中 Code: 开始的部分保存到文件(例如 oops_log.txt),然后运行:
ARCH=arm64 CROSS_COMPILE=your_toolchain_prefix ./scripts/decodecode < oops_log.txt
执行后,脚本会输出类似的结果,并指出触发异常的指令(trapping instruction):
Code starting with the faulting instruction
===========================================
0: b9000001 .word 0xb9000001
结合模块的反汇编,就能定位到出错的汇编指令。
4. 使用内核脚本 faddr2line
这是内核开发者常用的一个脚本,位于 scripts/faddr2line。它可以根据“函数名+偏移量”直接输出源码位置。
scripts/faddr2line /home/zhongyi/code/module/oops_module/oops_module.ko init_oopsdemo+0x24
init_oopsdemo+0x24/0x30:
init_oopsdemo at /home/zhongyi/code/module/oops_module/oops_module.c:18
注意:如果要分析内核本身的Oops,需要将第一个参数替换为包含调试信息的 vmlinux 文件。
问题根源
最后,让我们看看导致这个Oops的源代码 oops_module.c:
#include <linux/init.h>
#include <linux/module.h>
MODULE_LICENSE("BSD/GPL");
MODULE_AUTHOR("ZHONGYI");
static int init_oopsdemo(void)
{
printk("oops module init! \n");
*((int*)0x00) = 0x19760817; // 第10行:向地址0写入数据
return 0;
}
module_init(init_oopsdemo);
static void cleanup_oopsdemo(void)
{
printk("oops module exit! \n");
}
module_exit(cleanup_oopsdemo);
问题一目了然:在模块初始化函数中,第10行代码 *((int*)0x00) = 0x19760817; 试图向空地址写入一个整数值,这直接导致了内核的空指针解引用Oops。
通过以上步骤,我们完成了一次完整的内核Oops分析。从解读晦涩的日志,到使用各种工具定位源代码中的Bug行,系统调试 的过程虽然复杂,但非常有章可循。掌握这些方法,将能极大地帮助你在进行 内核 模块或驱动开发时,高效地诊断和解决问题。