eBPF编程接口

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 Map
  • BPF_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 程序的运行过程压缩成一条线,大致就是:

  1. 用户态程序先把 eBPF 字节码通过 bpf() 加载进内核。
  2. 用户态再把程序挂到 kprobe、tracepoint 或 perf 事件上。
  3. 内核事件发生时,eBPF 程序被触发执行。
  4. eBPF 通过 Helper 安全读取上下文、采集数据。
  5. 采集结果写入 BPF Map 或 perf buffer。
  6. 用户态把这些数据读出来,再做展示、分析或告警。

看到这里就会明白,eBPF 并不是"直接改内核",而是在一套严格受控的接口体系里,安全地观察、统计,甚至有限度地影响内核行为。

写在最后

如果你站在工程师视角再看一遍,会发现 eBPF 并不神秘。它没有绕过内核,也没有跳过安全边界,而是通过一组清晰、严格、可验证的接口,把"可观测、可扩展、可控制"这三件事带进了内核世界。

所以,理解 eBPF 与内核交互,关键不是死记命令,而是抓住四个核心角色:

  • bpf():负责"进内核"
  • Helper:负责"在内核里做事"
  • Map:负责"存数据、传数据"
  • BTF/CO-RE:负责"跨版本活下来"

把这四件事想明白,eBPF 的主线就清楚了。后面无论是继续看 BCC、bpftrace,还是直接上 libbpf,本质上都离不开这套框架。