BPF与eBPF

最近了解Linux的性能优化时, 偶然接触到了BPF(Berkeley Packet Filter)这个有意思的东西, 觉得很好玩.把学到的一些基本原理与知识记录下来, 算是一个初步的总结. 这篇文章主要从如下几个方面介绍下BPF :

  • BPF的原理
  • 什么是eBPF
  • 如何在Linux中使用BPF

用过tcpdump的同学应该都了解pcap, 实际上pcap就是基于BPF来实现网络数据包的过滤的. tcpdump的原理如下图所示: tcpdump将包过滤的表达式, 如查看某个网口所有udp包, 输入tcpdump -n -i eth0 udp, 这个表达式通过PCAP库编译成伪机器字节码后, 通过系统调用发送给内核(内核中有对应的机器码解释器)解释执行, 这样只要系统有udp包, 内核都会过滤出来转发给用户进程tcpdump:

how tcpdump works

BPF全称Berkeley Packet Filters, 最初是Steven MaCanne, Van Jacobson在文章‘The BSD Packet Filter: A New Architecture for User-level Packet Capture’中提出的一种基于BSD Unix操作系统的包过滤器, 其原理是将包过滤的表达式编译成特定伪机器码后, 由Kernel中的虚拟机执行; 每当网卡中有数据包过来时, 将其拷贝发送给BPF模块, 由BPF根据对应的过滤条件将过滤后的包发送给用户进程:

how BPF works

针对tcpdump, linux有专门用于包过滤的表达式, 参考pcap-filter

BPF在Linux中的发展大致经历三个阶段:

  • Linux 2.1.75最初的实现基于原有的socket接口, 被称为Linux Socket Filter(LSF)
  • Linux 3.0版本中,在BPF中加入JIT(Just-In-Time Compiler), 提升BPF的性能与速度
  • Linux 3.15开始将BPF扩展成为通用的模块, BPF不仅可用于数据包过滤, 也可以用来进行内核事件跟踪/应用性能调优/流量控制(Traffic Control)等, 代码也统一整合到了/kernel/bpf, 这也是目前被称为eBPF(extended BPF)的原因, 而早前的BPF实现则被称为cBPF(classic BPF).

首先来看下传统的cBPF是如何实现的.

cBPF

BPF最初的BSD实现方案是通过打开一个字符设备/dev/bpf*(/dev/bpf0, /dev/bpf1 etc), 再通过ioctl来控制该设备, 而Linux内核则基于现有的socket接口加入新的选项SO_ATTACH_FILTER/SO_DETACH_FILTER来执行系统调用, 具体的代码实现在/net/core/filter.c. 通过一个简单的示例来看下cBPF是如何工作的.

  • 首先通过tcpdump指令来产生数据包的过滤代码: tcpdump dst port 53 -dd(这里过滤所有目标端口为53的数据包)
  • 然后创建一个AF_PACKET(用于接收所有的数据包)的socket用于向kernel传递过滤代码, 并接收过滤后的数据包
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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84

// tcpdump dst port 53 -dd
static struct sock_filter filter_code[] = {
{ 0x28, 0, 0, 0x0000000c },
{ 0x15, 0, 8, 0x000086dd },
{ 0x30, 0, 0, 0x00000014 },
{ 0x15, 2, 0, 0x00000084 },
{ 0x15, 1, 0, 0x00000006 },
{ 0x15, 0, 17, 0x00000011 },
{ 0x28, 0, 0, 0x00000036 },
{ 0x15, 14, 0, 0x00000035 },
{ 0x28, 0, 0, 0x00000038 },
{ 0x15, 12, 13, 0x00000035 },
{ 0x15, 0, 12, 0x00000800 },
{ 0x30, 0, 0, 0x00000017 },
{ 0x15, 2, 0, 0x00000084 },
{ 0x15, 1, 0, 0x00000006 },
{ 0x15, 0, 8, 0x00000011 },
{ 0x28, 0, 0, 0x00000014 },
{ 0x45, 6, 0, 0x00001fff },
{ 0xb1, 0, 0, 0x0000000e },
{ 0x48, 0, 0, 0x0000000e },
{ 0x15, 2, 0, 0x00000035 },
{ 0x48, 0, 0, 0x00000010 },
{ 0x15, 0, 1, 0x00000035 },
{ 0x6, 0, 0, 0x00040000 },
{ 0x6, 0, 0, 0x00000000 },
};

static struct sock_fprog bpf = {
.len = sizeof(filter_code)/(sizeof(struct sock_filter)),
.filter = filter_code,
};

