Linux 内核与驱动调试指南

前言

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

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

1. 内核打印:printk 与 Dynamic Debug

1.1 基础利器:printk

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

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

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

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

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

  1. printk() -> vprintk_func() -> vprintk_default() -> vprintk_emit()
  2. 存入缓冲区:调用 vprintk_store(),先把要打印的信息老老实实地保存在全局的 log_buf 循环缓冲区中 。
  3. 尝试输出:调用 log_output(),这里会尝试获取控制台锁。
  4. 终端输出核心 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_stackWARN_ONBUG_ONpanic

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 日志片段 :

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

  1. 错误定性(死因):第一行直接揭示真相。Unable to handle kernel NULL pointer dereference 说明内核态发生了空指针解引用
  2. PC (Program Counter,案发第一现场)PC is at create_oops+0x18/0x20。这说明崩溃发生时,CPU 正在执行 create_oops 这个函数。+0x18 表示导致崩溃的指令在这个函数内部偏移 24 字节的位置,而 0x20 是这个函数的总长度 。
  3. LR (Link Register,幕后推手)LR is at my_oops_init+0x18...。LR 寄存器保存了函数调用的返回地址,这意味着是 my_oops_init 函数调用了惹祸的 create_oops 函数 。
  4. 寄存器快照与进程上下文:日志打印了 ARM 架构下从 r0r10 的通用寄存器状态(包含了函数传参和临时数据),以及 sp(堆栈指针)和 fp(帧指针) 。往下看,Process insmod (pid: 20021) 告诉我们,触发这次崩溃的进程是 insmod,也就是说这是在加载模块时挂掉的 。
  5. 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:指定读写数据的位宽,通常是 81632(默认一般是 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 小结:敬畏物理内存

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

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

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

  1. 硬件点火(Bring-up)阶段的纯底层验证。
  2. 驱动出 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 只能让你看到应用层发起了什么系统调用,但如果某个系统调用(比如 openioctl)在内核里耗时异常,怎么知道内核内部究竟走过了哪些山路十八弯?这就要用到自 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 位寄存器地址芯片,i2cgeti2cset 是最顺手的瑞士军刀。

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

假设设备 0x2d0x00 寄存器是 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 位的! 老旧的 i2cgeti2cset 默认只支持 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 的日志流分析,到 devmemi2c-tools 的底层硬件验证,再到 FtraceCrash 的纵深剖析,灵活组合并运用上述调试工具链,将显著提升嵌入式 Linux 平台的系统开发与维护效率。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