(gopher)一无所知学ebpf

这里写自定义目录标题

前言

我相信仍然有很多人不知道ebpf为何物,也不知道从何学起。但他似乎正在成为云原生开发性能优化的技术手段的事实标准,尤其是ebpf在容器网络性能、安全方面的巨大优势,几乎所有的云厂商无不在ebpf的实践应用上花功夫,不使用ebpf就跟不上别人的步伐。所以,掌握ebpf对于想要从事云原生开发、性能优化等有关工作的人很有帮助。ebpf的历史背景和应用背景我不再赘述,有很多文章已经介绍了。本文主要从讲述我,一个gopher学习ebpf的一些个人经验,姿势或许有不对的地方,若有大神能提出意见或批评,感激不尽。

ebpf是什么

eBPF 是一项革命性的技术,起源于 Linux 内核,可以在操作系统内核中运行沙盒程序。它用于安全有效地扩展内核的功能,而无需更改内核源代码或加载内核模块。eBPF 程序是事件驱动的,并在内核或应用程序通过某个挂钩点时运行。预定义的钩子包括系统调用、函数进入/退出、内核跟踪点、网络事件等。简单来说,ebpf程序就是hook。

关于ebpf的一些背景知识

以下内容,一开始看,完全没概念,看完也很快就忘,很正常,忘了的时候大不了再回过头来再看。

ebpf的程序类型

简单地可以理解ebpf是挂在内核或者应用程序上的程序。
(gopher)一无所知学ebpf

而且这些程序是有种类的,具体种类可以在源码中查看:ebpf程序类型

  • XDP
  • tc
  • kprobe
  • uretprobe
  • tracepoint

ebpf程序的种类有挺多,看起来这些程序种类和我们都不太熟,没关系,以后见多了就会知道xdp程序能够在网络数据包到达网卡驱动层时对其进行处理,tc程序能够控制流量,tracepoint程序能够在内核调用某些函数时执行某些动作,诸如这些初学者不懂是很正常的,我也还不明白怎么写这些程序。

ebpf的map类型

  • BPF_MAP_TYPE_HASH:哈希表,键值对
  • BPF_MAP_TYPE_ARRAY:数组,已针对快速查找速度进行了优化,通常用于计数器
  • BPF_MAP_TYPE_PROG_ARRAY:对应于eBPF程序的文件描述符数组;用于实现跳转表和子程序以处理特定的数据包协议,尾调用用到
  • BPF_MAP_TYPE_PERCPU_ARRAY:每个CPU的数组
  • BPF_MAP_TYPE_PERF_EVENT_ARRAY:存储指向struct perf_event的指针数组,用于读取和存储perf事件计数器
  • BPF_MAP_TYPE_CGROUP_ARRAY:存储控制组指针的数组
  • BPF_MAP_TYPE_PERCPU_HASH:每个CPU的哈希表
  • BPF_MAP_TYPE_LRU_HASH:带有淘汰机制的哈希,仅保留最近使用项目的哈希表
  • BPF_MAP_TYPE_LRU_PERCPU_HASH:带有淘汰机制的每个CPU的哈希表,仅保留最近使用的项目
  • BPF_MAP_TYPE_LPM_TRIE:最长前缀匹配树,适用于将IP地址匹配到某个范围
  • BPF_MAP_TYPE_STACK_TRACE:存储堆栈跟踪
  • BPF_MAP_TYPE_ARRAY_OF_MAPS:地图中地图数据结构
  • BPF_MAP_TYPE_HASH_OF_MAPS:地图中地图数据结构
  • BPF_MAP_TYPE_DEVICE_MAP:用于存储和查找网络设备引用
  • BPF_MAP_TYPE_SOCKET_MAP:存储和查找套接字,并允许使用BPF帮助器函数进行套接字重定向,跟BPF_SK_SKB_STREAM_VERDICT、BPF_SK_SKB_STREAM_PARSER等类型的ebpf程序有关
    (gopher)一无所知学ebpf

ebpf的map是用来沟通内核和用户空间的,我们知道我们自己些的业务程序是运行在用户空间,而用户空间和内核空间的沟通一般是通过系统调用,现在我们知道了还有另外一个途径,就是这个map。ebpf的程序类型和map类型,现在看了,有些同学可能感觉,这些字单个看起来我知道是什么东西,但是连在一起我不知道什么鬼,没关系,后面拿个ebpf程序来分析的时候,我们就知道是什么东西,怎么用了。

