如果你用过 tcpdump,你就用过 BPF。经典 BPF 是 1992 年的设计:一个运行在内核里的虚拟机,用来过滤网络包。简单、有效、没人觉得它还能干别的。
2014 年,Alexei Starovoitov 把 BPF 重写了。寄存器从 2 个扩展到 11 个,指令集从 CISC 变成了 RISC 风格,加了 Map 数据结构,加了 helper 函数调用,加了 JIT 编译器。更关键的是,挂载点从”只能过滤网络包”扩展到了几乎所有内核子系统。
这就是 eBPF。它的本质是一句话:用户态程序可以在内核执行路径上插入自定义代码,而不需要编译内核模块。
听起来很美。但和所有”听起来很美”的技术一样,细节里全是坑。
一、eBPF 虚拟机:一个受限的 CPU
eBPF 程序不是随便写的 C 代码编译进内核。它是一段运行在内核虚拟机里的字节码,每一条指令都要经过验证器(verifier)的静态分析。
虚拟机的规格:
| 特性 | 规格 |
|---|---|
| 寄存器 | R0-R10,64-bit。R10 只读(帧指针) |
| 栈 | 512 字节。没错,五百一十二字节 |
| 指令 | RISC 风格,64-bit 编码 |
| JIT | x86_64/ARM64/RISC-V 都有 |
| 最大指令数 | 100 万(5.2+ 内核),老内核 4096 |
| 循环 | 5.3+ 内核支持有界循环,之前完全禁止 |
512 字节的栈。这意味着你不能在 eBPF
程序里声明一个大的局部数组。你以为你可以
char buf[1024]?验证器直接拒绝。你需要用 Map 或
per-CPU 数组来存临时数据。
验证器:你的代码审查员
验证器是 eBPF 安全模型的核心。它在加载时(不是运行时)做以下检查:
- DAG 验证 – 控制流图不能有不可达代码
- 栈边界 – 每次栈访问都检查偏移量
- 指针安全 – 不能对未经验证的指针解引用
- 有界执行 – 循环必须有可证明的上界
- helper 函数参数类型 – 传给
bpf_map_lookup_elem()的参数必须是正确类型的 Map fd
验证器是保守的。它宁可拒绝正确的程序,也不放过可能有问题的程序。这意味着你会花大量时间和验证器打架。一段在用户态完全正确的 C 代码,编译成 eBPF 之后被验证器拒绝,是日常。
常见的验证器错误和解法:
// 验证器拒绝: "unbounded memory access"
// 原因: 指针可能为 NULL
struct data_t *val = bpf_map_lookup_elem(&my_map, &key);
val->count++; // 直接用,验证器不答应
// 正确写法: 必须显式检查 NULL
struct data_t *val = bpf_map_lookup_elem(&my_map, &key);
if (val) {
val->count++;
}
// 验证器拒绝: "invalid variable-length read"
// 原因: 从网络包读取时,验证器不知道你的偏移量是否越界
void *data = (void *)(long)ctx->data;
void *data_end = (void *)(long)ctx->data_end;
// 必须先做边界检查
if (data + sizeof(struct ethhdr) > data_end)
return XDP_DROP;
struct ethhdr *eth = data;
// 现在验证器知道 eth 在包范围内
这些检查在用户态 C 里完全不需要。但在内核里,一次越界读取就是一个提权漏洞。
二、四种挂载点:eBPF 的战场
eBPF 的能力取决于它挂在哪里。不同的挂载点决定了你能访问什么数据、能做什么操作。
kprobe / kretprobe – 函数级拦截
挂在内核函数的入口或返回点。可以读取函数参数和返回值。
SEC("kprobe/tcp_connect")
int BPF_KPROBE(trace_tcp_connect, struct sock *sk) {
u32 pid = bpf_get_current_pid_tgid() >> 32;
struct sockaddr_in *addr = /* extract from sk */;
bpf_printk("pid %d connecting to %pI4:%d",
pid, &addr->sin_addr, ntohs(addr->sin_port));
return 0;
}优势:粒度最细,几乎任何内核函数都能挂。 代价:不是 stable ABI。内核升级可能改函数签名,你的 eBPF 程序就挂了。BTF (BPF Type Format) 部分缓解了这个问题,但不是完全解决。
tracepoint – 稳定的观测点
挂在内核预定义的 tracepoint 上。这些是内核开发者承诺维护的接口。
SEC("tracepoint/sched/sched_switch")
int trace_sched_switch(struct trace_event_raw_sched_switch *ctx) {
// ctx->prev_comm, ctx->next_comm: 进程名
// ctx->prev_pid, ctx->next_pid: 进程 ID
bpf_printk("switch: %s -> %s", ctx->prev_comm, ctx->next_comm);
return 0;
}优势:stable ABI,跨内核版本兼容。 代价:数量有限,不是所有你想观测的地方都有 tracepoint。
用
cat /sys/kernel/debug/tracing/available_events
看当前内核支持的 tracepoint。在 5.15 上大约有 1800+ 个。
XDP (eXpress Data Path) – 网卡收包最前沿
挂在网卡驱动层,在 skb 分配之前执行。这是 Linux 网络栈里最早能碰到包的地方。
SEC("xdp")
int xdp_drop_by_ip(struct xdp_md *ctx) {
void *data = (void *)(long)ctx->data;
void *data_end = (void *)(long)ctx->data_end;
// 边界检查 (验证器要求)
if (data + sizeof(struct ethhdr) + sizeof(struct iphdr) > data_end)
return XDP_PASS;
struct ethhdr *eth = data;
if (eth->h_proto != htons(ETH_P_IP))
return XDP_PASS;
struct iphdr *ip = data + sizeof(struct ethhdr);
// 查 Map: 这个源 IP 是否在黑名单里
u32 src = ip->saddr;
u32 *blocked = bpf_map_lookup_elem(&blacklist, &src);
if (blocked)
return XDP_DROP; // 直接丢,不进协议栈
return XDP_PASS;
}这段代码等价于一条 iptables
规则:iptables -A INPUT -s <ip> -j DROP。区别是什么?
| 指标 | iptables | XDP |
|---|---|---|
| 执行位置 | netfilter (协议栈中层) | 网卡驱动 (协议栈之前) |
| 每包开销 | ~2-5 us (含 conntrack) | ~50-200 ns |
| 规则数量影响 | O(n) 遍历规则链 | O(1) 哈希表查找 |
| CPU 占用 (1Mpps) | 单核 40-60% | 单核 5-10% |
| 动态更新 | 需要 iptables reload | Map 热更新,零中断 |
10 倍以上的性能差距。在 DDoS 防护场景下,这意味着同样的硬件可以扛住 10 倍的攻击流量。Cloudflare 和 Facebook 都在用 XDP 替代传统的包过滤方案。
tc (traffic control) – 出口方向
XDP 只能在收包路径上挂。如果你要对发出去的包做处理,用 tc。
SEC("tc")
int tc_egress(struct __sk_buff *skb) {
// 可以修改包内容、重定向、限速
// skb 比 xdp_md 功能更全,但性能略低
return TC_ACT_OK;
}tc 程序跑在 skb 已经分配之后,所以它能用
skb_load_bytes()、skb_store_bytes()
等 helper,比 XDP 的裸指针操作方便,但性能也更差。
三、eBPF Map:内核态与用户态的共享内存
eBPF 程序跑在内核里,你的应用跑在用户态。两边怎么通信?Map。
Map 是 eBPF 的核心数据结构。它是一个驻留在内核内存中的键值存储,两边都能读写。
常用 Map 类型:
| 类型 | 用途 | 并发特性 |
|---|---|---|
BPF_MAP_TYPE_HASH |
通用 kv 存储 | RCU 读 + spinlock 写 |
BPF_MAP_TYPE_ARRAY |
定长数组 | 无锁读写(覆盖语义) |
BPF_MAP_TYPE_PERCPU_HASH |
高频计数器 | 每 CPU 一份,零竞争 |
BPF_MAP_TYPE_PERF_EVENT_ARRAY |
事件推送 | per-CPU ring buffer |
BPF_MAP_TYPE_RINGBUF |
高性能事件流 | MPSC ring buffer(5.8+) |
BPF_MAP_TYPE_LPM_TRIE |
最长前缀匹配 | 路由表、ACL |
一个典型的使用模式——用 per-CPU hash 做高频计数:
struct {
__uint(type, BPF_MAP_TYPE_PERCPU_HASH);
__uint(max_entries, 10240);
__type(key, u32); // 源 IP
__type(value, u64); // 包计数
} pkt_count SEC(".maps");
SEC("xdp")
int count_packets(struct xdp_md *ctx) {
// ... 解析 IP 头 ...
u32 src = ip->saddr;
u64 *cnt = bpf_map_lookup_elem(&pkt_count, &src);
if (cnt) {
(*cnt)++; // per-CPU,无锁
} else {
u64 init = 1;
bpf_map_update_elem(&pkt_count, &src, &init, BPF_ANY);
}
return XDP_PASS;
}用户态读取时需要聚合所有 CPU 的值:
// 用户态代码
u64 total = 0;
u64 per_cpu_values[num_cpus];
bpf_map_lookup_elem(map_fd, &key, per_cpu_values);
for (int i = 0; i < num_cpus; i++)
total += per_cpu_values[i];Map 的性能陷阱
per-CPU Map 在读多写少的场景下性能极好——每个 CPU 写自己的副本,零竞争。但有两个坑:
- 内存占用:per-CPU Map 的实际内存 =
max_entries × value_size × num_cpus。一个 10240 条目、value 64 字节的 per-CPU hash,在 128 核机器上占 128 × 10240 × 64 = 80 MB。 - 用户态聚合延迟:读取时需要遍历所有 CPU 的值。如果你每秒读一次、128 核,这个开销不算什么;如果你对每个 key 每毫秒读一次,这就是瓶颈。
如果你只需要事件流(而不是 kv 查询),用
BPF_MAP_TYPE_RINGBUF 比
PERF_EVENT_ARRAY 好。ringbuf
是多生产者单消费者,支持变长记录,不需要每 CPU 一个
buffer,内存效率更高。但它是 5.8+ 才有的。
四、实战:XDP 丢包 vs iptables
说了这么多理论,来做一个完整的例子。目标:按源 IP 丢包,对比 XDP 和 iptables 的性能。
eBPF 程序(xdp_drop.c):
#include <linux/bpf.h>
#include <linux/if_ether.h>
#include <linux/ip.h>
#include <bpf/bpf_helpers.h>
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 65536);
__type(key, __u32);
__type(value, __u32);
} blocked_ips SEC(".maps");
SEC("xdp")
int xdp_firewall(struct xdp_md *ctx) {
void *data = (void *)(long)ctx->data;
void *data_end = (void *)(long)ctx->data_end;
struct ethhdr *eth = data;
if ((void *)(eth + 1) > data_end)
return XDP_PASS;
if (eth->h_proto != __constant_htons(ETH_P_IP))
return XDP_PASS;
struct iphdr *ip = (void *)(eth + 1);
if ((void *)(ip + 1) > data_end)
return XDP_PASS;
__u32 src = ip->saddr;
if (bpf_map_lookup_elem(&blocked_ips, &src))
return XDP_DROP;
return XDP_PASS;
}
char _license[] SEC("license") = "GPL";加载和配置(用户态 loader):
#include <bpf/libbpf.h>
#include <net/if.h>
#include <arpa/inet.h>
int main(int argc, char **argv) {
struct bpf_object *obj = bpf_object__open_file("xdp_drop.o", NULL);
bpf_object__load(obj);
struct bpf_program *prog = bpf_object__find_program_by_name(obj, "xdp_firewall");
int prog_fd = bpf_program__fd(prog);
// 挂到 eth0
int ifindex = if_nametoindex("eth0");
bpf_xdp_attach(ifindex, prog_fd, 0, NULL);
// 往 Map 里加黑名单 IP
int map_fd = bpf_object__find_map_fd_by_name(obj, "blocked_ips");
__u32 blocked_ip, dummy = 1;
inet_pton(AF_INET, "10.0.0.100", &blocked_ip);
bpf_map_update_elem(map_fd, &blocked_ip, &dummy, BPF_ANY);
printf("XDP firewall attached. Press Ctrl+C to detach.\n");
// ... 等待信号,然后 bpf_xdp_detach() ...
}编译:
# eBPF 程序
clang -O2 -target bpf -c xdp_drop.c -o xdp_drop.o
# 用户态 loader
gcc -o loader loader.c -lbpf这个例子的完整代码在仓库的 examples/ebpf/
目录下。
五、eBPF 做不到什么
eBPF 不是万能的。它有硬性限制:
不能做的事:
- 不能分配任意内存 – 没有 malloc。只能用 Map 或栈上的 512 字节。
- 不能调用任意内核函数 – 只能调用白名单里的 helper 函数(约 200 个)。
- 不能阻塞 – 不能 sleep、不能等锁、不能做同步 I/O。
- 不能直接和用户态通信 – 必须通过 Map 或 ring buffer。
- 不能修改内核数据结构(大部分情况下)– 只读访问内核内存,除了少数 helper 允许的写操作。
不应该做的事:
- 不要把业务逻辑放进 eBPF – 512 字节栈、100 万指令限制、验证器的各种约束。eBPF 适合做过滤、计数、路由决策,不适合做复杂计算。
- 不要指望 kprobe 跨内核版本兼容 – BTF + CO-RE (Compile Once, Run Everywhere) 缓解了这个问题,但在 4.x 内核上你仍然需要针对具体版本编译。
- 不要在 XDP 里做复杂的包重组 – XDP 只能看到单个包,看不到 TCP 流。如果你需要流级别的处理,用 tc 或用户态。
什么时候该用 eBPF:
| 场景 | 推荐 | 不推荐 |
|---|---|---|
| 高速包过滤/DDoS 防护 | XDP | iptables(慢 10 倍) |
| 系统调用审计 | tracepoint/kprobe | strace(开销巨大) |
| 性能分析 | eBPF profiler | perf record + 磁盘 I/O |
| 网络策略 (K8s) | Cilium (eBPF) | iptables chains |
| 复杂协议解析 | 用户态 | eBPF(栈太小) |
| 持久化存储 | 用户态 | eBPF(没有文件 I/O) |
结语
eBPF 是过去十年 Linux 内核最重要的基础设施之一。它让观测、网络、安全三个领域的工具从”外挂式”变成了”内嵌式”。Cilium 用它替代了 kube-proxy 的 iptables 规则链,bpftrace 用它实现了一行命令的内核追踪,Falco 用它在系统调用层面做入侵检测。
但 eBPF 不是无代价的。验证器会让你反复修改代码直到它满意为止。512 字节的栈会逼你重新思考数据结构。kprobe 的不稳定 ABI 会在内核升级时给你惊喜。100 万指令的上限会在复杂场景下拦住你。
eBPF 给了你在内核里写代码的能力,但同时用验证器保证你不会炸掉内核。这是和 loadable kernel module 本质的区别——LKM 可以做任何事,包括 panic 整个系统。eBPF 宁可拒绝你的代码,也不让内核崩溃。
如果你觉得这个权衡合理,eBPF 是你工具箱里最锋利的刀。
延伸阅读:
- Linux 内核的内存屏障:一个让我调了三天的 bug – eBPF Map 的并发访问也涉及内存序
- 大多数”无锁”代码其实不是无锁的 – per-CPU Map 本质上是回避竞争而非解决竞争
- 用 Rust 重写你的 C 网络服务器 – io_uring 和 eBPF 是 Linux 高性能 I/O 的两面
参考资料:
- BPF and XDP Reference Guide – Cilium 维护的最全面的 eBPF 参考
- Linux Kernel Documentation: BPF – 内核官方文档
- bpftool – eBPF 程序的管理工具
- Gregg, B. (2019). BPF Performance Tools. Addison-Wesley. – Brendan Gregg 的 eBPF 性能分析大全