你好,我是倪朋飞。

上一讲,我带你一起梳理了 eBPF 在内核中的实现原理,以及 BPF 指令的具体格式。

用高级语言开发的 eBPF 程序,需要首先编译为 BPF 字节码,然后借助 bpf 系统调用加载到内核中,最后再通过性能监控等接口与具体的内核事件进行绑定。这样,内核的性能监控模块才会在内核事件发生时,自动执行我们开发的 eBPF 程序。

那么,eBPF 程序到底是如何跟内核事件进行绑定的?又该如何跟内核中的其他模块进行交互呢?今天,我就带你一起看看 eBPF 程序的编程接口。

BPF 系统调用

如下图(图片来自brendangregg.com)所示,一个完整的 eBPF 程序通常包含用户态和内核态两部分。其中,用户态负责 eBPF 程序的加载、事件绑定以及 eBPF 程序运行结果的汇总输出;内核态运行在 eBPF 虚拟机中,负责定制和控制系统的运行状态。

图片

对于用户态程序来说,我想你已经了解,它们与内核进行交互时必须要通过系统调用来完成。而对应到 eBPF 程序中,我们最常用到的就是 bpf 系统调用

在命令行中输入 man bpf ,就可以查询到 BPF 系统调用的调用格式:

#include <linux/bpf.h>

int bpf(int cmd, union bpf_attr *attr, unsigned int size);

BPF 系统调用接受三个参数:

注意,不同版本的内核所支持的 BPF 命令是不同的,具体支持的命令列表可以参考内核头文件 include/uapi/linux/bpf.h 中 bpf_cmd 的定义。比如,v5.13 内核已经支持 36 个 BPF 命令

enum bpf_cmd {
  BPF_MAP_CREATE,
  BPF_MAP_LOOKUP_ELEM,
  BPF_MAP_UPDATE_ELEM,
  BPF_MAP_DELETE_ELEM,
  BPF_MAP_GET_NEXT_KEY,
  BPF_PROG_LOAD,
  BPF_OBJ_PIN,
  BPF_OBJ_GET,
  BPF_PROG_ATTACH,
  BPF_PROG_DETACH,
  BPF_PROG_TEST_RUN,
  BPF_PROG_GET_NEXT_ID,
  BPF_MAP_GET_NEXT_ID,
  BPF_PROG_GET_FD_BY_ID,
  BPF_MAP_GET_FD_BY_ID,
  BPF_OBJ_GET_INFO_BY_FD,
  BPF_PROG_QUERY,
  BPF_RAW_TRACEPOINT_OPEN,
  BPF_BTF_LOAD,
  BPF_BTF_GET_FD_BY_ID,
  BPF_TASK_FD_QUERY,
  BPF_MAP_LOOKUP_AND_DELETE_ELEM,
  BPF_MAP_FREEZE,
  BPF_BTF_GET_NEXT_ID,
  BPF_MAP_LOOKUP_BATCH,
  BPF_MAP_LOOKUP_AND_DELETE_BATCH,
  BPF_MAP_UPDATE_BATCH,
  BPF_MAP_DELETE_BATCH,
  BPF_LINK_CREATE,
  BPF_LINK_UPDATE,
  BPF_LINK_GET_FD_BY_ID,
  BPF_LINK_GET_NEXT_ID,
  BPF_ENABLE_STATS,
  BPF_ITER_CREATE,
  BPF_LINK_DETACH,
  BPF_PROG_BIND_MAP,
};

为了方便你掌握,我把用户程序中常用的命令整理成了一个表格,你可以在需要时参考:
图片

BPF 辅助函数

说完用户态程序的 bpf 系统调用格式,我们再来看看内核态的 eBPF 程序。

eBPF 程序并不能随意调用内核函数,因此,内核定义了一系列的辅助函数,用于 eBPF 程序与内核其他模块进行交互。比如,上一讲的 Hello World 示例中使用的 bpf_trace_printk() 就是最常用的一个辅助函数,用于向调试文件系统(/sys/kernel/debug/tracing/trace_pipe)写入调试信息。

这里补充一个知识点:从内核 5.13 版本开始,部分内核函数(如 tcp_slow_start()tcp_reno_ssthresh() 等)也可以被 BPF 程序直接调用了,具体你可以查看这个链接。 不过,这些函数只能在 TCP 拥塞控制算法的 BPF 程序中调用,所以本课程不会过多展开。

需要注意的是,并不是所有的辅助函数都可以在 eBPF 程序中随意使用,不同类型的 eBPF 程序所支持的辅助函数是不同的。比如,对于 Hello World 示例这类内核探针(kprobe)类型的 eBPF 程序,你可以在命令行中执行 bpftool feature probe ,来查询当前系统支持的辅助函数列表:

