1. 项目概述:为什么动态输出是内核开发的“探照灯”

在Linux内核开发与调试的漫长黑夜里, printk 函数就像一支手电筒,它简单、直接,能照亮你眼前的一小片区域。但当你面对的是一个庞大、复杂、且时刻在高速运转的系统时,手电筒的光束就显得过于局限了。你需要的是能灵活控制、按需点亮、且对系统性能影响最小的“探照灯”——这就是动态输出(Dynamic Debug,简称 dyndbg )。

动态输出并非一个全新的打印函数,而是对内核中现有的 pr_debug() 、 dev_dbg() 这类调试打印语句的运行时控制机制。在传统编译方式下,这些调试语句要么被完全编译进去(在定义了 DEBUG 宏时),导致内核镜像膨胀、日志噪音巨大;要么被完全编译掉,在需要排查问题时束手无策。动态输出彻底改变了这一局面。它允许你将所有 *_dbg() 语句都编译进内核,但在默认情况下让它们保持“沉默”。只有当你在系统运行时,通过向一个特殊的控制文件写入命令,才能精确地“激活”你关心的那部分调试信息,比如只启用某个特定源文件、某行代码、甚至某个模块的所有调试打印。

想象一下这个场景:你负责的网卡驱动在某个客户的复杂网络拓扑下偶发性丢包。用 printk 打全局日志,海量的数据会瞬间淹没关键信息,并且可能因日志I/O压力而改变问题发生的时序,让问题更难以复现。而使用动态输出,你可以先安静地让系统运行,在问题发生时或需要深入探查时,立即动态开启该网卡驱动相关文件的调试输出,捕获那一刻最精确的内部状态。这不仅是调试效率的提升,更是对生产环境友好性的巨大增强。对于内核开发者、驱动工程师以及从事深度系统运维的工程师来说,熟练使用动态输出是从“会用工具”到“善用工具”的关键一步。

2. 动态输出的核心原理与架构设计

2.1 编译时标记与运行时控制分离

动态输出的魔法始于编译时。当内核配置启用了 CONFIG_DYNAMIC_DEBUG 后,编译器(实际上是预处理阶段)会对源代码中每一个 pr_debug() 、 dev_dbg() 、 netdev_dbg() 等调用进行处理。处理方式是在该调用处插入一个特殊的、包含丰富元数据的“站点”描述符。这个描述符是一个 struct _ddebug 结构体,它被放置在一个特殊的ELF节区(例如 __dyndbg 节)中。

这个结构体包含了决定性的信息:

  • 文件名 : 如 drivers/net/ethernet/intel/e1000e/netdev.c
  • 函数名 : 如 e1000_xmit_frame
  • 行号 : 如 1205
  • 格式化字符串 : 即 pr_debug(“Transmitting frame, len=%d\n”, len); 中的原始字符串。

所有这些信息在编译链接后,都作为一个只读的数据集合存在于内核二进制中。内核启动时,会将这些描述符全部收集起来,形成一个中央查询表。此时,每一个调试语句都关联着一个控制标志位,默认为“禁用”。因此,尽管代码被编译进去了,但只要不执行,其性能开销近乎为零。

运行时的控制,则通过 debugfs 文件系统中的 dynamic_debug/control 文件来实现。 debugfs 是一个专为调试设计的内存文件系统。当你向 control 文件写入一条如 file drivers/net/ethernet/intel/e1000e/netdev.c +p 的命令时,内核会解析这条命令,在中央表中找到所有匹配 drivers/net/ethernet/intel/e1000e/netdev.c 这个文件的 _ddebug 条目,并将其控制标志位中的“打印”位( p )置为1。此后,当代码执行流再次经过这些站点时,就会检查这个标志位,如果发现“打印”位被激活,则执行真正的打印逻辑。

注意 : 动态输出控制的是“是否打印”,而不是“是否执行”。包含 pr_debug 的那行代码本身(包括参数计算)是始终会执行的,只是打印动作被跳过了。因此,要避免在 pr_debug 的参数中放入有副作用的函数调用,例如 pr_debug(“value: %d\n”, atomic_inc(&counter)) ,这会导致无论是否打印,计数器都会增加。

2.2 控制粒度:从模块到单行代码

动态输出提供了极其精细的控制粒度,这是它相比传统宏开关最大的优势。其控制命令遵循一个灵活的“选择器-标志位”模型。

