半夜两点被值班电话叫起来,屏幕上滚动着一行熟悉的英文——unable to handle kernel null pointer dereference at virtual address,后面跟着一堆看不懂的寄存器值和调用栈。这种场景对每一个搞Linux内核或者驱动开发的人来说都不陌生。说句实话,内核调试这东西,入门靠的是查资料,但真正能让你“稳准狠”定位问题的,还是那条一条踩出来的经验路径:从崩溃现场收集信息,到反汇编定位源码行,再到动态调试验证猜想,每一步都有章可循,也有不少坑等着你跳。
这篇文章是我这些年做Linux内核调试的记录汇总。我没有打算给你讲完整的内核原理,也不会去贴大段的理论文档,而是把实际工作中最常用的调试思路、工具组合、内核配置项和实战案例拆开揉碎了说清楚。无论你是刚接触内核模块开发的新人,还是和我一样被高通CAF kernel、设备驱动、文件系统拦截这类问题折磨过的老手,这篇文章都能给你一套可以“抄作业”的排查路径。
需要提前说明的是,文中的命令、代码片段和配置项是我在常见内核版本和实际项目中验证过的。内核代码在不同版本之间差异较大,具体落地时请以你手上的内核源码和交叉编译工具链为准。
1. 崩溃现场还原:从一条Oops消息开始定位
内核调试和外层应用调试最大的区别在于,应用挂了最多就是一个core dump,而内核一旦挂掉,往往是整个系统直接panic,或者在日志里留下一段Oops信息后勉强续命。所以第一步永远是搞清楚:现场到底留下了什么。
1.1 区分Oops和Panic,先判断系统还能不能救
很多新手分不清Oops和Panic的区别。简单说,Oops是内核检测到异常(比如空指针解引用、非法指令、访问了错误的内存地址)后打印的一堆诊断信息,如果这个异常发生在进程上下文且不影响内核核心状态,系统可能会继续运行;而Panic是内核发现已经无法继续维护稳定状态,主动停机或重启。
判断标准很简单,看日志末尾是否有Kernel panic - not syncing,以及系统是否还能回显shell。还有一个关键参数/proc/sys/kernel/panic_on_oops,如果这个值是1,那么任何Oops都会直接升级成Panic。
调试时的建议是:如果你是想快速复现问题并在本地抓日志,把panic_on_oops设成1,让系统崩溃后自动重启更干脆,配合kdump能拿到完整的内存转储;如果你是线上环境想要尽量保活,或者系统本身已经挂了,那就得靠串口日志和pstore来捞最后一段信息。
1.2 收集第一手崩溃日志的三种手段
崩溃日志是后续所有分析的唯一依据。我实际用的最多的收集方式有三种,按优先级排列:
串口控制台日志:嵌入式设备最常见的方式。在
cmdline里加上console=ttyS0,115200,通过串口工具把内核启动及运行日志全部记录下来。调试内核时,我习惯把loglevel=8也加上,保证printk的所有级别都能输出。这种方式最可靠,因为即使系统完全死掉,串口上最后输出的内容就是内核的“临终遗言”。pstore/ramoops:如果没有串口线,或者设备已经量产出厂了,那就依赖
pstore框架。在设备树或内核配置里打开CONFIG_PSTORE和CONFIG_RAMOOPS,指定一段保留内存,崩溃时内核会把console log、dmesg、ftrace的内容存到那段内存里,重启后从/sys/fs/pstore/目录读取。kdump:这是最能还原现场的手段。kdump会在系统崩溃时启动一个捕获内核,把崩溃内核的内存镜像(vmcore)保存下来,之后就能用
crash工具离线分析了。配置kdump稍微复杂,但对复杂的内核问题(比如内存踩踏、死锁)几乎是必须的。
还有一种容易被忽略的路径:如果系统还活着,赶紧执行dmesg把内核环形缓冲区的日志导出来,或者直接cat /proc/kmsg。环形缓冲区的大小可以通过内核配置CONFIG_LOG_BUF_SHIFT调整,我习惯把它调大(比如17,即128KB),这样能多存很多历史日志。
1.3 用一份Oops日志还原崩溃现场
先看一条最常见的空指针崩溃日志(格式因内核版本和架构略有差异):
[ 4.588729] Unable to handle kernel NULL pointer dereference at virtual address 0000000000000000 [ 4.588729] Mem abort info: [ 4.588729] ESR = 0x96000046 [ 4.588729] EC = 0x25: DABT (current EL), IL = 32 bits [ 4.588729] SET = 0, FnV = 0 [ 4.588729] EA = 0, S1PTW = 0 [ 4.588729] Data abort info: [ 4.588729] ISV = 0, ISS = 0x00000046 [ 4.588729] CM = 0, WnR = 1 [ 4.588729] user pgtable: 4k pages, 48-bit VAs, pgdp=0000000041ca8000 [ 4.588729] [0000000000000000] pgd=0000000000000000, p4d=0000000000000000, pud=0000000000000000 [ 4.588729] Internal error: Oops: 96000046 [#1] SMP [ 4.588729] Modules linked in: oops_mod [last unloaded: oops_mod] [ 4.588729] CPU: 0 PID: 123 Comm: kworker/u2:1 Not tainted 5.10.120 [ 4.588729] Hardware name: Some Board [ 4.588729] pstate: 80000005 (Nzcv daif -PAN -UAO -TCO BTYPE=--) [ 4.588729] pc : my_function+0x18/0x40 [oops_mod] [ 4.588729] lr : worker_thread+0x1ec/0x480 [ 4.588729] sp : ffff000011e2be30 [ 4.588729] x29 : ffff000011e2be30 [ 4.588729] x0 : 0000000000000000 [ 4.588729] x1 : ffff800012345678 [ 4.588729] x2 : 0000000000000002 ... [ 4.588729] Call trace: [ 4.588729] my_function+0x18/0x40 [oops_mod] [ 4.588729] worker_thread+0x1ec/0x480 [ 4.588729] kthread+0x13c/0x160 [ 4.588729] ret_from_fork+0x10/0x18 [ 4.588729] Code: f9400000 91009000 f9400001 f9400021 (f9400000)这段日志的信息量非常大,解析时需要抓住几个关键点:
崩溃地址:
virtual address 0000000000000000,说明是向地址0发起了写操作(注意WnR = 1表示写)。日志里标注了是NULL pointer dereference,但在ARM架构上看到Data abort info时要反推是读还是写,因为指令在PC里能看到,而数据访问要结合ESR判断。PC指针:
pc : my_function+0x18/0x40 [oops_mod]指崩溃时CPU正在执行的指令在函数my_function偏移0x18处,函数整体大小0x40字节,属于模块oops_mod。如果你的内核开了CONFIG_DEBUG_INFO,还可用addr2line直接翻译成源码行号;如果没开,就只能靠反汇编。调用栈:
Call trace提供了函数调用关系。这里的栈是从上到下调用顺序的反序,即my_function是被worker_thread调用的,而worker_thread又是内核线程的入口。这告诉我们崩溃发生在哪个执行上下文里,也就是“谁干的”。寄存器状态:
x0的值是0,非常典型——x0通常是函数第一个参数,这里为0说明某处把一个NULL指针当作this指针或者参数传了进来。代码段:
Code:后面的字节就是崩溃点的机器码。利用objdump反汇编后可以对照PC偏移找到具体是哪条指令触发了异常。
拿到这些信息之后,剩下的工作就是定位:把my_function+0x18翻译成源码行,看看是哪个指针在什么场景下变成了NULL。
1.4 用addr2line和objdump把地址翻译成源码行
如果你的内核在编译时打开了CONFIG_DEBUG_INFO,那这一步非常简单。对于内核本体:
# 找到vmlinux(带符号表的内核镜像,不要用压缩后的zImage) addr2line -e vmlinux -f ffff000008001234对于内核模块,不能直接用模块的.ko文件,需要先拿到构建模块时的符号信息。我在实际项目中维护了一个“调试脚本”,把编译目录下的Module.symvers、vmlinux和各个.ko保存好,崩溃后直接跑:
# 模块崩溃时,使用模块的符号偏移 arm64-linux-gnu-addr2line -e oops_mod.ko -f my_function+0x18如果恰好没有CONFIG_DEBUG_INFO,那就用objdump反汇编整个模块或vmlinux,手动对比PC偏移:
arm64-linux-gnu-objdump -d oops_mod.ko | grep -A 50 "<my_function>:"从反汇编里找到my_function+0x18对应的指令,再配合寄存器值推测出错原因。比如上面的日志中,Code段最后一条指令是f9400000(ldr x0, [x0]),而x0是0,所以指令尝试从地址0读取数据——空指针读。看到这个基本就能确认,是某个指针变量没有被正确初始化或赋值,就着急解引用了。
2. 准备工作:你知道该打开哪些内核调试选项吗
很多人拿到一个崩溃日志,第一反应是百度搜报错字符串,而不是先检查自己的内核开了哪些调试功能。实际上,一个“配置得当”的调试内核能让你省掉一半的排查时间。我建议在开发阶段就建立一个专用的调试内核配置,和发布版配置分开维护。
2.1 必开的内核调试选项
以下配置项是我日常调试内核时的标配,它们在menuconfig里的位置不同版本稍有差异,但名字基本稳定:
| 配置项 | 作用 | 说明 |
|---|---|---|
CONFIG_DEBUG_INFO | 生成DWARF调试信息 | 配合gdb、addr2line等工具,否则符号丢失、参数变偏移 |
CONFIG_DEBUG_KERNEL | 总开关 | 依赖它才能打开下面的调试项 |
CONFIG_KASAN | 内存越界/释放后使用检测 | 对性能影响大,但调试指针悬空、越界写入非常有用 |
CONFIG_KCOV | 代码覆盖率采集 | 做fuzz或需要了解哪些路径被执行时用 |
CONFIG_KGDB | 内核调试器 | 可以通过串口或网络下断点、单步,适合疑难杂症 |
CONFIG_FTRACE | 函数追踪 | 动态追踪函数调用,性能开销小,适合日常使用 |
CONFIG_KPROBES | 动态探针 | 在不修改代码、不重新编译内核的情况下挂钩函数 |
CONFIG_DYNAMIC_DEBUG | 动态printk | 允许运行时开关指定文件的日志输出 |
CONFIG_PANIC_ON_OOPS | Oops直接panic | 配合kdump或自动重启使用 |
CONFIG_PSTORE | 崩溃日志持久化 | 我前面提到过,没有串口时靠它救命 |
这里特别想强调一下CONFIG_DEBUG_INFO。我见过太多人用发行版内核(比如Ubuntu的generic内核)调试,日志里全是[<0>] ? ? ?,什么符号都看不到,那种感觉真的抓瞎。所以如果你要调试内核,务必自己编译内核并带上调试符号。如果实在不方便编译,至少也要拿linux-source包里的符号表或者发行版提供的kernel-debuginfo包。
CONFIG_KASAN也是神器级选项。它能在你访问越界内存的第一时间(而不是在几百行代码之后)触发报告,直接告诉你越界的地址、size和调用栈。但它对性能影响非常明显,不适合在量产版上开启。我一般只在专门的调试内核里开,并且在问题复现难、怀疑内存被踩时才用。
2.2 用KGDB给内核下断点
如果上面这些静态调试手段不够用,需要真正“下断点单步”的时候,KGDB就派上用场了。KGDB是一个内置于内核的调试器,需要通过串口或以太网与主机上的gdb通信。
打开CONFIG_KGDB之后,启动参数里加上kgdboc=ttyS0,115200(串口)或kgdboe=eth0(网络),然后通过主机的gdb连接。比如我用串口的场景:
gdb vmlinux (gdb) target remote /dev/ttyUSB0 (gdb) hbreak my_function (gdb) continue我实际使用的体会是,KGDB适合调试时序问题、死锁、以及那些“代码走到某个分支就出错但是从日志看不出来”的问题。但它也有很明显的限制:一旦打断点的位置处于原子上下文或中断禁用区域,系统可能卡死。所以我会先确认打断点的函数是否可能在spinlock保护区内执行。另外,KGDB本身是个内核模块的“基础设施”,不是所有平台都支持得非常好,ARM架构上要留意串口中断是否会被调试中断打断——用网络方式相对稳一些。
2.3 动态调试配置与printk的等级控制
printk是最基础也最常用的调试手段,但直接把printk打满整个代码会污染日志、影响性能。Linux提供了dynamic debug机制,允许在运行时动态地控制某个文件、某个函数的打印开关。
配置方法:
# 挂载debugfs mount -t debugfs none /sys/kernel/debug # 打开指定文件所有pr_debug echo 'file drivers/i2c/busses/i2c-xxx.c +p' > /sys/kernel/debug/dynamic_debug/control # 打开指定函数 echo 'func my_function +p' > /sys/kernel/debug/dynamic_debug/control这个方法要求你内核源码里用的是pr_debug、dev_dbg这类可动态控制的打印宏。如果你用的是printk(KERN_INFO...),那是没法在运行时开关的。所以写代码时养成用dev_dbg而不是裸printk的习惯,对后期调试非常友好。
再补充一个常用技巧:如果你用的是Android或嵌入式平台,很多日志会默认被屏蔽,记得看/proc/sys/kernel/printk里的四个数值,分别代表控制台日志级别、默认日志级别、最小级别、最大级别。排查问题前先看一眼,必要时手动调低控制台级别:
echo "7 4 1 7" > /proc/sys/kernel/printk这样连KERN_DEBUG级别的日志都能打到串口上。
3. 动态调试三板斧:printk、ftrace和kprobe
拿到崩溃地址、翻译完源码行还只是第一步。很多时候你并不想在崩溃现场死磕,而是想搞清楚“这个指针为什么是NULL”,或者“这段代码在真实运行中到底走了哪条路径”,这就需要用动态手段来观察内核的运行状态。
3.1 ftrace:内核函数追踪的瑞士军刀
ftrace是内核自带的功能追踪器,能在几乎不改代码的情况下记录函数的调用关系、执行时间和延迟。我排查“莫名其妙超时”、“函数根本没执行”这类问题时,第一步就是用ftrace确认函数有没有被调用到。
使用步骤非常简单:
# 挂载tracefs mount -t tracefs nodev /sys/kernel/tracing # 查看当前支持的追踪器 cat /sys/kernel/tracing/available_tracers # 使用function_graph追踪器,能画出函数调用图和执行时间 echo function_graph > /sys/kernel/tracing/current_tracer # 只追踪特定函数 echo 'my_function' > /sys/kernel/tracing/set_ftrace_filter # 开始/结束追踪 echo 1 > /sys/kernel/tracing/tracing_on # ... 复现问题 ... echo 0 > /sys/kernel/tracing/tracing_on # 查看结果 cat /sys/kernel/tracing/trace如果觉得function_graph输出太乱,还可以用后面的latency类追踪器和events子系统。比如追踪特定事件(如kmalloc、schedule)结合stacktrace,能看到某次内存分配是由哪个调用链触发的。
注意,CONFIG_FTRACE是x86、ARM64等主流架构上默认打开的,但嵌入式设备常因为性能考虑被关掉。如果/sys/kernel/tracing目录不存在,就要检查内核配置了。
3.2 kprobe:不重新编译也能下探针
ftrace控制的是“函数入口和出口”,而kprobe允许你在函数的任意指令位置插入探针,并且能读取寄存器、修改参数、记录返回值,几乎是内核调试里的“手术刀”。
在使用kprobe时,我通常用两种方式:
第一种,直接通过/sys/kernel/debug/tracing/kprobe_events接口:
# 在函数入口注册探针,并把第一个参数(x0)和第二个参数(x1)打印出来 echo 'p:my_probe my_function arg1=%x0 arg2=%x1' > /sys/kernel/debug/tracing/kprobe_events # 开启事件 echo 1 > /sys/kernel/debug/tracing/events/kprobes/my_probe/enable # 复现问题后查看 cat /sys/kernel/debug/tracing/trace第二种,在主机上写一个小的eBPF程序去attach kprobe。这种方法更灵活,能拿到内核结构体字段、做条件过滤。不过eBPF在内核模块调试里还是有些门槛的,需要BCC工具链或者libbpf,但其好处是允许你在不修改内核源码和重新编译的情况下实时观测。
我记得有一次排查一个悬空指针问题,就是在某个驱动函数的入口处用kprobe把指针值打出来,连续跑了几轮后发现指针在第三次调用时变成了不可达地址,于是顺藤摸瓜找到了释放内存的地方。如果没有kprobe,这种偶发问题真的会让头发少一半。
3.3 动态printk与trace_printk的取舍
有人会问:“printk和trace_printk有什么区别?”我的理解是,传统的printk会把日志写到环形缓冲区甚至串口,如果打印太频繁会拖慢系统、甚至导致看门狗复位;而trace_printk是专门写在ftrace里头的,日志只会进入trace缓冲区,开销小得多,适合高频打印。
使用trace_printk的姿势:
trace_printk("enter my_function, arg=%d\n", arg);无需额外配置,但前提也是内核开了CONFIG_TRACEPRINTK。它不会被动态debug的control文件开关控制,而是只要ftrace处于开启状态就会记录。
我个人的习惯是:需要长期保留、线上也会开的日志用pr_info或dev_info;开发期排查路径问题用trace_printk;调试完要删掉的临时日志用pr_debug配合动态debug功能。
这里额外提一个坑:trace_printk里不能直接打印所有类型,比如%p这类格式化在某些版本上支持有限。打印地址时建议直接用%px或拆成unsigned long,避免花式踩坑。
4. 实战案例:追查一次空指针解引用的完整过程
理论讲再多,不如走一遍真实案例。下面是我在某个嵌入式项目里遇到过的一个很典型的空指针崩溃,我把排查的关键节点和思考过程整理出来。
4.1 现象与第一轮排查
设备开机后,在kworker线程里偶发出现NULL pointer dereference,并且有一个非常诡异的规律:只在冷启动时概率出现,热重启几乎必现。崩溃栈指向一个自定义驱动模块的xxx_poll函数,偏移在xxx_poll+0x20左右。
第一反应是看这个函数里有没有解引用用户传入的指针。翻代码后发现,xxx_poll函数是从一个全局链表里取设备对象,然后调用了设备的read操作。它原本的判断逻辑是:
if (!dev || !dev->ops) { return 0; } return dev->ops->read(dev, buf, len);看上去像是空指针检查做过了,但为什么还会崩?
于是我用addr2line把xxx_poll+0x20翻译成源码行,发现崩溃点根本不是dev->ops->read那一行,而是在前面从链表取节点的宏展开里,具体是list_for_each_entry内部的container_of。也就是说,链表本身被破坏了,拿到一个非法指针,后续解引用必然出问题。
这给我一个教训:看到“空指针”别只盯着指针,很可能是更早的内存踩踏或链表损坏,只是巧合表现为解引用NULL。
4.2 借助KASAN锁定内存踩踏源头
既然怀疑链表被踩,那就得找出是谁往链表里写了坏数据。
做法是:专门编一个打开CONFIG_KASAN和CONFIG_DEBUG_LIST的内核,把这个驱动模块也重新编译一遍,然后反复做冷启动测试。CONFIG_DEBUG_LIST会在链表插入、删除时强制校验一致性,一旦发现异常会立即报出被破坏节点的地址和调用栈。
经过十几个小时的循环跑测,KASAN终于报了一笔账:某次kmalloc申请的缓冲区大小为128字节,但后面有一个memcpy写了约256字节,越界部分覆盖了相邻内存,正好是链表节点所在的对象。源头是驱动初始化时错误地估计了一个数据包的最大长度,在极端场景下缓冲区溢出。
修复很简单,把那个缓冲区的分配大小改成按实际最大包长计算。但这里更值得记下来的是排查思路:如果只靠肉眼看代码,这种越界大概率要很久才能发现,而KASAN能直接帮你把作案现场圈出来。
4.3 没有KASAN时怎么办:利用ftrace和kprobe缩小范围
并不是每次崩溃都能用上KASAN,比如你在客户现场、跑的是release内核,根本没法替换。这时纯动态调试就非常重要了。
我再分享一个场景:同样是在自定义驱动里,用户态通过ioctl触发了一个操作,结果内核在某个工作队列里崩溃。打开KASAN不现实,现场又限制只能通过和printk观察。我的操作如下:
第一,在可疑函数入口和出口用kprobe记录参数和执行状态,确认函数本身有没有入、出异常。
第二,用trace-cmd记录系统级的函数调用序列,把崩溃前的几十个函数调用关系拉出来,看看到底是哪个函数最后一次写入了某个寄存器或内存。
第三,在驱动的关键路径上临时用动态debug打印关键变量的值和指针地址,观察其变化规律。
最后,通过三个数据源交叉比对,发现工作队列里用到的一个struct xxx_context指针在ioctl的处理路径中被某条分支覆盖成了局部变量地址,退出作用域后悬空。到了队列执行阶段,这个悬空地址被当成有效对象解引用,于是崩溃。
这种问题如果用静态代码审查,效率非常低。动态追踪手段能让你直接从“数据流”角度发现问题,对于内存类故障特别有效。
4.4 高通CAF内核与通用内核的差异
题外话,有不少人在高通平台上做内核开发,这里多说两句。高通CAF(Code Aurora Forum)的内核是基于上游Linux kernel加高通自家驱动修改来的,代码量非常大,而且很多修改并没有跟随upstream的惯例提交到主线。
所以你在网上搜到的主流内核调试方法(比如某些ftrace的event名称、某些proc节点的位置)在高通CAF上可能对不上。我遇到过的最典型问题:
/sys/kernel/tracing路径在高通内核里可能仍是旧的/sys/kernel/debug/tracing;- 某些debugfs节点需要先挂载
debugfs,而高通的根文件系统默认可能不挂载; - 内核版本停留在4.9、4.14这种“老旧”版本,有不少新工具链编译出的eBPF程序无法加载。
因此在CAF内核上调试的第一要务是“看代码+看配置”,别照搬经验。我一般会先把它的arch/arm64/configs/下的defconfig与lkml的默认配置做一遍diff,确认哪些调试选项被关了。很多时候,问题定位不出来,就是因为某个关键的debug config在高通release内核里没有开。
4.5 文件系统拦截与file_operations挂钩的调试要点
还要再说一类常见场景——做文件系统透明加密或者安全监控时,经常要动态拦截read、write等系统调用,或者挂接file_operations结构体。这类代码一旦出问题,往往表现为用户态进程崩溃、数据错乱、甚至内核死锁。
我常用的调试姿势是:
先确认你的拦截点是在VFS层还是具体文件系统的回调层。VFS层通常对应
do_sys_open、vfs_read这些函数,挂接file_operations则要找到对应文件系统实例的函数表。这两类拦截出错的表现完全不同。在拦截函数入口处用
dev_dbg打印文件名、flag、进程PID,这样能第一时间看出是哪些文件路径触发了异常。特别注意
read/write的返回值语义。做透明加密时,如果加密后的密文长度发生变化(比如扩展了IV头),在VFS层返回的长度必须与实际写入/读取的字节数一致,否则上层可能会反复重试或读出乱码。用ftrace追踪ext4_file_write_iter或f2fs_file_write_iter的调用链,能直接看到返回值被谁错误地二次处理。如果发生死锁,优先用
sysrq(echo t > /proc/sysrq-trigger)导出所有任务栈,看看有没有两个进程互相等待。绝大多数文件系统拦截的死锁,都是因为持锁后又调用了文件系统的另一条路径,导致锁的嵌套顺序不一致。
可以说,文件系统拦截的内核态调试比普通驱动还要繁琐,因为它牵涉到用户态、VFS、页缓存、块设备等多个层次。每次改动前先想清楚“这次修改会影响哪条路径、是否可能引入锁的重复获取”,能帮你避开很多雷。
5. 调试过程中必备的工具与命令集
聊完了方法论和实战案例,再整理一下我平时最常用的工具和命令。作为内核开发者,这些工具基本就是“吃饭的家伙”,熟了能省一半时间。
5.1 一个内核调试工具箱
| 工具 | 用途 | 我的使用心得 |
|---|---|---|
addr2line | 地址转源码行 | 注意用带调试符号的vmlinux |
objdump | 反汇编 | 看Code段和检查PC偏移最直接 |
gdb | 源码级调试vmlinux | 配合KGDB或QEMU调试早期启动问题 |
crash | 分析kdump生成的vmcore | 内存泄漏、死锁分析不可替代 |
trace-cmd/kernelshark | ftrace的前端和可视化 | 适合处理复杂的多线程时序问题 |
bcc/bpftrace | 基于eBPF的动态追踪 | 高层级调用分析、性能问题首选 |
perf | 性能分析 | 排查调度延迟、锁竞争时有奇效 |
strace | 用户态系统调用追踪 | 内核问题往往先从用户态入手 |
这里额外说下crash工具。它虽然是离线分析工具,但处理复杂问题时价值极高。比如你怀疑某个内核对象的引用计数不对、某项内存泄漏持续增长,都可以在vmcore里直接查看所有对象的状态。我见过很多死锁问题,用crash的bt命令遍历所有任务的栈,瞬间就找到了互相等待的那两个线程。
5.2 串口与网络调试的内核启动参数
调试嵌入式设备时,内核启动参数(cmdline)的配置直接影响你能拿到多少信息。我常用的参数组合:
console=ttyS0,115200n8 loglevel=8 ignore_loglevel panic=-1 panic_on_oops=1 oops=panic ftrace_dump_on_oopsftrace_dump_on_oops这个参数绝对是我最推荐的内核调试神器之一——它在系统Oops的瞬间自动把ftrace缓冲区里的函数调用记录全部打印出来,等于免费帮你保存了一份崩溃前执行的函数序列。对于分析“崩溃前最后几十毫秒发生了什么”这种问题,几乎是作弊级别的工具。
如果用的是KGDB,还需要在cmdline里指定kgdboc:
kgdboc=ttyS0,115200 kgdbwaitkgdbwait会让内核在启动早期就停下来等待调试器连接,适合调试启动阶段就崩溃的场景。
5.3 高通CAF内核的编译与符号保存技巧
说到高通CAF,编译时保存好符号和构建产物是个好习惯。我一般会把整个out目录打包存到一个归档位置,包括vmlinux、System.map、Module.symvers、以及所有.ko。这样线上设备崩溃后,只要拿到之前记录的gittag或者build号,我就能在本地把地址翻译成源码行,而不需要现场提供太多东西。
另外一个经验是,高通CAF内核编译时经常有多个defconfig(比如vendor/xxx-perf_defconfig),不同产品线差异很大。构建调试内核时,我建议使用和release完全相同的defconfig,然后只手动追加CONFIG_DEBUG_INFO和CONFIG_KASAN这类调试选项。如果改动太大(比如换了编译器、改了优化等级),很多偶现问题可能就复现不出来了。
6. 常见内核调试问题与排查方法速查
最后整理一份我自己反复用到的问题排查表。表格没法覆盖所有场景,但能帮你快速建立“从现象到怀疑点”的映射。
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
NULL pointer dereference | 指针未初始化、被释放、链表被踩 | 反汇编看PC,检查调用者赋值路径,用KASAN排查越界 |
kernel NULL pointer dereference at virtual address 000000... | 常见于驱动probe失败后继续访问设备资源 | 确认probe返回值是否检查,检查platform_get_resource等接口是否返回NULL |
BUG: unable to handle page fault | 访问了未映射地址或权限错误 | 看ESR/页表状态,区分用户态/内核态访问,用CONFIG_DEBUG_VM辅助 |
KASAN: use-after-free | 内存释放后又访问 | 直接看KASAN报告的调用栈,检查释放和访问的路径 |
BUG: scheduling while atomic | 在原子上下文里睡眠 | 查调用栈中是否经过了spinlock、rcu_read_lock、中断上下文 |
死锁/软死锁(soft lockup) | 自旋锁持锁太久、中断处理耗时过长 | 导出所有任务栈,用crash或sysrq-t分析等待关系 |
| 驱动加载失败但无明确报错 | probe返回值被忽略、资源冲突 | 打开probe相关dev_dbg,检查/sys/kernel/debug/devices_deferred |
| 系统随机重启 | 看门狗复位、电压不稳、内核踩内存 | 优先查pstore记录,分析reset reason寄存器,其次怀疑硬件 |
使用这张表时有一个原则:先确认硬件环境是否正常,再怀疑驱动和内核代码。很多时候“内核崩溃”其实是电源纹波、DDR不稳定、时钟配置错误导致的。判断方式是看崩溃日志是否每次都在完全相同的地址和指令上崩——如果不同,优先怀疑硬件层面。
另一个常见问题是“改了内核代码但效果没变化”。大概率是你编译的镜像并没有被真正烧录进去,或者模块没有重新加载。我有个习惯:先在系统里执行cat /proc/version和modinfo xxx确认内核版本及模块路径,再开始调试,避免浪费几个小时在一个旧镜像上。
7. 最后分享一点我的个人习惯
内核调试是一个不断和“信息不完整”作斗争的过程。你把日志抓得越全、工具用得越熟练,定位问题的速度就越快。我个人的体会是,不要把时间浪费在反复猜测“是不是某个驱动不兼容”上,而是先用最快速度把崩溃完整信息收集下来,再用反汇编和动态追踪缩小范围,最后结合代码审查确认根因。
再分享一个小技巧:给每个项目准备一份DEBUG_NOTES.md,记录下这个平台相关的特殊调试方式,比如串口打印级别要求、KGDB使用的接口、pstore的内存布局、以及所有能复现问题的操作路径。这个问题可能一个月后又有人问,直接把笔记甩过去,比自己重新摸索一遍效率高太多。
内核调试这条路没有捷径,但也没有想象中那么可怕。希望这份记录能帮你在下一次遇到崩溃日志时,少一些慌乱,多一些从容。