#1201 xdp

搭建开发环境

使用 vagrant+centos8(centos8 内核版本 >=4.18.0-80,可以符合 xdp 程序开发的最低内核版本要求)。

https://en.wikipedia.org/wiki/CentOS#CentOS_version_8

主要的依赖有 libbpf、llvm,clang、libelf。llvm+clang 将 xdp c 程序编译为 bpf,存储在 ELF obj 文件中。libbpf 用来 load bpf 程序入内核并将 bpf 程序 attach 到内核的各种 hook 中去。

$ yum install clang llvm elfutils-libelf-devel libbpf bpftool

详细可以参见(centos 的参见其中的 fedora 部分即可): https://github.com/xdp-project/xdp-tutorial/blob/master/setup_dependencies.org#dependencies

第一个 xdp 程序

一个 xdp 程序一般包含两个部分:

  1. control plane 一个用户空间程序,一般以 _user.c 结尾,用来将 xdp bpf 程序加载从 elf 文件中加载入内核并且后续可以通过 bpf map 和其交互。

  2. data plane 加载入内核的 bpf 程序,这个一般以 _kern.c 结尾,使用 clang 和 llvm 编译为 bpf。

https://github.com/xdp-project/xdp-tutorial/tree/master/basic01-xdp-pass 为例(就是一个啥也不干,直接将包交给内核去处理的程序)。

data plane 的程序大致如下:

$ cat xdp_pass_kern.c
#include <linux/bpf.h>

#define SEC(NAME) __attribute__((section(NAME), used))

SEC("xdp")
int  xdp_prog_simple (struct xdp_md *ctx)
{
    return XDP_PASS;
}

将其编译为 obj 文件:

$ clang -O2 -target bpf -c xdp_pass_kern.c -o xdp_pass_kern.o

control plane xdp_pass_user.c 的代码比较长,但核心代码如下:

int prog_fd = -1;
struct bpf_object *obj;
bpf_prog_load ("xdp_pass_kern.o", BPF_PROG_TYPE_XDP, &obj, &prog_fd);
bpf_set_link_xdp_fd (ifindex, prog_fd, xdp_flags);

其中,ifindex 是要加载的网络接口的 index,这段代码主要就是调用 bpf_prog_load 加载 obj 文件中的 bpf 代码进内核,然后调用 bpf_set_link_xdp_fd 将这段代码挂载到网络接口的 hook 中去。

编译:

$ gcc -Wall -I../libbpf/src//build/usr/include/-g -I../headers/-L../libbpf/src/-o xdp_pass_user ../common//common_params.o \
xdp_pass_user.c -l:libbpf.a -lelf

执行:

$ ./xdp_pass_user -d eth1  --skb-mode
Success: Loading XDP prog name:xdp_prog_simple (id:24) on device:eth1 (ifindex:3)

查看:

$ ip link list dev eth1
3: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 xdpgeneric qdisc fq_codel state UP mode DEFAULT group default qlen 1000
    link/ether 08:00:27:82:92:cd brd ff:ff:ff:ff:ff:ff
    prog/xdp id 24 tag 3b185187f1855c4c jited

最后一行可以看到 xdp 程序被加载入网络接口的 hook 中了。

使用 ip 命令加载 xdp 程序

简单的 xdp 程序也可以使用 ip 命令直接加载,不用自己写加载程序。

加载:

$ ip link set dev eth1 xdp obj xdp_pass_kern.o sec xdp

如果已加载了 xdp 程序,可以使用下面命令强制替换现有 xdp 程序。

$ ip -force link set dev eth1 xdp obj xdp_pass_kern.o sec xdp

卸载:

$ ip link set dev eth1 xdp off

xdp 代码加载模式

xdp 代码有好几种加载的模式:skb/driver/hardware,性能上 skb < driver < hardware,但 driver 和 hardware 需要驱动和硬件的支持。

https://stackoverflow.com/questions/57171394/with-attach-xdp-does-flags-control-the-mode/57173029#57173029

查看网络接口的 driver

# ethtool -i eth1
driver: e1000
version: 7.3.21-k8-NAPI
firmware-version:
expansion-rom-version:
bus-info: 0000:00:08.0
supports-statistics: yes
supports-test: yes
supports-eeprom-access: yes
supports-register-dump: yes
supports-priv-flags: no

目前支持 xdp driver 的 driver list:

https://github.com/iovisor/bcc/blob/master/docs/kernel-versions.md#xdp

Map

这个应该可以用来存储 forwading table。

