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_EMERG0系统不可用
KERN_ALERT1必须立即处理
KERN_CRIT2严重条件
KERN_ERR3错误条件
KERN_WARNING4警告
KERN_NOTICE5正常但重要
KERN_INFO6信息
KERN_DEBUG7调试信息

小技巧: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 加日志,看 dmesg ftrace 跟踪函数调用图 kgdb 断点+单步调试 kprobe 动态插桩,无侵入 trace_printk 低开销日志 模块调试 独立加载+符号 原则:先简单后复杂,先 printk 后 kgdb

调试内核,说白了就是跟内核“斗智斗勇”。printk 是基础,ftrace 是进阶,kprobe 是奇兵,kgdb 是王牌。内核模块调试则是在这个框架下的具体应用。我建议你从 printk 开始,慢慢尝试 ftrace,等遇到复杂问题时再上 kgdb。别一上来就搞 kgdb,配置环境的时间都够你写十个 printk 了。

最后说一句:调试工具只是手段,理解内核行为才是目的。多读代码,多动手,慢慢你就能“听”懂内核在说什么了。


公众号:蓝海资料掘金营,微信deep3321