侧边栏壁纸
  • 累计撰写 53 篇文章
  • 累计收到 3 条评论

从零实现一个用户态 TCP/IP 协议栈:用 DPDK 把内核 TCP 甩在身后

PacketForge
2026-08-16 / 0 评论 / 10 阅读 /

从零实现一个用户态 TCP/IP 协议栈:用 DPDK 把内核 TCP 甩在身后

6000 行 C 代码,11 状态 TCP 状态机,RFC 5681 拥塞控制,零拷贝零系统调用。
实测:TCP 连接 1.5ms(内核 2.5ms,快 40%),WS echo RTT 1.5ms(内核 3.1ms,快 52%)。

一、为什么要绕过内核 TCP

对于绝大多数应用,Linux 内核 TCP 是完美的:30 年打磨、各种边界场景都处理过。但在低延迟数据面(金融行情、交易执行、高频网关)场景下,内核协议栈有三笔"固定开销":

开销内核 TCP用户态协议栈(本项目)
内存拷贝(收+发)2 次(skb → 用户缓冲区)0 次(mbuf 全程零拷贝)
系统调用每次 I/O 都要进入内核0 次(纯用户态函数调用)
协议栈可定制性改内核模块,复杂且高风险任意修改,改完 make 即可
可观测性/proc/net/tcp 有限每个 TCB 可挂任意指标

本项目用 DPDK 24.11 接管网卡,在用户态从二层(以太网)一路实现到七层(WebSocket),数据包全程不经过内核。

测试拓扑:Intel X540 10G 网卡 → 千兆交换机 → C WebSocket 服务端,rdtsc 硬件时间戳测量。

二、整体架构:双核流水线 + 无锁队列

核心思路:用两个 lcore 组成流水线,核间只通过 rte_ring SP/SC 无锁队列传递指针,数据本体零拷贝。

flowchart TD
    subgraph NIC["物理网卡 (DPDK PMD)"]
        RX["RX 队列 (128 desc)"]
        TX["TX 队列 (512 desc)"]
    end

    subgraph LC0["lcore 0 — 主线程 (main.c)"]
        B1["rte_eth_rx_burst 收包"]
        B2["ring→out 出队 → rte_eth_tx_burst 发包"]
        B3["WS 帧接收轮询 + 应用逻辑"]
    end

    subgraph LC1["lcore 1 — pkt_process (tcp_core.c)"]
        C1["分发: ARP / ICMP / TCP"]
        C2["TCP 11 状态机 + 拥塞控制"]
        C3["定时器: RTO / delACK / keepalive / ZWP"]
        C4["TX 输出引擎 ng_tcp_out"]
    end

    RX -->|"ring→in 只入队指针"| C1
    C4 -->|"ring→out 只出队指针"| B2

    subgraph TCB["每连接 (TCB)"]
        D1["rcvbuf rte_ring (1024)"]
        D2["sndbuf rte_ring (1024)"]
        D3["pthread_cond_t + mutex"]
    end

    C2 -->|"数据入队 + cond signal"| D1
    D2 --> C4
    D1 --> B3
    B3 -->|"nsend 分段入队"| D2

为什么这样分工?

  • lcore0 只做两件事:从网卡收包、向网卡发包,以及跑应用逻辑(WS 轮询)。它不碰任何协议状态,忙轮询即可。
  • lcore1 是协议处理核心:TCP 状态机、拥塞控制、定时器都在这里。单线程无锁,天然避免竞态。
  • 跨核传递只传 rte_mbuf * 指针(8 字节入 ring),实测 0.2 μs,接近 CPU 三级缓存延迟(~15ns)的量级。

三、TCB 查找:O(1) 的两级设计

每个 TCP 连接一个 ng_tcp_stream(TCB)。数据包到达后,第一步就是找到它属于哪条连接。这里做了两级 O(1) 设计:

