eBPF 为什么能"插"进内核?看懂这 4 个接口就够了
很多人第一次学 eBPF,最困惑的往往不是语法,而是这几个更本质的问题:一段 C 代码到底怎么进内核?内核事件来了,为什么它就会自动执行?采到的数据,又是怎么安全地回到用户态的?
如果把 eBPF 看成一条完整链路,核心其实只有 4 个角色:bpf() 系统调用、Helper 函数、BPF Map、BTF/CO-RE。它们分别解决"怎么加载""怎么做事""怎么传数据""怎么跨版本运行"四件事。把这条主线捋顺,eBPF 的编程接口就不再零散了。
一、先把角色分清:谁负责控制,谁负责执行
一个完整的 eBPF 程序,通常分成两部分:
- 用户态程序:负责加载字节码、绑定事件、创建 Map、读取结果并输出。
- 内核态程序:运行在 eBPF 虚拟机中,在事件发生时被触发,完成过滤、统计、采样等动作。
换句话说,eBPF 不是一段"单独运行"的代码,而是一套用户态和内核态协同工作的机制。用户态更像调度台,负责把程序送进去并接线;内核态更像执行器,负责在关键时刻快速处理。
从工程师视角看,理解 eBPF 的第一步,不是先背 API,而是先建立这个认知:eBPF 本质上是一套受控的内核扩展机制。
二、真正的入口:bpf() 系统调用
用户态和内核交互的核心入口,是 bpf() 系统调用:
c
#include <linux/bpf.h>
int bpf(int cmd, union bpf_attr *attr, unsigned int size);
理解它,抓住 3 个参数就够了:
cmd:告诉内核"这次要做什么",比如加载程序、创建 Map、固定对象。attr:这次操作携带的具体参数。size:参数结构体的大小。
实际开发里,很多关键动作都靠它完成,比如:
BPF_PROG_LOAD:加载 eBPF 程序BPF_MAP_CREATE:创建 BPF MapBPF_OBJ_PIN:把对象固定到文件系统
可以把 bpf() 理解为用户态发给内核的"总控台指令"。不过要注意,不同内核版本支持的命令并不完全一致。遇到兼容性问题时,与其猜,不如直接看内核头文件,或者先用 bpftool feature probe 确认当前系统到底支持什么。
三、内核态不能乱来:靠 Helper 安全做事
eBPF 程序运行在受限沙箱里,不能像普通内核代码那样随意调用函数。它想拿时间、取 PID、读上下文、输出数据,都必须通过内核预先开放的 Helper 函数 来完成。
常见的 Helper 包括:
bpf_get_current_pid_tgid():获取当前进程信息bpf_ktime_get_ns():获取时间戳bpf_perf_event_output():把数据发送回用户态bpf_trace_printk():输出调试信息
其中最值得记住的一类,是 bpf_probe_read*() 系列。原因很简单:eBPF 自己可用的内存空间非常有限,访问其他内核地址或用户地址时,不能直接解引用,必须通过这组 Helper 安全读取。它们会做边界和安全检查,避免非法访问把内核带崩。
这里还有一个很容易踩坑的点:不同类型的 eBPF 程序,可用的 Helper 不一样。所以当某个 Helper 用不了时,先别急着怀疑代码,先确认你的程序类型是否支持它。
四、数据怎么共享:靠 BPF Map 做状态中心
如果说 Helper 解决的是"程序怎么做事",那 BPF Map 解决的就是"数据怎么存、怎么传"。
BPF Map 本质上是内核中的键值存储,既可以被 eBPF 程序访问,也可以被用户态程序访问,所以它天然适合承担两类任务:
- 保存统计状态
- 在用户态和内核态之间传递数据
常见的 Map 类型包括:
hash:适合做按 key 统计array:适合固定索引访问perf_event_array:适合高性能事件输出percpu_*:适合降低多核竞争
这里有两个实践细节特别关键。
第一,Map 只能由用户态创建。内核态 eBPF 程序不能像普通内核代码那样临时申请一块大内存,它要用的共享存储,必须提前由用户态准备好。
第二,Map 会随着文件描述符关闭而自动释放 。如果你希望程序退出后仍然保留这个 Map,就需要通过 BPF_OBJ_PIN 把它挂到 /sys/fs/bpf。
所以,Map 可以理解为 eBPF 体系里的"共享内存 + 状态中心"。很多看起来复杂的 eBPF 数据通路,拆开后本质上都离不开它。
五、为什么 BTF 和 CO-RE 会改变开发体验?
eBPF 真正难的,往往不是写程序,而是 跨内核版本兼容。
过去,很多 eBPF 工具依赖内核头文件来获取结构体定义。这会带来一连串问题:头文件依赖重、内核一升级偏移就可能变化、生产环境往往又不允许安装完整头文件。结果就是,程序能编译,不一定能稳定跑。
BTF(BPF Type Format)就是为了解决这个问题而生。它把内核类型信息内嵌进内核二进制里,开发者可以通过工具导出 vmlinux.h,从而减少对大量内核头文件的依赖。
在 BTF 的基础上,CO-RE(Compile Once, Run Everywhere)进一步解决了跨版本适配问题。它通过重定位和兼容处理,让同一份 eBPF 程序尽量适配不同版本的内核,而不是每升一次内核就重新编译一遍。
一句话理解:
- BTF 解决"我怎么知道内核数据结构长什么样"
- CO-RE 解决"我怎么让程序尽量一次编译,多处运行"
当然,这套能力也有前提:通常要求较新的内核版本,并开启对应的 BTF 配置。
六、把整条链路串起来
把 eBPF 程序的运行过程压缩成一条线,大致就是:
- 用户态程序先把 eBPF 字节码通过
bpf()加载进内核。 - 用户态再把程序挂到 kprobe、tracepoint 或 perf 事件上。
- 内核事件发生时,eBPF 程序被触发执行。
- eBPF 通过 Helper 安全读取上下文、采集数据。
- 采集结果写入 BPF Map 或 perf buffer。
- 用户态把这些数据读出来,再做展示、分析或告警。
看到这里就会明白,eBPF 并不是"直接改内核",而是在一套严格受控的接口体系里,安全地观察、统计,甚至有限度地影响内核行为。
写在最后
如果你站在工程师视角再看一遍,会发现 eBPF 并不神秘。它没有绕过内核,也没有跳过安全边界,而是通过一组清晰、严格、可验证的接口,把"可观测、可扩展、可控制"这三件事带进了内核世界。
所以,理解 eBPF 与内核交互,关键不是死记命令,而是抓住四个核心角色:
bpf():负责"进内核"- Helper:负责"在内核里做事"
- Map:负责"存数据、传数据"
- BTF/CO-RE:负责"跨版本活下来"
把这四件事想明白,eBPF 的主线就清楚了。后面无论是继续看 BCC、bpftrace,还是直接上 libbpf,本质上都离不开这套框架。