XDP_TX on veth:为什么 peer 也要挂一个 XDP_PASS

1
2
3
$ ethtool -S veth2_3 | grep xdp_tx
$ ip netns exec ns3 ip link set dev veth3_2 xdp obj xdp_pass.o sec xdp
$ ping 10.0.0.3

这篇文章整理一个很容易误判的 veth + XDP 现象:XDP 程序确实收到了包,也确实执行到了 return XDP_TX;,但包没有被发回去,ethtool -S 里还能看到 xdp_tx_errors 增长。

这个问题来自我之前在 Stack Overflow 上写的一个问答:XDP_TX don’t work for veth for the simplest L2 forwarding。最后的解法看起来很怪:在 veth 的另一端也挂一个什么都不做、只返回 XDP_PASS 的 XDP 程序。

它怪,是因为这不是转发逻辑需要;它是 veth driver 的 XDP 数据路径需要。

现象

测试拓扑大概是这样:

1
2
3
4
5
ns1/veth1_3
-> ns3/veth3_1
-> br0
-> ns3/veth3_2
-> ns2/veth2_3

veth2_3 上挂一个最小 XDP 程序:收到 IPv4 包后交换二层源/目的 MAC,然后返回 XDP_TX,期望包从同一个 veth 口打回去。

简化后的程序类似这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
SEC("xdp")
int xdp_ingress_func(struct xdp_md *ctx)
{
void *data_end = (void *)(long)ctx->data_end;
void *data = (void *)(long)ctx->data;
struct ethhdr *eth = data;
unsigned char tmp[ETH_ALEN];

if ((void *)(eth + 1) > data_end)
return XDP_PASS;

if (eth->h_proto != __builtin_bswap16(ETH_P_IP))
return XDP_PASS;

__builtin_memcpy(tmp, eth->h_dest, ETH_ALEN);
__builtin_memcpy(eth->h_dest, eth->h_source, ETH_ALEN);
__builtin_memcpy(eth->h_source, tmp, ETH_ALEN);

return XDP_TX;
}

调试时会看到几个互相矛盾的信号:

  • bpf_printk() 能证明程序收到了包。
  • 代码确实走到了 return XDP_TX;
  • L2 地址看起来也是对的。
  • tcpdump 能看到包进入 veth2_3
  • 但返回方向看不到包。
  • ethtool -S veth2_3rx_queue_0_xdp_tx_errors 增长。

如果只从 XDP action 的语义理解,很容易以为问题在 MAC、checksum、bridge、route 或 namespace 配置上。实际上根因在 veth 的 XDP 发送路径。

误区:XDP_TX 不是普通 dev_queue_xmit

对物理网卡来说,XDP_TX 可以粗略理解成“把这个刚收到的 packet buffer 从同一块网卡发回去”。所以很多人会把这个心智模型直接搬到 veth 上:

1
2
3
4
packet enters veth2_3
-> XDP program
-> return XDP_TX
-> packet goes back to veth3_2

方向没错,但机制不对。veth 没有真实硬件 TX ring。veth pair 的“发出去”本质上是把帧交给 peer device 的 receive side。

也就是说,在 veth 上执行 XDP_TX 时,driver 不是走普通内核网络栈重新发一个 skb,而是把 xdp_frame 放进 peer 侧为 XDP 准备的接收队列。

关键点是:那个接收队列并不是永远存在。

veth 里的真正路径

当前 Linux veth driver 的核心路径在 drivers/net/veth.c 里。veth_xdp_xmit() 会拿到 peer device,然后选择 peer 的 receive queue。

关键判断是这个意思:

1
2
3
4
5
rq = &rcv_priv->rq[veth_select_rxq(rcv)];

/* xdp_ring is initialized on receive side and the peer device is up. */
if (!rcu_access_pointer(rq->napi))
goto out;

后面才会把 frame 放进 peer 侧的 xdp_ring

1
__ptr_ring_produce(&rq->xdp_ring, ptr)

如果 peer 侧没有启用对应的 XDP/NAPI receive path,rq->napi 不存在,veth_xdp_xmit() 直接失败。于是你会看到程序执行到了 XDP_TX,但 driver 统计里出现 xdp_tx_err

