编程 eBPF 源码级深度拆解:从字节码验证器、CO-RE 到 XDP,手写一个零侵入的内核级可观测探针(附完整 C + Go 实现)

2026-08-01 04:15:13 +0800 CST views 7

eBPF 源码级深度拆解:从字节码验证器、CO-RE 到 XDP,手写一个零侵入的内核级可观测探针(附完整 C + Go 实现)

你有没有遇到过这样的场景:线上一个服务偶发 500ms 的调用抖动,日志里啥都没有,改代码加埋点要走一遍完整的发布流程,等你部署上去、复现问题、拉下日志,一整天过去了。而隔壁做安全的同事,只在宿主机上敲了一行命令,就把所有进出容器的网络包、系统调用、函数耗时全抓下来了——不改一行业务代码,不重启一个进程。

这不是魔法,是 eBPF。这篇文章我想把 eBPF 这个"内核里的 JavaScript 引擎"从头到尾拆一遍:它到底是什么、内核凭什么敢让你往里塞代码、验证器(verifier)是怎么把你摁在地上摩擦的、CO-RE 又是怎么解决"一次编译到处运行"的世纪难题。最后我们用 C 写内核态、用 Go(cilium/ebpf)写用户态,手撸两个能直接跑的探针:一个统计进程 execve 系统调用,一个用 XDP 在网卡驱动层做丢包统计。

全程程序员视角,实用主义,代码能跑。准备好,我们进内核了。


一、背景:为什么可观测性的终局是 eBPF

先说清楚一件事:eBPF 不是又一个新框架,它是一次内核能力的范式转移。

1.1 传统方案的三宗罪

要理解 eBPF 的价值,得先看看没有它的时候我们是怎么受苦的。

第一宗:埋点侵入。 想知道某个函数耗时?改代码,加 startTime := time.Now(),加 metrics.Observe(...),编译、测试、发布。埋点写进了业务逻辑,删都不好删。更别提第三方闭源组件,你连代码都没有,怎么埋?

第二宗:采样精度与开销的两难。strace 抓系统调用?它基于 ptrace,每一次系统调用都要在被追踪进程和追踪进程之间做两次上下文切换,开销动辄几倍到几十倍,生产环境根本不敢开。用抓包工具 tcpdump?数据包要从内核拷贝到用户态,高流量下 CPU 直接打满。

第三宗:数据碎片化。 指标(Metrics)在 Prometheus,日志(Logs)在 ELK,链路(Tracing)在 Jaeger,三套系统三套采集器,关联全靠 traceId 手工拼。而这三类数据的源头——内核里的每一次网络收发、每一次调度、每一次系统调用——本来是同一个事件流。

1.2 eBPF 的解法:把探针下沉到内核

eBPF(extended Berkeley Packet Filter)的核心思想极其简单粗暴:既然所有事件都要经过内核,那我就在内核里放探针,事件发生的那一刻当场处理,只把聚合后的结果传给用户态。

这带来了三个质变:

  • 零侵入:探针挂在内核函数、系统调用、网络协议栈的挂载点上,业务进程完全无感知,不用改代码、不用重启。
  • 低开销:数据在内核里就地聚合,避免了海量原始数据的拷贝;eBPF 程序被 JIT 编译成原生机器码,执行效率接近内核原生代码。
  • 统一数据源:Metrics / Logs / Tracing / Profiling 全都能从内核事件流里派生,天然关联。

Meta、Google、Netflix、Cloudflare 早就在生产环境大规模跑 eBPF 了。Cilium 用它重写了 Kubernetes 的网络与安全,Tetragon 用它做运行时安全,Pixie / DeepFlow 用它做无埋点可观测。这已经不是未来,是现在进行时。


二、核心概念:eBPF 到底是什么

一句话定义:eBPF 是一个运行在 Linux 内核中的沙箱化虚拟机,允许你在不修改内核源码、不加载内核模块的前提下,把自定义程序安全地注入到内核的各种挂载点上执行。

我们把这句话拆开看。

2.1 它是一台虚拟机

