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)