bpf2go原理和使用介绍

说明

前面 ebpf-example 都是通过llvm编译ebpf的内核程序得到BPF格式.o后缀的文件,通过go:embed的方式将.o文件嵌入到go程序中,然后通过ebpfmanager加载和运行ebpf程序。

最近在学习 vArmor-ebpf,发现采用的是bpf2go的方式编译eBPF程序得到golang程序。用户态通过这些Golang程序就可以与eBPF程序交互。

为了以后再遇到通过bpf2go方式编译eBPF程序,于是就学习了相关的概念。

基本概念

在了解bpf2go之前,首先还是看看llvm编译ebpf的内核程序得到BPF格式.o后缀的文件,这种方式如何加载和运行ebpf程序。还是以 helloworld 为例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//go:embed ebpf/bin/probe.o
var _bytecode []byte

func main() {
m := &manager.Manager{
Probes: []*manager.Probe{
{
UID: "VFSMkdir",
Section: "kprobe/vfs_mkdir",
EbpfFuncName: "kprobe_vfs_mkdir",
AttachToFuncName: "vfs_mkdir",
},
},
}
err := m.Init(bytes.NewReader(_bytecode))
.....
}

通过go:embed ebpf/bin/probe.o的方式,得到[]byte类型的_bytecode变量,然后通过m.Init(bytes.NewReader(_bytecode))的方式加载和运行ebpf程序。

bpf2go其实就是将这一部分操作封装起来,让开发人员不需要关心这些细节,只需要关心ebpf程序的开发即可。整个bpf2go的逻辑如下所示:

bpf2go工具的主要作用是将位于C源文件中的eBPF程序转换为Go语言代码,并生成相应的Go包。这样,开发人员可以在Go项目中使用这些生成的代码来与eBPF程序进行交互和操作。如上图所示bpf_bpfel.obpf_bpfel.go文件就是由bpf2go工具生成。

上面的例子中bpf_bpfel.obpf_bpfel.go表示小端。bpf字节码文件bpf_bpfeb.o(大端)和bpf_bpfel.o(小端),然后bpf2go会基于ebpf字节码文件生成bpf_bpfeb.go或bpf_bpfel.go。生成大端或者是小端,是通过bpf2go程序运行时指定,后面的示例代码会说明如何使用bpf2go工具。

bpf_bpfel.go

bpf_bpfel.o是由eBPF程序编译得到的,和使用LLVM编译得到的程序基本上没有差别,这个文件也没有什么特殊的地方,就不作介绍,主要是看看bpf_bpfel.go内容。

读取bpf_bpfel.o文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// loadBpf returns the embedded CollectionSpec for bpf.
func loadBpf() (*ebpf.CollectionSpec, error) {
reader := bytes.NewReader(_BpfBytes)
spec, err := ebpf.LoadCollectionSpecFromReader(reader)
if err != nil {
return nil, fmt.Errorf("can't load bpf: %w", err)
}

return spec, err
}

// Do not access this directly.
//
//go:embed bpf_bpfel.o
var _BpfBytes []byte

bpf_bpfel.go文件中,也是通过go:embed bpf_bpfel.o,将bpf_bpfel.o转换成为[]byte数据,之后在loadBpf()函数中,通过bytes.NewReader(_BpfBytes)加载程序,通过这种方式加载和解析eBPF程序。

加载bpf_bpfel.o文件

1
2
3
4
5
6
7
8
func loadBpfObjects(obj interface{}, opts *ebpf.CollectionOptions) error {
spec, err := loadBpf()
if err != nil {
return err
}

return spec.LoadAndAssign(obj, opts)
}

这个函数用于加载eBPF程序并将其转换为结构体。它接受一个obj参数,该参数可以是bpfObjects、bpfPrograms或*bpfMaps类型的对象。它还接受一个可选的ebpf.CollectionOptions参数。函数内部调用loadBpf()函数获取ebpf.CollectionSpec对象,并使用spec.LoadAndAssign()将程序和映射加载到内核中,并将其分配给obj对象。

实际流程

上面对bpf2go工具和生成的bpf_bpfel.go文件进行了一个简要的分析,接下来以一个实际的例展示用法。

内核态

1
2
3
4
5
6
SEC("kprobe/vfs_mkdir")
int kprobe_vfs_mkdir(void *ctx)
{
bpf_printk("mkdir (vfs hook point) by using bpf2go\n");
return 0;
};

