WEBKT

打满万兆网卡:基于 AF_XDP 的高性能发包工具设计与内核级优化实践

24 0 0 0

在传统 Linux 网络编程中,使用 sendtowrite 向 Raw Socket 发送数据包时,会经历多次内存拷贝(用户态 -> 内核态 -> 网卡驱动)、频繁的系统调用上下文切换以及繁重的 TCP/IP 协议栈封装。在 10Gbps 甚至 100Gbps 的高并发场景下,这种传统的 Socket 机制会迅速使 CPU 满载,丢包率飙升。

为了解决这一问题,DPDK 曾是唯一的救星。但 DPDK 强行接管网卡的“黑盒”模式导致其丧失了 Linux 内核的大量优秀特性(如路由表、安全过滤、常规工具 tcpdumpethtool 等)。

Linux Kernel 4.18 引入的 AF_XDP(Address Family eXpress Data Path)改变了这一局面。它既能实现接近 DPDK 的零拷贝(Zero-Copy)极致性能,又能与内核协议栈完美共存。本文将深入剖析如何基于 AF_XDP 设计并实现一个高性能的网络发包工具,并分享关键的内核级优化技巧。


一、 AF_XDP 核心架构与高性能机理

AF_XDP 的核心思想是旁路(Bypass)。它利用 eBPF/XDP 程序在网卡驱动层(Rx/Tx 阶段)直接拦截或注入数据包,绕过复杂的内核网络协议栈。

1. UMEM 内存池

AF_XDP 发包的核心是 UMEM。UMEM 是一块由用户空间申请、并向内核注册的连续内存区域。这块内存被切分成固定大小的“槽(Frames)”(通常为 2KB 或 4KB)。发送和接收的所有数据包都必须存放在 UMEM 的这些 Frame 中。

2. 四大环形缓冲区(Ring Buffers)

用户态与内核态之间通过 4 个单生产者单消费者(SPSC)无锁环形队列进行交互:

  • Fill Ring(填充队列):用户态填入空闲 Frame 虚拟地址,供内核接收数据(发包工具主要关注 TX,但初始化时仍需了解此概念)。
  • Rx Ring(接收队列):内核将接收到的数据包信息放入该队列,通知用户态读取。
  • Tx Ring(发送队列):用户态将待发送的数据包 Frame 地址填入此队列,通知内核发送。
  • Completion Ring(完成队列):内核发送完数据包后,将 Frame 地址填入此队列,通知用户态该内存已可回收重用。
+-------------------------------------------------------------+
|                          User Space                         |
|                                                             |
|      +-------------+                  +-------------+       |
|      |   Tx Ring   |                  | Compl Ring  |       |
|      +------+------+                  +------+------+       |
+-------------|--------------------------------|--------------+
              | (Produce: Push Frame)          | (Consume: Pop Frame)
              v                                ^
+-------------|--------------------------------|--------------+
|             v                                |              |
|      +------+------+                  +------+------+       |
|      |   Tx Ring   |                  | Compl Ring  |       |
|      +------+------+                  +------+------+       |
|                                                             |
|                     Linux Kernel (XSK)                      |
|                                                             |
|                        +----------+                         |
|                        |   UMEM   |                         |
|                        +----------+                         |
|                             |                               |
|                             v                               |
|                        [NIC Driver]                         |
+-----------------------------|-------------------------------+
                              v
                      [ Physical Network ]

二、 发包工具架构设计

高并发发包工具在设计上必须遵循 Share-Nothing(无共享) 架构。任何全局锁或跨线程内存访问都会严重拖累吞吐量。

1. 多线程与多队列绑定

现代网卡通常支持多队列(RSS)。我们的工具应为每个网卡物理队列(Queue)分配一个独立的发送线程。

  • 每个线程拥有独立的 AF_XDP Socket (XSK)、独立的 UMEM 和一套 Ring Buffers
  • 利用线程亲和性(Thread Affinity)将每个发包线程绑定到指定的 CPU 核心。

2. 发包流水线设计

单线程内的发送主循环应采用“生产-消费”闭环逻辑:

  1. 查询完成:从 Completion Ring 中批量读取已经发送完毕的 Frame 索引。
  2. 更新状态:将这些 Frame 重新标记为空闲(可用)。
  3. 构造数据包:从空闲池中取出 Frame,在用户态直接填充以太网头、IP头、UDP头及 Payload。
  4. 提交发送:将 Frame 索引及长度写入 Tx Ring
  5. 触发内核:通过 sendto 系统调用唤醒内核进行物理发包(在零拷贝模式下,此调用开销极低)。

三、 核心代码实现

以下为基于 libbpf(或标准 libxdp)库实现 AF_XDP 发包工具的核心 C 语言逻辑。

1. UMEM 初始化与注册

首先,分配大页内存(Hugepages)作为 UMEM 的物理载体,以减少 TLB miss。