$ bpftool feature probe
...
eBPF helpers supported for program type kprobe:
	- bpf_map_lookup_elem
	- bpf_map_update_elem
	- bpf_map_delete_elem
	- bpf_probe_read
	- bpf_ktime_get_ns
	- bpf_get_prandom_u32
	- bpf_get_smp_processor_id
	- bpf_tail_call
	- bpf_get_current_pid_tgid
	- bpf_get_current_uid_gid
	- bpf_get_current_comm
	- bpf_perf_event_read
	- bpf_perf_event_output
	- bpf_get_stackid
	- bpf_get_current_task
	- bpf_current_task_under_cgroup
	- bpf_get_numa_node_id
	- bpf_probe_read_str
	- bpf_perf_event_read_value
	- bpf_override_return
	- bpf_get_stack
	- bpf_get_current_cgroup_id
	- bpf_map_push_elem
	- bpf_map_pop_elem
	- bpf_map_peek_elem
	- bpf_send_signal
	- bpf_probe_read_user
	- bpf_probe_read_kernel
	- bpf_probe_read_user_str
	- bpf_probe_read_kernel_str
...

对于这些辅助函数的详细定义,你可以在命令行中执行 man bpf-helpers ,或者参考内核头文件 include/uapi/linux/bpf.h ,来查看它们的详细定义和使用说明。为了方便你掌握,我把常用的辅助函数整理成了一个表格,你可以在需要时参考:
图片

这其中,需要你特别注意的是以bpf_probe_read 开头的一系列函数。我在上一讲中已经提到,eBPF 内部的内存空间只有寄存器和栈。所以,要访问其他的内核空间或用户空间地址,就需要借助 bpf_probe_read 这一系列的辅助函数。这些函数会进行安全性检查,并禁止缺页中断的发生。

而在 eBPF 程序需要大块存储时,就不能像常规的内核代码那样去直接分配内存了,而是必须通过 BPF 映射(BPF Map)来完成。接下来,我带你看看 BPF 映射的具体原理。

BPF 映射

BPF 映射用于提供大块的键值存储,这些存储可被用户空间程序访问,进而获取 eBPF 程序的运行状态。eBPF 程序最多可以访问 64 个不同的 BPF 映射,并且不同的 eBPF 程序也可以通过相同的 BPF 映射来共享它们的状态。下图(图片来自docs.cilium.io)展示了 BPF 映射的基本使用方法。

图片

在前面的 BPF 系统调用和辅助函数小节中,你也看到,有很多系统调用命令和辅助函数都是用来访问 BPF 映射的。我相信细心的你已经发现了:BPF 辅助函数中并没有 BPF 映射的创建函数,BPF 映射只能通过用户态程序的系统调用来创建。比如,你可以通过下面的示例代码来创建一个 BPF 映射,并返回映射的文件描述符:

int bpf_create_map(enum bpf_map_type map_type,
		   unsigned int key_size,
		   unsigned int value_size, unsigned int max_entries)
{
  union bpf_attr attr = {
    .map_type = map_type,
    .key_size = key_size,
    .value_size = value_size,
    .max_entries = max_entries
  };
  return bpf(BPF_MAP_CREATE, &attr, sizeof(attr));
}

这其中,最关键的是设置映射的类型。内核头文件 include/uapi/linux/bpf.h 中的 bpf_map_type 定义了所有支持的映射类型,你可以使用如下的 bpftool 命令,来查询当前系统支持哪些映射类型:

$ bpftool feature probe | grep map_type
eBPF map_type hash is available
eBPF map_type array is available
eBPF map_type prog_array is available
eBPF map_type perf_event_array is available
eBPF map_type percpu_hash is available
eBPF map_type percpu_array is available
eBPF map_type stack_trace is available
...

在下面的表格中,我给你整理了几种最常用的映射类型及其功能和使用场景:
图片
如果你的 eBPF 程序使用了 BCC 库,你还可以使用预定义的宏来简化 BPF 映射的创建过程。比如,对哈希表映射来说,BCC 定义了 BPF_HASH(name, key_type=u64, leaf_type=u64, size=10240),因此,你就可以通过下面的几种方法来创建一个哈希表映射:

// 使用默认参数 key_type=u64, leaf_type=u64, size=10240
BPF_HASH(stats);

// 使用自定义key类型,保持默认 leaf_type=u64, size=10240
struct key_t {
  char c[80];
};
BPF_HASH(counts, struct key_t);

// 自定义所有参数
BPF_HASH(cpu_time, uint64_t, uint64_t, 4096);

除了创建之外,映射的删除也需要你特别注意。BPF 系统调用中并没有删除映射的命令,这是因为 BPF 映射会在用户态程序关闭文件描述符的时候自动删除(即close(fd) )。 如果你想在程序退出后还保留映射,就需要调用 BPF_OBJ_PIN 命令,将映射挂载到 /sys/fs/bpf 中。

在调试 BPF 映射相关的问题时,你还可以通过 bpftool 来查看或操作映射的具体内容。比如,你可以通过下面这些命令创建、更新、输出以及删除映射:

