1. 调试内核态的第一板斧:printk 的前世今生
1.1 为什么内核调试这么难
我在做 Linux 内核开发这几年,最直观的感受就是:内核态出问题,比用户态麻烦十倍。用户态程序崩了,有 core dump,有 gdb,有 ASAN,甚至还能开着 IDE 断点调试。到了内核态,你连个像样的“运行时环境”都没有——它没有进程的概念来保护自己,一个野指针就能让整个系统 panic 或者静默地损坏内存,然后在几小时后才在某个毫不相干的地方爆炸。
这也是为什么内核开发者对调试工具特别执着。内核里每一个机制,几乎都是被血和泪逼出来的。从最早的 printk 到后来的一整套静态插桩、动态追踪体系,本质上都是在回答同一个问题:当系统行为不符合预期时,你怎么知道它内部到底发生了什么?
对于刚入门嵌入式内核、或者正在啃内核源码的朋友,我建议你先别急着追新潮的 eBPF,先把 printk 这条最古老也最可靠的路线吃透。它看起来简单,但用好了是真能救命的。
1.2 printk 的级别与使用姿势
printk 是内核里最朴素的输出函数,用法基本和 printf 一致,但它有个用户态 printf 没有的东西:日志级别(loglevel)。这个级别决定了这条日志会不会被立即输出到控制台,以及会写进内核环形缓冲区(ring buffer)的哪个位置。
常见的级别有这几个:
c复制#define KERN_EMERG "<0>" /* 系统不可用 */
#define KERN_ALERT "<1>" /* 必须立即采取措施 */
#define KERN_CRIT "<2>" /* 严重情况 */
#define KERN_ERR "<3>" /* 错误情况 */
#define KERN_WARNING "<4>" /* 警告情况 */
#define KERN_NOTICE "<5>" /* 正常但重要的情况 */
#define KERN_INFO "<6>" /* 信息性消息 */
#define KERN_DEBUG "<7>" /* 调试级别消息 */
使用非常简单,在代码里直接塞:
c复制printk(KERN_INFO "my_driver: probe called, irq=%d\n", pdev->irq);
但这里有几个容易踩的坑:
- 第一,不要在中断上下文或持有自旋锁的代码里用 printk 做大量输出。虽然 printk 本身在多数情况下可以工作,但在某些路径上(比如在中断处理函数里)它会尝试唤醒 klogd 之类的进程,可能导致死锁或调度异常。你要是非得打,至少用
printk_ratelimited()这种限速版本,或者干脆记录标志位,等进程上下文再统一输出。 - 第二,printk 带“级别前缀”时,写法是
printk(KERN_INFO "..."),注意 KERN_INFO 和后面的字符串之间没有逗号。这是 C 语言字符串字面量拼接的语法,很多新手会在这里写错,编译不过还找不到原因。 - 第三,控制台只会输出 loglevel 小于
console_loglevel的日志。你改了内核代码加了一堆调试信息,发现 dmesg 里能看到,但屏幕上就是不显示,十有八九就是级别没配对。可以用echo 8 > /proc/sys/kernel/printk把控制台级别调到最大,这是在开发调试阶段最常见的操作。
1.3 动态 printk:不重编内核的开关
printk 最大的痛点是什么?每次加日志都要重新编译内核,对于嵌入式开发动辄十几分钟半小时的编译时间来说,这事效率极低。而且生产环境出问题的时候,你不可能随便拉个内核版本就上去加日志。
内核开发者当然也想到了这一点,于是有了 dynamic_printk(dyndbg) 机制。它允许你在运行时动态启用或禁用源码中预先标注好的 pr_debug() / dev_dbg() 这类调试打印,而无需重新编译。
用法也很简单。首先确保内核开启了 CONFIG_DYNAMIC_DEBUG,然后在想要调试的模块里用 pr_debug() 而不是 printk(KERN_DEBUG ...) 写日志。系统启动后,通过 debugfs 接口动态控制:
bash复制# 查看当前所有可动态控制的调试点
cat /sys/kernel/debug/dynamic_debug/control
# 打开某个文件里所有调试点
echo 'file drivers/i2c/busses/i2c-designware-common.c +p' > /sys/kernel/debug/dynamic_debug/control
# 打开某个模块全部调试输出
echo 'module dw_i2c +p' > /sys/kernel/debug/dynamic_debug/control
# 关闭
echo 'module dw_i2c -p' > /sys/kernel/debug/dynamic_debug/control
这里 +p 表示 enable print,-p 就是 disable。除了按文件和模块过滤,还可以按函数名过滤(func xxx +p)。这套机制在排查驱动问题时极其好用,尤其是 I2C、SPI、GPIO 这类子系统,驱动源码里本来就密集分布着 dev_dbg,你只要在运行时把对应文件的调试开关打开,就能看到完整的寄存器读写序列。
我在实际项目里,遇到过不少“内核态偶发问题”,比如外设寄存器读回来偶尔是错的。这种问题你不好直接改代码加打印,因为你不知道什么时候复现,但用 dyndbg 可以在问题现场把日志量开到最大,配合 /proc/sys/kernel/printk 调整级别,哪怕问题一周才出现一次,也能抓到现场。
1.4 printk 在并发和性能上的隐藏陷阱
printk 看起来是无脑输出,但在多核 SMP 系统上,它的行为远比你想象的复杂。早期内核里 printk 是有一个大锁(logbuf_lock)保护的,所有核的打印都要抢这把锁,高并发下会严重拖慢系统。后来内核改进了实现,引入了无锁环形缓冲区设计(CONFIG_PRINTK 重写后使用基于多 producer 的 lockless 方案),但依然不代表你可以随意刷屏。
一个很典型的例子:你在中断处理里用 printk_ratelimited() 输出每条中断的信息,在极端情况下(比如外部设备疯狂触发中断),打印本身就会占用大量 CPU 时间,反而让中断风暴更严重。这时候你要做的不是打印,而是先找到中断风暴的根源,否则打印就是雪上加霜。
实操心得:printk 的调试信息默认会写进内核环形缓冲,缓冲区大小可以通过
dmesg -s或者内核参数log_buf_len=调整。但如果你是做长时间的嵌入式设备日志采集,建议还是通过串口或者 pstore 把内核日志持久化到外置存储,否则环形缓冲区被冲掉之后,你只能看着一个空空的 dmesg 发呆。
1.5 从 printk 走向更强大的工具
printk 作为“字节级”的观测手段,能告诉你内核正在执行哪一行代码,但它有两个先天不足:一是你得提前在代码里埋点,现场改不了;二是打印频率高了会干扰被测系统本身,海森堡效应严重。
所以当你的问题不再是“某行代码有没有执行”,而是“这个函数被谁调用了”“每次调用耗时多少”“这个结构体里的值是怎么变的”,printk 就有点力不从心了。这也是我为什么强烈建议每个内核开发者在掌握 printk 之后,尽快进入动态追踪的世界,那里才真正称得上是“显微镜级别”的调试能力。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 内核的动态追踪地基:从 kprobes 到 tracepoint
2.1 什么是动态追踪
动态追踪的核心思想是:不需要重新编译内核,不需要修改源码,就能在内核运行的任意时刻、任意函数入口或出口挂上钩子,采集数据。这相当于给运行中的内核做“B 超”,而不是像 printk 那样“开刀”。
Linux 内核的动态追踪体系,最底层的地基是三个机制:kprobes、uprobes、tracepoints。
- kprobes:可以动态地在内核任意指令地址上插入探测点,包括函数入口(kprobe)、函数返回(kretprobe),以及指令级的探测点。它的工作原理是把目标指令替换成断点指令(x86 上通常是
int3),当 CPU 执行到这里时触发异常,异常处理程序会回调你注册的 handler。因为是在指令级别工作,所以理论上它能探测任何内核函数,甚至是 inline 函数(用kallsyms查到地址后直接下探)。 - uprobes:和 kprobes 类似,但作用于用户态程序。它通过修改用户进程的指令来实现探测,常用于动态调试某个用户态守护进程的内部状态。
- tracepoints:这是内核静态埋点的一组宏。内核开发者会在关键路径上预先放置
trace_xxx()调用,编译时如果开启CONFIG_TRACEPOINTS,这些点就会生效。相比 kprobes 它更安全、更稳定,因为参数是明确定义的,不会因为内核版本升级导致结构体布局变化而失效。
2.2 kprobes 的两种使用方式
kprobes 有两种玩法:传统 C 语言编程方式和基于 tracefs 的命令行方式。
传统的注册方式比较硬核,需要写内核模块:
c复制#include <linux/kprobes.h>
static int handler_pre(struct kprobe *p, struct pt_regs *regs)
{
printk(KERN_INFO "pre_handler: ip=%lx\n", regs->ip);
return 0;
}
static struct kprobe kp = {
.symbol_name = "do_sys_openat2",
.pre_handler = handler_pre,
};
static int __init kprobe_init(void)
{
register_kprobe(&kp);
return 0;
}
module_init(kprobe_init);
这种方式适合做深度定制,但开发成本高,而且稍不留神就写得有 bug,还得重新编译模块。大多数情况下,我推荐直接用 perf 或者 tracefs 的方式,零代码就能完成 80% 的调试需求。
比如用 tracefs 挂函数入口:
bash复制# 挂载 tracefs(一般已经挂载)
mount -t tracefs nodev /sys/kernel/tracing
# 开启 kprobe 事件,探测 __x64_sys_openat
echo 'p:myopen __x64_sys_openat filename=%di:ustring' > /sys/kernel/tracing/kprobe_events
# 开启 tracing
echo 1 > /sys/kernel/tracing/tracing_on
cat /sys/kernel/tracing/trace
这里 %di 是 x86_64 SysV ABI 的第一个参数寄存器(filename 指针),:ustring 表示把它当作用户态字符串读取。看到这里你应该明白了,动态追踪虽然不需要改内核,但还是需要你懂一点体系结构和内核调用约定,知道参数在哪个寄存器里。
2.3 tracepoint 的现代用法
如果你只是想知道某个子系统发生了什么事,比如“网络包进来了”“块设备发出了 IO 请求”“某个进程被调度走了”,其实完全不需要 kprobes 去硬探函数,直接用内核已经埋好的 tracepoint 就行了。
查看系统支持哪些 tracepoint,只需要:
bash复制ls /sys/kernel/tracing/events/
可以看到一大堆分类:sched、irq、net、block、kmem、syscalls 等等。每个分类下都是具体的事件点。比如我想看进程切换事件:
bash复制echo 1 > /sys/kernel/tracing/events/sched/sched_switch/enable
cat /sys/kernel/tracing/trace_pipe
这会源源不断地输出所有 CPU 上的进程切换记录。配合 trace-cmd record/report 命令,可以把它后台记录下来,再拿 trace-cmd report 精确分析。这是做性能调优和调度问题排查时特别好用的一对组合。
2.4 内核插桩技术的选型建议
经常有读者问我:“遇到一个问题,我到底该用 kprobe 还是 tracepoint,还是回头老老实实改代码加 printk?”
我的经验是分几类情况:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 代码里已经有 dev_dbg/pr_debug,只是没打开 | dyndbg | 零开销,直接动态开启 |
| 需要看某个函数的调用参数、返回值 | kprobe/kretprobe | 无需源码,任何函数都能挂 |
| 内核子系统自带的事件(调度、中断、网络收发包) | tracepoint + trace-cmd | 安全稳定,字段定义完整 |
| 需要统计函数调用频率/耗时分布 | perf kprobe 或 ftrace function_graph | perf 自带直方图统计,无须自己写聚合 |
| 代码能改,且问题稳定复现 | printk + dmesg | 最简单直接,适合新手 |
这个表格其实就是我日常工作里的默认决策表。记住一个原则:能用现成 tracepoint 就不用 kprobe,能用 kprobe 就不改代码,这是侵入性递增的顺序。总想着改代码,工作效率上不去,还容易引入新 bug。
提示:kprobe 并不是免费的,它在每个探测点的命中路径上都会产生一次断点异常开销,高频函数(比如
schedule、kmalloc)上开 kprobe 会明显拖慢系统。生产环境调试要设置采样率(perf 的-F参数)来控制开销,不要全量抓取。
3. 现代追踪利器:ftrace 与 perf 组合拳
3.1 ftrace:内核自带的瑞士军刀
ftrace 是内核早期就引入的追踪框架,最初只做函数追踪,现在已经演变成一个功能很全的追踪平台。相比外挂式的 kprobe,ftrace 更依赖编译器提供的 -pg 选项和 CONFIG_FUNCTION_TRACER,它会在每个函数入口放置一段追踪指令,所以追踪所有函数的调用关系非常方便。
最常用的两个功能:
function tracer:显示当前执行路径。适合回答“我们的驱动代码到底走没走这条路”。
bash复制echo function > /sys/kernel/tracing/current_tracer
echo 'my_driver_irq_handler' > /sys/kernel/tracing/set_ftrace_filter
echo 1 > /sys/kernel/tracing/tracing_on
cat /sys/kernel/tracing/trace
如果你只想追踪某一个函数被调用的动态路径,这个 filter 极其好用。它能告诉你这个函数在前几层调用栈里是谁拉起来的,往往一眼就能看出谁调错了接口。
function_graph tracer:以类似 C 语言调用树的形式显示函数间的嵌套调用,同时还能显示每个函数的执行耗时。这对理解内核启动流程、驱动 probe 顺序、系统调用内部实现非常有帮助。
bash复制echo function_graph > /sys/kernel/tracing/current_tracer
echo '__x64_sys_read' > /sys/kernel/tracing/set_graph_function
cat /sys/kernel/tracing/trace
你会看到类似下面这样的输出(示意):
code复制 3) | __x64_sys_read() {
3) 0.500 us | ksys_read() {
3) 0.250 us | vfs_read() {
3) 0.750 us | __vfs_read() {
...
这里需要注意的是,function_graph 的开销比 function 大不少,因为它要记录函数的进入和退出,在高频路径上可能造成明显的延迟放大。对于性能敏感的场景,建议只在特定函数上过滤使用,不要全局追踪。
3.2 perf:从采样到火焰图
perf 是内核自带的性能剖析工具,也是我排查性能问题时的首选。它的功能非常多,这里只说和内核调试最相关的几个用法。
查看某个内核函数的调用频率与热点:
bash复制perf top -G
采集指定命令执行过程中的内核函数调用栈:
bash复制perf record -a -g -- sleep 5
perf report -g graph
-g 表示记录调用栈,这是生成火焰图的前提。在嵌入式板子上如果内存紧张,可以用 perf record -F 99 把采样频率降到 99Hz,减少记录的数据量。
perf 配合 kprobe 动态测量延迟:
这是一个能直接落地的实战技巧。比如你觉得某个驱动 IO 操作太慢,想量化它究竟慢在内核哪一段,可以用 perf 在驱动的关键函数上挂上 kprobe,然后测量时间戳:
bash复制perf probe -a 'my_driver_read_start'
perf probe -a 'my_driver_read_end'
perf record -e probe:my_driver_read_start -e probe:my_driver_read_end -- sleep 10
perf script
这样每次进入和退出该函数的记录都会被采样到,简单算一下两个时间戳的差值,就能得到每次调用的耗时。这个方法比我之前用 printk 打时间戳要精准得多,因为 printk 本身会引入毫秒级的延迟,而 perf/kprobe 的开销可以控制在微秒级。
3.3 火焰图:让性能问题可视化
火焰图是 Brendan Gregg 推广起来的一种可视化工具,原理并不复杂:把 perf 记录到的调用栈数据按比例画成堆叠矩形,横向是采样数占比,纵向是调用栈深度。
生成火焰图的完整流程:
bash复制# 1. 采集数据(这里可以采集内核态和用户态)
perf record -F 99 -a -g -- sleep 10
# 2. 解析输出
perf script > out.perf
# 3. 折叠调用栈
stackcollapse-perf.pl out.perf > out.folded
# 4. 生成 SVG 火焰图
flamegraph.pl out.folded > flamegraph.svg
这个工具链在 GitHub 上开源的 FlameGraph 仓库里都能找到,stackcollapse-perf.pl 和 flamegraph.pl 是其中两个脚本。生产环境部署很简单,把脚本放到服务器上就能跑。
火焰图看起来好像很花哨,但读起来也就三个要点:
- 横向越宽的函数,占用的 CPU 时间越多;
- 纵向越长,说明调用越深,可能是嵌套逻辑复杂或者循环调用;
- 顶部平顶、横向宽大的函数就是热点。
有一次我在调一个网络吞吐上不去的驱动,perf record 一抓,火焰图里明显看到一个 csum_partial 函数占据了大量 CPU——原来是驱动在发送路径上每次都调用软件校验和函数,而网卡本身支持硬件校验和卸载。把这个逻辑改掉之后,吞吐直接提升了一倍。这种问题,如果只看代码,真是需要看很久才能发现。
3.4 ftrace 和 perf 的配合技巧
实际处理复杂问题时,我经常先上 perf 做宏观扫描,找到可疑的热点函数,再用 ftrace 或 kprobe 对可疑函数做微观追踪,查看具体参数和调用路径。
举个例子:某次系统 iowait 飙高,我先是 perf top 看到 submit_bio 在热点列表,然后用 ftrace 抓 submit_bio 的调用栈,发现是某个用户态进程在循环刷盘,刷盘逻辑来自一个第三方数据库的配置问题,而不是内核 bug。整个排查过程大概花了十分钟,要是全靠改代码加 printk 然后重新部署验证,可能一个下午就没了。
实操心得:ftrace 的 tracefs 支持
trace_pipe,读它会阻塞等待新数据,非常适合实时观察。但在读取 trace 文件之后记得要echo 0 > tracing_on关掉追踪,否则内核会持续开销。
4. eBPF:新一代内核可观测性技术
4.1 eBPF 的工作机制
如果说 ftrace 和 kprobe 是“在目标函数上挂个钩子”,那 eBPF 就是在内核里开了一个 安全的沙箱运行时:你写一段字节码,通过系统调用加载到内核里,内核的校验器(verifier)会先静态检查这段代码是否安全(有没有可能越界访问、有没有死循环、指令数是否超限),检查通过才允许执行。
这样做的好处是显而易见的:你不仅能在任意函数入口采样,还能在内核里直接做数据聚合、过滤、统计,只把最终结果传回用户态,极大减少了上下文切换和用户态/内核态数据拷贝的开销。
eBPF 的编程目前有两条路:
- bcc / bpftrace:提供 Python 和高级脚本语言的前端,适合快速做调试脚本;
- libbpf / CO-RE(Compile Once, Run Everywhere):用 C 写 eBPF 程序,配合 BTF(BPF Type Format)实现真正意义上的跨内核版本兼容。
4.2 bpftrace 一行命令追踪内核
bpftrace 是我个人非常喜欢的调试工具,它的语法有点像 awk,学习曲线很平缓。上面我们用 perf 加 kprobe 做了函数耗时,用 bpftrace 只需要一行:
bash复制bpftrace -e 'kprobe:my_driver_read { @start[tid] = nsecs; } kretprobe:my_driver_read /@start[tid]/ { @usecs = hist((nsecs - @start[tid]) / 1000); delete(@start[tid]); }'
它会输出一个直方图,可以直观看到驱动 read 操作的延迟分布情况。
再比如说,你想看看哪些进程正在触发 sys_read,就用 tracepoint 版本:
bash复制bpftrace -e 'tracepoint:syscalls:sys_enter_read { @[comm] = count(); }'
这里 comm 是任务名,count() 是聚合函数。按下 Ctrl-C 之后 bpftrace 会自动打印统计结果。整个排查过程不需要写一行内核模块代码。
如果要抓参数,bpftrace 还能直接读取内核函数参数:
bash复制bpftrace -e 'kprobe:do_sys_openat2 { printf("%s open %s\n", comm, str(arg1)); }'
注意这里 arg1 是第二个参数,也就是用户态的文件路径指针。str() 函数负责把指针转换成字符串。
4.3 CO-RE 与 BTF:解决内核版本兼容问题
eBPF 早期最头疼的问题就是内核数据结构不兼容。你在一台 5.4 内核上写的 eBPF 程序,换个 5.15 内核可能就跑不了了,因为目标结构体字段偏移变了。
CO-RE 的解决思路是:编译 eBPF 时不直接写死字段偏移,而是记录下“我想访问这个结构体的这个字段”这样的意图,运行时通过 BTF 信息动态计算偏移量。
实际用起来就是这样的体验:
c复制// 这是 libbpf 方式,使用 vmlinux.h 自动获取内核类型
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
SEC("kprobe/__x64_sys_execve")
int BPF_KPROBE(handle_execve, const char *filename)
{
char buf[256];
bpf_probe_read_user_str(buf, sizeof(buf), filename);
bpf_printk("execve: %s\n", buf);
return 0;
}
char LICENSE[] SEC("license") = "GPL";
编译的时候需要本机(或目标同架构环境)提供 vmlinux.h 和 libbpf 工具链。对于嵌入式环境,只要目标板子内核开启了 CONFIG_DEBUG_INFO_BTF,这个程序就可以跨小版本运行,不需要针对每台设备单独编译。
4.4 内核调试的未来趋势
从 printk 到 eBPF,本质上是“观测能力”与“开销控制”之间平衡点的不断前移。printk 是把数据写出来,人肉看;eBPF 是在数据产生地就完成统计和过滤,只把最有价值的信息呈现给你。内核社区对 eBPF 的投入非常大,几乎所有主流子系统都在逐步增强 tracepoint 和 BPF 辅助函数的支持。
但这并不意味着你可以完全忽略基础工具。恰恰相反,eBPF 程序本身也是要依赖 kprobe、tracepoint、perf_event 这些底层机制工作的。我见过一些初学者一上来就抱着 bpftrace 文档猛啃,面对复杂问题依然无从下手,就是因为不了解底层机制,不知道某个探针类型适合解决什么问题、开销有多大。
5. 实战案例:一次内核态 I/O 延迟排查全程还原
5.1 问题现场
去年做一个嵌入式项目,板子上跑的是定制化 5.10 内核,外挂一块 FPGA 通过 PCIe 与 SoC 相连,驱动是内部同事写的。反馈的问题很明确:板子启动后运行一段时间,FPGA 的 DMA 中断处理会偶发出现几十毫秒的延迟,导致数据丢包。这个问题在客户现场只偶发,实验室里跑几天才能复现一次,很难稳定抓到现场。
这种偶发问题,第一反应就是不能在代码里堆 printk——你不知道问题什么时候来,而且加了 printk 之后时序就变了,可能就再也复现不出来了。
5.2 排查步骤还原
第一步:perf 采样看整体趋势。
在板子上跑了一晚上 perf record,第二天早上拿到数据生成火焰图,发现 IRQ 处理路径上有一个 msleep 调用占据了可观的 CPU 时间。msleep 出现在中断上下文里本来就是可疑的——中断处理里不应该睡得着。但这还不足以定位问题。
第二步:ftrace 抓函数调用链。
用 ftrace 的 function_graph 追踪中断入口到中断处理返回的完整路径,限定在 my_fpga_irq_handler 函数上:
bash复制echo function_graph > /sys/kernel/tracing/current_tracer
echo 'my_fpga_irq_handler' > /sys/kernel/tracing/set_graph_function
echo 1 > /sys/kernel/tracing/tracing_on
结果清晰地显示,my_fpga_irq_handler 在某个分支里调用了 mutex_lock,而 mutex_lock 在持有锁的进程侧被一个长时间运行的临界区阻塞了。到底是什么临界区?继续往上看。
第三步:trace-cmd 记录锁等待。
用 trace-cmd 加上 lock 事件组来抓:
bash复制trace-cmd record -e 'lock:*' -e 'irq:*' -- sleep 30
trace-cmd report
这次逮了个正着:一个跑在 CPU2 上的内核线程 kworker/u8:1 持有同一个 mutex,并且在里面调用了 i2c_transfer——I2C 总线速度本来就慢,加上总线上还挂着一堆设备,某些时候一次传输会被拖到几十毫秒。而 FPGA 的中断处理想在同一个锁上等待,自然被阻塞了这么长时间。
第四步:修正方案。
定位到根因之后,修复就清晰了:中断处理路径不应该直接拿这个可能睡眠的 mutex,改成在中断里只设置标志位 + wakeup 一个内核线程,把真正的数据搬运和 FPGA 寄存器操作放到线程上下文里去做。此外还把 I2C 的传输延迟用 per-cpu 的统计计数暴露到了 debugfs,方便后续值班人员观察现场。
整个过程下来,我们没有改一行生产环境的内核源码,全靠动态追踪工具完成了定位。而这个案例里如果一开始就上头加 printk,大概率会因为改变了时序而让问题更难复现。
5.3 工具链选型复盘
回顾这次排查,用到的工具链是:
- perf record + FlameGraph:宏观热点分析;
- ftrace function_graph:调用路径确认;
- trace-cmd + lock 事件组:锁阻塞的最终定位。
如果这个项目部署了 bpftrace,我可能还能更早一点用直方图看到锁等待的延迟分布,但即便是老一套工具组合,只要能静下心一层层推测、验证,也足够解决大多数内核问题。调试工具的本质是“降低验证假设的成本”,而不是替代你的思考。
6. 各家调试手段的横向对比与选型清单
6.1 一张表看清所有工具
这个对比表我整理了很久,基本把我日常使用的心得都浓缩进去了,也方便你收藏随时查阅:
| 工具/机制 | 侵入性 | 功能覆盖 | 开销 | 适用场景 | 上手难度 |
|---|---|---|---|---|---|
| printk / dev_dbg | 需改代码 | 任意代码位置输出 | 高(若无限速) | 代码稳定复现、学习内核代码 | 低 |
| dyndbg | 需改代码但运行时开关 | 仅限 pre 标注的 pr_debug/dev_dbg | 可忽略(关闭时) | 驱动开发调试 | 低 |
| kprobe/kretprobe | 无需改代码 | 任意内核函数入口/返回 | 中断异常开销,较高 | 查函数参数、返回值、调用路径 | 中 |
| tracepoint | 无需改代码 | 子系统预置事件 | 低 | 标准事件追踪、性能基线 | 低 |
| ftrace function | 无需改代码 | 函数级调用路径 | 中 | 看函数调用链、过滤特定函数 | 中 |
| ftrace function_graph | 无需改代码 | 带耗时信息的嵌套调用树 | 高 | 理解执行流程、定位慢路径 | 中 |
| perf | 无需改代码 | CPU 采样、热点、调用栈 | 低(采样方式) | 性能问题初筛、热点定位 | 中 |
| bpftrace | 无需改代码 | 基于 eBPF 的任意变量/事件观测 | 低(可控制) | 快速编写临时追踪脚本 | 中 |
| CO-RE eBPF 程序 | 无需改代码 | 长期埋点的自定义观测 | 低 | 生产环境长期监控 | 高 |
6.2 针对不同人群的路线建议
如果你是刚接触内核开发的学生或者转行工程师,我的建议是先老老实实学会 printk,能用它把一个驱动从 probe 到 read/write 的流程打出来,理解设备模型基本概念,然后再去碰 ftrace。不要一开始就上 bpftrace,否则你连“自己的驱动走了哪条函数路径”都还不会看,谈何抽象分析。
如果你是在做嵌入式 BSP 或者内核驱动开发,建议把 ftrace + perf 组合练熟,这是日常排查最刚需的组合。到了项目后期,如果客户现场出问题不让你重启系统,你就需要靠 trace-cmd 和 bpftrace 这类低侵入工具线上采集。
如果你负责的是生产环境 Linux 服务器的性能保障,那 eBPF 应该是你的主力方向。CO-RE 的 eBPF 程序可以灰度部署到整个集群,不重启服务就能采集系统关键路径数据。配合 Prometheus 这类监控平台,能做到很细粒度的可观测性覆盖。
6.3 千万不要落入的误区
内核调试领域有一些经典误区,我在这里统一说一下:
- 误区一:调试只靠工具,不靠思考。 工具只是验证假设的手段。如果你对内核的调度、锁、内存模型没有基本概念,看再多的火焰图也只是看到一堆函数名字。我建议大家在学工具的同时,认真把《Linux 内核设计与实现》或者任一本靠谱的内核入门书过一遍,尤其是进程管理、中断、同步机制这三章。
- 误区二:上来就想全自动化。 一些朋友想着搞一套“万能调试框架”,以后任何问题都能一键定位。现实是内核问题千奇百怪,一个简单的硬件 errata 可能就会让你折腾好久。我建议的路线是先掌握单点工具,再根据实际问题组合使用。
- 误区三:生产环境不敢开 tracing。 的确,某些老版本内核的 ftrace 有过 bug,但现代内核(4.x 以后的 mainline)的 tracing 框架已经足够稳定。负载不高的环境下开启 function_graph 做短时间追踪,风险极低,收益却很可观。当然,如果是银行核心系统那种强监管环境,还是按照变更流程走,先在测试环境复现验证。
7. 遇到内核问题时的排查思维框架
7.1 先定位现象再选工具
很多初学者拿到一个内核问题,第一反应是直奔工具去抓数据。但我建议先花五分钟做一件事:把“现象”描述清楚。是系统卡死(hang),是重启(panic),是性能下降,还是数据错乱?这四类问题的排查路径完全不同:
- 系统 hang:优先看对应的
/proc/*/stack、/proc/<pid>/stack,或者 ftrace 抓当前运行堆栈,判断是不是死锁。 - panic/oops:先看 oops 输出里的调用栈和寄存器,确认是空指针、野指针还是其他异常类型。
- 性能下降:perf 采样 + 火焰图,先找热点再谈优化。
- 数据错乱:这类最恶心,通常需要结合内存检查和动态追踪,而且大概率涉及并发访问或内存越界,需要配合 KASAN、KCSAN 这类内核检测工具。
如果能在第一步把现象归类,你就能少走 80% 的弯路。
7.2 一套能复用的排查流程模板
我给自己总结了一套标准流程,分享出来供你参考:
- 确认内核版本、内核配置、出问题的时间窗口、是否有规律;
- 系统层面先看
dmesg -T,排除明显的 oops、硬件报错、内存不足; - 如果是性能问题,
perf top快速扫一眼当前热点; - 如果是逻辑问题(行为不符合预期),用 ftrace function 或者 function_graph 追踪可疑函数路径;
- 如果怀疑锁竞争,用
lockdep(内核配置里打开CONFIG_PROVE_LOCKING)跑一遍复现,它会输出锁依赖环的详细报告; - 如果怀疑内存越界,用 KASAN 重新编译内核复现;
- 如果上面都没定位,考虑用 bpftrace 写一个针对性脚本,长期挂机等待偶发现场。
这套流程的核心逻辑是:从宏观到微观,从低开销到高开销,从外围到核心。不要一开始就动用重型武器,否则很容易被海量数据淹没。
7.3 与内核社区打交道的经验
当你自己实在查不动了,准备发邮件到内核邮件列表(linux-kernel@vger.kernel.org)求助时,有几点经验值得留意:
- 提供内核版本、工具链版本、硬件平台、内核配置(
.config)的完整信息; - 给出最小复现步骤,并强调在不加调试手段时能稳定复现;
- 附上完整的 oops 输出和 dmesg(用
scripts/decode_stacktrace.sh将地址翻译成源码行号后再贴); - 不要直接问“请帮我看看为什么”,而是给出你已经做过的排查和分析,社区开发者更愿意帮一个做过功课的人。
我自己发邮件求助的经验是,基本都能在几天内收到有价值的回复,前提是你把问题描述得足够清楚。反过来,如果是一个“信息一团糟”的问题,很容易被无视。
最后一件事:调试能力是练出来的
提到内核调试,有一点我觉得比任何工具都重要——动手练。你可以把一份内核源码下载下来,编译一个带 CONFIG_DEBUG_INFO 和 CONFIG_KALLSYMS 的调试内核,跑在 QEMU 或真实板子上,然后给自己出题:比如“为什么这个驱动 probe 会失败?”“为什么这个中断处理会有延迟?”然后用上面说的工具一个个去验证。
我自己早期学内核调试,就是把一个简单的字符设备驱动反复改,故意塞各种 bug:空指针、死锁、越界访问。然后用 oops、lockdep、KASAN 一个个揪出来。这个过程虽然很基础,但建立的“直觉”是后面处理复杂问题时的根。工具可以学得很快,但直觉只能靠一次次踩坑换回来。希望这篇内容能帮你少踩一些坑,也能让你在内核出问题时多几分从容。
