dpdk
XDP、AF_XDP 与 DPDK
内核协议栈为什么处理不了每秒千万个包;XDP 在驱动层做什么;AF_XDP 怎样把包零拷贝送到用户态; DPDK 怎样把网卡整个搬到用户态,收到的原始帧怎样变成应用能用的数据;三者的差别和选型。
适用范围:Linux x86-64。XDP / AF_XDP 以内核 5.x 起的特性为主线,DPDK 以 23.11 LTS 为参照。特性的内核版本和驱动支持情况属于实现细节,随版本变化。
实测说明:本文代码与命令均未实测。性能数字来自公开文献,已在出现处标注来源,只用于比较量级。
eBPF 的验证器、map、程序加载流程见 ebpf.md;DPDK 向量收包路径用到的 SIMD 见 simd.md。
目录
| # | 章节 | 主题 |
|---|---|---|
| 一 | 内核收包路径的开销 | 每个包的时间预算;协议栈的固定开销 |
| 二 | XDP | 位置、返回码、运行模式、带 map 的例子 |
| 三 | XDP 的用途 | 过滤、负载均衡、转发、送到用户态、观测 |
| 四 | AF_XDP | UMEM、四个环、零拷贝、唤醒与忙轮询 |
| 五 | DPDK | 结构、核心技术、网卡与环境要求、转发循环、代价 |
| 六 | DPDK 的关键机制 | UIO 与 VFIO、rte_mbuf、内存池、rte_ring、RSS、丢包排查 |
| 七 | 收到包之后:协议处理与用户态协议栈 | 解析包头、TCP 重组、F-Stack |
| 八 | AF_XDP 与 DPDK 对比 | 结构差异、性能差距的来源、组合使用 |
| 九 | 选型 | 判断流程与场景表 |
| 十 | 其他内核旁路方案 | Onload / ef_vi、VMA、RDMA、FPGA |
| 十一 | 面试速答卡 | 高频问题浓缩答案 |
一、内核收包路径的开销
每个包的时间预算
10 GbE 链路上,最小以太网帧占 84 字节(64 字节帧 + 8 字节前导码 + 12 字节帧间隔):
每秒包数 = 10^10 bit/s ÷ (84 × 8 bit) ≈ 14.88 Mpps
每包时间 = 1 ÷ 14.88 Mpps ≈ 67 ns
每包周期数 = 67 ns × 3 GHz ≈ 200 个 CPU 周期
一次未命中 cache 的内存访问约 60~100 ns,单这一项就用完了整个预算。
协议栈在每个包上做的事
网卡 → 硬中断 → 软中断(NAPI poll) → 分配 sk_buff → GRO → tc → netfilter → 路由
→ TCP/UDP → socket 接收队列 → 唤醒进程 → recv() 系统调用 → 拷贝到用户缓冲区
| 开销 | 来源 |
|---|---|
sk_buff 分配与初始化 |
每个包一次;结构体超过 200 字节,跨多个 cache line |
| 协议栈各层处理 | GRO、netfilter、conntrack、路由查找、socket 查找 |
| 内核态到用户态的拷贝 | recv() 把数据从内核缓冲区拷到用户缓冲区 |
| 系统调用与上下文切换 | 每次 recv() 进出内核;进程被唤醒时发生调度 |
| 中断 | 低负载时每个包一次硬中断 |
| 锁与共享数据 | socket 锁、队列锁,多核下有竞争和 cache line 迁移 |
文献数字(Høiland-Jørgensen 等,The eXpress Data Path,CoNEXT 2018,单核):
| 处理方式 | 单核丢包速度 |
|---|---|
| 内核 conntrack 之后丢弃 | 约 1.8 Mpps |
| 内核 iptables raw 表丢弃 | 约 4.8 Mpps |
| XDP | 约 24 Mpps |
| DPDK | 约 43.5 Mpps |
提速的办法只有两类:在协议栈之前把包处理掉(XDP),或者让包不经过协议栈(AF_XDP、DPDK)。
二、XDP
XDP(eXpress Data Path)是网卡驱动收包函数里的一个 eBPF 钩子,Linux 4.8 引入。它在分配 sk_buff 之前运行,程序看到的是原始帧。
位置
flowchart LR
NIC["网卡"] --> DRV["驱动 NAPI poll"]
DRV --> XDP{"XDP 程序"}
XDP -- XDP_DROP --> DROP["丢弃,回收页"]
XDP -- XDP_TX --> NIC
XDP -- XDP_REDIRECT --> RD["另一网卡 / 另一 CPU / AF_XDP socket"]
XDP -- XDP_PASS --> SKB["分配 sk_buff"]
SKB --> STACK["tc → netfilter → IP → TCP/UDP → socket"]
返回码
| 返回码 | 动作 | 典型用途 |
|---|---|---|
XDP_DROP |
丢弃,驱动直接回收页 | 抗 DDoS、防火墙 |
XDP_PASS |
交给协议栈继续处理 | 放行 |
XDP_TX |
从收包的同一网卡发回去 | 四层负载均衡 |
XDP_REDIRECT |
转发到另一网卡(DEVMAP)、另一 CPU(CPUMAP)或 AF_XDP socket(XSKMAP) |
转发、送到用户态 |
XDP_ABORTED |
程序出错,丢弃并触发 xdp:xdp_exception tracepoint |
调试 |
运行模式
| 模式 | ip link 参数 |
运行位置 | 性能 | 条件 |
|---|---|---|---|---|
| generic | xdpgeneric |
协议栈入口,sk_buff 已分配 |
与 tc 相当 | 任何网卡,用于测试 |
| native | xdpdrv |
驱动 NAPI 收包函数里 | 高 | 驱动支持(ixgbe、i40e、ice、mlx5、virtio_net、veth 等) |
| offload | xdpoffload |
网卡硬件 | 最高,不占主机 CPU | 少数 SmartNIC |
快的原因
| 省掉的开销 | 说明 |
|---|---|
sk_buff 分配 |
程序操作的是 xdp_buff,只含 data 和 data_end 两个指针 |
| 协议栈处理 | 不经过 GRO、netfilter、路由、socket 查找 |
| 拷贝与上下文切换 | 包不进用户态,在软中断上下文里处理完 |
| 解释执行 | eBPF 程序经 JIT 编译成机器码 |
| 逐包处理 | 一次 NAPI poll 处理一批包,程序和数据都在 cache 里 |
例子:按源地址黑名单丢包
黑名单存在 eBPF map 里,用户态随时增删,不需要重新加载程序。
// xdp_blacklist.bpf.c
blacklist ;
int
char _license = "GPL";
编译:
挂载到网卡(native 模式):
把 10.0.0.1 加入黑名单(key 是 4 个字节,value 是 8 个字节):
查看命中次数:
卸载:
流量很大时把 map 类型换成 BPF_MAP_TYPE_PERCPU_HASH:每个 CPU 一份计数,更新时不需要原子操作,也没有 cache line 在多核之间迁移。
限制
| 限制 | 说明 |
|---|---|
| 只有收包方向 | 发包方向用 tc eBPF |
| 受验证器约束 | 循环必须有界,每次访存前要做边界检查,程序复杂度有上限 |
| 只看到单个原始帧 | 没有 TCP 重组和连接状态,需要时用 map 自行维护 |
| 需要驱动支持 | 否则退回 generic 模式,性能优势消失 |
| 巨帧与分片帧 | 多缓冲区支持自 5.18 起,依赖驱动(实现相关) |
三、XDP 的用途
XDP 程序对每个包可以做三件事:改写内容、读写 map 里的状态、决定去向。丢包是其中最简单的组合。
| 用途 | 对包做什么 | 返回码 | 代表项目 |
|---|---|---|---|
| 过滤 | 判断后丢弃 | XDP_DROP |
Cloudflare 抗 DDoS |
| 四层负载均衡 | 改写包头或加一层封装,再发出去 | XDP_TX / XDP_REDIRECT |
Meta Katran、Cilium |
| 转发 | 查路由表,转到另一个网卡 | XDP_REDIRECT |
软件路由器、容器间转发 |
| 送到用户态 | 转给 AF_XDP socket | XDP_REDIRECT |
行情接收、自定义协议栈 |
| 观测 | 计数、采样后放行 | XDP_PASS |
流量统计 |
四层负载均衡的处理过程
flowchart TD
A["收到客户端的包"] --> B["解析五元组"]
B --> C{"连接表 map 里有这条连接?"}
C -- 有 --> E["取出对应的后端"]
C -- 没有 --> D["一致性哈希选一台后端,写入连接表"]
D --> E
E --> F["bpf_xdp_adjust_head 在包头前扩出空间,封装一层外部头,目的地址填后端"]
F --> G["返回 XDP_TX,从收包的网卡发出"]
整个过程没有丢包,全部是查表、改写和转发。
不适合的场景
| 场景 | 原因 | 通常的做法 |
|---|---|---|
| 解析应用层内容 | 看到的是单个帧,没有流的概念 | XDP 做第一道筛选,其余交给协议栈或用户态 |
| 发包方向的处理 | 没有对应的钩子 | tc eBPF |
| 逻辑复杂的处理 | 验证器限制循环和程序大小 | 送到用户态(AF_XDP)处理 |
四、AF_XDP
AF_XDP 是一种 socket 类型(AF_XDP,内核 4.18 引入)。XDP 程序用 XDP_REDIRECT 把选中的包转给它,用户态程序从一块共享内存里直接读包。没被选中的包照常进协议栈,网卡仍由内核驱动管理。
组成
| 部件 | 作用 |
|---|---|
| UMEM | 用户态分配的一块内存,切成等长的帧(通常 2 KB 或 4 KB),注册给内核后双方共享 |
| fill ring | 用户态 → 内核:交出空闲帧的地址,供驱动装新收到的包 |
| rx ring | 内核 → 用户态:已收到的包的地址和长度 |
| tx ring | 用户态 → 内核:要发送的帧的地址和长度 |
| completion ring | 内核 → 用户态:已发送完成、可以回收的帧的地址 |
XSKMAP |
eBPF map,键是接收队列号,值是 AF_XDP socket;XDP 程序靠它重定向 |
四个环都是单生产者单消费者的无锁环形队列,环里传递的是帧在 UMEM 里的偏移,不是包数据。fill ring 和 completion ring 属于 UMEM,rx ring 和 tx ring 属于 socket。
收包流程
sequenceDiagram
participant U as 用户态程序
participant K as 内核驱动
U->>K: fill ring:空闲帧的地址
Note over K: 网卡收到包,驱动把数据写进空闲帧
Note over K: XDP 程序返回 XDP_REDIRECT 到 XSKMAP
K->>U: rx ring:帧地址 + 长度
Note over U: 直接读 UMEM 里的数据,没有拷贝
U->>K: fill ring:把处理完的帧还回去
两种模式
| 模式 | 数据路径 | 条件 |
|---|---|---|
零拷贝(XDP_ZEROCOPY) |
网卡通过 DMA 直接把包写进 UMEM | 驱动支持(i40e、ice、ixgbe、mlx5 等,实现相关) |
拷贝(XDP_COPY) |
驱动先收到自己的缓冲区,再拷一次到 UMEM | 任何支持 XDP 的驱动 |
绑定到接收队列
一个 AF_XDP socket 绑定到一个(网卡,接收队列)组合,只能收到进入这个队列的包。要让目标流量进入指定队列,用网卡的流分类规则:
这条规则把目的端口 9999 的 UDP 包导入 3 号队列。其余队列上的流量不受影响,照常走协议栈。
收包循环
用 libxdp 提供的环操作函数(片段;umem_area、fq、rx、pfd 已在初始化阶段创建):
while
唤醒与忙轮询
| 机制 | 内核版本 | 作用 |
|---|---|---|
poll() 休眠 |
4.18 | 没包时让出 CPU,来包时被唤醒;有唤醒延迟 |
XDP_USE_NEED_WAKEUP |
5.4 | 内核在环上置标志,只有标志置位时用户态才需要发系统调用;减少无效的系统调用 |
SO_PREFER_BUSY_POLL |
5.11 | 用户态线程在系统调用里直接驱动收包,收包和处理在同一个核上完成,省掉软中断与用户线程之间的切换 |
发包时用户态把帧放进 tx ring 后,需要一次 sendto() 通知内核开始发送(开启忙轮询或 need_wakeup 后可减少调用次数)。
五、DPDK
DPDK(Data Plane Development Kit)把网卡驱动搬到用户态。网卡从内核驱动上解绑,收发包不经过内核。
结构
┌────────────────────────── 用户态 ──────────────────────────┐
│ 应用(转发、负载均衡、用户态协议栈) │
│ ────────────────────────────────────────────────────── │
│ 库:mempool ring hash LPM ACL cryptodev ... │
│ ────────────────────────────────────────────────────── │
│ PMD(轮询模式驱动):直接读写网卡寄存器和收发描述符环 │
│ ────────────────────────────────────────────────────── │
│ EAL(环境抽象层):大页、CPU 亲和、PCI 设备映射 │
└────────────────────────────────────────────────────────────┘
┌────────────────────────── 内核 ────────────────────────────┐
│ vfio-pci / uio:只负责把网卡的寄存器空间映射给用户态 │
└────────────────────────────────────────────────────────────┘
核心技术
| 技术 | 做法 | 消除的开销 |
|---|---|---|
| 用户态驱动 | 通过 vfio-pci 或 uio 把网卡寄存器映射到进程地址空间 |
系统调用、内核态到用户态的拷贝 |
| 轮询 | 线程死循环读收包描述符环,不用中断 | 中断和上下文切换 |
| 大页 | 包缓冲区放在 2 MB 或 1 GB 的大页上 | TLB miss |
| CPU 亲和与独占 | 每个工作线程绑定一个核,配合 isolcpus 把该核从调度器里隔离 |
线程迁移、调度抖动 |
| 内存池 | rte_mempool 预分配等长的 rte_mbuf,每个核有本地缓存 |
malloc、锁竞争 |
| 无锁环 | rte_ring 用 CAS 实现多生产者多消费者队列 |
锁 |
| 批处理 | rte_eth_rx_burst 一次取一批包(通常 32 个) |
每包一次的函数调用和 cache miss |
| 向量收包 | PMD 用 SSE / AVX2 / AVX-512 一次处理多个收包描述符 | 逐个描述符解析的指令数 |
| NUMA 感知 | 内存池和线程分配在网卡所在的 NUMA 节点 | 跨节点访存 |
| RSS 多队列 | 网卡按五元组哈希把流分到多个队列,每个核处理一个队列 | 核与核之间的共享数据 |
rte_mbuf、内存池、rte_ring、RSS 的实现细节见第六节。
两种线程模型
| 模型 | 做法 | 特点 |
|---|---|---|
| run-to-completion | 一个核从收包到发包全部做完 | 没有核间传递,cache 局部性好;每个核的逻辑相同 |
| pipeline | 收包、处理、发包分在不同的核上,用 rte_ring 传递 |
各阶段可独立扩展;核间传递带来 cache miss |
网卡与环境要求
DPDK 为每种网卡单独实现 PMD,网卡要在支持列表里才能发挥性能。
| 类别 | 例子 | 性能 |
|---|---|---|
| 物理网卡 | Intel(ixgbe、i40e、ice)、Mellanox / NVIDIA(mlx5)、Broadcom(bnxt) | 高 |
| 虚拟网卡 | virtio-net、vmxnet3、AWS ENA、SR-IOV 的 VF | 中到高 |
| 软件驱动 | af_packet、af_xdp、tap、pcap |
低,用于测试或兜底 |
不在支持列表里的网卡只能用软件驱动。这时包仍经过内核,DPDK 的性能优势基本消失。
| 环境要求 | 说明 |
|---|---|
| CPU | x86 上至少支持 SSE4.2 |
| 大页 | 生产环境必需;测试时可用 --no-huge 跳过 |
| IOMMU | 用 vfio-pci 时需要开启(Intel VT-d / AMD-Vi) |
| 网卡特性(可选) | 多队列、RSS、校验和卸载、硬件流表;有则性能更好 |
环境准备
预留 1024 个 2 MB 大页:
|
把网卡从内核驱动解绑,绑定到 vfio-pci:
用自带的测试程序验证(使用 0~3 号核):
绑定之后,ip link 里看不到这块网卡,tcpdump、ethtool 也不能再对它使用。
转发循环
初始化顺序:rte_eal_init → 创建 rte_mempool → rte_eth_dev_configure → 建立收发队列 → rte_eth_dev_start。之后工作线程进入循环(片段):
for
循环里没有系统调用,没有休眠。没有包时线程仍占满一个核。
代价
| 代价 | 说明 |
|---|---|
| 独占 CPU | 每个工作线程占满一个核,与流量大小无关 |
| 网卡脱离内核 | 内核工具不可用,抓包和统计要用 DPDK 自己的工具(dpdk-pdump、dpdk-proc-info) |
| 没有协议栈 | 拿到的是原始帧;需要 TCP 时接入用户态协议栈,见第七节 |
| 与内核交换流量要另搭通道 | 通过 virtio-user 或 TAP 把包回注内核;KNI 已在 23.11 移除(实现相关) |
| 部署条件多 | 大页、网卡绑定、核隔离、NUMA 规划 |
Mellanox / NVIDIA 网卡是例外:mlx5 PMD 与内核驱动并存(分叉驱动),网卡不需要解绑,未被 DPDK 接管的流量仍进内核协议栈。
六、DPDK 的关键机制
UIO 与 VFIO
两者的作用相同:把网卡的寄存器空间映射到用户态进程,让 PMD 直接读写。
UIO(igb_uio、uio_pci_generic) |
VFIO(vfio-pci) |
|
|---|---|---|
| 地址隔离 | 无,设备可以 DMA 到任意物理内存 | 借助 IOMMU,设备只能访问映射给它的内存 |
| 权限 | 需要 root | 可配置给非特权用户 |
| 内核模块 | igb_uio 需要单独编译 |
内核自带 |
| 前提 | 无 | 开启 IOMMU;没有时可用 no-iommu 模式,隔离随之失效 |
新部署用 VFIO。
rte_mbuf
一个 rte_mbuf 描述一个包(或大包的一段)。元数据和包数据在同一块连续内存里:
buf_addr
│
▼
┌───────────────┬───────────┬──────────────────────┬──────────┐
│ rte_mbuf 元数据 │ headroom │ 数据区 │ tailroom │
│ 128 字节 │ 128 字节 │ data_len 字节 │ │
└───────────────┴───────────┴──────────────────────┴──────────┘
▲
│
buf_addr + data_off(包的第一个字节)
元数据 128 字节(两个 cache line)、headroom 128 字节是 DPDK 23.11 在 x86-64 上的默认值,实现相关。
| 字段 | 含义 |
|---|---|
data_off |
包数据相对缓冲区起点的偏移 |
data_len |
本段的数据长度 |
pkt_len |
整个包的长度(各段之和) |
next、nb_segs |
大包由多个 mbuf 串成链 |
refcnt |
引用计数,多处共享同一份数据时使用 |
ol_flags |
硬件卸载标志:校验和是否已验证、是否带 VLAN 等 |
hash.rss |
网卡算好的 RSS 哈希值,可直接用作流表的键 |
| 操作 | 作用 | 用途 |
|---|---|---|
rte_pktmbuf_mtod(m, type) |
取包数据的指针 | 解析包头 |
rte_pktmbuf_prepend(m, len) |
向 headroom 方向扩展 | 加封装头,不搬动数据 |
rte_pktmbuf_adj(m, len) |
从头部去掉 len 字节 | 解封装 |
rte_pktmbuf_append(m, len) |
向 tailroom 方向扩展 | 追加数据 |
内存池
rte_mempool 在启动时一次性分配全部 mbuf,运行中不调用 malloc。
核 0 本地缓存 核 1 本地缓存 核 2 本地缓存 各核独占,取放不需要同步
│ ▲ │ ▲ │ ▲
▼ │ ▼ │ ▼ │ 本地缓存取空或放满时才批量交换
┌─────────────────────────────────────────────────┐
│ 共享的 rte_ring(全部空闲对象) │
└─────────────────────────────────────────────────┘
| 操作 | 常见路径 | 少见路径 |
|---|---|---|
| 分配 | 从本核的本地缓存取 | 本地缓存空了,从共享环批量取一批 |
| 释放 | 放回本核的本地缓存 | 本地缓存满了,批量还一批给共享环 |
绝大多数分配和释放只碰本核的数据,没有竞争。
rte_ring
定长的环形队列,容量是 2 的幂,下标用位与取模。生产者和消费者各有一对 head / tail。
多生产者入队分三步:
| 步骤 | 动作 | 同步方式 |
|---|---|---|
| 1 | 读 prod.head,算出新值 head + n,用 CAS 写回;失败则重试 |
CAS |
| 2 | 把 n 个对象写进抢到的那一段槽位 | 无,这段槽位归自己 |
| 3 | 等 prod.tail 追上自己抢占时的 head,再把 prod.tail 推进到新值 |
自旋等待 |
消费者只能看到 prod.tail 之前的数据,所以写到一半的槽位不会被读到。
第 3 步要等排在前面的生产者全部完成。一个线程在第 1 步和第 3 步之间被调度走,后面的生产者会一直自旋。工作线程要绑核并隔离,保证不被抢占。
| 模式 | 同步方式 | 适用 |
|---|---|---|
| 单生产者 / 单消费者 | 无 | 线程一对一传递,最快 |
| 多生产者 / 多消费者 | CAS | 默认 |
| RTS、HTS | 限制同时进入的线程数 | 线程可能被抢占的环境(如容器里超卖的核) |
RSS 与对称 RSS
RSS(Receive Side Scaling)由网卡完成:
五元组 ──Toeplitz 哈希(带密钥)──▶ 32 位哈希值 ──取低位查重定向表──▶ 接收队列号
同一条流的包哈希值相同,始终进同一个队列,由同一个核处理,核与核之间不需要共享连接状态。
默认密钥下,一条连接的两个方向哈希值不同:
| 方向 | 哈希的输入 | 结果 |
|---|---|---|
| 客户端 → 服务端 | (A, B, portA, portB) | 队列 2 |
| 服务端 → 客户端 | (B, A, portB, portA) | 队列 5 |
NAT、负载均衡、有状态防火墙需要同一个核看到两个方向的包。对称 RSS 把密钥设成 0x6D5A 重复填满,交换源和目的后哈希值不变。
硬件流表
rte_flow 把匹配规则下发到网卡,由硬件完成分流、打标记或丢弃。在 dpdk-testpmd 里把目的端口 9999 的 UDP 包导入 3 号队列:
flow create 0 ingress pattern eth / ipv4 / udp dst is 9999 / end actions queue index 3 / end
支持哪些匹配字段和动作取决于网卡型号(实现相关)。
降低轮询的 CPU 占用
| 办法 | 做法 | 代价 |
|---|---|---|
空转时执行 rte_pause() |
对应 x86 的 pause 指令,降低功耗,让出超线程的执行资源 |
CPU 占用率仍显示 100% |
| 连续空转若干次后短暂休眠 | usleep 或 rte_delay_us_sleep |
休眠期间到达的包延迟上升 |
| 收包中断模式 | rte_eth_dev_rx_intr_enable 配合 epoll,没包时睡眠、来包时唤醒 |
唤醒有延迟,接近内核收包的行为 |
| 功耗管理 | rte_power 按负载调整 CPU 频率 |
负载突增时有一段爬升时间 |
自带示例 l3fwd-power 演示了后两种办法。
多进程模型
| 进程类型 | 启动参数 | 职责 |
|---|---|---|
| 主进程 | --proc-type=primary |
初始化大页、内存池、网卡 |
| 从进程 | --proc-type=secondary |
把同一块大页内存映射到相同的虚拟地址,直接使用主进程建好的对象 |
用途:
- 业务进程之外另起抓包(
dpdk-pdump)和统计(dpdk-proc-info)进程 - 多个业务进程各处理一个队列,单个进程崩溃不影响其他进程
NUMA 与伪共享
| 问题 | 做法 |
|---|---|
| 包数据跨 NUMA 节点访问 | 用 rte_eth_dev_socket_id(port) 查出网卡所在节点,内存池和工作线程都放在这个节点 |
| 各核的计数器落在同一个 cache line | 每核一份,结构体按 cache line 对齐(__rte_cache_aligned),各核只写自己的那份 |
丢包排查
在 dpdk-testpmd 里执行 show port stats all,或者从进程方式查看:
| 统计项 | 含义 | 常见原因 | 处理 |
|---|---|---|---|
imissed |
网卡收到了包,但收包描述符环已满 | 应用处理太慢;工作线程被抢占 | 加大收包环、增加队列和核、检查核隔离 |
rx_nombuf |
内存池里没有空闲 mbuf | 某条路径上 mbuf 没有释放 | 查 rte_mempool_avail_count,逐条路径检查释放 |
ierrors |
链路层错误(CRC、长度) | 线缆、光模块、对端配置 | 查物理链路 |
oerrors |
发送失败 | 链路断开、发送环异常 | 查链路状态 |
rte_eth_tx_burst 的返回值小于请求数时,说明发送环满了。没发出去的 mbuf 归调用者所有,要重试或释放,否则就是 rx_nombuf 的来源。
七、收到包之后:协议处理与用户态协议栈
DPDK 交给应用的是原始的二层帧。以太网头、IP 头、TCP / UDP 头都由应用解析,DPDK 不做协议处理。
哪些应用需要协议栈
| 应用类型 | 需要 TCP 协议栈 | 做法 |
|---|---|---|
| 转发、路由、防火墙 | 否 | 解析包头,查表,改写,发出 |
| 四层负载均衡(如 DPVS) | 否 | 只维护连接表,不终结连接 |
| UDP 应用(行情、DNS) | 否 | 解析以太网、IP、UDP 头;另外处理 ARP 和组播加入 |
| TCP 服务端或客户端 | 是 | 接入用户态协议栈,或把流量回注内核 |
只有应用要终结 TCP 连接时才需要协议栈。
解析包头:取出 UDP 负载
// 返回 UDP 负载的指针,长度写入 *len;不是 IPv4 UDP 包时返回 NULL
static inline const uint8_t *
// 编译:cc -O3 $(pkg-config --cflags libdpdk) -c udp_payload.c
这段代码没有处理 VLAN 标签和 IP 分片。
UDP 应用除了解析负载,还要处理两件内核原本代劳的事:
| 事项 | 原因 | 做法 |
|---|---|---|
| ARP | 对端要先问到本机的 MAC 地址才会发包 | 应答 ARP 请求,或在对端配静态 ARP |
| 组播加入 | 交换机只把组播流量转给加入了该组的端口 | 定期发送 IGMP 报告,或在交换机上配静态组播 |
TCP 协议栈做的事
TCP 包可能乱序、重复、丢失。协议栈把它们变成应用可读的连续字节流:
flowchart TD
A["rte_eth_rx_burst 收到一批帧"] --> B["解析以太网头和 IP 头,分片的先重组"]
B --> C["按四元组查连接表,找到这条连接的控制块"]
C --> D{"序号等于期望的下一个序号?"}
D -- 是 --> E["数据追加到接收缓冲区"]
D -- "否:乱序" --> F["放进乱序队列,等前面的包到达"]
E --> G["检查乱序队列里的数据能否接上"]
G --> H["回 ACK,更新接收窗口"]
F --> H
H --> I["通知应用可读"]
I --> J["应用读走连续的字节流"]
完整的协议栈还包括:
| 功能 | 内容 |
|---|---|
| 连接管理 | 三次握手、四次挥手、状态机 |
| 可靠传输 | 序号、确认、超时重传、快速重传 |
| 流量控制 | 滑动窗口 |
| 拥塞控制 | 慢启动、拥塞避免 |
| 定时器 | 重传、保活、延迟确认、TIME_WAIT |
| 周边协议 | ARP、ICMP、IP 分片重组 |
常见的用户态协议栈
| 项目 | 来源 | 特点 |
|---|---|---|
| F-Stack | 腾讯 | 把 FreeBSD 协议栈移植到用户态;接口与 POSIX socket 相近;已适配 Nginx、Redis |
| VPP | fd.io | 完整的数据面加主机协议栈,功能最全 |
| Seastar | ScyllaDB | C++ 异步框架,自带协议栈,每核一个实例 |
| mTCP | KAIST | 研究项目,更新较少 |
| Gazelle | openEuler | 基于 lwIP |
DPDK 自带的几个库只覆盖协议栈的局部:
| 库 | 功能 |
|---|---|
rte_ip_frag |
IP 分片与重组 |
rte_gro / rte_gso |
合并相邻的 TCP 段 / 拆分大包 |
rte_hash |
哈希表,可用来实现连接表 |
rte_timer |
定时器 |
F-Stack
┌────────────┐ ┌────────────┐ ┌────────────┐
│ 进程 0 │ │ 进程 1 │ │ 进程 2 │
│ 应用逻辑 │ │ 应用逻辑 │ │ 应用逻辑 │
│ FreeBSD │ │ FreeBSD │ │ FreeBSD │ 每个进程一份独立的协议栈
│ 协议栈 │ │ 协议栈 │ │ 协议栈 │ 进程之间不共享连接状态
│ DPDK 队列0 │ │ DPDK 队列1 │ │ DPDK 队列2 │
└─────▲──────┘ └─────▲──────┘ └─────▲──────┘
└───────────────┼───────────────┘
网卡 RSS 分流
| 特点 | 说明 |
|---|---|
| 每进程一个协议栈实例 | 进程绑核,连接状态不跨进程,没有锁 |
| 依赖 RSS | 同一条连接的包始终落到同一个进程 |
接口加 ff_ 前缀 |
ff_socket、ff_bind、ff_epoll_wait、ff_read 等 |
| 应用跑在协议栈的主循环里 | ff_run 每轮先收包、跑协议栈,再调用应用的回调 |
| 只能用非阻塞写法 | 回调里阻塞会停掉整个进程的收发包 |
回显服务(片段,省略了 ff_bind、ff_listen、ff_epoll_create 和监听描述符的注册):
int epfd, listenfd;
struct epoll_event ev, events;
int
int
网卡、IP 地址、使用的核写在 config.ini 里,启动时用 --conf config.ini 指定。
不接协议栈的办法
| 办法 | 做法 |
|---|---|
| 回注内核 | DPDK 只处理关心的流量,其余通过 virtio-user 或 TAP 交给内核协议栈 |
| 用 mlx5 网卡 | PMD 与内核驱动并存,TCP 流量留给内核 |
| 换方案 | Onload 自带协议栈,应用不改代码,见第十节 |
八、AF_XDP 与 DPDK 对比
结构差异
AF_XDP:网卡 → 内核驱动(软中断里收包)→ XDP 程序 → rx ring → 用户态程序
└─ 驱动在内核里
DPDK: 网卡 → PMD(用户态线程轮询)→ 用户态程序
└─ 驱动在用户态,内核不参与
对比表
| XDP | AF_XDP | DPDK | |
|---|---|---|---|
| 处理位置 | 内核驱动层 | 用户态 | 用户态 |
| 驱动位置 | 内核 | 内核 | 用户态 |
| 编程方式 | eBPF,受验证器约束 | 普通用户态程序 | 普通用户态程序 |
| 性能 | 高 | 零拷贝模式下低于 DPDK,公开测试中常见的范围是 DPDK 的六到八成(与驱动、内核版本相关) | 最高 |
| 网卡归属 | 内核 | 内核 | DPDK 独占(mlx5 除外) |
| 与协议栈共存 | 可以,按包选择 | 可以,按队列和包选择 | 不可以,其余流量需回注 |
| 没包时的 CPU 占用 | 无 | 可休眠 | 占满核 |
| 发包方向 | 不支持 | 支持 | 支持 |
| 配套库 | eBPF map 和 helper | 只有收发包 | 内存池、哈希、路由、ACL、加密、QoS 等 |
| 硬件卸载 | 有限 | 有限;校验和、时间戳等元数据自 6.3 起逐步加入(实现相关) | 完整,含 rte_flow 硬件流表 |
| 部署要求 | 驱动支持 native 模式 | 较新的内核,驱动支持零拷贝 | 大页、网卡绑定、核隔离 |
AF_XDP 性能低于 DPDK 的原因
| 原因 | 说明 |
|---|---|
| 收包在内核软中断里完成 | 包从内核递到用户态有一次交接;软中断和用户线程不在同一个核时还有核间 cache 迁移 |
| 每个包都执行 XDP 程序 | 程序只有一句重定向时也有固定开销 |
| 发包要系统调用触发 | sendto() 通知内核;忙轮询可减少次数 |
| 驱动路径通用性优先 | DPDK 的 PMD 有针对单一网卡的向量收包路径 |
组合使用
DPDK 自带一个 AF_XDP 驱动(net_af_xdp)。上层使用 DPDK 的接口和配套库,底层收发包走 AF_XDP,网卡留在内核里。性能低于原生 PMD,部署条件少。
九、选型
flowchart TD
A["需要加速收发包"] --> B{"包需要进用户态处理?"}
B -- "否:丢弃、改写、转发即可" --> X["XDP"]
B -- 是 --> C{"其余流量还要走内核协议栈?"}
C -- 是 --> D{"网卡是 mlx5?"}
D -- 是 --> E["AF_XDP 或 DPDK 分叉驱动"]
D -- 否 --> F["AF_XDP"]
C -- "否:整块网卡都归数据面" --> G{"需要极限吞吐或完整的数据面组件?"}
G -- 是 --> H["DPDK"]
G -- 否 --> F
| 场景 | 选择 | 依据 |
|---|---|---|
| 抗 DDoS、简单防火墙 | XDP | 在最早的位置丢包,不需要用户态参与 |
| 四层负载均衡 | XDP | 查表、封装、发回,逻辑在验证器限制之内 |
| 只加速一部分流量(如一路组播行情) | AF_XDP | 用 ethtool 把目标流导入一个队列,其余流量不受影响 |
| 容器与云环境 | AF_XDP | 不需要大页和独占网卡 |
| 软件路由器、网关、虚拟交换机 | DPDK | 整块网卡归数据面,需要路由查找、ACL 等组件 |
| 单核每秒千万包以上的转发 | DPDK | 性能上限更高 |
| 依赖网卡硬件流表和卸载 | DPDK | 支持完整 |
十、其他内核旁路方案
| 方案 | 做法 | 特点 |
|---|---|---|
| Onload(Solarflare / AMD 网卡) | 用 LD_PRELOAD 拦截 socket 调用,在用户态实现 TCP/UDP 协议栈 |
应用不改代码;绑定特定网卡 |
| ef_vi(同上) | 直接收发二层帧的接口 | 延迟低于 Onload;需要自己解析协议 |
| VMA(Mellanox / NVIDIA 网卡) | 同样用 LD_PRELOAD 拦截 socket 调用 |
应用不改代码;绑定特定网卡 |
| RDMA | 网卡完成可靠传输,应用直接读写远端内存 | 用于存储和集群内部通信;两端都要支持 |
| FPGA | 协议解析乃至交易逻辑放在硬件里 | 延迟最低;开发成本最高 |
低延迟交易系统关心的是单个包的延迟和抖动,不是吞吐。这类场景里常见的是专用网卡加 Onload / ef_vi,或者 FPGA。AF_XDP 和 DPDK 的优势在通用硬件上的吞吐。
十一、面试速答卡
内核协议栈为什么慢
每个包都要分配 sk_buff,经过 GRO、netfilter、路由、socket 查找,再经一次系统调用拷贝到用户态。10 GbE 满速时每个包只有约 67 ns,一次 cache miss 就用完。
XDP 是什么
网卡驱动收包函数里的 eBPF 钩子,在分配 sk_buff 之前运行,程序的返回码决定包被丢弃、放行、发回还是重定向。
XDP 为什么快
省掉 sk_buff 分配和整个协议栈,包不进用户态,程序经 JIT 编译,按批处理。
XDP 只能做过滤吗 不是。它能改写包、用 map 维护状态、决定去向,四层负载均衡和转发是生产环境里的主要用途。
XDP 是不是内核旁路 不是。它运行在内核里,处理完的包可以继续进协议栈。AF_XDP 和 DPDK 才让包绕过协议栈。
AF_XDP 怎样做到零拷贝 用户态分配 UMEM 并注册给内核,网卡通过 DMA 直接把包写进 UMEM。四个环里传递的只是帧的地址。
AF_XDP 的四个环各做什么 fill 交出空闲帧,rx 收到包,tx 提交要发的帧,completion 回收发完的帧。前一对用于收,后一对用于发。
DPDK 为什么快 用户态驱动直接操作网卡,没有中断、系统调用和拷贝;再加上大页、核独占、内存池、无锁环、批处理和向量收包。
AF_XDP 能不能替代 DPDK 收发包功能有重叠,部分流量加速和云环境里可以替代。DPDK 的性能上限更高,配套库和硬件卸载支持更完整,整块网卡都归数据面时仍用 DPDK。
AF_XDP 和 DPDK 最根本的区别 驱动的位置。AF_XDP 的驱动在内核,网卡仍归内核管理,能与协议栈共存;DPDK 的驱动在用户态,网卡被独占。
DPDK 的代价 工作线程占满 CPU 核,网卡脱离内核工具,没有协议栈,部署要配置大页、网卡绑定和核隔离。
DPDK 需要网卡支持吗
需要。每种网卡要有对应的 PMD。不支持的网卡只能用 af_packet、tap 等软件驱动,包仍经过内核,性能没有优势。
UIO 和 VFIO 的区别 都是把网卡寄存器映射到用户态。VFIO 借助 IOMMU 限制设备能访问的内存,支持非特权用户,内核自带;UIO 没有隔离。
大页的作用 一个 2 MB 页相当于 512 个 4 KB 页,TLB 覆盖的内存范围大得多,TLB miss 减少。大页不会被换出,物理地址固定,满足网卡 DMA 的要求。
rte_mbuf 的结构
元数据、headroom、数据区、tailroom 在同一块连续内存里。headroom 用来在包头前加封装而不搬动数据;大包用多个 mbuf 串成链。
内存池为什么快 对象在启动时预分配;每个核有本地缓存,绝大多数分配和释放只碰本核的数据。
rte_ring 的无锁原理
生产者用 CAS 抢占 head 的一段槽位,写入数据,再按抢占的顺序推进 tail。线程在中途被抢占会让后面的线程自旋,所以工作线程要绑核隔离。
RSS 和对称 RSS
网卡按五元组哈希把流分到各个队列,同一条流始终由同一个核处理。默认密钥下一条连接的两个方向落在不同队列;对称 RSS 用 0x6D5A 重复的密钥让两个方向落到同一个队列。
轮询占满 CPU 怎么办
空转时执行 pause 或短暂休眠;改用收包中断模式;按负载调整 CPU 频率。这几种办法都以延迟为代价。
run-to-completion 和 pipeline
前者一个核从收包到发包全部做完,cache 局部性好;后者各阶段分在不同核上用 rte_ring 传递,各阶段可独立扩展。
DPDK 丢包怎么查
看网卡统计。imissed 表示收包环满,应用处理太慢;rx_nombuf 表示内存池耗尽,通常是 mbuf 没释放;ierrors 是链路层错误。
DPDK 收到包之后怎么交给 TCP 应用 DPDK 只给原始帧。转发类应用自己解析包头即可;要终结 TCP 连接就接入用户态协议栈(F-Stack、VPP、Seastar),或者把流量回注内核。
F-Stack 是什么 腾讯开源的用户态协议栈,把 FreeBSD 协议栈移植到 DPDK 之上,接口接近 POSIX socket。每个进程一份协议栈实例,靠 RSS 保证一条连接只落到一个进程。
低延迟交易用哪个 追求极低延迟时通常用专用网卡加 Onload / ef_vi,或 FPGA。通用硬件上只加速行情接收这一路流量时,AF_XDP 部署最简单。
暂无评论,欢迎留下第一条评论。