eBPF 有自己的指令集(ISA)——一套 RISC 风格的 64 位指令,11 个通用寄存器(R0-R10),一个只读栈(512 字节)。你写的 C 代码,会被 Clang/LLVM 编译成 eBPF 字节码(而不是 x86/ARM 机器码),这套字节码在内核里由 eBPF 虚拟机解释执行,或者被 JIT 编译器翻译成宿主 CPU 的原生指令后执行。

寄存器的约定很关键,记住这张表,后面写代码和看 verifier 报错都要用:

寄存器用途
R0返回值 / helper 函数返回值
R1-R5函数调用参数(R1 是第一个参数,也就是程序上下文 ctx)
R6-R9被调用者保存(callee-saved)
R10只读栈帧指针

程序入口时,R1 指向"上下文"(context),不同程序类型的 ctx 结构不同:网络程序拿到的是 __sk_buff,kprobe 拿到的是 pt_regs(寄存器快照)。

2.2 它是沙箱化的

这是 eBPF 最反直觉、也最牛的地方:内核凭什么敢让你这个普通用户往它最核心的地方塞代码?

答案是——它根本不信任你。你的每一行 eBPF 代码,在被加载进内核之前,都要经过一个叫 verifier(验证器) 的守门员。verifier 会模拟执行你的程序的所有可能路径,确保:程序一定会终止(不能有无界循环)、不会访问越界内存、不会有未初始化的寄存器、不会泄露内核指针到用户态。过不了 verifier,bpf() 系统调用直接返回 -EINVAL,你的程序连内核的门都进不去。

正是这个"不信任"的设计,让 eBPF 做到了内核模块做不到的事:内核模块一旦有 bug(空指针、死循环)能直接把整个系统搞崩溃(kernel panic),而 eBPF 程序在设计上就无法让内核崩溃。安全性是 eBPF 能被广泛部署的根本前提。

2.3 它是事件驱动的

eBPF 程序不会主动运行,它是"挂"上去的钩子,只有当对应的事件发生时才被触发。挂载点(attach point)非常丰富:

  • kprobe / kretprobe:挂在任意内核函数的入口 / 返回处
  • uprobe / uretprobe:挂在用户态程序的函数上(比如抓 libsslSSL_write 看明文)
  • tracepoint:内核预定义的稳定追踪点(比 kprobe 更稳定,不随内核版本改名)
  • XDP:网卡驱动层,数据包刚进网卡还没进协议栈就处理,最快
  • tc (traffic control):流量控制层,能看到 sk_buff,比 XDP 靠后但信息更全
  • perf_event:绑定到性能计数器,做 CPU profiling
  • LSM (Linux Security Module):挂在安全钩子上做策略控制

三、架构分析:一个 eBPF 程序的一生

我们跟着一个 eBPF 程序,走一遍它从源码到内核执行的完整生命周期。

  C 源码 (.bpf.c)
      │  clang -target bpf
      ▼
  eBPF 字节码 (.o, ELF 格式,含 BTF 信息)
      │  用户态 loader (libbpf / cilium-ebpf) 解析 ELF
      ▼
  bpf() 系统调用 (BPF_PROG_LOAD)
      │
      ▼
  ┌─────────────────────────────┐
  │  内核 verifier 验证           │ ← 过不了这里就 -EINVAL
  └─────────────────────────────┘
      │  通过
      ▼
  ┌─────────────────────────────┐
  │  JIT 编译成原生机器码          │
  └─────────────────────────────┘
      │
      ▼
  attach 到挂载点 (kprobe/XDP/...)
      │
      ▼
  事件触发 → 执行 → 读写 Map → 通过 Map/RingBuf 把数据传回用户态

3.1 verifier:那个把你摁在地上摩擦的男人

verifier 是整个 eBPF 体系的灵魂。它做的是静态分析 + 抽象解释:从程序入口开始,遍历控制流图(CFG)的每一条可能路径,追踪每个寄存器和栈槽的状态(是标量还是指针?指向哪?取值范围是多少?)。