选择器 用于匹配你想要控制的调试语句:

  1. 模块选择器 ( module ) : module e1000e 匹配 e1000e 模块中的所有调试语句。
  2. 文件选择器 ( file ) : file drivers/net/ethernet/intel/e1000e/netdev.c 匹配该文件中的所有调试语句。路径可以是完整的或相对的。
  3. 函数选择器 ( func ) : func e1000_xmit_frame 匹配该函数内的所有调试语句。
  4. 行号选择器 ( line ) : line 1205 或 line 1200-1250 匹配特定行或行区间的调试语句。
  5. 格式字符串选择器 ( format ) : format “Transmitting frame” 匹配打印信息中包含该子串的调试语句。

这些选择器可以通过 , 进行“与”组合,实现精准定位,例如: module e1000e func e1000_xmit_frame line 1200-1210 。

标志位 用于指定要执行的操作:

  • +p : 启用打印。
  • -p : 禁用打印。
  • +l : 启用 _LINE__ 宏输出(在打印信息中显示行号)。
  • -l : 禁用行号输出。
  • +m : 启用 _func__ 宏输出(在打印信息中显示函数名)。
  • -m : 禁用函数名输出。
  • +t : 启用线程ID输出。
  • -t : 禁用线程ID输出。

一个完整的命令示例: module e1000e +pmlt 。这条命令会启用 e1000e 模块内所有调试语句的打印功能,并在每条信息前加上函数名、行号和线程ID。

2.3 性能考量与最佳实践

动态输出在禁用状态下性能开销极低,通常只是一个内存读取和条件判断的开销。但在高频率路径(如网络数据包处理、存储IO循环)中,即使是一个条件判断,累积起来也可能可观。因此,最佳实践是:

  • 审慎添加 : 在性能敏感路径上,即使使用 pr_debug ,也要三思而后行。
  • 避免复杂参数 : 如前所述,避免在参数列表中调用复杂函数。
  • 动态启用 : 永远遵循“默认关闭,按需开启”的原则。不要在产品内核的启动参数中默认启用大量动态调试。

3. 动态输出的完整实操指南

3.1 内核配置与启动准备

要使用动态输出,首先必须确保你的内核支持它。这通常需要你自行编译内核。

  1. 配置内核 : 在内核源码目录下,运行 make menuconfig 。

    • 导航至 Kernel hacking -> printk and dmesg options 。
    • 选中 Enable dynamic printk() support (即 CONFIG_DYNAMIC_DEBUG )。
    • 同时,确保 Debug Filesystem (即 CONFIG_DEBUG_FS ) 被选中,因为控制接口依赖它。
  2. 编译与安装内核 : 按常规流程编译并安装新内核,然后重启到新内核。

  3. 挂载 debugfs : debugfs 通常不会自动挂载。你需要手动挂载它:

    mount -t debugfs none /sys/kernel/debug
    

    为了方便,可以将这行加入 /etc/fstab : debugfs /sys/kernel/debug debugfs defaults 0 0 。

  4. 验证 : 挂载后,检查控制文件是否存在:

    ls -l /sys/kernel/debug/dynamic_debug/control
    

    如果文件存在,说明动态输出功能已就绪。

3.2 基础命令语法与常用操作

动态输出的所有操作都通过向 /sys/kernel/debug/dynamic_debug/control 文件写入字符串命令来完成。使用 echo 命令或 cat 结合重定向。

1. 查看当前状态 在修改前,先查看所有或特定的调试语句状态是一个好习惯。

# 查看所有动态调试点的状态
cat /sys/kernel/debug/dynamic_debug/control

# 查看特定模块的状态,例如查看 e1000e 网卡驱动
grep “module e1000e” /sys/kernel/debug/dynamic_debug/control

输出格式类似于: drivers/net/ethernet/intel/e1000e/netdev.c:1205 [e1000e]e1000_xmit_frame =p “Transmitting frame, len=%d\n” 。这里的 =p 表示当前只有 p (打印) 标志被启用, =p_ 则表示 p 标志未启用。

2. 启用与禁用打印 这是最核心的操作。

# 启用整个 e1000e 模块的调试打印
echo “module e1000e +p” > /sys/kernel/debug/dynamic_debug/control

# 禁用整个 e1000e 模块的调试打印
echo “module e1000e -p” > /sys/kernel/debug/dynamic_debug/control

# 启用特定文件的所有调试打印
echo “file drivers/usb/storage/scsiglue.c +p” > /sys/kernel/debug/dynamic_debug/control

# 启用特定函数的调试打印
echo “func usb_stor_control_thread +p” > /sys/kernel/debug/dynamic_debug/control