#include <sys/mman.h>
#include <xdp/xsk.h>
#include <stdlib.h>
#include <stdio.h>

#define NUM_FRAMES 4096
#define FRAME_SIZE 2048
#define UMEM_SIZE (NUM_FRAMES * FRAME_SIZE)

struct xsk_umem_info {
    struct xsk_ring_prod fq;
    struct xsk_ring_cons cq;
    struct xsk_umem *umem;
    void *buffer;
};

struct xsk_umem_info *configure_umem(void) {
    struct xsk_umem_info *umem_info;
    int ret;
    void *bufs;

    umem_info = calloc(1, sizeof(*umem_info));
    
    // 使用 MAP_HUGETLB 申请 2MB 大页内存,避免页表开销
    bufs = mmap(NULL, UMEM_SIZE, PROT_READ | PROT_WRITE,
                MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB, -1, 0);
    if (bufs == MAP_FAILED) {
        perror("mmap failed, falling back to standard page");
        bufs = mmap(NULL, UMEM_SIZE, PROT_READ | PROT_WRITE,
                    MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    }

    // 向内核注册 UMEM
    ret = xsk_umem__create(&umem_info->umem, bufs, UMEM_SIZE,
                           &umem_info->fq, &umem_info->cq, NULL);
    if (ret) {
        fprintf(stderr, "xsk_umem__create failed\n");
        return NULL;
    }

    umem_info->buffer = bufs;
    return umem_info;
}

2. 创建 AF_XDP Socket 及配置 Tx Ring

struct xsk_socket_info {
    struct xsk_ring_prod tx;
    struct xsk_ring_cons rx; // 本文侧重TX,RX可留空
    struct xsk_umem_info *umem;
    struct xsk_socket *xsk;
};

struct xsk_socket_info *setup_xsk_socket(struct xsk_umem_info *umem, const char *ifname, int queue_id) {
    struct xsk_socket_info *xsk_info;
    struct xsk_socket_config cfg;
    int ret;

    xsk_info = calloc(1, sizeof(*xsk_info));
    xsk_info->umem = umem;

    cfg.rx_size = XSK_RING_CONS__DEFAULT_NUM_DESCS;
    cfg.tx_size = XSK_RING_PROD__DEFAULT_NUM_DESCS;
    // 强制使用零拷贝模式,如果驱动不支持则会报错
    cfg.bind_flags = XDP_ZEROCOPY; 
    cfg.xdp_flags = XDP_FLAGS_DRV_MODE;

    ret = xsk_socket__create(&xsk_info->xsk, ifname, queue_id,
                             umem->umem, &xsk_info->rx, &xsk_info->tx, &cfg);
    if (ret) {
        fprintf(stderr, "xsk_socket__create failed on %s queue %d\n", ifname, queue_id);
        return NULL;
    }

    return xsk_info;
}

3. 高性能发包循环(Tx Loop)

在此步骤中,我们利用批量化(Batching)技术填充 Tx Ring,并集中发起系统调用。

#define BATCH_SIZE 64

static void packet_generator_tx_loop(struct xsk_socket_info *xsk_info) {
    struct xsk_ring_prod *tx = &xsk_info->tx;
    struct xsk_ring_cons *cq = &xsk_info->umem->cq;
    uint32_t tx_idx = 0;
    uint32_t cq_idx = 0;
    uint32_t available_frames = NUM_FRAMES; 
    
    // 简易空闲帧管理器,实际项目中可使用更高效的 LIFO 栈
    uint64_t free_frames[NUM_FRAMES];
    for (int i = 0; i < NUM_FRAMES; i++) {
        free_frames[i] = i * FRAME_SIZE;
    }
    int free_count = NUM_FRAMES;

    while (1) {
        // Step 1: 从 Completion Ring 回收已经发完的内存帧
        unsigned int completed = xsk_ring_cons__peek(cq, BATCH_SIZE, &cq_idx);
        if (completed > 0) {
            for (int i = 0; i < completed; i++) {
                uint64_t addr = *xsk_ring_cons__comp_addr(cq, cq_idx + i);
                free_frames[free_count++] = addr; // 归还到空闲池
            }
            xsk_ring_cons__release(cq, completed);
        }

        // Step 2: 判定是否有足够的空闲帧及 Tx 队列空间
        if (free_count < BATCH_SIZE) {
            continue; // 稍后重试
        }

        uint32_t tx_pos;
        unsigned int reserved = xsk_ring_prod__reserve(tx, BATCH_SIZE, &tx_pos);
        if (reserved < BATCH_SIZE) {
            continue; // Tx Ring 慢,等待内核消费
        }

        // Step 3: 批量构造并填充数据包
        for (int i = 0; i < BATCH_SIZE; i++) {
            uint64_t addr = free_frames[--free_count];
            char *pkt_data = (char *)xsk_info->umem->buffer + addr;

            // 构造以太网、IP、UDP数据(静态或动态填充偏移)
            // 这里为了演示,假设 pkt_data 已经预先填好了模版
            // memcpy(pkt_data, pre_templated_packet, pkt_len);

            // 填充 Tx 描述符
            struct xdp_desc *desc = xsk_ring_prod__tx_desc(tx, tx_pos + i);
            desc->addr = addr;
            desc->len = 64; // 发送 64 字节小包进行压力测试
        }

        // 提交到 Tx 队列
        xsk_ring_prod__submit(tx, BATCH_SIZE);

        // Step 4: 唤醒内核启动物理发送
        // 使用 MSG_DONTWAIT 减少阻塞,仅在内核未处于 NAPI 轮询时触发 syscall
        if (xsk_ring_prod__needs_wakeup(tx)) {
            sendto(xsk_socket__fd(xsk_info->xsk), NULL, 0, MSG_DONTWAIT, NULL, 0);
        }
    }
}

四、 极致性能优化指南

要实现 10Mpps(每秒千万级数据包)甚至打满 40GbE 线速,仅仅写出上述代码是不够的。必须结合 Linux 内核与底层硬件特性进行深度调优:

1. 开启 Zero-Copy (ZC) 模式