它主要检查几件事:

  1. 可终止性:早期内核完全禁止循环,你只能用 #pragma unroll 把循环展开。Linux 5.3+ 引入了 bounded loop(有界循环),verifier 能证明循环会退出就放行。
  2. 内存安全:任何指针解引用前,必须先做边界检查。这就是为什么你从 map 里查出来的指针,用之前必须写 if (ptr == NULL) return 0;——不是防御性编程,是 verifier 强制要求,不写直接拒绝加载。
  3. 寄存器初始化:不能读一个没写过的寄存器。
  4. 复杂度上限:verifier 遍历的指令数有上限(老内核 4096,现在放宽到 100 万),程序太复杂路径太多,会因为 verifier 分析超限而被拒绝。

新手 90% 的时间都花在和 verifier 搏斗上。记住一个心法:verifier 报错不是在为难你,是在告诉你程序有一条路径可能不安全。 它的报错信息会给出寄存器状态和出错的指令行号,学会读它。

3.2 Maps:内核态与用户态的共享内存

eBPF 程序是无状态的、短命的——事件来了执行一下就结束。那状态存哪?怎么把数据传给用户态?答案是 Map

Map 是内核里的键值存储,eBPF 程序和用户态程序都能读写它,是二者通信的桥梁。常用类型:

Map 类型用途
BPF_MAP_TYPE_HASH通用哈希表,比如 pid → 计数
BPF_MAP_TYPE_ARRAY定长数组,索引即 key
BPF_MAP_TYPE_PERCPU_HASH每 CPU 一份,无锁高性能计数
BPF_MAP_TYPE_LRU_HASH满了自动淘汰最久未用
BPF_MAP_TYPE_RINGBUF环形缓冲区,内核向用户态高效推送事件流(5.8+)
BPF_MAP_TYPE_PERF_EVENT_ARRAY老式的 per-CPU 事件通道(RingBuf 出现前的标准)
BPF_MAP_TYPE_PROG_ARRAY存 eBPF 程序 fd,用于尾调用(tail call)

后面的实战里我们会大量用到 PERCPU_HASHRINGBUF

3.3 Helper 函数:eBPF 能调用的"系统调用"

eBPF 程序运行在沙箱里,不能随便调用内核函数。它能调的是内核预先导出的一组 helper 函数,比如:

  • bpf_map_lookup_elem / bpf_map_update_elem:读写 map
  • bpf_ktime_get_ns:拿纳秒级时间戳(算耗时用)
  • bpf_get_current_pid_tgid:拿当前进程 pid/tgid
  • bpf_get_current_comm:拿当前进程名
  • bpf_probe_read_kernel / bpf_probe_read_user:安全地读内核 / 用户态内存
  • bpf_ringbuf_reserve / bpf_ringbuf_submit:往 ring buffer 里塞数据
  • bpf_perf_event_output:往 perf buffer 输出

新内核还有一类更强的东西叫 kfunc,允许 eBPF 直接调用内核内部函数,比 helper 更灵活,这是当前 eBPF 生态扩展的主要方向。


四、CO-RE 与 BTF:破解"一次编译到处运行"

这一节是理解现代 eBPF 工程化的关键,也是很多教程讲不清楚的地方。

4.1 那个曾经致命的问题

eBPF 程序经常要读内核数据结构的字段。比如你想拿到 task_struct 里的 pid,就得知道 pid 这个字段在结构体里的偏移量(offset)

问题来了:task_struct 这个结构体的布局,每个内核版本都可能不一样!内核 5.4 里 pid 可能在偏移 1234 字节处,5.10 里可能变成 1250 字节。这意味着你在 A 机器上编译的 eBPF 程序,拿到 B 机器上就读错字段了。

老一代方案(BCC)的做法是:在目标机器上现场编译。用户机器上得装 LLVM/Clang、内核头文件(kernel headers),程序运行时读取当前内核头文件重新编译。又慢又重又脆,生产环境部署简直是灾难。

4.2 BTF:给内核装上"类型自描述"

BTF(BPF Type Format) 是解法的第一块拼图。它是一种紧凑的调试信息格式,描述了内核里所有的类型信息——每个结构体有哪些字段、每个字段的偏移和大小。

内核编译时打开 CONFIG_DEBUG_INFO_BTF=y,就会把自己的 BTF 信息内嵌进 /sys/kernel/btf/vmlinux。也就是说,运行中的内核自己就知道自己所有数据结构的精确布局

