Switch-Router

Dive into eBPF (2): 将虚拟机程序载入内核

Published at 2019-12-01 | Last Update 2019-12-01

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 关联起来,最后将文件描述符返回给用户空间。

如此,虚拟机机器码便被灌入了内核….

(本文完)