借助libbpf/libxdp使用AF_XDP,我们都需要做什么——以一个简单程序为例

前言

AF_XDP是一种Linux提供的针对高性能数据包处理进行优化的地址族协议,为了进一步的理解和熟悉,我们以一个返回IPv4 ICMP Ping报文的程序为例,看看借助libbpf/libxdp使用AF_XDP,我们都需要做什么。例程不做复杂处理,就只是简单的交换源/目的IP、MAC,修改ICMP头部的type字段为ICMP_ECHOREPLY,最后重新计算ICMP头部校验和;

ICMP的校验和是要校验ICMP首部和数据部分的,当时没注意这点导致程序卡了半天……

涉及两部分程序:

  • XDP程序,需要挂载到我们使用的网卡接口上;(注意别用本地的lo接口,虽然试过可以加载SKB模式的XDP程序,但是容易出乱子)
  • 用户程序,用于创建umem、xsk、处理各个ring收发包、处理数据包等;

先说XDP程序

XDP程序其实比较简单了,甚至更简单一点,我们可以直接用libbpf/libxdp[^libbpf和libxdp]提供的默认XDP程序;

libbpf/libxdp提供的xsk_socket__create函数,如果给其提供的配置参数struct xsk_socket_config *为空或者将其中的libbpf_flags / libxdp_flags置为0,就会默认加载一个简单的XDP程序到所给出的接口,把所有配置的queue_id对应的网卡队列收到的包都redirect到该xsk;

如果将其中的libbpf_flags / libxdp_flags置为XSK_LIBBPF_FLAGS__INHIBIT_PROG_LOADXSK_LIBXDP_FLAGS__INHIBIT_PROG_LOAD,就可以禁止默认XDP程序的加载,后边就得自己写程序自己加载和更新xsks_map映射了,但这样能让程序更可控一些;

我们还是自己写一个XDP程序,过滤出IPv4 ICMP报文,将其redirect到XSK,其余数据包则是通过XDP_PASS给内核去继续处理;顺便通过ELF约定格式,预定义BPF_MAP_TYPE_XSKMAP类型的xsks_map,等会儿用户程序创建好xsk了需要更新[queue_idx, xsk_fd]键值对到该映射中;

给出程序:

/* SPDX-License-Identifier: GPL-2.0 */

#include <linux/bpf.h>
#include <linux/if_ether.h>
#include <linux/ip.h>
#include <linux/in.h>

#include <bpf/bpf_endian.h>
#include <bpf/bpf_helpers.h>

struct {
	__uint(type, BPF_MAP_TYPE_XSKMAP);
	__uint(max_entries, 64);
	__type(key, int);
	__type(value, int);
} xsks_map SEC(".maps");

SEC("xdp")
int xdp_prog(struct xdp_md *ctx)
{
	__u32 off;
	void *data_end = (void *)(long)ctx->data_end;
	void *data = (void *)(long)ctx->data;

	struct ethhdr *eth = data;
	struct iphdr *ip = data + sizeof(*eth);

	off = sizeof(struct ethhdr);
	if (data + off > data_end) // To pass verifier
		return XDP_PASS;

	if (bpf_htons(eth->h_proto) == ETH_P_IP) {

        off += sizeof(struct iphdr);
		if (data + off > data_end) // To pass verifier
			return XDP_PASS;
		/* We redirect IPv4 ICMP pkts only. */
		if (ip->protocol == IPPROTO_ICMP) {
			int idx = ctx->rx_queue_index;
            /* 如果idx对应网卡队列已绑定xsk并更新到了xsks_map中,数据包就会被redirect到该xsk */
			if (bpf_map_lookup_elem(&xsks_map, &idx)) {
				return bpf_redirect_map(&xsks_map, idx, 0);
			}
		}
	}
	return XDP_PASS;
}

char _license[] SEC("license") = "GPL";

再看用户程序

既然是redirect到用户空间,主要工作都在用户空间,包括创建umem、xsk、处理各个ring收发包、处理数据包等。

好在有libbpf/libxdp的帮助我们能让事情变得简单点。

我的例程按顺序分以下几个步骤:

  • 创建和配置UMEM、XSK

  • 加载挂载XDP程序到对应的网络接口上(预定义的xsks_map也会在这一步创建)

  • 将我们要绑定的queue_id和得到的xsk_fd更新到xsks_map

  • 收包、处理包、发包

  • 还有就是可以另外创建个线程用于打印收发包状态,我们就忽略了;