定义(在 _kernel.c 中定义一个全局变量):

新语法(会在程序的调试信息中带上 map 的信息,详细见后文 BTF 小节):

struct {
    __uint(type, BPF_MAP_TYPE_ARRAY);
    __type(key, __u32);
    __type(value, struct datarec);
    __uint(max_entries, XDP_ACTION_MAX);
} xdp_stats_map SEC(".maps");

旧语法:

struct bpf_map_def SEC ("maps") xdp_stats_map = {
    .type        = BPF_MAP_TYPE_ARRAY,
    .key_size    = sizeof (__u32),
    .value_size  = sizeof (struct datarec),
    .max_entries = XDP_ACTION_MAX,
};

在 bpf 程序中,直接调用 bpf 接口就可以操作 map 了:

rec = bpf_map_lookup_elem (&xdp_stats_map, &key);
lock_xadd (&rec->rx_packets, 1);

在用户态的程序中操作 map 需要先通过 map 名获取 map 的 fd,然后再通过 map fd 操作:

map =  bpf_object__find_map_by_name (bpf_obj, "xdp_stats_map");
map_fd = bpf_map__fd (map);
map_get_value_array (map_fd , key, &value)

bpf map 有很多种类型:https://github.com/torvalds/linux/blob/master/include/uapi/linux/bpf.h#:~:text=BPF_MAP_TYPE

Pinning Map

前述 bpf map 操作 中,bpf map 的操作需要 bpf 程序的 bpf_object 对象,通过其获取 bpf map 的 fd。bpf map 可以通过 pinning 操作将 map 和一个文件路径绑定在一起,这样要读取 map 直接从该文件路径获取 map 的 fd 就可以了。

Mount BPF 文件系统:

$ mount -t bpf bpf /sys/fs/bpf/

在 loader 程序中 pinning map:

// 清理
bpf_object__unpin_maps(bpf_obj, "/sys/fs/bpf/eth1");
bpf_object__pin_maps(bpf_obj, "/sys/fs/bpf/eth1/xdp_stats_map");

其它程序如果要操作 map,直接通过路径获取 map 的 fd 即可:

int stats_map_fd;
struct bpf_map_info info = { 0 };
stats_map_fd = open_bpf_map_file("/sys/fs/bpf/eth1/", "xdp_stats_map", &info);

函数内联、循环展开

bpf 对函数和循环的支持有限,所以 bpf 程序中

  • 如果使用子函数,需要在函数前面加上 __always_inline,让函数始终内联。

  • 如果有循环,需要在循环语句前加上 #pragma unroll,让循环展开。

而不是让编译器自己去做决定。

网络包解析

下面是一个简单的 IPv6 版本的 ICMP 处理程序示例,功能是丢掉 sequence number 为偶数的 icmp 包。

int  xdp_parser_func(struct xdp_md *ctx) {
    // [data, data_end) 为网络包数据
    void *data     = (void *)(long)ctx->data;
    void *data_end = (void *)(long)ctx->data_end;

    struct ethhdr *eth = data;
    if (eth + 1 > data_end)
        return XDP_ABORT;
    if (eth->h_proto != bpf_htons(ETH_P_IPV6))
        return XDP_DROP;

    struct ipv6hdr *ip6h = eth + 1;
    if (ip6h + 1 > data_end)
        return XDP_ABORT;
    if (ip6h->nexthdr != IPPROTO_ICMPV6)
        return XDP_DROP;

    struct icmp6hdr *icmp_hdr = ip6h + 1;
    if (bpf_ntohs(icmp_hdr->icmp6_sequence) % 2 != 0)
        return XDP_DROP;

    return XDP_PASS;
}

解析网络包第一步,包含定义 packet header 的各种头文件:

Struct

Header file

struct ethhdr

<linux/if_ether.h>

struct ipv6hdr

<linux/ipv6.h>

struct iphdr

<linux/ip.h>

struct icmp6hdr

<linux/icmpv6.h>

struct icmphdr

<linux/icmp.h>

第二步,bounds checking,在读取 *hdr 结构体中的字段时,首先需要检查 *hdr 结构体是否在 [data, data_end) 之间,如果不检查,程序后续加载的时候 bpf verifier 会报错。也就是上面代码中的 if (eth + 1 > data_end) ... 这样的判断,It’s necessary。

最后,网络包的数据是直接从网卡读到的数据,所以引用字段时要注意字节序,不要混用网络序和主机序的数据,必要时使用 bpf_htons/bpf_ntohs 之类的函数互相转换。


