# 从零实现一个用户态 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 无锁队列传递指针,数据本体零拷贝。 ```mermaid 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 个状态。作为客户端,我们主要走右侧这条路径: ```mermaid 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 ``` ```c /* 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` 减半,进入恢复阶段 | | RTO | RFC 6298 RTT 估计(`srtt + 4×rttvar`)+ 指数退避 | 配套的定时器系统(`src/tcp_timer.c`)在 `pkt_process` 每次迭代中检查: | 定时器 | 间隔 | 用途 | |--------|------|------| | RTO 重传 | 动态(指数退避) | 丢包恢复 | | 延迟 ACK | 200 ms | 合并 ACK,减少小包 | | Keepalive | 2 小时空闲 | 探测对端存活 | | 零窗口探测 (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 上跑。解决方案是**条件变量 + 内联轮询**: ```mermaid 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 几乎一致: ```c 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 ms** | 2.5 ms | 快 40% | | WS 握手 | **3.0 ms** | 5.4 ms | 快 45% | | WS echo RTT | **1.5 ms** | 3.1 ms | 快 52% | | 跨核包传递 | **0.2 μs** | N/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 帧编解码实战](./02-WebSocket协议RFC6455实战.md) --- *相关源码:`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`(主循环)*