3、内核协议栈旁路:DPDK与XDP入门

绕过内核,直接跟网卡对话。

这句话听起来有点疯狂,对吧?

但在低延迟领域,这是我们必须掌握的技能。内核协议栈虽然稳定,但它太「重」了。每次数据包进来,都要经过中断、拷贝、协议解析、socket 传递……这一套流程下来,几十微秒就没了。对于金融交易、5G核心网这些场景,这简直是灾难。

3.1 为什么要绕过内核?

我先问你一个问题:你写一个简单的 UDP 接收程序,从网卡收到数据到你的业务代码拿到数据,中间经历了多少步?

我帮你数数:

  • 网卡收到数据,DMA 写到内核缓冲区
  • 网卡触发硬中断,CPU 暂停当前工作
  • 硬中断处理程序调度软中断(NET_RX_SOFTIRQ)
  • 软中断里调用驱动,把数据从 ring buffer 取出
  • 协议栈逐层处理:L2 以太网头、L3 IP 头、L4 UDP 头
  • 查找 socket,把数据拷贝到用户态缓冲区
  • 唤醒你的应用程序

嗯,这还没算上上下文切换和锁竞争。每一步都有开销,积少成多。

核心痛点:内核协议栈为了通用性,做了太多「你不需要」的事情。比如校验和验证、分片重组、路由查找……如果你的应用场景很明确(比如只收特定端口的 UDP 包),这些全是浪费。

我记得有一次帮一家量化交易公司做优化。他们的行情网关延迟一直在 20 微秒左右,怎么调都下不去。后来我们用 DPDK 绕过了内核,延迟直接降到 3 微秒。客户当时就愣住了。

3.2 DPDK:用户态网卡驱动

DPDK(Data Plane Development Kit)的思路很直接:把网卡驱动搬到用户态。

说白了,就是让应用程序直接接管网卡硬件。数据包从网卡 DMA 到用户态预先分配好的内存池里,应用程序轮询(Polling)读取,全程不经过内核。

3.2.1 DPDK 的核心机制

  • UIO(Userspace I/O): 通过内核模块把网卡硬件寄存器映射到用户空间,让用户态程序可以直接操作。
  • 大页内存(Hugepages): 使用 2MB 或 1GB 的大页,减少 TLB miss,提升内存访问效率。
  • 轮询模式驱动(PMD): 放弃中断,用 CPU 不断轮询网卡接收队列。CPU 占用高,但延迟极低且稳定。
  • 无锁环形队列(Ring Buffer): 多核之间通过无锁队列传递数据包,避免锁竞争。

我的经验:刚开始用 DPDK 时,最容易忽略的是 CPU 亲和性。一定要把轮询线程绑定到固定的物理核上,并且隔离该核,避免被其他进程干扰。我曾经因为没做隔离,延迟抖动从 1 微秒飙到 10 微秒,排查了一整天。

3.2.2 一个简单的 DPDK 收包示例

下面是一个最简的 DPDK 收包流程。注意,这只是一个骨架,实际项目里还要处理内存池初始化、端口配置等。

#include <rte_eal.h>
#include <rte_ethdev.h>

#define RX_RING_SIZE 1024
#define NUM_MBUFS 8191
#define MBUF_CACHE_SIZE 250
#define BURST_SIZE 32

static const struct rte_eth_conf port_conf_default = {
    .rxmode = { .max_rx_pkt_len = RTE_ETHER_MAX_LEN }
};

int main(int argc, char *argv[]) {
    // 1. 初始化 EAL(Environment Abstraction Layer)
    int ret = rte_eal_init(argc, argv);
    if (ret < 0) rte_exit(EXIT_FAILURE, "EAL init failed\n");

    // 2. 配置网卡端口
    struct rte_mempool *mbuf_pool = rte_pktmbuf_pool_create(
        "MBUF_POOL", NUM_MBUFS, MBUF_CACHE_SIZE, 0,
        RTE_MBUF_DEFAULT_BUF_SIZE, rte_socket_id());

    uint16_t port_id = 0;
    rte_eth_dev_configure(port_id, 1, 0, &port_conf_default);
    rte_eth_rx_queue_setup(port_id, 0, RX_RING_SIZE,
                           rte_eth_dev_socket_id(port_id), NULL, mbuf_pool);
    rte_eth_dev_start(port_id);

    // 3. 轮询收包
    struct rte_mbuf *bufs[BURST_SIZE];
    while (1) {
        uint16_t nb_rx = rte_eth_rx_burst(port_id, 0, bufs, BURST_SIZE);
        for (int i = 0; i < nb_rx; i++) {
            // 处理数据包
            process_packet(bufs[i]);
            rte_pktmbuf_free(bufs[i]);
        }
    }
    return 0;
}

