在 Linux 系统的广袤世界里,内核调试是支撑系统稳定与高效运行的幕后英雄。无论是优化性能还是排查故障,精准的内核调试技术都至关重要。想象一下,当服务器突然卡顿或关键服务崩溃时,内核调试就如同黑暗中的明灯,帮你快速定位问题。
在众多调试技术中,kprobe 无疑是一颗耀眼的明星。它是 Linux 内核提供的一种动态调试机制,允许开发者在不修改内核源码、不重启系统的情况下,对内核函数进行探测。这就像不拆开精密仪器,却能直接观察其内部关键部件的运转。kprobe 的出现,极大地改变了内核调试的方式,让整个过程变得更加灵活高效。
一、初相识 kprobe
1.1 kprobe 是什么
kprobe,即 Kernel Probes,是 Linux 内核提供的一种强大的动态跟踪技术。它就像一个灵活的“观察者”,允许开发者在不修改内核代码、不重启系统的前提下,在内核函数的特定位置插入探测点。
当内核执行到预设的探测点时,kprobe 会执行我们提前定义好的处理函数。这个处理函数可以记录函数的参数值、监测执行时间,甚至对执行逻辑进行干预,从而为深入理解内核机制、排查故障和优化性能提供有力支持。
1.2 kprobe 的类型
kprobe 主要包含三种类型,各有其独特的功能和应用场景。
-
kprobes:最基础、最通用的成员,几乎可以被插入到内核的任意指令位置。当内核执行到这个探测点时,会依次执行 pre_handler 和 post_handler 函数。pre_handler 在指令执行前记录现场(如寄存器状态、函数参数),post_handler 在指令执行后处理结果(如统计执行时间)。
-
jprobes:相对“专一”,只能被插入到内核函数的入口处。其主要职责是帮助我们轻松获取函数的参数。jprobes 的处理函数原型必须与被探测函数完全一致,以确保能准确获取参数。
-
kretprobes:专注于函数的返回阶段,在指定函数返回时被触发。主要用于获取函数的返回值,以及精确计算函数的执行时间。使用时需注意合理设置 maxactive 字段,它决定了可同时探测的函数实例数,避免因资源不足导致探测点丢失。
二、kprobe 工作原理
2.1 基于 int3 的 kprobe 实现
在 x86 架构中,CPU 提供了特殊的单字节指令 INT3(机器码 0xcc)用于触发调试异常(#BP)。kprobe 巧妙地利用了这个机制。
当我们通过 register_kprobe() 注册一个探测点时,kprobe 子系统会查找目标函数的虚拟地址,备份该地址处的第一个字节,然后写入 0xcc(断点指令)进行替换。
CPU 执行到这个被替换的位置时,会抛出 #BP 异常,并转入 do_int3() 异常处理函数。kprobe 通过注册的钩子函数,获取当前 CPU 寄存器状态,找到对应的 kprobe 结构体。
接着,kprobe 会先执行 pre_handler 函数。执行完毕后,恢复原始指令的一个副本,并设置 CPU 进入单步执行模式。当原始指令执行完后,执行 post_handler 函数。最后,kprobe 恢复正常的指令执行流程。
2.2 kretprobe 的实现机制
kretprobe 的实现同样巧妙,它利用了 kprobes 在函数入口处设置探测点的能力。
调用 register_kretprobe() 注册时,实际上是在被探测函数的入口处建立了一个 kprobe 探测点。当内核执行到这个探测点时,kprobe 会保存被探测函数的返回地址,并将其替换为一个预先定义的 trampoline 地址。
在 kprobe 初始化时,就已经为这个 trampoline 注册了一个 kprobe 并关联了处理函数。当被探测函数执行返回指令,控制流转到 trampoline 时,对应的处理函数就会被执行。这个处理函数会调用我们关联到 kretprobe 上的用户处理函数,以获取返回值、计算执行时间等。处理完毕后,kprobe 会设置指令寄存器指向已备份的原始返回地址,让函数正常返回。
被探测函数的返回地址等信息保存在 kretprobe_instance 变量中。kretprobe 结构体中的 maxactive 字段指定了可同时探测的实例数。如果设置过小,在函数调用非常频繁时,可能导致探测点执行丢失,丢失的次数会记录在 nmissed 字段中。
三、kprobe 使用指南:从基础到进阶
3.1 环境准备
在开始使用 kprobe 前,需要确保内核已开启 CONFIG_KPROBE_EVENT 选项。可以通过查看 /boot/config-$(uname -r) 文件来确认是否存在 CONFIG_KPROBE_EVENT=y。
此外,需要挂载 debugfs 文件系统:
mount -t debugfs none /sys/kernel/debug
3.2 使用 debugfs 创建 kprobe
(1)挂载与进入目录:
挂载 debugfs 后,进入 kprobes 目录:
cd /sys/kernel/debug/kprobes
(2)设置探测点:
以追踪 do_fork 函数为例:
echo ‘p:myprobe do_fork’ > events
其中,p 表示创建普通 kprobe;myprobe 是探测点名称;do_fork 是目标函数。
(3)启用与查看结果:
启用事件:
echo 1 > events/myprobe/enable
查看追踪结果:
cat /sys/kernel/debug/tracing/trace
输出示例:
bash-3285[001] .... 1234.567890: myprobe: (do_fork+0x0/0x320)
(4)禁用与删除:
禁用探测点:
echo 0 > events/myprobe/enable
删除探测点:
echo ‘myprobe’ > events/unregister
3.3 编写内核模块注册 kprobe
编写内核模块是更灵活的使用方式。核心是 struct kprobe 结构体:
symbol_name:指定要探测的内核函数名。
pre_handler:指令执行前的回调函数指针。
post_handler:指令执行后的回调函数指针。
fault_handler:处理内存异常的回调函数。
关键函数是 register_kprobe(注册)和 unregister_kprobe(注销)。
下面是一个监视 do_fork 函数的内核模块代码示例:
#include <linux/module.h>
#include <linux/kprobes.h>
// 定义 pre_handler 回调函数
static int handler_pre(struct kprobe *p, struct pt_regs *regs){
printk(KERN_INFO "Pre handler: %s called\n", p->symbol_name);
return 0;
}
// 定义 post_handler 回调函数
static void handler_post(struct kprobe *p, struct pt_regs *regs, unsigned long flags){
printk(KERN_INFO "Post handler: %s finished\n", p->symbol_name);
}
// 定义 kprobe 结构
static struct kprobe kp = {
.symbol_name = "do_fork",
.pre_handler = handler_pre,
.post_handler = handler_post,
};
// 模块初始化函数
static int __init kprobe_init(void){
int ret;
// 注册 kprobe
ret = register_kprobe(&kp);
if (ret < 0) {
printk(KERN_ERR "Failed to register kprobe\n");
return ret;
}
printk(KERN_INFO "Kprobe registered successfully\n");
return 0;
}
// 模块退出函数
static void __exit kprobe_exit(void){
// 注销 kprobe
unregister_kprobe(&kp);
printk(KERN_INFO "Kprobe unregistered successfully\n");
}
module_init(kprobe_init);
module_exit(kprobe_exit);
MODULE_LICENSE("GPL");
编译与使用:
创建一个 Makefile:
obj-m += kprobe_module.o
all:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules
clean:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean
编译模块:
make
加载模块:
sudo insmod kprobe_module.ko
查看内核日志验证:
dmesg | tail
卸载模块:
sudo rmmod kprobe_module
3.4 结合 perf 和 ftrace 使用 kprobe
(1)perf 工具集成:
使用 perf probe 添加探测点:
perf probe -x /vmlinuz do_fork
记录事件(记录10秒):
perf record -e ‘probe:do_fork’ -a sleep 10
查看结果:
perf script
(2)ftrace 的联动:
通过 /sys/kernel/debug/tracing/kprobe_events 动态添加事件:
echo ‘p:myprobe do_fork’ > /sys/kernel/debug/tracing/kprobe_events
启用事件和 ftrace:
cd /sys/kernel/debug/tracing
echo 1 > events/kprobes/myprobe/enable
echo 1 > tracing_on
查看跟踪结果:
cat trace
四、Kprobe 使用实例
4.1 编写简单的 Kprobes 探测模块
以下示例演示如何探测 do_sys_open 函数,获取其文件名和标志参数:
#include <linux/module.h>
#include <linux/kprobes.h>
#include <linux/sched.h>
// 定义一个计数器,用于统计函数被调用的次数
static int count = 0;
// pre_handler 回调函数,在被探测指令执行前被调用
static int handler_pre(struct kprobe *p, struct pt_regs *regs){
// 从寄存器中获取文件名和标志信息
char *filename = (char *)regs->di;
int flags = (int)regs->si;
// 打印函数调用信息,包括文件名和标志
printk(KERN_INFO "do_sys_open called with filename=%s, flags=%x\n", filename, flags);
// 计数器加一
count++;
return 0;
}
// 定义 kprobe 结构,指定要探测的函数为 do_sys_open,并关联 pre_handler 回调函数
static struct kprobe kp = {
.symbol_name = "do_sys_open",
.pre_handler = handler_pre,
};
// 模块初始化函数,用于注册 kprobe
static int __init mymodule_init(void){
int ret;
// 调用 register_kprobe 函数注册 kprobe
ret = register_kprobe(&kp);
if (ret < 0) {
// 如果注册失败,打印错误信息
printk(KERN_INFO "register_kprobe failed\n");
return ret;
}
// 如果注册成功,打印成功信息
printk(KERN_INFO "kprobe registered\n");
return 0;
}
// 模块退出函数,用于卸载 kprobe
static void __exit mymodule_exit(void){
// 调用 unregister_kprobe 函数卸载 kprobe
unregister_kprobe(&kp);
// 打印卸载信息,包括函数被调用的次数
printk(KERN_INFO "kprobe unregistered\n");
printk(KERN_INFO "do_sys_open called %d times\n", count);
}
// 声明模块初始化和退出函数
module_init(mymodule_init);
module_exit(mymodule_exit);
// 指定模块许可证为 GPL
MODULE_LICENSE("GPL");
4.2 基于 ftrace 使用 kprobe
(1)kprobe 配置:
内核需配置以下选项(在 .config 文件中):
CONFIG_KPROBES=y
CONFIG_OPTPROBES=y
CONFIG_KPROBES_ON_FTRACE=y
CONFIG_UPROBES=y
CONFIG_KRETPROBES=y
CONFIG_HAVE_KPROBES=y
CONFIG_HAVE_KRETPROBES=y
CONFIG_HAVE_OPTPROBES=y
CONFIG_HAVE_KPROBES_ON_FTRACE=y
CONFIG_KPROBE_EVENT=y
(2)kprobe trace events 使用:
相关文件节点:
/sys/kernel/debug/tracing/kprobe_events # 配置kprobe事件属性
/sys/kernel/debug/tracing/kprobe_profile # kprobe事件统计属性文件
/sys/kernel/debug/tracing/kprobes/<GRP>/<EVENT>/enabled # 使能事件
/sys/kernel/debug/tracing/kprobes/<GRP>/<EVENT>/filter # 过滤事件
/sys/kernel/debug/tracing/kprobes/<GRP>/<EVENT>/format # 查询事件显示格式
(3)kprobe 事件配置:
语法:
p[:[GRP/]EVENT] [MOD:]SYM[+offs]|MEMADDR [FETCHARGS] # 设置 probe 探测点
r[:[GRP/]EVENT] [MOD:]SYM[+0] [FETCHARGS] # 设置 return probe 探测点
-:[GRP/]EVENT # 删除一个探测点
参数详解:
GRP : Group name. If omitted, use "kprobes" for it.
EVENT : Event name. If omitted, the event name is generated based on SYM+offs or MEMADDR.
MOD : Module name which has given SYM.
SYM[+offs] : Symbol+offset where the probe is inserted.
MEMADDR : Address where the probe is inserted.
FETCHARGS : Arguments. Each probe can have up to 128 args.
%REG : Fetch register REG
@ADDR : Fetch memory at ADDR (ADDR should be in kernel)
@SYM[+|-offs] : Fetch memory at SYM +|- offs (SYM should be a data symbol)
$stackN : Fetch Nth entry of stack (N >= 0)
$stack : Fetch stack address.
$retval : Fetch return value.(*)
$comm : Fetch current task comm.
+|-offs(FETCHARG) : Fetch memory at FETCHARG +|- offs address.(**)
NAME=FETCHARG : Set NAME as the argument name of FETCHARG.
FETCHARG:TYPE : Set TYPE as the type of FETCHARG. Currently, basic types (u8/u16/u32/u64/s8/s16/s32/s64), hexadecimal types (x8/x16/x32/x64), "string" and bitfield are supported.
(*) only for return probe.
(**) this is useful for fetching a field of data structures.
配置示例(x86架构,寄存器%ax, %dx, %cx对应第1,2,3个参数):
echo ‘p:myprobe do_sys_open dfd=%ax filename=%dx flags=%cx mode=+4($stack)’ > /sys/kernel/debug/tracing/kprobe_events
echo ‘r:myretprobe do_sys_open ret=$retval’ >> /sys/kernel/debug/tracing/kprobe_events # 注意用 `>>`
# 删除事件
echo ‘-:myprobe’ >> /sys/kernel/debug/tracing/kprobe_events
echo ‘-:myretprobe’ >> /sys/kernel/debug/tracing/kprobe_events
(4)kprobe 使能:
echo > /sys/kernel/debug/tracing/trace
echo ‘p:myprobe do_sys_open dfd=%ax filename=%dx flags=%cx mode=+4($stack)’ > /sys/kernel/debug/tracing/kprobe_events
echo ‘r:myretprobe do_sys_open ret=$retval’ >> /sys/kernel/debug/tracing/kprobe_events
echo 1 > /sys/kernel/debug/tracing/events/kprobes/myprobe/enable
echo 1 > /sys/kernel/debug/tracing/events/kprobes/myretprobe/enable
# 执行一些操作,例如 ls
ls
echo 0 > /sys/kernel/debug/tracing/events/kprobes/myprobe/enable
echo 0 > /sys/kernel/debug/tracing/events/kprobes/myretprobe/enable
cat /sys/kernel/debug/tracing/trace
trace 文件输出示例(片段):
ls-18078 [005] .... 3542865.757950: myprobe: (do_sys_open+0x0/0x290) dfd=0xffffffffbd676460 filename=0x88000 flags=0x1 mode=0xc1b7bf48ffffffff
ls-18078 [005] d... 3542865.757953: myretprobe: (SyS_open+0x1e/0x20 <- do_sys_open) ret=0x3
(5)kprobe 事件过滤:
可以通过 filter 文件设置过滤条件,语法类似C语言表达式。
echo ‘filename==0x8241’ > /sys/kernel/debug/tracing/events/kprobes/myprobe/filter
(6)kprobe 和栈配合使用:
启用栈回溯:
echo stacktrace > /sys/kernel/debug/tracing/trace_options
(7)kprobe_profile 统计信息:
查看统计信息(命中与未命中次数):
cat /sys/kernel/debug/tracing/kprobe_profile
4.3 调试工具搭配使用
- 查看内核日志:使用
dmesg 命令查看 printk 输出的调试信息,例如 dmesg | grep “do_sys_open”。
- gdb调试器:在内核编译时开启调试信息,可用于更深入地分析问题(如结合kprobe发现的异常点进行源码级调试)。
4.4 常见问题与解决方法
- 探测点无法注册:确认目标函数是否存在(查内核源码或
/proc/kallsyms)、符号是否已导出。有时内核保护机制(如写保护)也可能导致失败,需谨慎处理。
- 回调函数未按预期执行:检查回调函数代码是否存在内存访问越界、空指针引用等错误。注意回调函数运行在中断上下文中,不能执行可能导致阻塞的操作(如睡眠)。如需复杂操作,应将其放入工作队列或内核线程。
掌握 kprobe 的使用,如同获得了一把深入Linux内核内部的万能钥匙。无论是用于性能剖析、故障诊断还是单纯的学习研究,它都能提供不可替代的价值。希望本文的详细介绍和实例能帮助你在实际开发运维中更好地运用这一强大工具。如果在实践中遇到更多有趣的问题或用法,欢迎在云栈社区交流分享。