前言
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_LOAD
或XSK_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填满了;
填充的大体步骤为:
-
通过
xsk_ring_prod__reserve(fill, nb, idx)
向FILL Ring申请预留nb
个desc空间,得到起始下标idx
,准备填充;这些函数细节其实不需要知道,但由于涉及
cached_prod
和prod
的关系,我还是把我的理解放在这;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
; -
填充,即填充对应的地址(相对于umem起始地址的字节偏移量)到FILL Ring中,从
xsk_ring_prod__reserve
得到的idx
开始填; -
填充完毕以后,通过
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__fd
从struct 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中,继续下一波循环……);
- 首先通过libxdp提供的帮助函数
总结
核心部分其实就是在四个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的操作是类似的,具体以程序为准。