printf("%p\n", (void*)NULL + 1);
printf("%p\n", (int*)NULL + 1);

上面的代码运行的结果是:

0x1
0x4

指针加 1 的时候,如果是 void* 指针,指针是往后移一个字节,如果指针有类型,那么则是往后移 sizeof(类型) 个字节。所以上面所有 hdr 类型的 bounds checking 的时候都是使用相同的指针加 1 操作。

修改网络包

xdp 程序可以直接修改 [ctx->data, ctx->data_end] 之间的数据,并且可以通过 bpf_xdp_adjust_head 函数来扩大 / 缩小网络数据包的 buffer 的大小。

long bpf_xdp_adjust_head(struct xdp_buff *xdp_md, int delta)

xdp_md->data 指针移动 delta 个字节,delta 为正的时候是缩小网络包的 buffer(可以用在解包场景下),如果为负,相当于在 buffer 的前面新申请了一段 buffer 空间(可以用来封包),返回 0 成功或者一个负的错误码。

改包的内容后,比如修改了 IPv4 包的 header 之后,要更新其 checksum 字段。这个可以使用 bpf_csum_diff 来增量计算 checksum,而不是重新计算。

https://en.wikipedia.org/wiki/IPv4_header_checksum

使用 bpf_csum_diff 更新 checksum

修改网络包后很多时候需要更新相应的 checksum,一般可以增量修改,以修改一个 tcp 包的目的地址为例:

// ipv4 指向 iphdr 结构
// ipv6 指向 ipv6hdr 结构
// tcp 指向 tcphdr 结构

// 保存旧地址(增量计算 checksum 要用)
struct in6_addr old_daddr = ipv6->daddr;
// 修改目的地址为 1:2:3:4::
struct in6_addr new_daddr = {
    .in6_u.u6_addr32 = { bpf_htonl(0x00010002), bpf_htonl(0x00030004), 0, 0 }
};
ipv6->daddr = new_daddr;

// ipv6 的 ip 头没有 checksum,只需要更新 tcp 层的即可
// 网络包中的 checksum 字段保存的反码,所以需要先取反再进入计算
// bpf_csum_diff 计算返回的是一个 u32,但是 checksum 需要 u16,csum_fold 将 u32 转为 u16
tcp->check = csum_fold(bpf_csum_diff((__be32*)&old_daddr, sizeof(struct in6_addr), (__be32*)&ipv6->daddr, sizeof(struct in6_addr), ~tcp->check));

// ==== ipv6/ipv4 分割线 ====

// 保存旧地址(增量计算 checksum 要用)
__be32 old_daddr = ipv4->daddr;
// 修改目的地址
ipv4->daddr = 0x04030201;

// 调用 bpf_csum_diff 计算 checksum 差值
__u32 csum_diff = bpf_csum_diff(&old_daddr, sizeof(__be32), &ipv4->daddr, sizeof(__be32), 0);

// 更新 ip 和 tcp 层的 checksum
ipv4->check = csum_fold(csum_add(~ipv4->check, csum_diff));
tcp->check = csum_fold(csum_add(~tcp->check csum_diff));

// ==== helper 函数 ====

static __always_inline __u16 csum_fold(__u32 csum) {
    __u32 sum;
    sum = (csum >> 16) + (csum & 0xffff);
    sum += (sum >> 16);
    return ~sum;
}

static __always_inline __wsum csum_add(__wsum csum, __wsum addend)
{
        csum += addend;
        return csum + (csum < addend);
}

https://en.wikipedia.org/wiki/Internet_checksum#Calculating_the_IPv4_header_checksum

另外, bpf_csum_diff 也可以直接用来计算校验码:

ipv4->check = 0;
ipv4->check = csum_fold(bpf_csum_diff(0, 0, &ipv4, sizeof(ipv4), 0));

网络包重定向 Redirect

long bpf_redirect(u32 ifindex, u64 flags)

直接在处理函数的最后调用 return bpf_redirect(ifindex, 0) redirect 网络包。

Note

在调用之前需要修改目地 mac 地址为 ifindex 对应的网卡的 mac 地址,否则会失败。

long bpf_redirect_map(struct bpf_map *map, u32 key, u64 flags)

bpf_redirect_map 的使用稍微复杂一点,首先在 _kern.c 程序中:

// 定义一个 map 用于存储 key->ifindex 这个映射关系
struct {
    __uint(type, BPF_MAP_TYPE_DEVMAP);
    __uint(key_size, sizeof(int));
    __uint(value_size, sizeof(int));
    __uint(max_entries, 1);
} tx_port SEC(".maps");

