
踩坑从来都是最好的学习方式。这篇文章我就把过去一段时间折腾 Linux 内核调试的完整记录整理出来从模块加载失败、驱动冲突、到用 QEMU 调试内核崩溃、再到自己写file_operations拦截时踩的坑都原原本本还原出来。整个过程没有用到特别高深的理论但每一步排查的思路、工具的选择、以及事后复盘得出的结论我猜对正在搞内核驱动或者对底层机制好奇的朋友会很有用。1. 从一次 rc-1908 开始内核模块为什么加载不进去1.1 看似“玄学”的 VirtualBox 驱动加载失败很多人的内核调试之路不是从写代码开始的而是从一个报错开始的。我那次是因为 VirtualBox 虚拟机突然起不来启动任何 VM 都会报Kernel driver not installed (rc-1908) The VirtualBox Linux kernel driver (vboxdrv) is either not loaded or there is a permission problem...其实rc-1908本身已经说得很明白内核模块vboxdrv没有加载成功。但“没有加载成功”和“不能加载”完全是两回事。很多人会下意识直接modprobe vboxdrv结果大概率又是一句modprobe: ERROR: could not insert vboxdrv: Operation not permitted然后就懵了。我习惯性先看dmesg | tail但那次dmesg出了个很常见的坑权限限制导致普通用户看不到内核环形缓冲里的关键信息。如果你也遇到命令敲了却没有任何输出优先sudo dmesg | tail -50别在权限问题上浪费太多时间。1.2 Secure Boot 才是第一怀疑对象Operation not permitted这类错误在 Ubuntu 22.04 平台上出现频率最高一大半原因是Secure Boot。内核模块在加载时必须要通过内核模块签名校验而 VirtualBox 的第三方驱动默认没有注册到 MOKMachine Owner Key里于是被内核拒绝了。我当时的排查流程是这样的# 查看 Secure Boot 是否开启 mokutil --sb-state # 如果输出 SecureBoot enabled那就是签名问题 sudo mokutil --disable-validation也可以直接在 BIOS 里关掉 Secure Boot但如果你不想重启机器用 MOK 注册签名也可以。不过对个人开发机来说最简单粗暴且实际的做法就是关闭 Secure Boot因为后面你可能还要反复编内核模块每个模块都去做签名不现实。1.3 依赖链断裂头文件版本与编译器不匹配Secure Boot 排查完之后如果modprobe vboxdrv依然失败那就再看一眼/var/log/vbox-setup.log和dmesg这两个文件几乎能定位 90% 的模块加载问题。我在一台机器上遇到的报错是编译模块时报了“头文件找不到”或“版本字符串不匹配”。原因是系统更新过内核从6.2.0-xx升到了6.5.0-xx但/lib/modules/$(uname -r)/build这个软链接指向的linux-headers-$(uname -r)没装或者装的是旧版本。模块编译时其实是在对着一套不完整的头文件做校验自然过不去。解决办法很直接sudo apt install linux-headers-$(uname -r) sudo /sbin/vboxconfig别忘了gcc版本也要匹配。如果你同时装了多个 GCC 版本内核编译时会因为编译器版本跟编内核时不一致直接报CC version check failed。这时候要么切换/usr/bin/gcc的 symlink要么用make CCgcc-12这种形式指定编译器。这类问题我自己碰了两次每次都因为没先看编译日志白折腾半天。2. 真正的内核态调试三板斧printk、kdump、KGDB2.1 printk 不是 low tech而是最稳的第一道防线很多人看不起printk觉得这不是调试是“打日志”。但内核态开发跟用户态不一样你不可能在任意位置下断点随便看变量很多 bug 必须到了现场才能判断。我在实际调驱动时第一件事永远是确认核心路径里有没有足够的打印。printk的级别很关键。很多人只会用默认的KERN_INFO结果发现某些日志根本没进/var/log/kern.log。我常用的级别是printk(KERN_ERR my_drv: something went wrong, val%d\n, val); printk(KERN_DEBUG my_drv: debug info, ptr%px\n, ptr);KERN_DEBUG在默认loglevel下不会显示需要临时打开echo 8 /proc/sys/kernel/printk或者直接dmesg -n 8。这个技巧对于排查驱动初始化顺序类问题非常有用尤其是探测函数probe有没有被调用、资源申请在哪一步失败加打印之后一目了然。2.2 kdump crash 的组合拳死机后的尸体解剖printk能解决运行中看得见的问题但遇到直接 panic 或者死机重启的场景必须上 kdump。我之前调一个自定义字符设备时一不小心在read回调里解引用了空指针内核直接 oops然后重启。当时如果没有 kdump重启后只能靠dmesg残留信息猜效率极低。配置了 kdump 之后crash 瞬间会把内存转储到磁盘重启后直接用crash工具打开 vmcore 分析。这里有个非常容易踩的坑kdump 的内核要预留足够的内存。在 GRUB 的linux行加crashkernel512M但 512M 对不同机器可能不够如果不确定就直接crashkernel1G。转储文件往往有几个 GB/var/crash目录所在分区要提前检查空间。打开 vmcore 后我最常用的三个命令crash bt # 查看崩溃时的调用栈 crash log # 查看内核日志相当于崩溃前 dmesg 的完整拷贝 crash p 变量名 # 查看任意全局变量bt基本能直接告诉我崩溃发生在哪个函数、由谁调用。这个信息量比任何靠猜的排查方式都大。2.3 KGDB本地调试的灵活性有限但适合远程场景KGDB 是一个内置在内核里的调试代理它通过串口或者网络接收 GDB 的指令可以实现真正的源码级断点调试。理论上你可以在__x64_sys_read这类函数下断点然后单步跟踪。不过要在真实机器上用 KGDB 调试需要另一台主机接串口线这个配置过程多少有些繁琐。我自己的经验是KGDB 更适合嵌入式环境或者你有专用调试机的场景如果你只有一台笔记本想折腾本地内核更推荐用 QEMU 跑一个调试内核体验好得多。3. 用 QEMU 搭一个随便折腾的内核调试环境3.1 为什么推荐 QEMU断点、单步、看寄存器全都自由我后来大部分内核调试其实都不在真机上做而是在 QEMU 虚拟机里做。原因很简单QEMU GDB 的组合能让你像调试普通用户态程序一样调试内核。你可以在任意函数下断点比如(gdb) break start_kernel (gdb) break do_sys_open也可以单步执行查看寄存器、内存、内核变量的值还能用lx-dmesg这类 GDB 命令直接读内核日志缓冲。更棒的是在虚拟机里调试内核崩溃了也不会搞坏宿主机器可以随便折腾。3.2 编译一个带调试符号的内核调试内核和普通内核最主要的区别就是编译选项。以下这些 CONFIG 必须打开CONFIG_DEBUG_INFOy CONFIG_DEBUG_KERNELy CONFIG_KGDBy CONFIG_FRAME_POINTERy CONFIG_GDB_SCRIPTSy其中CONFIG_DEBUG_INFO负责生成 DWARF 调试符号没有它 GDB 就看不到源码行号CONFIG_GDB_SCRIPTS会提供一组内核专用的 GDB 辅助脚本调试时非常有用。编译的话就是标准流程make defconfig make menuconfig make -j$(nproc)如果只是为了调试内核本身不需要全量编译所有模块可以先make -j$(nproc) vmlinux bzImage等需要模块时再编模块。我一开始每次全量编译都要等很久后来只编这两个目标快多了。3.3 QEMU 启动与 GDB 连接QEMU 启动内核调试最关键的是加-s -S两个参数qemu-system-x86_64 \ -kernel arch/x86/boot/bzImage \ -initrd /path/to/initrd.img \ -append root/dev/ram0 consolettyS0 nokaslr \ -nographic \ -s -S \ -m 2G参数含义-s表示在 TCP 1234 端口开放 GDB 调试服务-S表示启动后立即暂停 CPU等待 GDB 连接nokaslr非常重要它禁止内核地址空间随机化否则 GDB 下断点时地址会对不上然后另开一个终端gdb vmlinux (gdb) target remote :1234 (gdb) hbreak start_kernel (gdb) continuehbreak是硬件断点在调试内核早期启动阶段比软断点更可靠。我最早用break在start_kernel上下断点结果一直没触发后来换成hbreak立刻就好原因是那段代码所在的内存区域可能还处在不能使用软件断点指令的状态。3.4 真实场景单步跟踪系统调用有一次我想弄清楚一个文件系统相关的 bug直接跟踪do_sys_open就行。处理流程是(gdb) break do_sys_open (gdb) continue # 在虚拟机里执行某个 shell 命令触发文件打开 (gdb) bt (gdb) p filename这种“人肉 ftrace”的方式虽然慢但能很直观地看到内核是怎么从系统调用入口一路走到具体文件系统实现的。遇到看不懂的流程时单步看一遍调用链比单纯看源码快多了。4. 一次 file_operations 拦截的真实排错模块加载成功但 hook 不生效4.1 从需求说起为什么要拦截 read/write我有一段时间接到一个需求想在内核态监控某个设备文件的读写操作。当时最直接的思路就是替换该文件对应的struct file_operations里的.read和.write指针换成自己的实现再在实现内部调用原始函数。这就是典型的 file_operations hook。原理并不复杂内核里每个文件/设备都对应一个file结构里面有一个f_op指针指向file_operations结构体。把.read、.write改成我们的函数所有对这个文件的读写在经过 VFS 层时就会先进到我们的代码。4.2 代码写好了模块也加载了却毫无反应当时我写了最简单的内核模块static struct file_operations *orig_fops; static struct file_operations hooked_fops; static ssize_t hooked_read(struct file *filp, char __user *buf, size_t count, loff_t *pos) { printk(KERN_INFO hooked read called\n); return orig_fops-read(filp, buf, count, pos); } static int __init hook_init(void) { // 找到目标 inode保存原始 f_op替换成 hooked_fops }模块加载成功dmesg也没有报错但实际读文件时hooked read called这条日志根本没出现。也就是 hook 没生效。这种问题最麻烦——不报错但行为不对。我花了一些时间排查最后总结出三个可能性。第一个可能file-f_op并不是从 inode 上拿的。VFS 在每次打开文件时调用do_dentry_open它会把inode-i_fop赋给file-f_op。但如果文件在 hook 之前就已经被打开了file-f_op已经复制了一份旧的file_operations指针这时你替换 inode 的i_fop根本不会影响已打开的文件描述符。我当时监控的目标文件是系统已经打开的日志文件所以替换 inode 的i_fop完全没用必须同时处理已打开文件实例。第二个可能我保存的原函数调用方式不对。read和write在struct file_operations里的函数指针类型是固定的一组参数。但有些驱动或者文件系统实现的是read_iter/write_iter而不是传统的read/write。如果原本就是read_iter被调用来处理读写请求那只改read指针是拦截不到任何东西的。这个问题在一个用io_uring的场景里特别常见因为io_uring直接走read_iter路径。第三个可能编译器对函数指针做了优化。有些情况下对f_op的读取会被缓存或者通过 RCU 读侧拿到的是旧指针。尤其当内核启用了CONFIG_SLAB_FREELIST_HARDENED一类的安全特性或者文件系统用了struct proc_ops而不是file_operations时直接改f_op这类做法往往失效。4.3 用 ftrace/kprobe 替代直接修改 f_op经过这次踩坑我后来对“动态修改内核数据结构”这件事变得非常谨慎。内核里有一整套更规范的追踪机制ftrace kprobe就够用了。用 kprobe 挂vfs_read或者vfs_write是更推荐的做法echo p:my_probe vfs_read file%di count%dx /sys/kernel/debug/tracing/kprobe_events echo 1 /sys/kernel/debug/tracing/events/kprobes/my_probe/enable cat /sys/kernel/debug/tracing/tracekprobe 的挂载点选在 VFS 层可以拦截所有文件的读写入口不用关心具体文件系统实现。更重要的是kprobe 不会改动内存里的任何内核代码安全性高很多。如果一定要自己写模块也可以用kallsyms_lookup_name拿到目标文件系统的file_operations地址然后只针对那些确实使用read回调的设备文件下手。切记并发问题——多核 CPU 上同时读写同一个文件时你替换的指针可能不是一个原子操作需要在替换前后加锁或至少用WRITE_ONCE/READ_ONCE保证可见性。4.4 那段代码最终改成了什么样最后我的模块没有直接改file_operations而是注册了一个security_file_permission类型的 LSM hook或者更轻量地用tracepoint在文件操作经过时记录日志。这样既不破坏原有调用链也不会因为某个文件系统有自己的read_iter而漏拦截。如果你要解决的是“我想知道谁在读某个文件”这种问题推荐优先级是能上 eBPF 就上 eBPFtracepoint:sys_enter_read这类能用 ftrace/kprobe 就用它实在需要自己写模块改动内核对象时才考虑替换函数指针并务必处理并发和已打开文件实例这个原则帮我省了无数个下午。5. 从 crash、oops 到 debugfs那些容易忽略的隐蔽细节5.1 空指针解引用为什么会发生在 read 回调里前面提到我调试一个自定义字符设备时在read回调里解引用了一个空指针。事后来看这个 bug 的根因非常典型我在open里分配了private_data但在某些错误路径上open提前返回了导致private_data为 NULL可用户态程序依然拿到了一个有效的 fd。等它调用read时内核进到我的回调一解引用就 oops。排查这种问题光看代码不容易定位。最好的办法是在read回调开头加一个防御性检查同时用IS_ERR_OR_NULL判断所有可能非法返回的情况。内核 API 返回错误指针是常态很多人习惯只判NULL结果错误码被当作有效地址传下去了这是非常常见的崩溃来源。5.2 debugfs内核态与用户态之间的调试后门调试内核模块时我最满意的技巧之一就是用 debugfs 暴露内部状态。你可以在/sys/kernel/debug下创建一个自己的目录用debugfs_create_file或debugfs_create_u32把关键变量直接变成文件然后在用户态用cat、echo查看或修改。static struct dentry *debug_dir; debug_dir debugfs_create_dir(my_drv, NULL); debugfs_create_u32(counter, 0644, debug_dir, my_counter); debugfs_create_file(status, 0444, debug_dir, NULL, status_fops);这个做法比较适合那些周期性变化的内部状态。比如我在调一个 DMA 驱动时想知道环形缓冲区到底写到第几格、硬件读到第几格直接在 debugfs 里放两个 u32 变量用户态脚本轮询即可不用反复插拔设备或者重启加载模块。相比printk刷屏这更优雅高效。要注意 debugfs 在有些内核配置里没开需要确认CONFIG_DEBUG_FSy。另外debugfs文件在模块卸载时要记得删掉否则会留下指向已释放内存的 dangling entry下一次打开它系统直接就崩了。5.3 GDB 脚本和 /proc/kallsyms内核调试的隐藏加速器GDB 加载vmlinux之后如果你编译时开了CONFIG_GDB_SCRIPTS就能在 GDB 里用一批非常有用的命令。我常用的(gdb) lx-dmesg (gdb) lx-lsmod (gdb) lx-ps这些比手工翻 dmesg 日志高效得多尤其调试内核模块时lx-lsmod能直接看到模块加载的基地址。而/proc/kallsyms能提供函数符号地址用于确认某个导出/非导出函数是否能够被 kprobe 挂载。需要注意kptr_restrict这个 sysctl 在安全配置里会限制非 root 用户查看地址调试时如果发现地址全为 0记得先echo 0 /proc/sys/kernel/kptr_restrict或者确认是否用 root。5.4 修改启动参数和 crashkernel 预留提前做好准备很多内核调试操作需要提前在启动参数里加东西不然等出了问题再改就晚了。我常用的GRUB_CMDLINE_LINUX配置有nokaslr crashkernel512M printk.devkmsgonnokaslr不管是在真机上调试还是 QEMU 里调试都建议加上不然每次启动的符号地址都随机化GDB 断点很难定位。crashkernel是为 kdump 预留的不加这个panic 时连转储都做不了。printk.devkmsgon则是让内核日志直接在/dev/kmsg可读方便一些日志工具收集信息。6. 调试经验之外从换内核到修驱动的几个通用方法论6.1 先确认“你改的东西真被用到了吗”这句话听起来像废话但真正执行到位的人不多。内核里函数指针、结构体成员、宏开关实在太多你改的字段很可能根本不是运行时走的路径。比如file_operations的read和read_iter我敢说至少有一半内核开发者没搞清它们的差异。调这类问题最直接的方法是先bpftrace -e kprobe:vfs_read { [comm] count(); }或 ftrace 统计一下看看系统里当前哪些进程在调用vfs_read。如果连入口都没触发你改的东西自然不可能生效。6.2 从“能用”到“稳定”之间的坑很多内核模块在开发机上跑半天都没事一放到生产环境或长时间跑就崩原因多半和以下几点有关没有处理GFP_KERNEL内存分配失败的情况返回值直接用自旋锁和信号量混用导致睡眠时持有自旋锁没有使用READ_ONCE/WRITE_ONCE处理共享变量模块卸载时没有正确注销接口设备节点变成“僵尸”拿我在一次 PCIe 驱动调试里遇到的问题举例我申请 DMA 缓冲区时用了kmalloc但设备需要的物理地址要求 64K 对齐。结果在某些内存布局下 DMA 不工作但不是每次都失败查找起来极其痛苦。后来换成__get_free_pages并手动对齐问题才稳定消失。内核开发里内存对齐、内存屏障、并发模型这些东西不踩一遍真的理解不深。6.3 学会阅读 oops 信息不只是堆栈内核崩溃时打印的一大段 oops 信息很多人看到RIP:后面那个函数名就往那找这是不对的。完整的崩溃信息里最有价值的是这几行RIP:后面的函数名和偏移说明崩溃点在哪个函数里Call Trace:调用链说明是谁调进来的RSP:堆栈指针附近的内容有时能直接还原现场CR2:寄存器这是触发 page fault 的地址空指针时往往就是 0 或很小偏移的数字我处理一次死锁问题时就靠Call Trace发现一个持锁路径同时调用了可能睡眠的函数这光靠看代码根本看不出来。6.4 别神话内核调试工具最后想说一点实在的内核调试工具再多真正最常用的还是老老实实的代码阅读加上有策略的printk。工具可以提高效率但不能替代理解。QEMU GDB 这种“完美调试环境”也只是辅助你要调试的逻辑错误最终还是必须落在“对内核机制的理解”上。我自己调试最多的问题回头看其实都是对 VFS 层、内存管理、并发模型这几个基础模块理解不深导致的。把这几块啃透很多疑难杂症会变得非常显然。7. 写在最后的一点心得做内核调试最大的错觉是“这次改动小不会出问题”。事实上我几乎在每一次自认为安全的改动里都翻过车从那以后就养成了一个习惯每改一处先问自己四个问题——有没有可能 NULL有没有可能并发有没有可能在中断上下文被调用有没有可能在模块卸载后还在用这四个问题能挡住大多数低级但致命的 bug。如果这篇文章只能留下一句话那我会说内核调试没有银弹但把printk用熟、把 QEMU GDB 环境搭起来、把 ftrace/kprobe 用好你已经解决了绝大部分问题。剩下的就交给耐心和一点运气吧。