news 2026/10/16 19:10:34

Linux内核Hook read系统调用:kprobe、eBPF与sys_call_table方案对比

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Linux内核Hook read系统调用:kprobe、eBPF与sys_call_table方案对比

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上提供了静态 tracepoint,read自然也在覆盖范围内。配合 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 headers,make、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_read;ARM64 上则是__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/dx,ARM64 是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 上取的是ax,ARM64 上是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 16):

unsigned 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.0x
kprobe(pre+post 都打点)3.8s约 9x
kprobe(仅 pre,无 printk)1.6s约 3.8x
eBPF tracepoint0.55s约 1.3x

这组数据说明几件事。kprobe 的开销主要来自断点机制和回调本身,pre+post 双打点几乎翻倍。去掉 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_read,register_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 或改表。很多时候你会发现,统计清楚比拦截修改更有用,而前者恰恰是风险最低的那条路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 19:10:34

磁饱和型加压调 Q 模块分析

一&#xff1a;主要特A&#xff1a;重复率 0-100Hz B&#xff1a;晶压≥5.0kV C&#xff1a;高压脉冲沿<25.0nS D&#xff1a;窄脉冲输出&#xff0c;高压半宽约 30ns E&#xff1a;高可靠性&#xff0c;短路不会损坏 F&#xff1a;EMI/RFI 屏蔽密封二&#xff1a;应用范围(…

作者头像 李华
网站建设 2026/10/16 19:10:34

Swing登录系统开发全解析:布局、事件监听与密码加密

前阵子有位读者问我&#xff1a;“Swing是不是已经被淘汰了&#xff1f;我是不是不应该在这上面浪费时间&#xff1f;”我特别理解这种焦虑&#xff0c;毕竟现在前端动不动就是 Vue 三件套、Flutter 跨平台&#xff0c;桌面 Java 好像成了老古董。但实际情况是&#xff0c;很多…

作者头像 李华
网站建设 2026/10/16 19:10:35

Spring Boot企业级在线办公平台开发实战

1. 项目概述&#xff1a;Spring Boot企业级在线办公平台十年前我刚入行时&#xff0c;企业OA系统还停留在Struts2JDBC的笨重架构上。如今基于Spring Boot的现代化办公平台&#xff0c;已经能实现分钟级部署和弹性扩展。这次要分享的毕业设计项目&#xff0c;正是运用Spring Boo…

作者头像 李华
网站建设 2026/10/16 19:10:35

RVG29-PEG偶联物在神经靶向药物递送中的应用

1. 项目概述&#xff1a;RVG29-PEG偶联物的核心价值与应用场景在神经科学和靶向药物递送领域&#xff0c;RVG29肽段因其特异性结合神经元的能力而备受关注。这种源自狂犬病病毒糖蛋白的29个氨基酸短肽&#xff08;RVG29&#xff09;&#xff0c;能够有效穿透血脑屏障并与神经元…

作者头像 李华
网站建设 2026/10/16 19:10:36

基于3000张YOLO格式数据集的海事船舶检测模型训练全流程

1. 海事视觉检测的项目背景与数据集定位1.1 为什么船舶检测是个被低估的刚需场景我最早接触船舶检测&#xff0c;是在一个港口卡口的项目里。当时甲方给的需求很朴素&#xff1a;把进出港口的船只自动识别出来&#xff0c;记录船型、大致尺寸、进出时间。听起来简单&#xff0c…

作者头像 李华
网站建设 2026/10/16 10:14:09

DCT彩色图像数字水印技术解析与实践

1. 项目概述&#xff1a;DCT彩色图像数字水印技术解析在数字媒体版权保护领域&#xff0c;DCT&#xff08;离散余弦变换&#xff09;域水印技术因其良好的鲁棒性和不可见性成为主流方案。这个项目实现了一套完整的彩色图像水印系统&#xff0c;包含嵌入、攻击模拟和提取三大核心…

作者头像 李华