Loading

eBPF 运行原理和流程

前言

极客时间 eBPF 核心技术与实战 的学习笔记.
本章说一下 ebpf 的运行原理, 本章有些内容是直接 copy 自课程原文

eBPF虚拟机(执行器)包含了什么

官方的说话, eBPF 是运行在 eBPF 虚拟机中, 而不是直接作用于系统.
但是也有人说, eBPF执行系统更应该称之为 执行器, 因为他并不如 虚拟机, 他们的差异如下:

  • eBPF只提供了有限的指令集, 并不像虚拟机一样是一个完整的系统, 提供了完整的指令集, 这是因为 eBPF 不能影响到系统的稳定性, 所以只提供了一些处理过的指令集.
    在前一章中, 我们可以发现内核态代码部分, 使用 C 直接调用了辅助函数, 这是 eBPF 为了提高开发效率有意为之的.
    那么我们看一下 eBPF 虚拟机包含了哪些部分

eBPF辅助函数

就像上一章的 bpf_get_current_pid_tgid() , eBPF为我们提供了许多辅助函数, 通过这些辅助函数来调用到系统的若干运行信息
这些函数实际上是帮我们调用了内核的其他模块, 但是需要注意的是, eBPF提供的辅助函数并不是全部可用的, 能够调用的函数由 eBPF 的程序类型决定

eBPF验证器

用于确保 eBPF 代码的安全, 验证器会将需要执行的指令创建成有向无环图, 确保执行的指令都是可达的, 再模拟执行指令, 确保指令不是无效的

存储模块

11 个 64 位寄存器、一个程序计数器和一个 512 字节的栈组成的存储模块. 这里控制eBPF程序的执行. 这样的设计, 导致了程序的若干限制:

  • 函数的调用只能有一个返回值
  • 函数调用参数不能超过5个
  • 栈存储不能超过512字节

即时编译器

将 eBPF 程序编译成字节码执行

BPF 映射(map)

大块存储, 可以让用户态程序访问, 来进行数据的读取

BPF指令长什么样

需要先安装 bpftool

apt install bpftool

然后运行

bpftool prog list

会输出当前运行的 bpf 程序, 打印类似于

root@VM-4-12-debian:~# bpftool prog list
3: cgroup_device  name sd_devices  tag 3650d9673c54ce30  gpl
        loaded_at 2024-11-30T14:06:29+0800  uid 0
        xlated 504B  jited 310B  memlock 4096B

其中, 3 是这个 eBPF 程序的编号, cgroup_device 是这个程序的类型, sd_devices 是这个 程序的名字
我们可以再开一个命令行, 运行上一章的 helloworld.py, 在运行时再次运行 bpftool prog list

42: kprobe  name hello_world  tag 38dd440716c4900f  gpl
           loaded_at 2024-12-04T21:12:45+0800  uid 0
           xlated 104B  jited 71B  memlock 4096B
           btf_id 85

发现这次多了一条, 名称就是我们定义的 hello_world, 而我们的程序类型是 kprobe, 编号是 42, 知道了 编号 后, 我们可以查看这个程序的详细指令(42注意替换为你的 编号)

root@VM-4-12-debian:~# sudo bpftool prog dump xlated id 42
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#-61424
; return 0;
  11: (b7) r0 = 0
  12: (95) exit

其中, ; 开头的行是我们编写的代码, 其他行是转换的指令
就拿 0: (b7) r1 = 33 举例, 0 是指令的行数, (b7) 是十六进制值, 代表BPF 指令码, 具体的码和含义可以参考 bpf-docs/eBPF.md at master · iovisor/bpf-docs , 这里的 b7 代表 64位寄存器赋值, r1 = 33 则是BPF 指令的伪代码
所以上面的详细指令可以翻译成:

  • 第 0-8 行,借助 R10 寄存器从栈中把字符串 “Hello, World!” 读出来,并放入 R1 寄存器中
  • 第 9 行,向 R2 寄存器写入字符串的长度 14(即代码注释里面的 sizeof(_fmt) )
  • 第 10 行,调用 BPF 辅助函数 bpf_trace_printk 输出字符串
  • 第 11 行,向 R0 寄存器写入 0,表示程序的返回值是 0
  • 最后一行,程序执行成功退出
    这些指令先通过 R1 和 R2 寄存器设置了 bpf_trace_printk 的参数, 然后调用 bpf_trace_printk 函数输出字符串, 最后再通过 R0 寄存器返回成功.
    而BPF 虚拟机在接受到这些指令后, 经过校验, 会通过即时编译器模块编译成本地机器指令执行.
    使用命令查看编译后的本地机器指令
bpftool prog dump jited id 42

如果报错说不支持, 是因为内核默认不开启查看机器指令的功能, 可自行搜索解决办法

BPF程序什么时候执行

需要安装模块 strace

apt install strace

使用 strace 工具来查看 hello.py 的运行过程

# -ebpf表示只跟踪bpf系统调用
sudo strace -v -f -ebpf ./hello.py

输出

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}
        ],
        prog_name="hello_world",
        ...
    },
    128) = 4

可以看到调用 bpf 函数, 传入了 3 个参数, 实际上 bpf 函数只需要3个参数, 那么这里,这三个参数的含义是:

  • 第一个参数是 BPF_PROG_LOAD, 表示加载 BPF 程序
  • 第二个参数是 bpf_attr 类型的结构体, 表示 BPF 程序的属性. 其中, 有几个需要你留意的参数, 比如: prog_type 表示 BPF 程序的类型, 是 BPF_PROG_TYPE_KPROBE , 跟我们 Python 代码中的 attach_kprobe 一致. insn_cnt (instructions count) 表示指令条数, insns (instructions) 包含了具体的每一条指令, 这儿的 13 条指令跟我们前面 bpftool prog dump 的结果是一致的
  • prog_name 则表示 BPF 程序的名字, 即 hello_world. 第三个参数 128 表示属性的大小.

而在第一章中, 我们就说了, eBPF程序并不是一直运行, 而是指定的事件发生后才触发执行.
我们的 hello.py 中代码写明了调用了 attach_kprobe 进行事件的注册

b.attach_kprobe(event="do_sys_openat2", fn_name="hello_world")

为了验证这个结果, 我们使用 strace 再次获取一下, 这次获取全部的流程而不是只是 ebpf

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, "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
...

所以, 其实eBPF 的程序执行分为如下几步:

  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 程序绑定到性能监控事件。
posted @ 2024-12-04 21:59  ChnMig  阅读(56)  评论(0编辑  收藏  举报