eBPF 学习笔记(面向内核 / 网络开发工程师)
假设你已经阅读过 cBPF 相关文档,并对 Linux 网络栈、kprobe、netfilter 等机制有一定了解。
背景介绍
eBPF 的核心目标是:在不修改内核源码、无需重启系统的前提下,将用户自定义的逻辑安全地注入到内核的关键执行路径中运行。
典型应用场景包括:
- 在最早的数据包接收阶段(XDP 层)实现丢包控制、流量重定向或负载均衡;
- 在 tc 的 ingress 或 egress 阶段进行 QoS 控制和流量整形;
- 通过 kprobe 或 tracepoint 实现细粒度性能分析与在线故障排查;
- 结合 socket 和 cgroup 实现访问控制、速率限制等安全策略。
可以将 eBPF 理解为一套完整的内核级可编程机制,具备受限沙箱环境以及完善的工具链支持(如 clang、libbpf、bpftool 等),形成一个强大的生态系统。
1. eBPF 与 cBPF 的主要区别:升级点解析
相较于传统的 cBPF,eBPF 在多个维度实现了显著增强:
更强大的指令集与寄存器架构
cBPF 仅提供两个 32 位寄存器 A 和 X,外加一个临时存储区域(scratch space)用于辅助计算。
eBPF 则引入了 11 个 64 位通用寄存器:
R0–R10,具体用途如下:
- R0:函数返回值寄存器;
- R1–R5:传递函数参数;
- R6–R9:被调用者保存的寄存器,可用于长期存放局部变量;
- R10:帧指针,指向当前栈顶位置。
此外,每个程序拥有最多 512 字节的栈空间,可通过 R10 加偏移的方式访问(例如 r10 - 16)。
M[16]
eBPF 支持调用预定义的 helper 函数,并能通过 map 结构实现数据持久化和用户态通信。常见的交互方式包括哈希表、数组、ringbuf 等结构。
bpf_map_update_elembpf_get_prandom_u32bpf_redirect
丰富的挂载点与程序类型
eBPF 可以附加在多种内核事件点上,包括但不限于:
- XDP
- tc ingress / egress
- kprobe / tracepoint
- socket 操作
- cgroup 子系统
- LSM(Linux Security Module)
- perf 事件监控
静态验证机制(Verifier)
所有 eBPF 程序在加载至内核前必须经过 Verifier 的严格校验,确保其安全性。主要检查内容包括:
- 内存访问无越界;
- 指针操作合法且受控;
- 循环结构具有静态可确定的上界,防止无限循环;
- 所有执行路径均正确初始化寄存器与栈变量。
若程序未能通过验证,则拒绝加载。因此,可以形象地理解为:cBPF 是简单的过滤脚本,而 eBPF 提供了一个“迷你 CPU + 安全沙箱”的完整执行环境。
2. eBPF 执行模型详解
2.1 寄存器与调用约定
| 寄存器 | 作用说明 |
|---|---|
| R0 | 用于存放函数返回值 |
| R1–R5 | 作为参数传递给 helper 函数 |
| R6–R9 | 由被调用方负责保存,适合用作局部状态存储 |
| R10 | 帧指针,始终指向栈顶 |
栈空间使用规则
每个 eBPF 程序最多可使用 512 字节的栈空间,访问方式限定为基于 R10 的负偏移形式,即:R10 - offset(offset 为正整数)。
示例代码:
// 假设要在栈上创建一个本地结构体 foo
struct foo *ptr = (void *)(long)ctx;
// 编译后实际生成类似指令:*(struct foo *)(r10 - 16)
Verifier 会强制检查所有栈访问是否落在 [-512, -1] 范围内,超出则加载失败。
struct bpf_insn
返回值含义随程序类型变化
- XDP 程序:返回动作码,决定如何处理数据包;
XDP_PASSXDP_DROPXDP_TXXDP_REDIRECT - tc 程序:返回相应操作标识,控制后续转发行为;
TC_ACT_OKTC_ACT_SHOTTC_ACT_REDIRECT - kprobe 程序:通常返回 0,也可配合 override_return 等 helper 修改原函数返回值;
- tracepoint 程序:一般返回 0 表示正常执行;
- socket filter 程序:返回允许拷贝到用户空间的字节数,语义与 cBPF 类似。
2.2 指令格式与编码结构
eBPF 指令在内核中的表示结构如下:
struct bpf_insn {
__u8 code; // 操作码
__u8 dst_reg:4;
__u8 src_reg:4;
__s16 off;
__s32 imm;
};
直接手写此类指令较为少见,开发者通常采用 C 语言编写程序,再由编译器自动生成对应字节码。
例如:
SEC("xdp")
int xdp_drop_udp_53(struct xdp_md *ctx) {
// C 源码逻辑
}
该代码会被
clang -target bpf 工具链编译为标准的 eBPF 字节码 .o,最终加载进内核执行。bpf_insn
主要指令类别
- ALU/ALU64:执行算术运算(加减乘除)和位操作;
- JMP/JMP32:实现条件跳转、比较判断及函数调用;
- LD/ST:完成寄存器与栈或 map 之间的数据加载与存储;
:用于调用 helper 函数或子程序;CALL
:表示函数返回,结束执行。EXIT
3. Verifier:eBPF 的安全守门人
Verifier 是保障 eBPF 安全性的核心组件,任何程序在加载进入内核前都必须通过其静态分析验证。
关键验证项
内存安全
只允许访问以下几类已知安全的内存区域:
- 上下文结构体(如
)中明确暴露的字段;ctx - 从 map 查询返回的有效指针;
- 栈上已分配并初始化的对象。
所有指针偏移必须经过显式的边界检查,禁止任意类型转换成内核地址进行非法读写。
控制流终止性
程序不得包含无限循环。现代内核虽支持有界循环,但要求 Verifier 能够静态推导出最大迭代次数。
寄存器与栈初始化一致性
每条路径上的寄存器和栈变量在使用前必须已被赋值;
所有可能的执行分支最终返回的类型需保持一致。
程序大小限制
单个 eBPF 程序的指令数量存在上限(通常为 4096 条),防止过长程序影响系统稳定性。
eBPF 程序受到最大指令数量的限制,该限制可在内核中进行配置,通常范围在几千到十几万条之间。当超出此限制时,系统会触发常见错误提示:
R# unbounded memory access
invalid mem access
R# min value is negative, must be >= 0
invalid BPF_LD/BPF_ST instruction
编写 eBPF 程序的一个核心技巧是“取悦 Verifier”,即确保程序能通过内核校验器的检查。为此,需遵循以下实践:
- 在访问任何数组或指针前,必须先进行边界范围检查;
- 在条件分支中,确保各个路径都对指针进行了合法性验证;
- 优先使用由 helper 提供的 API 接口,例如:
bpf_xdp_adjust_head
bpf_map_lookup_elem
4. eBPF 程序类型与 Hook 点概览
以下列出的是常用的 eBPF 程序类别,每种可视为一个“Hook 家族”:
XDP(eXpress Data Path)
- 挂载于网卡驱动最前端的数据包接收点;
- 接收到的上下文包含 data / data_end 和 ingress 接口索引等信息;
- 返回值控制数据包处理行为,如:
BPF_PROG_TYPE_XDP
struct xdp_md *ctx
XDP_DROP / XDP_PASS / XDP_TX / XDP_REDIRECT
- 适用场景:抵御 DDoS 攻击、实现四层/七层负载均衡、早期丢包策略。
tc(Traffic Control)
- 挂载于 ingress 或 egress 队列调度器(clsact)上;
- 上下文结构通常为:
BPF_PROG_TYPE_SCHED_CLS
SCHED_ACT
struct __sk_buff *
- 返回动作包括:
TC_ACT_OK / TC_ACT_SHOT / TC_ACT_REDIRECT
- 典型用途:流量整形、QoS 控制、路由策略实施、透明代理功能。
kprobe / kretprobe
- 用于挂接到任意内核函数的入口或返回点;
- 上下文为:
BPF_PROG_TYPE_KPROBE
struct pt_regs *
- 支持读取函数参数、返回值以及当前进程信息;
- 常用于性能分析、延迟统计和系统调试。
tracepoint / raw_tracepoint
- 绑定到内核预定义的 tracepoint 上,不依赖具体函数名,因此更加稳定;
- 上下文为固定结构体,其定义位于内核对应的 tracepoint 头文件中;
- 适用于需要长期运行的监控与统计任务。
perf event
- 通过 perf 子系统的事件触发执行,如周期性采样或硬件事件;
- 主要用于系统性能剖析(profiling)。
BPF_PROG_TYPE_PERF_EVENT
cgroup 程序族
- 包括以下几种类型:
cgroup_skb
cgroup_sock
cgroup_sock_addr
- 挂载至特定 cgroup,仅影响该组内进程的网络行为;
- 适合实现基于 cgroup 的防火墙规则或带宽限速机制。
socket 程序
- 相较于传统 socket filter,功能更强大;
- 可在 socket 层实现数据包重写与拦截操作;
- 支持多种类型,例如:
SK_SKB
SK_MSG
SOCKET_FILTER
SOCK_OPS
LSM / LSM_CGROUP(安全相关)
- 利用 eBPF 实现 Linux 安全模块(LSM)hook,如 open、bind、exec 等系统调用的策略拦截;
- 可用于构建细粒度的安全控制机制,作为 SELinux 之外的补充方案。
5. eBPF 工具链与开发流程
5.1 编译:clang + LLVM
典型的编译命令如下:
clang -O2 -g -target bpf \
-D__TARGET_ARCH_x86 \
-c xdp_prog.c -o xdp_prog.o
注意事项:
- 必须指定目标架构为 BPF:
-target bpf
- 根据实际运行环境添加相应的宏定义以适配架构:
-D__TARGET_ARCH_x86 / arm / arm64 / riscv
- 建议启用调试信息选项:
-g
- 以便后续结合 BTF 和 CO-RE 技术进行符号解析与调试。
5.2 加载与附加:libbpf + bpftool
现代主流推荐方式为使用 libbpf + CO-RE(一次编译,到处运行):
- 要求内核开启 BTF 支持:
CONFIG_DEBUG_INFO_BTF=y
- 使用工具生成 skeleton 头文件:
bpftool gen skeleton
- 基于源码中的 BPF 程序定义:
.o
- 用户态 C 程序可通过标准接口加载并运行:
#include "xdp_prog.skel.h"
struct xdp_prog_bpf *skel;
skel = xdp_prog_bpf__open_and_load();
if (!skel) { ... }
xdp_prog_bpf__attach(skel); // 自动 attach 到配置好的网卡
高层级辅助工具介绍:
- bpftool:官方提供的命令行工具,可用于:
bpftool prog load/attach
bpftool map dump/update
bpftool gen skeleton
- bcc:提供 Python 和 C++ 封装,适合快速编写调试脚本;
- bpftrace:类 awk 的领域专用语言(DSL),适用于临时性的跟踪与诊断任务。
6. eBPF Map:状态存储与内核-用户态通信机制
eBPF Map 是一种内核级的键值存储结构,可供 eBPF 程序与用户态进程共享数据。
常见 Map 类型包括:
BPF_MAP_TYPE_HASH:通用哈希表;
BPF_MAP_TYPE_HASH
BPF_MAP_TYPE_ARRAY:固定大小数组;
BPF_MAP_TYPE_ARRAY
- per-CPU 版本(如):
*_PERCPU
- 每个 CPU 拥有独立副本,避免锁竞争;
- LRU 版本(如 LRU hash):
LRU_HASH
- 适用于需要淘汰旧记录的场景;
BPF_MAP_TYPE_RINGBUF:高效的单生产者多消费者环形缓冲区,依赖 Linux 5.7+ 内核版本;
BPF_MAP_TYPE_RINGBUF
BPF_MAP_TYPE_PERF_EVENT_ARRAY:传统的 perf buffer 实现;
BPF_MAP_TYPE_PERF_EVENT_ARRAY
BPF_MAP_TYPE_LPM_TRIE:前缀树结构,广泛用于 IP 前缀匹配场景。
BPF_MAP_TYPE_LPM_TRIE
在 eBPF 程序中通过 helper 函数访问 Map,示例定义(CO-RE 风格):
// map 定义(CO-RE 风格)
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__type(key, __u32); // 举例:IP
// 定义一个用于统计的 map,存储 IP 对应的计数值
__type(value, __u64);
__uint(max_entries, 1024);
} ip_cnt SEC(".maps");
// 在 eBPF 程序中对源 IP 进行计数处理
__u32 key = src_ip;
__u64 init_val = 1;
__u64 *val;
// 查找当前 IP 是否已有记录
val = bpf_map_lookup_elem(&ip_cnt, &key);
if (!val) {
// 若未存在,则初始化为 1
bpf_map_update_elem(&ip_cnt, &key, &init_val, BPF_ANY);
} else {
// 已存在则原子增加计数
__sync_fetch_and_add(val, 1); // 或等价于 (*val)++
}
// 用户态通过 libbpf 获取 map 数据
int map_fd = bpf_map__fd(skel->maps.ip_cnt);
__u32 key = ...; // 设置要查询的键
__u64 value;
// 从 map 中读取对应值
bpf_map_lookup_elem(map_fd, &key, &value);
ctx->data/ctx->data_end
7. 初探 XDP 丢包实例
本示例展示如何在 XDP 层面拦截并丢弃目标端口为 53 的 UDP 数据包。
7.1 内核态 eBPF XDP 程序实现
以下代码实现了基于 XDP 的数据包过滤逻辑:
// xdp_drop_udp_53.c
#include "vmlinux.h" // 支持 CO-RE 的内核结构定义(可由 bpftool 自动生成)
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_endian.h>
// 许可证声明,必须存在以加载程序
char LICENSE[] SEC("license") = "GPL";
// 将函数绑定到 XDP 执行段
SEC("xdp")
int xdp_drop_udp_53(struct xdp_md *ctx)
{
void *data_end = (void *)(long)ctx->data_end;
void *data = (void *)(long)ctx->data;
struct ethhdr *eth = data;
__u16 h_proto;
__u64 nh_off;
// 第一步:确保有足够的空间容纳以太网头部
nh_off = sizeof(*eth);
if (data + nh_off > data_end)
return XDP_PASS;
h_proto = bpf_ntohs(eth->h_proto);
// 第二步:仅处理 IPv4 数据包
if (h_proto != ETH_P_IP)
return XDP_PASS;
struct iphdr *iph = data + nh_off;
if ((void *)(iph + 1) > data_end)
return XDP_PASS;
// 第三步:只关注 UDP 协议
if (iph->protocol != IPPROTO_UDP)
return XDP_PASS;
// 第四步:根据 IP 头长度计算 UDP 头位置
__u32 ip_hdr_len = iph->ihl * 4;
struct udphdr *udph = (void *)iph + ip_hdr_len;
if ((void *)(udph + 1) > data_end)
return XDP_PASS;
// 第五步:检查目的端口是否为 53(DNS)
if (bpf_ntohs(udph->dest) == 53) {
// 可在此处更新 map 实现丢包统计功能
return XDP_DROP;
}
return XDP_PASS;
}
关键说明:
指针 + sizeof(...) <= data_end
指向当前数据包的起始地址;
每次访问 packet 数据前都必须校验指针有效性;
bpf_ntohs()
用于网络字节序与主机字节序之间的转换;
返回
XDP_DROP
表示在网卡驱动层级直接丢弃该包;
此操作使数据包不会进入内核协议栈的后续处理流程,如
ip_rcv
或
udp_rcv
7.2 用户态加载逻辑(基于 libbpf 的 skeleton 模式)
以下是简化的用户态程序框架,展示如何加载和挂载 XDP 程序:
#include "xdp_drop_udp_53.skel.h"
int main(int argc, char **argv)
{
const char *ifname = "eth0";
__u32 ifindex = if_nametoindex(ifname);
struct xdp_drop_udp_53_bpf *skel;
int err;
// 打开并加载编译好的 BPF skeleton
skel = xdp_drop_udp_53_bpf__open_and_load();
if (!skel)
return 1;
// 将 XDP 程序附加到指定网络接口
// XDP 程序附加逻辑
err = xdp_drop_udp_53_bpf__attach(skel);
if (err) {
return 1;
}
printf("XDP program attached on %s\n", ifname);
// 可在此处进行阻塞处理或实现其他控制流程
// detach 与 destroy 操作将在程序退出时统一执行
xdp_drop_udp_53_bpf__destroy(skel);
return 0;
8. cBPF 与 eBPF 的关系及思维转换
8.1 数据路径中的位置对比
cBPF socket filter 所处的执行位置如下所示:网卡 -> 驱动 -> netif_receive_skb -> ip_rcv -> udp_rcv -> 找 socket
↓
在这里跑 cBPF filter(per-socket)
eBPF XDP 程序在数据包进入时更早阶段生效:
网卡 -> 驱动
↓
XDP prog (DROP/PASS/REDIRECT)
↓
netif_receive_skb -> ip_rcv -> ...
eBPF tc ingress 则作用于网络栈稍后层级:
网卡 -> 驱动 -> netif_receive_skb
↓
tc ingress BPF (DROP/REDIRECT)
↓
ip_rcv
8.2 指令集与编程体验差异
cBPF 编程通常需要手动编写或通过 libpcap 生成字节码:sock_filter
其资源受限,仅提供有限寄存器,不支持 helper 函数和 map 结构,无法实现复杂状态管理。
相比之下,eBPF 提供了现代化的开发体验:
- 使用 C 语言(甚至 Rust、Go 等高级语言)编写,通过 clang 编译;
- 支持完整的寄存器集、局部栈空间、辅助函数(helper)以及 map 数据结构;
- 更适合实现复杂的业务逻辑,如状态机维护、流量统计与行为分析。
可以将 cBPF 视作一种轻量级、为兼容旧系统而保留的技术方案,而 eBPF 才是当前及未来的核心发展方向。
9. 推荐学习路线
从阅读 Linux 内核自带的 eBPF 示例代码开始入门:samples/bpf/
重点参考以下目录中的实例:
xdp1_kern.c /
xdp2_kern.c /
xdp_tx_iptunnel_kern.c
同时结合内核源码中提供的文档辅助理解:
tools/lib/bpf 、
tools/bpf/bpftool
实践建议分阶段进行:
第一阶段:按程序类型完成两个实战项目
- XDP 类型:实现一个简易防火墙,支持基于 IP 地址和端口号对 UDP 53 流量进行丢弃或重定向,并使用 map 记录统计数据;
- kprobe/tracepoint 类型:构建一个内核函数延迟监控工具,例如追踪特定函数调用耗时:
tcp_v4_connect
第二阶段:引入 CO-RE 与 BTF 技术
采用 libbpf + skeleton 架构开发:
vmlinux.h
体验“一次编译,多版本内核运行”的优势,理解如何利用 BTF 实现跨内核版本兼容性。
第三阶段:拓展至 cgroup 与 socket 类型程序
尝试将 BPF 程序挂载到 cgroup 上,用于限制特定服务组的网络访问行为或带宽使用,深入掌握 eBPF 在安全与资源控制方面的应用场景。

雷达卡


京公网安备 11010802022788号