内核态程序保持不变,因为bpf2go只是一个编译工具,不会对内核态程序进行任何修改。

编译

1
2
3
4
5
.PHONY: build-ebpf
build-ebpf:
export BPF_CLANG=clang; \
export BPF_CFLAGS="-O2 -g -Wall -Werror"; \
go generate ./...

首先是设置了BPF_CLANGBPF_CFLAGS,然后运行go generate ./...go generate ./... 是执行的命令。它使用 go generate 命令来生成 eBPF 代码和库。./...表示递归地在当前目录及其子目录中执行 go generate

generate

前面在Makefile中存在go generate ./...,那么就会编译在go文件中存在go:generate的代码。在本例中的代码就是在main.go中。

1
2
3
4
5
6
7
package main

//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc $BPF_CLANG -cflags $BPF_CFLAGS -target bpfel bpf ebpf/main.c -- -I ./ebpf/bpf -I ./ebpf/coreheaders

func main() {
.....
}

核心代码是是:

1
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc $BPF_CLANG -cflags $BPF_CFLAGS -target bpfel bpf ebpf/main.c -- -I ./ebpf/bpf -I ./ebpf/coreheaders

这段代码的目的是使用bpf2go工具将位于ebpf/main.c文件中的eBPF程序转换为Go语言代码,并生成相应的Go包。这样,开发人员可以在Go项目中使用这些生成的代码来与eBPF程序进行交互和操作。

  • -target bpfel,表示目标平台为bpfel,即基于eBPF的Little-endian平台。
  • bpf ebpf/main.c,要转换为 eBPF 代码的 C 文件路径
  • -- -I ./ebpf/bpf -I ./ebpf/coreheaders:这是传递给 C 编译器的附加选项,其中 -I 用于指定包含文件的搜索路径。

最终通过make build-ebpf的效果就是:

1
2
3
4
5
6
export BPF_CLANG=clang; \
export BPF_CFLAGS="-O2 -g -Wall -Werror"; \
go generate ./...
Compiled /ebpf-example/bpf2go/bpf_bpfel.o
Stripped /ebpf-example/bpf2go/bpf_bpfel.o
Wrote /ebpf-example/bpf2go/bpf_bpfel.go

就会成功生成bpf_bpfel.gobpf_bpfel.o文件。

用户态

由于用户态的程序基本上都是大同小异,主要是关于如何加载eBPF的程序。

1
2
3
4
5
6
7
8
9
10
11
12
// Load pre-compiled programs and maps into the kernel.
objs := bpfObjects{}
if err := loadBpfObjects(&objs, nil); err != nil {
fmt.Println("loading objects: %s", err)
}
defer objs.Close()

kp, err := link.Kprobe("vfs_mkdir", objs.bpfPrograms.KprobeVfsMkdir, nil)
if err != nil {
fmt.Println("opening Kprobe: %s", err)
}
defer kp.Close()

loadBpfObjects(&objs, nil),加载eBPF程序并赋值到objs对象上。

Kprobe(),函数用于将给定的eBPF程序附加到一个性能事件(perf event),当给定的内核符号开始执行时触发该事件。它接受三个参数:内核符号的名称、eBPF程序的句柄和可选的ebpf.LinkOptions参数。在这里,我们将vfs_mkdir内核符号作为第一个参数,将objs.bpfPrograms.KprobeVfsMkdir作为第二个参数,将nil作为第三个参数。这个函数返回一个ebpf.Link对象,它可以用来关闭这个链接。
其中KprobeVfsMkdir实际的值就是:

1
2
3
type bpfPrograms struct {
KprobeVfsMkdir *ebpf.Program `ebpf:"kprobe_vfs_mkdir"`
}

通过link.Kprobe(...)就是加载vfs_mkdir事件。

实际运行

1
2
cat /sys/kernel/debug/tracing/trace_pipe  
<...>-1019699 [000] d...1 624015.272410: bpf_trace_printk: mkdir (vfs hook point) by using bpf2go

通过trace_pipe成功读取到消息,表示eBPF程序运行成功。

vArmor分析

存在Makefile代码:

1
2
3
4
5
6
7
8
CLANG ?= clang
CFLAGS := -O2 -g -Wall -Werror $(CFLAGS)