ebpf的体系结构

BPF 是一个通用的 RISC 指令集,最初的设计目的是用 C 的子集编写程序,这些程序可以通过编译器后端(例如 LLVM)编译成 BPF 指令,以便内核稍后可以通过内核中的 JIT 编译器到本机操作码中,以实现内核内的最佳执行性能。通俗地讲就是说用C编写一段程序然后编译成ELF格式的字节码,经过验证(拥有权限,不危害内核,不会无止境循环等),再经过JIT编译器(如果开启了)转换成机器特定的指令集加载到了内核。
(gopher)一无所知学ebpf

其他概念

诸如像bpf的指令系统、尾调用、bpf到bpf调用、bpf的辅助函数等等,我觉得在初学的时候没有必要去深究或者了解其中的概念,因为很快就会忘了,而且一开始还没应用到那块,先跳过,有需要再查资料。

参考

BPF 和 XDP 参考指南
eBPF Documentation
BPF Documentation

我该如何学习ebpf

刚开始接触ebpf,我只知道这是很牛逼的东西,但是我不知道从何学起,尤其是像我这种菜鸟,别说C语言了,go我都没整明白呢,怎么学呢?
我把ebpf当做一门语言来学习,以下几点是我在学习过程中所考虑的:

  • 尽量不涉及其他语言,能用go解决的,就坚决不使用别的语言,所以后面在开发的时候选择库工具时,我选择的时cilium/ebpf,不用bcc(python用)等其他工具
  • 掌握大部分语法
  • 掌握一门语言,还要知道怎么调试程序,所以知道怎么编写ebpf程序之后,我还要知道怎么查看调试信息,知道出了问题怎么排查
  • 掌握关于这项技术的应用,业界的发展到哪一步了,有学习的渠道,知道怎么学习进步
  • 鉴于ebpf对内核版本的依赖,这跟ebpf的co-re(create once, run everywhere)有关,我希望我写出来的程序能够跨环境运行

以上几点,我尽量在后面的文章体现。

搭建ebpf开发环境

对于学习一门新技术来说,搭环境可以说是学习路上的第一只拦路虎,据我研究表明,大多数人会因为环境没有搭建成功而放弃学习这门技术。所以我的建议是,使用虚拟机去学习,学习过程中,注意备份,把环境给搞乱了可以回滚。众所周知(前面我提到了),ebpf依赖内核版本,Linux kernel 3.18版本开始包含了eBPF。我不想跟内核版本纠缠太多,所以我直接下载了比较新版本的Ubuntu。当然其他系统也是可以,用以下命令查看自己的内核版本,内核版本太低的,可以升级一下:

$ uname -r
5.11.0-49-generic

我的环境主要参考cilium的文档,以下内容可以不看我的,直接去看cilium的文档,更丰富。
接着,安装一些必要的库和软件:

$ sudo apt-get install -y make gcc libssl-dev bc libelf-dev libcap-dev \
  clang gcc-multilib llvm libncurses5-dev git pkg-config libmnl-dev bison flex \
  graphviz

编译内核,下载所需的内核源码版本,然后编译(这并不是必须的,内核版本足够高了也不需要这么做)。

$ git clone git://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git
$ cd linux
$ cp /boot/config-`uname -r`* .config
$ make defconfig
$ make -j4
$ sudo make modules_install install
$ sudo update-grub2

接着重启生效。
编译 bpftool,bpftool 是围绕 BPF 程序和映射的调试和自省的重要工具。它是内核树的一部分,在tools/bpf/bpftool/。

$ cd <kernel-tree>/tools/bpf/bpftool/
$ make
$ make install

其实我觉得用比较新的Linux版本,并且安装完llvm、clang这些,环境就差不多搭建完了。如果遇到其他问题,在遇到的时候再去解决。

开始第一个bpf程序

我不是一个很喜欢系统学习的人,尤其是如标题所言,我是一个gopher小白,我对C语言、内核开发相关的知识知之甚少。如果让我从头学习如何开发内核、如何C语言开发,那我会奔溃并产生放弃的想法,对一个小白要求不能太严格,我希望的是能马上上手,并且看到成果,甚至能将它应用到生产上,让我吹吹牛逼。那我该怎么开始我的第一个bpf程序呢?当然是参考别人的代码啦。可以参考下bcc的代码,以及Linux源码里面关于bpf的代码示例,在源码目录下的samples/bpf里或者其他优秀的作品源码都是学习的资料。这里我以cilium/ebpf/examples的例子稍微做点修改来举例。代码如下:

