1. 为什么说 xdp_md 是 XDP 程序的“最小上下文”
写 eBPF XDP 程序的人,第一眼看到 struct xdp_md *ctx 这个参数时,十有八九会愣一下。它不像 struct sk_buff 那样自带一堆协议指针和缓存信息,也不像 struct pt_regs 那样直接暴露寄存器现场,它看起来就是六个 __u32 字段,挤在一张不到 24 字节的“卡片”里。但这张卡片恰恰是 XDP 程序唯一能拿到的上下文信息,搞不懂它,后面所有代码都是空中楼阁。
我最近在把几个 XDP 程序从 5.4 内核适配到 6.8 内核,顺便给团队写了一篇内部文档,把 xdp_md 从字段含义到报文解析、从踩坑到调试链路整理了一遍。有人问“XDP 程序不就是要处理包吗,直接给我网卡发来的指针不就行了,为什么还包一层结构体?”这个问题问到点子上了,xdp_md 的设计初衷就是:它是 eBPF 虚拟指令集和真实内核数据结构之间的“翻译层”。
XDP 挂载在网卡驱动里,在 sk_buff 还没分配、甚至没有进入协议栈之前就运行。此时内核手里只有一块原始内存页,里面是一帧网络数据。如果直接把 struct xdp_buff * 塞给 BPF 程序,问题就来了:BPF 指令集不能随意访问任意内核内存,verifier 也无法保证你访问的内存一定在包范围内。于是内核抽象出 xdp_md,只暴露当前上下文里最关键的几个数字:数据在哪、数据到哪、元数据在哪、从哪个网卡哪个队列进来的。
它的定位很像电影院给你的一张座位票:上面不写影厅的完整建筑图纸,只写“你在 3 排 5 座,入口在左边”,你顺着这个信息就能找到座位,又不会被卷进整栋楼的安保系统。这也是 XDP 能跑这么快的根基之一——程序能碰的东西被压缩到极致,verifier 审查起来也快。
如果你正在读《深入理解 eBPF 与可观测性》这类书,或者从可观测性角度研究 XDP,你会发现很多高级用法到最后都绕不开 xdp_md 的字段语义。它不复杂,但细节非常多:哪些字段什么时候能读、哪些字段需要配合特定返回码、data 和 data_end 到底是偏移量还是指针、元数据区怎么用。下面我按自己的使用顺序,把这些细节一个个拆开。
1.1 它和 sk_buff 的根本差异
先澄清一个最常见的误区:xdp_md 不是 sk_buff 的简化版,它的存在前提是“没有 sk_buff”。XDP 程序运行在网卡驱动收到包、把 DMA 数据映射到内存页之后,在 netif_receive_skb 之前。这个节点上,数据是一整块干净的 page,没有协议栈的额外封装,没有 netfilter 钩子,没有路由表查询。
sk_buff 是协议栈内部的“万能背包”,里面有 head、data、tail、end 四个指针指来指去,还有 protocol、cb[48] 这种辅助字段,甚至可以通过 bpf_skb_load_bytes 这类 helper 跨区间读数据。而 xdp_md 只有两个真正指向包内容的字段:data 和 data_end,中间这段就是完整的一帧报文。你不需要关心 skb 里的各种偏移量,因为 XDP 阶段根本没有这些概念。
这个差异带来一个直接后果:在 XDP 里你不能用处理 sk_buff 的习惯去调用 bpf_skb_store_bytes 或 bpf_skb_change_tail,想改包只能用 bpf_xdp_adjust_head、bpf_xdp_adjust_tail、bpf_xdp_adjust_meta 这几个 helper,或者直接改 data 和 data_end 之间的内容。理解这一点,你就知道为什么所有 XDP 教程都在强调“直接报文访问”(Direct Packet Access),而不是“通过 helper 读包”。
1.2 结构体定义与每个字段的来历
打开内核头文件 include/uapi/linux/bpf.h,你会看到这样一段定义:
c复制struct xdp_md {
__u32 data;
__u32 data_end;
__u32 data_meta;
__u32 ingress_ifindex; /* rxq->dev->ifindex */
__u32 rx_queue_index; /* rxq->queue_index */
__u32 egress_ifindex; /* txq->dev->ifindex */
};
注意这里有个非常反直觉的设计:data、data_end、data_meta 在结构体里声明成 __u32,但在 BPF 程序里你几乎总是把它们当作指针来用。我最早写 XDP 代码时直接写 ctx->data 然后就想解引用,结果编译过了,verifier 直接报错。原因在于,这个 __u32 只是“用户态视角的声明”,内核在实际加载程序时,会把它们翻译成 xdp_buff 里真正的指针值。BPF 字节码层面,你看到的是一个 64 位的标量值,但 verifier 会给它打上 PTR_TO_PACKET 类型标签,允许你在通过边界检查的前提下做解引用。
六个字段的来历:
- data / data_end:数据区的起止偏移。
data指向帧的起始位置(通常是 Ethernet 头),data_end指向数据区末尾。两者之差就是当前 XDP 程序可见的包长。 - data_meta:元数据区的起点。它和
data之间夹着一段额外的头部空间,由之前执行的 XDP 程序通过bpf_xdp_adjust_meta预留。默认情况下data_meta等于data,也就是没有元数据。 - ingress_ifindex:包进入的网卡 ifindex。用于路由、策略判断、防欺骗等场景。
- rx_queue_index:网卡收包队列的索引。配合 RSS(Receive Side Scaling)做 per-queue 统计、CPU 亲和性判断非常好用。
- egress_ifindex:5.6 内核之后新增的字段,表示发送方向网卡的 ifindex。它只在特定上下文里有效(例如
XDP_TX或 BPF_PROG_TEST_RUN 里做回包测试时),普通收包路径读它是没有意义的。
字段不长,但每一个展开都能讲一堆东西。下面我先从 data / data_end 这两位主角讲起,因为它们是解析报文的底气所在。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 拿着 xdp_md 安全地拆报文:data / data_end 实操
XDP 程序 90% 的工作都是“看一看包里的内容,然后决定放行、丢弃还是转发”。而这个“看”的动作,靠的就是 data 和 data_end。我见过太多人第一次写 XDP 解析逻辑时,直接写 struct ethhdr *eth = (void *)ctx->data; 然后放心大胆地访问 eth->h_proto,结果一加载就被 verifier 拒绝:R0 invalid mem access 'inv'。
这不是代码写得不对,而是没有理解 verifier 的边界检查机制。
2.1 偏移量还是指针?先把这个概念掰清楚
很多材料说 ctx->data 是“指向包头的指针”,
