1. 项目概述:AF_XDP 到底解决什么问题碰上 AF_XDP 这个技术点的人,多半是已经在 Linux 上做高性能包处理,或者被 DPDK 的运维成本折腾过之后,想在内核生态里找个折中方案。AF_XDP 不是一个框架,而是 Linux 内核提供的地址族套接口,配合 eBPF 把数据包从网卡驱动直接送到用户态进程,中间不经过完整的内核网络协议栈。它解决的是“既要用户态处理、又不想完全绕开内核”这类需求。先说清楚它和传统套接字的差别。普通 socket 收包要走软中断、协议栈、协议分层,这个链路里每一层都有拷贝和上下文切换,单核处理高性能流量很容易撞到天花板。AF_XDP 的思路是:驱动收到包之后,通过 XDP 程序决定哪些包交给用户态,把包描述符(不是整包拷贝)塞进一块内核和用户态共享的内存区域,用户态直接读这块内存。驱动在零拷贝模式下直接把 DMA 出来的内存区域映射给用户态,连数据拷贝都省了。这个方案适合谁?想在高并发下做负载均衡、流量审计、入侵检测、自定义网关的人。它比 DPDK 学习曲线平缓得多,毕竟不需要接管网卡驱动,也照样能用内核协议栈处理剩下的流量。比如同一个口子上,一部分流量进 AF_XDP 做深度分析,另一部分继续走内核转发,一条命令就能切换,这种灵活性是 DPDK 很难给的。Ubuntu 24.04.3 选做开发环境很合理。它自带的内核主线是 6.8 系列,AF_XDP 从 4.18 引入以来,到 6.x 已经迭代了四五个大版本,UMEM 管理、ring 队列行为、libbpf 的封装都稳定了。用 24.04.3 还有一个好处:工具链默认的 clang、llvm、libbpf 版本对 BPF CO-RE 和 AF_XDP 常用接口支持完整,不需要自己折腾老版本的编译器兼容问题。2. 环境准备:Ubuntu 24.04.3 的工具链与内核能力确认2.1 内核和驱动的“前置门槛”万事开头先确认两件事:内核编译选项有没有开 XDP socket,网卡驱动要不要支持 AF_XDP 加速。Ubuntu 24.04.3 的发行内核默认开了CONFIG_XDP_SOCKETS,正常不需要动内核,但严谨点还是检查一下:# 查看运行内核版本 uname -r # 检查内核配置,发行版一般有 /boot/config-$(uname -r) grep -E "CONFIG_XDP_SOCKETS|CONFIG_BPF_SYSCALL|CONFIG_XDP_SOCKETS_DIAG" /boot/config-$(uname -r)能看到几个=y就是正常的。有个容易忽略的点:如果跑在云主机或者某些容器环境,宿主机内核未必开放这些能力,最好是在物理机或者能控制内核参数的虚拟机里做。单开一个内核选项理论上能重新编译,实际没人这么干,开发期直接选发行版默认内核省心。第二件事看网卡驱动。AF_XDP 的零拷贝模式需要驱动内置ndo_xdp_xmit和 XDP_REDIRECT 支持。目前主流驱动的支持已经很广,常见的有mlx5、i40e、ixgbe、ice、nfp、qede、virtio_net、veth这些。怎么看自己的网卡:ethtool -i eth0如果驱动不在支持列表里也不用慌。内核有“通用 XDP 模式”,任何驱动都能跑 XDP,代价是收包路径仍会走 SKB,AF_XDP 的零拷贝优势就没了,性能大概是驱动原生模式的三分之一到一半。开发调试阶段用 veth 或者通用模式先把逻辑跑通,完全可行。2.2 依赖包安装清单Ubuntu 24.04.3 下装依赖,我用的是这一套:sudo apt update sudo apt install -y build-essential clang llvm libelf-dev libbpf-dev \ linux-tools-common linux-tools-generic linux-headers-$(uname -r) \ pkg-config libnuma-dev逐个解释为什么需要。build-essential和make不用多说,编译用户态代码的底座。clang和llvm是编译 BPF 字节码的,内核里的 GCC 虽然也能编部分 BPF,但主流工具链和 libbpf 的宏都偏向 clang,没必要给自己挖坑。libbpf-dev是最关键的,AF_XDP 用户态接口xsk.h就是 libbpf 提供的。linux-tools-generic装完会带来bpftool和perf,不过 Ubuntu 有个坑,bpftool可执行文件埋在/usr/lib/linux-tools/6.8.0-xx-generic/下面,PATH 里未必直接找得到。想省事可以手动做个软链接:sudo ln -sf /usr/lib/linux-tools/$(uname -r)/bpftool /usr/local/bin/bpftoollibnuma-dev是给 xdpsock 这类示例程序用的,它有 NUMA 相关的可选参数,缺了它也编不过某些 sample。内核头文件包linux-headers-$(uname -r)主要用来做内核模块和部分 sample 编译,提前装上省得后面缺文件。2.3 BPF 文件系统与基础验证还要确认 BPF 文件系统挂载了,bpftool加载程序、pin map 都依赖它。Ubuntu 默认一般挂载在/sys/fs/bpf:mount | grep bpf如果没挂,手动挂一下:sudo mount -t bpf bpffs /sys/fs/bpf环境准备完之后可以做个快速烟雾测试。写一个空的 XDP 程序编译一下,确认编译器工具链是通的。新建test_xdp.c:#include <linux/bpf.h> #include <bpf/bpf_helpers.h> SEC("xdp") int xdp_pass(struct xdp_md *ctx) { return XDP_PASS; } char LICENSE[] SEC("license") = "GPL";编译命令:clang -O2 -g -Wall -target bpf -c test_xdp.c -o test_xdp.o能出.o文件,说明 BPF 编译链路没问题。之后用bpftool prog load test_xdp.o /sys/fs/bpf/test_xdp或者ip link set dev eth0 xdp obj test_xdp.o都能加载。这一步不用真正连网卡,只是验证工具链。3. 核心概念拆解:UMEM、描述符环和 XDP 程序3.1 UMEM 就是一块用户态和内核共享的“货仓”AF_XDP 里最核心的抽象是 UMEM(unmapped memory)。你可以把 UMEM 理解成一块用户态分配、内核也能访问的大仓库。仓库被切成很多个固定大小的 chunk,网卡收到一个包,驱动就把包数据放进其中一个 chunk,用户态程序从这个 chunk 里读。用户态要发数据包,也是先把包写进一个 chunk,让驱动从里面拿。代码里的形态是一块连续虚拟内存:void *buf; size_t umem_size = NUM_FRAMES * FRAME_SIZE; posix_memalign(&buf, getpagesize(), umem_size);有几个参数绕不开:chunk_size决定每个数据单元多大,必须大于等于请求的frame_size,而且通常要求是 2 的幂。frame_size的底线是你的 MTU 整包大小,以太网缺省 1500,加上 L2 头、VLAN 头,一般会设成 2048;如果跑巨型帧,就至少 4096。frame_headroom是留给 XDP 程序做头部预留给用户态的,类似 skb 的 headroom 概念。UMEM 分配好之后,再创建两个描述符环:fill ring 和 completion ring。这两个环属于 UMEM 这一层,前者是用户态告诉内核“我有哪些 chunk 可以拿来收包”,后者是内核把用过的 chunk 归还给用户态。这块内存为什么关键?因为零拷贝模式下的所有数据流动都在 UMEM 里发生。你如果不理解 UMEM 的 chunk 生命周期,后面调试丢包的时候会非常痛苦。我自己的口头禅是:UMEM 是水,环是水管,XDP map 是阀门。3.2 四个环形队列,别搞混方向整个 AF_XDP socket 一共四个 ring,不只是两个。除了 UMEM 层的 fill 和 completion,还有 socket 层的 RX 和 TX。拿数据接收方向来看这条线的完整流程:用户态把 UMEM 里的空闲 chunk 地址填充到 fill ring。内核从 fill ring 拿一个 chunk 地址,网卡 DMA 写入数据后,把xdp_desc(包含 chunk 地址和长度)放到 RX ring。用户态从 RX ring 读取描述符,根据地址和长度直接访问 UMEM 里的包数据。用户态处理完,把这个 chunk 地址重新填入 fill ring,等待内核下一次使用。发送方向类似:用户态把待发送包写入 UMEM 的某个 chunk,把描述符放到 TX ring,内核取走并交给网卡发送,发送完成后把描述符放进 completion ring 归还给用户态。方向感特别重要。经常看到新手在 fill ring 上纠结:为什么我的程序收不到包?回头一看 fill ring 是空的,内核根本没有可用的 chunk 去承接网卡数据,驱动只能丢包。所以收包循环里,消费完 RX ring 之后必须把 chunk 还回 fill ring,否则 UMEM 的可用 chunk 越用越少,最终必然丢包。描述符本身也有讲究。libbpf 里的struct xdp_desc有addr、len、options三个字段,options一般用XDP_PKT_CONTD,表示一个包跨了多个 chunk。现实中零拷贝模式下驱动会把整包放连续 chunk 里,跨 chunk 的情况大部分出现在 copy 模式或者特殊驱动里,开发初期可以先不管,但读到不认识的options时别直接按单 chunk 去解析。3.3 XDP 程序如何把包“引流”到 AF_XDP socket有了 UMEM 和环,还差最后一步:怎么让网卡驱动知道“哪些包发给这个 socket”?答案是一个 XDP 程序和一个 XSKMAP。XSKMAP 是 BPF map 的一种,key 是网卡的队列编号,value 是 AF_XDP socket 的文件描述符。XDP 程序里调用bpf_redirect_map,把包重定向到当前队列对应的 socket 上:struct { __uint(type, BPF_MAP_TYPE_XSKMAP); __uint(max_entries, 256); __type(key, __u32); __type(value, __u32); } xsks_map SEC(".maps"); SEC("xdp") int xsk_redirect_prog(struct xdp_md *ctx) { return bpf_redirect_map(&xsks_map, ctx->rx_queue_index, XDP_PASS); }关键就是ctx->rx_queue_index:网卡每个 RX queue 对应一个 socket。为什么非得按队列分?因为 AF_XDP socket 创建时绑定一个队列 id,多队列场景下每个队列一个 socket,性能才能线性扩展。负载均衡用的 RSS 哈希会在硬件层把不同流分到不同队列,每个队列的 XSKMAP 值和 socket 要一一对应。XDP 程序返回XDP_PASS做兜底:如果当前队列没有绑定 socket(比如 map 里没这个 key),包就继续走内核协议栈。这个设计特别好用,等于给你一个“部分流进入用户态,其他流走内核”的通道开关。4. 实操:从零跑通一个最小 AF_XDP 示例4.1 快速验证:用内核自带的 xdpsock最省事的冒烟测试是用内核自带的xdpsock示例。它在内核源码树的samples/bpf/目录,编译方式有两种。第一种是直接从内核源码里编。如果你有对应版本的内核源码:cd linux-src/samples/bpf make xdpsock因为 sample 依赖内核的构建系统,如果缺配置,建议执行一次make olddefconfig后编译。另一种更轻量的方式是从tools/lib/bpf或发行版 libbpf 头文件入手,手动编译xdpsock_user.c,这个文件依赖的bpf_util.h也在 sample 目录里,属于“抄现成代码”的路子。跑起来之后大概是这个形态:sudo ./xdpsock -i eth0 -q 0 -z -N参数含义:-i指定网卡,-q指定队列,-z强制零拷贝模式,-N打开 busy poll。正常会打印 recevied packet 的计数。另一边用ping或者iperf3打点流量进去,就能看到计数在涨。跑不起来的常见表现也说明一下。比如报UMEM creation failed,大概率是frame_size和网卡 MTU 不匹配,xdpsock可以加-f 4096调大 frame。报Error: bind failed,先查驱动是否支持 AF_XDP,用ethtool -S eth0 | grep xdp看看有没有 xdp 相关统计项。这个阶段的目标只是验证环境,细节后面再抠。4.2 写一个最小的 AF_XDP 接收程序xdpsock 跑通只说明系统层面 OK,真要写业务代码还得理解用户态怎么和内核交互。下面是个最小接收程序的骨架,代码本身不含业务处理逻辑,只做收包和打印。首先是创建 UMEM 和 socket:struct xsk_umem *umem; struct xsk_socket *xsk; struct xsk_ring_cons rx; struct xsk_ring_prod fill; struct xsk_umem_config ucfg = { .frame_size = FRAME_SIZE, .frame_headroom = 0, }; // 分配 UMEM 内存 void *base; posix_memalign(&base, getpagesize(), NUM_FRAMES * FRAME_SIZE); // 创建 UMEM 和它的两个环 xsk_umem__create(&umem, base, NUM_FRAMES * FRAME_SIZE, &fill, &comp, &ucfg); // 创建 AF_XDP socket struct xsk_socket_config scfg = { .rx_size = RING_SIZE, .tx_size = RING_SIZE, .bind_flags = XSK_ZEROCOPY, }; xsk_socket__create(&xsk, ifname, queue_id, umem, &rx, &tx, &scfg);然后往 fill ring 塞满空闲 chunk,让内核有地方放包:for (int i = 0; i < RING_SIZE; i++) { *xsk_ring_prod__fill_addr(&fill, idx++) = i * FRAME_SIZE; } xsk_ring_prod__submit(&fill, RING_SIZE);主循环是收包-处理-归还的无限循环,其中归还 chunk 那步非常容易漏:while (1) { // 从 RX ring 拿一个包 unsigned int rcvd = xsk_ring_cons__peek(&rx, 1, &idx); if (!rcvd) continue; struct xdp_desc *desc = xsk_ring_cons__rx_desc(&rx, idx); char *pkt = xsk_umem__get_data(umem, desc->addr); // 这里做你的业务处理,比如解析 IP 头 printf("rcv pkt len=%u\n", desc->len); // 消费完这一项 xsk_ring_cons__release(&rx, 1); // 把这个 chunk 还回 fill ring,否则 UMEM 内存会被耗尽 unsigned int f_idx; if (xsk_ring_prod__reserve(&fill, 1, &f_idx) == 1) { *xsk_ring_prod__fill_addr(&fill, f_idx) = desc->addr; xsk_ring_prod__submit(&fill, 1); } }有个细节:零拷贝模式下,如果 fill ring 长时间为空,内核可能因为缺少可用缓冲区直接丢包,不会通知你。有的驱动需要用户态显式唤醒 netdev,代码里经常出现这样的片段:if (xsk_ring_prod__needs_wakeup(&fill)) { sendto(xsk_socket__fd(xsk), NULL, 0, MSG_DONTWAIT, NULL, 0); }它本质是告诉内核去调xsk_net_xmit之类的路径,把之前挂起的 RX 处理重新拉起来。这个调用开销很小,但少了它某些驱动在低流量或 sleep 状态时会一直不喂包。4.3 编译、加载与联调用户态代码编译:gcc -O2 -Wall -o xsk_recv xsk_recv.c -lbpf -lpthreadXDP 程序编译成 BPF 字节码:clang -O2 -g -Wall -target bpf -c xsk_prog.c -o xsk_prog.o加载 XDP 程序到网卡,两种方式都行:# 用 iproute2 sudo ip -force link set dev eth0 xdp obj xsk_prog.o sec xdp # 或者用 bpftool sudo bpftool prog load xsk_prog.o /sys/fs/bpf/xsk_prog sudo bpftool net attach xdp pinned /sys/fs/bpf/xsk_prog dev eth0想卸载 XDP 程序:sudo ip link set dev eth0 xdp off联调的时候建议先用ping产生最基础的流量,确认 RX ring 里有计数后有 IP 头可解析,再上iperf3或pktgen加压验证性能。中间如果发现收不到包,优先去看 fill ring 有没有被掏空,其次检查 XSKMAP 的 key 是不是对应上了网卡的 RX queue id。多队列网卡即使你只监听 queue 0,发过来的流量也可能被 RSS 分到其他队列,这种情况要么绑定队列,要么用ethtool -L把队列合并成单队列方便调试。5. 常见问题与排查技巧实录AF_XDP 并不是装完就能顺利跑通的技术,我把自己踩过和帮人排查过的坑整理成一个速查表:现象大概率原因处理方式bind 返回 Operation not permitted缺少 CAP_NET_RAW / CAP_NET_ADMIN 权限用 root 跑,或者让程序 setcap 后以普通用户执行UMEM create 返回 EINVALchunk/frame 大小不是 2 的幂,或小于 MTU+包头调大 frame_size,推荐 2048 起步,巨型帧用 4096创建 socket 报 EOPNOTSUPP驱动不支持 XDP,或该接口跑在 generic 模式查ethtool -i驱动名,换 veth/受支持驱动做开发一直收不到包但 ethtool 计数正常fill ring 没填满,或 XSKMAP key 和队列 id 对不上初始化时把整个 ring 填满;确认ctx->rx_queue_index映射收包能通但吞吐上不去正在走 copy 模式而不是 zero-copy看xdpsock是否落到零拷贝路径,用ethtool -S观察 xdp 命中网卡显示 rx_missed 增长NAPI 处理不过来,用户态消费太慢调大 ring 深度,开启 busy poll,绑定 CPU启动时报 RX ring 初始化失败ring 数量不是 2 的幂,或超出驱动上限检查 ring depth 参数,默认 1024/2048 通常安全BPF 程序加载报 Cannot allocate memory旧内核 memlock 限制确认 rlimit 设置,24.04 上改RLIMIT_MEMLOCK或直接用 root几个值得展开说的点。权限问题最隐蔽。AF_XDP socket 创建需要CAP_NET_RAW,bind 到网卡、更新 XSKMAP 需要CAP_NET_ADMIN。root 用户当然没烦恼,但开发环境经常用 systemd-run、容器、CI 用户,忘了加 capabilities 就会出现“编译全过、运行就挂”的诡异情况。排查命令就一条:capsh --print看看当前 shell 的权限集合。GRO/LRO 是另一个高频坑。LRO 会把多个包合并成一个大 SKB,但 XDP 是在驱动层早期处理的,逻辑上不应该受影响。现实中某些驱动的 XDP 路径和 LRO 合并逻辑共存时会出问题,特别是 AF_XDP 零拷贝模式下,合并后的包没法按原始帧送回用户态。稳妥做法是关掉 LRO:sudo ethtool -K eth0 lro off sudo ethtool -K eth0 gro off开发验证阶段全程关着,跑通后再按需开回去做 AB 对比。还有一个很隐蔽的问题:net.core.busy_read 和 busy_poll 系统参数。开了 busy poll 之后,AF_XDP 的 RX 路径会在用户态循环里主动轮询 NAPI,延迟能降不少,但 CPU 占用会明显上升。xdpsock -N就是干这个的。生产环境要按包的量级来权衡,没有统一答案。我一般先把sysctl net.core.busy_read=50和一个队列的 CPU 亲和钉在一起测试,再把参数调大观察性价比。6. 性能调优与实操心得环境跑通之后,想把性能从“能跑”推到“能打”,通常要动四板斧:队列、CPU、批量、模式。队列维度。一个网卡多队列,最好一个队列对应一个 AF_XDP socket 和一个处理线程,线程绑核。RSS 哈希把不同的流散到不同队列,天然就是分布式处理。队列和 CPU 的绑定用ethtool -L、ethtool -X配合irqbalance的关闭操作,别让系统自动迁移中断,否则你在用户态做 CPU 亲和,中断却在另一个核上乱跳,性能就浪费了。CPU 维度。推荐taskset把用户态进程钉在中断处理核的同一个 NUMA 节点上。单个队列的场景最理想是:硬中断在 CPU 2,NAPI 在 CPU 2,xdpsock 线程也在 CPU 2 附近,减少跨 NUMA 访问 UMEM 内存的开销。UMEM 是用户态内存,跨 NUMA 访问会拉高内存延迟,这对零拷贝场景特别致命。批量维度。一次处理一个包是正确性优先的写法,性能场景要改成一次 peek 多个描述符、循环处理完再一次 release,再把 chunk 批量归还 fill ring。描述符是连续数组,批量处理能摊薄内存屏障和内核唤醒的开销。我在自己的程序里习惯一个批次取 64 个包,吞吐和延迟之间最均衡。模式维度。确保走的是零拷贝而不是通用 XDP。有个简单判断:xdpsock输出 flags 里会出现 copy 而不是 zero-copy,或者接口的 XDP 统计里 generic 命中在涨而 native 不涨。通用 XDP 因为要走 SKB 路径,AF_XDP 最大的零拷贝优势没了,性能面板直接难看很多。调优过程中我个人的体会是:别一上来就盯网卡吞吐,先把单队列单进程的基线拉出来,再逐步扩队列、加线程。AF_XDP 的复杂度大部分在数据路径的“归还”逻辑上,填满 fill ring、及时消费 RX ring、及时归还 chunk,这三个动作稳定了,性能就差不到哪里去。很多人遇到瓶颈第一反应是怀疑框架,实际上多半是用户态消费速度跟不上,或者 chunk 归还不及时导致驱动缺缓冲区。最后再分享一个小技巧:开发期用 veth 做端到端测试非常香。veth 天然支持 XDP 通用模式,你可以不插物理网卡就在虚拟机里把 UMEM、ring、XSKMAP 的整个数据路径调通。等逻辑没有问题,再挪到物理网卡验证零拷贝模式,环境差异带来的变量会少很多。这个顺序能帮你省下大量“驱动没反应还是代码有 bug”的排查时间。
1. 项目概述:AF_XDP 到底解决什么问题碰上 AF_XDP 这个技术点的人,多半是已经在 Linux 上做高性能包处理,或者被 DPDK 的运维成本折腾过之后,想在内核生态里找个折中方案。AF_XDP 不是一个框架,而是 Linux 内核提供的地址族套接口,配合 eBPF 把数据包从网卡驱动直接送到用户态进程,中间不经过完整的内核网络协议栈。它解决的是“既要用户态处理、又不想完全绕开内核”这类需求。先说清楚它和传统套接字的差别。普通 socket 收包要走软中断、协议栈、协议分层,这个链路里每一层都有拷贝和上下文切换,单核处理高性能流量很容易撞到天花板。AF_XDP 的思路是:驱动收到包之后,通过 XDP 程序决定哪些包交给用户态,把包描述符(不是整包拷贝)塞进一块内核和用户态共享的内存区域,用户态直接读这块内存。驱动在零拷贝模式下直接把 DMA 出来的内存区域映射给用户态,连数据拷贝都省了。这个方案适合谁?想在高并发下做负载均衡、流量审计、入侵检测、自定义网关的人。它比 DPDK 学习曲线平缓得多,毕竟不需要接管网卡驱动,也照样能用内核协议栈处理剩下的流量。比如同一个口子上,一部分流量进 AF_XDP 做深度分析,另一部分继续走内核转发,一条命令就能切换,这种灵活性是 DPDK 很难给的。Ubuntu 24.04.3 选做开发环境很合理。它自带的内核主线是 6.8 系列,AF_XDP 从 4.18 引入以来,到 6.x 已经迭代了四五个大版本,UMEM 管理、ring 队列行为、libbpf 的封装都稳定了。用 24.04.3 还有一个好处:工具链默认的 clang、llvm、libbpf 版本对 BPF CO-RE 和 AF_XDP 常用接口支持完整,不需要自己折腾老版本的编译器兼容问题。2. 环境准备:Ubuntu 24.04.3 的工具链与内核能力确认2.1 内核和驱动的“前置门槛”万事开头先确认两件事:内核编译选项有没有开 XDP socket,网卡驱动要不要支持 AF_XDP 加速。Ubuntu 24.04.3 的发行内核默认开了CONFIG_XDP_SOCKETS,正常不需要动内核,但严谨点还是检查一下:# 查看运行内核版本 uname -r # 检查内核配置,发行版一般有 /boot/config-$(uname -r) grep -E "CONFIG_XDP_SOCKETS|CONFIG_BPF_SYSCALL|CONFIG_XDP_SOCKETS_DIAG" /boot/config-$(uname -r)能看到几个=y就是正常的。有个容易忽略的点:如果跑在云主机或者某些容器环境,宿主机内核未必开放这些能力,最好是在物理机或者能控制内核参数的虚拟机里做。单开一个内核选项理论上能重新编译,实际没人这么干,开发期直接选发行版默认内核省心。第二件事看网卡驱动。AF_XDP 的零拷贝模式需要驱动内置ndo_xdp_xmit和 XDP_REDIRECT 支持。目前主流驱动的支持已经很广,常见的有mlx5、i40e、ixgbe、ice、nfp、qede、virtio_net、veth这些。怎么看自己的网卡:ethtool -i eth0如果驱动不在支持列表里也不用慌。内核有“通用 XDP 模式”,任何驱动都能跑 XDP,代价是收包路径仍会走 SKB,AF_XDP 的零拷贝优势就没了,性能大概是驱动原生模式的三分之一到一半。开发调试阶段用 veth 或者通用模式先把逻辑跑通,完全可行。2.2 依赖包安装清单Ubuntu 24.04.3 下装依赖,我用的是这一套:sudo apt update sudo apt install -y build-essential clang llvm libelf-dev libbpf-dev \ linux-tools-common linux-tools-generic linux-headers-$(uname -r) \ pkg-config libnuma-dev逐个解释为什么需要。build-essential和make不用多说,编译用户态代码的底座。clang和llvm是编译 BPF 字节码的,内核里的 GCC 虽然也能编部分 BPF,但主流工具链和 libbpf 的宏都偏向 clang,没必要给自己挖坑。libbpf-dev是最关键的,AF_XDP 用户态接口xsk.h就是 libbpf 提供的。linux-tools-generic装完会带来bpftool和perf,不过 Ubuntu 有个坑,bpftool可执行文件埋在/usr/lib/linux-tools/6.8.0-xx-generic/下面,PATH 里未必直接找得到。想省事可以手动做个软链接:sudo ln -sf /usr/lib/linux-tools/$(uname -r)/bpftool /usr/local/bin/bpftoollibnuma-dev是给 xdpsock 这类示例程序用的,它有 NUMA 相关的可选参数,缺了它也编不过某些 sample。内核头文件包linux-headers-$(uname -r)主要用来做内核模块和部分 sample 编译,提前装上省得后面缺文件。2.3 BPF 文件系统与基础验证还要确认 BPF 文件系统挂载了,bpftool加载程序、pin map 都依赖它。Ubuntu 默认一般挂载在/sys/fs/bpf:mount | grep bpf如果没挂,手动挂一下:sudo mount -t bpf bpffs /sys/fs/bpf环境准备完之后可以做个快速烟雾测试。写一个空的 XDP 程序编译一下,确认编译器工具链是通的。新建test_xdp.c:#include <linux/bpf.h> #include <bpf/bpf_helpers.h> SEC("xdp") int xdp_pass(struct xdp_md *ctx) { return XDP_PASS; } char LICENSE[] SEC("license") = "GPL";编译命令:clang -O2 -g -Wall -target bpf -c test_xdp.c -o test_xdp.o能出.o文件,说明 BPF 编译链路没问题。之后用bpftool prog load test_xdp.o /sys/fs/bpf/test_xdp或者ip link set dev eth0 xdp obj test_xdp.o都能加载。这一步不用真正连网卡,只是验证工具链。3. 核心概念拆解:UMEM、描述符环和 XDP 程序3.1 UMEM 就是一块用户态和内核共享的“货仓”AF_XDP 里最核心的抽象是 UMEM(unmapped memory)。你可以把 UMEM 理解成一块用户态分配、内核也能访问的大仓库。仓库被切成很多个固定大小的 chunk,网卡收到一个包,驱动就把包数据放进其中一个 chunk,用户态程序从这个 chunk 里读。用户态要发数据包,也是先把包写进一个 chunk,让驱动从里面拿。代码里的形态是一块连续虚拟内存:void *buf; size_t umem_size = NUM_FRAMES * FRAME_SIZE; posix_memalign(&buf, getpagesize(), umem_size);有几个参数绕不开:chunk_size决定每个数据单元多大,必须大于等于请求的frame_size,而且通常要求是 2 的幂。frame_size的底线是你的 MTU 整包大小,以太网缺省 1500,加上 L2 头、VLAN 头,一般会设成 2048;如果跑巨型帧,就至少 4096。frame_headroom是留给 XDP 程序做头部预留给用户态的,类似 skb 的 headroom 概念。UMEM 分配好之后,再创建两个描述符环:fill ring 和 completion ring。这两个环属于 UMEM 这一层,前者是用户态告诉内核“我有哪些 chunk 可以拿来收包”,后者是内核把用过的 chunk 归还给用户态。这块内存为什么关键?因为零拷贝模式下的所有数据流动都在 UMEM 里发生。你如果不理解 UMEM 的 chunk 生命周期,后面调试丢包的时候会非常痛苦。我自己的口头禅是:UMEM 是水,环是水管,XDP map 是阀门。3.2 四个环形队列,别搞混方向整个 AF_XDP socket 一共四个 ring,不只是两个。除了 UMEM 层的 fill 和 completion,还有 socket 层的 RX 和 TX。拿数据接收方向来看这条线的完整流程:用户态把 UMEM 里的空闲 chunk 地址填充到 fill ring。内核从 fill ring 拿一个 chunk 地址,网卡 DMA 写入数据后,把xdp_desc(包含 chunk 地址和长度)放到 RX ring。用户态从 RX ring 读取描述符,根据地址和长度直接访问 UMEM 里的包数据。用户态处理完,把这个 chunk 地址重新填入 fill ring,等待内核下一次使用。发送方向类似:用户态把待发送包写入 UMEM 的某个 chunk,把描述符放到 TX ring,内核取走并交给网卡发送,发送完成后把描述符放进 completion ring 归还给用户态。方向感特别重要。经常看到新手在 fill ring 上纠结:为什么我的程序收不到包?回头一看 fill ring 是空的,内核根本没有可用的 chunk 去承接网卡数据,驱动只能丢包。所以收包循环里,消费完 RX ring 之后必须把 chunk 还回 fill ring,否则 UMEM 的可用 chunk 越用越少,最终必然丢包。描述符本身也有讲究。libbpf 里的struct xdp_desc有addr、len、options三个字段,options一般用XDP_PKT_CONTD,表示一个包跨了多个 chunk。现实中零拷贝模式下驱动会把整包放连续 chunk 里,跨 chunk 的情况大部分出现在 copy 模式或者特殊驱动里,开发初期可以先不管,但读到不认识的options时别直接按单 chunk 去解析。3.3 XDP 程序如何把包“引流”到 AF_XDP socket有了 UMEM 和环,还差最后一步:怎么让网卡驱动知道“哪些包发给这个 socket”?答案是一个 XDP 程序和一个 XSKMAP。XSKMAP 是 BPF map 的一种,key 是网卡的队列编号,value 是 AF_XDP socket 的文件描述符。XDP 程序里调用bpf_redirect_map,把包重定向到当前队列对应的 socket 上:struct { __uint(type, BPF_MAP_TYPE_XSKMAP); __uint(max_entries, 256); __type(key, __u32); __type(value, __u32); } xsks_map SEC(".maps"); SEC("xdp") int xsk_redirect_prog(struct xdp_md *ctx) { return bpf_redirect_map(&xsks_map, ctx->rx_queue_index, XDP_PASS); }关键就是ctx->rx_queue_index:网卡每个 RX queue 对应一个 socket。为什么非得按队列分?因为 AF_XDP socket 创建时绑定一个队列 id,多队列场景下每个队列一个 socket,性能才能线性扩展。负载均衡用的 RSS 哈希会在硬件层把不同流分到不同队列,每个队列的 XSKMAP 值和 socket 要一一对应。XDP 程序返回XDP_PASS做兜底:如果当前队列没有绑定 socket(比如 map 里没这个 key),包就继续走内核协议栈。这个设计特别好用,等于给你一个“部分流进入用户态,其他流走内核”的通道开关。4. 实操:从零跑通一个最小 AF_XDP 示例4.1 快速验证:用内核自带的 xdpsock最省事的冒烟测试是用内核自带的xdpsock示例。它在内核源码树的samples/bpf/目录,编译方式有两种。第一种是直接从内核源码里编。如果你有对应版本的内核源码:cd linux-src/samples/bpf make xdpsock因为 sample 依赖内核的构建系统,如果缺配置,建议执行一次make olddefconfig后编译。另一种更轻量的方式是从tools/lib/bpf或发行版 libbpf 头文件入手,手动编译xdpsock_user.c,这个文件依赖的bpf_util.h也在 sample 目录里,属于“抄现成代码”的路子。跑起来之后大概是这个形态:sudo ./xdpsock -i eth0 -q 0 -z -N参数含义:-i指定网卡,-q指定队列,-z强制零拷贝模式,-N打开 busy poll。正常会打印 recevied packet 的计数。另一边用ping或者iperf3打点流量进去,就能看到计数在涨。跑不起来的常见表现也说明一下。比如报UMEM creation failed,大概率是frame_size和网卡 MTU 不匹配,xdpsock可以加-f 4096调大 frame。报Error: bind failed,先查驱动是否支持 AF_XDP,用ethtool -S eth0 | grep xdp看看有没有 xdp 相关统计项。这个阶段的目标只是验证环境,细节后面再抠。4.2 写一个最小的 AF_XDP 接收程序xdpsock 跑通只说明系统层面 OK,真要写业务代码还得理解用户态怎么和内核交互。下面是个最小接收程序的骨架,代码本身不含业务处理逻辑,只做收包和打印。首先是创建 UMEM 和 socket:struct xsk_umem *umem; struct xsk_socket *xsk; struct xsk_ring_cons rx; struct xsk_ring_prod fill; struct xsk_umem_config ucfg = { .frame_size = FRAME_SIZE, .frame_headroom = 0, }; // 分配 UMEM 内存 void *base; posix_memalign(&base, getpagesize(), NUM_FRAMES * FRAME_SIZE); // 创建 UMEM 和它的两个环 xsk_umem__create(&umem, base, NUM_FRAMES * FRAME_SIZE, &fill, &comp, &ucfg); // 创建 AF_XDP socket struct xsk_socket_config scfg = { .rx_size = RING_SIZE, .tx_size = RING_SIZE, .bind_flags = XSK_ZEROCOPY, }; xsk_socket__create(&xsk, ifname, queue_id, umem, &rx, &tx, &scfg);然后往 fill ring 塞满空闲 chunk,让内核有地方放包:for (int i = 0; i < RING_SIZE; i++) { *xsk_ring_prod__fill_addr(&fill, idx++) = i * FRAME_SIZE; } xsk_ring_prod__submit(&fill, RING_SIZE);主循环是收包-处理-归还的无限循环,其中归还 chunk 那步非常容易漏:while (1) { // 从 RX ring 拿一个包 unsigned int rcvd = xsk_ring_cons__peek(&rx, 1, &idx); if (!rcvd) continue; struct xdp_desc *desc = xsk_ring_cons__rx_desc(&rx, idx); char *pkt = xsk_umem__get_data(umem, desc->addr); // 这里做你的业务处理,比如解析 IP 头 printf("rcv pkt len=%u\n", desc->len); // 消费完这一项 xsk_ring_cons__release(&rx, 1); // 把这个 chunk 还回 fill ring,否则 UMEM 内存会被耗尽 unsigned int f_idx; if (xsk_ring_prod__reserve(&fill, 1, &f_idx) == 1) { *xsk_ring_prod__fill_addr(&fill, f_idx) = desc->addr; xsk_ring_prod__submit(&fill, 1); } }有个细节:零拷贝模式下,如果 fill ring 长时间为空,内核可能因为缺少可用缓冲区直接丢包,不会通知你。有的驱动需要用户态显式唤醒 netdev,代码里经常出现这样的片段:if (xsk_ring_prod__needs_wakeup(&fill)) { sendto(xsk_socket__fd(xsk), NULL, 0, MSG_DONTWAIT, NULL, 0); }它本质是告诉内核去调xsk_net_xmit之类的路径,把之前挂起的 RX 处理重新拉起来。这个调用开销很小,但少了它某些驱动在低流量或 sleep 状态时会一直不喂包。4.3 编译、加载与联调用户态代码编译:gcc -O2 -Wall -o xsk_recv xsk_recv.c -lbpf -lpthreadXDP 程序编译成 BPF 字节码:clang -O2 -g -Wall -target bpf -c xsk_prog.c -o xsk_prog.o加载 XDP 程序到网卡,两种方式都行:# 用 iproute2 sudo ip -force link set dev eth0 xdp obj xsk_prog.o sec xdp # 或者用 bpftool sudo bpftool prog load xsk_prog.o /sys/fs/bpf/xsk_prog sudo bpftool net attach xdp pinned /sys/fs/bpf/xsk_prog dev eth0想卸载 XDP 程序:sudo ip link set dev eth0 xdp off联调的时候建议先用ping产生最基础的流量,确认 RX ring 里有计数后有 IP 头可解析,再上iperf3或pktgen加压验证性能。中间如果发现收不到包,优先去看 fill ring 有没有被掏空,其次检查 XSKMAP 的 key 是不是对应上了网卡的 RX queue id。多队列网卡即使你只监听 queue 0,发过来的流量也可能被 RSS 分到其他队列,这种情况要么绑定队列,要么用ethtool -L把队列合并成单队列方便调试。5. 常见问题与排查技巧实录AF_XDP 并不是装完就能顺利跑通的技术,我把自己踩过和帮人排查过的坑整理成一个速查表:现象大概率原因处理方式bind 返回 Operation not permitted缺少 CAP_NET_RAW / CAP_NET_ADMIN 权限用 root 跑,或者让程序 setcap 后以普通用户执行UMEM create 返回 EINVALchunk/frame 大小不是 2 的幂,或小于 MTU+包头调大 frame_size,推荐 2048 起步,巨型帧用 4096创建 socket 报 EOPNOTSUPP驱动不支持 XDP,或该接口跑在 generic 模式查ethtool -i驱动名,换 veth/受支持驱动做开发一直收不到包但 ethtool 计数正常fill ring 没填满,或 XSKMAP key 和队列 id 对不上初始化时把整个 ring 填满;确认ctx->rx_queue_index映射收包能通但吞吐上不去正在走 copy 模式而不是 zero-copy看xdpsock是否落到零拷贝路径,用ethtool -S观察 xdp 命中网卡显示 rx_missed 增长NAPI 处理不过来,用户态消费太慢调大 ring 深度,开启 busy poll,绑定 CPU启动时报 RX ring 初始化失败ring 数量不是 2 的幂,或超出驱动上限检查 ring depth 参数,默认 1024/2048 通常安全BPF 程序加载报 Cannot allocate memory旧内核 memlock 限制确认 rlimit 设置,24.04 上改RLIMIT_MEMLOCK或直接用 root几个值得展开说的点。权限问题最隐蔽。AF_XDP socket 创建需要CAP_NET_RAW,bind 到网卡、更新 XSKMAP 需要CAP_NET_ADMIN。root 用户当然没烦恼,但开发环境经常用 systemd-run、容器、CI 用户,忘了加 capabilities 就会出现“编译全过、运行就挂”的诡异情况。排查命令就一条:capsh --print看看当前 shell 的权限集合。GRO/LRO 是另一个高频坑。LRO 会把多个包合并成一个大 SKB,但 XDP 是在驱动层早期处理的,逻辑上不应该受影响。现实中某些驱动的 XDP 路径和 LRO 合并逻辑共存时会出问题,特别是 AF_XDP 零拷贝模式下,合并后的包没法按原始帧送回用户态。稳妥做法是关掉 LRO:sudo ethtool -K eth0 lro off sudo ethtool -K eth0 gro off开发验证阶段全程关着,跑通后再按需开回去做 AB 对比。还有一个很隐蔽的问题:net.core.busy_read 和 busy_poll 系统参数。开了 busy poll 之后,AF_XDP 的 RX 路径会在用户态循环里主动轮询 NAPI,延迟能降不少,但 CPU 占用会明显上升。xdpsock -N就是干这个的。生产环境要按包的量级来权衡,没有统一答案。我一般先把sysctl net.core.busy_read=50和一个队列的 CPU 亲和钉在一起测试,再把参数调大观察性价比。6. 性能调优与实操心得环境跑通之后,想把性能从“能跑”推到“能打”,通常要动四板斧:队列、CPU、批量、模式。队列维度。一个网卡多队列,最好一个队列对应一个 AF_XDP socket 和一个处理线程,线程绑核。RSS 哈希把不同的流散到不同队列,天然就是分布式处理。队列和 CPU 的绑定用ethtool -L、ethtool -X配合irqbalance的关闭操作,别让系统自动迁移中断,否则你在用户态做 CPU 亲和,中断却在另一个核上乱跳,性能就浪费了。CPU 维度。推荐taskset把用户态进程钉在中断处理核的同一个 NUMA 节点上。单个队列的场景最理想是:硬中断在 CPU 2,NAPI 在 CPU 2,xdpsock 线程也在 CPU 2 附近,减少跨 NUMA 访问 UMEM 内存的开销。UMEM 是用户态内存,跨 NUMA 访问会拉高内存延迟,这对零拷贝场景特别致命。批量维度。一次处理一个包是正确性优先的写法,性能场景要改成一次 peek 多个描述符、循环处理完再一次 release,再把 chunk 批量归还 fill ring。描述符是连续数组,批量处理能摊薄内存屏障和内核唤醒的开销。我在自己的程序里习惯一个批次取 64 个包,吞吐和延迟之间最均衡。模式维度。确保走的是零拷贝而不是通用 XDP。有个简单判断:xdpsock输出 flags 里会出现 copy 而不是 zero-copy,或者接口的 XDP 统计里 generic 命中在涨而 native 不涨。通用 XDP 因为要走 SKB 路径,AF_XDP 最大的零拷贝优势没了,性能面板直接难看很多。调优过程中我个人的体会是:别一上来就盯网卡吞吐,先把单队列单进程的基线拉出来,再逐步扩队列、加线程。AF_XDP 的复杂度大部分在数据路径的“归还”逻辑上,填满 fill ring、及时消费 RX ring、及时归还 chunk,这三个动作稳定了,性能就差不到哪里去。很多人遇到瓶颈第一反应是怀疑框架,实际上多半是用户态消费速度跟不上,或者 chunk 归还不及时导致驱动缺缓冲区。最后再分享一个小技巧:开发期用 veth 做端到端测试非常香。veth 天然支持 XDP 通用模式,你可以不插物理网卡就在虚拟机里把 UMEM、ring、XSKMAP 的整个数据路径调通。等逻辑没有问题,再挪到物理网卡验证零拷贝模式,环境差异带来的变量会少很多。这个顺序能帮你省下大量“驱动没反应还是代码有 bug”的排查时间。