1. 从一次线上排查说起为什么要动 read 系统调用几年前我接手过一个挺棘手的问题一台跑着自研采集程序的生产服务器磁盘 I/O 时不时飙到 100%但业务侧的日志里却看不到任何异常请求。用iostat看r/s和rkB/s都不高可%util就是压不下去。折腾了大半天最后用strace跟了一下进程才发现有个后台模块在疯狂地对一个已经 EOF 的文件描述符反复调用read每次返回 0但调用方没做判断直接死循环。问题本身不复杂可它让我意识到一件事read 这个看起来最普通的系统调用恰恰是最容易被忽视、也最值得动手 hook 的入口之一。所谓 hook 系统调用说白了就是在内核真正执行某个系统调用之前或之后插入我们自己的逻辑。对read下手能做的事情非常多统计某个进程到底读了多少字节、拦截对敏感文件的读取、给文件访问做审计、在容器场景里做 I/O 限流、甚至单纯为了调试某个第三方库到底在读什么。它不像改业务代码那样需要源码也不像纯用户态LD_PRELOAD那样容易被静态链接绕过属于卡在咽喉位置的一种手段。这篇内容适合谁看如果你已经写过内核模块、编译过内核、对sys_call_table、kprobe、tracepoint这些词不陌生那读起来会很顺如果你只是听说过 hook 但没真正动过手我也会把每一步的动机和坑讲清楚让你能照着复现。核心会围绕 Linux 内核里 hookread的几条主流路线展开重点讲清楚为什么选这条路、每条路的边界在哪、实测中会撞上什么。需要提前说明的是下面涉及的内核代码都以较新的 5.x/6.x 为背景老内核比如 2.6/3.x在符号导出和 API 上有差异我会在对应位置点出来。2. 动手之前先想清楚hook read 到底有哪几条路很多人一上来就问怎么 hook其实更该先问我该用哪种方式 hook。Linux 内核给出来的口子不止一个选错了轻则白干重则把机器搞挂。我按侵入性和稳定性从低到高排一下你可以对照自己的场景挑。2.1 用户态 LD_PRELOAD最轻但最容易漏最省事的做法是在用户态拦截read的 libc 封装。写一个共享库定义同名read函数通过LD_PRELOAD抢先加载内部再dlsym(RTLD_NEXT, read)拿到真正的实现。优点是零内核风险、调试方便、改完即生效。但它的问题也很致命只对动态链接 libc 的程序有效。静态链接的二进制、Go 这种自己走 syscall 的语言、直接内联syscall指令的代码统统绕过你。而且它拦的是 libc 的read不是内核的syscall语义上有偏差。所以如果你的目标是审计所有进程的文件读取这条路直接排除。2.2 修改 sys_call_table经典但越来越难走这是很多老教程里的标准答案找到sys_call_table的地址把__x64_sys_read这一项替换成自己的函数保存原函数指针在自己的函数里做逻辑再调用原函数。原理直观控制力强能拿到完整的pt_regs。麻烦在于从内核 5.7 左右开始sys_call_table不再默认导出给模块使用kallsyms_lookup_name也被取消导出。你得靠 kprobe 去钓地址或者读/proc/kallsyms需要 root 且kptr_restrict放开。更关键的是写sys_call_table所在的内存页需要先关掉写保护清 CR0 的 WP 位这个操作在多核上是危险的稍有不慎就是整机崩溃。所以这条路能用但我不推荐在生产环境用。2.3 kprobe目前最推荐的通用方案kprobe 允许你在几乎任意内核函数入口/返回处挂回调不需要改表不需要关写保护。对read来说可以挂在__x64_sys_read系统调用入口或者更底层的vfs_read、ksys_read上。它天然支持拿到寄存器参数稳定性远好于改表。kprobe 的代价是性能开销。每次命中都要走一遍异常/断点机制老版本用 int3新版本用 optprobe 优化高频read场景下开销可观。所以如果你的程序每秒几百万次read得考虑用 tracepoint 或者 eBPF 来降开销。2.4 tracepoint 与 eBPF性能与安全的平衡点内核在sys_enter/sys_exit上提供了静态 tracepointread自然也在覆盖范围内。配合 eBPF尤其是tracepoint/syscalls/sys_enter_read你可以在不写内核模块、不重启的前提下完成统计和过滤而且有 verifier 兜底安全性高得多。eBPF 的限制是能做的事受 verifier 约束不能随意改返回值除非用bpf_override_return且需要特定配置不能睡眠栈空间有限。所以拦截并篡改 read 结果这种需求eBPF 做起来别扭还是得回到 kprobe 或改表。下面这张表是我自己选型时的对照供你参考方案侵入性能否改返回值性能开销适用场景LD_PRELOAD极低能极低单进程调试、动态链接程序改 sys_call_table高能低老内核、需要完全控制kprobe中能中高通用拦截、审计、调试tracepoint eBPF低受限低大规模统计、生产观测选型这件事没有银弹我的经验是先问自己要不要改行为再问性能预算最后才看实现难度。只统计不改行为优先 eBPF要改行为且能接受开销kprobe两者都不满足才考虑改表。3. 用 kprobe 挂住 read完整可复现的实现这一节我把 kprobe 方案从头到尾走一遍。选它是因为它通用性最好而且不依赖内核是否导出符号5.x/6.x 都能跑。下面代码基于一个标准的内核模块骨架我尽量把每个字段的来龙去脉讲清楚。3.1 环境准备与模块骨架先确认你的环境需要和目标内核版本一致的 kernel headersmake、gcc这些基础工具。模块的Makefile是标准写法obj-m read_hook.o KDIR : /lib/modules/$(shell uname -r)/build all: $(MAKE) -C $(KDIR) M$(PWD) modules clean: $(MAKE) -C $(KDIR) M$(PWD) clean模块骨架里要包含linux/kprobes.h、linux/ptrace.h、linux/sched.h这些头。核心结构是struct kprobe它至少需要填symbol_name要挂的函数名和pre_handler/post_handler两个回调。这里有个细节symbol_name填的是内核符号不是系统调用号。对 x86_64 来说read的入口符号是__x64_sys_readARM64 上则是__arm64_sys_read。填错了注册会失败register_kprobe返回负值记得检查返回值并打印。3.2 pre_handler 里怎么拿到 read 的参数read的原型是ssize_t read(int fd, void __user *buf, size_t count)。在pre_handler里参数通过regs传递。x86_64 的调用约定是rdi、rsi、rdx依次对应前三个参数所以static int handler_pre(struct kprobe *p, struct pt_regs *regs) { int fd (int)regs-di; void __user *buf (void __user *)regs-si; size_t count (size_t)regs-dx; pr_info(read called: fd%d, count%zu, pid%d\n, fd, count, current-pid); return 0; }这里有几个容易踩的点。第一regs-di这些字段名是架构相关的x86_64 是di/si/dxARM64 是regs-regs[0]这种数组形式跨架构代码要加宏区分。第二buf是用户态地址绝对不能在 handler 里直接解引用否则可能触发缺页异常导致内核 oops。要读用户态内存得用copy_from_user而且要注意当前上下文是否允许睡眠。第三current-pid拿的是当前进程但 kprobe 可能在任何上下文命中包括中断所以别在里面做重活。3.3 post_handler 拿返回值与常见陷阱pre_handler只能看到入参要看read实际读了多少字节得用post_handler。返回值在regs_return_value(regs)里static void handler_post(struct kprobe *p, struct pt_regs *regs, unsigned long flags) { long ret regs_return_value(regs); pr_info(read returned: %ld\n, ret); }陷阱在于post_handler不一定被调用。如果pre_handler返回非 0或者被挂的函数在中途触发了 fault 被跳过post 就不会执行。所以别把关键逻辑只放在 post 里最好在 pre 里记录状态、post 里配对。另外regs_return_value在 x86_64 上取的是axARM64 上是regs[0]同样架构相关。还有一个我踩过的坑在 handler 里调用printk太频繁会拖垮系统。read是超高频调用如果你每个都打日志dmesg会被刷爆而且printk本身有锁竞争。正确做法是用 per-CPU 计数器或者环形缓冲区只在必要时才输出。我一般会加一个开关变量默认只统计不打印。3.4 注册、注销与模块生命周期注册就是register_kprobe(kp)注销是unregister_kprobe(kp)。模块init里注册exit里注销这是基本纪律。但要注意如果注册失败init必须返回错误码不能继续往下走否则模块加载成功但 hook 没生效排查起来很迷惑。static int __init read_hook_init(void) { int ret register_kprobe(kp); if (ret 0) { pr_err(register_kprobe failed: %d\n, ret); return ret; } pr_info(read hook installed\n); return 0; }模块卸载时如果还有进程正在read里unregister_kprobe会等待正在执行的 handler 结束这是内核帮你做的同步不用自己操心。但如果你在 handler 里持有锁或者做了长时间操作卸载就会卡住所以 handler 一定要短。4. 改 sys_call_table 那条路原理、风险与替代虽然我不推荐在生产用改表方案但理解它很有必要因为很多老系统和老资料都基于它而且它能做到 kprobe 做不到的事——比如完全替换实现、直接改返回值而不调用原函数。4.1 sys_call_table 的定位与写保护绕过sys_call_table是一个函数指针数组索引就是系统调用号。read在 x86_64 上是 0 号__NR_read所以sys_call_table[0]就是__x64_sys_read的地址。要改它先得拿到表地址。老内核可以直接extern void *sys_call_table[]新内核没导出了常见做法是用 kprobe 挂kallsyms_lookup_name来钓出地址或者解析/proc/kallsyms。拿到地址后写之前要关写保护。x86_64 上是清CR0的 WP 位bit 16unsigned long cr0 read_cr0(); write_cr0(cr0 ~X86_CR0_WP); sys_call_table[__NR_read] (void *)my_read; write_cr0(cr0);这段代码在多核上是有窗口期的其他核可能在你关保护期间正好写这块内存。更稳妥的做法是用set_memory_rw/set_memory_ro改页属性粒度更细。但不管哪种都属于知道自己在干什么的操作新手别轻易上生产。4.2 替换函数的签名与调用原函数替换函数的签名要和原函数一致asmlinkage long my_read(const struct pt_regs *regs)。注意新内核里系统调用统一用pt_regs传参不再是老式的逐个参数。要调用原函数得先保存原指针static long (*orig_read)(const struct pt_regs *);然后在my_read里做逻辑再return orig_read(regs);。如果你想改返回值比如把某个 fd 的读取结果清零可以直接构造返回值而不调用原函数但这样会破坏语义慎用。4.3 为什么我最终放弃了这条路除了写保护的风险还有几个现实问题。第一内核版本升级后符号和偏移经常变你的代码可能一夜之间失效。第二和内核自带的完整性校验机制可能冲突某些加固内核会检测sys_call_table是否被篡改。第三调试困难一旦改错表现往往是整机 panic没有太多线索。所以我的结论是能用 kprobe 就用 kprobe能用 eBPF 就用 eBPF改表只作为最后手段且只在可控的测试环境用。如果你确实需要完全替换的语义可以考虑 ftrace 的function_graph或者直接改内核源码重新编译后者虽然重但至少行为可预期。5. 实测中的性能账hook read 到底慢多少理论讲完得看数据。我在一台 4 核虚拟机上做了组对比测试用一个简单的 C 程序对/dev/zero做 100 万次 4KB 的read分别测无 hook、kprobe hook、eBPF tracepoint 三种情况。结果如下数值为多次运行的中位数仅供量级参考场景总耗时相对基线无 hook0.42s1.0xkprobeprepost 都打点3.8s约 9xkprobe仅 pre无 printk1.6s约 3.8xeBPF tracepoint0.55s约 1.3x这组数据说明几件事。kprobe 的开销主要来自断点机制和回调本身prepost 双打点几乎翻倍。去掉 printk 能省一大半因为日志输出才是真正的瓶颈。eBPF 的开销小得多因为 tracepoint 是静态插桩没有动态改指令的成本。所以如果你只是做统计eBPF 是明显更优的选择。如果必须改行为kprobe 也能接受但要把 handler 写得极简能不加锁就不加锁能用 per-CPU 变量就别用全局变量能批量输出就别逐条打印。我自己的经验是handler 里超过 20 行代码就要警惕了那通常意味着你在做不该在这里做的事。还有一个容易被忽略的点hook 的粒度。挂在__x64_sys_read上每次系统调用都命中挂在vfs_read上命中次数会少一些因为有些路径不走 vfs但语义更贴近文件层。选哪个取决于你要观测的是系统调用行为还是文件访问行为两者不是一回事。6. 几个真实踩过的坑与排查思路最后这部分是我觉得最有价值的地方因为文档里通常不会写这些。第一个坑handler 里睡眠导致死锁。我早期在pre_handler里调用了copy_from_user去读用户缓冲区结果在某些上下文比如持有自旋锁时直接触发 scheduling while atomic。后来改成只在current-mm存在且上下文允许时才读并且用copy_from_user_nofault这种不会睡眠的变体。判断能否睡眠看preempt_count()和是否在中断上下文这是基本功。第二个坑符号名填错注册静默失败。有次在 ARM64 上填了__x64_sys_readregister_kprobe返回-ENOENT但我的代码没检查返回值模块加载成功了却什么都没发生。排查时用cat /proc/kallsyms | grep sys_read确认符号名才发现架构前缀不对。永远检查register_kprobe的返回值这是血泪教训。第三个坑高频 read 把 dmesg 刷爆导致日志服务卡死。这个前面提过但值得再强调。printk在高压下会成为系统瓶颈甚至触发printk的限流机制让你丢失日志。正确姿势是用trace_printk配合 tracefs或者自己维护环形缓冲只在用户主动 dump 时才输出。第四个坑模块卸载时卡住。如果 handler 里有自旋锁且某个路径持锁时间过长rmmod会一直等。用cat /proc/kprobes能看到已注册的 probe 状态配合dmesg定位。实在卸不掉只能重启所以测试环境一定要能随便重启。排查这类问题的通用思路是先确认 hook 是否真的生效看计数、看日志再确认开销是否可接受看 CPU、看延迟最后才看逻辑对不对。顺序反了容易在错误的方向上浪费时间。如果你也在做类似的内核观测或拦截我的建议是先从 eBPF 入手把需求跑通确认数据有价值之后再评估要不要下沉到 kprobe 或改表。很多时候你会发现统计清楚比拦截修改更有用而前者恰恰是风险最低的那条路。
阅读完成 · 觉得有帮助?