内核技术eBPF

300 阅读6分钟

eBPF初探

Snipaste_2023-11-07_20-58-36.png

从整体上看, eBPF分为两个层面, 第一步是用户层面, 第二部分是内核层面。用户层面就是通过编译程序, 内核层面就是执行代码。那么执行代码的结果呢? 结果就是通过我们的程序可以完成与内核交互。这样我们就可以获得我们想要的信息, 比如占用的内存, 线程的消耗, 完成一些排查。

在开始之前, 我们需要知道该用到哪些库, 其实这个库是和上面这张图片相互关联的:

  1. 首先是LLVM, 将program编译成为BPF bytecode
  2. eBPF工具集合BCC和它依赖的内核头文件
  3. 与内核代码仓库同步的libbpf
  4. 内核提供的eBPF管理工具bpftool

接下来我们就来实验一下

首先是一段eBPF程序

int hello(void* ctx)
{
    bpf_trace_printk("Hello, World");
    return 0;
}

bpf_trace_printk是一个经典的BPF辅助函数, 它的作用是输出一段字符串, 由于eBPF是运行在内核中, 所以并不是标准的输出, 而是内核调试文件

/sys/kernel/debug/tracing/trace_pipe

然后我们写一段Python程序

#hello.py 

#导入文件
b = BPF('./hello.c')

#将BPF程序加载到Kprobe上, 对应的event是do_sys_openat2()
#do_sys_openat2()是openat在内核中的实现
b.attach_kprobe(event="do_sys_openat2()", fn_name="hello")

#读取/sys/kernel/debug/tracing/trace_pipe
b.trace_print();

然后运行一下

python3 hello.py

b' cat-10656 #表示进程的名字和PID 
[006] #表示CPU编号 
d... #表示一系列的选项
2348.114455: #表示时间戳
bpf_trace_printk: #表示函数名称
Hello, World!'#表示输出结果

上面这种通过内核调试文件系统的输出日志的方式不够方面:

int hello_world(
    struct pt_regs *ctx, 
    int dfd, 
    const char __user * filename, 
    struct open_how *how
)
{
  struct data_t data = { };

  data.pid = bpf_get_current_pid_tgid();   //获取pid
  data.ts = bpf_ktime_get_ns();            //获取时间
    
  //获取当前进程名
  if (bpf_get_current_comm(&data.comm, sizeof(data.comm)) == 0)
  {
    bpf_probe_read(&data.fname, sizeof(data.fname), (void *)filename);
  }
  
  events.perf_submit(ctx, &data, sizeof(data));
  return 0;
}

我们改进这段程序:

TIME(s)            COMM             PID    FILE            
0.000000000        systemd          1      /proc/979/cgroup
0.000460508        systemd          1      /proc/854/cgroup
0.000612384        systemd          1      /proc/635/cgroup

eBPF运行原理

Snipaste_2023-11-07_20-57-18.png

上面我们分析了eBPF的基本架构, 现在我们就来看看eBPF的运行原理

  1. 第一个模块是eBPF验证器(BPF verifier), 它主要用于确保eBPF的安全, 验证器首先会将等待执行的指令创建为一个有向无环图, 确保程序中不会不包含不可以达到的指令。
  2. 第二个模块是即时编译器(JIT complier), 将eBPF字节码编译成本地机器指令, 以便高效地在内核中执行。
  3. 第三个模块是BPF辅助函数, 这个模块为我们提供了一系列的函数。
  4. 第四个模块是由11个64位寄存器、一个程序计数器和一个512字节的栈组成的存储模块

R0寄存器用于存储函数调用和eBPF程序的返回值 R1-R5寄存器用于函数调用时候的参数 R10是一个栈寄存器, 用于从栈上读取数据

  1. 第五个模块是BPF映射, 它用于提供大块存储, 这些存储可以被用户空间程序来进行访问, 进而控制eBPF的运行状态

