本帖最后由 look_w 于 2017-12-8 08:58 编辑
新的指令集eBPF 对于既有 cBPF 令集的改动量之大,以至于基本上不能认为两者还是同一种语言了。个中变化,我们可以通过反汇编清单 4 的源代码(llvm-objdump --disassemble)略知一二:
清单 6 Disassemble of sockex1_kern.o1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| sockex1_kern.o: file format ELF64-BPF
Disassembly of section socket1:
bpf_prog1:
0: bf 16 00 00 00 00 00 00 r6 = r1
1: 30 00 00 00 17 00 00 00 r0 = *(u8 *)skb[23]
2: 63 0a fc ff 00 00 00 00 *(u32 *)(r10 - 4) = r0
3: 61 61 04 00 00 00 00 00 r1 = *(u32 *)(r6 + 4)
4: 55 01 08 00 04 00 00 00 if r1 != 4 goto 8
5: bf a2 00 00 00 00 00 00 r2 = r10
6: 07 02 00 00 fc ff ff ff r2 += -4
7: 18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0ll
9: 85 00 00 00 01 00 00 00 call 1
10: 15 00 02 00 00 00 00 00 if r0 == 0 goto 2
11: 61 61 00 00 00 00 00 00 r1 = *(u32 *)(r6 + 0)
12: db 10 00 00 00 00 00 00 lock *(u64 *)(r0 + 0) += r1
LBB0_3:
13: b7 00 00 00 00 00 00 00 r0 = 0
14: 95 00 00 00 00 00 00 00 exit
|
我们不用管这段汇编写了点儿什么,先跟清单 2 开头的那段 cBPF 代码对比一下两者的异同:
- 寄存器:eBPF 支持更多的寄存器;
- cBPF:A, X + stack, 32bit;
- eBPF:R1~R10 + stack, 64bit,显然,如此的设计主要针对现在大行其道的 64 位硬件,同时更多的寄存器设计也便于运行时和真实环境下的寄存器进行对应,以提高效率;
- opcode:两者的格式不同;
- cBPF: op 16b, jt 8b, jf 8b, K 32b;
- eBPF: op 8b, dstReg 4b, srcReg 4b, off 16b, imm 32b;
- 其他:sockex1_kern.o 设计的比较简单,但还是可以从中看出 eBPF 的一大改进:可以调用内核中预设好的函数(Call 1,这里指向的函数是 bpf_map_lookup_elem(),如果需要比较全的预设函数索引的话可以移步。除此之外,eBPF 命令集中比较重要的新晋功能还有:
- load/store 多样化:
- cBPF:仅可以读 packet(即 skb)以及读写 stack;
- eBPF:可以读写包括 stack/map/context,也即 BPF prog 的传入参数可读写。换句话说,任意传入 BPF 代码的数据流均可以被修改;
- 除开预设函数外,开发者还可以自定义 BPF 函数(JUMP_TAIL_CALL);
- 除了前向跳转外(Jump Forward,cBPF 支持),还可以后向跳转(Jump Backword);
至于 eBPF 具体的指令表,因为过于庞杂这里笔者就不作文抄公了。不过 eBPF 中的几个寄存器的利用规则这里还是可以有的,否则要读懂清单 6 中的代码略有困难:
- R0:一般用来表示函数返回值,包括整个 BPF 代码块(其实也可被看做一个函数)的返回值;
- R1~R5:一般用于表示内核预设函数的参数;
- R6~R9:在 BPF 代码中可以作存储用,其值不受内核预设函数影响;
- R10:只读,用作栈指针(SP);
In-kernel Verifier其实结合前面那么多的内容看下来不难发现 eBPF 其实近似于一种改头换面后的内核模块,只不过它比内核模块更短小精干,实现的功能也更新颖一些罢了,但无论是什么样的架构,只要存在注入的代码就会有安全隐患,eBPF 也不外如是——毕竟注入的代码是要在内核中运行的。
为了最大限度的控制这些隐患,cBPF 时代就开始加入了以防止不规范的注入代码;到了 eBPF 时代则在载入程序(bpf_load_program())时加入了更复杂的,在运行注入程序之前,先进行一系列的安全检查,最大限度的保证系统的安全。具体来说,verifier 机制会对注入的程序做两轮检查:
- 首轮检查(First pass,实现于可以被认为是一次深度优先搜索,主要目的是对注入代码进行一次 DAG(Directed Acyclic Graph,有向无环图)检测,以保证其中没有循环存在;除此之外,一旦在代码中发现以下特征,verifier 也会拒绝注入:
- 代码长度超过上限,目前(内核版本 4.12)eBPF 的代码长度上限为 4K 条指令——这在 cBPF 时代很难达到,但别忘了 eBPF 代码是可以用 C 实现的;
- 存在可能会跳出 eBPF 代码范围的 JMP,这主要是为了防止恶意代码故意让程序跑飞;
- 存在永远无法运行(unreachable)的 eBPF 令,例如位于 exit 之后的指令;
- 次轮检查(Second pass,实现于)较之于首轮则要细致很多:在本轮检测中注入代码的所有逻辑分支从头到尾都会被完全跑上一遍,所有的指令的参数(寄存器)、访问的内存、调用的函数都会被仔细的捋一遍,任何的错误都会导致注入程序被退货。由于过分细致,本轮检查对于注入程序的复杂度也有所限制:首先程序中的分支(branch)不允许超过 1024 个;其次经检测的指令数也必须在 96K 以内。
Overview: eBPF 的架构诚然,eBPF 设计的复杂程度已是超越 cBPF 太多太多,笔者罗里吧嗦了大半天,其实也就是将将领着大家入门的程度而已,为了便于读者们能够把前文所述的碎片知识串到一起,这里笔者将 eBPF 的大体架构草绘一番,如下图所示,希望能帮助大家对 eBPF 构建一个整体的认识。
图 5. Architecture of eBPF追求极简:BPF Compiler Collection(BCC)现在让我们将目光聚焦到 eBPF 的使用——相信这是大部分读者最感兴趣的部分,毕竟绝大多数人其实并没有多少机会参与 eBPF 的开发——重新回到清单 3&4 中的 sockex1:说句良心话,虽然现在可以用 C 来实现 BPF,但编译出来的却仍然是 ELF 文件,开发者需要手动析出真正可以注入内核的代码。这部分工作多少有些麻烦,如果可以有一个通用的方案一步到位的生成出 BPF 代码就好了,开发者的注意力应该放在其他更有价值的地方,不是吗?
于是就有人设计了 BPF Compiler Collection(BCC),BCC 是一个 python 库,但是其中有很大一部分的实现是基于 C 和 C++的,python 只不过实现了对 BCC 应用层接口的封装而已。
使用 BCC 进行 BPF 的开发仍然需要开发者自行利用 C 来设计 BPF 程序——但也仅此而已,余下的工作,包括编译、解析 ELF、加载 BPF 代码块以及创建 map 等等基本可以由 BCC 一力承担,无需多劳开发者费心。
限于篇幅关于 BCC 笔者不再过多展开,文章的最后笔者再给出一个基于 BCC 实现的 sockex1 的例子,读者可以感受一下使用 BCC 带给开发者们的便利性:
清单 7 A sample of BCC1
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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
| from bcc import BPF
# 和清单 2 一样,篇幅所限,这里只贴一部分源码,完全版请移步
interface="ens160"
# BCC 可以接受直接将 BPF 代码嵌入 python code 之中
# 为了方便展示笔者使用了这一功能
# 注意:prog 中的中文注释是由于笔者需要写作之故加入,如果读者想尝试运行这段代码,
# 则请将中文全部删除,因为目前 BCC 还不支持在内嵌 C 代码中使用中文注释
prog = """
#include <net/sock.h>
#include <bcc/proto.h>
// BCC 中专门为 map 定义了一系列的宏,以方便使用
// 宏中的 struct 下还定义了相应的函数,让开发者可以如 C++一般操作 map
// 这里笔者定义了一个 array 类型的 map,名为 my_map1
BPF_ARRAY(my_map1, long);
// BCC 下的 BPF 程序中不再需要定义把函数或变量专门放置于某个 section 下了
int bpf_prog1(struct __sk_buff *skb)
{
// ……
struct ethernet_t *eth = cursor_advance(cursor, sizeof(*eth));
// ……
struct ip_t *ip = cursor_advance(cursor, sizeof(*ip));
int index = ip->nextp;
long zero = 0; // BCC 下的 bpf 书写还是有很多坑的
// 例如,这里如果不去定义一个局部变量 zero,
// 而是直接用常量 0 作为 lookup_or_init()的变量就会报错
// map 类下的各个方法的具体细节可以参照 reference_guide.md
value = my_map1.lookup_or_init(&index, &zero);
if (value)
__sync_fetch_and_add(value, skb->len);
return 0;
}
"""
bpf = BPF(text=prog, debug = 0)
# 注入 bpf_prog1 函数
function = bpf.load_func("bpf_prog1", BPF.SOCKET_FILTER)
# 这是一段 SOCKET_FILTER 类型的 BPF,所以需要挂载到某一个 interface 上
BPF.attach_raw_socket(function, interface)
# 利用 map 机制获取进出 interface 的各个协议的报文总长
bpf_map = bpf["my_map1"]
while 1:
print ("TCP : {}, UDP : {}, ICMP: {}".format(
bpf_map[socket.IPPROTO_TCP].value,
# …
|
结束语本文从 BPF 的源头开始,一路讲到了近年来刚刚杀青的 eBPF,虽说拘泥于篇幅,大多内容只能蜻蜓点水、浅尝辄止,但文中 BPF 的原理、设计、实现和应用均有所涉猎,勉强也能拿来入个门了。加之近年来基于 eBPF 的应用层出不穷,希望本文能激发读者们的奇思妙想,从而设计出更多基于 BPF 的优秀应用来。 |