创建和配置UMEM

一句话总结就是:

分配和对齐umem所需的内存,通过libxdp提供的xsk_umem__create创建umem。

UMEM是用户空间存放数据包的地方,是预申请好的连续的一整块内存,其中每个元素我们称作一个chunk;

libxdp虽然提供了UMEM创建函数,umem的内存分配和对齐什么的还是得自己做;

关于xsk_umem__create函数自己写了一个描述:

/*!
 * @fn int xsk_umem__create(
 *				struct xsk_umem **umem,
 *				void *umem_area,
 *				__u64 size,
 *				struct xsk_ring_prod *fill,
 *				struct xsk_ring_cons *comp,
 * 				const struct xsk_umem_config *config);
 *
 * @brief   创建umem,XSK收到的包和待发送的包都会放到umem中
 *
 * @param[out] *umem: 我理解算是一个umem的opaque handle,并不是给你用结构体具体内容的
 * @param[in] *umem_area: 提前分配好的umem内存的起始地址
 * @param[in] size: umem大小(Byte),即umem中元素大小乘元素个数
 * @param[out] *fill: fill ring
 * @param[out] *comp: completion ring
 * @param[in] *config: umem创建相关的配置,传入NULL以使用默认配置
 *
 * @retval 0    Success
 * @retval else Fail
 */

这其中的struct xsk_umem_config

struct xsk_umem_config {
	__u32 fill_size; // FILL Ring的大小(元素个数), 默认2048,也就是默认UMEM总元素个数的一半
	__u32 comp_size; // COMP Ring的大小(元素个数),默认2048,也就是默认UMEM总元素个数的一半
	__u32 frame_size; // UMEM数据帧大小(Byte),即UMEM中每个元素(我们叫chunk)的大小,默认4096B
	__u32 frame_headroom; // (Byte),如果设置了,那么报文数据将不是从每个chunk的起始地址开始存储,而是要预留出headroom大小的内存,再开始存储报文数据,headroom在隧道网络中比较常见,方便封装外层头部;默认0B
	__u32 flags; // 不知道作甚的,默认0
};

创建和配置XSK

一句话总结就是:

通过libxdp提供的创建XSK的函数:xsk_socket__create,创建xsk。

想多个xsk共用一个umem的话还有这个函数:xsk_socket__create_shared,这里不涉及;

关于xsk_socket__create函数自己写了一个描述:

/*!
 * @fn int int xsk_socket__create(
 *				  struct xsk_socket **xsk,
 *				  const char *ifname, 
 *   			  __u32 queue_id,
 *				  struct xsk_umem *umem,
 *				  struct xsk_ring_cons *rx,
 *				  struct xsk_ring_prod *tx,
 *				  const struct xsk_socket_config *config);
 *
 * @brief   创建xsk
 *
 * @param[out] *xsk: 我理解算是一个xsk的opaque handle,并不是给你用结构体具体内容的
 * @param[in]  ifname: 待绑定的接口名称
 * @param[in]  queue_id: 待绑定的接口队列id
 * @param[in]  *umem: 刚才创建umem得到的指针
 * @param[out] *rx: RX Ring
 * @param[out] *tx: TX Ring
 * @param[in]  *config: xsk创建相关的配置,传入NULL以使用默认配置
 *
 * @retval 0    Success
 * @retval else Fail
 */

此线内为帮助理解的附件:

我们知道,XSK的操作涉及四个Ring:RX、TX、Fill和Completion Ring;都是单生产者单消费者(SCSP)模型,需要内核或者用户程序先生产了,对方才能消费;

Fill Ring来说,用户程序作为生产者,得先生产一些元素,我们统一叫desc,这些desc指向umem中的某个chunk,这样内核才能作为消费者,拿走这些desc,找到desc指向的umem的chunk,再把收到的数据包放到该chunk中,示意图:
在这里插入图片描述
Rx Ring来说:内核存放完数据以后,会往RX Ring生产对应的desc,指向刚才放好数据的chunk;用户程序即可消费RX Ring中的desc,进一步直接处理desc指向的chunk中存放的数据包;

TX和Completion Ring同理;

Ring中存放的元素具体是什么?

FILL Ring和Completion Ring中存放的元素是地址,即相对于umem起始地址buffer的字节偏移量(completion ring这个等我确认一下先);

