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

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

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

在自研 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 缓冲。
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[读负载 → 解掩码 → 返回]

三个容易做错的点,这里都处理了:

  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 协议逻辑:

# 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 ms5.4 ms快 45%
WS echo RTT1.5 ms3.1 ms快 52%
帧编解码吞吐(CPU 微基准)18 GbpsN/A50 万次编码

八、总结

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

评论 (0)

取消