eBPF系列一:Hello eBPF

迫于Linux eBPF文档过少,我边学习边把对其的理解记录下来,供后来者参考。
本文是eBPF系列的第一篇:hello eBPF。

Overview

先写了一个eBPF例子,它能够在每次进行系统调用clone()时打印一行Hello eBPF!,并说明eBPF程序怎么从源码到执行的。
再写了点eBPF执行的内幕,包括两点:

  1. eBPF怎么检查内存访问的
  2. eBPF程序怎么进行BPF helper function call

an Example

这些源码在这里

eBPF program

一个简单的eBPF程序如下,它能够在每次进行系统调用clone()时打印一行Hello eBPF!

1
2
3
4
5
6
7
8
9
10
11
12
// hello_kern.c
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>

SEC("kprobe/sys_clone")
int hello(struct pt_regs *ctx) {
char msg[] = "Hello eBPF!";
bpf_trace_printk(msg, sizeof(msg));
return 0;
}

char _license[] SEC("license") = "GPL";

在该代码中,SEC(NAME)定义在头文件bpf/bpf_helpers.h中,为__attribute__((section(NAME), used)),即把该变量、函数放在ELF文件中名为NAME的section中。_license定义了该eBPF程序的license类型,在eBPF程序加载的过程中会校验license是否为GPL[1]兼容的。hello()调用了bpf_trace_printk()将msg写入tracefs的ring buffer中,bpf_trace_printk()最多可接收三个参数进行格式化输出。SEC("kprobe/sys_clone")声明hello()使用kprobe hook至系统调用clone()的函数入口处。

当前(2021.01.03)可用clang+llvm编译eBPF程序,其他的编译器,比如GCC、rust等,亦在添加对eBPF的支持中[2][3]。clang把eBPF程序翻译为中间语言(IR)是LLVM的object(参数-c -emit-llvm),再通过llc编译、链接成target为bpf的ELF程序(参数-march=bpf -filetype=obj)。这里还使用了-O2参数对其进行优化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ clang -O2 -emit-llvm -c hello_kern.c -o - | \
llc -march=bpf -filetype=obj -o hello_kern.o
$ readelf -a hello_kern.o
ELF Header:
...
Machine: Linux BPF
...
Section Headers:
...
[ 3] kprobe/sys_clone PROGBITS 0000000000000000 00000040
...
[ 5] license PROGBITS 0000000000000000 000000a4
...
Symbol table '.symtab' contains 5 entries:
...
3: 0000000000000000 4 OBJECT GLOBAL DEFAULT 5 _license
4: 0000000000000000 88 FUNC GLOBAL DEFAULT 3 hello

Userspace Helper Program

仅有eBPF程序还不够,需要用户态程序把eBPF程序注入内核、并从内核中读取相关的信息。Linux提供了libbpf[4](即tools/lib/bpf)供用户态程序调用,它有两层level:封装系统调用bpf()的bpf.c,及解析bpf target格式的ELF文件的libbpf.c。Linux亦在sample/bpf下提供了一些eBPF示例,后缀_kern.c代表eBPF程序,_user.c代表用户态程序,也实现了简化版libbpf.c的bpf_load.c。

对于hello_kern.o来讲,使其工作需要这几个步骤:

  1. 解析ELF文件,解析出函数hello()指令,调用bpf(BPF_PROG_LOAD)将其注入内核,取得prog_fd
  2. 生成kprobe clone()的hook point,使用perf_event_open()获取pfd,该pfd与hook point相关联
  3. 使用ioctl(pfd, PERF_EVENT_IOC_SET_BPF, prog_fd)将该eBPF程序与perf event绑定
  4. 使用ioctl(pfd, PERF_EVENT_IOC_ENABLE, 0)使能该perf event
  5. 读取tracefs中的trace_pipe获取来自eBPF程序的msg

Note:
原则上kprobe能够hook的func,在config CONFIG_KALLSYMS_ALL=y的情况下,全都记录在/proc/kallsyms中。对于kprobe:

  • bpf_load.c采用的是echo 'p:<name> <func>' <tracefs>/kprobe_events方式生成名为<name><func> hook point,再通过perf_event_open()获取关联着该hook point的pfd。因此尽管在/proc/kallsyms中没有名为sys_clone的symbol,仍然会产生相应的hook point
  • libbpf.c与bpf_load.c不同,它使用perf_event_open()获取pfd的同时生成hook point。因此它无法使用sys_clone symbol生成hook point

使用bpf_load.c写成的代码在这里

