嵌入式Linux模块开发(5)——内核调试技术
仓库已经开源!所有教程,主线内核移植,跑新版本imx-linux/uboot都在这里!欢迎各位大佬观摩!喜欢的话点个⭐!
仓库地址:https://github.com/Awesome-Embedded-Learning-Studio/imx-forge
静态网页:https://awesome-embedded-learning-studio.github.io/imx-forge/
在黑暗中寻找光亮
在前面的章节中,我们学习了内核打印、模块机制等基础知识。但说实话,知道怎么打印消息,和知道怎么高效调试,是两回事。当你写的驱动加载失败、系统崩溃、或者行为异常时,你需要的是一套系统的调试方法论。这一章,我们想带你成为内核调试专家。
调试驱动真的和调试用户空间程序很不一样。你不能用gdb直接attach上去,不能随便printf,出了问题经常就是整个系统死掉。这些限制让内核调试变得特别有挑战性,但一旦你掌握了正确的方法,那种把bug从黑暗中揪出来的成就感也是无与伦比的。
dmesg:内核的日记本
dmesg是你最重要的调试工具,没有之一。内核把所有消息写入一个环形缓冲区,dmesg就是查看这个缓冲区的窗口。不管你遇到什么问题,第一步永远是看dmesg。
最基本的用法就是直接运行dmesg查看所有内核消息,但输出会非常多,滚屏都滚不完。更实用的方式是用管道配合tail命令,只看最后几行:
dmesg|tail-20这会显示最近20条消息,通常就是你刚刚操作产生的输出。如果你想知道系统启动时的消息,可以用head命令:
dmesg|head-20持续监控新消息是调试动态问题的利器:
dmesg-w加上-w参数后,dmesg会像tail -f一样持续显示新的内核消息。这在调试驱动加载、设备初始化等过程时特别有用。你可以在一个终端运行dmesg -w,在另一个终端加载驱动,实时观察内核的反应。
如果你想监控特定模块的消息,可以结合grep使用:
dmesg-w|grepmy_driver这样你就只会看到包含"my_driver"的消息,不会被其他无关信息干扰。这个技巧在复杂系统中调试特定驱动时非常有价值。
清空日志缓冲区是另一个实用的技巧。当你调试了好几轮,日志已经乱成一团的时候,可以先清空再测试,这样输出更清晰:
dmesg-cinsmod my_driver.kodmesg但要注意,dmesg -c会清空整个缓冲区,不仅仅是过滤,所以如果你想保留之前的日志,要先备份。
时间戳显示让日志分析更方便。默认的时间戳是从系统启动开始的秒数,不太直观。加上-T参数后,时间戳会变成人类可读的格式:
dmesg-T输出会变成类似"[Wed Apr 16 10:30:45 2026] my_driver: Device loaded"这样的格式,方便你理解事件发生的时间顺序。
如果你只想看错误和警告,可以用-l参数过滤:
dmesg-lerr,warn这个命令只显示错误和警告级别的消息,忽略info和debug。当你只关心问题时,这个过滤功能能帮你快速定位。
内核日志级别控制
在前面讲内核打印的时候,我们提到了日志级别。现在我们深入理解一下这个机制,因为它直接影响你能看到什么调试信息。
你可以通过以下命令查看当前的日志级别设置:
cat/proc/sys/kernel/printk输出类似"4 4 1 7"这样四个数字。第一个数字是控制台日志级别,决定了哪些消息显示到控制台。默认值通常是4,这意味着KERN_WARNING及更高级别的消息会显示到控制台,而KERN_NOTICE和KERN_INFO会被过滤掉。
如果你想看到所有消息,包括DEBUG级别的,可以这样设置:
echo8>/proc/sys/kernel/printk设置是立即生效的,不需要重启。但这个设置是临时的,重启后会恢复默认值。如果想永久设置,可以修改/etc/sysctl.conf文件,添加"kernel.printk = 8"这一行,然后运行sysctl -p使配置生效。
或者你可以在内核启动参数中设置,编辑/etc/default/grub文件,在GRUB_CMDLINE_LINUX中添加"printk.time=1 printk=8",然后运行update-grub更新grub配置。这种方式适合需要永久保持特定日志级别的场景。
我们来对比一下不同日志级别下的实际效果。当日志级别等于4(默认)时,加载模块后只显示EMERGENCY、ALERT、CRITICAL、ERROR、WARNING这几个级别的消息,INFO、NOTICE、DEBUG都没有显示。但当你把日志级别设置为8后重新加载模块,NOTICE和INFO级别的消息也会显示出来。
这个对比清楚地说明了日志级别控制的重要性。很多时候你以为代码有问题,其实只是日志级别过滤掉了你的调试输出。
动态调试:运行时控制打印
传统的调试方式需要在编译时决定是否包含调试代码,但Linux内核提供了更强大的机制:动态调试。这个功能允许你在运行时控制哪些pr_debug()和dev_dbg()消息被打印,而不需要重新编译模块。说实话,这个特性真的让调试效率提升了一个档次。
首先你要确认内核是否支持动态调试:
cat/boot/config-$(uname-r)|grepDYNAMIC_DEBUG或者
zcat /proc/config.gz|grepDYNAMIC_DEBUG你应该看到CONFIG_DYNAMIC_DEBUG=y和CONFIG_DYNAMIC_DEBUG_CORE=y这两个配置项。如果你的内核没有启用动态调试,那就有点麻烦了,要么重新编译内核,要么只能用传统的调试方法。
动态调试的使用很简单。首先查看可用的调试点:
cat/sys/kernel/debug/dynamic_debug/control|head-20这会显示前20个动态调试点,每行包含文件名、函数名、行号和格式化字符串。你可以用grep过滤特定模块的调试点:
cat/sys/kernel/debug/dynamic_debug/control|grepmy_module启用调试也很简单:
echo"module my_module +p">/sys/kernel/debug/dynamic_debug/control这会启用my_module模块的所有调试打印。你也可以只启用特定文件或函数的调试:
echo"file my_driver.c +p">/sys/kernel/debug/dynamic_debug/controlecho"func my_read +p">/sys/kernel/debug/dynamic_debug/control甚至可以精确到某一行:
echo"file my_driver.c line 78 +p">/sys/kernel/debug/dynamic_debug/control禁用调试就是把+p改成-p:
echo"module my_module -p">/sys/kernel/debug/dynamic_debug/control查看当前状态也很方便:
cat/sys/kernel/debug/dynamic_debug/control|grep"=p"这会显示所有已启用的调试点,你可以快速确认你的调试设置是否生效。
动态调试的高级用法包括组合条件和添加自定义前缀。你可以在一条命令中指定多个条件:
echo"file my_driver.c func my_read +p">/sys/kernel/debug/dynamic_debug/control这会只启用my_driver.c文件中my_read函数的调试。你还可以启用调试并添加自定义前缀:
echo"file my_driver.c +p\"[MY_DEBUG]\"">/sys/kernel/debug/dynamic_debug/control这样所有来自my_driver.c的调试消息都会加上[MY_DEBUG]前缀,方便在日志中识别。
常见问题排查流程
我们总结了一些常见问题的排查流程,这些是我们踩过无数坑后沉淀下来的经验。
模块加载失败是最常见的问题之一。症状通常是insmod报错"Invalid module format"。排查步骤是这样的:首先查看内核日志,dmesg | tail -20会告诉你具体是什么问题。然后检查内核版本匹配,uname -r和modinfo my_module.ko | grep vermagic应该一致,如果不一致就是版本不匹配。接着检查模块依赖,modinfo my_module.ko | grep depends会显示模块依赖的其他模块,确保这些依赖模块已经加载。最后如果都不行,就重新编译,make clean && make -C /lib/modules/( u n a m e − r ) / b u i l d M = (uname -r)/build M=(uname−r)/buildM=(pwd) modules。
模块加载成功但设备不工作也很常见。这种情况下驱动加载了,但操作设备时没反应。排查步骤:首先检查设备注册,cat /proc/devices | grep my_module确认设备号已经注册,ls -l /dev/my_device确认设备节点存在。然后检查模块参数,cat /sys/module/my_module/parameters/*可以看到当前参数值,cat /sys/module/my_module/refcnt可以看引用计数。接着检查内核消息,dmesg | grep my_module看有没有错误或警告。最后测试设备,echo “test” > /dev/my_device和cat /dev/my_device看设备是否响应。如果还不行,cat /sys/module/my_module/initstate检查模块初始化状态。
系统崩溃是最令人头疼的问题。症状是系统冻结或重启,dmesg可能都看不了。这种情况下你要想办法保存崩溃日志。如果系统还能勉强操作,dmesg > crash_log.txt先保存日志。然后分析崩溃位置,dmesg | grep -A 30 "Oops|Panic"查看完整的崩溃信息,dmesg | grep "Call Trace"查看调用栈。常见的崩溃原因有空指针解引用、内存访问越界、死锁、栈溢出等。如果你有ksymoops工具,可以用它解析符号:ksymoops < crash_log.txt。
内存泄漏是个隐蔽的问题,症状是系统运行一段时间后内存不足,free -h显示内存持续减少。排查步骤:首先查看模块内存使用,cat /proc/modules | grep my_module会显示模块占用的内存。然后用slabtop查看slab分配器,slabtop | grep my_module看模块相关的slab缓存。接着启用内核内存调试,在内核启动参数中添加slab_debug或slub_debug。最后使用kmemleak(需要启用CONFIG_DEBUG_KMEMLEAK),echo scan > /sys/kernel/debug/kmemleak扫描内存泄漏,cat /sys/kernel/debug/kmemleak查看泄漏报告。
内核调试工具箱
除了dmesg和动态调试,还有很多强大的内核调试工具值得了解。
trace-cmd是ftrace的前端,用于追踪内核函数调用。安装很简单,apt-get install trace-cmd。记录函数调用:
trace-cmd record-pfunction-gdo_sys_open然后查看记录:
trace-cmd report或者实时显示:
trace-cmd show这个工具在分析内核调用路径时非常有用,比如你想知道某个系统调用都经过了哪些内核函数。
perf是性能分析工具,用于性能分析和热点定位。记录性能数据:
perf record-g./my_test_program然后查看报告:
perf report或者实时监控:
perftopperf会显示CPU时间花在哪些函数上,帮你找到性能瓶颈。
crash是内核崩溃分析工具,用于分析内核崩溃转储。安装后,你可以加载内核调试符号:
crash /usr/lib/debug/lib/modules/$(uname-r)/vmlinux /proc/kcore然后使用各种命令分析崩溃。bt查看回溯,ps查看进程列表,mod -s my_module查看模块信息。这个工具在分析系统崩溃时非常有价值,当然前提是你的系统配置了kdump并且生成了崩溃转储。
kgdb是内核调试器,用于远程调试内核,类似gdb。你需要在内核启动参数中添加kgdboc=ttyS0,115200 kgdbwait,然后在另一个终端运行gdb:
gdb vmlinux(gdb)target remote /dev/ttyS0(gdb)breakmy_function(gdb)continue这样你就可以像调试用户空间程序一样调试内核了,包括设置断点、单步执行、查看变量等。
/proc和/sys文件系统是运行时查看内核状态的窗口。你可以查看模块信息、设备信息、内存信息、中断信息等:
cat/proc/modulesls/sys/module/cat/proc/devicesls/sys/class/cat/proc/meminfocat/proc/iomemcat/proc/interrupts这些伪文件提供了丰富的内核状态信息,是调试时的重要参考。
调试技巧和最佳实践
最后我们分享一些调试技巧和最佳实践,这些是我们多年踩坑总结出来的经验。
渐进式调试很重要。不要一次性打印太多信息,要循序渐进。你应该先打印最基本的信息,确认代码执行到了哪里,然后逐步增加详细的调试信息。如果一开始就打印一大堆,反而会被信息淹没,找不到关键问题。
使用标识符很实用。在调试消息中添加唯一标识符,方便搜索和过滤。你可以定义一个DEBUG_TAG宏,在每条调试消息前加上文件名、函数名、行号等信息。这样当你grep日志时,能快速定位到具体的代码位置。
条件编译可以避免生产环境的性能影响。使用#ifdef DEBUG包围详细的调试代码,或者使用pr_debug(),它只在定义了DEBUG时才编译。这样你的发布版本可以完全不含调试代码,既减小了体积,又避免了性能损失。
断言检查能帮你尽早发现错误。使用BUG_ON()和WARN_ON()检测关键错误,比如检查指针有效性、检查状态错误等。BUG_ON()会触发oops,WARN_ON()只会打印警告栈回溯。在开发阶段,这些检查能帮你快速发现逻辑错误。
dump_stack()是理解执行路径的好工具。当某些异常情况发生时,打印调用栈能帮你了解代码是怎么执行到这里的。我们在调试时经常加这个,看看实际的调用路径是否和预期一致。
hex dump调试在处理二进制数据时很有用。使用print_hex_dump()查看二进制数据的内容,可以按十六进制和ASCII两种格式显示,特别适合调试协议、数据结构等问题。
实战案例
让我们通过一个实际案例,综合运用这些调试技巧。问题是驱动加载成功,但读取设备时返回错误。我们的调试过程是这样的:
首先确认问题现象,加载驱动后cat /dev/my_device报错"read error: Invalid argument"。然后查看内核日志,dmesg | tail -20显示read函数被调用了,但返回了错误码。接着启用动态调试,echo “module my_driver +p” > /sys/kernel/debug/dynamic_debug/control,再次cat /dev/my_device,这次看到了更详细的调试输出。
查看代码后发现问题:read函数检查count参数时,如果大于1024就直接返回-EINVAL,而不是调整大小。修复方法是调整count值而不是返回错误。重新编译加载后测试,cat /dev/my_device成功输出数据,问题解决。
这个小案例展示了完整的调试流程:确认现象、查看日志、启用调试、分析代码、修复验证。虽然问题很简单,但这个流程对复杂问题同样适用。
下一步
到这里,我们已经把内核调试技术讲完了。你现在应该有一套系统的调试方法论,知道从哪里开始排查问题,用什么工具,怎么分析日志。这些技能在后面的驱动开发中会不断用到,因为说实话,没有谁能一次写出完美无缺的驱动,调试是开发过程的重要组成部分。
接下来我们要开始真正的字符设备驱动开发了。我们会从最简单的虚拟设备开始,逐步过渡到真实的硬件驱动。准备好了吗?让我们开始吧。
相关阅读
- 深入理解Linux模块——模块参数与内核调试:让模块"活"起来的魔法 - 相似度 100%
- 深入理解Linux模块——内核模块编译与加载详解:从 Makefile 到 insmod 的完整旅程 - 相似度 100%
- 嵌入式Linux驱动开发(3)——内核模块机制 - Linux 的插件系统 - 相似度 100%