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";
  1. go:build ignore: 当把 C 文件和 Go 文件放在同一目录下时,需要用 Go build tag 将这些 C 文件排除掉。否则,在未使用 cgo 或 SWIG 的情况下,go build 会报错:不允许出现 C 源文件。Go 工具链可以安全地忽略我们的 eBPF C 文件。

  2. 两个头文件

    #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__typeSEC(".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 校验并执行
    
  3. 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 定义被放置在 .maps ELF 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。

  4. 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_CLS
    

    eBPF 程序可用于日益增长的多种不同用途。为了适应这些不同的使用场景,存在不同类型的 eBPF 程序。Linux 内核可能会根据程序类型限制或允许某些功能,并非所有类型的程序都能执行相同的操作,因为它们在 kernel 中的执行位置不同。验证器将强制执行这些限制。因此Program Type 不仅仅是普通的“分类标签”,它会影响:

    1. 这个程序能挂到哪里
    2. ctx 参数是什么类型
    3. 可以调用哪些 BPF helper
    4. 返回值应该是什么语义
    5. 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 节名称。

  5. 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-helpers man page,查看当前内核支持的所有 BPF helper。

    所有 Map 查询都有可能失败。如果 Map 中没有请求 key 对应的元素,count 就会是一个空指针。BPF verifier 对潜在空指针的访问检查非常严格,所以后续对 count 的任何访问都必须先经过空指针检查。

    这里会把 count 指向的值原子地加 1。需要注意的是,在启用了 SMP 的系统上,也就是现在大多数系统上,同一个 BPF 程序可能会并发执行。虽然我们只加载了一份 Program,并且只有一个 pkt_count Map,但内核可能需要在多个接收队列上并行处理传入的数据包。这会导致程序的多个实例同时执行,而 pkt_count 实际上就变成了一块共享内存。因此,需要使用原子操作,避免脏读和脏写。

    SMP 是 Symmetric Multi-Processing,中文通常叫:对称多处理
    简单说就是:一台机器里有多个 CPU 核心,并且这些核心都可以平等地访问同一份内存、运行同一个内核。现代多核 CPU 系统基本都是 SMP 系统。
    在 SMP 系统上,多个 CPU 核心可能同时收到数据包,也就可能同时执行这段 XDP 程序。所以所有 CPU、所有执行中的 eBPF 程序实例,都会更新同一个计数器。

    XDP 允许在非常早的阶段丢弃数据包,远早于数据包进入内核网络栈。路由、防火墙,也就是 iptables/nftables,以及 TCP、socket 等机制,都是在内核网络栈中实现的。这里我们返回 XDP_PASS,表示放行数据包,避免干扰内核网络栈。

  6. 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.ccounter_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
    

评论