---
title: "eBPF 不只是可观测性"
description: "聊点真正干活用的东西——从 XDP 到 TC，从 kprobe 到 BPF 链表。"
pubDatetime: 2026-08-10
author: "Qihan"
tags: ["Linux", "内核", "eBPF", "网络"]
---

写这篇东西的起因很简单：最近折腾了一阵 eBPF，网上教程不少，但大多数不是停留在"hello world"就是复制粘贴 kernel 文档。真正上手做点实际的事——比如用 XDP 挡 DDOS、用 TC 做透明代理——中间踩的坑，很少有文章好好讲。

这篇不打算再从头给你讲一遍 eBPF 是什么、verifier 怎么工作，那些官方文档写得够清楚了。我更想聊的是：**你在做 eBPF 程序时大概率会遇到但文档没写的东西**。

---

## XDP 的几件事，和你想的不太一样

### XDP 不一定要网卡支持 native

很多文章告诉你 XDP 需要网卡驱动支持 native mode——严格来说没错，但实战中你八成会用 generic mode（`XDP_FLAGS_SKB_MODE`）做开发调试，最后再切到 native。用 `ip link set dev eth0 xdpgeneric obj xdp_prog.o` 就行，内核会把 SKB 模式的 BPF 程序挂在入口。

性能差距当然有——generic mode 走了完整协议栈再到 BPF，native 在驱动层就截住了。但开发阶段差那几微秒无所谓，先把逻辑调通。

### XDP_PASS 不是"绕过协议栈"

有同学以为 XDP_PASS 过的包直接进 TCP/IP 栈。实际上 **XDP_PASS 只是让包回到正常的内核网络路径**，该过的 netfilter（iptables/nftables）、路由决策一个都不少。你在 XDP 里做了合法性过滤，PASS 出去的包该被 iptables 拦还是被拦。

### XDP_TX 的典型场景：不是回包而是 hairpin

XDP_TX 可以把包从收到它的网卡原路丢回去。很多人以为这是用来构造回包的，但更常见的落地场景是做 **hairpin load balancing**——比如在 LVS/Direct Routing 场景下，从 eth0 进来的包处理后直接从 eth0 TX 回去，不走路由表。

性能好归好，但前提是网卡驱动支持。ixgbe、mlx5 这些主流没问题，有些虚拟网卡（virtio_net 的老版本）就不行。

---

## TC BPF：比 iptables 更灵活的流量控制

### clsact qdisc 是更干净的挂载点

传统用 `tc filter add dev eth0 ingress` 挂 ingress BPF，但你得先有一个 root qdisc。从 kernel 4.12 开始，推荐用 clsact：

```bash
tc qdisc add dev eth0 clsact
tc filter add dev eth0 ingress bpf da obj tc_prog.o sec tc_ingress
tc filter add dev eth0 egress bpf da obj tc_prog.o sec tc_egress
```

clsact 的好处是不用操心 qdisc 链，直接挂 ingress/egress。注意参数 `da`（direct-action）——没它 filter 只做分类不执行 action，新手最容易漏掉。

### 绕过 conntrack 做有状态过滤

Netfilter 的 conntrack 在大量连接场景下性能瓶颈很明显。TC BPF 里可以用 `bpf_skb_load_bytes`+`bpf_map_lookup_elem` 自己做状态表。实测 64 字节的 BPF_MAP_TYPE_HASH 存五元组，单核跑满 10G 线速比 iptables conntrack 能省 30-40% CPU。

代价是得手写超时回收逻辑。`BPF_MAP_TYPE_HASH` 不支持自动过期，可以配合 `bpf_timer`（5.15+）做定时清理。

---

## BPF 链表（Linked List）是 6.6 才真正能用的

6.6 kernel 之前，`BPF_MAP_TYPE_TASK_STORAGE`、`BPF_MAP_TYPE_CPUMAP` 这类 map 用起来绑手绑脚。6.6 引入了 BPF linked list 和 `bpf_list_node`，让 eBPF 程序能维护更复杂的数据结构。

```c
struct node {
    __u64 value;
    struct bpf_list_node list;
};

struct array_map {
    __u64 count;
    struct bpf_list_head head __contains(node, list);
};
```

但有个限制很烦：**链表头只能在 map value 里定义**，不能直接在全局变量里用。而且 `bpf_list_push_front/back` 这些 helper 对指针的有效性检查非常严格——说白了还是 verifier 那套安全机制的锅。

---

## kprobe/fentry 的选择：不要惯性用 kprobe

如果你还在用 `SEC("kprobe/xxx")`，可以考虑换 fentry/fexit 了。

```c
// kprobe 方式
SEC("kprobe/tcp_v4_connect")
int BPF_KPROBE(tcp_v4_connect, struct sock *sk)
{
    // ...
}

// fentry 方式（5.5+）
SEC("fentry/tcp_v4_connect")
int BPF_PROG(tcp_v4_connect, struct sock *sk)
{
    // ...
}
```

fentry 的优势：

- 性能更好：kprobe 通过断点/跳转指令模拟，fentry 是直接在函数入口插入 trampoline，延迟更低
- 参数直接可读：kprobe 用 `PT_REGS_PARM1` 这些宏间接拿参数，fentry 用签名就能直接取
- 稳定性高：不再依赖内核函数的 prologue 结构