4.3 CO-RE:编译一次,到处运行

CO-RE(Compile Once - Run Everywhere) 是最终解法。它的工作流是这样的:

  1. 编译时:你用 bpf_core_read() 之类的宏访问字段,Clang 不会把偏移量写死,而是生成一条 CO-RE 重定位记录,意思是"这里要读 task_struct.pid,具体偏移量以后再填"。
  2. 加载时:用户态 loader(libbpf / cilium-ebpf)读取目标机器 /sys/kernel/btf/vmlinux 里的 BTF,查出当前内核里 task_struct.pid 的真实偏移量,然后动态改写字节码,把正确的偏移量填进去。

这样一来,同一个编译好的 .o 文件,在任何支持 BTF 的内核上都能自动适配。用户机器上不再需要 Clang、不需要内核头文件,一个二进制走天下。这就是现代 eBPF 工具(Cilium、Tetragon、cilium/ebpf)能优雅部署的底层魔法。

配合 bpftool 生成的 vmlinux.h(把整个内核的类型定义导成一个头文件),你写 eBPF 时甚至不用引任何内核头文件,#include "vmlinux.h" 一把梭。


五、代码实战(一):统计进程 execve 系统调用

理论讲完,上手撸代码。第一个例子:监控整机所有进程的 execve 调用(也就是"谁启动了什么程序"),这是运行时安全的基础能力。

我们用 tracepointsys_enter_execve)做挂载点,因为它比 kprobe 稳定。内核态用 C,用户态用 Go 的 cilium/ebpf 库。

5.1 内核态:execve.bpf.c

// execve.bpf.c
#include "vmlinux.h"          // bpftool 生成的全内核类型定义
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_core_read.h>

char LICENSE[] SEC("license") = "GPL"; // 用 GPL helper 必须声明

#define TASK_COMM_LEN 16
#define MAX_FILENAME  256

// 传给用户态的事件结构
struct event {
    __u32 pid;
    __u32 ppid;
    __u8  comm[TASK_COMM_LEN];
    __u8  filename[MAX_FILENAME];
};

// 定义一个 ring buffer,用来把事件推给用户态
struct {
    __uint(type, BPF_MAP_TYPE_RINGBUF);
    __uint(max_entries, 256 * 1024); // 256KB
} events SEC(".maps");

// 挂到 execve 系统调用的入口 tracepoint
SEC("tracepoint/syscalls/sys_enter_execve")
int handle_execve(struct trace_event_raw_sys_enter *ctx)
{
    struct event *e;

    // 在 ring buffer 里预留一块空间;失败说明缓冲区满了
    e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
    if (!e)
        return 0;

    // 拿当前进程 pid(高 32 位是 tgid,也就是我们平时说的 pid)
    __u64 id = bpf_get_current_pid_tgid();
    e->pid = id >> 32;

    // 通过 CO-RE 读父进程 pid:task->real_parent->tgid
    struct task_struct *task = (struct task_struct *)bpf_get_current_task();
    e->ppid = BPF_CORE_READ(task, real_parent, tgid);

    // 拿进程名
    bpf_get_current_comm(&e->comm, sizeof(e->comm));

    // execve 的第一个参数是文件名指针,位于 ctx->args[0](用户态内存)
    const char *filename = (const char *)ctx->args[0];
    bpf_probe_read_user_str(&e->filename, sizeof(e->filename), filename);

    // 提交事件,用户态即可读到
    bpf_ringbuf_submit(e, 0);
    return 0;
}

几个关键点值得停下来讲:

  • BPF_CORE_READ(task, real_parent, tgid) 是 CO-RE 的核心宏。它等价于安全地做 task->real_parent->tgid,但每一步字段访问都会生成 CO-RE 重定位记录,加载时自动适配当前内核的字段偏移。你绝对不能直接写 task->real_parent->tgid——那样 verifier 会因为你未经检查地解引用内核指针而拒绝加载。
  • bpf_probe_read_user_str 读的是用户态内存(execve 的文件名参数在用户空间),必须用带 _user 的版本;读内核内存要用 _kernel 版本。搞混了在有些内核上会读到垃圾数据。
  • ring buffer 的 reserve / submit 模式避免了一次额外的内存拷贝:你直接在缓冲区里填数据,填完提交,用户态零拷贝读到。

