register_kretprobe实战:把iowait抓到进程级

发布时间:2026/9/29 15:23:41
register_kretprobe实战:把iowait抓到进程级
如果有人拿着我以前写的那份 iowait 抓取脚本问我能不能查出到底是哪个进程在等 IO我大概率只能摇头。/proc/stat里的 iowait 字段顶多告诉你系统整体有多少 CPU 时间耗在了等待 IO 完成上是谁引起的、等了多久完全看不出来。后来我被逼着把目光从用户态挪进内核翻到 kprobes 一族的接口才发现正确的打开方式叫register_kretprobe——一个专门把探针挂在函数返回点上的注册接口。这篇就讲我实际用register_kretprobe改造一个 iowait 抓取程序的完整过程包括接口怎么用、回调怎么写以及哪些坑跳进去真的会疼。1. 探针打在函数“进入点”和“返回点”结果天差地别1.1 kprobe 能看到“进门”却抓不到“等待”kprobe 的原理很直接在目标函数入口放一条断点指令执行到那里 CPU 陷入异常内核调用你注册的 pre_handler处理完再恢复单步执行。它适合回答“这个函数有没有被调用、进来时参数是什么”这类问题。但对于“等待”类问题kprobe 基本无能为力。进程等 IO 的语义是我调用io_schedule()然后睡在那里直到 IO 完成被唤醒才从函数里出来。真正的等待时间发生在“进去之后”和“回来之前”这段区间而 kprobe 只看到进门那一刻看不到出门。所以想测等待时长必须能在函数返回时再被敲一次。这也就是我从“抓 iowait”变成“研究 kretprobe”的直接原因。老程序只关心系统整体 iowait随便看看还行一旦要定位到进程没有返回探针就是空中楼阁。1.2 kretprobe 内部到底做了什么register_kretprobe其实就是帮你在函数入口放一个 kprobe同时偷偷做了一件额外的事把函数原本的返回地址替换成一个 trampoline不同内核版本实现细节不一样x86_64 上一般和 RIP 相对跳转相关。函数正常执行到 ret 时会先跳到这个 trampoline由 trampoline 负责调用你注册的返回回调然后再把控制权交还给原始返回地址。理解了这一点后面很多事情就顺了入口回调entry_handler和返回回调handler是成对出现的但入口并非必须。你完全可以只注册一个handler只关心返回那一刻的状态。因为返回地址被劫持过内核必须为每一次“还没返回的调用”保留一份实例instance用来存原始返回地址和你要存的私有数据。这就是maxactive字段的由来。返回回调拿到的pt_regs是函数返回那一刻的寄存器现场不是你进门时拍下来的参数现场。想拿到参数必须在入口回调里先保存一份。这个“入口保存、返回取用”的模型是我后面改造 iowait 程序时真正受益的地方。2. register_kretprobe 的用法一个结构体两个回调2.1 注册前该填哪些字段先看struct kretprobe的几个关键字段字段作用我的建议kp.symbol_name要探测的函数名先grep一下/proc/kallsyms确认存在entry_handler函数入口触发可空需要时间戳或参数时就必填handler函数返回触发核心回调至少写一个maxactive允许并发未返回的最大实例数先 256看nmissed再调data_size每个实例分配的私有数据大小只存时间戳就 8 字节nmissed因实例耗尽丢掉的探测次数只读属性注销后用它校验kp.symbol_name是挂在struct kprobe里的这个不用多想给函数名字符串就行。register_kretprobe()返回 0 代表成功负数代表失败最常见的失败原因是符号找不到。2.2 入口回调和返回回调怎么分工两个回调的签名一致typedef int (*kretprobe_handler_t)(struct kretprobe_instance *ri, struct pt_regs *regs);入口回调返回 0 表示继续返回非 0 表示你不想再管这次调用的后续。返回回调在函数真正返回时触发要读函数返回值用regs_return_value(regs)在 x86_64 上本质就是regs-regs[0]。ri-data是每次调用独立的数据区大小就是注册时指定的data_size。入口回调写、返回回调读天然按调用实例隔离不需要自己加锁。它不会自动清零所以每次入口都要完整初始化别指望内核帮你擦干净。2.3 一段最小可编译模块我用来验证思路的最小模块长这样// SPDX-License-Identifier: GPL-2.0 #include linux/kernel.h #include linux/module.h #include linux/kprobes.h #include linux/ktime.h struct io_wait_sample { ktime_t begin; }; static struct kretprobe io_sched_rp; static int io_sched_entry(struct kretprobe_instance *ri, struct pt_regs *regs) { struct io_wait_sample *s (struct io_wait_sample *)ri-data; s-begin ktime_get(); return 0; } static int io_sched_ret(struct kretprobe_instance *ri, struct pt_regs *regs) { struct io_wait_sample *s (struct io_wait_sample *)ri-data; s64 us ktime_us_delta(ktime_get(), s-begin); if (us 1000) { pr_info(iowait pid%d comm%s delta_us%lld\n, current-pid, current-comm, us); } return 0; } static int __init iowait_krp_init(void) { io_sched_rp.kp.symbol_name io_schedule; io_sched_rp.entry_handler io_sched_entry; io_sched_rp.handler io_sched_ret; io_sched_rp.maxactive 256; io_sched_rp.data_size sizeof(struct io_wait_sample); if (register_kretprobe(io_sched_rp) 0) return -EINVAL; pr_info(iowait kretprobe registered\n); return 0; } static void __exit iowait_krp_exit(void) { unregister_kretprobe(io_sched_rp); pr_info(iowait kretprobe unregistered, missed%d\n, io_sched_rp.nmissed); } module_init(iowait_krp_init); module_exit(iowait_krp_exit); MODULE_LICENSE(GPL);insmod之后随便跑个会产生 IO 等待的任务dmesg里就会出现类似这样的行iowait pid1024 commdd delta_us48213这个模块就是整个 iowait 抓取程序改进版的核心骨架。3. 抓 iowait 的老程序差在哪register_kretprobe 改了什么3.1 老程序的三个短视我最早那套抓 iowait 的方式非常“经典”定时去读/proc/stat算两次采样之间iowait字段的差值。这件事有几个天生缺陷只有系统级口径。/proc/stat的 iowait 是整机每个 CPU 的汇总值看不到具体是哪个进程在等 IO。时间粒度粗。采样间隔至少 1 秒间隔内可能发生几十次上百次等待算出来的只是平均值拿不到等待时长的长尾分布。口径和进程迁移耦合。CPU 级的 iowait 计数跟着 CPU 走进程在不同 CPU 之间来回切换后你想把等待时间归因到某个进程基本是做梦。老程序拿来汇报“今天系统 IO 重不重”还行想回答“哪个进程害得存储这么忙”一个字懵。3.2 新方案把“等待区间”直接量出来改进思路很简单既然 iowait 的本质是进程卡在 io 等待上那就直接测“进入io_schedule()到从它返回”的驻留时间。入口记时间戳返回算差值顺手把current-pid和current-comm一起打出来。io_schedule()是内核里进程因 IO 进入不可中断睡眠的主要路径之一。除了它还有一个带超时版本的io_schedule_timeout()如果目标场景里有人用超时等待最好也挂一个 kretprobestatic struct kretprobe io_sched_timeout_rp; /* 初始化时设置 kp.symbol_name io_schedule_timeout */两个 kretprobe 共用同样的入口/返回回调逻辑即可data_size不变数据结构也不用改。注意一个容易搞混的地方任务级io_schedule()驻留时长和/proc/stat里的 CPU iowait 并不是同一个口径。后者只在 CPU 空闲且存在 IO 等待时累加如果一个进程在等 IO但 CPU 上还有别的可运行任务这个 CPU 的 iowait 并不增加。所以改造之后别拿新数据和老数据硬做绝对值对比方向一致就说明没有抓偏。3.3 改进前后的对比对比项老程序kretprobe 程序数据源/proc/stat轮询io_schedule入口/返回探针粒度整机 CPU、秒级单次调用、单任务能否归因到进程不能能pid/comm 直接拿到等待分布无法得到可以聚合成直方图或累计量额外开销轮询读文件每次 IO 等待多两次ktime_get对我来说最大的提升不是数字更精确而是终于能把“某某进程在某次 IO 等待里卡了多久”变成一条条可审计的记录。4. 改进过程中踩过的坑4.1 maxactive 不够大nmissed 悄悄变红kretprobe 的每个 in-flight 调用都要占用一个 instance用于保存原始返回地址和ri-data。如果并发未返回的io_schedule()调用数超过maxactive多余调用会被跳过计数器nmissed自动累加。在负载不高的机器上maxactive 256通常够用。但如果目标机器是那种存储高并发的场景几十个线程同时卡在 IO 等待上很常见这时候我建议先跑一段时间卸载时打印nmissed观察insmod iowait_krp.ko # 跑一段时间负载 rmmod iowait_krp # dmesg 里看 iowait kretprobe unregistered, missed...如果missed不为 0说明探针丢事件了把maxactive调大再试。不过也别无脑调太大每个 instance 都带一块data_size的私有内存数量过多也是浪费。4.2 ri-data 不是无限储物柜很多人第一次写 kretprobe 都容易犯一个毛病把入口处拿到的参数指针直接存下来留着到返回回调里用。这个思路在 kretprobe 里很危险因为返回回调发生时那个指针指向的栈或对象可能早已失效。正确做法是把你需要的值拷贝进ri-data。比如我只存ktime_t begin就是一个 8 字节的值拷贝安全又省事。如果你的场景还需要保存入口参数那就把data_size扩大在入口回调里顺手存一个拷贝别只存地址。4.3 返回回调里不要做sleep性质的操作kretprobe 的返回回调运行环境比一般进程上下文要谨慎不少可能在中断/软中断底半部也可能是抢占被禁用的路径。在这种上下文里调用msleep()、vmalloc()、kmalloc(GFP_KERNEL)这类可能睡眠或睡觉的操作不是闹着玩的。我一开始习惯在回调里做复杂统计后来模块动不动就挂。现在的守则是回调里只做纯计算和时间戳最多用printk_ratelimited打日志聚合工作全部丢到用户态或者 per-cpu 计数器里。4.4 probe 不上怎么办先查符号表不是所有函数都能 kretprobe。内核里有些函数被标记了notrace有些恰好位于__kprobes保护区段探针会拒绝挂载。io_schedule这个函数经验上能挂但如果你要换成其它函数建议先确认一下grep io_schedule /proc/kallsyms查得到名字多半就能挂查不到或者显示地址全是 0先去看内核配置有些符号需要CONFIG_KALLSYMS_ALL才有。注册失败也别慌先怀疑符号再怀疑模块参数最后怀疑maxactive设置。4.5 并发注销时留个心眼unregister_kretprobe()会把探针摘掉理论上之后不会再触发新回调。但在多核机器上某个 CPU 可能正执行到返回回调的半路你这边模块就被卸载了。稳妥的做法是先停止产生 IO 等待的应用等几秒再rmmod。别在真业务高峰期直接卸载模块这种“看起来很干净”的操作最容易出诡异问题。5. 验证效果和后续还能怎么改5.1 用可控负载验证是否真的抓到 iowait模块加载之后我习惯用一个同步 IO 负载制造可预期的等待。比如找一个不会被 page cache 完全吞掉的目标跑fio --ioenginesync --rwread --bs64k --size256M \ --nameiowait-test --filename/dev/sdb注意这里最好选一个真实的块设备或者比较慢的存储路径否则数据都在 page cache 里根本走不到io_schedule()。跑的过程中看dmesg应该能持续刷出iowait pid... delta_us...的记录。然后快速瞄一眼/proc/stat的 iowait 字段两条曲线方向一致就说明探针没抓偏。绝对值对不上很正常原因前面讲过了一个测的是 CPU 闲置被 IO 拉住的时段一个测的是任务实际睡在 IO 上的时长两个口径本来就不等价。5.2 从“看时长”到“看分布和归因”基础版能打印单次等待时长之后下一步我会建议这么扩展用 pid 做 key把每次delta_us累加到 per-task 计数器里模块退出时 dump 出“谁贡献了最多 IO 等待”。把等待时长分桶比如 0-1ms、1-10ms、10-100ms、100ms一眼看出是平均慢还是长尾慢。再叠加一个submit_bio_noacct或blk_mq_make_request的 kretprobe统计 IO 从下发到完成的时间结合io_schedule的驻留时长就能把“等待调度”和“等待完成”拆开看。如果只是想快速验证某个函数能不能抓、抓到的数据长什么样也可以先用 bpftrace 挂一行的 kretprobe 探探路确认链路没问题再回来写正式内核模块。bpftrace 的探针语义和register_kretprobe一致差别只是管理方式不影响排查思路。最后说点个人体会把抓 iowait 的程序从/proc/stat口径改成 kretprobe 口径之后我最常看的其实不是平均值而是长尾。一次线上卡顿背后往往就是那么一两笔几百毫秒级别的io_schedule()驻留。maxactive调大一点确实能少丢采样但最坑的地方从来都在返回回调里手贱写了慢操作。我现在的习惯是回调里只放计数器和时间戳聚合一律丢给用户态。这个习惯帮我少挂了不知道多少次模块也顺带把抓 iowait 这个工具从“看热闹”变成了“看门道”。