BPF instruction是什么样子的 ,使用这个命令bpftool prog list

2: kprobe  name hello_world  tag b4bae6793f19e39e  gpl
        loaded_at 2023-11-07T09:35:56-0500  uid 0
        xlated 104B  jited 71B  memlock 4096B
        btf_id 2

然后使用bpftool prog dump xlated id 2导出eBPF程序的指令

int hello_world(void * ctx):
; int hello_world(void* ctx)
   0: (b7) r1 = 1684828783
; ({ char _fmt[] = "Hello, World"; bpf_trace_printk_(_fmt, sizeof(_fmt)); });
   1: (63) *(u32 *)(r10 -8) = r1
   2: (18) r1 = 0x57202c6f6c6c6548
   4: (7b) *(u64 *)(r10 -16) = r1
   5: (b7) r1 = 0
   6: (73) *(u8 *)(r10 -4) = r1
   7: (bf) r1 = r10
;
   8: (07) r1 += -16
; ({ char _fmt[] = "Hello, World"; bpf_trace_printk_(_fmt, sizeof(_fmt)); });
   9: (b7) r2 = 13
  10: (85) call bpf_trace_printk#-65776
; return 0;
  11: (b7) r0 = 0
  12: (95) exit
  1. 第一部分, 冒号前面的数字0-12, 代表BPF的指令行数
  2. 第二部分, 括号中的16进制数值, 表示BPF的指令码
  3. 第三部分, 括号后面的部分, 就是BPF指令的伪代码
  4. 其余部分是我们的C语言代码, 也就是冒号开头的部分

然后就是将上面的BPF指令加载到内核中, 当这些BPF指令加载到内核之后, BPF即使编译器会将其编译成为本地机器指令, 最后才会执行编译后的机器指令, 这些具体的指令和寄存器都换成了X86格式。

bpftool prog dump jited id 89

eBPF是什么时候执行的

我们通过观察BPF的系统调用的格式, 就可以发现, 它实际上只需要三个参数:

int ebpf(int cmd, union bpf_attar *attr, unsigned int size);

使用下面这个命令跟踪程序的执行

sudo strace -v -f -ebpf ./hello.py
  1. 第一个参数是BPF_PROG_LOAD, 表示加载BPF程序
  2. 第二个参数是bpf
  3. 第三个参数128表示属性的大小

同时BPF程序加载到内核之后, 并不会立即执行, 那么他们会什么时候执行呢, eBPF程序并不会像规线程那样, 启动之后就一直运行在那里, 它需要事件触发后才会执行, 这些事件包括系统调用、内核跟踪点、内核函数和用户态函数的调用退出、网络事件等等

对于我们的Hello, World来说, 由于调用了attach_kprobe函数, 很明显, 这是一个内核跟踪事件:

b.attach_kprobe(event="do_sys_openat2", fn_name="hello_world")
  1. 也就是说, 我们除了需要将eBPF程序加载到内核
  2. 还需要把加载后的程序和具体的内核函数调用事件进行绑定

在eBPF的实现中, 诸如内核跟踪、用户调用等的时间绑定, 都是通过perf_event_open()来完成的

/* 1) 加载BPF程序 */
bpf(BPF_PROG_LOAD,...) = 4

/* 2)查询事件类型 */
openat(AT_FDCWD, "/sys/bus/event_source/devices/kprobe/type", O_RDONLY) = 5
read(5, "6\n", 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是上一步查询到的)
  4. 最后才通过ioctl的PERF_EVENT_IOC_SET_BPF命令, 将BPF程序绑定到性能监控事件

BPF与内核交互

对于我们程序员来说, 与内核直接进行交互必须通过系统调用来完成, 而对应到eBPF程序中

首先是eBPF的基本格式:

#include <linux/bpf.h>
int bpf(int cmd, union bpf_attr *attr, unsigned int size);

第一个cmd, 表示操作命令, 比如BPF_PROG_LOAD就是加