43、内核调试技巧:printk、ftrace、kprobe、kgdb、内核模块调试
内核调试,说白了就是跟内核“对话”。你写了个驱动,系统崩了,或者行为诡异,这时候你没法像用户态程序那样打断点、看变量。内核是个庞然大物,它不会给你太多提示。我这些年跟内核打交道,最深的体会就是:调试工具链就是你的“透视眼”。今天咱们就把这几种最常用的内核调试手段捋一遍。
43.1 printk:最朴素,也最管用
printk 是内核版的 printf。它能把日志打印到内核缓冲区,然后通过 dmesg 命令查看。别小看它,我遇到过很多复杂问题,最后都是靠几行 printk 定位的。
核心用法:
printk(KERN_INFO "my_driver: device opened, minor=%d\n", minor);
printk 有 8 个日志级别,从 KERN_EMERG 到 KERN_DEBUG。级别越低,越容易显示在控制台上。我个人习惯在调试阶段用 KERN_INFO,上线前改成 KERN_DEBUG 或者直接删掉。
| 宏 | 级别 | 含义 |
|---|---|---|
| KERN_EMERG | 0 | 系统不可用 |
| KERN_ALERT | 1 | 必须立即处理 |
| KERN_CRIT | 2 | 严重条件 |
| KERN_ERR | 3 | 错误条件 |
| KERN_WARNING | 4 | 警告 |
| KERN_NOTICE | 5 | 正常但重要 |
| KERN_INFO | 6 | 信息 |
| KERN_DEBUG | 7 | 调试信息 |
小技巧: 用 echo 8 > /proc/sys/kernel/printk 可以把所有级别的日志都输出到控制台。调试阶段我经常这么干,省得来回切终端看 dmesg。
注意: printk 在中断上下文或自旋锁保护区域里要小心。它可能会引起调度,导致死锁。我曾经在中断处理函数里加了个 printk,结果系统直接卡死——嗯,从那以后我养成了习惯,先判断上下文。
43.2 ftrace:内核的“手术刀”
ftrace 是内核内置的跟踪器。它能跟踪函数调用、中断、调度等几乎所有内核活动。说白了,它就像内核的“strace”,但更底层、更强大。
我建议你从 tracefs 入手。挂载后,所有控制文件都在 /sys/kernel/tracing 下。
# 挂载 tracefs
mount -t tracefs tracefs /sys/kernel/tracing
# 设置跟踪器为 function
echo function > /sys/kernel/tracing/current_tracer
# 设置要跟踪的函数
echo __alloc_pages_nodemask > /sys/kernel/tracing/set_ftrace_filter
# 开始跟踪
echo 1 > /sys/kernel/tracing/tracing_on
# 执行你的操作...
# 停止跟踪
echo 0 > /sys/kernel/tracing/tracing_on
# 查看结果
cat /sys/kernel/tracing/trace
你看,流程很清晰。设置跟踪器 -> 设置过滤条件 -> 开启 -> 操作 -> 关闭 -> 查看结果。我在分析内存分配路径时,就用这个组合拳,一下子就找到了哪个模块在频繁调用 alloc_pages。
常用跟踪器:
- function:跟踪函数调用,开销较大
- function_graph:显示函数调用图,带调用深度
- wakeup:跟踪进程唤醒延迟
- irqsoff:跟踪中断关闭时间
- preemptoff:跟踪抢占关闭时间
避坑指南: function 跟踪器开销不小,生产环境慎用。我曾经在线上服务器开了 function 跟踪,结果 CPU 使用率直接飙到 80%。后来我改用 trace_printk,它比 printk 开销小得多,而且能跟 ftrace 的日志混在一起看。
43.3 kprobe:动态插桩,无侵入调试
kprobe 允许你在内核任意地址插入探测点。不需要重新编译内核,不需要修改代码。你想想看,这多方便?
kprobe 有三种:kprobe(函数入口/出口)、kretprobe(函数返回值)、jprobe(函数参数)。我用的最多的是 kretprobe,专门看函数返回值。
// 一个简单的 kprobe 示例
#include <linux/kprobes.h>
static struct kprobe kp = {
.symbol_name = "do_fork",
};
static int handler_pre(struct kprobe *p, struct pt_regs *regs)
{
printk(KERN_INFO "do_fork called\n");
return 0;
}
static void handler_post(struct kprobe *p, struct pt_regs *regs,
unsigned long flags)
{
printk(KERN_INFO "do_fork returned\n");
}
static int __init kprobe_init(void)
{
kp.pre_handler = handler_pre;
kp.post_handler = handler_post;
register_kprobe(&kp);
return 0;
}
这段代码注册了一个 kprobe,每次 do_fork 被调用时,都会打印日志。我在调试一个 fork 性能问题时,就用它统计了每秒 fork 的次数,发现是某个监控进程在疯狂创建子进程。
注意: kprobe 处理函数必须在原子上下文中运行。不能调用可能睡眠的函数,比如 kmalloc(GFP_KERNEL)。我曾经犯过这个错,结果内核直接 oops 了。
43.4 kgdb:内核级断点调试
kgdb 是内核的 gdb 调试器。它需要两台机器:一台跑内核(目标机),一台跑 gdb(调试机)。通过串口或网络连接。
说实话,kgdb 配置起来有点麻烦。但一旦配好,它就是最强大的调试工具。你可以打断点、单步执行、查看变量、修改内存——跟用户态调试一模一样。
# 目标机内核启动参数
kgdboc=ttyS0,115200 kgdbwait
# 调试机
gdb vmlinux
(gdb) target remote /dev/ttyS0
(gdb) break do_fork
(gdb) continue
我个人习惯在开发阶段用 kgdb,尤其是调试驱动初始化流程。有一次我写了个 USB 驱动,在 probe 函数里总是崩溃。用 kgdb 单步执行,发现是某个指针没判空就直接解引用了。
小技巧: 如果没条件用两台机器,可以试试 kgdboe(基于以太网)。或者用 QEMU 模拟目标机,在宿主机上调试。我经常在虚拟机里这么干,省得来回搬机器。
43.5 内核模块调试:独立加载,独立调试
内核模块的调试,其实跟整个内核调试差不多。但有几个特殊点要注意。
首先,模块的符号表是独立的。你用 gdb 调试模块时,需要加载模块的符号文件。
# 查看模块加载地址
cat /sys/module/my_module/sections/.text
# gdb 中加载模块符号
(gdb) add-symbol-file my_module.ko 0xffffffffa0000000
其次,模块的 init 和 exit 函数是调试重点。我建议在 init 函数入口加个 printk,确认模块是否被加载。然后在 exit 函数里也加一个,确认卸载是否正常。
模块调试 checklist:
- 确认模块加载成功:lsmod | grep my_module
- 查看模块日志:dmesg | tail
- 检查模块参数:cat /sys/module/my_module/parameters/*
- 查看模块内存映射:cat /proc/modules
- 用 ftrace 跟踪模块函数:echo my_module:* > set_ftrace_filter
我曾经调试过一个网络驱动模块,加载后网络就断了。用 ftrace 跟踪模块里的函数,发现是注册 net_device 时传了个错误的回调函数指针。嗯,这种问题用 printk 也能定位,但 ftrace 能更快地看到调用链。
知识体系总览
下面这张图总结了这几种调试工具的关系和适用场景。你可以把它当作一个决策树:遇到问题,先想清楚要查什么,再选工具。
调试内核,说白了就是跟内核“斗智斗勇”。printk 是基础,ftrace 是进阶,kprobe 是奇兵,kgdb 是王牌。内核模块调试则是在这个框架下的具体应用。我建议你从 printk 开始,慢慢尝试 ftrace,等遇到复杂问题时再上 kgdb。别一上来就搞 kgdb,配置环境的时间都够你写十个 printk 了。
最后说一句:调试工具只是手段,理解内核行为才是目的。多读代码,多动手,慢慢你就能“听”懂内核在说什么了。
公众号:蓝海资料掘金营,微信deep3321