int main(int argc, char *argv[]) {
int sock_fd, bytes;
char buf[4096];

struct sockaddr_ll addr;
struct iphdr *ip_header;

char src_addr[INET_ADDRSTRLEN], dst_addr[INET_ADDRSTRLEN];
char *iface_name;

if (argc != 2) {
printf("Usage: %s iface_name\n", argv[0]);
return 1;
}

// 1. create socket
sock_fd = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL));
if (sock_fd < 0) {
perror("socket created failure");
return 1;
}

iface_name = argv[1];
memset(&addr, 0, sizeof(addr));
addr.sll_ifindex = if_nametoindex(iface_name);
addr.sll_family = AF_PACKET;
addr.sll_protocol = htons(ETH_P_ALL);

// 2. attach filter (no need to call bind)
if (setsockopt(sock_fd, SOL_SOCKET, SO_ATTACH_FILTER, &bpf, sizeof(bpf)) < 0) {
perror("attaching filter failed");
return 2;
}

for (;;) {
bytes = recv(sock_fd, buf, sizeof(buf), 0);
if (bytes < 1) {
perror("received data failed");
return -1;
}

ip_header = (struct iphdr *) (buf + sizeof(struct ether_header));
inet_ntop(AF_INET, &ip_header->saddr, src_addr, sizeof(src_addr));
inet_ntop(AF_INET, &ip_header->daddr, dst_addr, sizeof(dst_addr));
printf("IPv%d proto=%d src=%s dest=%s\n",
ip_header->version, ip_header->protocol, src_addr, dst_addr);
}

return 0;
}

刚才说到在Linux 3.0版本中BPF加入了JIT; JIT能够提升伪机器码的执行效率. 从执行流程来说, 主要区别在于传入内核的socket_filter伪机器代码都会通过bpf_jit_compile进行优化处理, 然后再通过字节码解释器执行. 具体来说, 不同的平台bpf_jit_compile的实现不一样, 内核中的代码路径位于arch/<platform>/net/, 感兴趣的可以跳转到这里看下.

eBPF

eBPFextended BPF, 顾名思义是对原有BPF进行了扩展, 这样不仅BPF可以用来过滤网络数据包(tcpdump/XDP), 也可以用于性能分析, 将BPF代码插入到内核的跟踪点(tracepoints), kprobes, perf事件, 收集相应的数据.除了扩展了cBPF的功能外, eBPF另一个不同于cBPF的地方在于调用的方式: eBPF提供了一个统一的bpf()系统调用来执行相应的操作,同时应用与内核的数据传递也统一通过map这种数据结构来进行. 比如用BPF_MAP_CREATE命令来创建一个eBPF的map对象(不同的内核事件对应的map类型不同):

1
2

int bpf(int cmd, union bpf_attr *attr, unsigned int size)

我们可以通过man bpf来查看bpf系统调用的具体用法. eBPF对于内核的开发, 以及性能分析无疑是一大利器, 先通过具体的实例来看一看如何使用eBPF(Linux源码中有很多示例samples/bpf):

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66

static int test_sock(void)
{
int sock = -1, map_fd, prog_fd, i, key;
long long value = 0, tcp_cnt, udp_cnt, icmp_cnt;

// 创建一个bpf的array map用于保存结果
map_fd = bpf_create_map(BPF_MAP_TYPE_ARRAY, sizeof(key), sizeof(value), 256);
if (map_fd < 0) {
printf("failed to create map '%s'\n", strerror(errno));
goto cleanup;
}

// eBPF对应的伪机器码
struct bpf_insn prog[] = {
BPF_MOV64_REG(BPF_REG_6, BPF_REG_1),
BPF_LD_ABS(BPF_B, ETH_HLEN + offsetof(struct iphdr, protocol) /* R0 = ip->proto */),
BPF_STX_MEM(BPF_W, BPF_REG_10, BPF_REG_0, -4), /* *(u32 *)(fp - 4) = r0 */
BPF_MOV64_REG(BPF_REG_2, BPF_REG_10),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -4), /* r2 = fp - 4 */
BPF_LD_MAP_FD(BPF_REG_1, map_fd),
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem),
BPF_JMP_IMM(BPF_JEQ, BPF_REG_0, 0, 2),
BPF_MOV64_IMM(BPF_REG_1, 1), /* r1 = 1 */
BPF_RAW_INSN(BPF_STX | BPF_XADD | BPF_DW, BPF_REG_0, BPF_REG_1, 0, 0), /* xadd r0 += r1 */
BPF_MOV64_IMM(BPF_REG_0, 0), /* r0 = 0 */
BPF_EXIT_INSN(),
};

