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代价是收包路径仍会走 SKBAF_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/bpfmount | 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 里最核心的抽象是 UMEMunmapped 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 和 socketstruct 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_RAWbind 到网卡、更新 XSKMAP 需要CAP_NET_ADMIN。root 用户当然没烦恼但开发环境经常用 systemd-run、容器、CI 用户忘了加 capabilities 就会出现“编译全过、运行就挂”的诡异情况。排查命令就一条capsh --print看看当前 shell 的权限集合。GRO/LRO 是另一个高频坑。LRO 会把多个包合并成一个大 SKB但 XDP 是在驱动层早期处理的逻辑上不应该受影响。现实中某些驱动的 XDP 路径和 LRO 合并逻辑共存时会出问题特别是 AF_XDP 零拷贝模式下合并后的包没法按原始帧送回用户态。稳妥做法是关掉 LROsudo 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_read50和一个队列的 CPU 亲和钉在一起测试再把参数调大观察性价比。6. 性能调优与实操心得环境跑通之后想把性能从“能跑”推到“能打”通常要动四板斧队列、CPU、批量、模式。队列维度。一个网卡多队列最好一个队列对应一个 AF_XDP socket 和一个处理线程线程绑核。RSS 哈希把不同的流散到不同队列天然就是分布式处理。队列和 CPU 的绑定用ethtool -L、ethtool -X配合irqbalance的关闭操作别让系统自动迁移中断否则你在用户态做 CPU 亲和中断却在另一个核上乱跳性能就浪费了。CPU 维度。推荐taskset把用户态进程钉在中断处理核的同一个 NUMA 节点上。单个队列的场景最理想是硬中断在 CPU 2NAPI 在 CPU 2xdpsock 线程也在 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”的排查时间。