这就解释了为什么这个 bug 看起来很反直觉:不是你的 XDP 程序没有 return XDP_TX,而是 XDP_TX 后面的 veth peer-side ring 没准备好。

为什么 Linux 要这样设计

这个行为不是因为 XDP 语义要求“对侧也必须运行 BPF 程序”。真正原因是:veth 的 XDP_TX 要在 XDP fast path 里完成跨 peer 交付,而这个 fast path 的基本单位不是 skb,而是 xdp_frame

普通 veth 发送路径是 skb 世界:

1
2
3
4
skb
-> veth xmit
-> peer receive path
-> kernel network stack

XDP 路径则刻意绕开了这件事。XDP 的设计目标是在尽量早的位置处理包,最好在进入完整网络栈、分配和加工 skb 之前就完成 drop、redirect 或 tx。到了 XDP_TX 这里,veth 手里拿到的是从 xdp_buff 转出来的 xdp_frame,它代表的是一块由 XDP 内存模型管理的 packet buffer。

所以 veth 有两个选择:

1
2
3
4
5
6
7
8
choice A: keep xdp_frame
-> enqueue to peer xdp_ring
-> peer NAPI poll drains ring
-> still in XDP-aware receive path

choice B: convert xdp_frame back to skb
-> inject into normal peer receive path
-> leave XDP fast path

当前实现选择的是 A。也就是:XDP_TX 不负责临时造一个 skb 再走普通 veth receive,而是把 xdp_frame 放进 peer 的 xdp_ring,让 peer side 的 NAPI poller 去消费。

这个选择有几个好处:

  • 避免从 XDP buffer 退回 skb 路径,少一次对象转换和更多 skb 相关开销。
  • 可以批量处理 frame,符合 XDP/driver fast path 的设计方式。
  • peer 收到 frame 后仍然可以继续运行 peer 侧 XDP 程序,而不是直接进入普通协议栈。
  • 内存所有权更清楚:frame 进入 XDP ring,由 XDP-aware 的 receive path 负责继续处理或释放。

代价就是:peer 必须真的有一个能消费 xdp_ring 的 receive side。这个 consumer 通常由 peer 侧启用 XDP 后初始化出来,包括 rq->napixdp_ringxdp_rxq 等状态。没有这个 consumer,发送侧就算已经执行到了 XDP_TX,也没有一个合法的地方可以把 xdp_frame 交出去。

可以把它理解成这样:

1
2
3
4
5
6
7
XDP_TX on physical NIC:
driver owns RX/TX rings
-> bounce frame to hardware TX path

XDP_TX on veth:
no hardware TX ring
-> peer receive side must provide a software XDP ring

为什么不在 peer 没开 XDP 时自动走 choice B?从接口体验看,这样确实更友好;但从 driver 设计看,这会把一个 fast-path action 变成“有时走 XDP ring,有时临时转 skb”的混合路径。这个 fallback 不只是多几行转换代码,它还要处理 headroom、frags、metadata、GRO、引用计数、错误释放和统计口径。旧实现/旧讨论里确实出现过 fallback 思路,但当前主线 veth 的代码选择更明确:没有 peer-side XDP/NAPI consumer,就让 XDP_TX 失败并计入错误。

所以,“对侧挂一个 XDP_PASS”不是业务需求,而是在声明 peer 侧也支持 XDP receive path:

1
2
I do not want to transform packets on peer side,
but I do want peer side to have the XDP machinery needed to receive xdp_frame.

这里很容易想到另一个解释:是不是 Linux 故意要求 peer 开 XDP,是为了防止 veth pair 在内核里互相 XDP_TX,形成死循环?

我觉得这个直觉有道理,但它不是这个现象的主要原因。两边都写成 XDP_TX 的确可能制造 packet bounce:

1
2
3
4
5
veth A XDP_TX
-> peer B receive side
-> B's XDP program also returns XDP_TX
-> back to peer A receive side
-> ...

这种情况更像是一个 packet-level livelock 或 packet storm,而不是同一个内核调用栈里的递归死循环。veth 的 XDP 路径通过 ring 和 NAPI poller 交付 frame,不是 A() 直接同步调用 B() 再同步调用 A() 的函数递归。

