最近在调试一台嵌入式设备时,遇到了一个偶发的开机死机问题。通过查看系统启动日志,发现内核报告了一个oops错误,具体信息如下(中间省略了部分重复或次要的堆栈信息,以......代替):
Unable to handle kernel NULL pointer dereference at virtual address 0000000c
pgd = cdd90000
[0000000c] *pgd=8df4d831, *pte=00000000, *ppte=00000000
Internal error: Oops: 17 [#1] SMP ARM
CPU: 0 PID: 206 Comm: mount Tainted: P O 3.18.20 #4
task: ced40e40 ti: cdf7c000 task.ti: cdf7c000
PC is at exfat_fill_super+0xc8/0x4cc [exfat]
LR is at exfat_fill_super+0x48/0x4cc [exfat]
pc : [<bf64b670>] lr : [<bf64b5f0>] psr: a0080013
sp : cdf7de48 ip : ffffffff fp : c0744a30
r10: 00000001 r9 : bf652dac r8 : 00008000
r7 : cdf80000 r6 : cf302000 r5 : cdf85000 r4 : cdf41000
r3 : 00000000 r2 : cdf85104 r1 : 00000003 r0 : 000001b5
Flags: NzCv IRQs on FIQs on Mode SVC_32 ISA ARM Segment user
Control: 10c5387d Table: 8dd9006a DAC: 00000015
SP: 0xcdf7ddc8:
ddc8 cfa70880 fffffffc 0000000b cf17f800 cf4ea000 cf17f600 00000000 cfdee780
dde8 bf64b670 a0080013 ffffffff cdf7de34 00008000 c0012e18 000001b5 00000003
......
Process mount (pid: 206, stack limit = 0xcdf7c238)
Stack: (0xcdf7de48 to 0xcdf7e000)
de40: 00000001 cdf41000 cdf7deb0 cf17f60c 00000001 00008000
de60: cdf41000 cdf7c038 c0744a30 c0264164 bf652db4 cdf7de84 3b9aca00 00000004
de80: cf4ea6c0 00000083 cf4ea734 cf302000 cf4ea6c0 00000083 00008000 cdf41000
......
dfc0: 01197040 01197040 be9fff49 00000015 be9fff31 00008000 00000000 00000000
dfe0: b6e3d2e0 be9ffaf8 0007ebec b6e3d2f0 60080010 be9fff49 00000000 00000000
[<bf64b670>] (exfat_fill_super [exfat]) from [<c00d12ec>] (mount_bdev+0x168/0x190)
[<c00d12ec>] (mount_bdev) from [<bf64b0ac>] (exfat_fs_mount+0x18/0x20 [exfat])
[<bf64b0ac>] (exfat_fs_mount [exfat]) from [<c00d1fd4>] (mount_fs+0x14/0xcc)
[<c00d1fd4>] (mount_fs) from [<c00ea480>] (vfs_kern_mount+0x4c/0x104)
[<c00ea480>] (vfs_kern_mount) from [<c00ed214>] (do_mount+0x194/0xb54)
[<c00ed214>] (do_mount) from [<c00edf1c>] (SyS_mount+0x74/0xa0)
[<c00edf1c>] (SyS_mount) from [<c000e4e0>] (ret_fast_syscall+0x0/0x38)
Code: e5851108 e3a01003 e593300c e5933308 (e1d330bc)
从日志第一行就能明确看出,内核试图访问一个空指针(NULL pointer dereference),地址是 0x0000000c。结合调用栈信息,问题发生在挂载(mount)exfat格式文件系统的过程中,具体函数是 exfat_fill_super。
由于最近硬件环境有变动(更换了读卡器和更高速的存储卡),最初怀疑是硬件兼容性问题。但硬件变更如何精确导致代码级的空指针访问,逻辑上并不清晰。好在oops信息给出了非常关键的线索:程序计数器(PC)停在了 exfat_fill_super+0xc8/0x4cc。这就为我们指明了调查方向。
第一步:定位问题代码行
首先,在工程中搜索 exfat_fill_super 函数,确认它来自一个第三方开源的exfat文件系统驱动模块,该模块被编译为内核模块(.ko文件)在启动时加载。
接下来,我们需要知道 +0xc8 这个偏移具体对应哪一行C代码。最直接的方法是使用GDB进行反汇编定位。通过以下命令,我们找到了问题点:
(gdb) l *exfat_fill_super+0xc8
0x9670 is at ./exfat-nofuse-master/exfat_super.c:2301.
GDB告诉我们,问题发生在源文件 exfat_super.c 的第2301行。查看该行代码,内容如下:
2301 opts->fs_fmask = opts->fs_dmask = current->fs->umask;
这是一行连续的赋值语句。根据C语言规则,赋值从右向左进行,所以实际执行顺序是:
- 计算
current->fs->umask 的值。
- 将值赋给
opts->fs_dmask。
- 再将同一个值赋给
opts->fs_fmask。
因此,可能的空指针嫌疑点有三个:opts、current->fs、以及计算出结果后赋值的目标 opts->fs_dmask 和 opts->fs_fmask。结合oops报出的地址 0x0000000c(一个很小的偏移),opts 或 current 这类根指针为NULL的可能性更大。
第二步:层层递进,分析指针来源
我们逐一分析这些指针。
1. 分析 current
current 是一个宏,用于获取当前进程的 task_struct 结构体指针。在ARM平台上,它通过当前栈指针(SP)计算得来。oops日志中给出了 sp : cdf7de48 和 ti: cdf7c000(thread_info地址),计算是合理的。日志也明确打印了 task: ced40e40,证明 current 宏获取到的任务结构体指针非空。所以 current 本身不是NULL。
2. 分析 current->fs
fs 是 task_struct 结构体中一个指向 fs_struct 的指针,负责管理进程的文件系统信息(如根目录、当前工作目录等)。问题很可能出在这里:current 指针有效,但 current->fs 这个成员本身是 NULL。
为什么一个进程的 fs 结构体会是空呢?这通常发生在内核线程或某些早期初始化阶段的进程,它们可能没有设置完整的文件系统上下文。而 mount 进程理论上应该有,但这提示我们可能遇到了状态不一致的情况。
第三步:通过汇编和偏移确认罪魁祸首
仅仅怀疑还不够,我们需要确凿证据。oops日志底部的 Code: 部分给出了发生异常时CPU执行的机器码:
Code: e5851108 e3a01003 e593300c e5933308 (e1d330bc)
括号 () 内的指令 e1d330bc 就是触发异常的那一条。反汇编目标ko文件,找到 exfat_fill_super 函数开头偏移 0xc8(即地址0x9670)附近的代码,可以清晰地看到:
9660: e5851108 str r1, [r5, #264] ; 0x108
9664: e3a01003 mov r1, #3
9668: e593300c ldr r3, [r3, #12]
966c: e5933308 ldr r3, [r3, #776] ; 0x308
9670: e1d330bc ldrh r3, [r3, #12]
关键来了:
966c 行:ldr r3, [r3, #776]。这条指令从 r3+776 的地址加载数据到 r3。根据C代码逻辑,这对应着 current->fs 的操作,即从 task_struct 中取出 fs 成员。这里的 776 是 fs 在 task_struct 结构体中的偏移量。
9670 行:ldrh r3, [r3, #12]。这条指令从 r3+12 的地址加载半字数据到 r3。这对应着 fs->umask 的操作。此时如果 r3(即 fs 指针)为 NULL,那么访问 NULL+12 就会触发空指针异常,地址正好是 0x0000000c,与oops日志完全吻合。
至此,我们确认了:是 current->fs 指针为 NULL,导致访问 fs->umask 时崩溃。
第四步:探寻根本原因:偏移量为何“飘移”?
问题似乎找到了,但更深层的问题来了:mount 进程的 fs 指针为何会是 NULL?这不太符合常理。一个更诡异的线索出现了:上面汇编中 fs 的偏移量是 776 (0x308)。
为了调试,我在代码中打印了几个关键结构体成员的偏移量,得到:fs_offset=904。这与汇编里的 776 对不上!重新编译模块后查看新生成的汇编,发现 fs 的偏移量确实变成了 904 (0x388)。
同一个内核版本,同一个结构体,成员偏移量怎么会变?
答案指向一个经典问题:内核模块与内核版本不匹配。具体来说:
exfat.ko 内核模块是基于某个版本的内核源代码编译的,当时 task_struct 结构体中 fs 成员的偏移量是 776。
- 后来,内核的配置文件(
.config)发生了变更,例如启用了新的内核配置选项(如 CONFIG_PERF_EVENTS)。这些选项可能在 task_struct 中增加了新的成员,而新增成员的位置在 fs 成员之前,导致 fs 的实际偏移量向后移动,变成了 904。
- 系统升级时,只更新了内核镜像(
zImage),却没有重新编译 exfat.ko 模块。导致系统运行时,旧的模块(认为 fs 在 776 偏移处)去访问新内核(fs 实际在 904 偏移处)的数据。
- 从错误的偏移
776 读出来的数据是一个随机值(很可能就是 0),被当成 fs 指针,从而在后续访问时引发空指针异常。
第五步:验证与解决
为了验证这个猜想,我检查了内核的配置历史,发现确实后来为使能性能监控而打开了 CONFIG_PERF_EVENTS 选项。关闭此选项,保持与编译exfat模块时完全相同的配置,重新编译内核和模块,再次反汇编,fs 的偏移量果然变回了 776。
解决方案 非常明确:确保所有内核模块与当前运行的内核是严格匹配的。在更新内核或内核配置后,必须重新编译所有依赖的内核模块。在嵌入式开发中,这意味着一套完整的固件(包含内核和所有模块)应该作为一个整体进行版本管理。
经验总结
- oops是朋友:内核oops信息包含大量调试宝藏,如PC位置、堆栈、寄存器值和指令码,是定位问题的起点。
- 偏移量是关键线索:在内核模块相关故障中,结构体成员偏移量的异常往往是版本不匹配的“烟雾弹”。
- 模块与内核需同步:无论是桌面Linux还是嵌入式系统,在更新内核后,必须考虑对已加载内核模块的影响。它们是一个整体,版本必须同步。
- 调试需要耐心与推理:从表象(死机)到日志(oops),再到代码(C和汇编),最后结合系统状态(内核配置)进行推理,是解决复杂内核问题的标准路径。这种抽丝剥茧的分析过程,本身也是对系统理解的深化。
这次排查经历,始于一个模糊的硬件怀疑,终结于一个清晰的软件配置问题,再次体现了系统软件调试中保持严谨逻辑的重要性。