5.2 用户态:main.go(cilium/ebpf)

Go 生态里 cilium/ebpf 是纯 Go 实现的 eBPF 库,不依赖 libbpf 的 C 代码,跨平台交叉编译友好,是目前云原生项目的主流选择。

// main.go
package main

import (
	"bytes"
	"encoding/binary"
	"errors"
	"log"
	"os"
	"os/signal"
	"syscall"

	"github.com/cilium/ebpf/link"
	"github.com/cilium/ebpf/ringbuf"
	"github.com/cilium/ebpf/rlimit"
)

// 用 go:generate 调 bpf2go,把 execve.bpf.c 编译成字节码并生成 Go 胶水代码
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang bpf execve.bpf.c

// 和内核态 struct event 内存布局一一对应
type event struct {
	Pid      uint32
	Ppid     uint32
	Comm     [16]byte
	Filename [256]byte
}

func main() {
	// 解除内核对 eBPF 锁定内存(RLIMIT_MEMLOCK)的限制,老内核必须做
	if err := rlimit.RemoveMemlock(); err != nil {
		log.Fatalf("移除 memlock 限制失败: %v", err)
	}

	// 加载 bpf2go 生成的字节码到内核(这一步会过 verifier)
	var objs bpfObjects
	if err := loadBpfObjects(&objs, nil); err != nil {
		log.Fatalf("加载 eBPF 对象失败: %v", err)
	}
	defer objs.Close()

	// 把程序 attach 到 tracepoint
	tp, err := link.Tracepoint("syscalls", "sys_enter_execve", objs.HandleExecve, nil)
	if err != nil {
		log.Fatalf("attach tracepoint 失败: %v", err)
	}
	defer tp.Close()

	// 打开 ring buffer 读取器
	rd, err := ringbuf.NewReader(objs.Events)
	if err != nil {
		log.Fatalf("打开 ringbuf 失败: %v", err)
	}
	defer rd.Close()

	// 优雅退出
	stopper := make(chan os.Signal, 1)
	signal.Notify(stopper, os.Interrupt, syscall.SIGTERM)
	go func() {
		<-stopper
		rd.Close()
	}()

	log.Println("开始监控 execve... 按 Ctrl-C 退出")
	log.Printf("%-8s %-8s %-16s %s", "PID", "PPID", "COMM", "FILENAME")

	var e event
	for {
		record, err := rd.Read()
		if err != nil {
			if errors.Is(err, ringbuf.ErrClosed) {
				return
			}
			log.Printf("读 ringbuf 出错: %v", err)
			continue
		}
		// 按小端序解码成 Go 结构体
		if err := binary.Read(bytes.NewBuffer(record.RawSample),
			binary.LittleEndian, &e); err != nil {
			log.Printf("解码事件失败: %v", err)
			continue
		}
		log.Printf("%-8d %-8d %-16s %s",
			e.Pid, e.Ppid,
			cstr(e.Comm[:]), cstr(e.Filename[:]))
	}
}

// 把 C 的定长字节数组转成 Go 字符串(截到第一个 \0)
func cstr(b []byte) string {
	if i := bytes.IndexByte(b, 0); i >= 0 {
		return string(b[:i])
	}
	return string(b)
}

5.3 跑起来

# 1. 生成 vmlinux.h(CO-RE 的类型来源)
bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h

# 2. 编译并生成 Go 胶水代码
go generate ./...

# 3. 编译 Go 程序
go build -o execsnoop .

# 4. 运行(需要 CAP_BPF / root)
sudo ./execsnoop

另开一个终端随便跑几个命令,你会看到:

PID      PPID     COMM             FILENAME
34521    12003    bash             /usr/bin/ls
34522    12003    bash             /usr/bin/grep
34530    34529    node             /usr/bin/sh

整机所有进程的启动,被你零侵入地抓了个干净。这就是 Tetragon、Falco 这类运行时安全工具最底层的原理。