// 然后在 xdp 程序中,引用该 map 和 key 来 redirect。
return bpf_redirect_map(&tx_port, 0, 0);

在 _user.c 程序中,设置转发的 ifindex。

int map_fd = bpf_obj_get(pinned_file);
int map_key = 0;
bpf_map_update_elem(map_fd, &map_key, &ifindex, 0);

linux 5.6 之前 bpf_redirect 的性能不如 bpf_redirect_map,https://github.com/xdp-project/xdp-tutorial/issues/104#issuecomment-591302134,另外 hard code 没法更新等问题,一般不要直接使用 bpf_redirect 。

Python BPF 操作库

iovisor/bcc 封装了一个 python BPF 操作库 https://github.com/iovisor/bcc

调试日志

bpf_printk 跟 printf 一个样

https://www.kernel.org/doc/html/latest/core-api/printk-formats.html

日志查看方式:

$ cat  /sys/kernel/debug/tracing/trace_pipe

5.19 内核之前,bpf_printk 不会自动在打印的日志后面追加 '\n',所以在老内核上使用 bpf_printk 的话,fmt 需要以 '\n' 结尾,否则 trace_pipe 中的日志就会变成就会变成巨长无比的一整行,没法看。

https://nakryiko.com/posts/bpf-tips-printk/

清除 trace buffer:

# echo > /sys/kernel/debug/tracing/trace

如何解决 BPF Verifier 报错

编译 bpf 的时候带上 -g 选项。

使用下面的命令可以 dump 出带源码注释的 BPF 字节码,结合错误信息可以大致定位错误的位置。

# llvm-objdump -S -no-show-raw-insn program.o
program.o: file format ELF64-BPF

Disassembly of section program_handle_egress:
program_handle_egress:
; {
    0:       r7 = r1
; {
    1:       r6 = 0
; void *data_end = (void *)(long)skb->data_end;
    2:       r2 = *(u32 *)(r7 + 80)
; void *data = (void *)(long)skb->data;
    3:       r1 = *(u32 *)(r7 + 76)
; if (data + sizeof(*eth) > data_end)
    4:       r3 = r1
    5:       r3 += 14
    6:       if r3 > r2 goto 570

如何查询某一个 BPF 挂载点支持的 bpf 函数

在每个挂载点的 verfier ops 结构中有一个 get_func_proto 字段,这个字段指向的函数里列出了这个挂载点可以调用的所有 bpf 函数。比如 xdp 这个挂载点可以调用的函数可以参见 xdp_func_proto 函数。

还有一个 is_valid_access 字段,通过这个函数可以找到传给 bpf 程序的上下文参数中哪些字段是可读写的,比如 xdp 挂载点可以读写的字段可以参见 xdp_is_valid_access

详细见: https://elixir.bootlin.com/linux/v5.9/source/net/core/filter.c#L8879

具体的挂载点可以从 bpf_attach_type 挂载点的宏定义顺着引用往下查:

https://elixir.bootlin.com/linux/v5.9/source/include/uapi/linux/bpf.h#L203

BPF 特性与需要的内核版本

简要版:

https://github.com/iovisor/bcc/blob/master/docs/kernel-versions.md

详细每个内核版本加了什么 BPF 特性可以见内核更新日志中的 bpf 小节:

https://kernelnewbies.org/LinuxVersions

各种版本的内核

可以在 elrepo archive 找到各种版本的内核:https://mirrors.tuna.tsinghua.edu.cn/elrepo/archive/kernel/

安装后使用 grub2-set-default 命令切换要使用的内核版本(menuentry 序号从 0 开始):

# grep -w menuentry /etc/grub2.cfg
menuentry 'CentOS Linux (6.4.4-1.el7.elrepo.x86_64) 7 (Core)' --class centos --class gnu-linux --class gnu --class os --unrestricted $menuentry_id_option 'gnulinux-3.10.0-1160.el7.x86_64-advanced-9cff3d69-3769-4ad9-8460-9c54050583f9' {
menuentry 'CentOS Linux (4.19.113-88.8bs.el7.x86_64) 7 (Core)' --class centos --class gnu-linux --class gnu --class os --unrestricted $menuentry_id_option 'gnulinux-3.10.0-1160.el7.x86_64-advanced-9cff3d69-3769-4ad9-8460-9c54050583f9' {
# grub2-set-default 0

其他类型的 BPF 程序及挂载点

https://docs.kernel.org/bpf/libbpf/program_types.html