跳转至

从零起步探索 eBPF - 新手向

前阵子拜读了赖老师的惊世巨著 LEOCC (SIGCOMM'25). 实验非常硬核, 一眼望去有很多没用过/没听说过的高级开发工具, 比如eBPF, gRPC, ...

本文的目标就是: 记录从零入门eBPF的过程, 重点是了解它是什么?基础用法是什么?简单案例跑下试试?

References:

  1. eBPF explained
  2. eBPF - IBM
  3. eBPF技术介绍
  4. eBPF完全入门指南
  5. 聊聊最近很火的eBPF
  6. BPF and XDP Reference Guide
  7. The eXpress Data Path

在开始前,进行一个 whole view:

eBPF的入门有两条主线,

  • 一条是: 基于C语言编写eBPF程序, 函数调用需要遵循特殊的 API, 这一部分涉及到一些 define, segment, maps 定义与使用
    • 写好以后, llvm编译器会根据一些规范, 为我们生成 elf 格式(.o) 的文件
  • 另一条是: iproute / bpftool 等加载器, 将eBPF程序加载进 kernel
    • 加载工具会读取elf中的各种段信息,将其加载到内核中
    • 这一步也会涉及到一些约定与规范

厘清这个“主干过程”, 就会自然地使用 kernel提供的辅助函数 和 不同的map 做一些特定功能的BPF程序开发了

iproute/bpftool/elf 叽里咕噜说什么呢

(1) Workflow:

  1. 用户写程序: 您在C语言中编写程序逻辑, 并使用 SEC("maps") 等宏来组织程序和Map
  2. 编译 (LLVM): LLVM (Clang) 将您的C代码编译成一个 ELF 文件 (.o 文件)
    • 这个ELF文件内部有清晰的“段”划分,比如 maps 段、xdp 段...
  3. 加载 (Loader):
    • 如果写的程序是用于网络过滤 (TC/XDP), 可以使用 iproute2 这个“加载器”, 去解析这个ELF文件并附加程序
    • 或者, 可以使用 bpftool (的 prog load 等命令) 去加载、附加和管理这个ELF文件中的程序和Map
      • bpftool 更加通用, 可以处理所有类型的eBPF程序 (网络、跟踪、安全...)

(2) 名词解释:

  1. ELF (Executable and Linkable Format) - 程序的“集装箱”
    • Linux系统上一种标准的二进制文件格式,用于存放可执行文件、目标代码、共享库等
    • 是编译的产物! "加载器"通过解析这个文件, 就能知道我们的程序代码在哪里、我们定义了哪些Map...
  2. iproute2 - “网络专家”加载器
    • 是Linux中功能强大的网络管理工具包: ip addr / ip route ...
    • 是一个网络专用的eBPF加载器
    • 主要用它的 tc 命令, 将eBPF程序附加到网络数据包的处理路径上
  3. bpftool - “eBPF万能瑞士军刀”
    • 功能非常全面, 几乎涵盖了eBPF的所有管理任务
    • 作用1: 加载程序 bpftool prog load
    • 作用2: 附加程序 bpftool net attach
    • 作用3: 查看 bpftool prog list / bpftool map list
    • 作用4: 调试 bpftool map dump
    • 是一个eBPF专用的加载和管理工具, 功能异常强大!

BPF 与 eBPF 是什么

BPF: Berkeley Packet Filter

BPF (Berkeley Packet Filter), 中文翻译为伯克利包过滤器, 是类 Unix 系统上数据链路层的一种原始接口,提供原始链路层封包的收发

1992年, Steven McCanne 和 Van Jacobson 写了一篇名为 The BSD Packet Filter: A New Architecture for User-level Packet Capture 的论文

在文中,作者描述了他们如何在 Unix 内核实现网络数据包过滤

由于这些巨大的改进,所有的 Unix 系统都选择采用 BPF 作为网络数据包过滤技术。直到今天,许多 Unix 内核的派生系统中(包括 Linux 内核)仍使用该实现

eBPF: extended Berkeley Packet Filter

2014 年初, Alexei Starovoitov 实现了 eBPF (extended Berkeley Packet Filter)

经过重新设计, eBPF 演进为一个通用执行引擎,可基于此开发性能分析工具、网络数据包过滤、系统调用过滤,系统观测和分析等诸多场景

eBPF 最早出现在 3.18 内核中,此后:

原来的 BPF 就被称为经典 BPF,缩写 cBPF(classic BPF),cBPF 现在已经基本废弃

现在, Linux 内核只运行 eBPF, 内核会将加载的 cBPF 字节码透明地转换成 eBPF 再执行

本文讲解 eBPF 的重心

本文侧重于讲解 eBPF 在网络层的使用, 本质可以简单理解成:

  1. linux kernel 网络协议栈有很多 "钩子" (Hook Function)
  2. 数据包, 在进入到网卡再到流出网卡的过程中, 会触发这些 HOOK 上注册的"回调函数"执行相关过滤动作

如图所示, netfilter [Layer3-网络层] 有5个HOOK: PREROUTING / INPUT / FORWARD / OUTPUT / POSTROUTING

每个数据包 dataflow 会按顺序顺次经过这5个HOOK, 并采取不同的处理方式

所以本质上 netfilter 的 HOOKs 更像是 "数据溪流中设置的5个网", 针对 IP 数据包进行过滤

其他的处理工具

这一部分本质上是快速回顾《linux 网络协议栈》

  1. XDP: eXpress Data Path
    • 位于 Layer 2: 数据链路层 (网卡驱动的layer)
    • 是整个协议栈中最早的处理点
    • 可以在数据包进入主网络协议栈(IP层)之前,就将其丢弃(XDP_DROP)或转发(XDP_REDIRECT/XDP_TX),从而绕过了后续所有高成本的协议栈处理
  2. TC: Traffic Control
    • 比较特殊: 位于 Layer 2 和 Layer 3 之间
    • TC传统上用于 流量整形和队列管理, 这发生在pkt准备从网卡发出的 Egress 路径
    • Ingress 路径, TC提供了一个钩子: 它在XDP之后、但在Netfilter的PREROUTING钩子之前执行
Linux网络协议栈: pkt进入ingress的步骤

alt text

  1. NIC: 数据包到达网卡
  2. XDP: 驱动程序最早被触发, 处理原始程序, 可以 redirect/drop
  3. Kernel 分配 sk_buff: 数据包被封装成 Kernel Network Stack 的标准结构
  4. TC Ingress: TC的HOOK被触发, 可以 redirect/drop
  5. Netfilter: 数据包正式进入IP协议栈, Netfilter 的 HOOKs 被顺次触发
  6. 后续 routing / IP processing: ...

因此:

  • xdp框架会调用ebpf程序, 对数据包进行上述处理, 因此我们可以在ebpf程序中实现诸如网络策略, 负载均衡等功能

  • tc是linux内核中用于数据包流量调度的功能组件, 可以拿到数据包的更多信息, 如ip地址... tc也会调用ebpf程序对数据包进行处理

这就是为什么我们要使用eBPF, 它的功能在xdp和tc的“嵌入支持”下, 格外强大!

eBPF 程序

要运行ebpf程序, 首先需要我们开发一个ebpf程序! 开发ebpf程序可以使用多种语言!

类比: 基于bcc开发python和lua的程序; 基于llvm开发c语言程序 ...

ebpf程序通过这些编译器, 编译成字节码后, 被加载到内核中运行

Whole View

alt text

  1. 用户编写eBPF程序
  2. 编译器编译成 program + maps
  3. 加载进 eBPF library
    1. Verifier: 验证有无问题, 会不会导致内核崩溃
    2. JIT Compiler: 将字节码转换成机器指令
  4. 执行ebpf call指令

Details

这一节建议过仔细一点, 理清整条workflow, 对理解eBPF和linux网络协议栈都特别有帮助:

alt text

Development

In User Space.

(1) 编写代码 (C source)

  • 首先使用C语言编写eBPF程序(eBPF Program
  • 同时,用户会定义eBPF Maps(Maps
    • Maps是一种内核中的键值对存储,用于在 in-kernel eBPF programs 之间、或在 kernel - userspace program 之间共享数据

(2) 编译代码 (Clang)

  • clang编译器将C代码编译成 eBPF字节码 (eBPF bytecode)
  • 编译命令是 clang -target bpf ...
  • 编译的产物是一个ELF文件(program.o), 这个文件里包含了编译好的 eBPF program [bytecode] 和 Maps Definition

Loading & Verification

连接用户空间和内核的桥梁! 也是内核保护自身安全的第一道防线!

(3) 加载程序 (Loader)

  • 一个用户空间的“加载器”程序(Loader)负责将eBPF程序送入内核
  • 这个加载器可以是上面提到的 bpftooliproute2(tc)
    • 也可以是一个使用特定库(如第一张图中的 eBPF Go Library 或Python的 bcc)的自定义程序

(4) 系统调用 (Syscall)

  • 加载器读取ELF文件中的字节码和Maps定义, 然后通过一个核心的 bpf() 系统调用(Syscall), 将它们发送给Linux内核
  • 创建Maps:
    • 加载器首先请求内核创建 eBPF Maps
    • 内核会返回一个文件描述符(fd)指向这个Map
  • 加载字节码:加载器将eBPF字节码连同它需要访问的Maps的fd, 一起发送给内核

(5) 校验器 (Verifier)

  • 这是eBPF最关键的安全机制!
  • 在接受字节码后,内核的校验器(eBPF Verifier)会立即对代码进行静态分析
  • 它会检查:
    • 安全性:程序是否会访问非法的内存地址?是否会造成内核崩溃?
    • 终止性:程序是否包含无限循环?(eBPF程序必须保证能在有限时间内执行完毕。
    • 权限:程序是否只调用了允许的内核辅助函数(Helper Functions)?
  • 如果校验失败,内核会拒绝加载该程序

Compilation & Attachment

校验通过后,内核会“认领”这个程序,并为它的运行做准备

(6) 即时编译 (JIT Compiler)

  • 校验通过的字节码(approved eBPF Program)会被交给 JIT编译器(Just-In-Time Compiler)
  • 如您文中所述,如果 bpf_jit_enable=1, JIT会将通用的 eBPF bytecode 翻译成 CPU原生机器指令(x86_64)
  • 好处:这使得eBPF程序的执行效率几乎与内核原生代码相同, 远快于“解释执行”

(7) 挂载 (Attachment)

  • 程序此时已经在内核中,并且是编译好的状态,但它还不会运行
  • 加载器还需要最后一步:将这个程序“挂载”(Attach)到一个特定的内核钩子(Hook Point)
  • 例如:
    • 挂载到网络套接字(Sockets)
    • 挂载到 TC(流量控制) / XDP(网卡驱动)
    • 挂载到 Tracepoints(内核函数跟踪点)

Execution & Interaction

这是程序最终运行并发挥作用的阶段!

(8) 触发执行 (Trigger)

  • 程序被挂载后,它就处于待命状态。当内核的执行流碰到这个钩子时,程序就会被触发
  • 如图所示:
    1. 一个普通进程(Process)调用了 sendmsg() 发送网络包
    2. 这个调用进入内核,流经网络协议栈 (Sockets -> TCP/IP)
    3. 当它到达挂载了eBPF程序的那个钩子(Sockets层)时, eBPF程序就会被立即执行

(9) 沙箱内执行 (Sandbox Execution)

  • 即使被JIT编译成了原生指令, eBPF程序仍然在一个受限的“沙箱”中运行
  • 它不能随意调用内核函数或访问内存

eBPF程序在沙箱中能做什么? 有三种调用方式:

  • 辅助函数 (Helper Call):
    • eBPF程序通过一个特殊的 ebpf call 指令, 请求内核执行预先定义好的、安全的辅助函数
    • e.g. 读写eBPF Maps、获取当前时间、获取进程ID
  • 内部函数 (Internal Call):
    • 可以调用自己在程序内部定义的其他函数,以便复用代码
  • 尾调用 (Tail Call):
    • 一个eBPF程序可以“跳转”到另一个eBPF程序, 且不再返回(类似 goto), 这用于实现复杂的逻辑链

eBPF程序在执行期间,最主要的“副作用”就是通过辅助函数读写eBPF Maps

例如: 一个网络eBPF程序可以统计收到的数据包, 并将计数器写入一个Map。然后, 在用户空间的加载器程序可以随时读取这个Map, 以向用户展示实时的统计数据

Case Study

目前我们只是看到了eBPF的来龙去脉, 但也只是冰山一角, 还有很多的问题值得去学习:

  1. eBPF Map
  2. BTF
  3. 库 libbpf/bpftool
  4. 语法/调用方式

但这篇文章的目的是理清eBPF的前世今生、整套workflow, 因此过于细节的知识点并不会出现在本文

我们将采用两个案例来让读者简单感受一下 eBPF 的使用方式:

  1. 直接函数调用
    1. 工具: bpftrace. 一个eBPF的高级跟踪语言, 它把"编写C-编译-加载"的整个 Workflow 隐藏在一个简单的脚本语言背后
    2. 场景: 追踪系统上所有 execve 系统调用(所有新执行的程序), 并打印出"PID"和"程序名"
  2. 自己写-编译-调用
    • 复刻案例一的功能: 追踪 execve. 但这次, 要手动编写内核eBPF程序

直接函数调用

先安装 bpftrace:

Bash
1
2
sudo apt update
sudo apt install -y bpftrace

执行脚本. 追踪所有新执行的程序:

Bash
1
2
3
4
5
# -e '...' : 执行的脚本
# tracepoint:... : 附加到一个Kernel HOOK Point
# { ... } : 触发时执行的动作

sudo bpftrace -e 'tracepoint:syscalls:sys_enter_execve { printf("PID %d executing: %s\n", pid, str(args->filename)); }'

执行效果:

alt text

自己写eBPF程序-编译-调用

第一步: 准备环境 + 基础文件

(1) 配置环境:

Bash
1
2
sudo apt update
sudo apt install -y clang libbpf-dev linux-headers-$(uname -r) gcc libelf-dev

(2) 获取 BTF (内核类型信息):

Bash
1
2
# 生成一个 vmlinux.h 头文件,C代码会引用它
bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h

(3) 编写eBPF内核程序 (命名 trace.bpf.c):

C
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// trace.bpf.c
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>

// 1. 定义一个数据结构, 用于从 Kernel 传递给 User Space
struct exec_data {
    pid_t pid;
    char comm[16]; // 进程名
};

// 2. 定义一个eBPF Map: Perf Event Array
// 这是内核与用户空间通信的桥梁
struct {
    __uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
    __uint(key_size, sizeof(u32));
    __uint(value_size, sizeof(u32));
} events SEC(".maps");

// 3. eBPF程序本体

// SEC("...") 是宏, 告诉加载器这个程序应该挂载在哪里
SEC("tp/syscalls/sys_enter_execve")

int handle_exec(struct trace_event_raw_sys_enter *ctx) {
    struct exec_data data = {};

    // 4. 使用 eBPF 辅助函数
    data.pid = bpf_get_current_pid_tgid() >> 32;
    bpf_get_current_comm(&data.comm, sizeof(data.comm));

    // 5. 通过 Map 将数据发送到 User Space
    bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &data, sizeof(data));
    return 0;
}

// 必须的许可证
char LICENSE[] SEC("license") = "GPL";

(4) 编写用户空间加载器 (trace.c):

C
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// trace.c
#include <stdio.h>
#include <unistd.h>
#include <bpf/libbpf.h>
#include <bpf/bpf.h>

// 1. 必须和内核中的 struct exec_data 保持一致
struct exec_data {
    pid_t pid;
    char comm[16];
};

// 2. Map (Perf Buffer) 的回调函数
// 当从内核收到数据时,此函数被调用
void handle_event(void *ctx, int cpu, void *data, __u32 size) {
    struct exec_data *ed = (struct exec_data *)data;
    printf("PID %d executing: %s\n", ed->pid, ed->comm);
}

int main(int argc, char **argv) {
    struct bpf_object *obj;
    struct bpf_program *prog;
    struct perf_buffer *pb;
    int err;

    // 3. 打开、加载、验证 eBPF ELF 文件
    obj = bpf_object__open_file("trace.bpf.o", NULL);
    if (!obj) {
        fprintf(stderr, "ERROR: opening BPF object file failed\n");
        return 1;
    }

    err = bpf_object__load(obj);
    if (err) {
        fprintf(stderr, "ERROR: loading BPF object file failed\n");
        return 1;
    }

    // 4. 自动挂载 SEC("...") 标记的程序
    err = bpf_object__attach_skeleton(obj);
    if (err) {
        fprintf(stderr, "ERROR: attaching BPF skeleton failed\n");
        return 1;
    }

    // 5. 查找 "events" Map, 并设置 perf buffer 回调
    pb = perf_buffer__new(bpf_object__find_map_fd_by_name(obj, "events"), 8, handle_event, NULL, NULL, NULL);
    if (!pb) {
        fprintf(stderr, "ERROR: setting up perf buffer failed\n");
        return 1;
    }

    printf("eBPF program loaded. Waiting for exec() calls...\n");

    // 6. 轮询等待数据
    while ((err = perf_buffer__poll(pb, 100)) >= 0) {
        // 循环
    }

    return 0;
}

第二步: 编译

Bash
1
2
3
4
5
6
7
# 编译 eBPF 程序:
# -target bpf: 目标架构是 bpf
# -c: 生成 .o 文件
clang -g -O2 -target bpf -c trace.bpf.c -o trace.bpf.o

# 编译 Loader:
gcc trace.c -o trace -lbpf -lelf

第三步: 测试结果

Bash
1
2
# 新开一个终端
sudo ./trace

Over!