六、代码实战(二):用 XDP 在网卡驱动层做丢包统计

第二个例子上强度:XDP(eXpress Data Path)。它把 eBPF 程序挂在网卡驱动的最前端——数据包刚从网卡进来、还没分配 sk_buff、还没进入内核协议栈的那一刻就处理。这是 Linux 上能做包处理的最快位置,DDoS 防护、L4 负载均衡(比如 Cloudflare 的 Unimog、Facebook 的 Katran)都靠它。

XDP 程序的返回值决定数据包的命运:

  • XDP_PASS:放行,交给协议栈继续处理
  • XDP_DROP:直接丢弃(丢包性能极高,这是抗 DDoS 的关键)
  • XDP_TX:从原网卡发回去
  • XDP_REDIRECT:转发到其他网卡 / CPU

6.1 内核态:xdp_count.bpf.c

我们做一个简单但实用的东西:按 IP 协议号(TCP/UDP/ICMP)统计入向包数,并演示如何丢弃所有 ICMP 包(防 ping 洪水)。

// xdp_count.bpf.c
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_endian.h>

char LICENSE[] SEC("license") = "GPL";

// per-CPU 数组:每个 CPU 一份计数,无锁,最后用户态再求和
// key = IP 协议号(如 IPPROTO_TCP=6),value = 包计数
struct {
    __uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
    __uint(max_entries, 256);
    __type(key, __u32);
    __type(value, __u64);
} proto_count SEC(".maps");

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

    // 解析以太网头,注意:每次访问内存前必须做边界检查,否则 verifier 拒绝
    struct ethhdr *eth = data;
    if ((void *)(eth + 1) > data_end)
        return XDP_PASS;

    // 只处理 IPv4
    if (eth->h_proto != bpf_htons(ETH_P_IP))
        return XDP_PASS;

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

    __u32 proto = ip->protocol;

    // per-CPU map 计数:本 CPU 独占,直接 ++ 无需加锁
    __u64 *cnt = bpf_map_lookup_elem(&proto_count, &proto);
    if (cnt)
        __sync_fetch_and_add(cnt, 1);

    // 丢弃所有 ICMP 包(协议号 1)——演示 DDoS 防护
    if (proto == IPPROTO_ICMP)
        return XDP_DROP;

    return XDP_PASS;
}

注意那两处 if ((void *)(x + 1) > data_end) return XDP_PASS;——这不是可选的,是 verifier 强制的边界检查。XDP 拿到的是裸数据包指针,你想读以太网头、IP 头,必须先向 verifier 证明"我读的这块内存在包的范围内",否则一律拒绝加载。这是初学 XDP 最常踩的坑:报错 invalid access to packet,八成就是漏了边界检查。

PERCPU_ARRAY 是高性能计数的标准姿势:每个 CPU 核心操作自己那一份副本,完全无锁、无 cache line 争抢;用户态读的时候把所有 CPU 的值加起来。在几百万 PPS 的场景下,这和用全局带锁 map 的性能差着数量级。

6.2 用户态:加载并周期性打印统计

// main.go (XDP 版核心片段)
package main

import (
	"log"
	"net"
	"time"

	"github.com/cilium/ebpf"
	"github.com/cilium/ebpf/link"
	"github.com/cilium/ebpf/rlimit"
)

//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang bpf xdp_count.bpf.c

func main() {
	if err := rlimit.RemoveMemlock(); err != nil {
		log.Fatal(err)
	}

	var objs bpfObjects
	if err := loadBpfObjects(&objs, nil); err != nil {
		log.Fatalf("加载失败: %v", err)
	}
	defer objs.Close()

	iface, err := net.InterfaceByName("eth0")
	if err != nil {
		log.Fatalf("找不到网卡: %v", err)
	}

	// 把 XDP 程序挂到网卡上
	l, err := link.AttachXDP(link.XDPOptions{
		Program:   objs.XdpCounter,
		Interface: iface.Index,
	})
	if err != nil {
		log.Fatalf("attach XDP 失败: %v", err)
	}
	defer l.Close()

	log.Println("XDP 已挂载到 eth0,每 2 秒打印一次统计...")
	protoNames := map[uint32]string{1: "ICMP", 6: "TCP", 17: "UDP"}

	ticker := time.NewTicker(2 * time.Second)
	defer ticker.Stop()
	for range ticker.C {
		for proto, name := range protoNames {
			// per-CPU map:读出来是每个 CPU 一个值的切片,要自己求和
			var perCPU []uint64
			if err := objs.ProtoCount.Lookup(proto, &perCPU); err != nil {
				continue
			}
			var total uint64
			for _, v := range perCPU {
				total += v
			}
			log.Printf("%-5s : %d 包", name, total)
		}
	}
}