也就是说,peer 没开 XDP 就失败 不是一个“防止循环”的语义规则。如果内核想防止循环,单纯要求 peer 挂 XDP 也挡不住,因为 peer 挂的程序完全可以继续返回 XDP_TX。真正能避免 bounce loop 的,是你在 BPF 程序里写清楚 TTL、mark、方向位、metadata 或协议判断,让包只在预期方向上被 tx。

因此这个问题更准确的说法是:

1
2
peer-side XDP is required because veth XDP_TX needs a peer-side XDP receive queue.
loop prevention is still the XDP program's responsibility.

为什么挂 XDP_PASS 能修好

最小修复是在 peer 侧挂一个空 XDP 程序:

1
2
3
4
5
6
7
8
9
10
#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";

编译:

1
2
clang -O2 -g -Wall -target bpf \
-c xdp_pass.c -o xdp_pass.o

然后挂到 veth 的另一端。比如你的主程序在 ns2/veth2_3 上返回 XDP_TX,那就要确保 peer ns3/veth3_2 也有 XDP:

1
ip netns exec ns3 ip link set dev veth3_2 xdp obj xdp_pass.o sec xdp

这个程序不负责处理业务包。它只是在 peer 侧打开 veth XDP receive machinery,让 xdp_ring、NAPI 和相关队列状态变成可用。

可以把它理解成这样:

flowchart LR
  A["veth2_3 receives packet"] --> B["run real XDP program"]
  B --> C["return XDP_TX"]
  C --> D["veth_xdp_xmit()"]
  D --> E{"peer veth3_2 has XDP/NAPI?"}
  E -- "no" --> F["xdp_tx_err++"]
  E -- "yes" --> G["enqueue xdp_frame into peer xdp_ring"]
  G --> H["peer side drains ring"]

所以这个 XDP_PASS 程序的意义不是“让包 pass”,而是“让 peer 具备接收 XDP frame 的基础设施”。

该怎么记

我现在会用这条规则记 veth 的 XDP 行为:

1
2
If one side of a veth pair may XDP_TX into the peer,
make sure the peer side also has XDP enabled.

工程上可以直接写成 checklist:

1
2
3
4
5
6
7
8
# 1. 主处理程序挂在收到包的一侧
ip netns exec ns2 ip link set dev veth2_3 xdp obj xdp_main.o sec xdp

# 2. peer 侧至少挂一个 XDP_PASS
ip netns exec ns3 ip link set dev veth3_2 xdp obj xdp_pass.o sec xdp

# 3. 看错误计数器是否停止增长
ip netns exec ns2 ethtool -S veth2_3 | grep xdp_tx

如果你用的是容器、Kubernetes、UPF/N3/N6 这类 veth-heavy 的环境,这个规则尤其重要。很多时候你以为自己只是在一个接口上做“回包”,但对 veth driver 来说,回包动作会落到 peer 的 XDP receive queue 上。

调试顺序

遇到 XDP_TX 没效果时,我会按这个顺序查:

1
2
3
4
5
6
1. bpf_printk 确认程序是否真的被执行
2. 确认 return action 是否走到 XDP_TX
3. ethtool -S 看 xdp_tx / xdp_tx_err
4. 确认 veth peer 是否 up
5. 确认 veth peer 是否也挂了 XDP 程序
6. 再回头查 MAC、MTU、headroom、checksum 和 bridge/route

这能避免一开始就陷进 L2/L3 配置里。对这个问题来说,最关键的信号其实不是 tcpdump,而是 xdp_tx_errors

小结

这个现象的本质是:veth 的 XDP_TX 不是一个独立的“原路发回”动作,它依赖 peer side 的 XDP receive infrastructure。当前 veth driver 会通过 peer 的 xdp_ring 交付 xdp_frame;如果 peer 没有启用 XDP/NAPI,ring 不存在或不可用,XDP_TX 就会失败并计入错误统计。

所以最小修复很简单:

1
2
real XDP program on sender side
dummy XDP_PASS program on peer side

这行看起来多余的 XDP_PASS,实际上是在告诉 veth:这个 peer 也要走 XDP 数据路径。

References


XDP_TX on veth:为什么 peer 也要挂一个 XDP_PASS
https://jeremyguo.space/2026/06/22/xdp-tx-veth-peer-xdp/
作者
郭俊毅 / JeremyGuo
发布于
2026年6月22日
许可协议