
搞内核模块开发最让人头疼的不是写代码而是出了问题之后不知道怎么去查。用户态的程序出buggdb一挂、日志一打基本能定位个八九不离十内核模块一旦崩溃直接oops、panic搞不好整个系统都跟着重启你连个现场都抓不到。更麻烦的是内核态不像用户态有独立进程空间一个野指针就能把内核地址空间搅得天翻地覆排错靠猜是绝对行不通的。这篇文章我分几个部分来说先讲调试环境怎么搭、内核模块编译加载阶段那些容易踩的坑再展开printk和动态调试这两个最常用的手段对比它们的适用场景接着上ftrace、kprobe、kgdb这些相对进阶的工具最后把遇过的典型故障和排查思路整理成清单。这篇内容是我多年内核模块开发实际调试中沉淀下来的经验不搞花架子每个方法都是在机器上验证过能解决实际问题的适合刚上手内核模块的开发者也可以当作遇到疑难问题时随手翻阅的经验手册。 搞内核模块开发最让人头疼的不是写代码而是出了问题之后不知道怎么去查。用户态的程序出buggdb一挂、日志一打基本能定位个八九不离十内核模块一旦崩溃直接oops、panic搞不好整个系统都跟着重启你连个现场都抓不到。更麻烦的是内核态不像用户态有独立进程空间一个野指针就能把内核地址空间搅得天翻地覆排错靠猜是绝对行不通的。这篇文章我分几个部分来说先讲调试环境怎么搭、内核模块编译加载阶段那些容易踩的坑再展开printk和动态调试这两个最常用的手段对比它们的适用场景接着上ftrace、kprobe、kgdb这些相对进阶的工具最后把手头遇过的典型故障和排查思路整理成清单。这篇内容是我多年内核模块开发实际调试中沉淀下来的经验不搞花架子每个方法都是在机器上验证过能解决实际问题的适合刚上手内核模块的开发者也可以当作遇到疑难问题时随手翻阅的经验手册。1. 调试环境搭建与基础准备1.1 用虚拟机还是真机先想清楚再动手我见过不少朋友一上来就在自己的主力机上直接insmod测试模块系统崩了只能强制重启不仅丢失现场有时候连文件系统都搞出问题。调试内核模块的第一原则就是尽量别在生产环境或工作机上直接测试。推荐用虚拟机方案常见的有QEMU/KVM和VirtualBox。QEMU/KVM配合内核自带的gdb stub可以直接在宿主机上对guest内核下断点调试体验接近真机JTAGVirtualBox胜在配置简单快照功能也方便快速回滚。在虚拟机里调试还有两个隐藏优势。一个是方便串口输出你可以通过串口把内核日志导到宿主机文件里避免guest的屏幕全是刷屏日志看不清另一个是方便制造故障和快照对比模块把系统搞崩了直接回滚快照三秒钟回到崩溃前状态这在真机上想都不敢想。不过虚拟机也有局限性比如涉及真实硬件驱动的模块虚拟设备的寄存器布局跟真机有差异调试结果不能直接等同于真机行为。我的习惯是纯逻辑型模块字符设备、内核数据结构操作、网络协议栈hook优先在虚拟机里调需要跟具体硬件交互的模块先在虚拟机里把通用逻辑调通再上真机做硬件适配。1.2 开启内核调试选项编译内核时别偷懒很多发行版默认内核为了追求性能和体积砍掉了不少调试信息这会让你的调试难度直线上升。如果条件允许建议自己编译一个带调试符号的内核关键配置项如下CONFIG_DEBUG_INFOy生成内核实用的调试信息DWARF格式gdb连上内核后可以查看变量值、函数参数调试体验跟调试普通程序几乎一样。CONFIG_KALLSYMSy默认开启内核符号表oops日志里能显示函数名而不只是十六进制地址这个必须开着否则出问题只能对着地址发愁。CONFIG_DEBUG_KERNELy内核调试功能的开关总闸。CONFIG_KPROBESy、CONFIG_FTRACEy这两个后面讲动态追踪时会用到。CONFIG_MAGIC_SYSRQy魔术键系统卡死时通过SysRq组合键触发紧急操作比如触发crash dump、显示内存状态。CONFIG_PANIC_ON_OOPSnoops之后不要直接panic留给你抓日志的时间当然如果模块导致的内存破坏太严重该panic还是会panic这个选项只是让你有机会多看一些输出。CONFIG_DEBUG_MEMORY_INIT、CONFIG_DEBUG_LIST、CONFIG_DEBUG_SLAB等这些内存和链表调试选项在排查特定问题时非常有用比如怀疑链表被破坏时打开CONFIG_DEBUG_LIST内核会主动检查链表一致性并输出详细告警。编译自己的debug内核说白了是“一劳永逸”的事。第一次投入两三小时编译安装之后每次调试都在这个内核里进行省下的排查时间远远超过这个投入。1.3 配套工具链拿到oops现场后能第一时间做分析光有带符号的内核还不够配套工具也得准备好。至少要有addr2linebinutils包把oops日志中的地址翻译成源文件行号这是分析oops最基础的工具。gdb交叉版本或native版本配合vmlinux文件连接内核可以查看变量值、寄存器状态、反汇编。crash工具可选但推荐专门分析内核转储文件的工具功能比gdb更强比如可以查看slab分配器状态、遍历链表、查看进程栈。systemtap或bpftrace二选一进阶动态追踪、一键输出函数调用栈和局部变量。工具链需要配合带符号的内核vmlinux文件使用这也就是为什么我之前反复强调要保留编译出来的vmlinux——没有它addr2line和crash都无从下手。实际排障的时候我的流程一般是保存oops日志 → 用脚本提取出错的函数地址和寄存器值 → 用addr2line定位到源码行 → 打开源码分析上下文 → 必要时gdb加载vmlinux查看周边逻辑。2. 模块编译与加载阶段的坑2.1 内核版本和编译器版本不匹配最常见的第一个坑新手最容易遇到的错误就是insmod: ERROR: could not insert module: Invalid module format。这个报错99%是因为模块编译时使用的内核头文件版本、编译器版本或者内核配置和当前运行内核不一致。具体来说内核模块的加载有一个版本校验机制modversions编译时会把内核的版本、编译器版本、关键符号的CRC校验值都记录下来加载时逐一比对不一致就直接拒绝加载。解决办法就是确保用当前运行内核对应的头文件来编译模块# 查看当前内核版本 uname -r # 确认对应的头文件是否已安装 ls /usr/src/linux-headers-$(uname -r) # 编译模块时使用标准内核构建系统 make -C /lib/modules/$(uname -r)/build M$(pwd) modules如果你换了内核但没更新头文件或者头文件是不同版本混装的就会出现这种问题。还有一个常见场景是用的发行版是Ubuntu随手apt install linux-headers-generic装了一个头文件包但它对应的内核版本比当前运行内核新那就要么升级内核要么装对应版本的headers。实操心得把uname -r的结果记录下来装headers时严格对应这个版本号避免装错版本浪费一上午时间。2.2 insmod和modprobe选哪个不是随便挑的insmod和modprobe都能加载模块但行为差别很大。insmod只负责加载指定路径的.ko文件不处理模块依赖modprobe会分析模块之间的依赖关系自动加载依赖的那些模块加载顺序也由它统一安排。调试阶段我强烈建议用insmod手动加载原因很简单模块依赖关系复杂时用modprobe自动解决依赖会让你搞不清楚当前加载的到底是哪个模块出了问题不好定位。手动insmod一次加载一个输出日志清晰可见# 加载模块假设编译产物在当前目录 sudo insmod ./hello.ko # 查看是否加载成功 lsmod | grep hello # 查看模块信息 modinfo ./hello.ko # 卸载模块 sudo rmmod hello有一点需要特别留意rmmod卸载模块时如果模块正被其他进程占用比如打开着它注册的设备文件卸载会失败报Resource temporarily unavailable。此时可以用fuser -v /dev/你的设备查一下占用进程释放后再卸载。当然如果模块里的资源清理函数本身没写好照样会泄漏这个问题我们后面单独讲。2.3 模块参数传递调试时临时改配置不用重编译内核模块支持模块参数这个在调试阶段特别好用。写代码时声明参数#include linux/moduleparam.h static int debug_level 0; module_param(debug_level, int, 0644); MODULE_PARM_DESC(debug_level, Debug level: 0off, 1basic, 2verbose);加载时传参sudo insmod ./my_module.ko debug_level2这样就不用为了改一个参数反复重编译模块了。调试阶段我习惯把参数权限位设置成0644这样模块加载之后还能通过sysfs节点动态调整参数值echo 1 /sys/module/my_module/parameters/debug_level调完参数日志级别立马变化这个特性在做现场调优和bug复现的时候特别顺手。3. 基础调试printk与动态调试3.1 printk的八个日志级别用对了才能快速过滤printk是内核模块最基础、最直接的调试手段很多新手对它不够重视。它的用法跟printf差不多但有个关键区别printk有日志级别内核会按级别过滤输出不是所有printk都会出现在控制台或dmesg里。八个级别定义在linux/kern_levels.h中从高到低分别是宏定义数值含义典型场景KERN_EMERG0紧急事件系统不可用系统崩溃前最后一条消息KERN_ALERT1必须立即处理硬件故障告警KERN_CRIT2严重条件驱动发现硬件异常KERN_ERR3错误情况操作失败但系统仍在运行KERN_WARNING4警告潜在问题提示KERN_NOTICE5普通但值得注意模块加载卸载信息KERN_INFO6提示信息模块版本、初始化状态KERN_DEBUG7调试级信息变量值、函数入口出口控制台输出级别由/proc/sys/kernel/printk控制四个数字分别代表控制台日志级别、默认消息日志级别、最小控制台级别、默认控制台级别。想让所有级别的日志都打印到控制台可以设置echo 7 4 1 7 /proc/sys/kernel/printk使用建议是常规调试信息用KERN_DEBUG或KERN_INFO错误信息至少用KERN_ERR。如果你一开始就把所有printk写成了KERN_DEBUG日志量又大格式化输出又多最后输出被内核限流重要信息反而丢了。我遇到过调试一个网络驱动日志量太大直接导致网络中断排查了半天才发现是printk刷得太凶把软中断饿死了。实操心得调试性printk加上一个全局开关模块参数或全局变量控制上线前把开关关掉留好代码不动以后排查线上问题还能现场打开。3.2 printk格式化规范带不带位置信息差别太大了printk的格式化占位符跟printf基本一致但内核里还有几个特殊要求。比如打印指针值时用%p会做指针哈希不直接泄露真实地址这对安全有好处但调试时反而麻烦。需要打印真实地址时用%px打印结构体的某个字段时用%ps可以直接解析为函数名打印MAC地址有%pM打印IPv4地址有%pI4这些专用格式符能省不少事。关于IP地址和MAC地址的打印我在调试网卡驱动时深有体会。你如果用传统方式自己格式化打印MAC地址很容易踩到字节序的坑直接用%pM内核会正确输出xx:xx:xx:xx:xx:xx格式省心还不出错。另一个容易忽略的点是printk在中断上下文里不能用%p配合需要睡眠的格式符比如某些资源获取。在中断上下文或持有锁的时候打印本身还有可能加重锁竞争。所以要养成习惯中断上下文里只做最小限度的printk或者干脆用tracepoint方式记录等退出中断后再合并输出。3.3 dynamic debug运行时按模块、函数、行号精准开关日志printk有个很烦的问题模块编译完之后日志输出是固定的想调整级别要么得改代码重编模块要么靠全局的printk级别控制。这样要么日志太多淹没关键信息要么日志太少排查时缺少上下文。内核的dynamic debug机制简称dyndbg解决了这个痛点。它不是靠预编译决定是否输出而是把printk调用点做成可以在运行时动态开关的tracepoint。启用方式是在编译模块时加上CONFIG_DYNAMIC_DEBUG或者在模块代码里对需要动态控制的printk使用pr_debug()还需要在编译时定义DEBUG宏或者模块加载时通过dyndbg系统开启。使用方法是通过debugfs操作先挂载debugfssudo mount -t debugfs none /sys/kernel/debug查看当前模块有哪些可动态控制的调试点cat /sys/kernel/debug/dynamic_debug/control | grep my_module控制开关格式是module名称 function名 line号 format字符串 [/-]p。比如开启my_module这个模块里所有my_func函数的调试输出echo module my_module function my_func p /sys/kernel/debug/dynamic_debug/control开启整个文件所有调试点echo file drivers/net/my_net_driver.c p /sys/kernel/debug/dynamic_debug/control这个机制最大的价值在于代码里可以多写调试信息平时不输出不影响性能关键时刻远程直接动态打开拿到日志再关上完全不用重启系统。线上问题排查这个工具是网红级的杀手锏。我在调试一个偶发性的数据包丢包问题时就是用dyndbg把网卡驱动的数据路径调试点全部打开了抓到丢包点具体在哪个函数整个排查过程从“完全没头绪”变成了“对着日志一行一行过”。4. 进阶追踪ftrace、kprobe与kgdb4.1 ftrace跟踪函数调用不需要重编内核ftrace是内核自带的追踪工具功能包括函数追踪function tracer、函数调用栈stack tracer、事件追踪event tracer等。它可以在几乎不影响性能的情况下记录内核函数的调用关系、调用次数、耗时对于理解内核代码执行路径特别有帮助。常用的操作是通过tracefs通常挂在/sys/kernel/tracing或/sys/kernel/debug/tracing# 查看可用的追踪器 cat /sys/kernel/tracing/available_tracers # 使用function tracer echo function /sys/kernel/tracing/current_tracer echo 1 /sys/kernel/tracing/tracing_on # 限定只追踪某个函数和调用它的函数 echo my_module_func /sys/kernel/tracing/set_ftrace_filter # 跑一段后关闭查看结果 echo 0 /sys/kernel/tracing/tracing_on cat /sys/kernel/tracing/trace | head -100在追踪到调用关系后还能看到每个函数的耗时情况latency字段这对性能问题定位很有价值。比如你怀疑模块里的某个函数偶发性卡顿就可以用ftrace看它前后调用了哪些函数哪个函数耗时异常。需要注意权限这些节点通常需要root才能写而且有些发行版对tracefs挂载有安全策略限制需要手动挂载或调整SELinux策略。4.2 kprobe/kretprobe在不改代码、不重编内核的前提下插入探针kprobe允许你在内核函数的入口或出口动态插入探针执行你自定义的逻辑比如打印参数值、记录调用次数。这个能力简直像是给内核做“手术不切口”。kprobe可以通过debugfs的kprobes/目录或者用perf、bpftrace间接使用。用bpftrace方式举例不需要写内核模块就能监控指定函数# 监控内核函数入口打印进程名和参数 sudo bpftrace -e kprobe:__kmalloc { printf(%s called __kmalloc, size%d\n, comm, arg1); }用kretprobe看函数返回值sudo bpftrace -e kretprobe:__kmalloc { printf(__kmalloc returned %p\n, retval); }这里有个很有用的技巧如果你的函数有大概率返回错误可以在kretprobe里检查返回值一旦非0就打印调用栈。这种“按需打印调用栈”的做法比全量打印省很多日志定位也更快。写kprobe模块时需要用register_kprobe核心结构体是struct kprobe指定要探测的符号名和探针处理函数。但坦白讲现在写原生kprobe模块的人越来越少了bpftrace和perf提供的封装已经足够应付绝大多数场景还省去模块编译和加载的麻烦。4.3 kgdb串口/gdb模块整体行为跟断点调试上面这些追迹手段适合“事后看日志”但有时候你想在某个条件满足时停下来细看内核当前状态这时候就需要kgdb。kgdb让内核通过串口或网络与gdb通信gdb可以在任意内核函数设断点查看变量、内存、寄存器。使用kgdb需要内核开启CONFIG_KGDB和CONFIG_KGDB_SERIAL还要指定串口作为调试口。启动参数加kgdbocttyS0,115200 kgdbwait然后在宿主机上执行gdb连接guest内核gdb vmlinux (gdb) target remote /dev/ttyUSB0 (gdb) break my_module_init (gdb) continue等guest系统启动到模块加载时断点命中。这是我调试模块初始化逻辑最常用的方法特别是在初始化顺序、资源分配顺序有疑问的时候断点让整个流程都变得透明可见。kgdb有一个比较明显的局限guest系统在断点暂停期间是完全停止的如果这个断点在一个被频繁调用的路径上那基本上整个系统就“卡”住了。这种场景就不能用断点得配合条件断点或者改用ftrace/kprobe做非侵入式采样。实操心得kgdb适合定位“一次性”问题比如模块初始化、加载时的资源申请失败不适合高频路径上的问题那种“系统跑着跑着偶发crash”的场景ftrace和kprobe更靠谱。5. 常见故障类型与排查实操5.1 oops和panic拿到完整日志是第一步但不是最后一步模块崩溃最常见的表现就是oops内核检测到异常但还能继续运行和panic内核无法继续稳定运行而主动停机。不管你遇到哪种第一步都是保留现场日志。如果是虚拟机里用串口日志会自动输出到宿主机文件省去抄屏的痛。oops日志里最关键的信息有四个出错函数地址RIP:、出错原因BUG:后面那行、调用栈Call Trace:、寄存器值。示例BUG: unable to handle kernel NULL pointer dereference at 0000000000000028 RIP: 0010:my_func0x14/0x50 [my_module] Call Trace: my_module_init0x20/0x100 [my_module] do_one_initcall0x5d/0x1b0 kernel_init_freeable0x1e9/0x280用addr2line定位addr2line -e vmlinux -f my_func0x14把my_func0x14里的地址偏移换算成实际地址或直接使用函数名加偏移的方式addr2line会帮你映射到源码行addr2line -e vmlinux -f 0xffffffffc0123456在拿到RIP地址后经常能看到一个陷阱oops日志里的地址是模块加载运行时的虚拟地址和vmlinux里的链接地址之间有偏移。直接用addr2line去解析地址号往往得到错误行号。正确做法是用模块加载的基地址做差算出模块内偏移如果日志里有Modules linked in:那一行它会列出模块加载地址直接用模块偏移来定位。5.2 空指针 / 野指针排查看寄存器比看代码更快内核里空指针解引用是最常见的崩溃之一。oops日志中的CR2寄存器保存了触发异常的地址如果它是0x28这种很小的值基本可以断定是解引用了空指针的某个字段。比如current-task_struct为NULL访问current-pid时就会访问到0x28附近。这种情况下打开源码找到崩溃的源码行看它访问了哪个结构体成员的偏移量就能反推出是哪个指针为空。比如崩溃行是return info-name;而name在结构体里偏移量是0x28那问题就是info指针为NULL。顺便说一句结构体字段偏移可以用offsetof(type, member)打印出来验证match上0x28基本实锤。野指针指向已释放内存则麻烦得多。它可能在访问时地址看起来合理但内容已经被改得面目全非。这种场景我一般这么排查看寄存器值是否合理比如rbx应该是指向某结构体的指针现在却指向了内核的.text段地址说明这个变量早被写乱了。用CONFIG_DEBUG_PAGEALLOC抓越界释放后立刻访问的情况它在释放后会取消页表映射一旦访问立即oops。使用KASAN如果内核支持并开启它在内存访问越界和释放后使用时能打出非常清晰的报告。KASAN在内核调试里比用户态ASan好用也更准但代价是内存开销和性能下降明显所以一般只在专用调试内核里开生产内核保持关闭。5.3 模块加载时dev_err打印的日志级别和权限问题有时候模块加载失败你在dmesg里看到的却是Permission denied或者权限类报错。这里有个容易忽略的点注册设备号、创建sysfs节点这些操作需要对应的权限命名空间允许加载模块本身需要root权限如果模块要访问GPIO或者硬件资源还得设备树或ACPI表正确配置。这类问题的排查思路是先看模块代码里哪一步返回的错误码比如-EPERM代表权限不足-ENOMEM代表内存分配失败-EINVAL代表参数不合法-ENODEV代表设备不存在或驱动与设备不匹配。错误码能定性定位范围接下来查对应的子系统日志dmesg里一般每个子系统都会补充输出原因。比如I2C控制器找不到设备会打i2c-msg之类的信息platform驱动和device不匹配时在/sys/bus/platform/drivers/xxx下查看绑定状态。5.4 死锁与自旋锁用lockdep提前发现锁问题内核模块里死锁问题的排查极其隐蔽特别是多核并发时可能一周都不出现一出现就把所有CPU都卡住。好在内核内置了lockdep锁依赖校验器开着它在开发和测试阶段就能提前暴露出绝大多数锁滥用问题。使用方法内核开启CONFIG_PROVE_LOCKINGlockdep然后在加载模块时关注日志凡是锁相关的潜在错误AB-BA死锁、在原子上下文中睡眠、重复加锁等lockdep都会打印详细的调用链和锁之间的依赖关系。我看过很多模块代码单独看每一个锁的获取释放都没问题但两个锁在不同的函数里以相反顺序获取这就是经典的AB-BA死锁。lockdep能把这些交叉依赖关系直接画出来。遇到锁问题我的建议是能不用锁就不用锁优先考虑无锁数据结构比如READ_ONCE、WRITE_ONCE配合内存屏障。必须用锁时保持全局一致的加锁顺序。随便一个内核线程里要等待资源时用mutex而不是spinlock中断上下文只能spinlock。开发阶段开着lockdep即使性能损失一些也值得。5.5 内存泄漏kmemleak和slabinfo齐上阵内核模块如果反复申请内存但不释放最终会导致Out Of Memory系统开启oom killer。内存泄漏的定位比崩溃还恶心因为系统可能正常运行只是可用内存不断下降。排查工具首选kmemleak它是内核自带的内存泄漏检测器# 开启kmemleak echo scan /sys/kernel/debug/kmemleak # 查看检测结果 cat /sys/kernel/debug/kmemleak它通过扫描内存内容来推断是否存在“只被指针引用的内存对象”找到后输出分配时的调用栈。比较考验耐心的是它需要等一段时间才能收敛结果而且某些合法的静态分配也会被误报需要人工甄别。另一种手段是看/proc/slabinfo如果你的模块对应某个专用slab cache比如kmalloc-64被持续增长说明有该size的分配没释放。配合ftrace的kmalloc和kfree追踪统计不配对情况就能找到泄漏点。实操心得排查内存泄漏看分配调用栈有个坑——分配点所有模块都共用一个kmalloc栈看起来都差不多。这时最好给模块里的每次分配加上标志性的__builtin_return_address(0)调用栈记录或者干脆在代码里给每个分配点分配一个独立的设计ID用/proc导出来配合对比效率会高很多。6. 调试技巧沉淀几条能救命的经验这些经验是我在多个项目里反复踩坑换来的写下来既是对我自己的总结也希望对看到这篇内容的朋友有所帮助。第一模块的日志一定要规范化。哪怕是临时调试的printk也尽量带上模块名、函数名、关键变量值格式统一。我定义了一套简单的日志宏输出格式固定为[模块名][函数名][关键值]这样grep起来极其方便。等到调试结束这些日志还能保留下来作为模块的运行时观测手段一举两得。第二善用内核自带的自检机制。你怀疑链表有问题就用CONFIG_DEBUG_LIST怀疑内存越界就用KASAN怀疑锁问题就用lockdep。内核已经帮你把“体检项目”都列好了你要做的就是在调试版本内核里把这些开关打开。这不是什么高深技巧就是老老实实把内核调试配置项过一遍该开全开。很多人舍不得编译调试内核的时间结果一次故障排查浪费几天。第三学会用动态工具给“偶发问题”留后手。我最讨厌的就是“偶尔崩一次重启又好了”这种问题。这类问题的特点是日志没有规律、难复现。我的处理思路是在代码里多埋动态调试点pr_debug平时不输出把ftrace的过滤规则、kprobe的探测函数做成脚本出现问题随时从远程启用。这样即使问题本身随机观测工具总是在线待命问题一出现就能抓到现场。第四一定保存好模块对应的vmlinux和System.map。哪怕时间过去半年再翻出当时的内核来看有了符号表一切都好说。我见过有人为了省磁盘空间把编译出来的中间文件全删了后来遇到线上问题想分析旧内核的oops结果vmlinux没了调用栈怎么都还原不出来只能靠猜那滋味别提多难受了。最后再说一个小技巧。如果你要调试的模块正好在系统启动早期就要加载比如存储驱动的模块等系统起来再insmod可能就晚了。这时候可以在内核启动参数里用rdinit或initcall_debug配合甚至直接把模块编译进内核把调试信息输出到串口。我在调一个块设备驱动时就吃过这个亏系统起来后块设备早就加载完了我的模块根本插不进去后来改成编译进内核才能在启动早期看到日志。搞内核调试就是这样方法很多关键是在正确的时间用正确的工具而积累这些判断力只能靠一次次的现场实战换出来。