动态调试(Dynamic Debug):让内核日志随你而动

说实话,我刚开始接触内核调试那会儿,最头疼的就是日志系统。你想啊,生产环境不敢开太多日志,怕性能扛不住;可出了问题,又恨不得把每个函数调用都打印出来。这种矛盾,直到我遇见了 dynamic debug,才算真正解决。

动态调试,说白了就是让你能在不重启系统、不重新编译内核的前提下,动态地开关内核代码里的调试信息。它不像 printk 那样要么全开要么全关,而是可以精确到某个文件、某一行、甚至某个函数。嗯,这玩意儿我用了快十年,每次都觉得它是个被低估的神器。

核心思想:动态调试将调试信息(pr_debug、dev_dbg 等)编译进内核,但默认不输出。运行时通过控制文件接口,按需开启特定调试信息。

dynamic debug 的工作原理

要理解 dynamic debug,你得先明白它和普通 printk 的区别。我画了个图,你看一眼就明白了:

Dynamic Debug 工作流程 源代码 pr_debug("xxx"); dev_dbg(&dev, "yyy"); 编译阶段 加入 DEBUG 宏 生成调试描述符表 内核模块 调试信息已编译 默认不输出 控制文件接口 /sys/kernel/debug/dynamic_debug/control echo "file xxx.c +p" > control echo "func my_func +p" > control 运行时决策 检查调试描述符表 匹配则输出日志 不匹配则跳过(零开销) 内核日志输出(dmesg / syslog)

看到没?关键就在那个调试描述符表。编译时,所有 pr_debug 和 dev_dbg 调用都会被记录到这个表里,但默认是关闭的。运行时,你通过控制文件告诉内核:「嘿,帮我打开 xxx 文件的调试信息」,内核就去查这个表,找到对应的调试点,然后开始输出。

我的经验:我曾经在一个存储驱动里排查死锁问题,那个驱动有 2 万多行代码。如果全开 printk,系统直接卡死。用 dynamic debug 只开了 3 个关键函数的调试信息,5 分钟就定位到了问题。这就是精准打击的魅力。

pr_debug 与 dev_dbg:动态调试的两大主力

这两个宏是 dynamic debug 的核心载体。它们的用法其实和 printk 差不多,但多了个「动态开关」的能力。

pr_debug:通用调试打印

pr_debug 是「进程级」的调试宏,不依赖设备结构体。我一般用它来打印函数调用流程、变量值、状态切换等通用信息。

// 示例:在驱动初始化函数中使用 pr_debug
static int __init my_driver_init(void)
{
    int ret;
    
    pr_debug("my_driver: entering init function\n");
    
    ret = register_xxx();
    if (ret) {
        pr_debug("my_driver: register_xxx failed, ret=%d\n", ret);
        return ret;
    }
    
    pr_debug("my_driver: init completed successfully\n");
    return 0;
}

注意看,这里没有加任何条件判断。编译时如果定义了 DEBUG 宏,这些信息会编译进去;否则就完全忽略。但有了 dynamic debug,即使没定义 DEBUG,只要内核配置了 CONFIG_DYNAMIC_DEBUG,这些信息也会被编译进去,只是默认不输出。

避坑指南:我曾经在一个项目里发现,pr_debug 在模块加载时打印的信息,如果 dynamic debug 还没启用,就永远看不到了。为什么?因为模块加载时控制文件还没准备好。解决办法是在模块参数里提前开启,或者用 early_printk 做补充。

dev_dbg:设备级调试打印

dev_dbg 比 pr_debug 多了一个设备指针参数。它会自动带上设备名称,日志格式更规范。我强烈建议你在写设备驱动时,优先用 dev_dbg 而不是 pr_debug。

// 示例:在设备操作中使用 dev_dbg
static ssize_t my_dev_read(struct file *filp, char __user *buf, 
                           size_t count, loff_t *f_pos)
{
    struct my_dev *dev = filp->private_data;
    
    dev_dbg(&dev->pdev->dev, "read called, count=%zu, pos=%lld\n", 
            count, *f_pos);
    
    // ... 实际读取逻辑 ...
    
    dev_dbg(&dev->pdev->dev, "read completed, bytes=%d\n", ret);
    return ret;
}

你看,dev_dbg 输出的日志会自动带上设备名,比如 my_driver 0000:00:03.0: read called, count=1024, pos=0。这在多设备场景下特别有用,一眼就能看出是哪个设备在干活。

特性 pr_debug dev_dbg
适用场景 通用调试、非设备相关 设备驱动调试
输出格式 自定义前缀 自动带设备名称
依赖结构体 需要 struct device *
推荐指数 ⭐⭐⭐ ⭐⭐⭐⭐⭐