#include "common.h"
#include "bpf_helpers.h"

char __license[] SEC("license") = "Dual MIT/GPL";

struct event_t {
	u32 pid;
	char str[80];
};

struct {
	__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
} events SEC(".maps");

SEC("uretprobe/bash_readline")
int uretprobe_bash_readline(struct pt_regs *ctx) {
	struct event_t event;

	event.pid = bpf_get_current_pid_tgid();
	bpf_probe_read(&event.str, sizeof(event.str), (void *)PT_REGS_RC(ctx));

	bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &event, sizeof(event));

	return 0;
}

接着编译它。我将用cilium/ebpf的库来完成这项工作,先看代码:

//go:build linux
// +build linux

// This program demonstrates how to attach an eBPF program to a uretprobe.
// The program will be attached to the 'readline' symbol in the binary '/bin/bash' and print out
// the line which 'readline' functions returns to the caller.
package main

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

	"github.com/cilium/ebpf/link"
	"github.com/cilium/ebpf/perf"
	"github.com/cilium/ebpf/rlimit"
	"golang.org/x/sys/unix"
)

// $BPF_CLANG and $BPF_CFLAGS are set by the Makefile.
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc $BPF_CLANG -cflags $BPF_CFLAGS bpf ./bpf/uretprobe_example.c -- -I../headers

// An Event represents a perf event sent to userspace from the eBPF program
// running in the kernel. Note that this must match the C event_t structure,
// and that both C and Go structs must be aligned same way.
type Event struct {
	PID  uint32
	Line [80]byte
}

const (
	// The path to the ELF binary containing the function to trace.
	// On some distributions, the 'readline' function is provided by a
	// dynamically-linked library, so the path of the library will need
	// to be specified instead, e.g. /usr/lib/libreadline.so.8.
	// Use `ldd /bin/bash` to find these paths.
	binPath = "/bin/bash"
	symbol  = "readline"
)

func main() {
	stopper := make(chan os.Signal, 1)
	signal.Notify(stopper, os.Interrupt, syscall.SIGTERM)

	// Allow the current process to lock memory for eBPF resources.
	if err := rlimit.RemoveMemlock(); err != nil {
		log.Fatal(err)
	}

	// Load pre-compiled programs and maps into the kernel.
	objs := bpfObjects{}
	if err := loadBpfObjects(&objs, nil); err != nil {
		log.Fatalf("loading objects: %s", err)
	}
	defer objs.Close()

	// Open an ELF binary and read its symbols.
	ex, err := link.OpenExecutable(binPath)
	if err != nil {
		log.Fatalf("opening executable: %s", err)
	}

	// Open a Uretprobe at the exit point of the symbol and attach
	// the pre-compiled eBPF program to it.
	up, err := ex.Uretprobe(symbol, objs.UretprobeBashReadline, nil)
	if err != nil {
		log.Fatalf("creating uretprobe: %s", err)
	}
	defer up.Close()

	// Open a perf event reader from userspace on the PERF_EVENT_ARRAY map
	// described in the eBPF C program.
	rd, err := perf.NewReader(objs.Events, os.Getpagesize())
	if err != nil {
		log.Fatalf("creating perf event reader: %s", err)
	}
	defer rd.Close()

	go func() {
		// Wait for a signal and close the perf reader,
		// which will interrupt rd.Read() and make the program exit.
		<-stopper
		log.Println("Received signal, exiting program..")

		if err := rd.Close(); err != nil {
			log.Fatalf("closing perf event reader: %s", err)
		}
	}()

	log.Printf("Listening for events..")

	var event Event
	for {
		record, err := rd.Read()
		if err != nil {
			if errors.Is(err, perf.ErrClosed) {
				return
			}
			log.Printf("reading from perf event reader: %s", err)
			continue
		}

		if record.LostSamples != 0 {
			log.Printf("perf event ring buffer full, dropped %d samples", record.LostSamples)
			continue
		}

		// Parse the perf event entry into an Event structure.
		if err := binary.Read(bytes.NewBuffer(record.RawSample), binary.LittleEndian, &event); err != nil {
			log.Printf("parsing perf event: %s", err)
			continue
		}

		log.Printf("%s:%s return value: %s", binPath, symbol, unix.ByteSliceToString(event.Line[:]))
	}
}

