在之前的系列文章中,我们探讨了 printk 的原理与使用以及动态输出 dynamic debug 等内核调试手段。本篇我们将通过一个具体的实验,深入分享如何分析和定位 Linux 内核中最常见的运行时错误之一 —— Oops。
本篇环境
- 硬件平台:飞凌 OK3588 开发板
- 编译环境:Ubuntu 20.04 LTS
- 编译工具链:aarch64-linux-gnu-
Oops 简介
对于嵌入式 Linux 底层开发人员来说,Oops 应该是一个比较常见的问题。它指的是 Linux 内核在发生不正确行为(例如访问非法地址)时产生的一份错误报告。这份报告包含了异常发生时的详细上下文信息:
- 出错原因
- CPU 寄存器状态
- 出错的指令地址和数据地址
- 函数调用栈回溯信息
- 甚至栈内存内容
内核会根据异常的严重程度来决定下一步操作:杀死导致异常的进程,或者直接挂起系统(panic)。通常,发生在进程上下文(非中断上下文)中的 Oops 会导致该进程以一个信号退出;但如果 Oops 发生在中断上下文,就会直接引发系统 panic。如果内核配置了 panic_on_oops 参数,那么任何 Oops 都会导致系统宕机。
那么,当我们拿到一份 Oops 错误报告时,应该如何进行分析呢?接下来我们通过一个实例来详细讲解。
Oops 错误实例
首先,我们来看一个制造空指针解引用 Oops 的简单内核模块示例:
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/init.h>
static void create_oops(void)
{
*(int *)0 = 0;
}
static int __init my_oops_init(void)
{
printk("oops module init\n");
create_oops();
return 0;
}
static void __exit my_oops_exit(void)
{
printk("oops module exit\n");
}
module_init(my_oops_init);
module_exit(my_oops_exit);
MODULE_LICENSE("GPL");
对应的 Makefile 内容如下:
KERNELDIR ?= /home/forlinx/OK3588_Linux_fs/kernel
obj-m += oops_test.o
all: modules
modules:
$(MAKE) ARCH=arm64 CROSS_COMPILE=aarch64-none-linux-gnu- -C $(KERNELDIR) M=$(shell pwd) modules
modules_install:
$(MAKE) -C $(KERNELDIR) M=$(shell pwd) modules_install
clean:
$(MAKE) -C $(KERNELDIR) M=$(shell pwd) modules clean
将模块编译后加载到开发板上,会立即触发 Oops,关键日志如下:
[ 173.330566] oops_test: loading out-of-tree module taints kernel.
[ 173.331004] oops module init
[ 173.331011] Unable to handle kernel NULL pointer dereference at virtual address 0000000000000000
[ 173.331016] Mem abort info:
[ 173.331018] ESR = 0x96000045
[ 173.331021] EC = 0x25: DABT (current EL), IL = 32 bits
[ 173.331024] SET = 0, FnV = 0
[ 173.331026] EA = 0, S1PTW = 0
[ 173.331028] Data abort info:
[ 173.331030] ISV = 0, ISS = 0x00000045
[ 173.331032] CM = 0, WnR = 1
[ 173.331034] user pgtable: 4k pages, 39-bit VAs, pgdp=0000000108782000
[ 173.331038] [0000000000000000] pgd=0000000000000000, p4d=0000000000000000, pud=0000000000000000
[ 173.331047] Internal error: Oops: 96000045 [#1] PREEMPT_RT SMP
[ 173.331052] Modules linked in: oops_test(O+)
[ 173.331060] CPU: 0 PID: 1458 Comm: insmod Tainted: G O 5.10.66-rt53 #21
[ 173.331065] Hardware name: Forlinx OK3588 Board (DT)
[ 173.331067] pstate: 60400009 (nZCv daif +PAN -UAO -TCO BTYPE=--)
[ 173.331072] pc : my_oops_init+0x28/0x1000 [oops_test]
[ 173.331082] lr : my_oops_init+0x24/0x1000 [oops_test]
[ 173.331088] sp : ffffffc01391bb20
[ 173.331091] x29: ffffffc01391bb20 x28: ffffff811e6db3b8
[ 173.331096] x27: 0000000000000003 x26: 0000000000000000
[ 173.331102] x25: 0000000000000019 x24: 0000000000000000
[ 173.331106] x23: 0000000000000000 x22: ffffffc011fa28c0
[ 173.331111] x21: ffffffc011fa4380 x20: ffffffc009035000
[ 173.331116] x19: ffffffc011fa2900 x18: 0000000000000000
[ 173.331121] x17: 0000000000000000 x16: 0000000000000000
[ 173.331126] x15: 180f0a0700000000 x14: 00656c75646f6d5f
[ 173.331132] x13: 0000000000000000 x12: 0000000000000018
[ 173.331136] x11: 0101010101010101 x10: ffffffff7f7f7f7f
[ 173.331141] x9 : ffffffc0100a07d0 x8 : 74696e6920656c75
[ 173.331146] x7 : 646f6d2073706f6f x6 : ffffffc012055ae9
[ 173.331151] x5 : ffffffc012055ae8 x4 : ffffff81feeb1b70
[ 173.331156] x3 : 0000000000000000 x2 : 0000000000000000
[ 173.331161] x1 : ffffff8119b2eac0 x0 : 0000000000000000
[ 173.331166] Call trace:
[ 173.331169] my_oops_init+0x28/0x1000 [oops_test]
[ 173.331176] do_one_initcall+0xb4/0x210
[ 173.331183] do_init_module+0x68/0x210
[ 173.331191] load_module+0x1cb4/0x2258
[ 173.331196] __do_sys_finit_module+0xe0/0x100
[ 173.331202] __arm64_sys_finit_module+0x28/0x34
[ 173.331207] el0_svc_common.constprop.0+0x154/0x204
[ 173.331213] do_el0_svc+0x8c/0x98
[ 173.331218] el0_svc+0x20/0x30
[ 173.331224] el0_sync_handler+0xd8/0x184
[ 173.331229] el0_sync+0x1a0/0x1c0
...
[ 173.336788] Code: 910003fd 91000000 95fee5c3 d2800000 (b900001f)
[ 173.336793] ---[ end trace 0000000000000002 ]---
面对这样一大段看起来复杂的日志,我们该如何入手分析呢?
Oops 错误分析方法
前期日志分析
首先,我们逐段解读关键信息。
-
模块加载提示:
[ 173.330566] oops_test: loading out-of-tree module taints kernel.
这表示加载了一个“树外”(未在内核源码树内编译)的模块,这会使内核状态被“污染”。通常是因为内核启用了签名验证机制,而我们编译的模块没有签名。
-
错误类型:
Unable to handle kernel NULL pointer dereference at virtual address 0000000000000000
直接指出了错误本质:内核空指针解引用。虚拟地址 0x0000000000000000 明确告诉我们,代码试图访问空指针指向的内存。
-
错误码与内核特性:
[ 173.331047] Internal error: Oops: 96000045 [#1] PREEMPT_RT SMP
96000045 是 ARM64 架构特定的错误码(ESR)。[#1] 表示此 Oops 信息是第一次显示。PREEMPT_RT SMP 表明当前内核配置了实时抢占并支持多核。
Oops 错误码的通用定义(部分架构)可以参考内核文档,通常各位含义如下:
- bit 0 == 0 表示页面未找到,1 表示保护错误。
- bit 1 == 0 表示读操作,1 表示写操作。
- bit 2 == 0 表示内核模式,1 表示用户模式。
- bit 3 == 0 表示数据访问,1 表示指令获取。
-
加载的模块:
[ 173.331052] Modules linked in: oops_test(O+)
Modules linked in 列出了已加载的模块,这里只有 oops_test。(O+) 中的 O 表示这是一个 Out-of-tree 模块。
-
CPU、进程与污染状态:
[ 173.331060] CPU: 0 PID: 1458 Comm: insmod Tainted: G O 5.10.66-rt53 #21
[ 173.331065] Hardware name: Forlinx OK3588 Board (DT)
CPU: 0:错误发生在 CPU 0 上。
PID: 1458 Comm: insmod:触发错误的进程是 insmod,进程ID为1458。
Tainted: G O:内核污染标志。G 表示所有模块都有GPL兼容许可证,O 表示加载了外部(Out-of-tree)模块。
5.10.66-rt53 #21:内核版本信息。
Hardware name:硬件平台名称。
-
关键寄存器:
[ 173.331072] pc : my_oops_init+0x28/0x1000 [oops_test]
[ 173.331082] lr : my_oops_init+0x24/0x1000 [oops_test]
[ 173.331088] sp : ffffffc01391bb20
pc (程序计数器):指向出错地址,位于 oops_test 模块的 my_oops_init 函数偏移 0x28 字节处。0x1000 是该函数的估计大小。
lr (链接寄存器):指向返回地址,位于同一函数偏移 0x24 字节处。
sp (栈指针):当前栈顶地址。
-
调用栈回溯:
[ 173.331166] Call trace:
[ 173.331169] my_oops_init+0x28/0x1000 [oops_test]
[ 173.331176] do_one_initcall+0xb4/0x210
...
这是最宝贵的调试信息之一,清晰地展示了从错误发生点开始的函数调用链,帮助我们理解代码的执行路径。
-
机器码:
[ 173.336788] Code: 910003fd 91000000 95fee5c3 d2800000 (b900001f)
这是出错位置附近的原始机器指令,对于没有源代码的情况尤为重要。
初步判断错误位置
解读完 Oops 日志,我们需要判断错误发生在内核函数还是驱动模块中。System.map 文件记录了内核所有符号的地址。我们查看内核编译生成的 System.map,找到内核函数的地址范围(例如 ffffffc010000000 ~ ffffffc0120d0000)。而我们的 PC 值 0xffffffc009034f28 显然不在这个范围内,因此可以断定是某个驱动模块(即我们的 oops_test)引起的错误。
注意:如果将模块直接编译进内核(built-in),那么错误就会被视为内核函数错误,PC 地址也会落在 System.map 的范围内。
既然确定是模块错误,并且 Oops 信息也给出了模块名和函数名,接下来的分析就更有针对性了。下面介绍三种定位出错代码行的实用工具。
objdump 工具分析
从 Oops 信息我们已知错误出在 oops_test 模块的 my_oops_init 函数。我们可以使用交叉编译工具链中的 objdump 工具反汇编该模块,进一步确认。
$ aarch64-linux-gnu-objdump -Sd oops_test.o
oops_test.o: file format elf64-littleaarch64
Disassembly of section .init.text:
0000000000000000 <init_module>:
0: d503245f bti c
4: d503201f nop
8: d503201f nop
c: d503233f paciasp
10: a9bf7bfd stp x29, x30, [sp, #-16]!
14: 90000000 adrp x0, 0 <init_module>
18: 910003fd mov x29, sp
1c: 91000000 add x0, x0, #0x0
20: 94000000 bl 0 <printk>
24: d2800000 mov x0, #0x0 // #0
28: b900001f str wzr, [x0]
2c: a8c17bfd ldp x29, x30, [sp], #16
30: d50323bf autiasp
34: d65f03c0 ret
在反汇编输出中,我们关注偏移量 0x28 对应的指令(与 Oops 中 pc : my_oops_init+0x28 对应):
24: d2800000 mov x0, #0x0:将 0 赋值给 x0 寄存器。
28: b900001f str wzr, [x0]:将零寄存器 wzr(其值恒为0)的值存储到 x0 寄存器所指向的内存地址。
这里 x0 的值为 0,所以 str wzr, [x0] 就是在向地址 0 写入 0,这正是导致“空指针解引用”的指令。
addr2line 工具分析
addr2line 是 GNU Binutils 中的工具,它能将地址转换为对应的文件名和行号,前提是二进制文件携带调试信息(-g 选项)。
我们可以用它来分析我们的模块。首先确保模块编译时添加了 -g 选项(例如在 Makefile 中添加 KBUILD_CFLAGS += -g)。然后执行:
$ aarch64-linux-gnu-addr2line -e oops_test.o -p -f 0x28
create_oops at /home/forlinx/test/oops/oops_test.c:7
或者直接分析带调试信息的 .ko 文件:
$ aarch64-linux-gnu-addr2line -e oops_test.ko -p -f 0x28
create_oops at /home/forlinx/test/oops/oops_test.c:7
输出清晰地告诉我们,地址 0x28 对应的是 create_oops 函数,位于源文件 /home/forlinx/test/oops/oops_test.c 的第 7 行。查看源代码,第7行正是 *(int *)0 = 0;。
提示:如果 Oops 发生在内核自身(vmlinux),只需将 oops_test.ko 替换为 vmlinux 文件即可用相同方法分析。
decodecode 工具分析
前面两种方法都需要源代码。decodecode 工具则适用于没有源代码或符号表的情景。它位于内核源码的 scripts/ 目录下,可以直接解析 Oops 日志中的机器码。
使用方法:
- 设置环境变量,指向正确的架构和工具链。
$ export ARCH=arm64
$ export CROSS_COMPILE=/home/forlinx/OK3588_Linux_fs/prebuilts/gcc/linux-x86/aarch64/gcc-arm-10.3-2021.07-x86_64-aarch64-none-linux-gnu/bin/aarch64-none-linux-gnu-
- 将 Oops 日志中从
Code: 开始的那一行及上下文保存到一个文本文件(如 oops.txt)。
- 运行
decodecode 脚本。
$ ./scripts/decodecode < oops.txt

执行后,工具会输出反汇编结果,并明确标出导致陷阱的指令(trapping instruction)。从输出中可以看到出错的指令是 str wzr, [x0],这与我们之前用 objdump 分析的结果一致。
总结
本篇通过一个实际的空指针 Oops 案例,详细介绍了三种分析定位错误的方法:
- 日志分析法:仔细阅读 Oops 报告,获取错误类型、发生位置(模块、函数、偏移)、调用栈等关键信息。
- objdump 反汇编:直接反汇编目标文件,查看出错地址对应的汇编指令,理解底层操作。
- addr2line 定位源码行:在拥有调试信息的情况下,快速将地址映射到源代码文件和行号,效率最高。
- decodecode 解析机器码:在没有源码时,利用内核自带工具解析 Oops 中的机器码,获取汇编指令。
这三种方法各有适用场景,熟练掌握后能极大地提升内核问题调试效率。希望本文能为大家的内核开发与调试工作带来帮助。如果你有更多关于内核调试的技巧或心得,欢迎在云栈社区交流讨论。