3. 组合选择器进行精细控制

# 仅启用 e1000e 驱动中 netdev.c 文件里,行号在 1200 到 1210 之间的调试打印
echo “module e1000e file netdev.c line 1200-1210 +p” > /sys/kernel/debug/dynamic_debug/control

# 启用所有打印信息中包含 “error” 或 “timeout” 字样的调试点(注意格式)
echo ‘format “error” +p’ > /sys/kernel/debug/dynamic_debug/control
echo ‘format “timeout” +p’ > /sys/kernel/debug/dynamic_debug/control

4. 启用额外信息 有时,仅知道打印信息还不够,还需要知道它来自哪里。

# 启用打印,并同时显示函数名和行号
echo “module e1000e +pm” > /sys/kernel/debug/dynamic_debug/control
# 或者更详细
echo “module e1000e +pmlt” > /sys/kernel/debug/dynamic_debug/control

3.3 在代码中嵌入动态调试语句

作为开发者,你需要在代码中正确放置调试点,才能利用动态输出。

  1. 包含头文件 : 在需要使用动态调试的文件中,确保包含 <linux/dynamic_debug.h> 。不过,通常通过包含 <linux/printk.h> 间接包含就足够了。

  2. 使用正确的宏 :

    • pr_debug() : 最通用的调试打印宏,在未定义 DEBUG 时,其行为取决于 CONFIG_DYNAMIC_DEBUG 。当动态调试启用时,它会被编译进内核并受其控制。
    • dev_dbg() : 针对设备结构体的调试宏,会自动附加设备信息(如 [usb-storage] )。 这是驱动开发中最推荐使用的宏 。
    • 其他领域特定的 *_dbg() 宏,如 netdev_dbg() 用于网络设备。
  3. 一个驱动代码示例 :

    #include <linux/module.h>
    #include <linux/kernel.h>
    #include <linux/device.h> // 为了 dev_dbg
    
    static int my_driver_probe(struct device *dev)
    {
        struct my_private_data *priv;
        int ret;
    
        priv = devm_kzalloc(dev, sizeof(*priv), GFP_KERNEL);
        if (!priv)
            return -ENOMEM;
    
        dev_set_drvdata(dev, priv);
    
        // 使用 dev_dbg,信息更丰富,且受动态调试控制
        dev_dbg(dev, “Probing device, allocated private data at %p\n”, priv);
    
        ret = hardware_init(priv);
        if (ret) {
            // 错误路径也使用 dev_dbg,默认不打印,排查时可动态开启
            dev_dbg(dev, “Hardware initialization failed with error %d\n”, ret);
            return ret;
        }
    
        dev_info(dev, “My Device successfully initialized\n”);
        return 0;
    }
    

    在这段代码中, dev_dbg 语句在正常操作时是安静的。当设备初始化遇到问题,你可以通过 module my_driver +p 来查看详细的探测和初始化过程日志,而无需重新编译内核或模块。

3.4 通过内核启动参数预设调试状态

对于需要在系统启动早期(例如在初始化某个驱动时)就捕获日志的场景,可以通过内核命令行参数来预设动态调试的状态。

在内核启动参数(GRUB 配置中的 linux 行)中添加:

dyndbg=“module e1000e +p; module usb_storage +p”

或者更精细地:

dyndbg=“file drivers/net/ethernet/intel/e1000e/netdev.c func e1000_xmit_frame +pmlt”

多个命令用分号 ; 分隔。这样,在内核启动过程中,匹配的调试点从一开始就会被激活。

实操心得 : 启动参数中的动态调试命令非常强大,但也需谨慎。如果启用了过多、太早的打印,可能会导致控制台被刷屏,甚至影响启动过程。建议先在运行时测试好命令,再写入启动参数。生产环境除非紧急调试,否则不应使用。

4. 高级技巧与复杂场景应用

4.1 脚本化与自动化调试

手动输入命令适合临时调试,但对于复杂的、需要重复进行的调试任务,将其脚本化能极大提升效率。

场景 :调试一个USB存储设备在特定操作下的异常。

#!/bin/bash
# debug_usb_storage.sh

DEBUGFS=“/sys/kernel/debug/dynamic_debug/control”

# 1. 清除所有旧的动态调试设置(谨慎!这会关闭所有动态打印)
# echo “” > $DEBUGFS # 通常不建议,可能会影响其他调试

