eBPF 学习路线

2 阅读10分钟

eBPF 学习路线:从 libbpf-bootstrap minimal 到 sched_ext

最近开始系统学习 eBPF 后,我发现它最容易踩的坑不是“代码写不出来”,而是概念一下子铺得太开。

一开始会同时遇到 tracepoint、kprobe、BPF Map、Ring Buffer、BTF、CO-RE、XDP、sched_ext……每个词都能继续展开一大堆内容。如果没有一个明确顺序,很容易变成“每个都看过一点,但真正写程序时还是不知道从哪里下手”。

所以我把自己的学习路线重新整理了一遍。目标不是刷完多少文档,而是按下面这个节奏推进:

能运行
  ↓
能看懂
  ↓
能修改
  ↓
能自己写

最终希望做到的不只是“会跑 eBPF 示例”,而是能够独立完成一个小型 eBPF 项目,再继续往性能分析、网络和 sched_ext 这些方向深入。


1. 先看完整路线

我目前给自己安排的顺序是:

Linux / C 基础
       ↓
libbpf-bootstrap minimal
       ↓
理解 eBPF 运行机制
       ↓
tracepoint
       ↓
kprobe / kretprobe
       ↓
uprobe / uretprobe
       ↓
BPF Map
       ↓
Ring Buffer
       ↓
BTF / CO-RE
       ↓
完成一个独立项目
       ↓
性能分析 / XDP / sched_ext

这个顺序的核心思路是:先把最小程序跑起来,再沿着这个程序把编译、加载、挂载、数据交换一步步拆开。


2. 第一阶段:补够用的 Linux 和 C 基础

学 eBPF 不需要先把整个 Linux 内核啃完,但有些基础绕不过去。

C 语言至少要比较熟悉:

  • 指针和二级指针;
  • 结构体、结构体指针;
  • 数组;
  • 宏;
  • 位运算;
  • 类型转换;
  • sizeof;
  • static、const、volatile;
  • 基本的内存布局。

后面经常会看到类似:

struct task_struct *task;

以及:

task->pid

如果结构体、指针还不熟,继续往下看 BTF、CO-RE 只会更痛苦。

Linux 这边,我认为最重要的是先把这条路径理解清楚:

用户态程序
     ↓
  syscall
     ↓
Linux 内核
     ↓
内核函数 / tracepoint

例如:

write(fd, buf, len);

用户态调用 write() 后会进入内核。eBPF 很多 tracing 程序,本质上就是在这条执行路径上的某个位置“挂一个观察点”。

除了系统调用,进程、线程、PID、文件描述符、虚拟内存、上下文切换、调度器这些概念也值得提前熟悉。


3. 第二阶段:不要急着写大项目,先把 minimal 跑通

我比较推荐从官方生态里的 libbpf-bootstrap 开始:

https://github.com/libbpf/libbpf-bootstrap

入门先看:

examples/c/minimal

这个例子很小,但里面已经包含了 eBPF 开发最重要的结构。

核心文件只有两个:

minimal.c
minimal.bpf.c

它们分别运行在两个世界:

minimal.c
    ↓
用户空间
​
minimal.bpf.c
    ↓
Linux 内核

这件事必须先建立直觉。很多后面的 API 看起来复杂,其实都围绕“用户态负责管理,eBPF 程序在内核事件上执行”这个模型展开。


4. 先读懂 minimal.bpf.c:eBPF 程序到底什么时候运行

一个典型片段是:

SEC("tp/syscalls/sys_enter_write")
int handle_tp(void *ctx)
{
    int pid = bpf_get_current_pid_tgid() >> 32;
​
    if (pid != my_pid)
        return 0;
​
    bpf_printk("BPF triggered from PID %d.\n", pid);
​
    return 0;
}

这里最值得先搞清的是三件事。

4.1 SEC(...) 决定挂载位置

SEC("tp/syscalls/sys_enter_write")

表示这个 eBPF 程序对应 sys_enter_write tracepoint。

因此执行链可以先简单理解成:

程序调用 write()
      ↓
进入 Linux 内核
      ↓
sys_enter_write
      ↓
handle_tp()

4.2 bpf_get_current_pid_tgid() 获取当前进程/线程信息

常见写法:

int pid = bpf_get_current_pid_tgid() >> 32;

bpf_get_current_pid_tgid() 返回一个 64 位值,高 32 位是 TGID,通常也就是我们平时说的进程 PID;低 32 位对应线程 ID。

4.3 bpf_printk() 只把它当调试工具

例如:

bpf_printk("hello\n");

调试阶段可以从:

sudo cat /sys/kernel/debug/tracing/trace_pipe

查看输出。

部分系统路径是:

sudo cat /sys/kernel/tracing/trace_pipe

真正做高频监控时,不应该把所有事件都靠 bpf_printk() 打出来。


5. 再读 minimal.c:用户态到底在做什么

minimal.c 这边主要负责 eBPF 对象的打开、加载和挂载。

一个典型流程是:

open
  ↓
load
  ↓
attach
  ↓
