欢迎光临
我们一直在努力

Linux 内核与驱动调试指南

Linux 内核与驱动调试指南

前言

在嵌入式 Linux 系统开发与板级支持包(BSP)的维护过程中,驱动程序的调试往往是耗时最长的环节。与用户态应用程序不同,内核态的错误通常会导致系统级的不稳定甚至崩溃。在底层硬件平台上进行 Board Bring-up 或外设驱动开发时,熟练运用 Linux 内核提供的标准调试工具链,是提升排错效率的关键。

本文将系统性地梳理 Linux 内核调试的核心方法论,从基础的日志系统到底层的寄存器操作,再到跨越系统边界的事件追踪,结合实际代码和工具输出,提供一套完整的排障指南。

1. 内核打印:printk 与 Dynamic Debug

1.1 基础利器:printk

printk() 是整个 Linux Kernel Log 机制的基础 API,几乎所有的内核日志方式都是基于它来实现的。了解它的底层流转,能帮我们搞清楚为什么有时候“打印了却看不见”,以及为什么不能随心所欲地到处加打印,它的工作流程大致分为两步:

  • 将所有级别的 Log 输出到内核的循环缓冲区(Log Buffer)中 。
  • 根据设定的 console_loglevel,决定是否将 Log 输出到终端(Console) 。
  • 1.1.1 扒开内核源码:printk 执行过程与调用栈

    在板卡调试中,很多时候系统还没有挂载文件系统,甚至连 I2C、SPI 都没通,只有最基础的 UART 串口能用 。这时候 printk 就是我们唯一的救命稻草。但是,printk 绝不是可以随意乱加的,它会严重影响系统性能 。

    为什么这么说?我们来看看 printk 的底层调用栈就明白了:

    当你在代码里调用 printk 时,内核实际上经历了以下漫长的流程 :

  • printk() -> vprintk_func() -> vprintk_default() -> vprintk_emit()
  • 存入缓冲区:调用 vprintk_store(),先把要打印的信息老老实实地保存在全局的 log_buf 循环缓冲区中 。
  • 尝试输出:调用 log_output(),这里会尝试获取控制台锁。
  • 终端输出核心 console_unlock():这是最关键的一步。在这个函数里,内核会进入一个死循环 for (;;),不断从 buffer 中取出日志:
    • 首先调用 suppress_message_printing(msg->level) 判断消息等级。如果消息的级别数值大于 console_loglevel,则直接抛弃,不打印此信息 。
    • 如果等级达标,则准备向串口发送数据。在发送前,会调用 printk_safe_enter_irqsave(flags) 。
    • 性能杀手:printk_safe_enter_irqsave 底层会调用 local_irq_save。这个操作会把当前的中断状态保存到 flags 中,然后强行禁用当前处理器上的所有本地中断 !
    • 接着调用 call_console_drivers(),真正把字符串推给串口驱动。
    • 最后调用 printk_safe_exit_irqrestore(flags) 恢复中断 。
  • 由于向串口写数据本身就很慢,在这个过程中还关了中断,这就意味着如果你在关键路径(比如中断服务函数)里疯狂 printk,整个系统的实时性将被彻底破坏,甚至导致内核行为异常!这也是为什么后面我们要隆重介绍 Dynamic Debug 的原因。

    1.1.2 常规玩法:终端日志等级控制

    Linux 内核为printk定义了8个打印等级,KERN_EMERG等级最高,KERN_DEBUG等级最低。在内核配置时,有一个宏来设定系统默认的打印等级 CONFIG_MESSAGE_LOGLEVEL_DEFAULT,通常该值设置为4,那么只有打印等级高于4时才会打印到终端或者串口。

    #define KERN_EMERG KERN_SOH "0" /* system is unusable 紧急事件,一般是系统崩溃之前的提示消息 */
    #define KERN_ALERT KERN_SOH "1" /* action must be taken immediately 必须立即采取行动 */
    #define KERN_CRIT KERN_SOH "2" /* critical conditions 临界状态,通常涉及严重的硬件或者软件操作失败 */
    #define KERN_ERR KERN_SOH "3" /* error conditions 报告错误状态,经常用来报告硬件错误 */
    #define KERN_WARNING KERN_SOH "4" /* warning conditions 对可能出现问题的情况进行警告,通常不会对系统造成严重问题 */
    #define KERN_NOTICE KERN_SOH "5" /* normal but significant condition 有必要的提示,通常用于安全相关的状况汇报 */
    #define KERN_INFO KERN_SOH "6" /* informational 提示信息,驱动程序常用来打印硬件信息 */
    #define KERN_DEBUG KERN_SOH "7" /* debug-level messages 用于调试信息 */

    # 查看当前级别
    cat /proc/sys/kernel/printk
    # 临时修改打印级别,比如只允许最高级别(0)的日志输出到控制台,屏蔽绝大部分干扰
    echo 1 > /proc/sys/kernel/printk

    代码中的最佳实践:为了快速定位代码位置,通常会加上 __func__ 和 __LINE__:

    printk("[T113-Debug] %s [%d]\\n", __func__, __LINE__);
    // 推荐使用包裹好的宏,例如 pr_err, pr_info 等
    pr_err("Init failed for T113 UART\\n");

    1.1.3 原厂硬核 Hack:源码级屏蔽等级控制

    在极其恶劣的调试环境下(例如系统在挂载根文件系统前就 Crash 了,或者你根本进不去 Shell 执行 echo 命令),你急需看到 dev_dbg 或者其他低优先级的调试信息。此时,我们可以直接祭出“源码级暴力破解法”:修改内核源码,废掉等级拦截机制

    打开内核源码目录下的 kernel/printk/printk.c 文件,找到 call_console_drivers 函数 。将判断日志等级的代码直接注释掉 :

    /*
    * Call the console drivers, asking them to write out
    * log_buf[start] to log_buf[end – 1].
    * The console_lock must be held.
    */

    static void call_console_drivers(int level, const char *text, size_t len)
    {
    struct console *con;

    trace_console(text, len);

    /* === 暴力注释掉等级拦截机制,让所有 Log 畅通无阻 === */
    /*
    if (level >= console_loglevel && !ignore_loglevel)
    return;
    if (!console_drivers)
    return;
    #ifndef CONFIG_DYNAMIC_DEBUG
    if (!perf_mode_console)
    return;
    #endif
    */

    /* ================================================ */

    for_each_console(con) {
    if (exclusive_console && con != exclusive_console)
    continue;
    if (!(con->flags & CON_ENABLED))
    continue;
    if (!con->write)
    continue;
    if (!cpu_online(smp_processor_id()) &&
    !(con->flags & CON_ANYTIME))
    continue;
    con->write(con, text, len); // 真正执行串口驱动发送
    }
    }

    重新编译内核烧录后,整个系统所有的日志(无视 console_loglevel 的阻拦)都会发送到你的串口终端上。 这招在做底层 Board Bring-up(板卡点亮)时非常管用!

    1.1.4 打印的信息保存在哪

    **dmesg **: 新日志会把老日志给覆盖

    我们执行dmesg命令可以打印以前的内核信息,所以这些信息必定是保存在内核buffer中。 在kernel\\printk\\printk.c中,定义有一个全局buffer:

    /* record buffer */
    #define LOG_ALIGN __alignof_(unsigned long)
    #define __LOG_BUF_LEN (1 << CONFIG_LOG_BUF_SHIFT)
    #define LOG_BUF_LEN_MAX (u32)(1 << 31)
    static char __log_buf[__LOG_BUF_LEN] __aligned(LOG_ALIGN);
    static char *log_buf = __log_buf;
    static u32 log_buf_len = __LOG_BUF_LEN;

    1.2 高阶隐身术:Dynamic Debug (动态打印)

    printk 虽然基础,但在有些场景下却成了“性能杀手”。比如在开发板上调试 USB、MMC 等高速设备模块时,如果放开日志,海量的打印会严重拖慢设备运行速度,甚至掩盖真正的时序 Bug;但不写日志,出问题时又无从查起 。

    有没有一种方法,能让我把调试代码留在驱动里“留作证据”,但在设备正常运行时完全静默(连 Log Buffer 都不进),需要调试时再动态打开呢?

    答案就是 Dynamic Debug(动态打印)。

    1.2.1 defconfig功能配置

    与普通 printk 不同,动态打印默认是隐身的,既不会输出到控制台,也不会输出到 log_buf 中 。开启条件: 你需要在板级内核 defconfig 配置文件中开启以下两项 :

    CONFIG_DEBUG_FS=y
    CONFIG_DYNAMIC_DEBUG=y

    1.2.2 dynamic debug参数介绍

    开启配置后,在驱动代码中我们就不再使用 printk,而是使用以下动态打印 API:

    • pr_debug():主要用于通用的动态调试打印 。
    • dev_dbg():主要用于挂载了设备节点(device)的驱动调试打印,会自动包含设备信息 。
    • print_hex_dump_debug() / print_hex_dump_bytes():用于动态打印十六进制数据块 。

    控制参数(Flags)含义: 通过节点 /sys/kernel/debug/dynamic_debug/control,我们可以为特定的文件、模块或函数动态附加参数来控制打印格式和开关 :

    • p:启用(enables)该动态打印。只有包含 p 的日志才会被真正打印输出。
    • f:在打印的消息中包含函数名(function name)。
    • l:在打印的消息中包含代码行号(line number)。
    • m:在打印的消息中包含模块名(module name) 。
    • t:在打印的消息中包含线程 ID(thread ID),前提是该消息不是在中断上下文中生成的。
    • _:代表没有任何标志被设置(默认状态,即不输出)。
    1.2.3 动态打印的实操与设置

    1. 查看当前系统中所有的动态打印点:

    cat /sys/kernel/debug/dynamic_debug/control
    # 你可以配合 grep 过滤想要看的模块,例如:
    cat /sys/kernel/debug/dynamic_debug/control | grep "gadget"

    2. 灵活控制动态打印开关:

    # 打开单个文件 (gadget.c) 中所有的 dev_dbg 打印
    echo -n "file gadget.c +p " > /sys/kernel/debug/dynamic_debug/control

    # 打开特定模块 (mmc) 的所有动态打印
    echo "module mmc +p" > /sys/kernel/debug/dynamic_debug/control

    # 打开特定函数 (svc_process) 中的所有动态打印
    echo "func svc_process +p" > /sys/kernel/debug/dynamic_debug/control

    # 关闭特定函数中的动态打印
    echo -n "func svc_process -p" > /sys/kernel/debug/dynamic_debug/control

    3. 终极 Hacks:动态打印强制转为普通打印

    在调试时,如果原厂工程师要求抓取某个 .c 文件的 dev_dbg 日志,但你不方便在控制台敲命令去动态开启。可以在该 C 文件的最开头添加以下宏定义,将动态打印强行转换为 info 级别的常规打印 :

    #undef dev_dbg
    #define dev_dbg dev_info
    #undef pr_debug
    #define pr_debug pr_info

    2. 内核调用栈 (Call Stack)

    在Linux的驱动开发中,很多时候我们会遇到这样的窘境:某个底层函数收到了一个非法的参数,但这个函数被几十个地方调用,到底是谁传进来的?

    为了揪出幕后黑手,我们需要让内核“自爆”它的调用过程。Linux 内核提供了四个级别的调用栈打印函数,它们的威力依次递增:dump_stack、WARN_ON、BUG_ON、panic 。

    2.1 佛系观察者:dump_stack()

    dump_stack() 是最温和的调试手段。它的唯一作用就是打印内核当前的调用堆栈,以及展示函数的调用关系,完全不会影响系统的正常运行 。

    实战场景:你想知道你的驱动 init 函数是在内核启动的哪个阶段被调用的。代码示例:

    #include <linux/module.h>
    #include <linux/kernel.h>

    static int __init helloworld_init(void)
    {
    printk(KERN_EMERG "helloworld_init\\r\\n");
    // 在这里插入 dump_stack,看看是谁在加载这个模块
    dump_stack();
    return 0;
    }
    module_init(helloworld_init);

    输出解析: 当你的驱动加载时,终端会打出类似下面的信息:

    [ 95.967919] Call trace:
    [ 95.967969] dump_backtrace+0x0/0x188
    [ 95.968004] show_stack+0x24/0x30
    [ 95.968042] dump_stack+0x8c/0xb4
    [ 95.968081] helloworld_init+0x20/0x1000 [helloworld]
    [ 95.968115] do_one_initcall+0xa0/0x1c0

    [ 95.968265] el0_svc_handler+0x70/0x8c

    从下往上看,你可以清晰地看到整个调用链,一目了然!

    2.2 严厉的警告:WARN_ON(condition)

    WARN_ON 实际上内部也是调用了 dump_stack,但它多了一个条件判断参数 。 它的含义是:“当条件成立时,抛出栈回溯,并打印一个警告信息,但系统还能继续苟活(不会崩溃)” 。

    实战场景:用于抓捕非法参数 。比如你写了一个内存分配驱动,规定请求的 size 不能超过最大限制,如果超过了,你想知道是哪个神仙队友瞎传的参数,但又不希望整个设备直接死机。

    原厂代码参考 (来自某 WiFi 驱动):

    struct sk_buff *rtw_alloc_skb_premem(u16 in_size)
    {
    struct sk_buff *skb = NULL;
    // 如果传入的 size 超过了规定的 MAX_RTKM_RECVBUF_SZ
    if (in_size > MAX_RTKM_RECVBUF_SZ) {
    pr_info("warning %s: driver buffer size(%d) > rtkm buffer size(%d)\\n",
    __func__, in_size, MAX_RTKM_RECVBUF_SZ);
    // 抛出警告并打印调用栈,揪出调用者!
    WARN_ON(1);
    return skb;
    }
    // … 正常逻辑
    }

    2.3 致命断言:BUG_ON(condition)

    如果你觉得错误极其严重,继续运行会导致内存踩踏或者数据损坏,那你就需要用到 BUG_ON 。 它非常像用户态 C 语言里的 assert(断言)。一旦条件成立(例如 BUG_ON(1)),内核就会立刻引发一个 Oops,导致栈的回溯和错误信息的打印,并触发系统崩溃 。

    实战场景:严重的逻辑缺陷或不可恢复的状态。行业潜规则:在做商业化产品时,触发 BUG_ON 通常会引发设备的 Watchdog 复位重启,以求尽快恢复服务;而在研发调试阶段,系统可能直接挂起,保留现场等你来修 Bug 。

    原厂代码参考 (来自内核时钟框架):

    clkp = lookup_root_clock(clk);
    mapping = clkp->mapping;
    // 时钟的 root 必须有 mapping 映射,如果没有,说明底层架构写错了,直接死机!
    BUG_ON(!mapping);

    2.4 终极毁灭:panic(“msg”)

    如果说 BUG_ON 还需要一个触发条件,那么 panic 就是直接按下核按钮 。 当你调用 panic(fmt…) 时,不需要任何判断条件,内核会直接死机(系统崩溃),并将函数调用关系以及当前的寄存器值全部打印出来 。

    实战场景:系统初始化时缺少了最核心的硬件资源。

    原厂代码参考 (来自 GPU 驱动):

    void rkSetFrequency(IMG_UINT32 ui32Frequency)
    {
    // …
    // 如果连平台结构体都是空的,什么都干不了,没救了,直接死机并打印 "oops"
    if (NULL == g_platform)
    panic("oops");
    }

    2.5 小结:

    在开发中,善用这四个函数能极大地提升查 Bug 的效率:

    • 想看流程看流向?用 dump_stack()。
    • 发现参数不合理想抓人?用 WARN_ON()。
    • 发现系统状态不可控,再跑下去要出大事?果断用 BUG_ON() 或 panic() 让它死得明明白白!

    3. 终极尸检报告:Oops 日志分析

    上一节我们提到,BUG_ON() 执行后会导致系统崩溃。但内核在崩溃时并不会“死得不明不白”,它会生成一条名为 “Oops” 的消息。

    Oops 是 Linux 内核的一种自我诊断机制 。当内核遇到无法从内部处理的严重错误(例如非法指针解引用、无效指令等)时,只要系统还没彻底死锁,它就会尽力保存“案发现场”,把当时的寄存器状态、堆栈回溯、调用链统统打印到终端上 。

    为什么会产生 Oops? 在开发中,最常见的 Oops 根源就是非法指针(尤其是 NULL 空指针)的解引用 。当 CPU 在内核态运行(超级用户模式)时,试图访问一个非法的虚拟地址,内存管理单元 (MMU) 映射失败,触发 Page Fault(页面失效)信号。内核判断该地址非法,便会当场生成 Oops 。此外,系统调用内部严重错误、CPU 陷入异常模式,或者我们主动调用的 BUG_ON() 和 panic() 都会触发 Oops 。

    3.1 读懂 Oops 的“死亡宣告”

    [29934.977983] Unable to handle kernel NULL pointer dereference at virtual address 00000000
    [29935.214354] PC is at create_oops+0x18/0x20 [oops]
    [29935.219082] LR is at my_oops_init+0x18/0x1000 [oops]
    [29935.224068] pc : [<bf2a8018>] lr : [<bf045018>] psr: 60000013
    [29935.224068] sp : cc66dda8 ip : cc66ddb8 fp : cc66ddb4
    [29935.235572] r10: cc68c9a4 r9 : c08058d0 r8 : c08058d0
    [29935.240813] r7 : 00000000 r6 : c0802048 r5 : bf045000 r4 : cd4eca40
    [29935.247359] r3 : 00000000 r2 : a6af642b r1 : c05f3a6a r0 : 00000014
    [29935.266822] Process insmod (pid: 20021, stack limit = 0xcc66c208)
    [29935.433257] Code: e24cb004 e52de004 e8bd4000 e3a03000 (e5833000)

    面对一长串让人头皮发麻的十六进制报错,我们该怎么看?我们来看一个经典的空指针解引用引发的 Oops 日志片段 :

    作为“内核法医”,我们只需提取其中最致命的几条线索:

  • 错误定性(死因):第一行直接揭示真相。Unable to handle kernel NULL pointer dereference 说明内核态发生了空指针解引用
  • PC (Program Counter,案发第一现场):PC is at create_oops+0x18/0x20。这说明崩溃发生时,CPU 正在执行 create_oops 这个函数。+0x18 表示导致崩溃的指令在这个函数内部偏移 24 字节的位置,而 0x20 是这个函数的总长度 。
  • LR (Link Register,幕后推手):LR is at my_oops_init+0x18…。LR 寄存器保存了函数调用的返回地址,这意味着是 my_oops_init 函数调用了惹祸的 create_oops 函数 。
  • 寄存器快照与进程上下文:日志打印了 ARM 架构下从 r0 到 r10 的通用寄存器状态(包含了函数传参和临时数据),以及 sp(堆栈指针)和 fp(帧指针) 。往下看,Process insmod (pid: 20021) 告诉我们,触发这次崩溃的进程是 insmod,也就是说这是在加载模块时挂掉的 。
  • Code(凶器):最后一行十六进制码是崩溃时 CPU 正在执行的机器指令转储,括号里的 (e5833000) 通常就是那条直接导致异常的非法指令 。
  • 3.2 缉凶实战:反汇编 (objdump) 精准定位

    光知道是 create_oops 函数偏移 0x18 的位置出错了还不够,我们最终要对应到是 C 语言源码里的哪一行代码写残了。这时候就要请出 objdump 神器了。

    第一步:反汇编带调试信息的驱动模块 在编译驱动时确保加入了 -g 调试选项,然后使用交叉编译工具链进行反汇编,-S 选项能让生成的汇编代码和 C 语言源码交替显示 :

    arm-linux-xxx-objdump -S oops.ko > oops.disassembly

    第二步:查找目标地址并对照源码 打开生成的 oops.disassembly 文件,搜索 create_oops 函数名。然后在这个函数内部向下寻找偏移 0x18 字节的位置 。

    create_oops:

    mov r3, #0
    … (偏移0x18处) …
    str r3, [r3] @ 对应C代码: *p = a; 或 *(int *)0 = 0;

    你会看到类似这样的结构 :

    真相大白!正是那句臭名昭著的 *(int *)0 = 0; 引发了血案。我们成功把 Oops 日志里的十六进制偏移,精准映射到了具体的 C 语言代码行 。

    3.3 进阶调查:Ftrace 与 Kdump 事后分析

    有时候 Oops 只是一个结果,比如某个指针早就被别的线程踩坏了,直到当前函数去访问才爆发。为了追溯“崩溃发生前到底发生了什么”,我们可以结合其它高级工具 :

    • Ftrace 现场快照:我们可以配置 ftrace_dump_on_oops。这样,当 Oops 发生时,Ftrace 会自动把崩溃前记录的函数调用序列打印出来,帮你重现崩溃前的内核执行路径 。
    • Kdump/Crash 终极尸检:对于直接导致 Panic(宕机)的严重错误,我们可以利用 Kdump 机制捕获崩溃那一瞬间的完整内存镜像(vmcore)。然后使用 Crash 工具进行离线分析,这就像是在调试一个冻结的时间胶囊,可以随便查看当时所有进程、内存和寄存器的状态,是解决复杂疑难杂症的终极武器 。

    4. 硬件沟通的直通车:devmem 与 io 工具

    这两个工具的核心理念非常简单粗暴:直接读写物理寄存器 。它们能帮我们快速定位问题到底是出在硬件走线/状态上,还是出在我们自己写的驱动逻辑上。

    4.1 devmem:系统自带的物理内存读写器

    devmem 是 Linux 系统中最经典的寄存器操作工具,通常包含在 BusyBox 中。

    前置条件 (内核配置):

    要使用 devmem,Linux 内核必须开启对应的虚拟设备支持。在内核的 menuconfig 中,确保进入 Device Drivers -> Character devices,并开启 /dev/kmem virtual device support(有时对应 CONFIG_DEVMEM 选项) 。

    语法规则:

    devmem 的语法非常简洁:

    devmem ADDRESS [WIDTH [VALUE]]

    • ADDRESS:要直接读写的物理寄存器地址
    • WIDTH:指定读写数据的位宽,通常是 8、16 或 32(默认一般是 32 位)
    • VALUE:如果要进行写操作,这里填入要写入的数据 。

    实操演示:

    假设我们要调试开发板的物理地址为 0x98000000 的控制器寄存器:

    1. 读取寄存器状态

    # 读取 32 位 (4 字节) 数据
    devmem 0x98000000 32

    # 读取 16 位 (2 字节) 数据
    devmem 0x98000000 16

    # 读取 8 位 (1 字节) 数据
    devmem 0x98000000 8

    2. 强制写入寄存器(点灯、强拉电平必备)

    # 向该地址写入 32 位数据 0x12345678
    devmem 0x98000000 32 0x12345678

    # 写入 16 位数据 0x1234
    devmem 0x98000000 16 0x1234

    # 写入 8 位数据 0x12
    devmem 0x98000000 8 0x12

    只要对着芯片原厂 Datasheet,结合 devmem 命令,我们就能瞬间化身为硬件工程师的“物理外挂”,指哪打哪!

    4.2 io:更为强大的 Raw Memory I/O 替代品

    除了 devmem,某些系统中还会提供一个名为 io 的工具(Raw memory i/o utility) 。它的功能更加丰富,支持连续读取和文件转储。

    常用语法解析:

    io -v -1|2|4 -r|w [-l <len>] [-f <file>] <addr> [<value>]

    • -1/2/4:对应读写的字节数(1 byte=8bit, 2=16bit, 4=32bit)
    • -r / -w:指定是读取 (read) 还是写入 (write),默认是读取
    • -l <len>:指定要访问的长度(字节数),这在你想 dump 一大块寄存器空间时非常有用
    • -f <file>:可以将读取到的寄存器数据直接写到文件里,或者从文件读取数据写入寄存器

    实操演示:如果你想查看某个外设控制器的状态寄存器(假设地址是 0xFDC20008),并且想以 4 字节(32位)的跨度进行读取:

    # -r 代表读取,-4 代表 4 字节位宽
    io -r -4 0xFDC20008

    终端输出:

    fdc20008: 00001110

    同样,如果你想往 0x1000 地址写入数据 0x12,可以这样执行:

    io 0x1000 0x12

    4.3 小结:敬畏物理内存

    devmem 和 io 工具本质上是打通了 Linux 用户空间与内核物理寄存器直接通信的桥梁 。

    但请记住,能力越大,风险越大。在 Linux 这样复杂的内存管理系统中,绕过驱动的并发锁保护直接去改写硬件状态,极有可能导致内核原本的驱动状态机彻底错乱,甚至引发系统瞬间 Panic。

    因此,这两个工具的最佳食用场景是:

  • 硬件点火(Bring-up)阶段的纯底层验证。
  • 驱动出 Bug 时,用来“偷窥”寄存器当前到底被配置成了什么鬼样子。
  • 5. 跨越边界的追踪者:strace 与 ftrace

    5.1 应用程序的听诊器:strace

    作为底层驱动开发者,当你面对一个“黑盒”应用程序,不知道它到底给你传了什么参数,或者想搞清楚业务延迟卡在哪个环节时,strace 就是你最好的听诊器。它可以精准记录应用程序执行的所有系统调用(Syscalls)、参数、返回值以及消耗的时间 。

    1. 扒开 strace 的底层原理

    strace 为什么能截获系统调用?它的底层依赖于 Linux 的 ptrace 机制(也就是 GDB 调试器使用的那套机制)。 当你执行 strace -p PID 时,它会主动 attach(附着)到目标进程上,并发送 PTRACE_SYSCALL 指令 。此时,目标进程每次进入或退出系统调用时,都会触发一个 SIGTRAP 信号并暂停执行 。strace 捕获到信号后,剥离出系统调用的详细信息打印出来,然后再恢复目标进程的运行。

    性能警告:因为每一次系统调用都会经历“打断 -> 暂停 -> 恢复”的折腾,使用 strace 会给目标进程带来非常明显的延迟。因此,绝对不要在对实时性要求极高的生产环境核心业务上盲目挂载 strace,否则可能直接导致业务超时崩溃 !

    2. 板卡上的实战操作

    • 常规玩法:跟踪整个应用的执行过程 如果你写了一个测试硬件外设的 ioctl_app,想看看它是怎么打开节点、传递参数的:

    # 启动程序并跟踪
    strace ./ioctl_app

    你会清晰地看到类似这样的输出,连底层 openat 返回的文件描述符(如 fd=3),以及 ioctl 传递的十六进制内存地址都一清二楚 。

    • 高阶排障:排查性能瓶颈与多线程 如果应用卡顿,我们可以加上时间戳和统计参数来抓取性能耗时点 :

    # -T: 打印每个系统调用消耗的时间
    # -tt: 打印微秒级的时间戳
    # -ff: 跟踪多线程/子进程,并将输出保存到不同的 strace.out.PID 文件中
    # -p: 附着到已经运行的进程
    strace -T -tt -ff -p 800 -o strace.out

    5.2 内核深处的显微镜:ftrace 与 trace-cmd

    strace 只能让你看到应用层发起了什么系统调用,但如果某个系统调用(比如 open 或 ioctl)在内核里耗时异常,怎么知道内核内部究竟走过了哪些山路十八弯?这就要用到自 Linux 2.6 起引入的内核调试框架 —— ftrace (Function Trace) 。

    1. 操作 sysfs 节点

    ftrace 的操作接口全部暴露在 /sys/kernel/debug/tracing 目录下。要在开发板上追踪一个内核函数(比如 do_sys_open)的内部调用栈,你可以直接敲命令:

    # 1. 设置跟踪器类型为函数调用图 (function_graph)
    echo function_graph > /sys/kernel/debug/tracing/current_tracer

    # 2. 指定你想用显微镜观察的核心函数
    echo do_sys_open > /sys/kernel/debug/tracing/set_graph_function

    # 3. 拨动开关,开始录制!
    echo 1 > /sys/kernel/debug/tracing/tracing_on

    # 运行你的测试代码后,关闭开关并查看结果
    echo 0 > /sys/kernel/debug/tracing/tracing_on
    cat /sys/kernel/debug/tracing/trace

    2. 封装利器:trace-cmd

    手动去 echo 一堆节点实在太繁琐了,Linux 社区提供了一个更方便的用户态命令行前端工具:trace-cmd 。

    用 trace-cmd 来实现上面的需求,只需要两行命令:

    # 录制:指定使用 function_graph 跟踪器,并跟踪特定函数
    trace-cmd record -p function_graph -g do_sys_open

    # 解析:将生成的 trace.dat 二进制文件转换为人类可读的文本
    trace-cmd report

    函数调用图 (Function Graph):

    打开 report 结果,你会看到一段极其舒适、类似 C 语言代码缩进格式的调用图 :

    // 截取自 trace-cmd report 输出
    do_sys_open() {
    getname() {
    getname_flags() {
    kmem_cache_alloc() {
    _cond_resched();
    // … 耗时信息一目了然
    }
    }
    }
    // …
    }

    3. trace-cmd 的参数

    • 精准跟踪特定进程:系统里跑着几百个进程,全开 ftrace 板子会卡死。使用 -P PID 仅仅跟踪你关心的那个应用触发的内核动作

    trace-cmd record -p function_graph -P 2830

    • 深浅控制(-g 与 -l 的区别):
      • -g do_sys_open:深挖,连着它内部调用的所有子孙函数一起跟踪(Graph)
      • -l "ext4_*":浅尝辄止,仅仅跟踪匹配该名字的函数本身的执行情况,不深入其内部子函数
    • 限制跟踪深度:-g 跟踪得太深眼花缭乱?用 –max-graph-depth 2 限制只看两层子函数

    6. 串行总线的透视镜:i2c-tools

    在板级支持包(BSP)开发中,我们打交道最多的低速总线非 I2C 莫属。无论是各类 Sensor、EEPROM,还是像 ML86251 这样的复杂视频解码芯片,它们统统挂在 I2C 总线上。

    当硬件工程师把新板子交给你,或者在调试过程中发现摄像头读不出图像时,千万不要一上来就开始撸驱动代码! 第一步永远是:用 i2c-tools 在用户空间直接去“撩”一下这颗芯片,确认它的物理连通性和基本状态。Linux 开源社区提供的 i2c-tools 包含了一组杀手级命令。我们来看看在实战中怎么利用它们的价值。

    6.1 i2cdetect (探测总线设备)

    当你不知道硬件工程师把芯片挂在了哪条 I2C 总线上,或者不确定芯片到底有没有供电工作时,i2cdetect 是最快验证物理层连通性的工具。

    实操演示:探测 I2C 总线 1 上的所有设备。

    # -y 表示取消交互确认,-r 表示使用 SMBus Read Byte 命令探测
    i2cdetect -y -r 1

    终端输出:

    0 1 2 3 4 5 6 7 8 9 a b c d e f
    00: — — — — — — — — — — — — —
    10: — — — — — — — — — — — — — — — —
    20: — — — — — — — — — — — — — 2d — —
    30: — — — — — — — — — — — — — — — —

    只要在这个矩阵里看到了芯片的数据手册里标明的设备地址(比如 0x2d),恭喜你,这颗芯片的供电、地线以及 I2C 连线基本正常,芯片已经在“喘气”了!

    6.2 i2cdump (批量导出寄存器)

    如果你想快速确认芯片是否被初始化,或者想把工作正常的芯片寄存器状态完整备份下来做比对,i2cdump 可以一次性拉出整个页面的寄存器数据。

    # 导出总线 1 上设备地址为 0x2d 的所有寄存器数据
    i2cdump -y -f 1 0x2d

    6.3 i2cget 与 i2cset (单点读写)

    对于常规的 8 位寄存器地址芯片,i2cget 和 i2cset 是最顺手的瑞士军刀。

    1. 读取芯片的 ID 寄存器(验证通信逻辑的铁证):

    假设设备 0x2d 的 0x00 寄存器是 Chip ID:

    # 读取总线 1,设备 0x2d,寄存器 0x00 的值
    i2cget -y -f 1 0x2d 0x00
    # 返回:0x86 (如果是预期值,说明读写逻辑彻底通了)

    2. 强行拉高复位引脚或修改配置:

    # 向总线 1,设备 0x2d,寄存器 0x10 写入数据 0xFF
    i2cset -y -f 1 0x2d 0x10 0xFF

    6.4 i2ctransfer (搞定 16 位大地址)

    如果你在做车载摄像头或者高端视频编解码器开发,一定会遇到一个大坑——很多复杂的音视频芯片(比如 ML86251)的寄存器地址是 16 位的! 老旧的 i2cget 和 i2cset 默认只支持 8 位寄存器地址,强行读写 16 位地址往往需要复杂的参数配置,极易出错。这时候,我们必须祭出现代化的 i2ctransfer。

    实战场景:读写 16 位寄存器地址的摄像头芯片

    1. 读取 16 位寄存器 (例如读取 0x300A 寄存器的值):

    你需要先向芯片写入两个字节(寄存器地址的高八位和低八位),然后紧接着发起读操作读取一个字节:

    # -f 强制执行
    # -y 1 指定总线 1
    # w2@0x2d: 向设备 0x2d 写入 2 个字节 (0x30 和 0x0A)
    # r1: 紧接着读取 1 个字节
    i2ctransfer -f -y 1 w2@0x2d 0x30 0x0A r1

    2. 写入 16 位寄存器 (例如向 0x300A 寄存器写入数据 0x55):

    你需要向芯片连续写入三个字节(寄存器高地址、低地址、要写入的数据):

    # w3@0x2d: 向设备 0x2d 写入 3 个字节 (地址 0x30, 地址 0x0A, 数据 0x55)
    i2ctransfer -f -y 1 w3@0x2d 0x30 0x0A 0x55

    通过这套组合拳,在面对任何未知的 I2C 外设时,你都能在没有一行驱动代码的情况下,把硬件的状态扒得一干二净!

    结语

    优秀的驱动开发工程师不仅需要具备扎实的内核源码阅读能力,更需要建立体系化的排错思维。从 printk 的日志流分析,到 devmem 与 i2c-tools 的底层硬件验证,再到 Ftrace 与 Crash 的纵深剖析,灵活组合并运用上述调试工具链,将显著提升嵌入式 Linux 平台的系统开发与维护效率。

    赞(0)
    未经允许不得转载:171主机测评 » Linux 内核与驱动调试指南
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址