编译运行后(sudo ./xdpcount),你会看到 TCP/UDP 包数持续增长,而对本机 ping 会全部超时——因为 ICMP 包在网卡驱动层就被 XDP_DROP 干掉了,压根没进协议栈。这就是最小化的 DDoS 缓解原型。


七、性能优化:让 eBPF 真正快起来

能跑只是及格线,生产级 eBPF 要抠性能。这里是几条从实战里总结的硬核经验。

7.1 per-CPU map 是计数的默认选择

普通 HASH / ARRAY map 在多核并发写时需要加锁(或用原子操作),高频路径上会成为瓶颈。PERCPU_* 系列让每个 CPU 操作独立副本,写路径完全无锁,代价只是用户态读取时要遍历求和。凡是"高频写、低频读"的计数、聚合场景,一律优先 per-CPU。

7.2 RingBuf 优于 PerfBuf

在 5.8 之前,内核向用户态推事件流用的是 PERF_EVENT_ARRAY(perf buffer),它是 per-CPU 的,有几个痛点:内存按 CPU 数量成倍分配、事件顺序在多 CPU 间无法保证、有额外的内存拷贝。

5.8 引入的 RingBuf 是全局共享的单一环形缓冲区:内存占用固定、跨 CPU 保序、支持 reserve/commit 的零拷贝写入。新项目无脑用 RingBuf,除非你要兼容特别老的内核。

7.3 减少 verifier 复杂度,别把逻辑全塞进一个大程序

verifier 的分析复杂度随程序路径数指数增长。一个巨型 eBPF 程序不仅难过验证,加载还慢。解法是 尾调用(tail call):用 BPF_MAP_TYPE_PROG_ARRAY 存多个小程序的 fd,程序执行完通过 bpf_tail_call() 跳到下一个,把大逻辑拆成流水线。协议解析这类"分阶段处理"特别适合。

7.4 用 batch 操作读写 map

用户态一个个 Lookup map 里的百万条目,系统调用开销会要命。cilium/ebpf 和 libbpf 都支持 BatchLookup / BatchUpdate,一次系统调用处理一批,吞吐能提升一个数量级。

7.5 XDP 优先用 native/offload 模式

XDP 有三种运行模式:generic(协议栈里软件模拟,慢,用于不支持的网卡)、native(网卡驱动原生支持,快)、offload(直接卸载到智能网卡硬件执行,最快)。生产环境务必确认网卡驱动支持 native XDP,否则你以为在用 XDP 的性能,其实跑的是 generic 模式,性能大打折扣。


八、生产踩坑清单(用血换的)

  • 内核版本是第一道坎。 RingBuf 要 5.8+,很多 CO-RE 特性要 5.4+,BTF 要发行版开了 CONFIG_DEBUG_INFO_BTF。上生产前先 cat /boot/config-$(uname -r) | grep BTF 确认。老到没 BTF 的内核,考虑 BTFHub 提供的外部 BTF 文件。

  • invalid access to packet 几乎都是漏了边界检查。 XDP/tc 里每次访问包数据前,都要有 if (ptr + n > data_end) return ...;,而且检查和访问之间不能插入可能让 verifier "忘记"边界的操作。

  • CO-RE 读字段一定用 BPF_CORE_READ,别裸解引用。 裸写 task->parent->pid 要么 verifier 拒绝,要么在字段偏移变化的内核上读到脏数据。

  • bpf_probe_read_userbpf_probe_read_kernel 别搞混。 读用户态内存(syscall 参数、字符串)用 _user,读内核结构体用 _kernel。5.5 之前只有不分家的 bpf_probe_read,新内核上分开了要对号入座。

  • RLIMIT_MEMLOCK。 老内核 eBPF map/prog 占用锁定内存,不解除 RLIMIT_MEMLOCK 会加载失败报 permission denied。5.11+ 改用 cgroup 计量,可以不管,但兼容老内核就加上 rlimit.RemoveMemlock()

  • 权限。 完整能力需要 CAP_BPF + CAP_PERFMON(5.8+ 拆分出来的),老内核则需要 CAP_SYS_ADMIN(基本等于 root)。容器里跑要注意 securityContext。

  • 卸载 XDP 别忘了。 程序崩了没 detach,XDP 程序会一直挂在网卡上导致流量异常。link.Close() 要用 defer 兜住,或者用 pinning 做持久化管理。


