跳到主要内容

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 的控制台是一个虚拟文件。根据你的内核配置不同,它可能出现在两个地方:

  1. 理想情况(调试内核): debugfs 文件系统被挂载了。控制文件在: /sys/kernel/debug/dynamic_debug/control

  2. 生产环境(安全加固过): 出于安全考虑,生产环境通常会禁止挂载 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"

把它拆开看:

  1. 文件名drivers/powercap/intel_rapl_msr.c:151 这就是源码路径和行号。你可以直接去这一行看代码。
  2. 模块名[intel_rapl_msr] 方括号里的是包含这个代码的模块名。如果是内核核心代码(vmlinuz),这里可能没有方括号。
  3. 函数名rapl_msr_probe 这个打印点所在的函数。
  4. 标志位=_ 这是关键中的关键。 它决定了这个打印点是开是关,以及打印什么额外信息。
  5. 格式字符串"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 最强大的地方:它不仅控制开/关,还控制日志的「详细程度」。

标志含义效果
pPrint开启该打印点。如果不加 p,其他的标志(mftl)也不会生效。
fFunction在日志前缀中包含函数名。
lLine number在日志前缀中包含行号。
mModule在日志前缀中包含模块名。
tThread 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 关键字:

关键字语法示例作用
modulemodule miscdrv匹配模块名为 miscdrv 的所有打印点(支持通配符 *)。
filefile drivers/tty/*匹配路径名匹配的文件。
funcfunc usb_submit_urb匹配函数名。
lineline 100-150匹配行号范围(100-150 表示 100 到 150 行)。
formatformat "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 都太慢了,或者内核还没起来到能打印的时候怎么办?下一章,我们要去触碰更深层的调试机制。