.PHONY: generate-ebpf
generate-ebpf: export BPF_CLANG := $(CLANG)
generate-ebpf: export BPF_CFLAGS := $(CFLAGS)
generate-ebpf: ## Generate the ebpf code and lib
go generate ./...

设置了BPF_CLANGBPF_CFLAGS的环境变量,然后运行go generate ./...命令。

go generate是Go语言的一个命令,用于自动生成Go代码或执行自定义的代码生成操作。它允许开发人员在Go源代码中添加特殊的注释指令(称为”generate directive”),以告诉Go编译器在构建过程中执行额外的代码生成任务。通过使用go generate命令,您可以在构建过程中自动化执行一些常见的代码生成任务,例如根据模板生成代码、从其他源生成代码等。这样可以减少手动重复的工作,提高开发效率。

分析enforcer.gotracer.go中的代码例子:

tracer.go

1
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc $BPF_CLANG -cflags $BPF_CFLAGS -target bpfel -type event bpf bpf/tracer.c -- -I./bpf/headers

enforcer.go

1
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc $BPF_CLANG -cflags $BPF_CFLAGS -target bpfel bpf bpf/enforcer.c -- -I./bpf/headers

两者的代码基本上都是一致的,上面的代码结合Makefile就等价于下面的命令:

1
go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang -cflags -O2 -g -Wall -Werror -target bpfel bpf bpf/enforcer.c -- -I./bpf/headers

  • go:generate:这是一个特殊的注释指令,告诉Go编译器在构建过程中执行代码生成任务。
  • go run:这是go generate命令执行的子命令,用于运行Go源代码或可执行文件。
  • github.com/cilium/ebpf/cmd/bpf2go:这是要运行的Go程序的导入路径,即bpf2go工具的位置。
  • -cc clang:这是bpf2go工具的参数,指定使用Clang作为C编译器。
  • -cflags -O2 -g -Wall -Werror:这是bpf2go工具的参数,指定C编译器的一些选项,例如优化级别、调试信息和错误处理。
  • -target bpfel:这是bpf2go工具的参数,指定目标平台为bpfel,即基于eBPF的Little-endian平台。
  • bpf bpf/enforcer.c:这是bpf2go工具的参数,指定要转换为Go代码的C源文件和相应的包名。
  • -- -I./bpf/headers:这是bpf2go工具的参数,指定C编译器的附加头文件搜索路径。

总体而言,这段代码的目的是使用bpf2go工具将位于bpf/enforcer.c文件中的eBPF程序转换为Go语言代码,并生成相应的Go包。这样,开发人员可以在Go项目中使用这些生成的代码来与eBPF程序进行交互和操作。

运行完成之后,最终得到的结果如下:

1
2
3
4
5
6
7
8
make generate-ebpf          
go generate ./...
Compiled /vArmor-ebpf/pkg/behavior/bpf_bpfel.o
Stripped /vArmor-ebpf/pkg/behavior/bpf_bpfel.o
Wrote /vArmor-ebpf/pkg/behavior/bpf_bpfel.go
Compiled /vArmor-ebpf/pkg/bpfenforcer/bpf_bpfel.o
Stripped /vArmor-ebpf/pkg/bpfenforcer/bpf_bpfel.o
Wrote /vArmor-ebpf/pkg/bpfenforcer/bpf_bpfel.go

成功生成了bpf_bpfel.go文件。在用户态的加载使用,也可以参考之前写的文章字节vArmor客户端代码解读

总结

bpf2go将一些和eBPF.o程序相关的操作全部都封装好了,方便程序员加载和使用eBPF内核态程序。向比较直接使用.o,可以简化很多的步骤。

当然也可以直接使用ebpfmanager来管理eBPF程序,这个工具也是比较方便的。至于具体的选择,就依据实际的情况而定。

参考

  1. https://github.com/cilium/ebpf/tree/main/cmd/bpf2go
  2. https://tonybai.com/2022/07/19/develop-ebpf-program-in-go/
  3. https://github.com/bigwhite/experiments/tree/master/ebpf-examples/helloworld
  4. https://github.com/bigwhite/experiments/tree/master/ebpf-examples/helloworld-go
  5. https://luckymrwang.github.io/2022/08/13/Cilium-eBPF-%E6%90%AD%E5%BB%BA%E4%B8%8E%E4%BD%BF%E7%94%A8/