一、eBPF的虚拟机在内核是如何工作的?
从之前的分析已经得知,.c的eBPF程序会通过BCC等工具编译并加载到内核中,但是具体在内核中,ebpf是如何工作的呢?
eBPF在内核中运行主要是由五个模块组成
- BPF Verifier:它提供了一系列用于 eBPF 程序与内核其他模块进行交互的函数;
- BPF JIT:将由LLVM从内核态程序(例如上篇case中的hello.c)转成BPF bytecode 再次译成本地机器指令,以便更高效地在内核中执行;
- BPF Helpers:提供了用于 eBPF 程序与内核其他模块进行交互的函数,hello.c 使用的bpf_get_current_pid_tgid、bpf_ktime_get_ns等函数;
- BPF 存储模块: 11 个 64 位寄存器、一个程序计数器和一个 512 字节的栈组成的存储模块;
- BPF Map:Large custom data storage,用来与用户态程序进行交互(例如上篇case中的hello.py)。
二、再通过一个case详细解析下在内核中的指令执行!
我是case
eBPF程序,hello.c
代码语言:javascript复制int hello_world(void *ctx)
{
bpf_trace_printk("Hello, World!");
return 0;
}
- bpf_trace_printk() 输出一段字符串,因为eBPF在内核中运行,所以不能stdout,而是在 /sys/kernel/debug/tracing/trace_pipe,需要用户态程序调用trace_print()输出,或者可以cat
用户态程序
代码语言:javascript复制1 #!/usr/bin/env python3
2 from bcc import BPF
3
4 b = BPF(src_file="hello.c")
5 b.attach_kprobe(event="do_sys_openat2", fn_nam e="hello_world")
6 b.trace_print()
~
- 第二行:导入bcc模块
- 第四行:编译并加载eBPF程序
- 第五行:将 eBPF程序挂载到kprobe,并绑定事件openat()
- 第六行:读取内核调试文件 /sys/kernel/debug/tracing/trace_pipe 的内容,并打印到标准输出中。
运行命令python3 helle.py
bpftool:查看eBPF的运行状态
当上述case运行后,执行bpftool prog lis
t命令
root@ubuntu-impish:/home/ebpf-test/case1# sudo bpftool prog list
...
580: kprobe name hello_world tag 38dd440716c4900f gpl
loaded_at 2022-02-16T14:30:56 0000 uid 0
xlated 104B jited 70B memlock 4096B
btf_id 66
- 580:eBPF程序的编号(实际上在虚拟机上会跑出来很多cgroup的eBPF程序哦= =!)
- kprob:程序类型,内核态插桩
- name hello_word:程序名
通过eBPF程序编号,可以查看这个程序所有的指令,执行bpftool prog dump xlated id 580
root@ubuntu-impish:/home/ebp f-test/case1# bpftool prog dump xlated id 580
int hello_world(void * ctx):
; int hello_world(void *ctx)
0: (b7) r1 = 33
; ({ char _fmt[] = "Hello, World!"; bpf_trace_printk_(_fmt, sizeof(_fmt)); });
1: (6b) *(u16 *)(r10 -4) = r1
2: (b7) r1 = 1684828783
3: (63) *(u32 *)(r10 -8) = r1
4: (18) r1 = 0x57202c6f6c6c6548
6: (7b) *(u64 *)(r10 -16) = r1
7: (bf) r1 = r10
;
8: (07) r1 = -16
; ({ char _fmt[] = "Hello, World!"; bpf_trace_printk_(_fmt, sizeof(_fmt)); });
9: (b7) r2 = 14
10: (85) call bpf_trace_printk#-61856
; return 0;
11: (b7) r0 = 0
12: (95) exit
- 分号后:c的代码;
- 冒号前面的数字0-12:代表 BPF 指令行数;
- 括号中的 16 进制数值:表示 BPF 指令码,为64位寄存器赋值,指令码含义可以参考IOVisor BPF ;
- 括号后面:BPF 指令的伪代码;
由此可看,LLVM编码后的BPF指令中标记了存储模块中各个寄存器的调用,上面的程序指令含义如下:
- 第 0-8 行,借助 R10 寄存器从栈中把字符串 “Hello, World!” 读出来,并放入 R1 寄存器中;
- 第 9 行,向 R2 寄存器写入字符串的长度 14(即代码注释里面的 sizeof(_fmt) );
- 第 10 行,调用 BPF 辅助函数 bpf_trace_printk 输出字符串;
- 第 11 行,向 R0 寄存器写入 0,表示程序的返回值是 0;
- 最后一行,程序执行成功退出。
当上面的BPF指令加载到内核后,JIT会将BPF bytecode再次转成机器指令,执行bpftool prog dump jited id 580
查看BPF程序的机器指令
strace:用进程照妖镜,分析下BPF程序都做了啥
我们写的BPF用户态程序hello.py使用了BCC完成eBPF内核态程序hello.c的编译与加载,跟踪BCC的系统调用过程,
可以执行strace -v -f -ebpf ./hello.py
过一会儿可以看到BCC调用bpf加载
bpf(BPF_PROG_LOAD,
{prog_type=BPF_PROG_TYPE_KPROBE,
insn_cnt=13,
insns=[
{code=BPF_ALU64|BPF_K|BPF_MOV, dst_reg=BPF_REG_1, src_reg=BPF_REG_0, off=0, imm=0x21},
{code=BPF_STX|BPF_H|BPF_MEM, dst_reg=BPF_REG_10, src_reg=BPF_REG_1, off=-4, imm=0},
{code=BPF_ALU64|BPF_K|BPF_MOV, dst_reg=BPF_REG_1, src_reg=BPF_REG_0, off=0, imm=0x646c726f},
{code=BPF_STX|BPF_W|BPF_MEM, dst_reg=BPF_REG_10, src_reg=BPF_REG_1, off=-8, imm=0},
{code=BPF_LD|BPF_DW|BPF_IMM, dst_reg=BPF_REG_1, src_reg=BPF_REG_0, off=0, imm=0x6c6c6548},
{code=BPF_LD|BPF_W|BPF_IMM, dst_reg=BPF_REG_0, src_reg=BPF_REG_0, off=0, imm=0x57202c6f},
{code=BPF_STX|BPF_DW|BPF_MEM, dst_reg=BPF_REG_10, src_reg=BPF_REG_1, off=-16, imm=0},
{code=BPF_ALU64|BPF_X|BPF_MOV, dst_reg=BPF_REG_1, src_reg=BPF_REG_10, off=0, imm=0},
{code=BPF_ALU64|BPF_K |BPF_ADD, dst_reg=BPF_REG_1, src_reg=BPF_REG_0, off=0, imm=0xfffffff0},
{code=BPF_ALU64|BPF_K|BPF_MOV, dst_reg=BPF_REG_2, src_reg=BPF_REG_0, off=0, imm=0xe},
{code=BPF_JMP|BPF_K|BPF_CALL, dst_reg=BPF_REG_0, src_reg=BPF_REG_0, off=0, imm=0x6},
{code=BPF_ALU64|BPF_K|BPF_MOV, dst_reg=BPF_REG_0, src_reg=BPF_REG_0, off=0, imm=0},
{code=BPF_JMP|BPF_K|BPF_EXIT, dst_reg=BPF_REG_0, src_reg=BPF_REG_0, off=0, imm=0}],
license="GPL", log_level=0, log_size=0, log_buf=NULL,
kern_version=KERNEL_VERSION(5, 13, 19), prog_flags=0,
prog_name="hello_world", prog_ifindex=0,
expected_attach_type=BPF_CGROUP_INET_INGRESS, prog_btf_fd=3, func_info_rec_size=8,
func_info=0x1f5b990, func_info_cnt=1, line_info_rec_size=16, line_info=0xfc42b0,
line_info_cnt=5, attach_btf_id=0, attach_prog_fd=0},
128) = 4
执行man bpf
查看bpf系统调用格式
int bpf(int cmd, union bpf_attr *attr, unsigned int size);
对应前面的 strace 输出结果,参数意义则是
- 第一个参数是 BPF_PROG_LOAD , 表示加载 BPF 程序;
- 第二个参数是 bpf_attr 类型的结构体,表示 BPF 程序的属性。其中
- prog_type 表示 BPF 程序的类型,这里 BPF_PROG_TYPE_KPROBE 的跟用户态代码中的 attach_kprobe 一致;
- insn_cnt (instructions count) 表示指令条数,与前面 bpftool prog dump 的结果是一致;
- insns (instructions) 包含了具体的每一条指令;
- prog_name 则表示 BPF 程序的名字,即 hello_world 。
- 第三个参数 120 表示属性的大小。
其实eBPF 程序需要事件触发后才会执行,其中我们用户态程序hello.py中,
代码语言:javascript复制b.attach_kprobe(event="do_sys_openat2", fn_name="hello_world")
调用了 attach_kprobe 函数,绑定了一个内核跟踪事件
执行strace -v -f ./hello.py
再看下系统调用
...
/* 1) 加载BPF程序 */
bpf(BPF_PROG_LOAD,...) = 4
...
/* 2)查询事件类型 */
openat(AT_FDCWD, "/sys/bus/event_source/devices/kprobe/type", O_RDONLY) = 5
read(5, "6n", 4096) = 2
close(5) = 0
...
/* 3)创建性能监控事件 */
perf_event_open(
{
type=0x6 /* PERF_TYPE_??? */,
size=PERF_ATTR_SIZE_VER7,
...
wakeup_events=1,
config1=0x7f275d195c50,
...
},
-1,
0,
-1,
PERF_FLAG_FD_CLOEXEC) = 5
/* 4)绑定BPF到kprobe事件 */
ioctl(5, PERF_EVENT_IOC_SET_BPF, 4) = 0
...
- 借助 bpf 系统调用,加载 BPF 程序,并记住返回的文件描述符;
- 查询 kprobe 类型的事件编号。BCC 实际上是通过 /sys/bus/event_source/devices/kprobe/type 来查询的;
- 调用 perf_event_open 创建性能监控事件。比如,事件类型(type 是上一步查询到的 6)、事件的参数( config1 包含了内核函数 do_sys_openat2 )等;
- 再通过 ioctl 的 PERF_EVENT_IOC_SET_BPF 命令,将 BPF 程序绑定到性能监控事件。
eBPF程序:
C
- struct data_t:定义想在用户态中获取的数据,没有固定结构;
- BPF_PERF_OUTPUT:定义test_events,其中用户态.py程序中,也会get相同变量名的event;
- hello_world:定义kprobe探针的处理函数,除了ctx是固定参数以外,dfd, filename和open_how是openat2的参数,如果入参就必须写全,会自动进行绑定;
- perf_submit:提交性能事件,参数应该都是固定参数.
Py
- print_event:定义处理c中struct data_t的处理函数.
- open_perf_buffer:定义了名为 “test_evens” 的 Perf 事件映射.
- perf_buff_poll:读取映射的内容,并执行回调函数输出进程信息.
交互
综上,梳理出eBPF在内核中实现
- 高级语言开发的eBPF程序,用户态、内核态两部分
- 编译成BPF字节码
- 借助linux bpf系统调用加载到内核
- 通过性能监控等接口与具体的内核事件进行绑定
上篇中也介绍到了,一个完成整eBPF程序中,通常包含用户态程序与内核态程序两部分,
用户态:负责eBPF的编译、加载、事件绑定、结果输出,它与内核进行交互的时候必须通过系统调用来完成
内核态:负责定制和控制系统的运行状态
BPF系统调用
执行man bpf
查看bpf系统调用格式
int bpf(int cmd, union bpf_attr *attr, unsigned int size);
参数:
- cmd:操作命令,eg BPF_PROG_LOAD 就是加载eBPF程序
- attr:bpf_attr类型的eBPF属性指针,不通类型的操作命令需要传入不同的属性参数
- size:属性大小
注:不同版本的内核支持的BPF操作命令不一样,常用的BPF操作命令
- BPF_MAP_CREATE: 创建一个BPF映射
- BPF_MAP_LOOKUP_ELEM:查找BPF映射
- BPF_MAP_UPDATE_ELEM:更新BPF映射
- BPF_MAP_DELETE_ELEM:删除BPF映射
- BPF_MAP_LOOKUP_AND_DELETE_ELEM:查找并删除BPF映射
- BPF_MAP_GET_NEXT_ELEM:遍历BPF映射
- BPF_PROG_LOAD:验证并加载BPF程序
- BPF_PROG_ATTACH:把BPF程序挂载到内核事件上
- BPF_PROG_DETACH:把BPF程序从内核事件上卸载
- BPF_OBJ_PIN:把BPF程序或映射挂载到sysfs中的/sys/fs/bpf目录
- BPF_OBJ_GET:从/sys/fs/bpf目录中查找BPF程序
- BPF_BTF_LOAD:验证并加载BTF信息
BPF辅助函数
eBPF程序并不能随便的调用内核函数,必须通过辅助函数才可完成eBPF程序和其他内核模块的交互,eg bpf_trace_printk()
注:不同类型的eBPF程序支持的辅助函数是不同的
执行bpftool feature probe
查看bpf系统支持的辅助函数列表
执行man bpf-helpers
查看辅助函数的详细定义
注:由于eBPF虚拟机的只有寄存器和栈,所以要访问其他内核空间或者用户控件地址,就需要借助bpf_probe_read系列辅助函数,eg
- bpf_probe_read:从内存指针中读取数据
- bpf_probe_read_user:从用户空间内存指针中读取数据
- bpf_probe_read_kernel:从内核空间内存指针中读取数据
BPF映射
- BPF映射给eBPF虚拟机提供了大空间的kv存储,可呗用户空间访问,从而获取eBPF程序的运行状态
- eBPF 程序最多可以访问 64 个不同的 BPF 映射,并且不同的 eBPF 程序也可以通过相同的 BPF 映射来共享它们的状态
BPF映射基本使用方法
注:BPF Map 只能通过用户态程序的系统调用来创建,并不能通过辅助函数创建,而且在用户态程序关闭文件描述符的时候就会自动删除
eg:
代码语言:javascript复制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));
}
执行命令bpftool feature probe | grep map_type
可以查看系统所有可用的映射类型
root@ubuntu-impish:/home/ebpf/hello_case# 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
.....
调试BPF映射
代码语言:javascript复制//创建一个哈希表映射,并挂载到/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
BTF
我们在部署eBPF环境的时候,安装了很多头文件,eg linux-headers-$(uname -r) ,这些头文件的作用就是BCC在编译eBPF程序的时候,需要在内核头文件中找到对应的数据结构定义,但是在生产机器中,很多都是不允许安装内核头文件,这个问题要怎么解决呢?
当kernel版本>5.2,只要开启了CONFIG_DEBUG_INFO_BTF,在编译内核时,内核数据结构的定义就会自动内嵌在内核二进制文件 vmlinux 中,so,你可以执行命令bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
将这些数据的定义导出到头文件中vmlinux.h
所以,当有了vmlinux.h后,我们在开发eBPF程序就不用自己定义数据结构(防止将错误的数据结构带入内核中)和引入一堆头文件了
借助BTF、pbftool等工具,我们可以直接看到BPF映射的结构化数据,eg:
代码语言:javascript复制# bpftool map dump id xxxx
[
{
"key": 0,
"value": {
"eth0": {
"value": 0,
"ifindex": 0,
"mac": []
}
}
}
]
eBPF程序分类
eBPF 程序类型决定了一个 eBPF 程序可以挂载的事件类型和事件参数,内核中不同事件会触发不同类型的 eBPF 程序
一般内核的版本或者编译配置不同,所支持的程序类型也不同,执行bpftool feature probe | grep program_type
可以查看当前kernel支持的bpf程序类型
跟踪类eBPF程序
主要用于从系统中提取跟踪信息,进而为监控、排错、性能优化等提供数据支撑,最常用的:
perf_event:用于性能事件跟踪,eg 内核调用,定时器,硬件等
kprobe:用于对特定函数进行动态插桩
tracingpoint:用于内核静态跟踪点
网络类eBPF程序
网络类 eBPF 程序主要用于对网络数据包进行过滤和处理,进而实现网络的观测、过滤、流量控制以及性能优化等各种丰富的功XDP
XDP 程序的类型定义为 BPF_PROG_TYPE_XDP,它在网络驱动程序刚刚收到数据包时触发执行。由于无需通过繁杂的内核网络协议栈,XDP 程序可用来实现高性能的网络处理方案,常用于 DDoS 防御、防火墙、4 层负载均衡等场景
,XDP 程序并不是绕过了内核协议栈,它只是在内核协议栈之前处理数据包,而处理过的数据包还可以正常通过内核协议栈继续处理。你可以通过下面的图片加深对 XDP 相对内核协议栈位置的理解
根据网卡和网卡驱动是否原生支持 XDP 程序,XDP 运行模式可以分为下面这三种:
通用模式。它不需要网卡和网卡驱动的支持,XDP 程序像常规的网络协议栈一样运行在内核中,性能相对较差,一般用于测试
原生模式。它需要网卡驱动程序的支持,XDP 程序在网卡驱动程序的早期路径运行;
卸载模式。它需要网卡固件支持 XDP 卸载,XDP 程序直接运行在网卡上,而不再需要消耗主机的 CPU 资源,具有最好的性能。
XDP 程序在处理过网络包之后,都需要根据 eBPF 程序执行结果,决定数据包的去处。这些执行结果对应以下 5 种 XDP 程序结果码:
TC