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
#include <linux/bpf.h>
#include <linux/if_ether.h>
#include <linux/ip.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_endian.h>

struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __uint(max_entries, 100000);
    __type(key, __u32);      // 源 IPv4 地址,网络字节序
    __type(value, __u64);    // 命中次数
} blacklist SEC(".maps");

SEC("xdp")
int xdp_blacklist(struct xdp_md *ctx) {
    void *data     = (void *)(long)ctx->data;
    void *data_end = (void *)(long)ctx->data_end;

    struct ethhdr *eth = data;
    if ((void *)(eth + 1) > data_end) return XDP_PASS;   // 访问前必须做边界检查,否则验证器拒绝加载
    if (eth->h_proto != bpf_htons(ETH_P_IP)) return XDP_PASS;

    struct iphdr *ip = (void *)(eth + 1);
    if ((void *)(ip + 1) > data_end) return XDP_PASS;

    __u32 saddr = ip->saddr;
    __u64 *hits = bpf_map_lookup_elem(&blacklist, &saddr);
    if (!hits) return XDP_PASS;                          // 不在黑名单,放行

    __sync_fetch_and_add(hits, 1);                       // 多个 CPU 并发更新,用原子加
    return XDP_DROP;
}

char _license[] SEC("license") = "GPL";

编译:

clang -O2 -g -target bpf -c xdp_blacklist.bpf.c -o xdp_blacklist.bpf.o

挂载到网卡(native 模式):

sudo ip link set dev eth0 xdpdrv obj xdp_blacklist.bpf.o sec xdp

把 10.0.0.1 加入黑名单(key 是 4 个字节,value 是 8 个字节):

sudo bpftool map update name blacklist key 10 0 0 1 value 0 0 0 0 0 0 0 0

查看命中次数:

sudo bpftool map dump name blacklist

卸载:

sudo ip link set dev eth0 xdp off

流量很大时把 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 绑定到一个(网卡,接收队列)组合,只能收到进入这个队列的包。要让目标流量进入指定队列,用网卡的流分类规则:

sudo ethtool -N eth0 flow-type udp4 dst-port 9999 action 3

这条规则把目的端口 9999 的 UDP 包导入 3 号队列。其余队列上的流量不受影响,照常走协议栈。

收包循环

用 libxdp 提供的环操作函数(片段;umem_area、fq、rx、pfd 已在初始化阶段创建):

#include <xdp/xsk.h>
#include <poll.h>

#define BATCH 64

while (running) {
    __u32 idx_rx = 0, idx_fq = 0;

    unsigned n = xsk_ring_cons__peek(&rx, BATCH, &idx_rx);   // rx ring 里有几个包
    if (n == 0) {
        poll(&pfd, 1, 1000);                                 // 没包时休眠,不占 CPU
        continue;
    }

    while (xsk_ring_prod__reserve(&fq, n, &idx_fq) != n)     // 在 fill ring 预留 n 个位置
        ;

    for (unsigned i = 0; i < n; ++i) {
        const struct xdp_desc *d = xsk_ring_cons__rx_desc(&rx, idx_rx + i);
        unsigned char *pkt = xsk_umem__get_data(umem_area, d->addr);
        handle(pkt, d->len);                                 // 直接读 UMEM
        *xsk_ring_prod__fill_addr(&fq, idx_fq + i) = d->addr;  // 帧还给内核
    }

    xsk_ring_prod__submit(&fq, n);
    xsk_ring_cons__release(&rx, n);
}

唤醒与忙轮询

机制 内核版本 作用
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 大页:

echo 1024 | sudo tee /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages

把网卡从内核驱动解绑,绑定到 vfio-pci:

sudo dpdk-devbind.py --bind=vfio-pci 0000:01:00.0

用自带的测试程序验证(使用 0~3 号核):

sudo dpdk-testpmd -l 0-3 -n 4 -- -i