我们看下目录结构,然后编译一下:

$ tree
.
├── go.mod
├── go.sum
├── headers
│   ├── bpf_helper_defs.h
│   ├── bpf_helpers.h
│   └── common.h
└── uretprobe
    ├── bpf
    │   └── uretprobe_example.c
    ├── bpf_bpfeb.go
    ├── bpf_bpfeb.o
    ├── bpf_bpfel.go
    ├── bpf_bpfel.o
    ├── main.go
    └── uretprobe

3 directories, 12 files
$ BPF_CLANG=clang BPF_CFLAGS="-O2 -Wall"  go generate main.go
$ go build 

这里解释一下BPF_CLANG=clang BPF_CFLAGS="-O2 -Wall"这部分是我自己在命令行执行加上去的,如果是直接用cilium的例子里面的makefile的话,就直接make就好了。接着go generate main.go,这对应main.go里面的//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc $BPF_CLANG -cflags $BPF_CFLAGS bpf ./bpf/uretprobe_example.c -- -I../headers,调用cilium的工具,做了两个事情,将C语言的内容转成了字节码,并且生成了不同平台下的go文件供main.go调用。我们后面再分析cilium/ebpf/cmd/bpf2go具体做了什么事情。我们先运行下我们的程序看下效果,打开两个窗口,一个运行我们的程序,一个窗口随便发点什么东西,可以看到我们的程序捕获到了另外一个窗口输入内容:

$ sudo ./uretprobe
2022/01/29 17:43:04 Listening for events..
2022/01/29 17:43:38 /bin/bash:readline return value: 
2022/01/29 17:43:40 /bin/bash:readline return value: 
2022/01/29 17:43:48 /bin/bash:readline return value: echo
2022/01/29 17:43:58 /bin/bash:readline return value: hello world

再来看下/sys/kernel/tracing/trace的内容:

tail: /sys/kernel/tracing/trace:文件已截断
# tracer: nop
#
# entries-in-buffer/entries-written: 10/10   #P:4
#
#                                _-----=> irqs-off
#                               / _----=> need-resched
#                              | / _---=> hardirq/softirq
#                              || / _--=> preempt-depth
#                              ||| /     delay
#           TASK-PID     CPU#  ||||   TIMESTAMP  FUNCTION
#              | |         |   ||||      |         |
            bash-27288   [001] d...  2139.056864: bpf_trace_printk: Hello World!
            bash-27288   [002] d...  2154.118664: bpf_trace_printk: Hello World!
            bash-27288   [000] d...  2199.808609: bpf_trace_printk: Hello World!
            bash-27288   [001] d...  2211.306168: bpf_trace_printk: Hello World!
            bash-27288   [001] d...  2228.578027: bpf_trace_printk: Hello World!
            bash-28525   [002] d...  2258.128661: bpf_trace_printk: Hello World!
            bash-28525   [001] d...  2407.117436: bpf_trace_printk: Hello World!
            bash-28525   [001] d...  2412.014455: bpf_trace_printk: Hello World!
            bash-28525   [001] d...  2412.163354: bpf_trace_printk: Hello World!

结果符合我们预期,那我们接下来分析一下代码。

kernel部分代码分析

首先是#include部分,说实话,我一开始并不是对C语言很熟。所以我的文章会比较啰嗦,因为我“一无所知”。

include

#include "common.h"
#include "bpf_helpers.h"

这两个文件不是用系统的头文件,是引用的相对路径,在编译的时候“-I”指定了headers目录。这里有个问题,困扰了我很久,那就是这些头文件怎么来的?这跟环境问题一样让我棘手,头文件应该指的是令人头大的文件吧。后来我知道了,大部分这些头文件是编译内核的时候产生的,可以把它们放到自己的项目里,以此解决一部分环境问题。而有些头文件在编译的时候还是会报错,诸如:fatal error: 'asm/types.h' file not found之类的问题,这个时候有时候需要安装一些delve包了。还有比较常规的办法是,自己编译生成一些头文件。我们看下Linux自带的samples/bpf的readme文档,里面写道:

Kernel headers
--------------

There are usually dependencies to header files of the current kernel.
To avoid installing devel kernel headers system wide, as a normal
user, simply call::

 make headers_install

