eBPF 挂接内核:kprobe 与 fentry 不是可以互换的选择
挂 eBPF 程序到内核函数有两条常见路径:kprobe 和 fentry。多数教程把它们当可互换——选一个、挂上、读数据。它们并不可互换:安装机制不同、给你函数参数的形式不同、热路径上每次调用的 CPU 成本不同。在每秒触发百万次的函数上选错,等于给机器上每个请求加可测的开销。
短版本
kprobe 通过 patch 目标指令挂接。经典形式是断点(x86 上的 int3),陷入 kprobe 机制、跑你的程序、恢复被移开的指令。几乎任何地方都能挂,内核构建不需要特殊条件。你拿到的是原始 struct pt_regs *,参数要自己从寄存器里挖。
fentry 挂到函数的 __fentry__ 调用点——编译器在每个可追踪函数顶部为 ftrace 预留的调用槽——通过生成的 BPF trampoline。无异常,成本接近普通调用。你拿到的是已经按类型解析好的参数(从 BTF 解析)。需要现代内核(≥5.5)且开启 BTF,函数必须可被 ftrace 附加。
同样的可观测性,不同的安装、不同的每次调用成本、不同的失败模式。
kprobe 的真实代码
以 SentinelEdge(用 kprobe 挂 13 个点的 eBPF 项目)里 hook do_mmap 为例:
SEC("kprobe/do_mmap")
int trace_mmap_detailed(struct pt_regs *ctx) {
__u32 pid = bpf_get_current_pid_tgid() >> 32;
__u64 addr = PT_REGS_PARM1(ctx);
__u64 len = PT_REGS_PARM2(ctx);
__u32 prot = PT_REGS_PARM3(ctx);
__u32 flags= PT_REGS_PARM4(ctx);
...
}
context 给的是捕获时刻的原始寄存器文件,do_mmap 原型无处可寻。用 PT_REGS_PARM1..N 恢复参数(展开成 x86-64 调用约定的 rdi/rsi/rdx/rcx…)。没有任何东西检查 PARM3 真的是 prot——内核的 do_mmap 签名跨版本变过,偏移会继续编译、继续返回一个数字,只是错的。破坏是静默的。
这种原始性也是优势:kprobe 不在乎函数的类型信息,经典形式甚至不需要函数被特殊编译,它 patch 字节——所以几乎能挂任何东西,也能跑在类型化追踪基础设施出现之前的内核上。
fentry 的方式
同样的 hook 用 fentry 写:
SEC("fentry/do_mmap")
int BPF_PROG(trace_mmap, struct file *file, unsigned long addr,
unsigned long len, unsigned long prot /* 按内核原型 */) {
...
}
BPF_PROG 是 libbpf 宏,把 trampoline 的参数数组解包成与内核真实原型一致的具名、类型化参数(类型从 BTF 解析)。不再按位置读寄存器,而是按名字读参数。如果你写的签名与内核不符,加载时就对 BTF 失败,而不是运行时静默给你错误寄存器。这个单一性质——不匹配变成加载错误而非数据损坏——就是"任何够新、提供 fentry 的内核上,fentry 是更安全默认"的大部分原因。
fexit 是同样的思路用于函数返回:返回路径上触发,给你参数与返回值。
实践建议
- 内核够新(≥5.5 + BTF)优先 fentry:类型化参数 + 签名不符加载期失败,杜绝静默错位;
- 需要兼容旧内核或挂"不可 ftrace 附加"的函数时用 kprobe,并接受寄存器偏移的脆弱性;
- 热路径上把每次调用的开销当一等公民:fentry 接近普通调用,kprobe 有断点/位移恢复成本。