libxdp有专门的两个帮助函数用于访问Fill Ring和Completion Ring某个下标idx处的地址的函数:

__u64 *xsk_ring_prod__fill_addr(struct xsk_ring_prod *fill, __u32 idx);
const __u64 * xsk_ring_cons__comp_addr(const struct xsk_ring_cons *comp, __u32 idx);

而RX Ring和TX Ring其中存放的desc元素,是struct xdp_desc,具体结构:

struct xdp_desc{
	__u64 addr;		// 该desc指向的地址,其实是相对于umem起始地址buffer的字节偏移量
    __u32 len;		// 该desc中数据的长度(Byte)
    __u32 options;	// 其他选项,不知道能有啥
}

libxdp还有专门的两个帮助函数用于应用程序访问RX Ring和TX Ring某个下标idx处的xdp_desc指针:

struct xdp_desc *xsk_ring_prod__tx_desc(struct xsk_ring_prod *tx, __u32 idx);
const struct xdp_desc *xsk_ring_cons__rx_desc(const struct xsk_ring_cons *rx, __u32 idx);

Ring的访问方式

各个Ring是通过下标访问的,虽然更多是叫生产者/消费者指针;看Ring的结构:

#define DEFINE_XSK_RING(name) \
struct name { \
	__u32 cached_prod; \	// 缓存的生产者下标,意思是,接下来要生产的话,从这个下标开始生产
	__u32 cached_cons; \	// 缓存的消费者下标,意思是,接下来要开始消费的话,从这个下标开始消费
	__u32 mask; \			// 掩码的作用,与它相与就可以得到0~(size-1)之间的值,mask值就是size - 1
	__u32 size; \			// ring大小(desc元素个数)
	__u32 *producer; \		// 指向实时的生产者下标
	__u32 *consumer; \		// 指向实时的消费者下标
	void *ring; \			// ring指向起始desc元素
	__u32 *flags; \			// 
}

DEFINE_XSK_RING(xsk_ring_prod);
DEFINE_XSK_RING(xsk_ring_cons);

将下标与mask(值为size - 1)相与&,就能保证随着idx的增加,访问效果像是在访问一个环;

descs[idx & mask]

创建完XSK,我们再填充FILL Ring,即向FILL Ring中生产desc,本例中是直接把FILL Ring填满了;

填充的大体步骤为:

  1. 通过xsk_ring_prod__reserve(fill, nb, idx)向FILL Ring申请预留nb个desc空间,得到起始下标idx,准备填充;

    这些函数细节其实不需要知道,但由于涉及cached_prodprod的关系,我还是把我的理解放在这;

    size_t xsk_ring_prod__reserve(struct xsk_ring_prod *prod, size_t nb, __u32 *idx)
    

    预留nb个desc空间,首先是查询当前ring是不是有把当前申请的这么多desc空间,不够的话返回0,够的话将cached_prod赋值给要返回的idx,告诉程序从这个idx开始生产desc,随后cached_prod += nb,返回nb

  2. 填充,即填充对应的地址(相对于umem起始地址的字节偏移量)到FILL Ring中,从xsk_ring_prod__reserve得到的idx开始填;

  3. 填充完毕以后,通过xsk_ring_prod__submit(fill, nb)提交nb个desc,实时的生产者下标producer会后移nb

    XDP_ALWAYS_INLINE void xsk_ring_prod__submit(struct xsk_ring_prod *prod, __u32 nb) {
    	/* Make sure everything has been written to the ring before indicating
    	 * this to the kernel by writing the producer pointer.
    	 */
    	__atomic_store_n(prod->producer, *prod->producer + nb, __ATOMIC_RELEASE);
    }
    

    提交以后,实时的producer才会后移,这里的操作是原子性的,我也不理解为什么这都需要原子性操作,明明一个xsk只有一个producer指针,而且应该只有一个线程在操作它吧;

加载挂载XDP程序到对应的网络接口上

我们直接复用xdp-tools的xdp-loader,免得自己再写加载的部分,怪麻烦的;

将我们要绑定的queue_id和得到的xsk_fd更新到xsks_map

由于没有用libxdp提供的XDP程序和xsks_map映射,我们需要自己去更新映射;

其中key为指定绑定的queue_id,value为xsk的fd,可以通过libxdp提供的帮助函数xsk_socket__fdstruct xsk_socket *xsk指针中拿到;