  • AF_XDP 有两种工作模式:XDP_COPYXDP_ZEROCOPY
  • Copy Mode:通用模式,兼容所有网卡驱动,但数据包会在内核空间与 UMEM 之间进行一次 memcpy
  • Zero-Copy Mode:数据包直接从 UMEM 送往网卡 DMA 区域。这需要网卡驱动原生支持(如 i40eixgbeicemlx5 等)。
  • 在创建 Socket 时,务必通过 cfg.bind_flags = XDP_ZEROCOPY 强制启用。如果返回错误,说明当前驱动或内核不支持,需更新驱动或升级内核。

2. CPU 亲和性(Affinity)与中断隔离

  • 避免跨 NUMA 访问:网卡插在哪个 PCIe 插槽上,它就属于哪个 NUMA 节点。发包线程和绑定的 CPU 核心必须与网卡处于同一个 NUMA 节点。通过 cat /sys/class/net/<ethX>/device/numa_node 查询。
  • 绑定中断:将网卡的硬件中断(MSI-X)亲和性绑定到负责发包的相同 CPU 核心上,或者使用专属的辅助核心,避免中断在不同 CPU 间漂移导致 Cache Line 抖动。

3. 启用 Busy Polling(繁忙轮询)

默认情况下,内核在发送完毕后会产生中断来通知应用程序。而在极高吞吐场景下,频繁的中断会导致严重的 CPU 颠簸。
可以通过设置 Socket 选项开启 SO_BUSY_POLL

int sock_fd = xsk_socket__fd(xsk_info->xsk);
int val = 1;
setsockopt(sock_fd, SOL_SOCKET, SO_PREFER_BUSY_POLL, &val, sizeof(val));

int busy_time = 50; // 50微秒忙轮询
setsockopt(sock_fd, SOL_SOCKET, SO_BUSY_POLL, &busy_time, sizeof(busy_time));

启用后,内核将不再依赖中断通知,而是通过用户态系统调用主动轮询网卡收发队列,大大降低延迟并提升吞吐量。

4. 驱动层参数调优

在使用 AF_XDP 进行极限压测前,应对网卡参数进行微调:

  • 增大 Ring 缓冲区大小
    ethtool -G eth0 rx 4096 tx 4096
    
  • 关闭无用卸载(Offload):由于我们要自己构造纯净的数据包,可以关闭部分 GSO/TSO 干扰:
    ethtool -K eth0 tso off gso off gro off
    

五、 总结与选型建议

特性 传统 Raw Socket DPDK AF_XDP (Zero-Copy)
发包吞吐量 (Mpps) 低 (<1 Mpps) 极高 (线速,>30 Mpps) 极高 (接近 DPDK)
内核协议栈共存 支持 不支持 (完全接管) 支持 (可通过 eBPF 选择性放行)
开发难度 极低 极高 (需独立驱动、专用 API) 中等 (基于 eBPF/libxdp C 语言 API)
硬件兼容性 任何网卡 需 DPDK 受控驱动列表 任何网卡 (ZC 模式需主流万兆网卡驱动)

AF_XDP 的出现,为网络性能优化开辟了一条崭新的道路。它在保持 Linux 内核生态完整性的同时,通过无锁环形队列、UMEM 内存管理和 eBPF 零拷贝技术,榨干了万兆/百兆网卡的最后一滴性能。对于开发高性能发包测试仪、软件定义网络(SDN)网关、DDOS 防御系统等,基于 AF_XDP 的设计都是当下的最优解。

SystemHacker AFXDP网络性能优化Linux内核

评论点评