This will creates a local "usr/include" directory in the git/build top
level directory, that the make system automatically pickup first.

可以通过这种方式去编译生成内核的头文件,建议各位下载源码后去看看这个文档。然鹅,事情并未结束。生成之后头文件之后,让我更懵逼了。不同目录下生成了一堆文件名一样的头文件,这怎么搞,比如:uapi/linux/if_ether.h和linux/if_ether.h,这怎么搞嘛,我自己写代码的时候用哪个啊,淦!我也没有找到很合适的资料去了解这方面的东西。我一开始只是想用ebpf写个小工具装个逼而已,搞这么复杂!后来我还是在上面提到的那个samples/bpf的readme文档里找到了一些蛛丝马迹,运行

$ make M=samples/bpf V=1
···
clang -nostdinc -isystem /usr/lib/gcc/x86_64-linux-gnu/10/include -I./arch/x86/include -I./arch/x86/include/generated  -I./include -I./arch/x86/include/uapi -I./arch/x86/include/generated/uapi -I./include/uapi -I./include/generated/uapi -include ./include/linux/kconfig.h -fno-stack-protector -g \
	-Isamples/bpf -I./tools/testing/selftests/bpf/ \
	-I./tools/lib/ \
	-D__KERNEL__ -D__BPF_TRACING__ -Wno-unused-value -Wno-pointer-sign \
	-D__TARGET_ARCH_x86 -Wno-compare-distinct-pointer-types \
	-Wno-gnu-variable-sized-type-not-at-end \
	-Wno-address-of-packed-member -Wno-tautological-compare \
	-Wno-unknown-warning-option  \
	-I./samples/bpf/ -include asm_goto_workaround.h \
	-O2 -emit-llvm -Xclang -disable-llvm-passes -c samples/bpf/hbm_edt_kern.c -o - | \
	opt -O2 -mtriple=bpf-pc-linux | llvm-dis | \
	llc -march=bpf  -filetype=obj -o samples/bpf/hbm_edt_kern.o
  CLANG-bpf  samples/bpf/xdpsock_kern.o

这下总算清楚了,源码里面写了这么多-I,虽然很麻烦,但是总算是有个结论,那就是真的要自己写很多include。而开源项目那些很多都是把系统的或者生成的头文件放到了自己项目里面,这样写include的时候或者写makefile的时候就没有那么多依赖要注意。
说到依赖,这里还有一个问题呢,我去,两行include能整这么多乱七八糟的事情?!系统的头文件会随着内核版本的变化而略有改动。这给代码和环境的兼容性带来了很大的挑战。所以就有了bpf程序的CO-RE概念,一次编译,到处运行。CO-RE的关键是BTF和vmlinux.h,BTF将BPF对象(prog和map)结构化,而vmlinux.h包含了大部分的系统头文件的结构体,生成vmlinux.h的命令:

$ bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h

那么在编写bpf代码的时候,include就不用include那么多头文件,include一个vmlinux.h就行了。但是据我的尝试,vmlinux.h还是没有完全包含所有的系统头文件的内容,这就导致了很多时候还要自己把一些头文件的内容剪出来,真的是折磨。大概bpf的CO-RE还不是很完善吧,也可能是我姿势不对。

SEC

char __license[] SEC("license") = "Dual MIT/GPL";
···
struct {
	__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
} events SEC(".maps");
···
SEC("uretprobe/bash_readline")

看下bpf_helpers.h中SEC的定义:

/*
 * Helper macro to place programs, maps, license in
 * different sections in elf_bpf file. Section names
 * are interpreted by libbpf depending on the context (BPF programs, BPF maps,
 * extern variables, etc).
 * To allow use of SEC() with externs (e.g., for extern .maps declarations),
 * make sure __attribute__((unused)) doesn't trigger compilation warning.
 */
#define SEC(name) \
	_Pragma("GCC diagnostic push")					    \
	_Pragma("GCC diagnostic ignored \"-Wignored-attributes\"")	    \
	__attribute__((section(name), used))				    \
	_Pragma("GCC diagnostic pop")					    \