┌─────────────────────────────────────────────────┐
│  256 桶哈希表 (4-tuple: sip, dip, sport, dport) │  ← 包路径: 收到段 → 查哈希 → 定位 TCB
│  ht_next 链表解决冲突                            │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│  fd_to_tcb[] 直接数组 (fd → TCB 指针)            │  ← 应用路径: nsend(fd) 直接取 TCB
└─────────────────────────────────────────────────┘
  • 包路径用哈希表:pkt_process 收到一个段,用四元组哈希直接命中 TCB,平均 2.1 ns/次(微基准实测 4.81 亿次/秒)。
  • 应用路径用直查数组:nsend(fd) 无需遍历,fd_to_tcb[fd] 一条指令完成。
  • 另有一条 active_head 双向链表维护活跃连接,定时器遍历时不用扫全部 256 个桶(这是 P4 优化的成果,详见第三篇)。

四、TCP 状态机:完整的 11 状态

src/tcp_core.h 中定义了与 RFC 793 一致的 11 个状态。作为客户端,我们主要走右侧这条路径:

stateDiagram-v2
    [*] --> CLOSED
    CLOSED --> SYN_SENT: nconnect() 发 SYN
    SYN_SENT --> ESTABLISHED: 收 SYN+ACK,回 ACK
    ESTABLISHED --> FIN_WAIT_1: nclose() 发 FIN
    FIN_WAIT_1 --> FIN_WAIT_2: 收 ACK
    FIN_WAIT_2 --> TIME_WAIT: 收 FIN,回 ACK
    TIME_WAIT --> CLOSED: 2MSL 超时
    FIN_WAIT_1 --> CLOSING: 同时收到 FIN
    CLOSING --> TIME_WAIT: 收 ACK
    ESTABLISHED --> CLOSE_WAIT: 收到对方 FIN
    CLOSE_WAIT --> LAST_ACK: 本方发 FIN
    LAST_ACK --> CLOSED: 收 ACK
/* src/tcp_core.h — 状态枚举 */
typedef enum _NG_TCP_STATUS {
    NG_TCP_STATUS_CLOSED = 0,  NG_TCP_STATUS_LISTEN,
    NG_TCP_STATUS_SYN_RCVD,    NG_TCP_STATUS_SYN_SENT,
    NG_TCP_STATUS_ESTABLISHED, NG_TCP_STATUS_FIN_WAIT_1,
    NG_TCP_STATUS_FIN_WAIT_2,  NG_TCP_STATUS_CLOSING,
    NG_TCP_STATUS_TIME_WAIT,   NG_TCP_STATUS_CLOSE_WAIT,
    NG_TCP_STATUS_LAST_ACK
} NG_TCP_STATUS;

状态机入口 ng_tcp_process()(src/tcp_input.c,~690 行)按状态 + 段标志(SYN/ACK/FIN/RST)驱动迁移,每个迁移附带动作(发 ACK、唤醒等待线程、回收资源)。

五、拥塞控制:RFC 5681 全套

src/tcp_output.c 实现了完整的拥塞控制闭环,参数直接对应 RFC:

机制实现
初始窗口cwnd = 2 × MSS(~2920 字节,RFC 6928)
慢启动每收一个 ACK,cwnd += MSS,指数增长
拥塞避免超过 ssthresh 后按 MSS²/cwnd 线性增长
快速重传收到 3 个重复 ACK 立即重传,不等 RTO
快速恢复快重传后 cwnd 减半,进入恢复阶段
RTORFC 6298 RTT 估计(srtt + 4×rttvar)+ 指数退避

配套的定时器系统(src/tcp_timer.c)在 pkt_process 每次迭代中检查:

定时器间隔用途
RTO 重传动态(指数退避)丢包恢复
延迟 ACK200 ms合并 ACK,减少小包
Keepalive2 小时空闲探测对端存活
零窗口探测 (ZWP)1 s,最多 10 次对端窗口恢复后继续发

六、乱序处理:排序链表 + 有界队列

乱序段(序号超前于接收窗口左缘)进入 ofo_head 排序链表(src/tcp_input.c),最大 64 个条目:

rcv_nxt = 1000  (期望下一个字节)

收到 seq=1460 的段 → 乱序,挂入 ofo_head(按 seq 排序)
收到 seq=2460 的段 → 乱序,插入 ofo_head 第二个位置
收到 seq=1000 的段 → 命中期望!立刻交付,然后沿 ofo_head 逐条取出:
                     seq=1460 ✓ 交付
                     seq=2460 ✓ 交付 → ofo_head 清空

