1. 驱动调试为什么比普通程序更难写过 Linux 驱动的开发者都有一个共同感受驱动通常不是跑在用户态而是跑在内核态一旦出错轻则功能异常、模块无法卸载重则内核 Oops、panic整个系统直接挂掉。普通用户态程序调试时常用的「断点、单步、打印堆栈、core dump」在内核里要么不可用要么需要一套完全不同的工具链。驱动调试的难点主要来自三个方面运行上下文复杂驱动程序可能运行在进程上下文、软中断、硬中断、定时器回调等多种上下文中。不同的上下文对「能不能睡眠、能不能访问用户内存、持锁」有严格约束很多 bug 恰恰来自对这些约束的违反。出错代价高用户态程序崩溃最多打印一个 Segmentation fault内核态的一个非法内存访问、死锁、长时间关中断往往意味着整机挂死甚至没有机会留下现场。现象与根因距离远硬件寄存器行为、中断时序、内存映射、DMA 一致性、并发竞争交织在一起往往在寄存器层面表现出的只是一个「数据不对」或「中断没来」的表象真正的根因却藏在另一层。因此驱动调试不是「学一个 gdb 就够」而是要建立一整套分层观测、逐步缩小范围的方法论先用便宜的、无侵入的手段快速定位到大致模块再用侵入性更强或更底层的工具精确定位到寄存器、指令、时序。本文以从零搭建一个假想字符驱动demo_drv为线索把 Linux 驱动开发中最常用、最实用的调试手段逐一拆开每类方法都按「原理 → 步骤 → 示例」三个维度展开帮你形成一套可以随取随用的调试工具箱。2. printk 与内核日志最基础也最可靠的观测手段2.1 原理printk是内核的「printf」它把格式化字符串写入内核环形缓冲区ring buffer__log_buf再由多个出口分发Console 驱动如串口 console、fb console把日志同步打印到终端klogd/journald守护进程把日志转发到用户态最终落到/var/log/下的日志文件或 systemd journal用户态还可以通过dmesg命令直接读取当前环形缓冲区内容。printk的强大之处在于它几乎是随处可用的——除了极早期的启动阶段和某些极端原子上下文绝大多数内核代码路径都可以安全地打印。消息级别从高到低定义在include/linux/kern_levels.h#defineKERN_EMERG0/* 系统不可用 */#defineKERN_ALERT1/* 必须立即采取行动 */#defineKERN_CRIT2/* 严重错误 */#defineKERN_ERR3/* 错误 */#defineKERN_WARNING4/* 警告 */#defineKERN_NOTICE5/* 正常但值得注意 */#defineKERN_INFO6/* 信息 */#defineKERN_DEBUG7/* 调试信息 */2.2 步骤在怀疑出问题的代码路径上插入printk根据信息的重要程度选择合适的级别重新编译模块并加载触发问题用dmesg | tail观察输出定位后删除或降级为dev_dbg等可动态开启的调试语句见第 3 节。2.3 示例在驱动入口和出口打印加载信息#includelinux/module.h#includelinux/kernel.h#includelinux/init.hstaticint__initdemo_drv_init(void){printk(KERN_INFOdemo_drv: module loaded\n);printk(KERN_INFOdemo_drv: major %d\n,demo_major);return0;}staticvoid__exitdemo_drv_exit(void){printk(KERN_INFOdemo_drv: module unloaded\n);}module_init(demo_drv_init);module_exit(demo_drv_exit);MODULE_LICENSE(GPL);在中断处理函数里观察硬件状态时注意不要打印过多信息否则会拖慢中断路径。通常只打印关键寄存器和错误分支staticirqreturn_tdemo_irq(intirq,void*dev_id){unsignedintstatusreadl(dev-baseREG_STATUS);if(unlikely(statusSTATUS_ERR)){/* 错误路径打印完整状态方便一次看全 */printk(KERN_ERRdemo_drv: error at card %d, status0x%x\n,dev-index,status);returnIRQ_HANDLED;}returnIRQ_HANDLED;}2.4 注意事项用dev_info/dev_err/dev_dbg(dev, ...)代替裸printk它们自动带上设备名多实例时更清晰用pr_info/pr_err/pr_debug作为不带struct device时的轻量封装高频路径中断、热循环的printk会显著拉低性能甚至导致日志风暴原则上先做rate limiting。3. 动态调试 dynamic debug不加日志也能按需开日志3.1 原理pr_debug/dev_dbg这一族宏在默认情况下不会产生任何输出。只有当内核开启了CONFIG_DYNAMIC_DEBUG并且通过用户态接口对指定的文件、函数、模块启用了这些调试语句时它们才会真正调用printk输出。这样就不需要为了调试反复改代码重新编译而是在运行时用同样的模块、同样的内核按下「开关」。其实现原理是编译器会把这些调试语句及其所在文件、行号、函数名信息登记到内核的__dyndbg段内核据此维护一张调试语句表。通过 debugfs 的dynamic_debug/control文件可以向这张表写入匹配规则动态地为匹配到的语句设置/清除_DPRINTK_FLAGS_PRINT标志。3.2 步骤内核配置中确认CONFIG_DYNAMIC_DEBUGy挂载 debugfs代码中使用pr_debug/dev_dbg查看当前已有调试语句mount-tdebugfs none /sys/kernel/debuggrepdemo_drv /sys/kernel/debug/dynamic_debug/control开启指定模块、函数、文件的调试输出# 开启整个模块echomodule demo_drv p/sys/kernel/debug/dynamic_debug/control# 只开启某个函数echofunc demo_read p/sys/kernel/debug/dynamic_debug/control# 只开启某个文件的指定行号范围echofile demo.c line 100-200 p/sys/kernel/debug/dynamic_debug/control关闭输出将p换成-p即可。3.3 示例驱动代码保持不变只使用dev_dbgssize_tdemo_read(structfile*filp,char__user*buf,size_tcount,loff_t*ppos){structdemo_dev*devfilp-private_data;dev_dbg(dev-this_dev,read: count%zu, offset%lld\n,count,*ppos);returnsimple_read_from_buffer(buf,count,ppos,dev-payload,dev-payload_len);}运行时不改动代码# 打开 demo_read 函数的调试输出echofunc demo_read p/sys/kernel/debug/dynamic_debug/control# 用户态触发读取dmesg 中会看到对应日志3.4 注意事项动态调试语句在编译期仍然存在只是默认不执行打印分支性能开销很小但也不是零成本极致性能路径应谨慎放置匹配规则支持module、file、func、line等组合规则一旦写入会持续生效模块卸载后再次加载需要重新启用对正在开发的驱动推荐默认大量使用dev_dbg用 dynamic debug 按需开启比到处临时加printk优雅得多。4. procfs / sysfs / debugfs把内核内部状态暴露出来4.1 原理打印日志是「推」式观测——内核主动输出而文件系统接口是「拉」式观测——用户态按需读取内核状态。Linux 提供了三类伪文件系统用于暴露内核信息/proc历史悠久信息混杂适合驱动放一些简单的文本信息/sys以 kobject 模型为基础的结构化接口每个属性一个文件典型的是attribute形式通过sysfs_create_group批量注册/sys/kernel/debugdebugfs专为调试设计的轻量文件系统比 proc/sys 更少规范束缚适合驱动开发阶段快速导出任意调试信息但不应作为产品级的正式 ABI。4.2 步骤以 debugfs 为例开启CONFIG_DEBUG_FSy挂载 debugfs在驱动 probe 或 init 时用debugfs_create_file创建调试节点实现对应的read/writefile_operations在 remove 或 exit 时用debugfs_remove销毁节点。4.3 示例在demo_drv中导出寄存器状态和一个可写的测试触发点。#includelinux/debugfs.hstaticstructdentry*demo_debugfs_dir;staticintdemo_regs_show(structseq_file*s,void*v){structdemo_dev*devs-private;seq_printf(s,STATUS: 0x%08x\n,readl(dev-baseREG_STATUS));seq_printf(s,IRQ_EN : 0x%08x\n,readl(dev-baseREG_IRQ_EN));return0;}staticintdemo_regs_open(structinode*inode,structfile*filp){/* inode-i_private 来自 debugfs_create_file 的 data 参数 */returnsingle_open(filp,demo_regs_show,inode-i_private);}staticconststructfile_operationsdemo_regs_fops{.ownerTHIS_MODULE,.opendemo_regs_open,.readseq_read,.llseekseq_lseek,.releasesingle_release,};staticintdemo_probe(structplatform_device*pdev){structdemo_dev*dev;/* ...初始化 dev... */demo_debugfs_dirdebugfs_create_dir(demo_drv,NULL);debugfs_create_file(regs,0444,demo_debugfs_dir,dev,demo_regs_fops);return0;}用户态直接读取cat/sys/kernel/debug/demo_drv/regs STATUS: 0x00000010 IRQ_EN:0x000000034.4 注意事项single_openseq_file是 debugfs 读操作的常用组合能方便地输出多行文本debugfs 的写路径可以用于向驱动注入测试命令比如触发一次模拟中断、切换工作模式非常有利于调试产品合入时不要把 debugfs 当作正式的配置接口正式接口应走 sysfs 的 attribute 或字符设备的 ioctl。5. 设备文件与 ioctl驱动自己的「控制面板」5.1 原理字符设备不仅可以通过read/write搬运数据还可以通过ioctl执行任意控制命令。对调试而言ioctl的价值在于它能以带类型、带参数、经内核校验的方式进入驱动的特定分支触发某个内部状态切换或回读某个内部数据结构。相比 debugfs它更接近「正式接口」也更适合保留在最终产品中。5.2 步骤定义命令号推荐使用内核宏_IO/_IOW/_IOR/_IOWR在file_operations.unlocked_ioctl中实现switch分派编写用户态测试程序调用ioctl。5.3 示例驱动侧定义两种调试命令读取统计计数、设置强制错误注入。#includelinux/ioctl.h#defineDEMO_IOC_MAGICD#defineDEMO_IOC_GET_STATS_IOR(DEMO_IOC_MAGIC,1,structdemo_stats)#defineDEMO_IOC_SET_FAULT_IOW(DEMO_IOC_MAGIC,2,int)structdemo_stats{unsignedintirq_count;unsignedintrx_bytes;unsignedinterr_count;};staticlongdemo_ioctl(structfile*filp,unsignedintcmd,unsignedlongarg){structdemo_dev*devfilp-private_data;switch(cmd){caseDEMO_IOC_GET_STATS:if(copy_to_user((void__user*)arg,dev-stats,sizeof(dev-stats)))return-EFAULT;break;caseDEMO_IOC_SET_FAULT:/* 故意注入故障用于验证错误恢复路径 */dev-fault_inject(int)arg;break;default:return-ENOTTY;}return0;}用户态测试程序#includestdio.h#includefcntl.h#includesys/ioctl.hstructdemo_statsstats;intmain(void){intfdopen(/dev/demo0,O_RDWR);if(fd0){perror(open);return1;}ioctl(fd,DEMO_IOC_GET_STATS,stats);printf(irq_count%u rx_bytes%u err_count%u\n,stats.irq_count,stats.rx_bytes,stats.err_count);close(fd);return0;}5.4 注意事项修为调试而设计的 ioctl 命令要防止与正式命令冲突统一用_IOC宏的 magic 段区分copy_to_user/copy_from_user必须检查返回值避免用户传空指针导致内核 oops错误注入命令例如故意返回 EBUSY、故意不释放锁在验证驱动的异常路径时非常有用合入产品前应评估是否保留。6. Oops / panic 分析从崩溃现场倒推根因6.1 原理当内核访问非法地址、执行非法指令、触发断言失败时会打印一段Oops信息。Oops 会包含CPU、内核版本等基础信息引发异常的指令指针PC/RIP完整的内核调用栈call trace各关键寄存器快照。Oops 是内核崩溃现场的「案发现场照片」。虽然它不直接告诉你 bug 的根因但通过地址反解到函数/文件/行号能快速定位到「崩溃发生的那一行代码」。如果内核配置了panic_on_oops或者崩溃发生在中断上下文等不允许继续运行的场景系统会直接 panic进一步可能触发 kdump 保存完整 vmcore见 6.4。6.2 步骤确认内核编译时开启了CONFIG_KALLSYMS和CONFIG_DEBUG_INFO前者保证符号表可用后者提供 DWARF 行号信息拿到完整的 Oops 文本串口 console、串口日志文件或dmesg保留下内容从 Oops 中提取PC地址或调用栈中的地址用addr2line/gdb/scripts/decode_stacktrace.sh反解6.3 示例某次 Oops 片段BUG: unable to handle kernel NULL pointer dereference at 0000000000000000 PGD 0 P4D 0 Oops: 0000 [#1] SMP PTI CPU: 1 PID: 1524 Comm: demo_test Tainted: G O 5.15.0-demo RIP: 0010:demo_write0x2b/0x60 [demo_drv]用addr2line把demo_write0x2b反解到源码行。先通过/proc/modules或 Oops 文本找到demo_drv的加载基址再计算绝对地址# 假设 demo_drv 模块基址为 0xffffffffc0000000addr2line-edemo_drv.ko 0x2b demo.c:87更推荐使用内核自带的解码脚本./scripts/decode_stacktrace.sh vmlinux.oops.txtoops_decoded.txt它会自动处理模块符号把整个调用栈还原成函数名文件:行号的形式。6.4 kdump / crash 工具完整崩溃转储如果问题复现后系统直接 panic 且没有 console 输出可以在引导参数中配置 kdump# 内核引导参数示例crash### 6.4 kdump / crash 工具完整崩溃转储如果问题复现后系统直接 panic 且没有 console 输出或者需要分析完整内存现场就需要用到 kdump。kdump 的核心理念是**双内核机制**1. 系统启动时通过引导参数预留一块内存给捕获内核capture kernel并通过 kexec 机制把捕获内核镜像预先加载到内存中2. 正常内核production kernel运行驱动和业务一旦发生 panic立即通过 kexec 跳转到捕获内核3. 捕获内核不依赖已崩溃内核的内存管理它重新初始化最小环境后把崩溃内核的完整内存转储到本地磁盘、NFS 或远程服务器生成vmcore文件4. 之后用户态使用crash工具加载带调试信息的vmlinux与vmcore对崩溃现场做离线分析。 配置 kdump 的典型步骤如下1. 内核确认开启CONFIG_KEXECy、CONFIG_CRASH_DUMPy2. 在引导参数中预留内存例如 bash# /etc/default/grub 的 GRUB_CMDLINE_LINUX 中追加crashkernel256M安装并启动 kdump 服务yuminstallkexec-tools# CentOS/RHELaptinstallkdump-tools# Debian/Ubuntusystemctlenablekdumpsystemctl start kdump确认 kdump 状态kdump-config show之后可以手动触发 panic 验证流程是否打通# 生产环境慎用会直接重启系统echoc/proc/sysrq-trigger系统重启后vmcore会保存在/var/crash/具体路径以kdump.conf为准。使用crash打开# vmlinux 应使用带 DWARF 调试信息的内核镜像crash vmlinux /var/crash/vmcorecrash提供了类似 gdb 的交互式命令对内核调试非常有用crash bt # 查看当前上下文/崩溃进程的内核栈 crash log # 查看内核环形缓冲区日志 crash ps # 查看崩溃时刻的进程列表 crash mod # 查看已加载模块及地址 crash kmem -i # 查看内存统计信息相比 Oops 文本vmcore保存的是崩溃瞬间的完整内存快照可以查看任意进程、任意变量、任意内存页甚至配合crash脚本做高度定制化的分析。代价是它需要预留内存、依赖 kexec 流程且分析门槛明显高于addr2line。7. 调试工具速查对比表最后把本文涉及的主要调试手段汇总成表方便在遇到问题时快速选择。选择的一般原则是先用无侵入、低成本的手段缩小范围再用高侵入、高精度的工具定位根因。调试手段侵入性主要观测对象使用门槛典型场景printk/dev_info低代码路径、变量值、寄存器的文本日志极低快速确认代码是否执行、变量是否异常dynamic debug极低预埋的dev_dbg/pr_debug语句低运行时按需开关日志无需重新编译procfs / sysfs低驱动的静态信息与配置项低定期上报状态、正式暴露配置接口debugfs低任意寄存器、内部统计、调试入口低开发阶段快速导出寄存器与测试命令ioctl中驱动控制命令、内部状态回读中向驱动注入命令、错误注入、读取计数addr2line/decode_stacktrace.sh无Oops 地址对应的函数与源文件行号中崩溃后快速定位到出错行gdb / kgdb极高内核代码的单步、断点、变量高需要在线逐步跟踪控制流时kdump crash高panic 瞬间的完整内存现场vmcore高复杂 Oops、难以复现的崩溃深度分析补充说明性能敏感路径中断、热循环少用printk优先用 dynamic debug 在需要时打开错误分支可以保留dev_err。平时开发建议结合dev_dbg debugfs ioctl日志按需开寄存器随时读控制命令随时发。崩溃分析先看 Oops 和调用栈用decode_stacktrace.sh或addr2line定位到行如果现场信息不足或需要分析完整内存再上 kdump crash。工具之间不是替代关系而是分层配合日志负责「发生了什么」伪文件系统负责「当前状态是什么」ioctl 负责「让驱动做什么」Oops/kdump 负责「它是在哪里死的」。
阅读完成 · 觉得有帮助?