搞内核模块开发的朋友应该都有这种体验:用户态程序崩了,顶多core dump,gdb一顿操作就能定位;内核模块一旦出问题,轻则Oops带出一串栈,重则整个系统直接panic,甚至黑屏重启,什么现场都留不下。我刚入门那阵子,调试一个字符设备驱动,只要一open设备文件整个虚拟机就重启,前前后后折腾了整整一周,最后才发现是ioctl里直接解引用了用户态指针,画面极度崩溃。
这篇文章就把我这些年积累的内核模块调试技巧系统性梳理一遍,从最基础的printk日志、动态调试,到Oops信息解读、kgdb断点调试、ftrace函数追踪,再到常见问题的排查套路。适合刚写完第一个hello world模块、正准备往深处走的新手,也适合已经踩过不少坑、想建立一套规范化调试流程的人。这些技巧都是我实际验证过、在多个内核版本上跑通的,照着做能省掉大量无头苍蝇式排查时间。
1. 先把调试思路理顺:别急着打日志
1.1 内核模块调试为什么难
先说个比喻。用户态进程出错,就像家里水管漏水,你把总阀一关,水停了,慢慢收拾就行;内核模块出错,相当于整栋楼的水管炸了,全楼都在喷水,而且你在泵房里根本跑不掉。内核没有进程隔离这层保护伞,一个野指针就能把系统内存踩得稀烂,而且崩溃之后CPU已经处于异常状态,连正常打印都可能失效。
这种环境下调试手段极其有限。普通用户态工具根本进不了内核地址空间,就算用gdb附着,也只能看个寂寞——内核的上下文切换频率极高,你打断一个CPU,其他CPU还在继续跑,瞬间改变系统状态。再加上内核模块和内核本身是高度耦合的,符号表、内存布局、编译选项稍有出入,调试gerät就失灵。
我在实际项目中总结下来的核心思路只有一条:把所有调试手段前置,在写代码之前就把环境准备好,而不是等崩溃之后再临时抱佛脚。日志不是越多越好,而是要能按需开关;崩溃现场不能靠事后猜,得让内核把关键信息完整输出;调试器不是不能用,但要提前配置好串口和内核选项。
1.2 越早搭建的调试环境越值钱
内核模块调试最基本的条件是:你能完整拿到崩溃现场的日志。这个要求看起来简单,实际上很多人栽在这里。我见过不少同事开发内核模块直接在物理机上搞,屏幕上显示一堆Oops之后直接死机,只能用手机拍屏幕,回来再一个字一个字敲到文档里,费劲且容易丢信息。
我现在的标配做法是:所有内核模块开发都在虚拟机里完成,串口输出完整重定向到宿主机文件。虚拟化平台我常用QEMU/KVM,串口配置方式如下:
qemu-system-x86_64 \ -kernel /path/to/bzImage \ -initrd /path/to/initramfs.img \ -append "console=ttyS0 kgdboc=ttyS0,115200 nokaslr" \ -serial file:serial.log \ -smp 2 -m 2048这样所有内核日志、Oops、panic输出都会落入serial.log,崩溃之后可以翻全文件,不用对着屏幕干瞪眼。nokaslr参数很关键,它关掉内核地址空间随机化,后面用addr2line或gdb解析地址时才能对得上号。
除了串口,内核自身的调试选项也得开全。我通常在编译内核时启用这些配置:
CONFIG_DEBUG_KERNEL=y CONFIG_DEBUG_INFO=y CONFIG_DEBUG_INFO_DWARF5=y CONFIG_KASAN=y CONFIG_KCOV=y CONFIG_PROVE_LOCKING=y CONFIG_DEBUG_KMEMLEAK=y CONFIG_DYNAMIC_DEBUG=y CONFIG_KGDB=y CONFIG_KGDB_SERIAL_CONSOLE=y CONFIG_KGDB_KDB=y CONFIG_FTRACE=y CONFIG_FUNCTION_TRACER=y CONFIG_FUNCTION_GRAPH_TRACER=y有人会质疑全开这些选项会不会影响性能,确实会,尤其是KASAN,内存访问的开销能翻好几倍。所以我会维护两套内核:一套性能版用于正常测试,一套调试版用于问题复现。开发阶段默认用调试版,性能验证时切到性能版。这个习惯帮我避开了无数坑。
调试环境准备好之后,还有一个细节容易被忽略:模块自身的编译信息。Makefile里我固定加-g选项保留调试符号,还要保证模块的 vermagic 和内核完全一致,否则insmod会直接报版本不匹配。后面在问题排查章节我会细讲。
2. 日志法从入门到进阶:printk不是printk那么简单
2.1 日志级别与控制台的联动关系
printk是内核最基础、最常用的日志接口,很多教程一句话就带过了,实际用起来坑特别多。printk的第一个参数是日志级别,常见的有这几种:
| 宏定义 | 数值 | 典型场景 |
|---|---|---|
| KERN_EMERG | 0 | 系统不可用 |
| KERN_ALERT | 1 | 必须立即处理 |
| KERN_CRIT | 2 | 严重错误 |
| KERN_ERR | 3 | 错误情况 |
| KERN_WARNING | 4 | 警告 |
| KERN_NOTICE | 5 | 正常但重要 |
| KERN_INFO | 6 | 信息提示 |
| KERN_DEBUG | 7 | 调试信息 |
光知道级别还不够,真正决定一条日志会不会显示在屏幕上的,是/proc/sys/kernel/printk里的四个数字。我经常看到有人修改这个文件后一脸疑惑:改了为什么日志还是不显示?这里面的门道得说清楚。
$ cat /proc/sys/kernel/printk 7 4 1 7四个数字从左到右分别是:控制台日志级别、默认消息日志级别、最小控制台日志级别、默认控制台日志级别。当printk消息的日志级别低于控制台日志级别(数值上小于)时,消息才会被输出到控制台。所以第二个数字是4,意思是没写日志级别的printk消息默认按KERN_WARNING级别处理,而控制台级别是7,KERN_WARNING=4小于7,所以会显示到屏幕。如果你把控制台级别调成3,那KERN_INFO(6)和KERN_DEBUG(7)的消息就不会上屏幕了,但它们仍然在环形缓冲区里,用dmesg随时能捞出来。
源码里常用的封装宏也值得留意:现代内核推荐使用pr_info、pr_err、pr_debug这一套pr_系列,以及按设备区分的dev_info、dev_err、dev_dbg。pr_fmt宏可以在编译期统一加前缀,比如模块名。我写驱动时习惯在源文件顶部这样定义:
#define pr_fmt(fmt) KBUILD_MODNAME ": " fmt #include <linux/kernel.h> #include <linux/module.h>这样所有pr_info打印自动带上模块名,排查日志时grep起来非常方便。这个细节不起眼,但日志一旦量大了,能帮你快速锁定模块边界。
2.2 dev_dbg/pr_debug与动态调试的配合
printk有个鸡肋之处:调试期加的日志在生产环境里就是噪音,只能编译时删掉或注释掉。可等你真碰到线上诡异问题,想再开日志就得重新编译模块,甚至要重新编译内核,代价极高。动态调试机制就是为解决这个问题设计的。
启用动态调试的核心是CONFIG_DYNAMIC_DEBUG,它让pr_debug、dev_dbg这些调试日志具备了运行时开关能力。编译期间这些语句的开销极小,运行时可以通过debugfs控制哪些模块、哪个函数、哪个文件的日志输出。
使用前先挂载debugfs:
mount -t debugfs none /sys/kernel/debug动态调试的控制文件在/sys/kernel/debug/dynamic_debug/control。开启某个模块的全部调试日志:
echo 'module mydev +p' > /sys/kernel/debug/dynamic_debug/control按函数开启:
echo 'func mydev_ioctl +p' > /sys/kernel/debug/dynamic_debug/control按源文件开启:
echo 'file drivers/misc/mydev.c +p' > /sys/kernel/debug/dynamic_debug/control对应的关闭操作就是把+p换成-p。这一手在排查问题时极其好用:模块加载后先用dmesg -w盯着,然后用echo 'module mydev +p'打开日志,复现问题,定位完再随手关掉。整个过程不需要重新编译,效率比传统printk高出几个量级。
我还用过一个进阶技巧:把动态调试过滤器和trace_pipe配合,可以同时抓用户态到内核态的交互。不过这是后话,下面的ftrace章节会展开。
2.3 几个我踩过的printk坑
日志这条路我走得不算顺畅,好几个坑都是拿加班换来的教训。
第一个坑是原子上下文里用可能睡眠的日志函数。printk本身在大多数上下文里都能用,但一些封装会带锁或可能导致调度。比如在自旋锁保护的临界区里,调用带might_sleep的调试函数,触发BUG: scheduling while atomic,问题还没定位到,先制造了一个新崩溃。所以规则很简单:自旋锁、中断上下文、原子上下文里,只用最朴素的printk,别用那些花哨封装。
第二个坑是高频日志把串口打爆。在热路径(比如网卡收包函数)里每包打一条日志,结果系统吞吐掉到原来的十分之一,而且串口输出本身改变了时序,竞态问题反而被掩盖了。内核为此提供printk_ratelimited,默认限速后基本不会影响性能。我现在的原则是:循环里的日志必须限速,宁可少打几条,也要保证系统能跑得动。
第三个坑是**%p指针格式符的隐藏行为**。%pK会基于权限隐藏内核指针地址,非特权用户只能看到00000000;而%p本身在较新内核里默认也会做哈希处理(除非指定%px)。如果日志里需要可用的原始地址做分析,记得用%px,同时确认/proc/sys/kernel/kptr_restrict的值。这是个安全相关改动,但很多人更新内核后debug日志里的地址全变成了哈希值,排查时对着代码对不上号,非常困惑。
3. 崩溃现场还原:读Oops信息与panic分析
3.1 Oops信息的结构
内核模块最常见的故障是Oops——内核访问了非法地址,但系统还没完全死透。早期我用dmesg看到Oops输出,第一反应是直接拉到最底部看栈回溯,这是极其错误的做法。完整的Oops信息包含的线索远比栈多,每一步都要看。
一段典型的Oops长这样(我精简并虚拟化了字段):
BUG: unable to handle kernel NULL pointer dereference at 0x0000000000000018 IP: mydev_ioctl+0x2c/0x50 [mydev] PGD 0 P4D 0 Oops: 0002 [#1] SMP NOPTI CPU: 1 PID: 2234 Comm: a.out Tainted: P O Hardware name: QEMU Standard PC (Q35 + ICH9, 2009) RIP: 0010:mydev_ioctl+0x2c/0x50 [mydev] Code: 48 8b 47 18 48 85 c0 74 0d 48 8b 40 10 ff d0 ... RSP: 0018:ffffa2b2c03b7df8 EFLAGS: 00010246 RAX: 0000000000000000 RBX: 0000000000000000 RCX: 0000000000000000 RDX: 0000000000000000 RSI: 000000005fed0001 RDI: 0000000000000000 RBP: ffffa2b2c03b7e30 R08: 0000000000000000 R09: 0000000000000000 R10: ffffffff9a403ac0 R11: 0000000000000000 R12: 0000000000000000 R13: ffff9b37a1a3b340 R14: ffff9b37a1a2c240 R15: 0000000000000000 Call Trace: __x64_sys_ioctl+0x96/0xc0 do_syscall_64+0x3c/0x80 entry_SYSCALL_64_after_hwframe+0x44/0xa5第一行告诉你崩溃类型:这里是内核空指针解引用,访问的地址是0x18(在0x00基础上偏移24字节)。如果你是结构体成员访问,0x18这个偏移直接就能告诉你是在访问哪个字段。
RIP: mydev_ioctl+0x2c/0x50 [mydev]这行信息量很大:崩溃执行点位于mydev_ioctl函数偏移0x2c处,该函数总共0x50字节。[mydev]表示属于哪个模块。拿到这个偏移量后,用objdump反汇编模块的.text段,比对偏移就能精确定位到是哪条指令,再结合寄存器的值判断出错的变量。
3.2 从寄存器与调用栈定位问题
寄存器是破解崩溃现场密码的关键。接上面的Oops,RDI是0,RIP在mydev_ioctl内访问[RDI+0x18]——也就是说我在ioctl里把某个用户传入的空指针直接传给了一个函数,函数内部解引用成员导致了崩溃。
定位汇编指令的做法是:
objdump -d mydev.ko | grep -A10 "<mydev_ioctl>"反汇编输出会比Code:里那行编码容易读得多,配合gdb还能看到源码行号:
gdb -q mydev.ko (gdb) disassemble mydev_ioctl (gdb) list *(mydev_ioctl+0x2c)调用栈Call Trace也不能只看第一行。从下往上看是系统调用路径,从上往下是你模块的调用链。万一模块里还调了其他函数,栈会逐层展开。我发现很多初学者只盯最后一个函数名,其实中间那几层往往才是真正传入错误状态的源头。
这里要强调nokaslr的作用。如果内核启用了KASLR,模块加载地址和内核基址每次启动都随机变化,RIP里的偏移虽然不受影响,但你在看其他未RIP化的地址时(比如栈里其他函数的绝对地址)就对不上符号表。所以在调试版内核启动参数里加上nokaslr,省掉一大半解释误差。
3.3 panic场景下的思路
如果Oops级别较高或者触发了内核保护机制,系统会直接panic,此时控制台上通常只剩最后一段输出,连栈都是被打断的。这种场景就不能依赖现场打印了,得靠专门工具。
我的标准方案是kdump + crash工具。kdump在内核崩溃时用另一个保留内存启动一个捕获内核,把崩溃瞬间的内存镜像(vmcore)完整保留下来。配置不算复杂:
# 内核启动参数 crashkernel=256M # kdump服务 systemctl enable kdump.service崩溃后vmcore一般生成在/var/crash/目录,用crash打开:
crash /usr/lib/debug/lib/modules/$(uname -r)/vmlinux /var/crash/xxx/vmcorecrash工具里最常用的命令包括:bt查看完整栈回溯、ps查看系统进程状态、struct查看结构体内容、kmem查看内存分配情况。我曾经靠crash定位过一个内存踩踏问题:崩溃现场里某个结构体的成员被改成了怪异值,用struct查看周围内存,才发现是另外一个模块的越界写正好落在我这片缓冲区的尾部。
如果暂时不想上kdump,至少把panic_on_oops打开,让系统在Oops后第一时间进入panic而不是带伤运行,避免二次破坏掩盖原始线索:
echo 1 > /proc/sys/kernel/panic_on_oops配合panic_timeout设置自动重启时间,远程环境里不至于永远卡死。
4. 断点式调试:kgdb与ftrace实战
4.1 kgdb配置
日志排查属于事后诸葛,而断点调试能让你在现场一步步看到变量变化。内核里对应gdb的工具是kgdb。它的工作方式是:目标机器上运行一个被kgdb模块化的内核,通过串口或网络和宿主机的gdb通信,gdb像调试普通进程一样设置断点、查看变量、单步执行。
我实测下来,串口kgdb最稳,网络kgdboe确实能跑但受限于网卡驱动的兼容性,很多网卡驱动在模块加载阶段还没有完全初始化,网络断点根本打不上。除非你用的是内核自带驱动,否则还是乖乖用串口。
配置步骤概括如下:
- 内核开启
CONFIG_KGDB、CONFIG_KGDB_SERIAL_CONSOLE和必要的CONFIG_DEBUG_INFO。 - 启动参数加上:
kgdboc=ttyS0,115200 kgdbwait。kgdbwait是让内核在启动早期就停下来等待gdb连接。 - 宿主机安装gdb和内核调试符号vmlinux,连接:
gdb vmlinux (gdb) target remote /dev/ttyS0 (gdb) continue系统就会继续启动,直到你设置的断点被触发。这套方案对调试模块的初始化流程特别有效——模块在insmod过程中崩溃,普通日志只能看到加载失败的结果,断点却能看到module_init里每一步的中间状态。
4.2 kgdb实战:加符号、打断点、改变量
用kgdb调试模块有个关键操作:模块是动态加载的,它的代码地址在加载前是未知的,你必须先让系统跑起来,等模块insmod成功后再把符号文件动态加进gdb会话。方法如下:
首先在目标机器上拿到模块在内存中的加载地址:
# 目标机 cat /sys/module/mydev/sections/.text cat /sys/module/mydev/sections/.bss cat /sys/module/mydev/sections/.data把这些地址传给宿主机gdb:
(gdb) add-symbol-file mydev.ko 0xffffffffc0000000 \ -s .data 0xffffffffc0008000 \ -s .bss 0xffffffffc0009000之后就可以正常下断点了:
(gdb) break mydev_ioctl (gdb) continue命中后bt看栈、info registers看寄存器、p打印变量、x看内存内容。我调试过一个内存越界,断点打在mydev_write里,单步执行到某个memcpy之前发现拷贝长度不对,源地址指针也偏向结构体末尾,这事用日志从头到尾打下来至少得加几十个printk,而断点只花了我几分钟。
kgdb还能在gdb里直接改变量值。比如你怀疑某个判断条件写反了,可以把变量改成期望值再继续跑,立刻验证假设,不用改代码重编译。这个能力在排查逻辑类bug时效率极高。
4.3 ftrace看函数调用路径
kgdb虽好,但运行开销极大。实际上线环境里你不想停住整个系统,只想看看某条路径调用了哪些函数、耗时多少,这种场景ftrace是更合适的选择。
ftrace最初就是内核的函数追踪器,挂载tracefs后操作路径在/sys/kernel/debug/tracing/。最基本的使用流程:
# 挂载 mount -t tracefs tracefs /sys/kernel/debug/tracing cd /sys/kernel/debug/tracing # 开启函数图追踪 echo function_graph > current_tracer # 只追踪目标模块里的函数 echo 'mydev_*' > set_ftrace_filter # 开始追踪 echo 1 > tracing_on cat trace_pipefunction_graph模式的输出直观,缩进就是调用层级,每行末尾还有函数耗时。我曾经用它追过一个驱动初始化慢的问题,一眼看出耗时的瓶颈居然在某次对固件状态的轮询等待——每次轮询间隔里夹了msleep,整体初始化时间被活生生拖长了三倍。这种问题用printk打日志也能发现,但耗时分布需要逐段统计,而ftrace直接给你画好了调用树和耗时,省事太多。
ftrace还有个配合用法是kprobe动态插桩。在不想重新编译的情况下,如果你想在某个没有tracepoint的函数入口处抓参数,可以用kprobe事件:
echo 'p:my_trace mydev_ioctl arg1=$arg1 arg2=$arg2' > kprobe_events echo 1 > events/kprobes/my_trace/enable内核版本不同参数语法略有差异,x86_64下通用的是$arg1到$arg6表示前六个寄存器参数。这个能力相当于给线上系统打了个临时补丁,可以在不碰业务流量的前提下观测内部数据。不过要小心,kprobe本身是会影响性能的,高流量路径慎用。
5. 常见问题与排查技巧实录
5.1 模块加载失败:别再只截一行报错
模块加载失败是每个内核模块开发者的入门第一课,但报错信息往往被简化成一行,导致排查特别慢。最常见的三种情况:
Unknown symbol:模块里用到了某个未导出的内核符号。解决办法先看模块的未解析符号表:
nm mydev.ko | grep ' U '每个未定义符号都对应内核里必须导出的符号,检查它是否真的被导出:
grep my_symbol /proc/kallsyms如果符号存在但带_GPL限制,而你的模块没有声明GPL,就会出现权限错误。要么把模块改成GPL协议,要么用EXPORT_SYMBOL_GPL的对称操作。这里有个细节:/proc/kallsyms在权限受限时会隐藏部分符号地址,判断导出与否要看它是否出现在导出符号列表里,而不是地址是否为0。
Invalid module format:这是版本和编译配置不匹配的信号。用modinfo mydev.ko查看vermagic与目标内核是否一致:
modinfo mydev.ko | grep vermagic关键点不只是内核版本号,CONFIG_SMP、CONFIG_PREEMPT等配置选项变了,vermagic也会跟着变,即使大版本号相同也可能加载失败。解决办法是保证模块编译使用的内核源码树和运行的内核完全一致。
init函数返回值错误:module_init的函数如果返回负数,insmod会报Operation not permitted或类似错误。但很多新手看不懂这里:init函数里已经发生部分资源分配,只返回错误码却没有清理干净,下次加载时系统里残留着半初始化状态,导致更诡异的问题。所以我的习惯是init函数里所有错误路径都走统一出口,用goto out_err集中释放已获取资源。
5.2 空指针与内存问题:从寄存器到KASAN
空指针/野指针问题高居内核崩溃榜首。前面Oops章节里已经演示了怎么用RIP和寄存器定位,这里补一个我强烈推荐的工具:KASAN。它可以在越界访问发生的瞬间给出精确报告,而不需要等崩溃点。
启用KASAN的内核会在每次内存访问时插入检查代码,对越界读/写、释放后使用(use-after-free)、栈越界都能给出警告。报告看起来像这样:
BUG: KASAN: use-after-free in mydev_write+0x6c/0x1a0它会告诉你问题发生在哪个函数偏移、被释放的内存在哪里、当前访问的地址范围。这个工具对排查“明明没有崩溃但行为异常”的悬案有奇效。代价是性能明显下降,所以只在调试环境开启。
内存泄漏是另一类顽疾,kmemleak是针对性利器。开启后定期扫描未引用的内存对象,报告形如:
unreferenced object 0xffff88801c2d0800 (size 512): comm "insmod", pid 1234, jiffies 4294933456 backtrace: kmalloc_trace+0x1d/0x30 mydev_open+0x25/0x60配合echo scan > /sys/kernel/debug/kmemleak手动触发扫描,再用echo clear清空历史记录,能确认泄漏是否持续增长。我踩过的教训是:泄漏往往不在你分配的地方,而在某个错误路径忘记释放。所以分配和释放建议用配套的包装函数,而不是散落各处的裸kmalloc/kfree。
5.3 死锁与竞态:让lockdep替你找问题
并发问题是最难复现的一类bug,因为它依赖时序,日志打多了时序变了,问题反而跑不出来。这时尤其要依赖LOCKDEP(CONFIG_PROVE_LOCKING)。它会在内核运行时建立锁的依赖图,一旦发现潜在死锁环,立刻输出一份详细的依赖链报告:
====================================================== WARNING: possible circular locking dependency detected ------------------------------------------------------ [mydev] -> [some_lock] -> [mydev] again看到这个提示基本就等于拿到死锁剧本了。我的做法是复现前打开lockdep,让系统多跑几轮压力测试,把可能出现的依赖环全部暴露出来。它还有个姊妹功能是CONFIG_DEBUG_SPINLOCK,能检测自旋锁是否在原子上下文里被错误使用。
另外一个常见崩溃信息是BUG: scheduling while atomic,它表示在原子上下文(自旋锁、中断处理、preempt_disable区间)里调用了可能睡眠的函数,比如kmalloc(GFP_KERNEL)、mutex_lock。遇到这种问题,第一件事是检查代码里哪些路径在持有自旋锁时调用了普通分配或锁操作。修复方案通常是在临界区外预分配或改用GFP_ATOMIC——但后者的正确性需要上下文分析,不能无脑替换。
5.4 排查流程速查表
| 表象 | 优先排查手段 | 辅助工具 | 关键线索 |
|---|---|---|---|
| insmod失败 | 查看完整modprobe/insmod报错 | modinfo、nm、/proc/kallsyms | vermagic、未导出符号 |
| 系统随机崩溃 | 检查串口日志完整捕获 | kdump、crash、gdb | RIP偏移、寄存器、调用栈 |
| 偶发死锁 | 开启lockdep跑压力 | ftrace、/proc/lock_stat | circular locking报告 |
| 内存泄漏 | kmemleak扫描 | /proc/slabinfo、kmem_cache | unreferenced object回trace |
| 越界写/读 | KASAN报告 | gdb断点+watch | use-after-free/out-of-bounds |
| 性能退化 | function_graph追踪 | perf、/proc/schedstat | 函数耗时分布 |
这轮排查下来我的体会是,不要试图在一个工具上吊死。日志告诉你表象,Oops告诉你位置,kgdb让你看清现场,ftrace给你全貌,lockdep/KASAN把隐藏问题暴露出来——每个工具补一块拼图,合在一起才是完整的问题图景。
最后再分享一个细节:调试内核模块和调试用户态程序有个共性,那就是复现路径越短越窄越好。出问题的模块动辄涉及几十个ioctl命令、多种并发场景,我每次调试前都先写一个最小复现用例,只调用出问题的那个命令,参数固定,并发数固定。这个习惯看起来简单,实际排查效果立竿见影——很多看似复杂的崩溃,最小化之后原始代码反而自己“暴露”了错误,连工具还没用上就找到原因了。