那次做现场问题排查,我是真被逼到墙角了:用户态日志、strace、perf,能看的都看了一遍,问题明明已经锁到内核里的某个函数,却拿不到它的入参和返回结果。文件句柄异常关闭的case,ceph日志每一条都正常,可fd就是在某个路径被提前关掉了。最后我只好把kprobe和kretprobe这两套机制从内核文档里搬出来,一个挂在入口抓参数,一个挂在出口抓返回值,才算把整条调用链拼完整。
这个经历之后,我基本养成了一个习惯:凡是遇到“用户态正常、内核态行为像黑盒”的问题,先别急着上gdb内核或者打补丁,kprobe系列是成本最低的入场方式。这篇文章我打算用两个实际可跑的例子,把kprobe和kretprobe从“怎么用”讲到“为什么是这样工作”,顺带把我踩过的坑也交代清楚。
1. 一段排障经历:kprobe在什么时候必须出手
先说说为什么会有kprobe这玩意。内核里函数那么多,并不是每个函数都适合加trace printk,也不是每次崩溃都能用crash工具还原现场。很多时候你就是想知道“某个函数被谁调了、入参是什么、返回值是什么”,这种需求用传统方式几乎无解。
kprobe的出现就是为了解决这个“无解”。它的核心能力是:在内核任意指令地址上动态插一个探测点,当CPU执行到这个地方时,跳到你注册的处理函数里跑一段逻辑,然后原样恢复继续执行。kretprobe则是它的孪生兄弟,专门处理函数返回那一刻的逻辑,让你能拿到返回值。两者配合起来,相当于给内核函数装了一个“进门计数器”和“出门扫描仪”。
1.1 这类问题为什么难查
我遇到的fd异常关闭case难在“两头对不上”。用户态认为某个fd还存在,内核里它已经被调用了close。如果你怀疑是某个驱动或者某个中间层的问题,但又不知道具体函数路径,就只能盲猜。函数tracepoint不是每个函数都有,ftrace的function_graph能看出调用关系,但看不到参数细节。perf probe倒是能在函数上动态插桩,可它的输出字段有限,想要完整拿到入参和返回值,尤其是指针指向的实际内容,还是得回到kprobe体系。
再一个痛点是复现窗口极短。问题可能一天就出现两三次,每次就几毫秒。你不可能24小时盯着dmesg手工敲命令,更不可能提前在系统里埋好printk等它发生。kprobe的价值就在于可以随时挂、随时摘,不影响系统主流程,而且可以把关键信息实时打到trace buffer里,触发条件完全由内核执行流决定。
1.2 从kprobe到kretprobe的追踪链路
打个比方,函数调用就像坐地铁进闸机。kprobe的pre_handler是你在闸机门口装的一个摄像头,记录每一个人进去时手上拿的东西。kretprobe的ret_handler则是闸机出口另一个摄像头,只负责记录每个人出来时带走了什么。只有入口和出口两个摄像头都有,你才算真正掌握了这段路程里发生了什么变化。单独装一个入口摄像头,只能知道有个人进来了,但不知道他出来时是不是变成了另一个人。
在调试链路里,我的习惯是先挂kprobe确认函数被调用且入参符合预期,再挂kretprobe确认返回路径是否异常。如果入口正常但出口结果不对,问题多半在函数内部或它调用的子函数里;如果入口参数就不对,那问题出在上游调用者,需要继续往调用栈上层排查。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. kprobe的原理:一条断点指令怎么变成一次回调
很多文档会把kprobe描述成“动态插桩”,但插桩具体是怎么实现的,以及为什么要有那么多限制,并不好理解。我尽可能用通俗但准确的方式把它拆开讲。
2.1 指令替换、异常处理和单步执行
在x86_64架构下,kprobe最常见的实现方式是把目标指令的前几个字节替换成一条断点指令INT3。原来的指令先被搬到一块叫做“kprobe草稿区”的暂存内存里,等探测流程结束再放回去执行。CPU跑到断点时触发#BP异常,内核的异常处理入口被调用,异常链最终找到do_int3,再由kprobe机制判断这个地址是不是自己的探测点。
命中探测点之后,内核会把当前完整的寄存器上下文保存到pt_regs结构里,然后调用你注册的pre_handler。pre_handler跑完,内核会设置单步标志(TF),让被搬走的原指令在草稿区以单步模式执行一遍,恢复它本来该有的副作用,最后再清掉单步标志,让指令流回到正常位置继续往下跑。
值得强调的是,kprobe不是每时每刻都在单步。它默认就喜欢在函数入口插桩,因为函数入口都是“冷”的,不会同时被很多CPU并发执行。但如果你对函数内部的某个指令地址插桩,那就得面对指令流替换带来的并发和缓存问题,这也是为什么kprobe官方不鼓励对热路径上的中间指令做探测。早期还有个优化版本叫optprobe,通过相对跳转指令把原指令搬到暂存区,减少单步带来的性能损失,但它的前提是跳转距离和指令布局满足约束条件。理解这一点对后面看性能数据很有帮助。
2.2 同地址互斥与可优化探针的约束
同一时间,一个地址只能挂一个kprobe。如果你尝试重复注册同一个符号,内核会返回错误。实际排查中经常遇到“我明明挂上了,为什么没触发”的情况,很多时候不是探针没触发,而是你注册的那个符号本身就没被调用,或者被另一个机制占了。
这里要注意“同一地址互斥”并不等于“同一函数互斥”。一个函数可能有多个指令地址,用kprobe挂函数名和用kprobe挂函数中间某条指令,在kprobe看来是两个不同的地址。不过kretprobe会特殊一些,它的入口探测地址通常就是函数入口,所以另一类kprobe如果在同一个入口插入,注册阶段就会冲突。
2.3 kretprobe把返回地址“掉包”了
kretprobe的实现比kprobe复杂一个层次。它也需要先在函数入口放置一个kprobe断点,但这个断点触发的不是普通pre_handler,而是kretprobe内部专门准备的entry_handler。entry_handler做的事情第一眼看起来很“作弊”:它把当前函数栈上的返回地址读出来,保存到一个kretprobe_instance结构里,然后偷偷把返回地址替换成kretprobe_trampoline这个蹦床函数的地址。
函数继续执行完自己的逻辑后,ret指令会照常弹出栈上的地址,弹出的却是蹦床函数的地址。于是CPU会跳到蹦床里,kretprobe利用这个时机执行你注册的ret_handler,通过instance里的记录还原原始返回地址,再把控制权转移回真实调用者。整个过程对函数内部是透明的,函数本身完全不知道自己的返回地址被掉包过。
这种方式有一个天然风险:函数返回前如果没有正常返回到蹦床,例如函数里直接调用do_exit导致进程死亡,或者函数陷入死循环不返回,那么ret_handler就跑不到。maxactive这个参数就是限制同时存活的kretprobe_instance数量,避免在高并发场景下内存被撑爆,或者嵌套探测引发不确定行为。默认值通常是20,生产环境我会根据并发度手动调大。
3. 第一个实战:用tracefs在sys_openat入口和出口挂探针
不用写代码,最快体验kprobe和kretprobe的方式是tracefs里的kprobe_events接口。这也是我每次排查问题时先做的最快验证手段。
3.1 准备工作:确认符号是否存在
不同内核版本里,openat系统调用对应的内核函数符号不一样。老一点的内核可能是do_sys_open,新内核可能是__x64_sys_openat或者do_sys_openat2。省心做法是先查一下:
bash复制grep -E ' do_sys_open(at2)?$| __x64_sys_openat$| __arm64_sys_openat$' /proc/kallsyms
如果你用的是普通用户或者系统开了kptr_restrict,可能看到的地址全是0。这不影响挂探针,只要内核允许你用kprobe,符号名解析就行。真正会影响的是能否从用户态直接读trace输出,一般需要root权限。
确认符号之后,把tracefs挂载好:
bash复制mount -t tracefs tracefs /sys/kernel/tracing
# 老内核也常见:
# mount -t debugfs debugfs /sys/kernel/debug
挂载后进入/sys/kernel/tracing,去看有没有kprobe_events文件。有就继续,说明内核打开了CONFIG_KPROBE_EVENTS。如果没有,你需要带root重新编译内核或者检查发行版是否裁剪了这个功能。
3.2 创建kprobe事件并抓入口参数
我以do_sys_openat2为例,假设它在当前内核里作为openat最终处理函数被调用。创建kprobe事件用p开头,事件名自己起,后面跟符号和字段:
bash复制cd /sys/kernel/tracing
echo 'p:kprobe_open do_sys_openat2 dfd=$arg1 filename=$arg2 flags=$arg3' > kprobe_events
echo 1 > events/kprobes/kprobe_open/enable
$arg1这种写法是让kprobe事件解析器自动按体系结构调用规约取参数。在x86_64下,$arg1对应rdi,$arg2对应rsi,$arg3对应rdx,依此类推。如果你内核版本较老,可能不支持$argN,可以直接改用寄存器名:
bash复制echo 'p:kprobe_open do_sys_openat2 dfd=%di filename=%si flags=%dx' > kprobe_events
开启之后随便做一个open动作,比如cat一个文件,然后读trace:
bash复制cat trace
输出大致长这样:
code复制# tracer: nop
# |# __________
# |# | | |
cat-12345 [001] .... 1234.567890: kprobe_open: (do_sys_openat2+0x0/0x20) dfd=0xffffff9c filename=0x7ffeabc12340 flags=0x0
注意filename和flags之间没有逗号,那是fetch字段的原始打印格式。filename打印的是用户态指针值,想知道它指向的字符串,可以在tracefs里用“+0(%si):string”这种增强字段,把用户空间字符串直接取出来:
bash复制echo 'p:kprobe_open2 do_sys_openat2 dfd=$arg1 filename=+0($arg2):string flags=$arg3' > kprobe_events
这个用法非常实用。系统在kprobe事件里内置了string类型,会自动帮你去用户态拷贝字符串,省得在回调里写copy_from_user。不过也正因为它要做内存拷贝,开销会比单纯打印寄存器大一点。
3.3 用kretprobe事件抓返回值
kretprobe事件用r开头,它能直接引用返回寄存器:
bash复制echo 'r:kret_open do_sys_openat2 ret=$retval' > kprobe_events
echo 1 > events/kprobes/kret_open/enable
同样是cat一个文件,trace输出会多出返回事件:
code复制 cat-12345 [001] .... 1234.567891: kret_open: (do_sys_openat2+0x0/0x20 <- ret_from_intr) ret=0x5
ret=0x5说明open成功返回fd=5。如果返回负数,那多半是错误码。把入口事件和出口事件同时开着时,你能清楚看到每一次进入是否都有对应的返回。如果某次进入没有返回事件,就要怀疑函数是否在内核态卡住或者被信号打断了。
排查结束后关闭事件:
bash复制echo 0 > events/kprobes/kret_open/enable
echo 0 > events/kprobes/kprobe_open/enable
4. 写内核模块kretprobe实例:把原理翻到明面上
tracefs适合快速抓取,但它把太多细节包装掉了。如果想要真正理解kretprobe的instance生命周期,或者想对多个函数做复杂逻辑处理,还是得写一个小内核模块。穷人在我这里带了真实代码,你可以直接编译跑。
4.1 从register_kretprobe开始的一段完整代码
新建probe_demo.c,内容如下:
c复制#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/kprobes.h>
#include <linux/fs.h>
#include <linux/sched.h>
#include <linux/slab.h>
static int demo_entry_handler(struct kretprobe_instance *ri, struct pt_regs *regs)
{
pr_info("%s enter do_sys_openat2: pid=%d dfd=%ld filename=%ld flags=%ld\n",
current->comm, current->pid,
(long)regs->di, (long)regs->si, (long)regs->dx);
return 0;
}
static int demo_ret_handler(struct kretprobe_instance *ri, struct pt_regs *regs)
{
pr_info("%s return from do_sys_openat2: pid=%d ret=%ld\n",
current->comm, current->pid, (long)regs_return_value(regs));
return 0;
}
static struct kretprobe demo_krp = {
.handler = demo_ret_handler,
.entry_handler = demo_entry_handler,
.maxactive = 32,
.data_size = 0,
.kp.symbol_name = "do_sys_openat2",
};
static int __init demo_init(void)
{
int ret = register_kretprobe(&demo_krp);
if (ret < 0) {
pr_info("register_kretprobe failed: %d\n", ret);
return ret;
}
pr_info("kretprobe registered at %p\n", demo_krp.kp.addr);
return 0;
}
static void __exit demo_exit(void)
{
unregister_kretprobe(&demo_krp);
pr_info("kretprobe unregistered\n");
}
module_init(demo_init);
module_exit(demo_exit);
MODULE_LICENSE("GPL");
Makefile就四行:
makefile复制obj-m += probe_demo.o
KDIR := /lib/modules/$(shell uname -r)/build
all:
make -C $(KDIR) M=$(PWD) modules
clean:
make -C $(KDIR) M=$(PWD) clean
编译、加载、看输出:
bash复制make
insmod probe_demo.ko
dmesg | tail -20
你在别的终端执行任意open操作之后,dmesg里就能看到两行成对出现的消息。entry_handler里的regs->di、si、dx分别是x86_64调用约定下的前三个参数。如果你在ARM64上跑,寄存器名要换成regs->regs[0]、regs->regs[1]等,这就是pt_regs跨架构无法通用的痛点。
4.2 从module到实际触发时,内部发生了什么
register_kretprobe里,内核会为你在.kp.symbol_name对应的地址上注册一个kprobe。这个kprobe的内部pre_handler会指向kretprobe自己准备好的入口逻辑。当目标函数入口被命中时,kretprobe内部会从预分配的内存池里拿一个kretprobe_instance,记录当前栈顶的返回地址,然后把返回地址改成蹦床。之后函数正常执行,ret指令跳到蹦床,蹦床逻辑再调用demo_ret_handler。
这里要特别说明data_size字段:它允许你在每个kretprobe_instance后面附带一块私有数据,用于在entry和ret之间传递信息。比如entry时记录当前时间戳或请求队列号,ret时取出来分析耗时。data_size设0虽然能用,但你在真实场景里往往需要它。用的时候注意它是按字节算的,内核不会管你往里面放了什么。
maxactive决定一次性最多能有多少个实例。假设你同时有100个CPU线程都在调用do_sys_openat2,而maxactive只有32,那么剩下68个调用可能不会触发ret_handler,或者干脆不会触发entry回调。发生这种情况时,kretprobe会在内核日志里打一条missed的提示。我在压测环境下经常用这个计数来判断是不是实例池不够大。
4.3 dmesg和tracefs怎么选
我建议你初学阶段,模块里的输出用pr_info打到dmesg,逻辑最简单。但dmesg有一个致命缺点:printk本身的开销很大,而且在内核路径里printk可能影响被测函数的时序,导致“探针先把自己干翻了”。
更推荐的做法是在模块里直接操作tracefs,或者干脆回归到第3节的kprobe_events,把trace输出交给内核的ring buffer来管理。我的习惯是:先用tracefs验证函数被调用的基本事实,再写模块做更复杂的统计逻辑,最终用perf或者BPF工具固化下来作为监控手段。
5. 探针失灵排查链路:符号、冲突和你看不见的递归
现在说重点。我敢说每个用过kprobe的人都会遇到“探针挂上了但不触发”的状况。这个现象背后的原因,通常逃不出下面几类。
5.1 符号解析失败与ftrace冲突
第一步永远是验证symbol_name解析出来的地址,以及当前函数是否已经在别的探针体系里。我遇到过最蠢也是最多的一次,是想挂do_sys_openat2,但当前内核里根本没有这个符号,系统里只有do_filp_open。虽然insmod没报错,kprobe的register直接返回-EINVAL。
还有一类是ftrace冲突。内核如果开启了CONFIG_FTRACE,很多函数的入口可能已经被ftrace机制接管。注册kprobe不是都能成功,比如某些函数被标记为“黑名单”,上面已经说了不让kprobe碰。查看方式:
bash复制cat /sys/kernel/debug/kprobes/blacklist
如果函数在里面,你就得换一个真正的子函数来挂,或者考虑用ftrace的接口而不是kprobe。另一个实用文件是:
bash复制cat /sys/kernel/debug/kprobes/list
这个文件会把当前已注册的所有kprobe/kretprobe列出来,包含地址、符号名、状态。如果这里没有你注册的那条记录,说明register阶段就失败了,先去dmesg看红色错误码。
5.2 探针触发了,但输出顺序和内容看着不对
如果你在pre_handler和ret_handler里都用了printk,会发现前缀信息经常是乱序的。这不是探针的问题,而是printk消息经过锁、缓冲、控制台刷新多个环节后,顺序不能保证和调用顺序一致。想要严格按顺序记录探针之间的关联数据,强烈建议把数据写进trace buffer的TRACE_MARKER接口,或者干脆用kprobe_events事件,它们内部维护的ring buffer在单核路径上基本保序。
有一次我在pre_handler里尝试打印当前进程的struct file指针,然后用%pD去解它指向的文件名,结果发现解出来的是乱码。这个坑的根源是我传错了对象:%pD期望的是struct file*,而不是用户态路径的char*。在内核回调里判断参数时,一定要确认参数原始类型。遇到用户态指针,用kprobe_events的string fetch字段,或者在自己的代码里安全地使用copy_from_user,不要想当然。
5.3 递归与重复注册
kretprobe还有一个隐蔽的坑:如果你的ret_handler里又触发了同一个被探测函数,就可能形成递归调用。内核里kprobe机制自带简单的递归保护,但它的保护有深度上限,不是无限防递归。最典型的情况是你在handler里调用了某个会打开文件的辅助函数,而这个辅助函数内部又走了do_sys_openat2,那新的kprobe又会被触发,形成嵌套。短时间看没问题,但嵌套加深到内核栈挤爆就瞬间panic。
我的规则是:kprobe回调里坚决不做复杂操作,尤其不能直接触发I/O、打开文件、调用不熟悉的辅助函数。所有数据当场拷贝到简单缓冲区,记录好时间戳和关键字段,回调返回后再在外部处理。这条规矩救过我很多次。
6. 性能开销、并发安全与选型边界
最后落到实际问题:用了会不会把线上搞挂?以及到底该在什么时候选kprobe,什么时候应该选别的。
6.1 一次kprobe触发到底会带来多少开销
按我的经验,一次普通kprobe触发的开销主要分三块:断点异常陷入、pre_handler执行、单步恢复。如果只是打印几个寄存器值,不计trace输出写盘的话,单次开销大约在几百纳秒到一两微秒量级。看起来不大,但如果你在对端网络收包路径这种千万次每秒的热路径上挂探针,整个服务吞吐能掉一截。kretprobe更重一些,它多了一次蹦床跳转和instance分配。
用下面的表做一个简单的对比判断:
| 机制 | 适用函数覆盖 | 主要开销 | 是否可见返回 |
|---|---|---|---|
| tracepoint/ftrace | 仅已埋点函数 | 相对较低 | ftrace function_graph可见返回,但无参数提取 |
| kprobe | 任意函数入口/地址 | 断点+单步 | 无 |
| kretprobe | 任意函数入口 | kprobe入口开销+蹦床 | 有 |
| uprobe | 用户态函数/地址 | 类似kprobe | 无,需配合uretprobe |
| BPF | 绑定以上机制 | 动态编译,通常更可控 | 按需 |
在生产环境,我基本不开常驻kprobe。需要排查时开,抓完立即关。如果这个函数调用频率很高,我优先切到BPF的kprobe attach方式,让handler在BPF验证器可控的沙箱里跑,然后通过perf event ring buffer输出,对业务干扰会小很多。
6.2 回调里的并发和锁问题
kprobe回调是在中断或异常上下文里执行的,这意味着一件事:你不能在里面随便拿普通信号量,更不能调用可能睡眠的函数。复制这个限制的常用说法是“只能在原子上下文里做原子操作”。用spinlock都有风险,因为如果断点本身触发在处理器的异常栈上,锁顺序稍有不对就是死锁。
多个CPU可能同时命中同一个探针,所以你在回调里访问的全局变量一定要考虑并发。我用得最多的方案是per-CPU变量加无锁追加,所有CPU各写各的buff,事后统一汇总。这既避免了锁竞争,也不会在回调路径上引入睡眠点。
6.3 什么时候答案不是kprobe
kprobe确实是万能工具,但“万能”不等于“正确”。如果是内核自身已经暴露了tracepoint,我一定优先用tracepoint,因为tracepoint的开销是编译期设计好的,比运行时改指令更稳定。如果你只是想知道函数的调用栈,那function_graph配合tracefs就够了,没必要上kprobe。如果目标是长期监控、数据要在用户态用Python/Go消费,BPF是更稳妥的工程化方向。kprobe适合的场景是快速判断一个函数是否被调用、调用参数是否符合预期、返回值是否有异常,以及在没有更合适机制时的临时兜底。
我个人最常用的一个确认技巧,放最后分享给你:想确认某个内核函数是否被外部路径调用,先连tracefs挂一个只有计数没有字段的kprobe事件,开一个定时读取事件统计的循环,然后由外部触发相应的业务动作。看事件计数变化比盯dmesg精准得多,也比写模块模块快得多。等你确认了这个函数的调用频率和触发条件,再决定要不要写正式探针模块或者上BPF,这才是kprobe系列最合理的打开方式。
