# 在自研 TCP 栈上跑通 WebSocket:RFC 6455 帧编解码实战 > 上一篇讲了自研 TCP 协议栈。这一篇往上走一层:如何在它上面实现 WebSocket 客户端, > 包括 HTTP Upgrade 握手、帧编解码、掩码、粘包半包、控制帧——以及掉进去的坑。 --- ## 一、为什么是 WebSocket 金融行情推送(股票、期货行情源)普遍用 WebSocket 对外提供实时数据流:HTTP 完成连接升级后,就是一个双向、低开销的帧协议。相比裸 TCP,它有标准化的握手、心跳(Ping/Pong)和关闭语义;相比 HTTP 轮询,它没有每秒数百次请求的开销。 在自研 TCP 栈上实现 WS 客户端,整个链路是: ``` DPDK 收包 → 自研 TCP 栈 → stream->rcvbuf → nrecv() → ws_recv_frame() 解帧 → 应用拿到完整 JSON 行情消息 ``` ## 二、握手:一次普通的 HTTP 请求,加上点仪式感 RFC 6455 的握手本质是一个 HTTP/1.1 Upgrade 请求。`ws_connect()`(`src/ws_client.c`)的实现步骤: 1. 生成 **16 字节随机数**,Base64 编码成 `Sec-WebSocket-Key`; 2. 发送 Upgrade 请求; 3. 收到响应后检查 `101` 状态码和 `Upgrade: websocket` 头; 4. 把响应中 `\r\n\r\n` **之后的字节**(可能是服务端抢先发的第一帧)存入 leftover 缓冲。 ```mermaid sequenceDiagram participant C as DPDK WS 客户端 participant S as WS 服务端 C->>S: GET /ws HTTP/1.1
Upgrade: websocket
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Version: 13 Note over S: 校验 key,按 RFC 计算
SHA1(key+magic) 回写 Accept S->>C: HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Sec-WebSocket-Accept: ... Note over C: 检查 101 + Upgrade 头 C->>C: 保存 \r\n\r\n 之后的字节
到 g_ws_leftover(防丢首帧) C->>S: 应用消息(TEXT 帧,带掩码) ``` 真实的握手代码(节选): ```c /* 生成随机 16 字节 key,base64 编码 */ uint8_t key_bytes[16]; ws_random_bytes(key_bytes, 16); base64_encode(key_bytes, 16, ws_key); /* 构建 HTTP Upgrade 请求 */ snprintf(handshake, sizeof(handshake), "GET %s HTTP/1.1\r\n" "Host: %s:%d\r\n" "Upgrade: websocket\r\n" "Connection: Upgrade\r\n" "Sec-WebSocket-Key: %s\r\n" "Sec-WebSocket-Version: 13\r\n", path, host, port, ws_key); ``` 两个值得注意的实现细节: - **IPv4 only**:当前用 `inet_pton(AF_INET, ...)` 解析主机,不支持域名(协议栈没有 DNS)。生产场景需要预先解析或加 DNS 模块。 - **leftover 缓冲**(`g_ws_leftover[4096]`):服务端可以把手握响应和第一帧数据**写进同一个 TCP 段**。如果解析器只读到 `\r\n\r\n` 就返回,后面的帧字节就丢了。这里把多读的字节存起来,`ws_recv_frame()` 优先从 leftover 消费——这是 WS-over-TCP 最经典的坑之一。 ## 三、帧格式:把 RFC 的图搬进代码注释 `ws_send_frame()` 的文件注释里直接画了帧结构(RFC 6455 §5.2): ``` 0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-------+-+-------------+-------------------------------+ |F|R|R|R| opcode|M| Payload len | Extended payload length | |I|S|S|S| (4) |A| (7) | (16/64) | |N|V|V|V| |S| | (if payload len==126/127) | | |1|2|3| |K| | | +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - -+ | Masking-key (4 bytes) | Payload Data | +--------------------------------+ - - - - - - - - - - - - - - -+ ``` 要点: | 字段 | 含义 | |------|------| | FIN (1bit) | 是否最后一帧(本项目暂未实现分片发送,但解析器兼容) | | opcode (4bit) | 帧类型:TEXT=0x1 / BINARY=0x2 / CLOSE=0x8 / PING=0x9 / PONG=0xA | | MASK (1bit) | 负载是否掩码。**客户端→服务端必须为 1**,服务端→客户端为 0 | | Payload len (7bit) | 长度三档:≤125 直接存;=126 用后续 2 字节;=127 用后续 8 字节 | ### 长度三档编码 ```c if (payload_len < 126) { /* 短帧: 7bit 直接存 */ } else if (payload_len <= 0xFFFF) { /* 126: 后续 2 字节大端 */ } else { /* 127: 后续 8 字节大端 */ } ``` ## 四、掩码:4 字节 XOR,安全与性能兼得 客户端发出的每一帧都必须带 4 字节随机掩码,负载逐字节 XOR: ```c /* ws_send_frame: 生成掩码并逐字节 XOR */ for (int i = 0; i < len; i++) masked[i] = payload[i] ^ mask_key[i & 3]; ``` 掩码的初衷是防止"缓存投毒"(恶意代理场景),实现代价极低——XOR 一条 SIMD 指令可以并行处理 16 字节。微基准实测**帧编解码吞吐 18 Gbps**(50 万次编码基准,`test/bench_tcp.c`)。 接收侧对应地解掩码:按规范,**服务端发来的帧不应带掩码**,但解析器做了兼容——只要 MASK 位为 1 就解。 ## 五、接收路径:一个健壮的帧解析器长什么样 `ws_recv_frame()` 的完整决策流程: ```mermaid flowchart TD A["ws_read 读 2 字节头
(优先消费 leftover)"] --> B{payload_len
== 126 / 127?} B -->|126| C[再读 2 字节扩展长度] B -->|127| D[再读 8 字节扩展长度] B -->|≤125| E{masked?} C --> E D --> E E -->|是| F[读 4 字节掩码] E -->|否| G{opcode?} F --> G G -->|CLOSE 0x8| H[消费负载, 返回 0] G -->|PING 0x9| I["读负载 → 自动回 PONG
(原样回显负载)"] G -->|PONG 0xA| J[丢弃负载] G -->|TEXT/BINARY| K{负载 > 应用缓冲?} K -->|是| L["分块读掉整个负载
(保解析器同步) → 返回 -1"] K -->|否| M[读负载 → 解掩码 → 返回] ``` 三个容易做错的点,这里都处理了: 1. **Ping → Pong 自动应答**:协议层自动回 PONG 并原样回显负载,应用层完全无感。配合 `main.c` 的 `-k 30` 参数(每 30 秒主动发一次 Ping),断线能被及时发现。 2. **超大帧跳过保持同步**:如果服务端发来一个比应用缓冲区还大的帧(本项目上限 `WS_MAX_FRAME_SIZE = 64KB`),**不能只丢弃返回**——否则后续字节会被当成下一个帧头解析,整个流全部错位。正确做法是分块把负载读完再返回错误(这是 commit `9a596e6` 修复的坑)。 3. **CLOSE 帧的负载也要消费**:否则 close 的 payload 会污染下一条连接的首帧。 ## 六、测试体系:不需要 DPDK 也能测协议 协议层与 DPDK 解耦的关键收益是**可测试性**——用内核 TCP socket 就能完整验证 WS 协议逻辑: ```bash # 1. 协议单测(内核 TCP 跑 WS 协议,不碰 DPDK) gcc -O2 -o test/test_ws_protocol test/test_ws_protocol.c -lpthread python3 test/ws_test_server.py 8080 # 起 Python 服务端(依赖 websockets) ./test_ws_protocol 127.0.0.1 8080 # 跑握手/echo/订阅/推送全流程 # 2. C 服务端(零依赖,通常跑在远端机器) gcc -O2 -o test/ws_echo_server test/ws_echo_server.c ./ws_echo_server 8080 # 3. 内核 TCP 对比基准(10 轮连接+握手+echo 延迟) python3 test/kern_c_test.py ``` 三层测试各司其职:协议单测快速迭代、C 服务端保证低延迟基准、内核对比脚本给出"反超内核"的证据。 ## 七、实测数据 WS 协议栈在自研 TCP 上的端到端表现(`PUBLISH.md`): | 指标 | DPDK WS | 内核 TCP WS | 优势 | |------|---------|-------------|------| | WS 握手 | **3.0 ms** | 5.4 ms | 快 45% | | WS echo RTT | **1.5 ms** | 3.1 ms | 快 52% | | 帧编解码吞吐(CPU 微基准) | **18 Gbps** | N/A | 50 万次编码 | ## 八、总结 WebSocket 协议本身很小:一个 HTTP 握手、一个 2-14 字节的帧头、一个 XOR 掩码。真正的工程量在**边界情况**——握手响应里夹带首帧、超大帧保同步、控制帧负载必须消费、粘包半包。把这些坑填平后,WS 层约 400 行代码,跑在自研 TCP 栈上,端到端延迟反超内核 52%。 下一篇:[微秒级性能优化实战:如何让自研 TCP 栈反超内核 52%](./03-性能优化实战-微秒级数据面.md) --- *相关源码:`src/ws_client.c`(握手 + 帧编解码)、`src/ws_client.h`(opcode 与 API)、`test/test_ws_protocol.c`(协议单测)、`test/ws_echo_server.c`(C 服务端)、`test/kern_c_test.py`(内核对比基准)*