九、生态全景:站在巨人的肩膀上

你不必总是从零手撸 eBPF,成熟的项目已经把它封装成了生产力工具:

  • Cilium:CNCF 毕业项目,用 eBPF 重写了 Kubernetes 的网络、负载均衡、网络策略,还带 Hubble 做网络可观测。云原生网络的事实标准之一。
  • Tetragon:Cilium 团队出品,基于 eBPF 的运行时安全与可观测,能在内核层做进程、文件、网络的实时监控和策略强制。
  • Pixie / DeepFlow:无埋点应用可观测,自动抓取 HTTP/gRPC/MySQL 等协议流量,画出全链路拓扑,零代码改动。
  • bpftrace:eBPF 界的 awk/dtrace,一行脚本就能做即席(ad-hoc)内核追踪,排障神器。比如 bpftrace -e 'tracepoint:syscalls:sys_enter_openat { printf("%s\n", comm); }'
  • BCC:老牌 eBPF 工具集,自带几十个开箱即用的性能分析工具(execsnoop、opensnoop、biolatency 等),学习和排障都好用。
  • cilium/ebpf:纯 Go 的 eBPF 库,云原生项目写 eBPF 用户态的首选。
  • libbpf + libbpf-rs:C/Rust 生态的 CO-RE 标准库。

一个务实的建议:排障用 bpftrace / BCC,做产品用 cilium/ebpf 或 libbpf,研究原理才自己从零写。 别重复造轮子。


十、总结与展望

我们从"线上偶发抖动无从下手"的痛点出发,一路拆到了 eBPF 的字节码虚拟机、把你摁在地上摩擦的 verifier、内核与用户态共享状态的 Map、破解版本兼容的 CO-RE/BTF,最后用 C + Go 手撸了 execve 追踪和 XDP 丢包统计两个能跑的探针。

如果只让我留下三句话,那就是:

  1. eBPF 的本质是"安全地把代码下沉到内核"——verifier 保证安全,JIT 保证性能,Map 保证通信,CO-RE 保证可移植。这四块拼图缺一不可。
  2. 它把可观测性、网络、安全三个领域的底层统一到了同一个内核事件流上,这是它区别于一切上层框架的降维优势。
  3. 动手写一次,胜过读十篇文章。 verifier 报错会教你什么叫内核级的安全约束,这种理解是看博客得不到的。

往前看,eBPF 的边界还在扩张:kfunc 让它能调用更多内核能力,sched_ext 让你能用 eBPF 写 CPU 调度器(这在几年前简直不可想象),Windows 也在推 eBPF-for-Windows,把这套机制搬到另一个操作系统上。可以预见,"用 eBPF 安全地扩展操作系统内核"会成为基础软件领域一个长期的主旋律。

内核不再是那个只能远观、不可亵玩的黑盒。带上 eBPF,你手里多了一把能安全切开内核的手术刀。去用它。


本文所有代码基于 Linux 5.8+ 内核、cilium/ebpf v0.12+ 编写,可直接编译运行。生产部署前请确认目标内核版本与 BTF 支持情况。

推荐文章

robots.txt 的写法及用法
2024-11-19 01:44:21 +0800 CST
js生成器函数
2024-11-18 15:21:08 +0800 CST
File 和 Blob 的区别
2024-11-18 23:11:46 +0800 CST
程序员茄子在线接单