本文地址:https://www.ebpf.top/post/02-understanding-ebpf-core-building-blocks

上一篇文章中,我们使用 Rust + eBPF 构建了一个系统调用跟踪器。这里,我们将解析使其工作的核心组件以及为 eBPF 工具提供核心动力的深层机制。理解这些核心组件将帮助你构建更复杂的可观测性、安全性和网络工具。

1. BPF 程序类型与钩子点

程序类型决定 eBPF 程序的功能范围,钩子点(Hook Point)则指定其在内核中的执行位置。主要程序类型如下所示:

XDP(快速数据路径)

  • 在网络数据包处理的最早阶段运行
  • 非常适合 DDoS 保护、负载均衡
  • 可以 DROP、PASS、REDIRECT 或 TX 数据包

TC(流量控制)

  • 附加到网络入口 / 出口处
  • 比 XDP 有更多上下文,可以修改数据包
  • 用于进阶的数据包过滤和整形

Kprobes/Kretprobes

  • 几乎可跟踪任何内核函数的动态跟踪
  • Kprobe = 函数入口,Kretprobe = 函数返回

Uprobes/Uretprobes

  • 跟踪用户空间应用程序(如 Go、Python 应用)
  • 可以检查函数参数、返回值
  • 非常适合应用程序性能监控

Tracepoints

  • 静态内核插桩(instrumentation)点
  • 跨内核版本方面比 kprobes 更稳定
  • 预定义事件如 <sys_enter_openat>

你可以在以下位置查看所有可用的跟踪点:/sys/kernel/tracing/events。我们的系统调用跟踪程序使用了<sys_enter>

CGROUP(控制组)

  • 按容器 / 进程组控制资源访问
  • 实施自定义策略(网络、文件访问)

Socket Programs(套接字程序)

  • 在套接字级别过滤 / 重定向数据包
  • 实现自定义负载均衡器、防火墙

注意:虽然这里介绍了我最熟悉的常用程序类型,但 Linux 内核还支持其他多种(且不断演进的)程序类型。完整的最新列表请参考官方文档:Supported eBPF Program Types

2. BPF 验证器:安全守护者

BPF 验证器是确保 eBPF 安全运行的关键组件。 验证器在将程序加载到内核之前对其执行静态分析。验证器检查内容:

内存安全

  • 无越界数组访问
  • 无空指针解引用
  • 变量的正确初始化

控制流

  • 无无限循环(Linux 5.3 起仅支持有界循环)
  • 所有代码路径必须退出
  • 无不可达代码

辅助函数使用

  • 只能调用程序类型许可的辅助函数(Helper Function)
  • 正确的参数类型和数量

栈使用

  • 限制为 512 字节的栈空间
  • 无需栈溢出保护
1
2
3
4
5
6
7
8
9
// ❌ 这将被验证器拒绝
for (int i = 0; ; i++) {  // 无限循环
    // 代码
}

// ✅ 这将被接受
for (int i = 0; i < 100; i++) {  // 有界循环
    // 代码
}

为什么重要:验证器确保 eBPF 程序不会导致内核崩溃,使其可以安全地在生产环境中运行。

3. BPF map:数据存储与通信

BPF map 提供三大核心能力:

  • 在 eBPF 程序调用之间存储状态
  • 在内核和用户空间之间通信
  • 在不同 eBPF 程序之间共享数据

基本 map 类型:

哈希 map(Hash Maps) - 键值存储

1
2
3
4
5
6
struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __uint(max_entries, 1024);
    __type(key, u32);           // PID
    __type(value, u64);         // 系统调用计数
} syscall_counts SEC(".maps");

数组(Arrays) - 基于索引的访问

1
2
3
4
5
6
struct {
    __uint(type, BPF_MAP_TYPE_ARRAY);
    __uint(max_entries, 256);   // 每个系统调用号一个
    __type(key, u32);
    __type(value, u64);
} syscall_stats SEC(".maps");

性能事件数组(Perf Event Arrays) - 高性能数据流

1
2
3
4
5
struct {
    __uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
    __uint(key_size, sizeof(u32));
    __uint(value_size, sizeof(u32));
} syscall_events SEC(".maps");

你可以在上一篇博客文章的代码中找到这个的用法:

1
2
// 发送数据到用户空间
bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &data, sizeof(data));

环形缓冲区(Ring Buffers) - perf 事件的现代替代方案(Linux 5.8+)

  • 更低的性能开销与更高的内存效率
  • 内置流控处理

map 使用场景:

  • 计数器:跟踪指标(数据包、系统调用、错误)
  • 缓存:存储查找结果以避免重复工作
  • 状态机:跟踪连接状态、用户会话
  • 配置:来自用户空间的运行时配置

4. 辅助函数:内核 API

eBPF 程序不能直接调用内核函数。相反,它们使用辅助函数 —一组经过严格筛选的安全内核 API。常见辅助函数类别:

调试与日志

1
bpf_printk("PID %d called syscall %d\n", pid, syscall_nr);

map 操作

1
2
3
4
5
6
7
8
// 从map读取
void *value = bpf_map_lookup_elem(&my_map, &key);

// 更新/插入map
bpf_map_update_elem(&my_map, &key, &value, BPF_ANY);

// 从map删除键/值
bpf_map_delete_elem(&my_map, &key);

上下文信息

1
2
3
4
// 这是获取进程的 Pid 和 Tid 的方法
// 用法:我们在上一篇博客中使用这个辅助函数从内核读取pid
u64 pid_tgid = bpf_get_current_pid_tgid();
u32 pid = pid_tgid >> 32;