前提是内核开了 `CONFIG_FUNCTION_TRACER` 和 `CONFIG_BPF_KPROBE_OVERRIDE`。LTS 内核基本都支持了，如果你还在用 4.x ——那就安心用 kprobe 吧。

---

## 实际踩过的坑

### 1. BPF 栈大小只有 512 字节

这是新手第一道坎。BPF 程序不能声明大的局部变量，超过 512 字节 verifier 直接拒绝。解法很简单——用 BPF map（`BPF_MAP_TYPE_PERCPU_ARRAY` 做临时 buffer）。

```c
struct {
    __uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
    __uint(max_entries, 1);
    __type(key, __u32);
    __type(value, struct my_big_struct);
} tmp_buf SEC(".maps");

// 用的时候
__u32 zero = 0;
struct my_big_struct *buf = bpf_map_lookup_elem(&tmp_buf, &zero);
if (!buf) return XDP_ABORTED;
```

### 2. bpf_printk 不是 printf

`bpf_printk` 最多支持 **三个** 格式化参数，而且只认 `%d %u %x %ld %lu %lld %llu %p %s` 这几种，不认 `%f` 或 `%zu`。想打的变量太多？分多次调用或者用 `BPF_MAP_TYPE_PERF_EVENT_ARRAY` 发到用户态。

```c
bpf_printk("pid %u sent %llu bytes to port %u\\n", pid, bytes, port);
```

多一个参数都不行，verifier 会让你怀疑人生。

### 3. verifier 的复杂路径限制

BPF 程序有复杂性限制（complexity limit），循环数、分支数、指令数超标就加载失败。

最烦人的是 **verifier 并不知道你的循环真的会终止**。比如：

```c
for (i = 0; i < max; i++) {
    if (arr[i] > threshold) { /* do something */ }
}
```

如果 `max` 是一个变量，verifier 会拒绝。得用 `#pragma unroll` 让编译器展开，或者用 `bpf_loop` helper（5.17+）。

### 4. 尾调用有 stack 限制

`bpf_tail_call` 不增加函数调用栈，但 tail call chain 最多 **33 层**（`MAX_TAIL_CALL_CNT`）。超过这个数 tail call 静默失败，返回 0。而且 tail call 之后当前 BPF 程序的栈内容全部消失——所以没法"partial execute + tail call"做分段处理。

---

## 实战：XDP 源 IP 速率限制

贴一个我在用的简化版 XDP，用 `BPF_MAP_TYPE_LRU_HASH` 做单 IP 速率限制：

```c
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_endian.h>

#define RATE_LIMIT_PPS 1000

struct ip_rate_key {
    __u32 src_ip;
};

struct ip_rate_val {
    __u64 last_seen;
    __u32 pps;
};

struct {
    __uint(type, BPF_MAP_TYPE_LRU_HASH);
    __uint(max_entries, 100000);
    __type(key, struct ip_rate_key);
    __type(value, struct ip_rate_val);
} rate_map SEC(".maps");

SEC("xdp")
int xdp_rate_limit(struct xdp_md *ctx)
{
    void *data_end = (void *)(long)ctx->data_end;
    void *data = (void *)(long)ctx->data;
    struct ethhdr *eth = data;

    if ((void *)(eth + 1) > data_end)
        return XDP_ABORTED;

    if (bpf_ntohs(eth->h_proto) != ETH_P_IP)
        return XDP_PASS;

    struct iphdr *ip = data + sizeof(*eth);
    if ((void *)(ip + 1) > data_end)
        return XDP_ABORTED;

    struct ip_rate_key key = { .src_ip = ip->saddr };
    struct ip_rate_val *val, new_val = {};
    __u64 now = bpf_ktime_get_ns();

    val = bpf_map_lookup_elem(&rate_map, &key);
    if (val) {
        __u64 elapsed = now - val->last_seen;
        if (elapsed < 1000000000ULL) {
            val->pps++;
            if (val->pps > RATE_LIMIT_PPS)
                return XDP_DROP;
        } else {
            val->last_seen = now;
            val->pps = 1;
        }
    } else {
        new_val.last_seen = now;
        new_val.pps = 1;
        bpf_map_update_elem(&rate_map, &key, &new_val, BPF_NOEXIST);
    }

    return XDP_PASS;
}

char _license[] SEC("license") = "GPL";
```

BPF_MAP_TYPE_LRU_HASH 自带最近最少使用淘汰，不会让 map 无限膨胀。rate limit 阈值根据实际场景调就行。

---

## 几个值得留意的方向

- **BPF 可移植性**：CO-RE + BTF 基本解决了跨内核版本的问题，别再写判断内核版本号的拙劣 hack 了
- **用户态 BPF 库**：libbpf 比 bcc 轻量，从 libbpf-bootstrap 起步挺舒服
- **sched_ext**（SCX）：6.12 合入的 BPF 调度器框架，用 BPF 写 CPU 调度策略——这件事本身够开一长篇
- **BPF 安全审计**：`BPF_MAP_TYPE_RINGBUF` + fentry 做内核审计日志，比传统 auditd 性能好不少

---

差不多就这些。都是实际项目里摸出来的，有人能少走点弯路就好。