土法炼钢兴趣小组的算法知识备份

eBPF:Linux 内核的隐藏武器

目录

如果你用过 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 安全模型的核心。它在加载时(不是运行时)做以下检查:

  1. DAG 验证 – 控制流图不能有不可达代码
  2. 栈边界 – 每次栈访问都检查偏移量
  3. 指针安全 – 不能对未经验证的指针解引用
  4. 有界执行 – 循环必须有可证明的上界
  5. 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 写自己的副本,零竞争。但有两个坑:

  1. 内存占用:per-CPU Map 的实际内存 = max_entries × value_size × num_cpus。一个 10240 条目、value 64 字节的 per-CPU hash,在 128 核机器上占 128 × 10240 × 64 = 80 MB。
  2. 用户态聚合延迟:读取时需要遍历所有 CPU 的值。如果你每秒读一次、128 核,这个开销不算什么;如果你对每个 key 每毫秒读一次,这就是瓶颈。

如果你只需要事件流(而不是 kv 查询),用 BPF_MAP_TYPE_RINGBUFPERF_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 不是万能的。它有硬性限制:

不能做的事:

  1. 不能分配任意内存 – 没有 malloc。只能用 Map 或栈上的 512 字节。
  2. 不能调用任意内核函数 – 只能调用白名单里的 helper 函数(约 200 个)。
  3. 不能阻塞 – 不能 sleep、不能等锁、不能做同步 I/O。
  4. 不能直接和用户态通信 – 必须通过 Map 或 ring buffer。
  5. 不能修改内核数据结构(大部分情况下)– 只读访问内核内存,除了少数 helper 允许的写操作。

不应该做的事:

  1. 不要把业务逻辑放进 eBPF – 512 字节栈、100 万指令限制、验证器的各种约束。eBPF 适合做过滤、计数、路由决策,不适合做复杂计算。
  2. 不要指望 kprobe 跨内核版本兼容 – BTF + CO-RE (Compile Once, Run Everywhere) 缓解了这个问题,但在 4.x 内核上你仍然需要针对具体版本编译。
  3. 不要在 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 是你工具箱里最锋利的刀。


延伸阅读:

参考资料:

  1. BPF and XDP Reference Guide – Cilium 维护的最全面的 eBPF 参考
  2. Linux Kernel Documentation: BPF – 内核官方文档
  3. bpftool – eBPF 程序的管理工具
  4. Gregg, B. (2019). BPF Performance Tools. Addison-Wesley. – Brendan Gregg 的 eBPF 性能分析大全

By .