在自研 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)的实现步骤:
- 生成 16 字节随机数,Base64 编码成
Sec-WebSocket-Key; - 发送 Upgrade 请求;
- 收到响应后检查
101状态码和Upgrade: websocket头; - 把响应中
\r\n\r\n之后的字节(可能是服务端抢先发的第一帧)存入 leftover 缓冲。
sequenceDiagram
participant C as DPDK WS 客户端
participant S as WS 服务端
C->>S: GET /ws HTTP/1.1<br/>Upgrade: websocket<br/>Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==<br/>Sec-WebSocket-Version: 13
Note over S: 校验 key,按 RFC 计算<br/>SHA1(key+magic) 回写 Accept
S->>C: HTTP/1.1 101 Switching Protocols<br/>Upgrade: websocket<br/>Sec-WebSocket-Accept: ...
Note over C: 检查 101 + Upgrade 头
C->>C: 保存 \r\n\r\n 之后的字节<br/>到 g_ws_leftover(防丢首帧)
C->>S: 应用消息(TEXT 帧,带掩码)真实的握手代码(节选):
/* 生成随机 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 字节 |
长度三档编码
if (payload_len < 126) {
/* 短帧: 7bit 直接存 */
} else if (payload_len <= 0xFFFF) {
/* 126: 后续 2 字节大端 */
} else {
/* 127: 后续 8 字节大端 */
}四、掩码:4 字节 XOR,安全与性能兼得
客户端发出的每一帧都必须带 4 字节随机掩码,负载逐字节 XOR:
/* 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() 的完整决策流程:
flowchart TD
A["ws_read 读 2 字节头<br/>(优先消费 leftover)"] --> B{payload_len<br/>== 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<br/>(原样回显负载)"]
G -->|PONG 0xA| J[丢弃负载]
G -->|TEXT/BINARY| K{负载 > 应用缓冲?}
K -->|是| L["分块读掉整个负载<br/>(保解析器同步) → 返回 -1"]
K -->|否| M[读负载 → 解掩码 → 返回]三个容易做错的点,这里都处理了:
- Ping → Pong 自动应答:协议层自动回 PONG 并原样回显负载,应用层完全无感。配合
main.c的-k 30参数(每 30 秒主动发一次 Ping),断线能被及时发现。 - 超大帧跳过保持同步:如果服务端发来一个比应用缓冲区还大的帧(本项目上限
WS_MAX_FRAME_SIZE = 64KB),不能只丢弃返回——否则后续字节会被当成下一个帧头解析,整个流全部错位。正确做法是分块把负载读完再返回错误(这是 commit9a596e6修复的坑)。 - CLOSE 帧的负载也要消费:否则 close 的 payload 会污染下一条连接的首帧。
六、测试体系:不需要 DPDK 也能测协议
协议层与 DPDK 解耦的关键收益是可测试性——用内核 TCP socket 就能完整验证 WS 协议逻辑:
# 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%
相关源码: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(内核对比基准)
评论 (0)