运行 / 与 eBPF 通信

常见代码:

skel = minimal_bpf__open();
minimal_bpf__load(skel);
minimal_bpf__attach(skel);

可以分别理解成:

open

minimal_bpf__open();

在用户态创建并打开对应的 BPF skeleton,为后续加载做准备。

load

minimal_bpf__load(skel);

把 eBPF 程序真正送入内核,并经过 Verifier 检查。

attach

minimal_bpf__attach(skel);

把已经加载的程序挂到 SEC(...) 声明的 hook 上。

所以用户态和内核态的整体关系就是:

用户态程序
    ↓
  libbpf
    ↓
Linux 内核
    ↓
 eBPF 程序

这一步看懂后,再学 Map、Ring Buffer、CO-RE 会顺很多。


6. 第三阶段:搞清 eBPF 程序是怎么被执行的

接下来要把“源代码为什么能在内核里跑”理顺。

我目前用这条链路来记:

eBPF C
  ↓
Clang
  ↓
BPF Bytecode
  ↓
Verifier
  ↓
JIT
  ↓
CPU 本地机器码

6.1 为什么 eBPF 经常用 Clang

普通 C 程序 GCC、Clang 都能编译。

但 eBPF 程序通常使用 Clang:

clang -target bpf -c minimal.bpf.c -o minimal.bpf.o

关键是:

-target bpf

它告诉编译器:目标不是 x86 或 ARM,而是 BPF 指令集。

6.2 Verifier 是一道必须过的门

eBPF 程序不能像普通内核代码一样直接执行。进入内核前,Verifier 会检查它是否满足安全要求。

常见检查包括:

  • 非法指针访问;
  • 越界;
  • 未初始化变量;
  • 不被允许的 helper;
  • 内核无法证明安全的控制流。

只有通过 Verifier,程序才能继续加载。

6.3 JIT 负责把字节码变成当前 CPU 更容易执行的机器指令

通过检查以后,系统可以把 BPF Bytecode JIT 成 CPU 本地机器码,从而提高执行效率。

这一阶段不需要一上来钻 Verifier 的所有细节。先把“编译 → 检查 → 执行”这条主线弄清楚就够了。


7. 第四阶段:系统学习几类常用 Probe

当 minimal 已经跑通,下一步就是把挂载点展开学习。

我建议顺序是:

tracepoint
    ↓
kprobe
    ↓
kretprobe
    ↓
uprobe
    ↓
uretprobe

原因很简单:minimal 本身就已经在使用 tracepoint,所以从它继续扩展最自然。

tracepoint

Linux 内核预先定义好的静态事件点,适合系统调用、调度、I/O 等监控。

kprobe / kretprobe

动态跟踪内核函数。

kprobe     -> 函数入口
kretprobe  -> 函数返回

需要函数参数、调用次数时可以用 kprobe;需要返回值或执行耗时时,再加入 kretprobe。

uprobe / uretprobe

作用对象换成用户态程序:

uprobe     -> 用户函数入口
uretprobe  -> 用户函数返回

可以用来跟踪自己写的程序、动态库甚至一些常见库函数。

这一阶段我单独整理了一篇五类探针笔记,重点是搞清“挂在哪里”和“什么时候触发”,而不是一上来追求写复杂工具。


8. 第五阶段:BPF Map——开始保存状态

只会“触发一个 eBPF 程序”还不够。很多实际需求都需要保存数据。

BPF Map 可以作为内核 eBPF 程序和用户态之间的重要数据结构:

内核 eBPF 程序
       ↕
    BPF Map
       ↕
   用户态程序

入门阶段我打算先掌握:

BPF_MAP_TYPE_HASH
BPF_MAP_TYPE_ARRAY
BPF_MAP_TYPE_PERCPU_ARRAY

最适合练手的小项目之一是:

统计每个 PID 调用 write() 的次数。

逻辑非常直接:

write()
   ↓
eBPF
   ↓
获取 PID
   ↓
map[pid]++
   ↓
用户态读取 Map

最后可以输出:

PID       COUNT
1234      50
2222      100
3456      17

做完这个小项目后,Map 的作用会比单纯背 API 清楚得多。


9. 第六阶段:Ring Buffer——把事件实时送到用户态

Map 更适合保存状态和统计结果,Ring Buffer 更适合实时事件流。

例如监控 openat():

openat()
   ↓
eBPF
   ↓
生成事件
​
PID
COMM
FILENAME
TIME
​
   ↓
Ring Buffer
   ↓
用户态实时接收

我目前给二者的区分是:

Map
-> 保存状态、计数、键值数据
​
Ring Buffer
-> 把事件连续发送给用户态

后面写系统调用监控器时,这两个机制通常会一起出现。


10. 第七阶段:再学习 BTF、vmlinux.h 和 CO-RE

我不打算太早碰 CO-RE 的细节。

在 tracepoint、Map、Ring Buffer 还没写熟之前,BTF 和 CO-RE 很容易学成一堆名词。

等前面的东西比较稳定后,再来看:

BTF

BTF 全称:

BPF Type Format

