3.4 使用内核的动态调试大杀器
上一节我们讲到,trace_printk() 是高频路径下的救生圈。但这就带来一个两难的局面:为了调试,我们需要打印;但为了性能,我们又不敢多打——尤其是在生产环境。
这种两难,本质上是因为我们一直在用「编译时」的开关(比如 #ifdef DEBUG)来控制「运行时」的行为。
这就像是飞机已经在天上了,你却还得回到地面的机库去调整机翼。太慢了,太笨重了。
如果能在运行时随意控制任何一句 pr_debug() 的开关,那该多好?
这不仅仅是美好的愿望,这是现实。内核有一个叫 Dynamic Debug(动态调试) 的设施,它就是为此而生的。但为了真正理解它的好处,我们需要先看看它的「前辈」——模块参数方案。
3.4.1 动态调试的前奏:模块参数
在 Dynamic Debug 普及之前,内核开发者们最常用的办法就是:用一个模块参数来控制调试开关。
这招非常简单,你甚至可以在很多现成的驱动代码里看到它。
思路是这样的:定义一个 debug 变量,默认是 0(关)。如果是 1(开),就打印日志。
static int debug;
module_param(debug, int, 0644);
这一行代码的背后,内核会帮你在 sysfs 里创建一个文件:
/sys/module/<模块名>/parameters/debug
实操:看看 i8042 是怎么干的
经典的 i8042 键盘/鼠标控制器驱动就用这招。打开它的源码(drivers/input/serio/i8042.c),你会看到:
// drivers/input/serio/i8042.c
static bool i8042_debug;
module_param_named(debug, i8042_debug, bool, 0600);
MODULE_PARM_DESC(debug, "Turn i8042 debugging mode on and off");
这里有个小细节:它用了 module_param_named。这允许你在代码里用变量名 i8042_debug,但在sysfs 里暴露名为 debug。这是一种好的命名习惯。
怎么用它?
首先,你可以用 modinfo 看看这个模块有什么参数:
$ modinfo -p /lib/modules/$(uname -r)/kernel/drivers/hid/hid.ko
debug:toggle HID debugging messages (int)
ignore_special_drivers:Ignore any special drivers and handle all devices by generic driver (int)
模块加载后,就可以直接操作那个 sysfs 文件了(注意权限是 0600,只有 root 能玩):
$ ls -l /sys/module/i8042/parameters/debug
-rw------- 1 root root 4096 Oct 3 07:42 /sys/module/i8042/parameters/debug
# 查看当前状态(默认是 N,即关)
$ sudo cat /sys/module/i8042/parameters/debug
N
# 打开它
# echo "Y" > /sys/module/i8042/parameters/debug
# 关闭它
# echo "N" > /sys/module/i8042/parameters/debug
这看起来很完美,不是吗?不需要重新编译,动态开关。
进阶玩法:多级 verbosity
你甚至可以把这个 debug 参数改成整数,搞一套「verbosity(详细程度)」分级系统:
- 0:完全静默
- 1:只打印关键路径
- 2:打印所有细节
- ...
然后代码里到处都是 if (debug > 1) pr_debug(...)。
⚠️ 但这里有个坑
这招虽然管用,而且灵活,但它有一个致命的缺点:性能开销。
想想看:即使 debug=0(关),CPU 每次跑到那个 if 语句时,还是得做一次判断。如果这个函数在一秒钟内被调用一百万次(这在内核里很常见),你就做了一百万次无效的判断。
而且,代码里会充斥着各种 if (debug > x),这让代码变得很丑,维护起来也是噩梦。
有没有一种办法,既能像模块参数那样动态开关,又在关闭时零开销?
有。这就是 Dynamic Debug 为什么被称为「大杀器」的原因。
3.4.2 认识 Dynamic Debug
Dynamic Debug (Dyndbg) 的设计哲学完全不同:
它不是在代码里加
if,而是直接修改代码本身。
这听起来很黑科技,其实原理就是内核的「动态代码修补」技术(和 ftrace 底层用的技术一样)。当你在 Dyndbg 里关闭一个日志点时,内核会直接把那段汇编指令_NOP_ 掉,或者跳过它。开销?几乎为零。
前提准备
要玩这个功能,你的内核配置里必须开启 CONFIG_DYNAMIC_DEBUG。
现在的发行版内核通常都开了。如果是自己剪裁的嵌入式内核,记得打开这个选项。
顺便提一句,这会让内核体积稍微变大一点点(大约 2%)。如果你极其在意体积,可以只开核心功能 CONFIG_DYNAMIC_DEBUG_CORE,然后在需要它的模块 Makefile 里加上 ccflags-y += -DDYNAMIC_DEBUG_MODULE。
它能控制谁?
不仅仅是 pr_debug()。只要是用到了 Dynamic Debug 机制的宏,统统都能管:
pr_debug()dev_dbg()print_hex_dump_debug()print_hex_dump_bytes()
3.4.3 找到控制台:debugfs 与 procfs
Dynamic Debug 的控制台是一个虚拟文件。根据你的内核配置不同,它可能出现在两个地方:
-
理想情况(调试内核): debugfs 文件系统被挂载了。控制文件在:
/sys/kernel/debug/dynamic_debug/control -
生产环境(安全加固过): 出于安全考虑,生产环境通常会禁止挂载 debugfs(
CONFIG_DEBUG_FS_DISALLOW_MOUNT=y)。这时候,控制文件会"降落"到 procfs:/proc/dynamic_debug/control
怎么判断?
先试着 mount | grep debugfs。如果没输出,就用 /proc/... 那个路径。不用慌,两者功能完全一样,只是路径不同。
看一眼这个文件长什么样
这个文件的内容通常是巨大的。因为它列出了内核里所有标记为动态调试的打印点。
# wc -l /sys/kernel/debug/dynamic_debug/control
3217 /sys/kernel/debug/dynamic_debug/control
在我这个调试内核上,有 3000 多个控制点。让我们只看前几行:
# head -n5 /sys/kernel/debug/dynamic_debug/control
# filename:lineno [module]function flags format
drivers/powercap/intel_rapl_msr.c:151 [intel_rapl_msr]rapl_msr_probe =_ "failed to register powercap control_type.\012"
drivers/powercap/intel_rapl_msr.c:94 [intel_rapl_msr]rapl_msr_read_raw =_ "failed to read msr 0x%x on cpu %d\012"
sound/pci/intel8x0.c:3160 [snd_intel8x0]check_default_spdif_aclink =_ "Using integrated SPDIF DMA for %s\012"
解读这个格式
第一行是表头:
# filename:lineno [module]function flags format
后面每一行都是一个「打印点」。我们拿第二行来拆解一下:
drivers/powercap/intel_rapl_msr.c:151 [intel_rapl_msr]rapl_msr_probe =_ "failed to register powercap control_type.\012"
把它拆开看:
- 文件名:
drivers/powercap/intel_rapl_msr.c:151这就是源码路径和行号。你可以直接去这一行看代码。 - 模块名:
[intel_rapl_msr]方括号里的是包含这个代码的模块名。如果是内核核心代码(vmlinuz),这里可能没有方括号。 - 函数名:
rapl_msr_probe这个打印点所在的函数。 - 标志位:
=_这是关键中的关键。 它决定了这个打印点是开是关,以及打印什么额外信息。 - 格式字符串:
"failed to register powercap control_type.\012"这就是传给printk的那个字符串(\012是换行符的八进制编码)。
验证一下源码
为了让你相信这个文件不是编的,我们去源码里看看第 151 行:
// drivers/powercap/intel_rapl_msr.c
149 rapl_msr_priv.control_type = powercap_register_control_type(NULL, "intel-rapl", NULL);
150 if (IS_ERR(rapl_msr_priv.control_type)) {
151 pr_debug("failed to register powercap control_type.\n");
152 return PTR_ERR(rapl_msr_priv.control_type);
153 }
完美匹配。
3.4.4 玩转标志位
刚才那个 =_ 是什么意思?
= 意味着「设置」,_ 意味着「无标志」。所以 =_ 就是「不开启任何功能,也就是关闭」。
你可以把它改成 =p(开启打印),或者加上更多前缀。这是 Dynamic Debug 最强大的地方:它不仅控制开/关,还控制日志的「详细程度」。
| 标志 | 含义 | 效果 |
|---|---|---|
| p | 开启该打印点。如果不加 p,其他的标志(mftl)也不会生效。 | |
| f | Function | 在日志前缀中包含函数名。 |
| l | Line number | 在日志前缀中包含行号。 |
| m | Module | 在日志前缀中包含模块名。 |
| t | Thread ID | 在日志前缀中包含进程 ID / 线程 ID(PID/TID)。 |
| _ | No flags | 啥也不干(默认状态)。 |
操作符
你会用三个符号来组合这些标志:
- = :设置。
=p意味着「只开 p,关掉其他的」。 - + :添加。
+p意味着「把 p 加上」。 - - :移除。
-p意味着「把 p 拿掉」。
举个例子
假设原始状态是 =_(全关)。
- 输入
+p-> 状态变为=p(开了)。 - 再输入
+t-> 状态变为=pt(开了,并且显示线程 ID)。 - 输入
-p-> 状态变为=t(显示线程 ID,但把打印关了——这就没意义了)。 - 输入
=pflt-> 状态直接变为=pflt(开启打印,显示函数、行号、线程)。
3.4.5 实战:在生产环境上调试模块
光说不练假把式。我们拿一个简单的 misc 驱动来做实验。
场景设定:
我们有一个 miscdrv_rdwr 驱动。
我们在编译它时,故意不定义 DEBUG 宏。
我们在生产内核上跑它(5.10.60-prod01)。
现在,突然有个 Bug,我们需要看它的日志。
Step 1:准备环境和模块
首先,检查一下 debugfs 是否挂载。
$ mount | grep -w debugfs
# (没输出)
正如我们预料的,生产内核上没挂 debugfs。没关系,用 /proc 那个控制文件。
先把模块加载进去(这里省略编译过程,假设你有编译好的 .ko 文件):
# insmod miscdrv_rdwr.ko
# dmesg | tail
[...] miscdrv_rdwr:miscdrv_rdwr_init(): LLKD misc driver (major # 10) registered
模块加载成功了。
Step 2:确认「未定义 DEBUG」的状态
我们往设备里写点数据,看看有没有 Debug 日志。
# echo "test" > /dev/llkd_miscdrv_rdwr
# dmesg | tail
[...] misc llkd_miscdrv_miscdrv_rdwr: filename: "/dev/llkd_miscdrv_rdwr"
只看到了一条 pr_info 类型的日志(那条 filename 是由 pr_info 打印的)。代码里的那些 dev_dbg 全部失踪了。这就是 DEBUG 未定义时的预期行为。
Step 3:用 Dynamic Debug「透视」代码
现在,我们在不重新编译、不重启的情况下,让那些 dev_dbg 显形。
先看看我们的模块在这个控制文件里有哪些点:
# grep "miscdrv_rdwr" /proc/dynamic_debug/control
<...>/miscdrv_rdwr.c:303 [miscdrv_rdwr]miscdrv_rdwr_init =_ "A sample print via the dev_dbg(): driver initialized\012"
<...>/miscdrv_rdwr.c:242 [miscdrv_rdwr]close_miscdrv_rdwr =_ "filename: \042%s\042\012"
[...]
看到了吗?
那些原本因为 DEBUG 未定义而「死」掉的代码,其实都活着,只是处于休眠状态(=_)。
Step 4:开启它
动手。我们要让 miscdrv_rdwr 模块的所有日志都打出来。
# echo -n "module miscdrv_rdwr +p" > /proc/dynamic_debug/control
注意:这里用了 module 关键字。这是 Match Spec(匹配规范)的一种。后面细讲。
现在再看一眼控制文件:
# grep "miscdrv_rdwr" /proc/dynamic_debug/control | head -n1
<...>/miscdrv_rdwr.c:303 [miscdrv_rdwr]miscdrv_rdwr_init =p "A sample print via the dev_dbg(): driver initialized\012"
看!=_ 变成了 =p。
Step 5:验证效果
再次操作设备:
# echo "test" > /dev/llkd_miscdrv_rdwr
# dmesg | tail
[...] misc llkd_miscdrv_miscdrv_rdwr: filename: "/dev/llkd_miscdrv_rdwr"
[...] miscdrv_rdwr:miscdrv_rdwr:open_miscdrv_rdwr(): 001) bash :1080 | ...0 /* open_miscdrv_rdwr() */
[...] miscdrv_rdwr: misc llkd_miscdrv_miscdrv_rdwr: opening "/dev/llkd_miscdrv_rdwr" now; wrt open file: f_flags = 0x8241
哇哦!
刚才那些沉默的 dev_dbg,现在全部跑出来了。你甚至能看到函数名、文件名、甚至是一些内部状态(f_flags)。
这完全是在运行中的生产内核上做到的。
Step 6:玩点高级的(Match Spec + Flags)
让我们关掉它,然后换个更高级的姿势打开。这次,我们不仅想看日志,还想看是哪个进程在操作它(加上 t flag),以及模块名(加上 m flag)。
# 先关掉
# echo -n "module miscdrv_rdwr -p" > /proc/dynamic_debug/control
# 再开,加上 p, t, m
# echo -n "module miscdrv_rdwr +ptm" > /proc/dynamic_debug/control
再操作一次:
# echo "advanced test" > /dev/llkd_miscdrv_rdwr
# dmesg | tail
[...] [1080] miscdrv_rdwr: miscdrv_rdwr:open_miscdrv_rdwr(): 001) bash :1080 | ...0 /* open_miscdrv_rdwr() */
注意看日志开头:[1080]。
这就是 PID 1080(也就是我们的 bash 进程)在操作设备。这在调试多线程竞态问题时简直是救命稻草。
3.4.6 Dynamic Debug 的语法秘籍
刚才那行 echo "module miscdrv_rdwr +ptm" 看起来像魔法。其实它有一套非常严格的语法。
命令格式:
echo -n "<match-spec> <flags>" > <control-file>
这里有两个部分:Match Spec(匹配谁) 和 Flags(怎么改)。
1. Flags(怎么改)
我们已经讲过了:=pmflt, +p, -t 等等。
2. Match Spec(匹配谁) Match Spec 决定了你的命令会修改哪些打印点。你可以非常精准地狙击一行代码,也可以狂野地打开整个模块。
常用的 Match Spec 关键字:
| 关键字 | 语法示例 | 作用 |
|---|---|---|
| module | module miscdrv | 匹配模块名为 miscdrv 的所有打印点(支持通配符 *)。 |
| file | file drivers/tty/* | 匹配路径名匹配的文件。 |
| func | func usb_submit_urb | 匹配函数名。 |
| line | line 100-150 | 匹配行号范围(100-150 表示 100 到 150 行)。 |
| format | format "DMA:" | 匹配包含特定字符串的格式化打印。 |
组合技
你可以把多个 Spec 组合起来,它们之间是**与(AND)**的关系。
# 只打开 snd 驱动里,函数名包含 'ctl' 的,并且行号在 600 之前的日志
echo -n "module snd func *ctl* line 1-600 +p" > /proc/dynamic_debug/control
这就像你在写数据库查询语句一样,只不过你查询的是内核代码。
3.4.7 生产环境的拦路虎:Kernel Lockdown
现在,还有一个潜在的问题。
如果你的生产内核开启了 Kernel Lockdown(内核锁定模式)(CONFIG_LOCK_DOWN_KERNEL),你可能会发现没法加载模块,或者没法操作 Dynamic Debug。
Lockdown 的目的是保护内核安全,防止有人(哪怕是 root)加载未签名的模块或修改内核内存。这通常是安全团队要求的。
怎么绕过?
如果你是系统管理员,你有权在启动时临时关闭它。
方法:修改 Bootloader 参数
重启机器,在 GRUB 界面按 e 编辑启动项,在 linux 那一行的末尾加上:
lockdown=none
然后按 Ctrl+x 启动。
或者在 /etc/default/grub 里永久修改(把 GRUB_CMDLINE_LINUX_DEFAULT 加上 lockdown=none),然后 update-grub。
⚠️ 注意:这会降低安全性。调试完记得改回来!
3.4.8 开机就调试?Boot 参数
Dynamic Debug 不仅能给 insmod 后的模块用,还能给内核核心代码和内置模块用。
如果你想调试内核启动阶段(比如初始化时的 initcall),你不能在启动后才去写 /proc/... 文件,因为那会儿早就过去了。
你得在启动时就把需求告诉内核。
玩法一:调试内置模块/内核核心
在 GRUB 的 kernel cmdline 里加:
dyndbg="module mydriver +p"
或者更复杂的:
dyndbg="file drivers/usb/* +pflmt"
玩法二:调试可加载模块的初始化
如果你想调试一个模块加载的一瞬间(比如 modprobe xxx),你可以把指令塞到 /etc/modprobe.d/ 配置里。
创建 /etc/modprobe.d/mydriver.conf:
options mydriver dyndbg=+pmflt
这样,每次 modprobe mydriver 时,systemd 会自动帮你把 Dyndbg 打开。
甚至可以直接在 cmdline 里搞:
mydriver.dyndbg="file mydriver.c +pmft"
3.4.9 顺手牵羊:其他有用的内核参数
既然说到 Boot 参数,顺便再送你几个可能救命的神器:
| 参数 | 作用 |
|---|---|
| debug | 开启所有内核调试消息(小心洪水)。 |
| ignore_loglevel | 忽略 loglevel,直接把所有消息全吐到控制台。极其吵,但关键时刻能救命。 |
| initcall_debug | 打印每一个 initcall 的执行时间和返回值。用来查启动卡死的神器。 |
如果你在内核启动的某个地方卡住了,加上 initcall_debug,你会看到最后停在哪个 initcall 上,那凶手就在那里。
3.4.10 收尾
到此为止,我们已经武装到了牙齿。
从最简单的 printk,到分级的 pr_debug,再到限速的 ratelimited,最后到上帝视角的 Dynamic Debug。
你不再是一个拿着锤子(printf)到处乱敲的维修工。你是一个带着精密仪器,可以在不停止机器运行的情况下,深入内核内部探查的工程师。
但在某些极端情况下,连 printk 都太慢了,或者内核还没起来到能打印的时候怎么办?下一章,我们要去触碰更深层的调试机制。