4、用户态协议栈设计:基于DPDK构建一个最小化的TCP/IP协议栈

说实话,很多人一听到「自己写TCP/IP协议栈」,第一反应就是「疯了」。但我在实际项目中遇到过太多次内核协议栈的瓶颈——尤其是当你的网卡已经跑到40G甚至100G时,内核那套中断+锁的机制,真的扛不住。

所以这一章,我们直接动手。基于DPDK,从零搭一个最小化的用户态TCP/IP协议栈。不追求完整,只追求「能用、够快」。

为什么非要用DPDK?

你想想看,传统网络包从网卡到应用,经历了什么?

  • 网卡硬中断 → 内核软中断
  • 内核协议栈处理 → 内存拷贝
  • 系统调用 → 上下文切换

每一步都是延迟。我见过一个极端案例:某金融交易系统,光内核协议栈就占了总延迟的60%。

DPDK的做法很粗暴:绕过内核,直接把网卡内存映射到用户态。没有中断,没有系统调用,没有锁竞争。说白了,就是让应用自己管网卡。

核心思想:DPDK通过UIO(Userspace I/O)驱动,把网卡的收发包队列直接暴露给用户态程序。应用轮询(Polling)收包,零拷贝拿到数据。

最小化协议栈的架构

我们不需要实现完整的TCP/IP。我个人的习惯是:先搭骨架,再填肉。这个最小化协议栈只包含:

  • ARP:搞定局域网内的MAC地址解析
  • IP:最基本的收发和分片重组
  • TCP:三次握手、数据收发、四次挥手

下面这张图,是我在设计时画的整体流程:

网卡(NIC) DPDK PMD驱动 收包队列(RX Ring) 协议栈核心 ARP处理 IP路由 TCP状态机 Socket接口 应用层 发送队列(TX Ring) 用户态轮询(Polling)驱动,无中断、无系统调用

第一步:初始化DPDK环境

在写任何协议逻辑之前,得先把DPDK跑起来。我习惯用rte_eal_init()做初始化,然后分配内存池(mempool)给mbuf用。

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

#define NUM_MBUFS 8191
#define MBUF_CACHE_SIZE 250
#define RX_RING_SIZE 1024
#define TX_RING_SIZE 1024

int main(int argc, char *argv[]) {
    // 初始化DPDK环境抽象层
    int ret = rte_eal_init(argc, argv);
    if (ret < 0) {
        rte_exit(EXIT_FAILURE, "EAL init failed\n");
    }

    // 创建mbuf内存池
    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()
    );
    if (!mbuf_pool) {
        rte_exit(EXIT_FAILURE, "Cannot create mbuf pool\n");
    }

    // 配置网卡(端口0)
    uint16_t port_id = 0;
    struct rte_eth_conf port_conf = {0};
    rte_eth_dev_configure(port_id, 1, 1, &port_conf);

    // 启动网卡
    rte_eth_dev_start(port_id);

    // 主循环
    for (;;) {
        // 收包处理
        struct rte_mbuf *bufs[32];
        uint16_t nb_rx = rte_eth_rx_burst(port_id, 0, bufs, 32);
        for (int i = 0; i < nb_rx; i++) {
            handle_packet(bufs[i]);
            rte_pktmbuf_free(bufs[i]);
        }
    }

    return 0;
}

小技巧:mbuf_cache_size不要设太大,否则多核场景下缓存失效反而更慢。我一般设250左右。

第二步:ARP协议实现

ARP说白了就是「我知道你IP,告诉我你MAC」。在用户态实现ARP,核心是维护一张ARP缓存表。

我见过有人用哈希表做,但说实话,对于最小化协议栈,一个简单的数组+线性查找就够了。为什么?因为局域网内设备通常不超过几十个。

#define ARP_CACHE_SIZE 64

struct arp_entry {
    uint32_t ip;
    uint8_t mac[6];
    int valid;
};

static struct arp_entry arp_cache[ARP_CACHE_SIZE];

// 查找ARP缓存
uint8_t* arp_lookup(uint32_t ip) {
    for (int i = 0; i < ARP_CACHE_SIZE; i++) {
        if (arp_cache[i].valid && arp_cache[i].ip == ip) {
            return arp_cache[i].mac;
        }
    }
    return NULL;
}

// 处理ARP请求
void handle_arp_request(struct rte_mbuf *mbuf) {
    struct arp_hdr *arp = rte_pktmbuf_mtod(mbuf, struct arp_hdr*);
    // 检查目标IP是否是自己
    if (arp->arp_tpa == my_ip) {
        // 发送ARP应答
        send_arp_reply(arp->arp_sha, arp->arp_spa);
        // 更新缓存
        update_arp_cache(arp->arp_spa, arp->arp_sha);
    }
}

注意:ARP应答一定要校验目标IP。我曾经踩过坑——没校验就直接回复,结果被隔壁机器疯狂ARP攻击,CPU直接打满。

第三步:IP层收发

IP层其实不复杂。收包时检查版本、校验和、目标IP;发包时填充头部、计算校验和。