eBPF Program Internal

eBPF程序并不关心如何执行到它这里的,这些是kprobe、tracepoint、uprobe等做的事情,至于能不能执行、怎么执行用户所写的eBPF代码则是eBPF所关心的。

如同互联网中多数文章所说,eBPF是解释执行的、运行在沙盒中的程序。eBPF程序有着严格的限制,目前发现的:

  • 它的状态必须是能确定的,像状态机一样从一个状态转换成另一个状态
  • 它不具备读写任意内存空间的能力,大部分内存的读取需使用bpf_probe_read_{kernel,user}()函数,只允许具有bpf helper的允许的地方使用bpf helper func改写内存
  • 它无法使用malloc()分配内存,准确地说它没有标准库,无法使用除了linux/bpf.h[5]中定义的所有函数(除了memset() / memcpy() / memmove() / memcmp(),他们可由编译器提供,即__builtin_{memset, memcpy, memmove, memcmp}()[6])
  • 对于loop,v5.3及之后也只是允许有限循环状态的loop[7][8]
  • 它不支持BPF-to-BPF call,v5.10及之后也只是在使用libbpf的情况下允许BPF-to-BPF call(这些call是在ELF解析时候完成的)

eBPF在载入程序时会进行模拟执行、检查,入口在bpf_check()[9]。对于内存访问,会使用check_mem_access()[10]进行检查。eBPF支持的各类hook方法都实现了struct bpf_verifier_ops,其成员is_valid_access是函数指针,规定了各类hook方法直接允许访问的内存范围,超出该范围的必须使用bpf_probe_read_{kernel,user}()进行访问。Linux对不同特权等级下程序能够访问的内存空间进行了区分,在v5.5之前,bpf_probe_read()只能访问内核空间的内存,因此引入了bpf_probe_read_user()bpf_probe_read()被重命名为bpf_probe_read_kernel(),为了保持兼容bpf_probe_read()作为bpf_probe_read_kernel()的别名存在。

那么,载入kernel的eBPF程序是如何执行、以及进行BPF helper func调用的呢?

eBPF解释执行的相关代码在___bpf_prog_run()[11]中。struct bpf_insn[12]是其指令格式,可以看到,它是定长的指令格式,具体含义在Documentation/networking/filter.rst[13]中进行了说明。若反汇编eBPF程序会发现,eBPF汇编中是存在call指令的,并且在解释器中会发现相关的跳转实现[14],只不过6这个值与bpf_trace_printk()对不上,而且这看起来怪怪的。回到bpf_check()会发现它调用了fixup_bpf_calls()[15],这里有一些switch case,通过跳转不同的BPF_FUNC_xxx得到不同的与__bpf_call_base有关的offset,offset赋给了insn->imm,也就有了那种看起来怪怪的调用方法。在include/uapi/linux/bpf.h[16]中存在着当前版本下所有的BPF_FUNC定义,6即FN(trace_printk),macro展开是BPF_FUNC_trace_printk。可这里并没有与trace_printk有关的字眼。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
$ llvm-objdump -d hello_kern.o | grep call
8: 85 00 00 00 06 00 00 00 call 6

/* kernel/bpf/core.c */
static u64 ___bpf_prog_run(u64 *regs, const struct bpf_insn *insn, u64 *stack)
/* CALL */
JMP_CALL:
/* Function call scratches BPF_R1-BPF_R5 registers,
* preserves BPF_R6-BPF_R9, and stores return value
* into BPF_R0.
*/
BPF_R0 = (__bpf_call_base + insn->imm)(BPF_R1, BPF_R2, BPF_R3,
BPF_R4, BPF_R5);
CONT;

/* kernel/bpf/verifier.c */
static int fixup_bpf_calls(struct bpf_verifier_env *env)
switch (insn->imm) {
case BPF_FUNC_map_lookup_elem:
insn->imm = BPF_CAST_CALL(ops->map_lookup_elem) -
__bpf_call_base;
...
patch_call_imm:
fn = env->ops->get_func_proto(insn->imm, env->prog);
/* all functions that have prototype and verifier allowed
* programs to call them, must be real in-kernel functions
*/

实际上,在struct bpf_verifier_ops另一个成员get_func_proto的实现中规定了各类hook方法中能够使用的BPF_FUNC,通用的BPF_FUNC才在fixup_bpf_calls()中写明,其余则通过回调env->ops->get_func_proto()获取。

因为eBPF的call指令用于调用kernel中定义的各种BPF helper func,这也解释了为啥不支持BPF-to-BPF call。