int xsk_fd = xsk_socket__fd(xsk);
err = bpf_map_update_elem(xsks_map_fd, &opt->xsk_if_queue, &xsk_fd, BPF_ANY);

收包、处理包、发包

和传统的recv收包不同,AF_XDP收包需要看RX Ring,另外也可以通过poll等方式去等待xsk收包;

libxdp中的函数:

size_t xsk_ring_cons__peek(struct xsk_ring_cons *cons, size_t nb, __u32 *idx)

就是用于应用程序从ring中消费的,(函数命名都很规范,看名称就知道是干啥的),peek意为窥视、窥探,也就是探一下cons ring中有没有nb个空闲元素可供消费,没有的话返回0,有的话返回空闲元素个数,最多不超过nb(相当于批处理最多nb个元素);

大致流程:

  • 内核收到包,消费FILL Ring中的umem地址,将数据包存放到对应地址中,再作为生产者将对应的desc填充到RX Ring中;

  • 随后xsk_ring_cons__peek发现有了一些空闲desc可供消费处理,于是返回可供处理的空闲desc个数,以及起始desc的消费者指针(下标)idx

  • 这之后就可以用libxdp提供的函数:xsk_ring_cons__rx_desc拿到对应下标的desc,进一步取到desc中的地址addr和数据长度len,再进一步可以通过libxdp提供的帮助函数:void *xsk_umem__get_data(void *umem_area, __u64 addr)拿到具体的data指针,后边就随你解析和操作了,具体的操作也就是:交换源目的IP和MAC,修改ICMP头部的type为reply,重新计算ICMP的校验和;

    void *xsk_umem__get_data(void *umem_area, __u64 addr)中,umem_area是分配给umem的起始地址,后面的addr是desc中的addr,即相对于umem起始地址的字节偏移量,emm不如直接放原函数:

    XDP_ALWAYS_INLINE void *xsk_umem__get_data(void *umem_area, __u64 addr)
    {
    return &((char *)umem_area)[addr];
    }
    
  • 处理完以后,我们就需要通过TX Ring通知内核发送数据包了,过程和前面填充FILL Ring非常类似:

    • 首先通过libxdp提供的帮助函数xsk_ring_prod__reserve(&xsk->tx, nb, &idx),从TX Ring中申请预留nb个desc;
    • 随后填充这些desc*(其实就是用我们前面收到数据包的地址和长度填充,毕竟我们处理完数据包长度和地址都没变)*;
    • 最后通过libxdp提供的帮助函数xsk_ring_prod__submit(&xsk->tx, nb),提交nb个desc给TX Ring;
    • 内核看到TX Ring有空闲后就会消费掉其中的desc(指把desc对应umem中的数据包发送出去);发送完毕后将用完的地址生产到Completion Ring中,应用再消费这些地址(指把这些地址再放回Fill Ring中,继续下一波循环……);

总结

核心部分其实就是在四个Ring之间来回生产和消费,理解清楚这部分以后,AF_XDP用起来就得心应手了;再次从总体上总结一下几个Ring之间的循环:

  • FILL Ring | 用户生产

    把空闲的umem地址放到FILL Ring中;

  • FILL Ring | 内核消费

    将收到的包放到FILL Ring中空闲的umem地址;

  • RX Ring | 内核生产

    收包完毕后的desc放入RX Ring中;

  • RX Ring | 用户消费

    取收包完毕后的desc,处理数据包;

    如果无需发送,这个desc的地址到这就已经没用了,可以再生产给FILL Ring了;

  • TX Ring | 用户生产

    将准备好的desc放到TX Ring中;

    如果只涉及发送,还需要把desc对应umem中的frame写好;

  • TX Ring | 内核消费

    内核将准备好的desc拿走,将对应umem中的数据包发送出去;

  • Comp Ring | 内核生产

    内核发送完毕后,将desc中的地址放到Comp Ring中;

  • Comp Ring | 用户消费

    用户拿到Comp Ring中的地址,即得知该地址已经空闲了,可以继续填到FILL Ring或者继续填到TX Ring了

  • ……

还是想做个动图,假设我们的数据包速度足够慢,可以让这个循环一个个数据包地完美运行:

请添加图片描述
注:为了简便,每个umem chunk的大小假定为1,以及图示和例程中操作空闲chunk的方式不太一样,但对四个Ring的操作是类似的,具体以程序为准。

  • 9
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 5
    评论
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

WuPeng_uin

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值