绑定之后,ip link 里看不到这块网卡,tcpdump、ethtool 也不能再对它使用。

转发循环

初始化顺序:rte_eal_init → 创建 rte_mempool → rte_eth_dev_configure → 建立收发队列 → rte_eth_dev_start。之后工作线程进入循环(片段):

#include <rte_ethdev.h>
#include <rte_mbuf.h>

#define BURST_SIZE 32

for (;;) {
    struct rte_mbuf *bufs[BURST_SIZE];

    uint16_t nb_rx = rte_eth_rx_burst(port_in, 0, bufs, BURST_SIZE);   // 没包时立即返回 0
    if (nb_rx == 0) continue;

    uint16_t nb_tx = rte_eth_tx_burst(port_out, 0, bufs, nb_rx);

    for (uint16_t i = nb_tx; i < nb_rx; ++i)                           // 没发出去的包要释放
        rte_pktmbuf_free(bufs[i]);
}

循环里没有系统调用,没有休眠。没有包时线程仍占满一个核。

代价

代价 说明
独占 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,或者从进程方式查看:

sudo dpdk-proc-info -- --stats
统计项 含义 常见原因 处理
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 负载

#include <netinet/in.h>
#include <rte_byteorder.h>
#include <rte_ether.h>
#include <rte_ip.h>
#include <rte_mbuf.h>
#include <rte_udp.h>

// 返回 UDP 负载的指针,长度写入 *len;不是 IPv4 UDP 包时返回 NULL
static inline const uint8_t *udp_payload(struct rte_mbuf *m, uint16_t *len) {
    if (rte_pktmbuf_data_len(m) < sizeof(struct rte_ether_hdr) +
                                  sizeof(struct rte_ipv4_hdr) +
                                  sizeof(struct rte_udp_hdr))
        return NULL;

    struct rte_ether_hdr *eth = rte_pktmbuf_mtod(m, struct rte_ether_hdr *);
    if (eth->ether_type != rte_cpu_to_be_16(RTE_ETHER_TYPE_IPV4)) return NULL;

    struct rte_ipv4_hdr *ip = (struct rte_ipv4_hdr *)(eth + 1);
    if (ip->next_proto_id != IPPROTO_UDP) return NULL;

    struct rte_udp_hdr *udp =
        (struct rte_udp_hdr *)((uint8_t *)ip + rte_ipv4_hdr_len(ip));
    *len = rte_be_to_cpu_16(udp->dgram_len) - sizeof(*udp);
    return (const uint8_t *)(udp + 1);
}
// 编译: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 和监听描述符的注册):

#include "ff_api.h"
#include "ff_epoll.h"

#define MAX_EVENTS 512

int epfd, listenfd;
struct epoll_event ev, events[MAX_EVENTS];

int loop(void *arg) {
    int n = ff_epoll_wait(epfd, events, MAX_EVENTS, 0);      // 超时为 0,不阻塞
    for (int i = 0; i < n; ++i) {
        int fd = events[i].data.fd;
        if (fd == listenfd) {
            int c = ff_accept(listenfd, NULL, NULL);
            if (c < 0) continue;
            ev.data.fd = c;
            ev.events  = EPOLLIN;
            ff_epoll_ctl(epfd, EPOLL_CTL_ADD, c, &ev);
        } else if (events[i].events & EPOLLIN) {
            char buf[4096];
            ssize_t len = ff_read(fd, buf, sizeof(buf));     // 读到的是重组好的字节流
            if (len > 0) ff_write(fd, buf, len);
            else         ff_close(fd);
        }
    }
    return 0;
}

int main(int argc, char *argv[]) {
    ff_init(argc, argv);                                     // 内部完成 DPDK 初始化
    listenfd = ff_socket(AF_INET, SOCK_STREAM, 0);
    /* ff_bind、ff_listen、ff_epoll_create、注册 listenfd */
    ff_run(loop, NULL);                                      // 主循环
    return 0;
}

网卡、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 部署最简单。