【eBPF笔记中篇】运行原理、交互、event触发 解析(未完)

2023-05-24 17:42:42 浏览数 (2)

一、eBPF的虚拟机在内核是如何工作的?

从之前的分析已经得知,.c的eBPF程序会通过BCC等工具编译并加载到内核中,但是具体在内核中,ebpf是如何工作的呢?

eBPF运行描述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 list命令

代码语言:javascript复制
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

代码语言:javascript复制
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加载

代码语言:javascript复制
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系统调用格式

代码语言:javascript复制
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再看下系统调用

代码语言:javascript复制
...
/* 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
...
  1. 借助 bpf 系统调用,加载 BPF 程序,并记住返回的文件描述符;
  2. 查询 kprobe 类型的事件编号。BCC 实际上是通过 /sys/bus/event_source/devices/kprobe/type 来查询的;
  3. 调用 perf_event_open 创建性能监控事件。比如,事件类型(type 是上一步查询到的 6)、事件的参数( config1 包含了内核函数 do_sys_openat2 )等;
  4. 再通过 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在内核中实现

  1. 高级语言开发的eBPF程序,用户态、内核态两部分
  2. 编译成BPF字节码
  3. 借助linux bpf系统调用加载到内核
  4. 通过性能监控等接口与具体的内核事件进行绑定

上篇中也介绍到了,一个完成整eBPF程序中,通常包含用户态程序与内核态程序两部分,

用户态:负责eBPF的编译、加载、事件绑定、结果输出,它与内核进行交互的时候必须通过系统调用来完成

内核态:负责定制和控制系统的运行状态

BPF系统调用

执行man bpf查看bpf系统调用格式

代码语言:javascript复制
int bpf(int cmd, union bpf_attr *attr, unsigned int size);

参数:

  1. cmd:操作命令,eg BPF_PROG_LOAD 就是加载eBPF程序
  2. attr:bpf_attr类型的eBPF属性指针,不通类型的操作命令需要传入不同的属性参数
  3. 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 mapbpf map

注: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 可以查看系统所有可用的映射类型

代码语言:javascript复制
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程序就不用自己定义数据结构(防止将错误的数据结构带入内核中)和引入一堆头文件了

vmlinux.h的使用示意图vmlinux.h的使用示意图

借助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

0 人点赞