290 lines
16 KiB
Markdown
290 lines
16 KiB
Markdown
|
# 05 | 编程接口:eBPF 程序是怎么跟内核进行交互的?
|
|||
|
|
|||
|
你好,我是倪朋飞。
|
|||
|
|
|||
|
上一讲,我带你一起梳理了 eBPF 在内核中的实现原理,以及 BPF 指令的具体格式。
|
|||
|
|
|||
|
用高级语言开发的 eBPF 程序,需要首先编译为 BPF 字节码,然后借助 bpf 系统调用加载到内核中,最后再通过性能监控等接口与具体的内核事件进行绑定。这样,内核的性能监控模块才会在内核事件发生时,自动执行我们开发的 eBPF 程序。
|
|||
|
|
|||
|
那么,eBPF 程序到底是如何跟内核事件进行绑定的?又该如何跟内核中的其他模块进行交互呢?今天,我就带你一起看看 eBPF 程序的编程接口。
|
|||
|
|
|||
|
## BPF 系统调用
|
|||
|
|
|||
|
如下图(图片来自[brendangregg.com](https://www.brendangregg.com/ebpf.html))所示,一个完整的 eBPF 程序通常包含用户态和内核态两部分。其中,用户态负责 eBPF 程序的加载、事件绑定以及 eBPF 程序运行结果的汇总输出;内核态运行在 eBPF 虚拟机中,负责定制和控制系统的运行状态。
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/a7/6a/a7165eea1fd9fc24090a3a1e8987986a.png?wh=1500x550)
|
|||
|
|
|||
|
对于用户态程序来说,我想你已经了解,**它们与内核进行交互时必须要通过系统调用来完成**。而对应到 eBPF 程序中,我们最常用到的就是 [bpf 系统调用](https://man7.org/linux/man-pages/man2/bpf.2.html)。
|
|||
|
|
|||
|
在命令行中输入 `man bpf` ,就可以查询到 BPF 系统调用的调用格式:
|
|||
|
|
|||
|
```plain
|
|||
|
#include <linux/bpf.h>
|
|||
|
|
|||
|
int bpf(int cmd, union bpf_attr *attr, unsigned int size);
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
BPF 系统调用接受三个参数:
|
|||
|
|
|||
|
* 第一个,cmd ,代表操作命令,比如上一讲中我们看到的 BPF\_PROG\_LOAD 就是加载 eBPF 程序;
|
|||
|
* 第二个,attr,代表 bpf\_attr 类型的 eBPF 属性指针,不同类型的操作命令需要传入不同的属性参数;
|
|||
|
* 第三个,size ,代表属性的大小。
|
|||
|
|
|||
|
注意,**不同版本的内核所支持的 BPF 命令是不同的**,具体支持的命令列表可以参考内核头文件 include/uapi/linux/bpf.h 中 `bpf_cmd` 的定义。比如,v5.13 内核已经支持 [36 个 BPF 命令](https://elixir.bootlin.com/linux/v5.13/source/include/uapi/linux/bpf.h#L828):
|
|||
|
|
|||
|
```plain
|
|||
|
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,
|
|||
|
};
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
为了方便你掌握,我把用户程序中常用的命令整理成了一个表格,你可以在需要时参考:
|
|||
|
![图片](https://static001.geekbang.org/resource/image/7c/88/7cc33d0bdd8a3ba0dda7f533f3375b88.jpg?wh=1920x1080)
|
|||
|
|
|||
|
## 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 程序直接调用了,具体你可以查看[这个链接](https://lwn.net/Articles/856005)。 不过,这些函数只能在 TCP 拥塞控制算法的 BPF 程序中调用,所以本课程不会过多展开。
|
|||
|
|
|||
|
需要注意的是,并不是所有的辅助函数都可以在 eBPF 程序中随意使用,不同类型的 eBPF 程序所支持的辅助函数是不同的。比如,对于 Hello World 示例这类内核探针(kprobe)类型的 eBPF 程序,你可以在命令行中执行 `bpftool feature probe` ,来查询当前系统支持的辅助函数列表:
|
|||
|
|
|||
|
```plain
|
|||
|
$ 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](https://elixir.bootlin.com/linux/v5.13/source/include/uapi/linux/bpf.h#L1463) ,来查看它们的详细定义和使用说明。为了方便你掌握,我把常用的辅助函数整理成了一个表格,你可以在需要时参考:
|
|||
|
![图片](https://static001.geekbang.org/resource/image/0b/cb/0b3edac18276a1236dde7135b961d8cb.jpg?wh=1920x1080)
|
|||
|
|
|||
|
这其中,需要你特别注意的是以`bpf_probe_read` 开头的一系列函数。我在上一讲中已经提到,eBPF 内部的内存空间只有寄存器和栈。所以,要访问其他的内核空间或用户空间地址,就需要借助 `bpf_probe_read` 这一系列的辅助函数。这些函数会进行安全性检查,并禁止缺页中断的发生。
|
|||
|
|
|||
|
而在 eBPF 程序需要大块存储时,就不能像常规的内核代码那样去直接分配内存了,而是必须通过 BPF 映射(BPF Map)来完成。接下来,我带你看看 BPF 映射的具体原理。
|
|||
|
|
|||
|
## BPF 映射
|
|||
|
|
|||
|
BPF 映射用于提供大块的键值存储,这些存储可被用户空间程序访问,进而获取 eBPF 程序的运行状态。eBPF 程序最多可以访问 64 个不同的 BPF 映射,并且不同的 eBPF 程序也可以通过相同的 BPF 映射来共享它们的状态。下图(图片来自[docs.cilium.io](https://docs.cilium.io/en/stable/bpf))展示了 BPF 映射的基本使用方法。
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/d8/11/d87b409fa85d3a07973a8689b228cf11.png?wh=576x383 "BPF 映射")
|
|||
|
|
|||
|
在前面的 BPF 系统调用和辅助函数小节中,你也看到,有很多系统调用命令和辅助函数都是用来访问 BPF 映射的。我相信细心的你已经发现了:BPF 辅助函数中并没有 BPF 映射的创建函数,BPF 映射只能通过用户态程序的系统调用来创建。比如,你可以通过下面的示例代码来创建一个 BPF 映射,并返回映射的文件描述符:
|
|||
|
|
|||
|
```c++
|
|||
|
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](https://elixir.bootlin.com/linux/v5.13/source/include/uapi/linux/bpf.h#L867) 中的 `bpf_map_type` 定义了所有支持的映射类型,你可以使用如下的 bpftool 命令,来查询当前系统支持哪些映射类型:
|
|||
|
|
|||
|
```bash
|
|||
|
$ 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
|
|||
|
...
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
在下面的表格中,我给你整理了几种最常用的映射类型及其功能和使用场景:
|
|||
|
![图片](https://static001.geekbang.org/resource/image/f3/d7/f3210199e6689e7659057a935e7fc5d7.jpg?wh=1920x1080)
|
|||
|
如果你的 eBPF 程序使用了 BCC 库,你还可以使用预定义的宏来简化 BPF 映射的创建过程。比如,对哈希表映射来说,BCC 定义了 `BPF_HASH(name, key_type=u64, leaf_type=u64, size=10240)`,因此,你就可以通过下面的几种方法来创建一个哈希表映射:
|
|||
|
|
|||
|
```c++
|
|||
|
// 使用默认参数 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 来查看或操作映射的具体内容。比如,你可以通过下面这些命令创建、更新、输出以及删除映射:
|
|||
|
|
|||
|
```c++
|
|||
|
//创建一个哈希表映射,并挂载到/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` 时,才能从内存地址中提取到正确的数据类型。
|
|||
|
|
|||
|
但是,编译时依赖内核头文件也会带来很多问题。主要有这三个方面:
|
|||
|
|
|||
|
* 首先,在开发 eBPF 程序时,为了获得内核数据结构的定义,就需要引入一大堆的内核头文件;
|
|||
|
* 其次,内核头文件的路径和数据结构定义在不同内核版本中很可能不同。因此,你在升级内核版本时,就会遇到找不到头文件和数据结构定义错误的问题;
|
|||
|
* 最后,在很多生产环境的机器中,出于安全考虑,并不允许安装内核头文件,这时就无法得到内核数据结构的定义。**在程序中重定义数据结构**虽然可以暂时解决这个问题,但也很容易把使用着错误数据结构的 eBPF 程序带入新版本内核中运行。
|
|||
|
|
|||
|
那么,这么多的问题该怎么解决呢?不用担心,BPF 类型格式(BPF Type Format, BTF)的诞生正是为了解决这些问题。从内核 5.2 开始,只要开启了 `CONFIG_DEBUG_INFO_BTF`,在编译内核时,内核数据结构的定义就会自动内嵌在内核二进制文件 vmlinux 中。并且,你还可以借助下面的命令,把这些数据结构的定义导出到一个头文件中(通常命名为 `vmlinux.h`):
|
|||
|
|
|||
|
```plain
|
|||
|
bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
如下图(图片来自[GRANT SELTZER博客](https://www.grant.pizza/blog/vmlinux-header))所示,有了内核数据结构的定义,你在开发 eBPF 程序时只需要引入一个 `vmlinux.h` 即可,不用再引入一大堆的内核头文件了。
|
|||
|
|
|||
|
![](https://static001.geekbang.org/resource/image/45/20/45bbf696e8620d322d857ceab3871720.jpg?wh=1920x1204 "vmlinux.h的使用示意图")
|
|||
|
|
|||
|
同时,借助 BTF、bpftool 等工具,我们也可以更好地了解 BPF 程序的内部信息,这也会让调试变得更加方便。比如,在查看 BPF 映射的内容时,你可以直接看到结构化的数据,而不只是十六进制数值:
|
|||
|
|
|||
|
```c++
|
|||
|
# bpftool map dump id 386
|
|||
|
[
|
|||
|
{
|
|||
|
"key": 0,
|
|||
|
"value": {
|
|||
|
"eth0": {
|
|||
|
"value": 0,
|
|||
|
"ifindex": 0,
|
|||
|
"mac": []
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
]
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
解决了内核数据结构的定义问题,接下来的问题就是,**如何让 eBPF 程序在内核升级之后,不需要重新编译就可以直接运行**。eBPF 的一次编译到处执行(Compile Once Run Everywhere,简称 CO-RE)项目借助了 BTF 提供的调试信息,再通过下面的两个步骤,使得 eBPF 程序可以适配不同版本的内核:
|
|||
|
|
|||
|
* 第一,通过对 BPF 代码中的访问偏移量进行重写,解决了不同内核版本中数据结构偏移量不同的问题;
|
|||
|
* 第二,在 libbpf 中预定义不同内核版本中的数据结构的修改,解决了不同内核中数据结构不兼容的问题。
|
|||
|
|
|||
|
BTF和一次编译到处执行带来了很多的好处,但你也需要注意这一点:它们都要求比较新的内核版本(>=5.2),并且需要非常新的发行版(如 Ubuntu 20.10+、RHEL 8.2+ 等)才会默认打开内核配置 `CONFIG_DEBUG_INFO_BTF`。对于旧版本的内核,虽然它们不会再去内置 BTF 的支持,但开源社区正在尝试通过 [BTFHub](https://github.com/aquasecurity/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 查询它的使用文档。
|
|||
|
|
|||
|
期待你在留言区和我讨论,也欢迎把这节课分享给你的同事、朋友。让我们一起在实战中演练,在交流中进步。
|
|||
|
|