//创建一个哈希表映射,并挂载到/sys/fs/bpf/stats_map(Key和Value的大小都是2字节)
bpftool map create /sys/fs/bpf/stats_map type hash key 2 value 2 entries 8 name stats_map

//查询系统中的所有映射
bpftool map
//示例输出
//340: hash  name stats_map  flags 0x0
//        key 2B  value 2B  max_entries 8  memlock 4096B

//向哈希表映射中插入数据
bpftool map update name stats_map key 0xc1 0xc2 value 0xa1 0xa2

//查询哈希表映射中的所有数据
 
bpftool map dump name stats_map
//示例输出
//key: c1 c2  value: a1 a2
//Found 1 element

//删除哈希表映射
rm /sys/fs/bpf/stats_map

BPF 类型格式 (BTF)

了解过 BPF 辅助函数和映射之后,我们再来看一个开发 eBPF 程序时最常碰到的问题:内核数据结构的定义。

在安装 BCC 工具的时候,你可能就注意到了,内核头文件 linux-headers-$(uname -r) 也是必须要安装的一个依赖项。这是因为 BCC 在编译 eBPF 程序时,需要从内核头文件中找到相应的内核数据结构定义。这样,你在调用 bpf_probe_read 时,才能从内存地址中提取到正确的数据类型。

但是,编译时依赖内核头文件也会带来很多问题。主要有这三个方面:

那么,这么多的问题该怎么解决呢?不用担心,BPF 类型格式(BPF Type Format, BTF)的诞生正是为了解决这些问题。从内核 5.2 开始,只要开启了 CONFIG_DEBUG_INFO_BTF,在编译内核时,内核数据结构的定义就会自动内嵌在内核二进制文件 vmlinux 中。并且,你还可以借助下面的命令,把这些数据结构的定义导出到一个头文件中(通常命名为 vmlinux.h):

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

如下图(图片来自GRANT SELTZER博客)所示,有了内核数据结构的定义,你在开发 eBPF 程序时只需要引入一个 vmlinux.h 即可,不用再引入一大堆的内核头文件了。

同时,借助 BTF、bpftool 等工具,我们也可以更好地了解 BPF 程序的内部信息,这也会让调试变得更加方便。比如,在查看 BPF 映射的内容时,你可以直接看到结构化的数据,而不只是十六进制数值:

# bpftool map dump id 386
[
  {
      "key": 0,
      "value": {
          "eth0": {
              "value": 0,
              "ifindex": 0,
              "mac": []
          }
      }
  }
]

解决了内核数据结构的定义问题,接下来的问题就是,如何让 eBPF 程序在内核升级之后,不需要重新编译就可以直接运行。eBPF 的一次编译到处执行(Compile Once Run Everywhere,简称 CO-RE)项目借助了 BTF 提供的调试信息,再通过下面的两个步骤,使得 eBPF 程序可以适配不同版本的内核:

BTF和一次编译到处执行带来了很多的好处,但你也需要注意这一点:它们都要求比较新的内核版本(>=5.2),并且需要非常新的发行版(如 Ubuntu 20.10+、RHEL 8.2+ 等)才会默认打开内核配置 CONFIG_DEBUG_INFO_BTF。对于旧版本的内核,虽然它们不会再去内置 BTF 的支持,但开源社区正在尝试通过 BTFHub 等方法,为它们提供 BTF 调试信息。

小结

今天,我带你一起梳理了 eBPF 程序跟内核交互的基本方法。

一个完整的 eBPF 程序,通常包含用户态和内核态两部分:用户态程序需要通过 BPF 系统调用跟内核进行交互,进而完成 eBPF 程序加载、事件挂载以及映射创建和更新等任务;而在内核态中,eBPF 程序也不能任意调用内核函数,而是需要通过 BPF 辅助函数完成所需的任务。尤其是在访问内存地址的时候,必须要借助 bpf_probe_read 系列函数读取内存数据,以确保内存的安全和高效访问。

在 eBPF 程序需要大块存储时,我们还需要根据应用场景,引入特定类型的 BPF 映射,并借助它向用户空间的程序提供运行状态的数据。

这一讲的最后,我还带你一起了解了 BTF 和 CO-RE 项目,它们在提供轻量级调试信息的同时,还解决了跨内核版本的兼容性问题。很多开源社区的 eBPF 项目(如 BCC 等)也都在向 BTF 进行迁移。

思考题

最后,我想邀请你来聊一聊:

  1. 你是如何理解 BPF 系统调用和 BPF 辅助函数的?
  2. 除了今天讲到的内容,bpftool 还提供了哪些有趣的功能呢?给你一个小提示:可以使用 man bpftool 查询它的使用文档。

期待你在留言区和我讨论,也欢迎把这节课分享给你的同事、朋友。让我们一起在实战中演练,在交流中进步。

评论