网络操作

1
2
3
4
5
// 将数据包重定向到另一个接口
bpf_redirect(ifindex, 0);

// 修改数据包数据
bpf_skb_store_bytes(skb, offset, data, len, 0);

时间与随机数

1
2
u64 timestamp = bpf_ktime_get_ns();
u32 random = bpf_get_prandom_u32();

辅助函数的使用范围取决于 eBPF 程序类型,同时辅助函数数量也取决于内核版本。

5. BTF & CO-RE:一次编写,到处运行

BTF(BPF 类型格式)和 CO-RE(一次编译,到处运行)解决了内核兼容性问题。

问题

内核结构在不同版本之间会变化:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// Kernel 5.4
struct task_struct {
    int pid;
    char comm[16];
    // ... 其他字段
};

// Kernel 5.10 - 字段移动了!
struct task_struct {
    char comm[16];
    int pid;        // 不同的偏移量!
    // ... 其他字段
};

解决方案 - CO-RE

我们在上一篇文章中简要介绍了这一点。现在,让我们探讨 CO-RE 技术如何工作以及为什么它对于构建可移植的 eBPF 程序至关重要。

CO-RE 技术基于 BTF(BPF 类型格式)实现,使 eBPF 程序在运行时感知内核版本,而无需为每个内核重新编译。

1
2
3
4
5
6
#include "vmlinux.h"  // 生成的BTF类型

struct task_struct *task = (struct task_struct *)bpf_get_current_task();

// CO-RE在加载时自动调整字段偏移!
int pid = BPF_CORE_READ(task, pid);

以下是工作原理:

  • vmlinux.h 包含使用 BTF 从内核提取的类型定义。
  • BPF_CORE_READ()安全地从 task_struct 读取 pid 字段,无论其偏移量如何。
  • 加载 eBPF 程序时,libbpf 将编译的偏移量与实际内核的布局进行比较,并根据需要重新定位字段访问。

使用 CO-RE 收益

  • 一次编译,在任何内核版本上运行
  • 自动字段偏移重定位
  • 运行时内核适配

这就是为什么 build.rs包含 <vmlinux> — 它为目标内核提供 BTF 类型!

6. BPF 尾调用(tail call):程序链接

尾调用允许一个 eBPF 程序调用另一个,实现:

  • 跨多个程序的复杂逻辑
  • 运行时程序更新
  • 模块化架构
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 程序数组map
struct {
    __uint(type, BPF_MAP_TYPE_PROG_ARRAY);
    __uint(max_entries, 10);
    __type(key, u32);
    __type(value, u32);
} programs SEC(".maps");

// 尾调用到另一个程序
bpf_tail_call(ctx, &programs, program_index);
// 此程序在此结束 - 控制权完全转移

使用场景:

  • 数据包处理管道
  • 功能切换(启用 / 禁用功能)

7. BPF Token(BPF 令牌):委托特权

BPF Token(BPF 令牌)通过允许特权委托,实现容器中的安全 eBPF。

问题

传统上 eBPF 需要<CAP_BPF><CAP_SYS_ADMIN>,给容器提供了太多特权。

解决方案

1
2
3
4
5
# 特权进程(容器运行时)创建令牌
bpftool token create /sys/fs/bpf/token delegate_cmds prog_load,map_create

# 非特权容器使用令牌
bpf_prog_load_token(prog_fd, token_fd, ...)

收益

  • 容器可以在没有 root 的情况下运行 eBPF
  • 细粒度权限控制
  • 更好的安全隔离

注意:BPF 令牌从 Linux 6.7 开始可用。有关完整用法,请参阅内核文档。

8. eBPF 工具生态系统

bpftool - 瑞士军刀

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 列出所有程序
bpftool prog list

# 显示程序详情
bpftool prog show id 123

# 转储程序字节码
bpftool prog dump xlated id 123

# 将程序固定到文件系统
bpftool prog pin id 123 /sys/fs/bpf/my_prog

bpftrace - 动态跟踪脚本 bpftrace 是调试 eBPF 程序的快速方法。

1
2
3
4
5
# 跟踪文件打开
bpftrace -e 'tracepoint:syscalls:sys_enter_openat { printf("%s opened %s\n", comm, str(args->filename)); }'

# 网络数据包计数
bpftrace -e 'kprobe:dev_queue_xmit { @packets[comm] = count(); }'

BCC - Python eBPF 框架 BCC 是一个强大的工具包,用于编写和运行 eBPF 程序。

  • 高级 Python 接口
  • 非常适合原型设计和学习
  • C 语言实时编译

我们在上一篇博客中讨论的 Rust/libbpf-rs 方法比 BCC 更现代、性能更好!

9. 综合应用

以下是这些组件如何在我们在上一篇文章中演示的系统调用跟踪器中工作:

  • 程序类型:跟踪点<sys_enter_*>
  • 钩子点:系统调用入口点
  • 验证器:验证程序安全性
  • BPF map:用于数据传递的 Perf 事件数组
  • 辅助函数<bpf_get_current_pid_tgid()><bpf_perf_event_output()>
  • BTF/CO-RE:内核兼容性机制
  • 工具:用于调试的 bpftool,用于用户空间管理控制的 Rust

有了对 eBPF 基础构建块的扎实理解,你现在已经准备好超越理论,开始构建强大的生产级工具。

原文地址:https://dev.to/maheshrayas/-02-understanding-ebpf-core-building-blocks-1221

作者:Mahesh Rayas