SEC,是一段宏,在编译好的elf文件中用来表示这是bpf的program、license、map的section。
license,当该程序使用内核提供的 BPF 辅助函数时,内核会使用许可证 section 验证程序是否与内核许可证兼容,一般都是“GPL”。
map,前面已经稍微介绍了,这段示例代码里面用了BPF_MAP_TYPE_PERF_EVENT_ARRAY类型的map。
uretprobe/bash_readline,这是这段代码里面最令人困惑的部分了,我怎么知道这里该写什么东西,后来经过一番查找资料,找到了这些定义

static const struct bpf_sec_def section_defs[] = {
	SEC_DEF("socket",		SOCKET_FILTER, 0, SEC_NONE | SEC_SLOPPY_PFX),
	SEC_DEF("sk_reuseport/migrate",	SK_REUSEPORT, BPF_SK_REUSEPORT_SELECT_OR_MIGRATE, SEC_ATTACHABLE | SEC_SLOPPY_PFX),
	SEC_DEF("sk_reuseport",		SK_REUSEPORT, BPF_SK_REUSEPORT_SELECT, SEC_ATTACHABLE | SEC_SLOPPY_PFX),
	SEC_DEF("kprobe/",		KPROBE,	0, SEC_NONE, attach_kprobe),
	SEC_DEF("uprobe/",		KPROBE,	0, SEC_NONE),
	SEC_DEF("kretprobe/",		KPROBE, 0, SEC_NONE, attach_kprobe),
	SEC_DEF("uretprobe/",		KPROBE, 0, SEC_NONE),
	···

而且类型是前缀,比如uretprobe/*,后面不管接什么内核也知道这是uretprobe类型的bpf程序。可能会有朋友疑惑为什么需要那么多分类,对新手实在有些不友好,还要去知道这些类型是啥才知道怎么写,其实这也没啥办法,因为bpf其实就是hook,要挂在内核的某个结点上,如果不标明这是什么类型的程序,那也就不知道要挂在那个内核结点上。

int uretprobe_bash_readline(struct pt_regs *ctx) {

不少人可能会对这个入参产生疑问,不知道要写什么参数,参数怎么用,这个可以在bpf.h这个文件里面查看,文档写得很齐全还有一些示例。/usr/include/linux/bpf.h

···
 *                      SEC("kprobe/sys_open")
 *                      void bpf_sys_open(struct pt_regs *ctx)
 *                      {
 *                              char buf[PATHLEN]; // PATHLEN is defined to 256
 *                              int res = bpf_probe_read_user_str(buf, sizeof(buf),
 *                                                                ctx->di);
 *
 *                              // Consume buf, for example push it to
 *                              // userspace via bpf_perf_event_output(); we
 *                              // can use res (the string length) as event
 *                              // size, after checking its boundaries.
 *                      }

···

bpf_helper

bpf_get_current_pid_tgid: 返回当前程序的pid
bpf_probe_read: 将内核空间的数据安全地存储到目标地址
bpf_perf_event_output: 将数据写到类型为BPF_MAP_TYPE_PERF_EVENT_ARRAY的map,以此来跟用户空间交互
bpf_helpers.hbpf_helper_defs.h定义了bpf有关的辅助函数,能帮助我们更好地编写bpf程序,值得通读一遍。

小结

虽然只是短短的几行代码,可是这其中却有那么多玄机,扩展开讲比较复杂,对于新手太不友好了。小小地总结一下,一个bpf程序该怎样写?

  1. 首先license是任何bpf程序都能先确定的,那我们就先写上去;
  2. 我们还必须知道我们要写的程序是啥?是SOCKET_FILTER还是TRACEPOINT。知道了程序类型后,我们可以去linux的源码(samples/bpf)或者其他渠道找一些程序示例,又或者自己写(从/usr/include/linux/bpf.h中知道程序的入参,返回),然后就可以写自己的主逻辑了;
  3. 程序中可能用到的一些头文件,不知道include怎么来的。可以自己生成vmlinux.h,加上一些自己复制的、自定义的文件,也可以直接用系统的头文件指定-I

掌握了以上流程差不多就能写出一个简单的bpf程序了。接着我们在来看用户空间方面怎么处理。

userspace代码分析

go generate

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

bpf程序的开发与应用一般(“一般”指的是我的理解)是这样的流程:

  1. 用C语言编写bpf程序;
  2. 将C语言程序编译成字节码;
  3. 将字节码加载到内核的加载点上(当然,还有将字节码编译成指令这些步骤,但我讲的是开发的流程,这些透明的步骤就不加细说了);
  4. 用户空间的程序通过系统调用或者map与这些加载到内核路径上的程序交互。

go generate调用bpf2go将语言程序编译成字节码,作用相当于clang XXX,并且还可以直接生成go语言代码,将接口暴露出来,直接调用就行,简单方便很多。看下生成的代码,里面有一段这个:

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

它将生成的字节码文件加载到了用户空间,然后解析了prog、map这些SEC,方便调用。

loadBpfObjects

// Load pre-compiled programs and maps into the kernel.
	objs := bpfObjects{}
	if err := loadBpfObjects(&objs, nil); err != nil {
		log.Fatalf("loading objects: %s", err)
	}
	defer objs.Close()

这段是加载bpf程序和map到内核中去,defer objs.Close()则是做一些用户空间程序关闭的处理工作,比如关闭文件啥的。

    // Open an ELF binary and read its symbols.
	ex, err := link.OpenExecutable(binPath)
	···
	// Open a Uretprobe at the exit point of the symbol and attach
	// the pre-compiled eBPF program to it.
	up, err := ex.Uretprobe(symbol, objs.UretprobeBashReadline, nil)
	···

打开elf格式的文件,读取symbols,这步不是每种程序都会有的,不同类型的程序可能不一样,具体的参考样例,很好写了。

perf.NewReader

	// Open a perf event reader from userspace on the PERF_EVENT_ARRAY map
	// described in the eBPF C program.
	rd, err := perf.NewReader(objs.Events, os.Getpagesize())

接着是内核程序和用户空间程序的交互了,这个程序的交互通过PERF_EVENT_ARRAY的map。剩下的事情就是从这个map中读取数据了:

	var event Event
	for {
		record, err := rd.Read()
		if err != nil {
			if errors.Is(err, perf.ErrClosed) {
				return
			}
			log.Printf("reading from perf event reader: %s", err)
			continue
		}

		if record.LostSamples != 0 {
			log.Printf("perf event ring buffer full, dropped %d samples", record.LostSamples)
			continue
		}

		// Parse the perf event entry into an Event structure.
		if err := binary.Read(bytes.NewBuffer(record.RawSample), binary.LittleEndian, &event); err != nil {
			log.Printf("parsing perf event: %s", err)
			continue
		}

		log.Printf("%s:%s return value: %s", binPath, symbol, unix.ByteSliceToString(event.Line[:]))
	}

小结

用户空间的代码回归了go语言,所以看起来就比较亲切,容易理解。开发流程在上面也先总结了一些,这里就不再赘述。基本上到这一步,开发和应用bpf程序,即便是一无所知的情况(不懂内核,甚至不懂C)也能掌握个入门了(我觉得应该可以)。添加链接描述

杂项

在使用bpf2go工具的时候,没有办法指定现成的字节码文件,只能是从C语言程序编译成字节码再生成go文件。比如:

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

当我有很多参数要在go generate指定的话,那就会变成很长的一行,看起来杂乱无章,因此我想把编译和转换成go文件这两步分开来。所以我fork了cilium/ebpf,能够指定字节码文件生成go文件,用法很简单,仅仅是加了一个参数-objfile,其他的不变,不过不用编译了,所以编译的参数也不用加了,示例如下:

//go:generate $GOPATH/bin/bpf2go -objfile collect_bpefel.o -target bpfel collect ../c/collect/collect.c

cilium/ebpf关于raw socket的例子不多,而且不是一起写在examples那里,我估计是还没有很完善。我自己写了一个例子,利用ebpf的能力,监听指定网卡的流量来达到旁路检测流量的目的,并使用图数据库neo4j保存节点之间的流量关系。有需要的话可以参考一下应用:https://github.com/TomatoMr/watch-dog
(gopher)一无所知学ebpf

总结

总的来说,这篇文章,杂乱无章,想到哪,写到哪,思路不容易跟上。不过这篇文章应该算是比较完整地从0到1地介绍了ebpf的开发流程。ebpf的程序种类有很多,还需要继续研究,本文只是简单地介绍了其中一种,便如此冗长,倒也正常,毕竟跟内核沾边,程序不是很长,但是知识的覆盖面却非常广。要继续学习bpf相关知识,我觉得可以关注ebpf.io以及官网里面提到的各个技术大神和大厂的文章。

上一篇:Kong 源码分析


下一篇:Go语言之开发常用知识点