# 2. 精确开启我们关心的部分
echo “module usb_storage +p” > $DEBUGFS
echo “module uas +p” > $DEBUGFS # 如果使用UAS驱动
echo “file drivers/scsi/sd.c +p” > $DEBUGFS # SCSI磁盘层
echo “func sd_init_command +p” > $DEBUGFS # 具体到命令初始化函数

# 3. 同时开启函数名和行号,方便定位
echo “module usb_storage +plmf” > $DEBUGFS

echo “Dynamic debug for USB storage enabled. Start your reproducer now...”
echo “Logs will be in dmesg or /var/log/kern.log”
echo “Press Ctrl+C to stop and disable debugging.”

# 等待用户触发问题
read -p “Press Enter after reproducing the issue...“

# 4. 问题复现后,关闭调试,减少日志噪音
echo “module usb_storage -p” > $DEBUGFS
echo “module uas -p” > $DEBUGFS
echo “file drivers/scsi/sd.c -p” > $DEBUGFS

echo “Debugging disabled.”

这个脚本将调试范围锁定在USB存储栈的关键层,并在问题复现后自动清理,避免持续产生冗余日志。

4.2 结合 tracepoints 和 ftrace 进行立体调试

动态输出是“打印”类调试的王者,但对于分析高频事件、性能瓶颈或复杂代码流,可能需要更强大的工具。此时,可以将其与内核的跟踪基础设施结合。

组合使用策略 :

  1. 先用 ftrace 进行宏观定位 : 使用 function_graph 跟踪器查看某个可疑函数(如 submit_bio )的调用关系和耗时。这能帮你快速定位到大致的问题区域和时间点。
    echo function_graph > /sys/kernel/debug/tracing/current_tracer
    echo submit_bio > /sys/kernel/debug/tracing/set_graph_function
    echo 1 > /sys/kernel/debug/tracing/tracing_on
    # ... 执行操作 ...
    echo 0 > /sys/kernel/debug/tracing/tracing_on
    cat /sys/kernel/debug/tracing/trace > /tmp/trace.log
    
  2. 再用动态输出进行微观洞察 : 从 ftrace 的输出中,你发现大部分时间耗在 scsi_queue_rq 函数中。此时,你可以动态开启该函数所在模块(如 megaraid_sas )的详细调试打印,查看其内部的队列状态、命令参数等详细信息,而这些信息是 ftrace 无法提供的。
    echo “func scsi_queue_rq +p” > /sys/kernel/debug/dynamic_debug/control
    # 或者更具体到驱动
    echo “module megaraid_sas +p” > /sys/kernel/debug/dynamic_debug/control
    

这种“ftrace 广角镜定位 + dyndbg 显微镜观察”的组合,是解决复杂内核问题的标准战术。

4.3 在生产环境中的安全使用法则

在生产服务器上使用动态输出,必须遵循“最小影响”原则。

  1. 预演 : 任何动态调试命令,务必先在测试环境或开发机上验证其效果,评估日志输出量和对系统性能的潜在影响。
  2. 精准打击 : 使用最精确的选择器组合。能用 file 和 func 定位的,绝不用 module 。能用 line 范围的,绝不用整个文件。
  3. 限时启用 : 使用脚本或计划任务,在开启调试后一段时间自动关闭。例如:
    # 开启调试
    echo “module ixgbe +p” > /sys/kernel/debug/dynamic_debug/control
    # 等待30秒后自动关闭
    sleep 30 && echo “module ixgbe -p” > /sys/kernel/debug/dynamic_debug/control &
    
  4. 日志管理 : 开启大量调试前,确保系统日志(如 journald 或 rsyslog )有足够的空间和轮转策略,防止日志塞满磁盘。可以考虑将内核日志重定向到独立文件。
    dmesg -w > /tmp/kernel_debug.log &
    DEBUG_PID=$!
    # ... 执行调试操作 ...
    kill $DEBUG_PID
    
  5. 及时清理 : 调试结束后,立即关闭所有动态调试标志。一个残留的、高频率的调试打印点,可能就是下一次性能问题的根源。

5. 常见问题排查与实战案例解析

5.1 问题排查速查表

