Dive into eBPF (2): 将虚拟机程序载入内核
在 Dive into eBPF (1) 中,介绍了 BPF 的来源。在本文中,我们将开始 eBPF 的旅程。
extended BPF
eBPF (extended BPF),是 Linux 内核对 BPF 的扩展。为了与传统的 BPF 更好地区别,传统的 BPF 现在被命名为 cBPF (classical BPF)。eBPF 与 cBPF 相比,有以下两个重要的改进:
- 首先, eBPF 的功能更加丰富。除了具有 cBPF 传统的报文过滤功能外,eBPF 大范围扩展了使用场景,比如性能测试、程序运行轨迹最终等。这同样是通过在内核通过 HOOK 点完成的,不同的 HOOK 点对应不同类型的 BPF 程序类型。随着内核版本的演进,内核支持的 BPF 程序也越来越多,这个类型定义在 /include/uapi/linux/bpf.h.
enum bpf_prog_type {
BPF_PROG_TYPE_UNSPEC,
BPF_PROG_TYPE_SOCKET_FILTER,
BPF_PROG_TYPE_KPROBE,
BPF_PROG_TYPE_SCHED_CLS,
BPF_PROG_TYPE_SCHED_ACT,
BPF_PROG_TYPE_TRACEPOINT,
BPF_PROG_TYPE_XDP,
BPF_PROG_TYPE_PERF_EVENT,
BPF_PROG_TYPE_CGROUP_SKB,
BPF_PROG_TYPE_CGROUP_SOCK,
BPF_PROG_TYPE_LWT_IN,
BPF_PROG_TYPE_LWT_OUT,
BPF_PROG_TYPE_LWT_XMIT,
BPF_PROG_TYPE_SOCK_OPS,
BPF_PROG_TYPE_SK_SKB,
BPF_PROG_TYPE_CGROUP_DEVICE,
};
其中, BPF_PROG_TYPE_SOCKET_FILTER 就是传统的报文过滤器类型,而其他类型则是 eBPF 相对于 cBPF 新增的程序类型
- 其次, eBPF 更加易为用户使用。在 cBPF 中,内核接收的是裸的虚拟机机器码,虽然像 tcpdump 这样的程序会借助 libpcap 将过滤表达式翻译为机器码,但这仅限于 tcpdump。绝大部分机器码都需要用户自己提供,虽然内核提供了 bpftool 工具可以将汇编格式(如在Dive into eBPF (1)中使用 tcpdump -d 显示的格式)转换为机器码,但这依然不方便。在 eBPF 中,用户可以用 C 语言编写最后需要在内核空间运行的代码,clang 编译器会将需要灌入到内核的代码编译成 .o 文件(机器码包含于其中),之后用户可以通过编写用户空间程序,载入 .o 文件,完成内核空间程序的灌注。
run eBFP sample
没有比先让一个 bpf 程序 run 起来更能重要的事了。所幸,内核提供了 sample,可以让我们快速体验 eBPF。eBPF 既支持在内核源码树编译,也支持使用 bcc 脱离源码树编译。这里,我正好有 linux 4.15 内核的源代码,因此我选择基于内核源码树树编译.
在内核代码的 samples/bpf 目录,有很多现成的例子,这里我们选择 sockex1。在此目录下执行 make 就可以编译所有例子,为了跳过一些依赖项,这里我修改了 Makefile,注释掉了除 sockex1 之外的其他例子。
...
# hostprogs-y += sock_example
# hostprogs-y += fds_example
hostprogs-y += sockex1
# hostprogs-y += sockex2
# hostprogs-y += sockex3
...
sockex1-objs := bpf_load.o $(LIBBPF) sockex1_user.o
# sockex2-objs := bpf_load.o $(LIBBPF) sockex2_user.o
# sockex3-objs := bpf_load.o $(LIBBPF) sockex3_user.o
...
always += sockex1_kern.o
# always += sockex2_kern.o
# always += sockex3_kern.o
...
与 sockex1 有关的文件是 sockex1_kern.c 和 sockex1_user.c , 前者编译生成 sockex1_kern.o , 后者编译生成可执行程序 sockex1. 执行 sockex1, 就可以观察到 bash 持续地输出:
root@yc:/usr/src/linux-source-4.15.0/samples/bpf # ./sockex1
TCP 0 UDP 0 ICMP 0 bytes
TCP 0 UDP 0 ICMP 196 bytes
TCP 0 UDP 0 ICMP 392 bytes
TCP 0 UDP 0 ICMP 588 bytes
TCP 0 UDP 0 ICMP 784 bytes
^C
以上就是 sockex1 例子的运行结果。那么:
- 内核空间的程序是什么样的,它应该有什么样的参数和返回值?
- 用户空间的程序是如何灌注程序到内核空间的 ?
sockex1_kern.c
从 sockex1_kern.c 的名字就能看出来,它就是被灌入内核空间的程序
#include <uapi/linux/if_ether.h>
#include <uapi/linux/if_packet.h>
#include <uapi/linux/ip.h>
#include "bpf_helpers.h"
struct bpf_map_def SEC("maps") my_map = {
.type = BPF_MAP_TYPE_ARRAY,
.key_size = sizeof(u32),
.value_size = sizeof(long),
.max_entries = 256,
};
SEC("socket1")
int bpf_prog1(struct __sk_buff *skb)
{
int index = load_byte(skb, ETH_HLEN + offsetof(struct iphdr, protocol));
long *value;
if (skb->pkt_type != PACKET_OUTGOING)
return 0;
value = bpf_map_lookup_elem(&my_map, &index);
if (value)
__sync_fetch_and_add(value, skb->len);
return 0;
}
char _license[] SEC("license") = "GPL";
代码很短,只有一个 my_map 数据结构和 bpf_prog1 函数。其中 my_map 是一个 K-V 存储空间(今后会谈到),而 bpf_prog1 就是我们在内核执行的程序片段,它的入参是报文 skb。这个函数完成了以下功能:
- load_byte 从报文的 IP 首部提取出 1 个字节的 protocol ,比如对于 TCP 就是 6,对于 UDP 就是 17,对于 ICMP 就是 1
- 从存储结构 my_map 读取出 key 为 index 的值,然后将这个值增加报文长度大小
所以,这个代码片段的功能就很明显了:统计各个协议报文的数据量。
sockex1_user.c
再来看用户空间的程序,并将主要的代码贴在这里,并且加了注释
int main(int ac, char **argv)
{
char filename[256];
FILE *f;
int i, sock;
snprintf(filename, sizeof(filename), "%s_kern.o", argv[0]);
/* 装载文件 sockex1_kern.o */
if (load_bpf_file(filename)) {
printf("%s", bpf_log_buf);
return 1;
}
/* 创建一个 socket, bind 到环回口设备 */
sock = open_raw_sock("lo");
/* 设置 socket 的 SO_ATTACH_BPF 选项,传入 prog_fd */
assert(setsockopt(sock, SOL_SOCKET, SO_ATTACH_BPF, prog_fd,
sizeof(prog_fd[0])) == 0);
/* 启动一个子进程执行 ping 命令 */
f = popen("ping -c5 localhost", "r");
(void) f;
/* 循环读取 map_fd[0] 对应存储区域的各个协议类型对应的统计计数并显示 */
for (i = 0; i < 5; i++) {
long long tcp_cnt, udp_cnt, icmp_cnt;
int key;
key = IPPROTO_TCP;
assert(bpf_map_lookup_elem(map_fd[0], &key, &tcp_cnt) == 0);
key = IPPROTO_UDP;
assert(bpf_map_lookup_elem(map_fd[0], &key, &udp_cnt) == 0);
key = IPPROTO_ICMP;
assert(bpf_map_lookup_elem(map_fd[0], &key, &icmp_cnt) == 0);
printf("TCP %lld UDP %lld ICMP %lld bytes\n",
tcp_cnt, udp_cnt, icmp_cnt);
sleep(1);
}
return 0;
}
看到这里,应该就很明显了,一方面内核空间 sockex1_kern.c 在存储,另一方面 sockex1_user.c 在不断读取。那么这衍生出以下两个问题:
- Q1: load_bpf_file 的入参 sockex1_user.o 是如何转换成虚拟机机器码灌入内核的 ?
- Q2: 内核代码何时执行,执行的上下文是什么?
- Q3: 用户空间和内核空间的程序是如何通过 map 进行通信的 ?
用户空间load .o 文件
用户空间程序需要加载 .o 文件到内核,但显然这里的 .o 绝不应该是随随便便的一个 .o 文件。我们来看看 load_bpf_file 做了什么
load_bpf_file
|
|- do_load_bpf_file
int do_load_bpf_file(const char *path, fixup_map_cb fixup_map)
{
fd = open(path, O_RDONLY, 0);
......
/* load programs */
for (i = 1; i < ehdr.e_shnum; i++) {
......
if (memcmp(shname, "kprobe/", 7) == 0 ||
memcmp(shname, "kretprobe/", 10) == 0 ||
memcmp(shname, "tracepoint/", 11) == 0 ||
memcmp(shname, "xdp", 3) == 0 ||
memcmp(shname, "perf_event", 10) == 0 ||
memcmp(shname, "socket", 6) == 0 ||
memcmp(shname, "cgroup/", 7) == 0 ||
memcmp(shname, "sockops", 7) == 0 ||
memcmp(shname, "sk_skb", 6) == 0) {
ret = load_and_attach(shname, data->d_buf,
data->d_size);
if (ret != 0)
goto done;
}
}
}
do_load_bpf_file 会将输入的 .o 文件作为 ELF 格式文件的逐个 section 进行分析,如 section 的名字是特殊的(比如 ‘socket’),那么就会将这个 section 的内容作为 load_and_attach 的参数。上面的代码片段列举了当前版本内核 eBPF 关心的 section 的名字的前缀,这也对应了该版本内核所有支持的 eBPF 程序的类型。我们熟悉的报文过滤器正是对应 ‘socket’ 这项。
回过头来再看看 sockex1_kern.c 中,函数 bpf_prog1 正是被 SEC(“socket1”) 修饰,它表示该函数会被编译到名为 socket1 的 section.
#define SEC(NAME) __attribute__((section(NAME), used))
这点我们可以用 objdump 得到验证, 这里 socket1 的内容就已经是 eBPF 虚拟机机器码了
root@yc:/usr/src/linux-source-4-15.0/samples/bpf# objdump -s sockex1_kern.o
sockex1_kern.o file format elf64-little
Contens of section socket1:
0000 bf160000 00000000 30000000 17000000 ........0.......
0010 630afcff 00000000 61610400 00000000 c.......aa......
0020 55010800 04000000 bfa20000 00000000 U...............
0030 07020000 fcffffff 18010000 00000000 ................
.....
接下来是 load_and_attach,这里会再调用 bpf_load_program, 填入的参数为程序类型 prog_type, 和虚拟机指令 insns_cnt 等
static int load_and_attach(const char *event, struct bpf_insn *prog, int size)
{
bool is_socket = strncmp(event, "socket", 6) == 0;
......
if (is_socket) {
prog_type = BPF_PROG_TYPE_SOCKET_FILTER;
}
......
fd = bpf_load_program(prog_type, prog, insns_cnt, license, kern_version,
bpf_log_buf, BPF_LOG_BUF_SIZE);
而 bpf_load_program 就到达用户空间与内核空间的边界了, 会通过 BPF_PROG_LOAD 系统调用,将需要的信息打包灌入内核,返回一个文件描述符 fd
bpf_load_program
|
|-- bpf_load_program_name
int bpf_load_program_name(enum bpf_prog_type type, const char *name,
const struct bpf_insn *insns,
size_t insns_cnt, const char *license,
__u32 kern_version, char *log_buf,
size_t log_buf_sz)
{
int fd;
union bpf_attr attr;
__u32 name_len = name ? strlen(name) : 0;
bzero(&attr, sizeof(attr));
attr.prog_type = type;
attr.insn_cnt = (__u32)insns_cnt;
attr.insns = ptr_to_u64(insns);
attr.license = ptr_to_u64(license);
attr.log_buf = ptr_to_u64(NULL);
attr.log_size = 0;
attr.log_level = 0;
attr.kern_version = kern_version;
memcpy(attr.prog_name, name, min(name_len, BPF_OBJ_NAME_LEN - 1));
fd = sys_bpf(BPF_PROG_LOAD, &attr, sizeof(attr));
if (fd >= 0 || !log_buf || !log_buf_sz)
return fd;
......
}
内核空间 load BPF 指令
SYSCALL_DEFINE3(bpf, int, cmd, union bpf_attr __user *, uattr, unsigned int, size)
{
......
case BPF_PROG_LOAD:
err = bpf_prog_load(&attr);
}
static int bpf_prog_load(union bpf_attr *attr)
{
struct bpf_prog *prog;
......
/* 分配内核 bpf_prog 程序数据结构空间 */
prog = bpf_prog_alloc(bpf_prog_size(attr->insn_cnt), GFP_USER);
.....
/* 将 bpf 虚拟机指令从用户空间拷贝到内核空间 */
copy_from_user(prog->insns, u64_to_user_ptr(attr->insns), bpf_prog_insn_size(prog));
.....
/* 分配一个 fd 与 prog 关联,最终这个 fd 将返回用户空间 */
err = bpf_prog_new_fd(prog);
.....
return err;
}
用户空间通过系统调用陷入内核后,内核也会分配相应的数据结构 struct bpf_prog,并从用户空间拷贝虚拟机指令。然后分配一个文件系统的 inode 节点,将它与 bpf_prog 关联起来,最后将文件描述符返回给用户空间。
如此,虚拟机机器码便被灌入了内核….
(本文完)