看到没?没有 socket,没有 recvfrom。就是直接从网卡队列里「抢」数据包。

避坑指南:我曾经在生产环境遇到过一个问题——DPDK 程序跑着跑着就收不到包了。排查后发现是网卡的流控(Flow Control)没关。网卡发现接收队列快满了,就自动发送暂停帧,让对方停止发送。这在低延迟场景下是致命的。记得一定要关闭流控:rte_eth_dev_flow_ctrl_set(port_id, &fc_conf, 0)

3.3 XDP:内核中的快速路径

DPDK 很好,但它有一个硬伤:你需要独占网卡。如果你的机器上还有别的服务要用内核协议栈(比如 SSH 连接),DPDK 就不太方便了。

XDP(eXpress Data Path)提供了另一种思路——在内核中、驱动层之前,插入一个 BPF 程序。

3.3.1 XDP 的工作原理

XDP 程序运行在网卡驱动刚收到数据包的时刻。它可以直接决定:

  • XDP_PASS: 放行,交给内核协议栈正常处理
  • XDP_DROP: 直接丢弃,内核都看不到这个包
  • XDP_TX: 从原路返回(比如用于实现简单的防火墙或负载均衡)
  • XDP_REDIRECT: 重定向到其他网卡或用户态程序(比如 AF_XDP socket)

你想想看,这意味着什么?

你可以在纳秒级别决定一个数据包的命运。而且不需要修改内核代码,只需要写一段 BPF 程序,动态加载进去。

3.3.2 一个 XDP 丢包程序

下面这个例子,直接丢弃所有发往 80 端口的 TCP 包。相当于一个极简的 DDoS 防护。

#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
#include <linux/if_ether.h>
#include <linux/ip.h>
#include <linux/tcp.h>

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

    struct ethhdr *eth = data;
    if ((void *)(eth + 1) > data_end) return XDP_PASS;

    // 只处理 IPv4
    if (eth->h_proto != __constant_htons(ETH_P_IP)) return XDP_PASS;

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

    // 只处理 TCP
    if (ip->protocol != IPPROTO_TCP) return XDP_PASS;

    struct tcphdr *tcp = (void *)ip + sizeof(*ip);
    if ((void *)(tcp + 1) > data_end) return XDP_PASS;

    // 丢弃目标端口为 80 的包
    if (tcp->dest == __constant_htons(80)) {
        return XDP_DROP;
    }

    return XDP_PASS;
}

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

编译后,用 ip link set dev eth0 xdp obj xdp_drop.o sec xdp_drop_tcp_80 加载即可。

关键区别:DPDK 是「完全绕过内核」,XDP 是「在内核中开一条快车道」。DPDK 适合独占网卡、极致性能的场景;XDP 适合需要与内核协议栈共存、快速过滤或重定向的场景。

3.4 如何选择?

我个人的经验是:

场景 推荐方案 原因
金融行情解析、高频交易 DPDK 需要极致低延迟,且通常独占网卡
软件交换机(如 Open vSwitch) DPDK 需要大量数据包转发,用户态更灵活
DDoS 防护、流量过滤 XDP 快速丢包,且不影响其他网络服务
负载均衡器(如 Cilium) XDP + AF_XDP 兼顾性能与灵活性
需要同时使用内核协议栈 XDP DPDK 独占网卡,无法与内核共存

一个小建议:如果你刚开始接触,我建议先从 XDP 入手。因为它的学习曲线更平缓,而且不会影响你现有的网络配置。等你理解了「旁路」的本质,再去看 DPDK 会轻松很多。

3.5 本章小结

绕过内核,说白了就是「减少中间商赚差价」。

DPDK 把网卡驱动搬到用户态,让你直接操作硬件。XDP 在内核驱动层插入 BPF 程序,让你在数据包进入协议栈之前就做出决策。

两者没有绝对的优劣,关键看你的场景。但有一点是共通的:理解数据包的完整路径,你才能知道在哪里下手优化。

下一章,我们会深入 DPDK 的内存管理和大页配置。嗯,那才是真正考验功底的地方。