问题现象 可能原因 排查步骤与解决方案
cat /sys/kernel/debug/dynamic_debug/control 无输出或文件不存在 1. CONFIG_DYNAMIC_DEBUG 未启用。
2. debugfs 未挂载。
3. 内核中确实没有编译进任何 *_dbg() 调用。
1. 检查内核配置: zcat /proc/config.gz | grep DYNAMIC_DEBUG 或查看 /boot/config-* 。
2. 挂载 debugfs: mount -t debugfs none /sys/kernel/debug 。
3. 使用 grep -r “pr_debug|dev_dbg” /lib/modules/$(uname -r)/build/ 查看源码中是否存在。
写入命令后, dmesg 没有预期输出 1. 命令语法错误,选择器未匹配到任何站点。
2. 代码执行路径未经过该调试点。
3. 日志级别被过滤。 pr_debug 对应 KERN_DEBUG 级别,可能需要调整 console_loglevel 。
1. 使用 cat /sys/kernel/debug/dynamic_debug/control | grep -i <关键词> 确认命令是否生效(看标志位是否变成 =p )。
2. 确认你的操作是否真的会执行到那段代码。可以尝试在更上层的函数开启调试。
3. 运行 echo 8 > /proc/sys/kernel/printk 或 dmesg -n 8 确保调试级别信息能显示到控制台。
动态调试启用后系统变慢或日志刷屏 1. 启用了过多或位于高频路径(如网络收发包、中断处理)的调试点。
2. pr_debug 参数中的表达式计算开销大。
1. 立即使用 echo “module <name> -p” 关闭最可能的模块。使用更精确的选择器缩小范围。
2. 检查代码,避免在 *_dbg() 参数中调用复杂函数。优化选择器,仅启用最关键的部分。
模块卸载后,其动态调试设置仍残留? 不会。动态调试的控制标志是附着在内核内存中的描述符上的。模块卸载后,其代码和数据内存被释放,对应的 _ddebug 条目也不再有效。重新加载模块后,所有标志恢复默认值(禁用)。 这是正常行为。无需手动清理模块相关的设置。

5.2 实战案例:调试一个偶发的网络延迟尖峰

场景 : 服务器上的一个KVM虚拟机通过 virtio-net 网络偶尔出现 ping 延迟尖峰(从 <1ms 跳到 100ms+)。

传统方法局限 : 使用 tcpdump 抓包只能看到网络层面的结果,无法知道内核网络栈或虚拟化层内部的处理延迟。

动态调试解决步骤 :

  1. 缩小范围 : 根据经验, virtio-net 的数据路径主要涉及 virtio_net 驱动和底层的 tap 设备。我们首先启用相关模块的动态调试。

    echo “module virtio_net +p” > /sys/kernel/debug/dynamic_debug/control
    echo “module tun +p” > /sys/kernel/debug/dynamic_debug/control # tap设备依赖tun模块
    
  2. 观察与过滤 : 让虚拟机运行产生网络流量的工作负载,同时用 dmesg -w 或 tail -f /var/log/kern.log 观察日志。很快会发现日志量巨大。我们需要更精确。

  3. 精准定位 : 分析 virtio_net 代码,发现数据包接收的核心函数是 virtnet_receive 和 virtnet_poll 。发送核心是 start_xmit 。我们只启用这些关键函数的调试,并加上时间戳和CPU ID以便分析时序。

    # 先关闭之前的全局设置
    echo “module virtio_net -p” > /sys/kernel/debug/dynamic_debug/control
    # 开启关键函数,并附加函数名、时间戳
    echo “func virtnet_receive +pft” > /sys/kernel/debug/dynamic_debug/control
    echo “func virtnet_poll +pft” > /sys/kernel/debug/dynamic_debug/control
    echo “func start_xmit +pft” > /sys/kernel/debug/dynamic_debug/control
    
  4. 捕获数据 : 在延迟尖峰出现时,我们获得了带有精确时间戳( t 标志)的日志。通过分析日志发现,在延迟尖峰出现前, virtnet_poll 函数内有一条关于“NAPI权重调整”的 pr_debug 信息频繁出现,指向了 net.core.netdev_budget 和 net.core.netdev_budget_usecs 参数。

  5. 根因分析与解决 : 这表明在流量突发时,网络软中断(NET_RX_SOFTIRQ)处理单个 poll 循环的时间或包数可能超过了预设的预算,导致一次软中断无法处理完所有数据包,需要内核调度下一次软中断,从而引入额外延迟。通过适当调大 netdev_budget 参数(例如从300调到600),我们成功消除了延迟尖峰。

    sysctl -w net.core.netdev_budget=600
    

案例总结 : 在这个案例中,动态输出帮助我们穿透了抽象层,直接观察到了虚拟网络设备内部数据处理的微观状态和潜在的性能阈值告警,这是传统网络工具无法提供的视角。通过动态的、可定制的日志,我们将一个模糊的“偶发延迟”问题,定位到了具体的内核函数和可调节的系统参数上。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