eBPF学习总结(未完)
简介
本文会从架构入手,介绍 eBPF 能做什么、适合做什么,以及它大致能做到什么程度。
一eBPF的架构解读

Development 开发
传统的 eBPF 原生开发方式通常依赖特定内核版本的头文件,因此跨内核兼容性较差;后续出现的 BTF 和 CO-RE 在很大程度上改善了这一问题。
eBPF 程序通常通过挂载到内核或用户态的特定执行点来获取事件和上下文数据,例如 tracepoint、kprobe、uprobe、XDP 等。
eBPF的节点在通常的情况下,并不会影响内核的使用,只有当开启的时候才会一定的影响,具体的影响取决于开发者所编写的eBPF程序加载到了哪个节点上,以及开发者使用了多少节点。
1. eBPF 应用的组成
eBPF的程序通常分为用户态程序和内核态程序,通常内核态程序是用于截获数据,用户态负责处理数据。当然这并不代表在内核态不能处理数据。
2. Kernel 内核态程序
内核态程序本质上是编译后的 eBPF 字节码。开发时通常先使用 C 编写,再通过 Clang/LLVM 编译成 eBPF 字节码,最后由用户态程序加载到内核中运行。
eBPF 程序运行在内核中,但并不能像普通内核模块那样任意执行。程序在加载前需要经过 **eBPF Verifier **的安全检查,以确保不会出现越界访问、非法跳转或不可终止循环等问题。通过验证后,程序再由内核中的 eBPF 执行环境解释执行或经 JIT 编译后运行。
从用途上看,内核态 eBPF 程序主要负责
- “在什么时机获取什么数据”
- “对这些数据做哪些轻量级处理”。
需要注意的是,eBPF 程序具体能实现哪些功能,与 内核版本密切相关。虽然 Linux 在较早版本中就已经引入了 eBPF 的基础能力,但今天常见的程序类型、Map 类型、Helper 函数以及 BTF、CO-RE、LSM 等高级特性,都是在后续多个内核版本中逐步完善的。
从 5.x 开始,eBPF 在可观测性、网络、安全和可移植性方面都明显成熟,尤其是 BTF、CO-RE、trampoline、LSM 等能力让开发体验和应用场景显著扩展。
因此,判断一个 eBPF 程序是否能够运行,不能只看“是否支持 eBPF”,还需要进一步确认:
- 当前内核是否支持对应的 程序类型
- 是否支持所需的 Map 类型
- 是否提供所依赖的 Helper 函数
- 是否具备 BTF、CO-RE、JIT 等相关能力
一般来说,4.x 内核已经具备不少实用能力,而 5.x 内核上的 eBPF 生态则明显更加成熟。尤其是在较新的长期支持版本中,eBPF 在可观测性、网络处理、安全控制和跨版本适配方面都更加完善,因此也是当前学习和生产实践中更常见的选择
3. 用户态程序
用户态程序整体上就跟普通程序其实差不多,它运行在用户态空间,主要负责与eBPF内核态程序协同工作。通常的情况下它负责将内核态程序加载到内核、管理eBPF map、读取内核态提供的数据,以及对这些数据进行更进一步的处理
与内核态程序相比,用户态程序对内核头文件的直接依赖相对较小。它更多依赖用户态的 eBPF 开发库和相关接口,例如 libbpf、bpftool 生成的头文件,或者系统提供的 bpf() 系统调用封装。因此,用户态程序更多体现为对内核态程序的管理、控制与数据消费,是整个 eBPF 应用中的控制面和处理面。
需要注意的是用户态程序与内核态程序相比,它并不直接受到内核的影响,它的影响更多的是取决于内核态所提供的信息的准确性,它能否借助eBPF内核态程序稳定的读取到自身所需要的上下文数据,并与内核态进行有效的交互,也就是说受限制的并不是用户态程序本身,而是其所依赖的 eBPF 能力是否被当前内核支持。
这些能力包括 eBPF 程序类型、Map 类型、Helper 函数、数据传输机制,以及 BTF、CO-RE、ring buffer 等相关特性。因此,在实际开发中,用户态程序的实现方式虽然相对灵活,但其可实现的功能范围和开发便利性,仍然会间接受到底层内核版本的影响。
4. eBPF map
可以把 eBPF Map 理解成:内核提供的一种通用数据容器。
用户态程序可以对 Map 中的数据进行创建、查询、更新和删除;运行在内核中的 eBPF 程序也可以对其进行读取或更新。因此,eBPF Map 是 eBPF 机制中重要的数据交互方式。
eBPF 程序本身有一个很大的限制:
它不能像普通程序那样随意申请内存,也不能方便地长期保存复杂状态。eBPF 程序每次被事件触发执行时,运行时间通常很短,更适合做“快速处理”。
所以,如果想让 eBPF 程序:
- 记住某些状态
- 保存统计数据
- 接收用户态下发的配置
- 把运行结果传回用户态
就需要借助 Map。
因此,Map的核心作用可以概括为三类:
- 状态存储
保存eBPF程序运行国产中需要维护的数据 - 用户态与内核态通信
用户态程序往Map里面写配置,eBPF去读取;
或eBPF程序把统计结果写进Map,用户态再读取 - 不同eBPF程序之间共享数据
多个eBPF程序可以共同访问同一个Map
4.1 为什么需要Map
理解Map,需要知道eBPF程序有什么限制。
eBPF程序运行在内核中,但它并不是普通的内核代码。为了安全性,内核验证其会严格限制它的行为,比如:
- 不能随意访问任意内存
- 不能无限循环
- 栈空间很小
- 不适合保存长期状态
这意味着,eBPF程序它本身不适合承担“大量数据存储”或者“长期状态管理”,因为这会拖累内核运行。
所以内核单独提供了Map机制,作为一种安全、受控、可共享的数据存储方式。
你可以把它简单理解为:
- eBPF程序负责处理逻辑
- Map负责保存数据
4.2 Map一般保存什么
Map中的数据取决于具体用途,常见的有
- 配置项
例如是否开启某个功能、过滤哪些PID、端口阈值是多少 - 统计信息
例如某个系统调用触发了多少次,某个IP发了多少字节数据 - 状态信息
例如某个链接是否建立,某个进程是否处于被跟踪状态 - 临时关联数据
例如在函数入口记录一个时间戳,在函数返回时再取出计算耗时
4.3 Map的基本结构
它的基本上差不多可以总结为三个部分:
- Key:键的类型和大小
- Value:值的类型和大小
- max_entries:最多能放多少项
例如:
- Key:PID
- Value:请求次数
- max_entries:102400
这表示:Map 里最多维护 10240 个 PID,每个 PID 对应一个计数值。
不过也要注意,不是所有 Map 都严格是普通键值结构。
有些特殊类型,比如 ring buffer、perf event array,本质上更像“事件传输通道”,只是也被归在 eBPF Map 体系里。
5. 用户态与内核态访问Map
5.1 用户态访问
用户态程序通常通过系统调用去访问,可以通过框架如libbpf提供的接口操作Map
例如:
- 创建Map
- 查找某个key对应的value
- 更新某个key对应的value
- 删除某个key
- 遍历所有的key
那么对应的libbpf框架的函数就是:
- lookup
- update
- delete
- get_next_key
5.2 内核态eBPF程序访问
eBPF程序在内核中并不能像用户态程序那样直接操作任意数据结构,而是通过BPF helper来访问Map
常见的helper包括:
- bpf_map_lookup_elem
- bpf_map_update_elem
- bpf_map_delete_elem
例如:
- 根据key查找值
- 如果查找到,就修改这个值
- 如果没查找到,就插入初始值
这一类模式在计数统计的eBPF程序中非常常见
6. 常见的Map类型
这是一个eBPF非常重要的部分,不同类型的Map对应不同的场景
6.1 Hash Map
这是最常用的一类Map,它在某种程度上能够非常灵活的控制eBPF程序的执行状态。
特点:
- 按key存储value
- 适合快速查找
- key不要求连续
- 常用于维护某种“对象-》状态”的映射关系
典型的场景:
- PID -> 配置
- socket -> 链接状态
- IP -> 统计信息
如果是想按Python中字典那样是根据一个标识符去查一个值,那么非常推荐Hash Map
6.2 Array Map
顾名思义是个数组Map,它的 key 通常是固定范围内的整数下标,并且范围在创建时就已经确定。
特定:
- key一般是从0开始连续下标
- 大小固定
- 查找效率高
- 不适合稀疏键
典型的场景:
- 按固定编号保存某类对象或状态
- 固定编号保存某一些对象
- 按CPU编号,协议编号,事件类型编号去存储统计数
比如说:
- key = 0表示功能开关
- key = 1表示阈值
- key = 3表示模式选择
这种方式本质上就是通过固定下标访问对应位置的数据。
另外,对于某些可以拆分的数据,也可以在提取出目标字段后作为数组下标或数组内容使用。比如 bpf_get_current_uid_gid() 返回一个 u64 值,其中低 32 位通常表示 UID,高 32 位表示 GID。开发者可以先将其拆分,再按需要存入 Array Map 中。
当然具体使用场景看开发者怎么想,怎么实现自身所需功能。
6.3 Per-CPU Map
这个Map我自身并不怎么了解,我将根据我学习到的进行总结:
Per-CPU Map 不是按“一个 key 对应一个 value”,而是:
一个 key 对应每个 CPU 各自的一份 value 副本。
常见有:
- Per-CPU Hash
- Per-CPU Array
特点: - 每个 CPU 写自己的副本
- 能减少多 CPU 并发更新时的竞争
- 特别适合计数统计
典型场景:
- 高频事件计数
- 按 CPU 分桶统计
- 低冲突、高性能数据收集
缺点是它对于开发者的技术能力要求较高,开发者需要较高的系统和硬件理解,才可能用的好它。
6.4 LRU Hash Map
淘汰型Hash Map,它能够自动将一些不太常用的项进行覆盖。
特点:
- 容量固定
- 满了之后会自动淘汰不常使用的项
- 适合 key 数量不确定、又不能无限增长的场景
典型场景:
- 跟踪短期内的活跃节点
- 缓存最近出现的进程或请求对象
- 以及一些需要进行不确定的测试一些情况
Hash Map 越长越大、难以控制内存,就可以考虑 LRU Hash
6.5 Queue / Stack
顾名思义是类似于队列与栈的一个Map
- Queue:队列:先进先出
- Stack:栈:先进后出
特点: - 不强调通过 key 查找
- 强调顺序
- 适合简单的事件
实际用的时候不是很常用。
6.6 Perf Event Array
Perf event array它与其他的Map不同,它是当发生事情的时候会向用户态直接上报事件。
并不直接存储数据,是一种早期常见的内核态向用户态传递事件的一种方式
它常用于:
- eBPF 内核程序把事件上报给用户态
- 用户态实时接收这些事件
比如:
- 上报进程创建事件
- 上报网络连接事件
- 上报文件访问事件
它更像是一个事件输出通道,而不是普通意义上的键值存储。
6.7 Ring Buffer
Ring Buffer 是后来的更现代的一种事件传输机制,也很常见。
特点:
- 用于 eBPF 程序向用户态输出事件
- 接口通常更统一
- 在很多场景下比 perf event array 更易用
典型场景:
- 传输结构化事件数据
- 实时追踪和观测
6.8 怎么选择 Map 类型(AI总结)
6.8.1 按照功能进行筛选
1)按某个标识查状态
比如 PID、TGID、IP、socket、cgroup id
→ 用 Hash Map
2)按固定下标存配置或统计
比如 key 只有 0、1、2、3
→ 用 Array Map
3)做高频计数,减少并发竞争
→ 用 Per-CPU Hash / Per-CPU Array
4)需要自动淘汰旧项
→ 用 LRU Hash Map
5)把事件实时发给用户态
→ 用 Ring Buffer 或 Perf Event Array
6.8.2 按照“事件”与“状态”进行选择
Map 适合存状态
比如:
当前有哪些 PID 被跟踪
每个连接累计了多少字节
某个请求开始时间是多少
Ring Buffer / Perf Event 适合发事件
比如:
某进程刚刚创建
某文件刚刚被打开
某连接刚刚建立
这些通常是“一次性消息”,更像日志或通知。
总结就是:
状态数据放 Map
**瞬时事件走 Ring Buffer / Perf Event **
7. eBPF 的挂载点类型 attach point
7.1挂载点是什么
它似乎不叫挂载点,只是我习惯性那么叫它。它一般指的是可支持eBPF程序加载的地方,因此它没有什么一个固定的称呼,只是我根据它的行为将这些节点叫做挂载点
7.2 eBPF程序分类
eBPF程序支持的挂载点非常的丰富,它几乎涵盖了系统的所有地方,但通常为区分功能,人们会对eBPF程序进行分类,以下是比较常见的eBPF程序的类型:
- XDP:高性能网络处理
- kprobe:内核函数跟踪
- uprobe:用户态函数跟踪
- tracepoint:稳定跟踪点
- tc:网络流量控制
- LSM:安全Hook
- cgroup:资源/网络控制
7.3 其他的一些补充
对于 eBPF 内核程序来说,它有一套自己的规范和组织方式。下面补充两个在 C + libbpf 开发方式中经常见到的机制。
如果你使用的是 Python、Go 或 Rust 等语言,具体写法可能会有所不同,但底层原理通常是一致的。
- SEC():用于声明程序或 Map 所属的 ELF section。加载器会根据 section 名称识别程序类型,并在很多情况下进一步确定程序应附加到哪一类挂载点上。
例如:SEC("xdp")、SEC("kprobe/do_sys_open")、SEC("tracepoint/syscalls/sys_enter_execve") - license():用于声明 eBPF 程序的许可证信息,常见写法例如:
char LICENSE[] SEC("license") = "GPL";在 Linux 中,某些 eBPF helper 函数或特性只对 GPL-compatible 的程序开放,因此许可证声明不仅是元信息,也可能影响程序能否使用某些内核能力。
8. eBPF 程序的加载与执行机制
eBPF 程序通常不会以源码形式直接进入内核,而是先由用户态编译为 eBPF 字节码,再通过 bpf() 系统调用或 libbpf 等方式加载到内核中。程序被内核接受之前,必须先通过 Verifier 的安全校验;在支持并启用 JIT 的系统中,eBPF 字节码还可以进一步被编译为本机机器码,以提升执行效率。
8.1 加载阶段
eBPF 程序通常由用户态完成加载。开发者先使用 Clang/LLVM 将源码编译为 eBPF 字节码文件,再由用户态程序通过 bpf() 系统调用、libbpf 或其他工具将程序送入内核。与程序配套使用的 Map 也通常在这一阶段由用户态创建,并与程序建立关联。
8.2 校验阶段
程序进入内核后,并不会立即执行,而是首先经过 Verifier 校验。Verifier 会检查程序是否满足安全要求,例如是否能够保证终止、是否存在非法内存访问、是否正确使用上下文与 helper 函数等。只有通过校验的程序,才允许继续后续流程。
8.3 附加阶段
校验通过后,程序会被附加到指定的挂载点(Attach Point / Hook Point)上。不同类型的 eBPF 程序可附加的位置不同,例如网络路径、系统调用、内核函数、tracepoint 或 XDP 等。附加完成后,程序本身并不会持续运行,而是处于等待触发的状态。
8.4 执行阶段
当对应的内核事件发生时,挂载在该位置的 eBPF 程序会被触发执行。程序在受限的执行环境中运行,可结合当前上下文进行过滤、统计、采集或处理,并按需将结果写入 Map、perf buffer 或 ring buffer,以便后续被用户态读取。
8.5 JIT 的作用
在部分系统中,内核可将通过校验的 eBPF 字节码进一步转换为本机机器码执行,这一过程称为 JIT(Just-In-Time Compilation)。JIT 不是 eBPF 程序生效的前提条件,但通常能够显著提升运行性能。
总体而言,eBPF 程序采用的是一种“预先加载、事件触发、执行即退出”的运行方式,而不是在内核中长期驻留并持续执行的常驻逻辑。这样的机制使其在保证安全性的同时,也具备较强的灵活性和较高的执行效

浙公网安备 33010602011771号