eBPF初探
从整体上看, eBPF分为两个层面, 第一步是用户层面, 第二部分是内核层面。用户层面就是通过编译程序, 内核层面就是执行代码。那么执行代码的结果呢? 结果就是通过我们的程序可以完成与内核交互。这样我们就可以获得我们想要的信息, 比如占用的内存, 线程的消耗, 完成一些排查。
在开始之前, 我们需要知道该用到哪些库, 其实这个库是和上面这张图片相互关联的:
- 首先是LLVM, 将program编译成为BPF bytecode
- eBPF工具集合BCC和它依赖的内核头文件
- 与内核代码仓库同步的libbpf
- 内核提供的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运行原理
上面我们分析了eBPF的基本架构, 现在我们就来看看eBPF的运行原理
- 第一个模块是eBPF验证器(BPF verifier), 它主要用于确保eBPF的安全, 验证器首先会将等待执行的指令创建为一个有向无环图, 确保程序中不会不包含不可以达到的指令。
- 第二个模块是即时编译器(JIT complier), 将eBPF字节码编译成本地机器指令, 以便高效地在内核中执行。
- 第三个模块是BPF辅助函数, 这个模块为我们提供了一系列的函数。
- 第四个模块是由11个64位寄存器、一个程序计数器和一个512字节的栈组成的存储模块
R0寄存器用于存储函数调用和eBPF程序的返回值 R1-R5寄存器用于函数调用时候的参数 R10是一个栈寄存器, 用于从栈上读取数据
- 第五个模块是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
- 第一部分, 冒号前面的数字0-12, 代表BPF的指令行数
- 第二部分, 括号中的16进制数值, 表示BPF的指令码
- 第三部分, 括号后面的部分, 就是BPF指令的伪代码
- 其余部分是我们的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
- 第一个参数是BPF_PROG_LOAD, 表示加载BPF程序
- 第二个参数是bpf
- 第三个参数128表示属性的大小
同时BPF程序加载到内核之后, 并不会立即执行, 那么他们会什么时候执行呢, eBPF程序并不会像规线程那样, 启动之后就一直运行在那里, 它需要事件触发后才会执行, 这些事件包括系统调用、内核跟踪点、内核函数和用户态函数的调用退出、网络事件等等
对于我们的Hello, World来说, 由于调用了attach_kprobe函数, 很明显, 这是一个内核跟踪事件:
b.attach_kprobe(event="do_sys_openat2", fn_name="hello_world")
- 也就是说, 我们除了需要将eBPF程序加载到内核
- 还需要把加载后的程序和具体的内核函数调用事件进行绑定
在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
- 借助bpf系统调用, 加载BPF程序, 并记住返回的文件描述符号,
- 然后查询kprobe类型的事件编号, BCC其实是通过/sys/bus/event_source/devices/kprobe/type来查询
- 接着调用perf_event_open创建性能监控事件, 比如, 事件类型(type是上一步查询到的)
- 最后才通过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就是加