Getting Started with eBPF in Go
ebpf-go 给出了一个比较详细的 eBPF / ebpf-go 入门实例:Getting Started with eBPF in Go
本文主要是一些翻译 + 实践记录。
在本指南中,我们将带你从零开始构建一个基于 eBPF 的 Go 应用程序。我们将介绍工具链,编写一个最小的 eBPF C 示例,并使用 bpf2go 进行编译。然后,我们将组装一个 Go 应用程序,将 eBPF 程序加载到内核中,并定期显示其输出。
该应用将一个 eBPF 程序挂载到 XDP 钩子上,用于统计物理接口接收的数据包数量。数据包的过滤和修改是 eBPF 的主要应用场景,因此你会看到它的许多功能都围绕这一需求设计。不过,eBPF 的能力正在不断扩展,目前已广泛应用于追踪、系统和应用可观测性、安全等诸多领域。
eBPF C program
To follow along with the example, you'll need:
- Linux kernel version 5.7 or later, for bpf_link support
- LLVM 11 or later 1 (clang and llvm-strip)
- libbpf headers 2
- Linux kernel headers 3
- Go compiler version supported by ebpf-go's Go module
sudo apt-get install -y libbpf-dev libelf-dev clang llvm linux-headers-$(uname -r)
让我们从编写 eBPF C 程序开始,因为它的结构将作为生成 Go 样板代码的基础。
//go:build ignore
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
struct {
__uint(type, BPF_MAP_TYPE_ARRAY);
__type(key, __u32);
__type(value, __u64);
__uint(max_entries, 1);
} pkt_count SEC(".maps");
// count_packets atomically increases a packet counter on every invocation.
SEC("xdp")
int count_packets() {
__u32 key = 0;
__u64 *count = bpf_map_lookup_elem(&pkt_count, &key);
if (count) {
__sync_fetch_and_add(count, 1);
}
return XDP_PASS;
}
char __license[] SEC("license") = "Dual MIT/GPL";
-
go:build ignore: 当把 C 文件和 Go 文件放在同一目录下时,需要用 Go build tag 将这些 C 文件排除掉。否则,在未使用 cgo 或 SWIG 的情况下,go build会报错:不允许出现 C 源文件。Go 工具链可以安全地忽略我们的 eBPF C 文件。 -
两个头文件
#include <linux/bpf.h> #include <bpf/bpf_helpers.h>区别:
linux/bpf.h提供内核定义的 BPF 接口和类型;bpf/bpf_helpers.h提供 libbpf 给 eBPF C 程序用的辅助宏和 helper 声明。<linux/bpf.h>是 Linux 内核 UAPI 头文件。它来自内核源码里的:include/uapi/linux/bpf.h它定义的是内核暴露给用户态和 eBPF 程序的 BPF 基础 ABI,比如:
__u64 __u32 BPF_MAP_TYPE_ARRAY BPF_MAP_TYPE_HASH BPF_PROG_TYPE_XDP BPF_FUNC_map_lookup_elem struct bpf_map_def enum bpf_map_type它关注的是:内核 BPF 接口本身。
<bpf/bpf_helpers.h>是 libbpf 提供的 eBPF 程序辅助头文件。它通常来自:
/usr/include/bpf/bpf_helpers.h它定义的是编写 eBPF C 程序时常用的宏和 helper 声明,比如:
SEC("xdp") __uint(type, BPF_MAP_TYPE_ARRAY) __type(key, __u32) __type(value, __u64) bpf_printk() bpf_map_lookup_elem()例如现代 libbpf 风格的 map 定义:
struct { __uint(type, BPF_MAP_TYPE_ARRAY); __uint(max_entries, 1); __type(key, __u32); __type(value, __u64); } my_map SEC(".maps");其中使用的
__uint、__type、SEC(".maps")不是内核直接提供的,而是bpf_helpers.h里的宏。libbpf 本身的定位是 eBPF 的用户态支撑库:一方面负责加载 eBPF 程序;另一方面也提供了一组头文件和宏,让开发者更方便地编写 eBPF C 程序。下面是一个比较清晰的图:
eBPF C 源码 ├─ include <linux/bpf.h> // 内核 UAPI 定义 ├─ include <bpf/bpf_helpers.h> // libbpf 提供的宏和 helper 声明 ↓ clang/LLVM 编译 ↓ BPF ELF object 文件,比如 prog.bpf.o ↓ libbpf 加载、重定位、创建 map、attach ↓ kernel verifier 校验并执行 -
map 定义
ebpf 的一项重要作用就是从内核/用户态函数采集数据,由于 ebpf 程序是在内核态执行的,而采集到的数据往往需要在用户态进行处理。ebpf 中进行用户态和内核态数据传输的工具就是 map。这种机制类似于 io_uring 中的 Ring Buffer。struct { __uint(type, BPF_MAP_TYPE_ARRAY); __type(key, __u32); __type(value, __u64); __uint(max_entries, 1); } pkt_count SEC(".maps");这个结构体声明一个名为
pkt_count的 BPF map。它是一个 Array 类型的 map,里面保存一个u64类型的值。关于所有可用 map 类型的概览,可以查看man bpf或在线的 bpf man pages。在这个例子中,我们选择了 array,因为它是一种你很可能已经熟悉的常见数据结构。在 BPF 中,array 会被预先分配并初始化为 0,因此无需额外初始化即可安全使用。这个 map 定义被放置在
.mapsELF section 中,ebpf-go会从这里查找它。BPF_MAP_TYPE_ARRAY虽然叫 array,但它不是编程语言里的普通数组。它是内核对象。key/value 类型是在声明它的 ABI:用户态、内核、verifier 都可能通过 ABI 知道怎么访问它。
访问时,可以通过 helper 访问:__u32 key = 0; __u64 *value = bpf_map_lookup_elem(&pkt_count, &key);BPF array map 的 key 就是 数组下标,由于上面定义的 Array 只有一个元素,所以 key 就是 0。
-
SEC("xdp")
在 BPF 中,并不是所有 eBPF 程序都是同一种类型。有些程序处理原始网络包,有些程序在内核函数或用户态函数的上下文中执行,还有一些程序期望运行在__sk_buff上下文中。这些差异通过 Program Type 来编码。libbpf 围绕“哪些 ELF section 对应哪些程序类型”引入了一组约定。
在这个例子中,我们选择了
xdp,因为稍后会把这个程序挂载到 XDP hook 上。Program Type 指的是内核层面的 BPF 程序类型:
BPF_PROG_TYPE_XDP BPF_PROG_TYPE_SOCKET_FILTER BPF_PROG_TYPE_KPROBE // 挂载到内核函数入口或返回点,用于动态追踪或调试 BPF_PROG_TYPE_TRACEPOINT // 挂载到内核预定义的静态跟踪点(Tracepoint)上 BPF_PROG_TYPE_CGROUP_SKB BPF_PROG_TYPE_SCHED_CLSeBPF 程序可用于日益增长的多种不同用途。为了适应这些不同的使用场景,存在不同类型的 eBPF 程序。Linux 内核可能会根据程序类型限制或允许某些功能,并非所有类型的程序都能执行相同的操作,因为它们在 kernel 中的执行位置不同。验证器将强制执行这些限制。因此Program Type 不仅仅是普通的“分类标签”,它会影响:
- 这个程序能挂到哪里
- ctx 参数是什么类型
- 可以调用哪些 BPF helper
- 返回值应该是什么语义
- verifier 按什么规则校验它
SEC()内部的节名称是否可以随便填?填什么?
它的定义大致是:#define SEC(NAME) __attribute__((section(NAME), used))从 编译器/ELF 角度,可以自定义,SEC 宏本质上只是告诉编译器:把这个函数或变量放进指定名字的 ELF section 里。SEC 宏的作用就是把符号放到指定 ELF section 中。但是符号所在的节通常具有隐含含义,可能会改变加载器(库)(如 libbpf)对该节内容的解释方式,因此使用时通常使用预定义的节名。Program Types and ELF Sections 给出了Program Type、相关的挂载类型以及 libbpf 为它们支持的 ELF 节名称。
-
count_packets 函数
int count_packets() { __u32 key = 0; __u64 *count = bpf_map_lookup_elem(&pkt_count, &key); if (count) { __sync_fetch_and_add(count, 1); } return XDP_PASS; }由于我们把
max_entries指定为1,所以pkt_count里只有一个可能存在的元素。因此,我们总是访问这个数组的第 0 个元素。这里,我们是在向 BPF runtime 请求
pkt_count这个 Map 中第 0 个元素的指针。bpf_map_lookup_elem是一个 BPF helper,声明在docs.h中。helper 是内核提供的一小段逻辑,用来让 BPF 程序和它的上下文,或者内核的其他部分进行交互。可以使用man bpf-helpers,或者在线的bpf-helpersman page,查看当前内核支持的所有 BPF helper。所有 Map 查询都有可能失败。如果 Map 中没有请求 key 对应的元素,
count就会是一个空指针。BPF verifier 对潜在空指针的访问检查非常严格,所以后续对count的任何访问都必须先经过空指针检查。这里会把
count指向的值原子地加1。需要注意的是,在启用了 SMP 的系统上,也就是现在大多数系统上,同一个 BPF 程序可能会并发执行。虽然我们只加载了一份 Program,并且只有一个pkt_countMap,但内核可能需要在多个接收队列上并行处理传入的数据包。这会导致程序的多个实例同时执行,而pkt_count实际上就变成了一块共享内存。因此,需要使用原子操作,避免脏读和脏写。SMP 是 Symmetric Multi-Processing,中文通常叫:对称多处理
简单说就是:一台机器里有多个 CPU 核心,并且这些核心都可以平等地访问同一份内存、运行同一个内核。现代多核 CPU 系统基本都是 SMP 系统。
在 SMP 系统上,多个 CPU 核心可能同时收到数据包,也就可能同时执行这段 XDP 程序。所以所有 CPU、所有执行中的 eBPF 程序实例,都会更新同一个计数器。XDP 允许在非常早的阶段丢弃数据包,远早于数据包进入内核网络栈。路由、防火墙,也就是 iptables/nftables,以及 TCP、socket 等机制,都是在内核网络栈中实现的。这里我们返回
XDP_PASS,表示放行数据包,避免干扰内核网络栈。 -
License
有些 BPF helper 内部会调用 Linux 内核中只允许 GPLv2-compatible 代码使用的内核函数/符号,所以使用这些 helper 的 BPF 程序也必须声明 GPL-compatible license。
也可以使用双许可证。这里我们选择了Dual MIT/GPL,因为ebpf-go本身采用 MIT 许可证。
Compile eBPF C and generate scaffolding using bpf2go
准备好 counter.c 源文件后,创建另一个名为 gen.go 的文件,其中包含 //go:generate 语句。当在项目目录中运行 go generate 时,这将调用 bpf2go 。
除了编译我们的 eBPF C 程序,bpf2go 还会生成一些脚手架代码,用于将 eBPF 程序加载到内核中并与其各个组件进行交互。这大大减少了我们启动和运行所需编写的代码量。
gen.go
package main
//go:generate go tool bpf2go -tags linux counter counter.c
为你的包使用一个专门的文件来存放
//go:generate语句,可以很好地将它们与应用逻辑分离开来。在本指南的当前阶段,我们还没有 main.go 文件。如果你愿意,也可以将其包含在现有的 Go 源文件中。
在使用 Go 工具链之前,Go 要求我们声明一个 Go 模块。以下命令可以完成这个操作:
go mod init ebpf-test && go mod tidy
首先,将 bpf2go 作为工具依赖项添加到你的 Go 模块中。这样可以确保 Go 工具链使用的 bpf2go 版本始终与你的库版本保持一致。
go get -tool github.com/cilium/ebpf/cmd/bpf2go
运行 go generate:
go generate
bpf2go 构建 counter.c 到 counter_bpf*.o 中,背后使用了 clang 。它根据对象文件的内容生成了两个对象文件和两个对应的 Go 源文件。请勿删除其中任何文件,我们稍后会用到它们。
$ tree .
.
├── counter_bpfeb.go
├── counter_bpfeb.o
├── counter_bpfel.go
├── counter_bpfel.o
├── counter.c
├── gen.go
├── go.mod
├── go.sum
The Go application
最后,在完成 eBPF C 代码编译和 Go 脚手架生成后,剩下的工作就是编写 Go 代码,Go 代码负责加载程序并将其挂载到 Linux 内核钩子上。
main.go
package main
import (
"log"
"net"
"os"
"os/signal"
"time"
"github.com/cilium/ebpf/link"
"github.com/cilium/ebpf/rlimit"
)
func main() {
// Remove resource limits for kernels <5.11.
if err := rlimit.RemoveMemlock(); err != nil {
log.Fatal("Removing memlock:", err)
}
// Load the compiled eBPF ELF and load it into the kernel.
var objs counterObjects
if err := loadCounterObjects(&objs, nil); err != nil {
log.Fatal("Loading eBPF objects:", err)
}
defer objs.Close()
ifname := "eth0" // Change this to an interface on your machine.
iface, err := net.InterfaceByName(ifname)
if err != nil {
log.Fatalf("Getting interface %s: %s", ifname, err)
}
// Attach count_packets to the network interface.
link, err := link.AttachXDP(link.XDPOptions{
Program: objs.CountPackets,
Interface: iface.Index,
})
if err != nil {
log.Fatal("Attaching XDP:", err)
}
defer link.Close()
log.Printf("Counting incoming packets on %s..", ifname)
// Periodically fetch the packet counter from PktCount,
// exit the program when interrupted.
tick := time.Tick(time.Second)
stop := make(chan os.Signal, 5)
signal.Notify(stop, os.Interrupt)
for {
select {
case <-tick:
var count uint64
err := objs.PktCount.Lookup(uint32(0), &count)
if err != nil {
log.Fatal("Map lookup:", err)
}
log.Printf("Received %d packets", count)
case <-stop:
log.Print("Received signal, exiting..")
return
}
}
}
loadCounterObjects: 加载编译后的eBPF ELF文件并将其载入内核。link.AttachXDP: 将count_packets函数挂载到 XDP hook。
load 和 attach 的阶段不同:
- load = 把 eBPF 程序放进内核,并通过 verifier 检查
- attach = 把已经 load 的 eBPF 程序挂到某个内核事件/ hook 上,让它实际触发执行
Building and running the Go application
$ go build && sudo ./ebpf-test
2026/07/07 14:30:48 Counting incoming packets on eth0..
2026/07/07 14:30:49 Received 50 packets
2026/07/07 14:30:50 Received 76 packets
2026/07/07 14:30:51 Received 96 packets
2026/07/07 14:30:52 Received 120 packets
2026/07/07 14:30:53 Received 170 packets
2026/07/07 14:30:54 Received 224 packets
2026/07/07 14:30:54 Received signal, exiting..
在 eth0 上生成一些流量,然后就能看到计数器增加。
Iteration Workflow
在迭代 C 代码时,请确保生成的文件保持最新。如果不重新运行 bpf2go,eBPF C 代码将不会被重新编译,对 C 程序结构所做的任何更改也不会反映在 Go 脚手架中。
问题
$ go generate
In file included from counter.c:4:
In file included from /usr/include/linux/bpf.h:11:
/usr/include/linux/types.h:5:10: fatal error: 'asm/types.h' file not found
#include <asm/types.h>
^~~~~~~~~~~~~
1 error generated.
Error: compile: exit status 1
gen.go:3: running "go": exit status 1
需要的头文件是/usr/include/asm/types.h
Debian multiarch 是一项功能,允许您在同一系统上安装和运行为不同 CPU 架构编译的软件包(尤其是库)。
$ find /usr/include -path '*asm/types.h'
/usr/include/x86_64-linux-gnu/asm/types.h
/usr/include/alpha-linux-gnu/asm/types.h
/usr/include/aarch64-linux-gnu/asm/types.h
/usr/include/arm-linux-gnueabi/asm/types.h
/usr/include/arm-linux-gnueabihf/asm/types.h
/usr/include/hppa-linux-gnu/asm/types.h
/usr/include/i386-linux-gnu/asm/types.h
/usr/include/loongarch64-linux-gnu/asm/types.h
/usr/include/m68k-linux-gnu/asm/types.h
/usr/include/mips-linux-gnu/asm/types.h
/usr/include/mips64-linux-gnuabi64/asm/types.h
/usr/include/mips64el-linux-gnuabi64/asm/types.h
/usr/include/mipsel-linux-gnu/asm/types.h
/usr/include/mipsisa64r6el-linux-gnuabi64/asm/types.h
/usr/include/powerpc-linux-gnu/asm/types.h
/usr/include/powerpc64-linux-gnu/asm/types.h
/usr/include/powerpc64le-linux-gnu/asm/types.h
/usr/include/riscv64-linux-gnu/asm/types.h
/usr/include/s390x-linux-gnu/asm/types.h
/usr/include/sh4-linux-gnu/asm/types.h
/usr/include/sparc64-linux-gnu/asm/types.h
/usr/include/x86_64-linux-gnux32/asm/types.h
有两种解决方法:
- 将
/usr/include/$(uname -m)-linux-gnu添加到 include 路径中。//go:generate go tool bpf2go -tags linux counter counter.c -- -I/usr/include/x86_64-linux-gnu - 安装 gcc-multilib
在 x86_64 PC 上,gcc-multilib debian 软件包会在 "/usr/include/asm" 处创建一个指向 "/usr/include/x86_64-linux-gnu" 的符号链接。$ ll /usr/include/asm lrwxrwxrwx 1 root root 20 Jan 8 2023 /usr/include/asm -> x86_64-linux-gnu/asm
评论