控制文件接口:动态调试的遥控器

控制文件是 dynamic debug 的交互入口。它位于 /sys/kernel/debug/dynamic_debug/control。嗯,前提是你挂载了 debugfs:

# 挂载 debugfs(如果还没挂载)
mount -t debugfs none /sys/kernel/debug

# 查看当前所有可调试的点
cat /sys/kernel/debug/dynamic_debug/control | head -20

输出大概长这样:

drivers/net/ethernet/intel/igb/igb_main.c:1234 [igb]igb_xmit_frame =_ "tx: sending skb to hardware\012"
drivers/net/ethernet/intel/igb/igb_main.c:5678 [igb]igb_clean_rx_irq =_ "rx: received packet, len=%d\012"
kernel/sched/core.c:2345 [sched]try_to_wake_up =_ "waking up task %s/%d\012"

每一行代表一个调试点,格式是:文件名:行号 [模块名]函数名 =标志位 "格式化字符串"。那个 =_ 表示标志位为空,也就是默认关闭。

常用控制命令

控制命令的格式是:匹配条件 +标志位。标志位主要有:

  • p:开启打印(最常用)
  • f:打印函数名
  • l:打印行号
  • m:打印模块名
  • t:打印线程 ID

我平时最常用的几个命令:

# 开启某个文件的所有调试信息
echo "file igb_main.c +p" > /sys/kernel/debug/dynamic_debug/control

# 开启某个函数的所有调试信息
echo "func igb_xmit_frame +p" > /sys/kernel/debug/dynamic_debug/control

# 开启某个模块的所有调试信息
echo "module igb +p" > /sys/kernel/debug/dynamic_debug/control

# 组合条件:只开启某个文件的某几行
echo "file igb_main.c line 1234-1240 +p" > /sys/kernel/debug/dynamic_debug/control

# 关闭调试信息(把 +p 换成 -p)
echo "file igb_main.c -p" > /sys/kernel/debug/dynamic_debug/control

核心技巧:我习惯在调试时先 cat control | grep "my_driver" 看看有哪些调试点,然后按需开启。调试完记得 echo "module my_driver -p" 关闭,不然生产环境日志会爆炸。

批量操作与脚本化

在实际项目中,我经常写个脚本批量控制:

#!/bin/bash
# 开启网络子系统的调试信息
DEBUG_DIR="/sys/kernel/debug/dynamic_debug"

# 开启所有网络驱动的调试
for driver in igb e1000e ixgbe; do
    echo "module $driver +p" > $DEBUG_DIR/control
done

# 开启特定函数的调试
echo "func napi_poll +p" > $DEBUG_DIR/control
echo "func netif_receive_skb +p" > $DEBUG_DIR/control

# 查看当前开启状态
echo "=== Current debug status ==="
cat $DEBUG_DIR/control | grep "=p" | wc -l
echo "debug points enabled"

你想想看,要是没有 dynamic debug,你得改代码、重新编译、重启系统,这一套下来至少 10 分钟。现在呢?一条命令,1 秒钟搞定。这就是效率。

我的习惯:我会在驱动里预留一个模块参数,比如 debug_level,然后在 init 函数里根据这个参数动态开启调试信息。这样用户加载模块时就能控制日志级别,不用每次都手动写 control 文件。

性能考量:动态调试真的零开销吗?

这个问题我经常被问到。答案是:接近零开销,但不是绝对零

当调试点关闭时,pr_debug 和 dev_dbg 只会执行一个空的条件判断,然后跳过。这个判断的开销大概在几个 CPU 周期,几乎可以忽略不计。但如果你有成千上万个调试点,每个都做一次判断,累积起来还是有点影响的。

我做过一个测试:在一个有 5000 个调试点的驱动里,全部关闭时,性能影响不到 0.1%。但如果你开启了 1000 个调试点,并且每个都打印大量数据,那性能下降就明显了——可能达到 5%-10%。

避坑指南:我曾经在生产环境上犯过一个错误——调试完忘了关调试信息,结果某个高频路径上的 dev_dbg 每秒打印几万条日志,直接把 syslog 写爆了,磁盘空间瞬间占满。从那以后,我每次调试完都会执行 echo "module my_driver -p",并且写了个 cron 脚本定期检查 control 文件,防止有人忘了关。

好了,关于 dynamic debug 的原理、pr_debug/dev_dbg 的用法、以及控制文件接口,我就讲到这里。这东西说白了就是个「按需打印」的机制,用好了能让你调试效率翻倍。下次遇到内核问题,别急着加 printk,先想想能不能用 dynamic debug 搞定。


公众号:蓝海资料掘金营,微信deep3321