使用Go开发eBPF程序可以通过以下三个步骤完成:

第一步,使用 C 语言开发内核态 eBPF 程序,这一步跟 libbpf 方法是完全相同的。

新建一个 hello.bpf.c 文件,然后写入内核态 eBPF 程序即可。

/* 由于我们并不需要cgo,这儿需要通过Go构建标签来排除C源文件,否则Go编译会报错 */
//go:build ignore

#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>

/* 定义BPF映射,用于存储网络包计数*/
struct {
    __uint(type, BPF_MAP_TYPE_ARRAY);
    __type(key, __u32);
    __type(value, __u64);
    __uint(max_entries, 1);
} pkt_count SEC(".maps");

/* XDP程序入口,统计网络包数量并存入BPF映射 */
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 表示 Go 编译时忽略 C 文件;
  • pkt_count 定义了一个用于存储网络包计数的 BPF 映射;
  • SEC("xdp") 定义了 XDP 程序的入口函数 count_packets。

从这段代码你可以发现,这儿的代码跟 libbpf 方法是一样的。只有一点需要注意的是 // go:build ignore 这一行是必不可少的,它的意思是让 Go 编译时忽略 C 源码文件。由于我们只是用 C 语言开发 eBPF 程序,并不需要通过 cgo 去直接调用内核态 eBPF 程序代码,所以在编译 Go 代码时应该忽略 C 源码文件。

第二步,借助 go generate 命令,使用 cmd/bpf2go 编译 eBPF 程序,并生成 Go 语言脚手架代码。

有了 eBPF 程序代码之后,接下来就是利用 cmd/bpf2go 来编译并生成 Go 脚手架代码了。创建一个 main.go 文件,并写入如下的代码。

package main

//go:generate go run github.com/cilium/ebpf/cmd/bpf2go hello hello.bpf.c

这段代码最关键的是第二句 go:generate 注解,用于在执行 go generate 时自动执行 cmd/bpf2go 命令。cmd/bpf2go 命令需要两个参数,第一个 hello 是生成文件名的前缀,而第二个参数 hello.bpf.c 就是我们第一步开发的 eBPF 程序。

在执行 go generate 命令之前,你还需要执行下面的命令,初始化一个 Go 模块,并添加对 github.com/cilium/ebpf/cmd/bpf2go 的依赖。

go mod init hello
go mod tidy
go get github.com/cilium/ebpf/cmd/bpf2go

接下来,你就可以执行 go generate 命令,编译并生成 Go 语言脚手架代码。如果一切顺利,你将看到如下输出:

$ go generate
Compiled /ebpf-apps/go/hello/hello_bpfel.o
Stripped /ebpf-apps/go/hello/hello_bpfel.o
Wrote /ebpf-apps/go/hello/hello_bpfel.go
Compiled /ebpf-apps/go/hello/hello_bpfeb.o
Stripped /ebpf-apps/go/hello/hello_bpfeb.o
Wrote /ebpf-apps/go/hello/hello_bpfeb.go

这其中,.o 文件就是编译目标文件, .go 文件就是对应的脚手架代码,而后缀 bpfel 和 bpfeb 则分别表示该文件用于小端系统和大端系统。

第三步,使用 cilium/ebpf 库配合上一步生成的脚手架代码开发用户态程序,包括 eBPF 程序加载、挂载到内核函数和跟踪点,以及通过 BPF 映射获取和打印执行结果等。

有了脚手架代码之后。可以在 main.go 里面继续添加 main() 函数,添加 eBPF 程序加载、挂载到 XDP,以及通过 BPF 映射获取和打印执行结果等执行逻辑。

// 1. 引入必要的依赖库
import (
 "log"
 "net"
 "os"
 "os/signal"
 "time"

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

func main() {
 // 2. 移除内核<5.11的资源限制
 if err := rlimit.RemoveMemlock(); err != nil {
  log.Fatal("Removing memlock:", err)
 }

 // 3. 调用脚手架函数,加载编译后的 eBPF 字节码
 var objs helloObjects
 if err := loadHelloObjects(&objs, nil); err != nil {
  log.Fatal("Loading eBPF objects failure:", err)
 }
 defer objs.Close()

  // 4. 挂载 XDP 程序到网卡上
 ifname := "eth0"
 iface, err := net.InterfaceByName(ifname)
 if err != nil {
  log.Fatalf("Getting interface %s failure: %s", ifname, err)
 }
 link, err := link.AttachXDP(link.XDPOptions{
  Program:   objs.CountPackets,
  Interface: iface.Index,
 })
 if err != nil {
  log.Fatal("Attaching XDP failure:", err)
 }
 defer link.Close()

 log.Printf("Counting incoming packets on %s..", ifname)

 // 5. 定期查询并打印数据包计数(Ctrl+C退出)
 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 failure:", err)
   }
   log.Printf("Received %d packets", count)
  case <-stop:
   log.Print("Received stop signal, exiting..")
   return
  }
 }
}

这段代码的主要逻辑跟 libbpf 方法也是类似的,所不同的只是编程语言和库函数的不同。另外,这段 Go 代码里面的 eBPF 程序名 CountPackets 和 BPF 映射名 PktCount 分别对应第一步 eBPF C 代码里面的 count_packets 和 pkt_count,这是 cmd/bpf2go 自动将 C 命名格式转换为 Go 的驼峰命名法导致的(即不使用下划线且单词首字母大写)。

代码开发完成后,你就可以编译并执行用户态的程序了。执行 go build 命令编译 Go 程序后并执行 ./hello 运行它,如果一切正常,你将看到如下的输出:

$ go build
$ ./hello
2023/12/30 14:19:49 Counting incoming packets on eth0..
2023/12/30 14:19:50 Received 9 packets
2023/12/30 14:19:51 Received 16 packets
2023/12/30 14:19:52 Received 20 packets

到这里已经使用 Go 语言成功开发并运行了第一个 eBPF 程序。