// 加载eBPF伪机器码到内核
prog_fd = bpf_prog_load(BPF_PROG_TYPE_SOCKET_FILTER, prog, sizeof(prog),
"GPL", 0);
if (prog_fd < 0) {
printf("failed to load prog '%s'\n", strerror(errno));
goto cleanup;
}

sock = open_raw_sock("lo");

// 将该eBPF的伪代码绑定到某个socket上
if (setsockopt(sock, SOL_SOCKET, SO_ATTACH_BPF, &prog_fd,
sizeof(prog_fd)) < 0) {
printf("setsockopt %s\n", strerror(errno));
goto cleanup;
}

// 查询过滤得到的结果
for (i = 0; i < 10; i++) {
key = IPPROTO_TCP;
assert(bpf_lookup_elem(map_fd, &key, &tcp_cnt) == 0);

key = IPPROTO_UDP;
assert(bpf_lookup_elem(map_fd, &key, &udp_cnt) == 0);

key = IPPROTO_ICMP;
assert(bpf_lookup_elem(map_fd, &key, &icmp_cnt) == 0);

printf("TCP %lld UDP %lld ICMP %lld packets\n",
tcp_cnt, udp_cnt, icmp_cnt);
sleep(1);
}

cleanup:
/* maps, programs, raw sockets will auto cleanup on process exit */
return 0;
}

可以看到,对于eBPF而言, 大概有如下几个执行步骤:

  • 编写ePBF伪机器代码
  • 调用bpf创建对应的map对象, 并将伪机器码加载到内核
  • 内核对加载得到伪机器码进行优化/校验, 验证其是否合法(是否有非法指令等)
  • 用户程序通过bpf的接口读取内核事件的结果

这个例子用的是类似汇编语言的方式来实现eBPF伪机器代码, 这个对于非专业的开发者来说实在有点痛苦. 那么, 能否通过其他高级语言来编写eBPF的代码了? 这就要用到专门的编译器LLVM(Lower Level Virtual Machine)了.通过LLVM, 我们只需要将需要执行的eBPF代码用C语言编写好后, 将其编译成elf格式的镜像(image)文件, 然后通过libelf库加载解析后, 装载到内核中执行. 如果去看samples/bpf中的其他示例, 都是通过类似的方式实现的.

了解了eBPF大致的执行流程, 我们一起来看下eBPF原理的框架简图(eBPF涉及的内核知识比较多, 刚开始建立一个整体的框架有助于理解):

eBPF architecture

BCC

即使有现成的编译工具LLVM, 有加载解析elf的库, 但对大部分开发者来说, 日常并没有太多时间精力一步步来开发这些eBPF工具与代码. 于是, 一些大牛们便搞出了一套BCC(BPF Compilation Collection)的eBPF工具集合, 这个工具集合把所有eBPF内核代码编写/编译以及错误处理的流程都封装好了, 使用时只需通过python/Lua等脚本语言进行调用,这里从BCC的示例中选出一个简单的例子, 可以看到BCC极大的简化了eBPF的开发与使用流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14

#!/usr/bin/python
# Copyright (c) PLUMgrid, Inc.
# Licensed under the Apache License, Version 2.0 (the "License")

# run in project examples directory with:
# sudo ./hello_world.py"
# see trace_fields.py for a longer example

from bcc import BPF

# 每当发生`clone`系统调用时, 该脚本都会打印`hello world`
# This may not work for 4.17 on x64, you need replace kprobe__sys_clone with kprobe____x64_sys_clone
BPF(text='int kprobe__sys_clone(void *ctx) { bpf_trace_printk("Hello, World!\\n"); return 0; }').trace_print()

如果Linux内核版本在4.4以上(如Ubuntu 16.04等), 可以通过如下指令安装BCC使用:

1
2
3
4
5

# echo "deb [trusted=yes] https://repo.iovisor.org/apt/xenial xenial-nightly main" | \
sudo tee /etc/apt/sources.list.d/iovisor.list
# sudo apt-get update
# sudo apt-get install bpfcc-tools # or the old package name: bcc-tools

相应的, BCC的工具会安装到/usr/share/bcc/tools下面.更多BCC功能的解锁说明可以参考Brendan D.Gregg性能优化大牛的eBPF介绍以及BCC的[官网]https://github.com/iovisor/bcc). 安装完成后, 无论是对CPU, 内存, 磁盘I/O还是网络数据的传输
都可以利用这一套工具进行监控了.

参考文献