可以把它理解为内核类型信息的一种描述方式。

系统中如果存在:

ls /sys/kernel/btf/vmlinux

说明通常可以直接利用内核提供的 BTF 信息。

vmlinux.h

可以通过 bpftool 生成:

bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h

eBPF 程序中再使用:

#include "vmlinux.h"

CO-RE

CO-RE:

Compile Once - Run Everywhere

它主要解决不同内核版本之间结构体布局可能变化的问题。

核心依赖关系可以先简单记成:

BTF
 ↓
vmlinux.h
 ↓
libbpf + CO-RE

11. 第八阶段:一定要做一个完整项目

基础学完以后,我不想继续只看零散 demo,而是准备做一个完整的系统调用监控器。

先监控:

openat
read
write
execve

用户态输出类似:

TIME        PID     COMM        EVENT
10:20:01    1234    bash        execve
10:20:03    1234    bash        openat
10:20:03    1234    bash        read
10:20:04    1234    bash        write

项目结构可以很简单:

syscall_monitor/
├── monitor.bpf.c
├── monitor.c
├── monitor.h
└── Makefile

其中:

monitor.bpf.c -> 内核侧 eBPF 程序
monitor.c     -> 用户态 loader / 事件处理

做到这里,前面学过的内容就能真正串起来:

SEC
tracepoint
kprobe
helper
BPF Map
Ring Buffer
Skeleton
libbpf
BTF
CO-RE
Verifier

这时我才认为自己真正完成了 eBPF 的入门阶段。


12. 第九阶段:再选深入方向

基础完成以后,再根据兴趣选方向。

Linux 性能分析

可以继续看:

CPU profiling
off-CPU
scheduler
block I/O
filesystem
memory
network
perf event
fentry / fexit

网络

可以继续看:

XDP
TC
socket
cgroup

大致位置关系:

网卡
 ↓
XDP
 ↓
Linux 网络协议栈
 ↓
TC
 ↓
Socket

sched_ext

我比较感兴趣的另一个方向是 sched_ext。

它允许使用 BPF 编写 Linux 调度策略,但我不打算把它当第一个 eBPF 项目,因为它前面至少还压着这些基础:

eBPF
BPF Map
BTF
CO-RE
libbpf
Linux 调度器
进程 / 线程

如果这些概念还没形成完整链路,直接研究 sched_ext 很容易只停留在“代码能跑,但原理没吃透”。


13. 我的实际练习顺序

理论学习之外,我给自己安排的练习大致是下面这样。

练习 1:跑通 minimal

目标:

能成功编译
能成功加载
能在 trace_pipe 中看到输出

练习 2:修改 tracepoint

把:

sys_enter_write

换成:

sys_enter_openat

观察什么操作会触发。

练习 3:监控 execve

执行:

ls
pwd
cat

观察不同命令启动时对应的 PID。

练习 4:增加进程名

输出:

PID
COMM

练习 5:加入 BPF Map

做:

PID -> syscall count

练习 6:加入 Ring Buffer

从内核实时发送事件,在用户态接收并打印。

练习 7:做一个简化版 execsnoop

监控系统中的进程执行事件。

这几个练习做完,比继续看十篇介绍文章更有价值。


14. 暂时不急着学什么

刚入门时,我会主动避开这些内容:

XDP 深入
复杂网络协议
LSM
高级 Verifier 技巧
大型 BCC 工具
sched_ext
复杂性能分析

不是这些内容不重要,而是它们都依赖更基础的东西。

如果下面这些还不熟:

tracepoint
BPF Map
Ring Buffer
BTF
CO-RE

越往后学,欠下的概念债越多。


15. 一个阶段性检查表

Linux / C

  • 理解用户态和内核态
  • 理解系统调用
  • 理解进程、线程和 PID
  • 理解文件描述符
  • 熟悉指针和结构体

eBPF 基础

  • 成功运行 minimal
  • 读懂 minimal.c
  • 读懂 minimal.bpf.c
  • 理解 SEC
  • 理解 helper
  • 理解 Verifier
  • 理解 JIT

Tracing

  • tracepoint
  • kprobe
  • kretprobe
  • uprobe
  • uretprobe

数据交换

  • HASH Map
  • ARRAY Map
  • PERCPU Map
  • Ring Buffer

CO-RE

  • BTF
  • vmlinux.h
  • CO-RE

项目

  • syscall monitor
  • exec monitor
  • file monitor
  • 简单性能监控工具

16. 当前最重要的一步

路线可以列得很长,但真正开始时没必要同时推进所有内容。

我目前认为最重要的一步仍然是:

把 libbpf-bootstrap/examples/c/minimal 跑通

然后按这个顺序继续:

1. 阅读 minimal.bpf.c
2. 阅读 minimal.c
3. 理解 tracepoint
4. 修改 sys_enter_write
5. 自己写第一个 eBPF 程序

先完成一个闭环,再扩展下一个概念。

对我来说,这比一开始就把 XDP、CO-RE、sched_ext 全部铺开,更适合真正把 eBPF 学扎实。