每个 OFO 段同样从 mempool 分配(见第三篇 P0 优化),上限 256 个,超过即丢弃,防止内存被恶意乱序攻击耗尽。

七、阻塞式 Socket API:用条件变量骗过应用层

这是最有意思的设计。用户态协议栈的难点之一:应用层习惯阻塞式调用(connect() 卡住直到连上),但协议处理在另一个 lcore 上跑。解决方案是条件变量 + 内联轮询:

sequenceDiagram
    participant APP as 应用线程 (lcore0)
    participant PKT as pkt_process (lcore1)

    APP->>APP: nconnect(): 发 SYN, 置状态 SYN_SENT
    loop 等待握手期间
        APP->>APP: 内联执行 rte_eth_rx_burst + rte_eth_tx_burst
        APP->>APP: pthread_cond_timedwait(stream->cond, 1ms)
    end
    PKT->>PKT: 收到 SYN+ACK → 状态迁移 ESTABLISHED
    PKT->>APP: pthread_cond_signal(stream->cond)
    APP->>APP: 醒来检查 state == ESTABLISHED → 返回成功

关键点(src/tcp_socket.c):

  • nrecv() 阻塞在 stream->cond 上;pkt_process 收到数据入 rcvbuf 后 signal 唤醒。
  • nconnect() 在等握手的间隙顺手帮 pkt_process 干收发包的活——反正核闲着也是闲着,这段内联轮询把连接建立延迟从"跨核通知"压到了理论下限。
  • nclose()(P3 优化后)用 rte_pause() 忙轮询替代条件变量定时等待,关闭路径无阻塞抖动。

应用层看到的 API 与 POSIX 几乎一致:

int fd = nsocket();                                          // 创建 socket
nconnect(fd, &servaddr, sizeof(servaddr));                   // TCP 三次握手
ws_connect(fd, host, port, "/ws", NULL);                     // WS 握手
nsend(fd, buf, len, 0);                                      // 发送(TCP 分段 + 拥塞控制)
nrecv(fd, buf, len, 0);                                      // 阻塞接收
nclose(fd);                                                  // 四次挥手

八、发送路径:从 nsend 到网线

nsend(fd, 1MB)
  → 按 MSS 1460 切成 ~700 个 ng_tcp_fragment(从 g_frag_pool 分配)
  → 逐个入 stream->sndbuf ring(不拷贝,指针传递)
      └─ pkt_process 每轮迭代:
           ng_tcp_out(): 从 sndbuf 出队 → 组以太网/IP/TCP 头
           → 查 ARP 表解析 MAC(未命中: 发 ARP 请求 + 段重新入队等待)
           → 受 cwnd 窗口限制,未发出的段留在 sndbuf
           → 入 ring->out → lcore0 出队 → rte_eth_tx_burst 发出

ARP 请求做了 10 次/秒/目的地址 的限速(src/arp.h 链表实现),避免对端无响应时刷爆网络。

九、实测结果

同一物理链路,rdtsc 时间戳测量(完整数据见 PUBLISH.md):

指标DPDK(本项目)Linux 内核 TCP优势
TCP 连接1.5 ms2.5 ms快 40%
WS 握手3.0 ms5.4 ms快 45%
WS echo RTT1.5 ms3.1 ms快 52%
跨核包传递0.2 μsN/A硬件极限

代码规模(纯 C,仅依赖 DPDK):

模块行数
TCP 核心(状态机 + 拥塞控制)~3500
SACK + 乱序队列~700
拥塞控制~600
定时器~200
Socket API~500
WebSocket~400
合计~6000

十、总结

一个"能用"的 TCP 客户端栈其实不需要几万行代码:11 个状态、4 个定时器、2 条无锁队列、1 张哈希表,就是全部骨架。真正拉开性能差距的,是绕开内核后的零拷贝与零系统调用,以及后续一系列数据面优化(P0-P4,见第三篇)。

下一篇:在自研 TCP 栈上跑通 WebSocket:RFC 6455 帧编解码实战


相关源码:src/tcp_core.h(结构体与常量)、src/tcp_input.c(状态机)、src/tcp_output.c(拥塞控制)、src/tcp_timer.c(定时器)、src/tcp_socket.c(Socket API)、src/main.c(主循环)

0

评论 (0)

取消