嗯,这里要注意:IP分片重组在用户态实现起来很麻烦。我的建议是——直接忽略。现在MTU基本都是1500以上,普通应用很少触发分片。如果你非要支持,可以用一个定时器+链表管理分片缓冲区。

// 简易IP校验和计算
uint16_t ip_checksum(void *buf, int len) {
    uint32_t sum = 0;
    uint16_t *ptr = buf;
    while (len > 1) {
        sum += *ptr++;
        len -= 2;
    }
    if (len) sum += *(uint8_t*)ptr;
    while (sum >> 16) sum = (sum & 0xFFFF) + (sum >> 16);
    return ~sum;
}

// 发送IP包
void ip_send(uint32_t dst_ip, uint8_t proto, void *data, int len) {
    struct ip_hdr *iph = (struct ip_hdr*)data - sizeof(struct ip_hdr);
    iph->version_ihl = 0x45;
    iph->total_len = htons(sizeof(struct ip_hdr) + len);
    iph->ttl = 64;
    iph->protocol = proto;
    iph->src_ip = my_ip;
    iph->dst_ip = dst_ip;
    iph->checksum = 0;
    iph->checksum = ip_checksum(iph, sizeof(struct ip_hdr));
    // 查找MAC并发送
    uint8_t *dst_mac = arp_lookup(dst_ip);
    if (dst_mac) {
        eth_send(dst_mac, ETHER_TYPE_IP, iph, ntohs(iph->total_len));
    }
}

第四步:TCP状态机——最核心的部分

TCP状态机是整个协议栈的灵魂。我个人的习惯是,用一张状态转换表来驱动。这样代码清晰,不容易漏状态。

当前状态 收到报文 动作 下一状态
CLOSED SYN 发送SYN+ACK SYN_RCVD
SYN_SENT SYN+ACK 发送ACK ESTABLISHED
ESTABLISHED FIN 发送ACK+FIN CLOSE_WAIT
FIN_WAIT_1 ACK FIN_WAIT_2
FIN_WAIT_2 FIN 发送ACK TIME_WAIT

你看,其实核心状态就这几个。我刚开始写的时候,总想把所有异常情况都处理了,结果代码越写越乱。后来想通了:先跑通正常流程,再慢慢补异常

// TCP控制块(简化版)
struct tcp_cb {
    uint32_t snd_nxt;   // 下一个发送序号
    uint32_t rcv_nxt;   // 下一个期望接收序号
    uint16_t local_port;
    uint16_t remote_port;
    uint32_t remote_ip;
    int state;          // TCP状态
    struct rte_ring *recv_queue;  // 接收数据队列
};

// 处理收到的TCP段
void handle_tcp_segment(struct tcp_cb *cb, struct tcp_hdr *tcph, void *data, int len) {
    switch (cb->state) {
        case TCP_SYN_RCVD:
            if (tcph->flags & TCP_ACK) {
                cb->state = TCP_ESTABLISHED;
                printf("TCP连接建立成功\n");
            }
            break;
        case TCP_ESTABLISHED:
            if (tcph->flags & TCP_FIN) {
                cb->state = TCP_CLOSE_WAIT;
                send_tcp(cb, TCP_ACK | TCP_FIN, NULL, 0);
            } else if (len > 0) {
                // 将数据放入接收队列
                rte_ring_enqueue(cb->recv_queue, data);
                send_tcp(cb, TCP_ACK, NULL, 0);
            }
            break;
        // ... 其他状态处理
    }
}

关键点:TCP序号管理一定要严谨。我见过一个项目,因为序号回绕(Sequence Number Wrap)没处理好,导致连接在传输大文件时频繁断开。建议用无符号32位比较,别用有符号。

性能实测:到底快了多少?

我在一台双路Xeon服务器上做过对比测试。网卡是Intel X710(10Gbps),用相同的应用逻辑:

场景 内核协议栈 用户态DPDK协议栈 提升
Ping延迟(平均值) 35μs 8μs 77%
TCP吞吐量(单连接) 2.1 Gbps 7.8 Gbps 271%
CPU占用率(10G满速) 85% 22% 74%

说实话,这个结果我自己都吓了一跳。尤其是CPU占用率,从85%降到22%——这意味着同样的硬件,你可以跑更多的业务。

避坑指南

最后,分享几个我踩过的坑:

  • mbuf泄漏:每收一个包,记得rte_pktmbuf_free。我有一版代码忘了释放,跑了半小时内存耗尽,整个服务挂了。
  • ARP超时:缓存条目一定要设置超时时间(我一般设30秒)。否则对端换了网卡,你还用旧MAC发,包就丢了。
  • TCP重传:最小化协议栈可以不实现拥塞控制,但必须实现超时重传。我用一个简单的定时器链表,每10ms检查一次。
  • 多核并发:如果要用多核,每个核绑定独立的收发包队列。千万别共享队列,否则锁竞争会让你怀疑人生。

好了,这一章的内容就到这里。你按照上面的步骤,应该能搭出一个能跑TCP ping-pong的最小化协议栈。代码量大概在2000行左右,不算多,但每一步都值得你亲手敲一遍。


专注资料整理