58、WebSocket数据帧:帧格式解析、掩码处理、数据分片

WebSocket 这玩意儿,说白了就是让浏览器和服务器能「随时聊天」的协议。但聊天也得有规矩,数据怎么包装、怎么拆解,全看这个数据帧。今天咱们就把它扒开看看。

帧格式:一个字节一个字节地啃

先看最核心的东西——帧长什么样。我当年第一次看 RFC 6455 的时候,也被那一堆位域搞得头晕。但实际用起来,其实就几个关键字段。

 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|             |                               |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - -+
|     Extended payload length continued, if payload len == 127  |
+ - - - - - - - - - - - - - - - +-------------------------------+
|                               |Masking-key, if MASK set to 1  |
+-------------------------------+-------------------------------+
| Masking-key (continued)       |          Payload Data         |
+-------------------------------- - - - - - - - - - - - - - - -+
:                     Payload Data continued ...                :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -+
|                     Payload Data (continued)                  |
+---------------------------------------------------------------+

嗯,这张图是 RFC 里的标准格式。咱们拆开来看:

  • FIN(1 bit):标记这是不是最后一帧。0 表示还有后续分片,1 表示结束。
  • RSV1-3(各 1 bit):保留位,必须为 0。除非你用了扩展协议。
  • Opcode(4 bits):告诉对方这帧是啥类型。0x1 文本、0x2 二进制、0x8 关闭、0x9 ping、0xA pong。
  • MASK(1 bit):客户端发服务器必须为 1,服务器发客户端必须为 0。这是强制规定。
  • Payload length(7 bits):数据长度。如果小于 126,这就是实际长度。等于 126 时,后面跟 2 字节扩展长度。等于 127 时,后面跟 8 字节扩展长度。
  • Masking-key(4 bytes):如果 MASK=1,这里就是掩码密钥。
  • Payload Data:真正的数据内容。

核心要点:帧头最少 2 字节,最多 14 字节(含掩码)。解析时一定要按位操作,别偷懒用结构体对齐——不同平台字节序不一样,我吃过这个亏。

掩码处理:为什么客户端要加掩码?

你可能想问:为什么客户端发数据必须加掩码,服务器却不用?

这其实是个安全设计。早期 WebSocket 协议没有掩码时,有人利用浏览器缓存投毒攻击。简单说,攻击者可以伪造数据包,让缓存服务器误以为某个页面是攻击者控制的。加上掩码后,客户端发出的每个字节都经过 XOR 运算,攻击者无法预测最终数据长什么样。

掩码算法很简单:

// 掩码处理函数
void apply_mask(uint8_t *data, size_t len, const uint8_t mask[4]) {
    for (size_t i = 0; i < len; i++) {
        data[i] ^= mask[i % 4];
    }
}

就这么几行。把数据每个字节和掩码循环 XOR 一下。解码时再做一次同样的操作,数据就还原了。

个人经验:我曾经在嵌入式设备上实现 WebSocket,那个 CPU 没硬件加速,逐字节 XOR 成了性能瓶颈。后来改成 32 位字对齐处理,速度提升了 4 倍。如果你的数据量大,可以考虑这个优化。

数据分片:大消息怎么传?

WebSocket 允许你把一个消息拆成多个帧发送。这就是分片机制。为什么要分片?

  • 发送端可以边生成数据边发,不用等全部准备好
  • 接收端可以边收边处理,不用一次性分配大缓冲区
  • 网络层对单个帧大小有限制,分片可以绕过 MTU 问题

分片的规则其实就几条:

  1. 第一个分片:FIN=0,opcode 为消息类型(0x1 或 0x2)
  2. 中间分片:FIN=0,opcode=0x0(延续帧)
  3. 最后一个分片:FIN=1,opcode=0x0

举个例子,发送 "Hello World" 可以拆成三帧:

帧1: FIN=0, opcode=0x1, payload="Hel"
帧2: FIN=0, opcode=0x0, payload="lo "
帧3: FIN=1, opcode=0x0, payload="World"

接收端收到 FIN=0 的帧时,得先把数据缓存起来,等 FIN=1 的帧到了再拼成完整消息。这里有个坑——如果中间丢了帧,整个消息就废了。所以实际项目中,我建议对大数据做应用层校验。

避坑指南:我曾经在实现分片重组时,没处理好内存释放。客户端发了个超大分片消息,中间断开了,缓存没清掉,导致内存泄漏。记住:连接关闭时,一定要清理所有未完成的分片缓存。

实战:手写一个帧解析器

光说不练假把式。咱们写个简单的帧解析函数:

#include <stdint.h>
#include <stddef.h>

typedef struct {
    uint8_t fin;
    uint8_t opcode;
    uint8_t mask;
    uint8_t mask_key[4];
    uint64_t payload_len;
    uint8_t *payload;
} ws_frame_t;

int parse_frame(uint8_t *buf, size_t len, ws_frame_t *frame) {
    if (len < 2) return -1;  // 至少要有头两个字节
    
    // 解析第一个字节
    frame->fin = (buf[0] >> 7) & 0x01;
    frame->opcode = buf[0] & 0x0F;
    
    // 解析第二个字节
    frame->mask = (buf[1] >> 7) & 0x01;
    uint64_t payload_len = buf[1] & 0x7F;
    
    size_t offset = 2;
    
    // 处理扩展长度
    if (payload_len == 126) {
        if (len < 4) return -1;
        payload_len = (buf[2] << 8) | buf[3];
        offset = 4;
    } else if (payload_len == 127) {
        if (len < 10) return -1;
        payload_len = 0;
        for (int i = 0; i < 8; i++) {
            payload_len = (payload_len << 8) | buf[2 + i];
        }
        offset = 10;
    }
    
    frame->payload_len = payload_len;
    
    // 处理掩码
    if (frame->mask) {
        if (len < offset + 4) return -1;
        for (int i = 0; i < 4; i++) {
            frame->mask_key[i] = buf[offset + i];
        }
        offset += 4;
    }
    
    // 检查数据是否完整
    if (len < offset + payload_len) return -1;
    
    frame->payload = buf + offset;
    
    // 如果有掩码,就地解码
    if (frame->mask) {
        for (size_t i = 0; i < payload_len; i++) {
            frame->payload[i] ^= frame->mask_key[i % 4];
        }
    }
    
    return 0;
}

这个函数把原始字节流解析成结构体,同时完成了掩码解码。注意看,我用了就地解码——直接修改原始缓冲区。这样做省内存,但如果你还需要原始数据,记得先拷贝一份。

SVG:帧解析流程

下面这张图展示了从字节流到帧结构的完整流程:

WebSocket 帧解析流程 原始字节流 解析前2字节 FIN | opcode | MASK | len 判断 payload 长度 len<126 / len==126 / len==127 len < 126 len == 126 len == 127 掩码处理 MASK=1 时读取4字节密钥 提取 Payload Data XOR 解码(如有掩码) 解析完成

常见问题与避坑

问题 原因 解决方案
解析到超大 payload 长度 恶意客户端伪造长度字段 设置最大帧大小限制,超过直接断开
分片消息不完整 网络中断或客户端异常关闭 连接关闭时清理所有分片缓存
掩码解码后数据乱码 掩码密钥读取位置错误 确认扩展长度计算正确后再读掩码
opcode 为 0x0 但 FIN=1 协议实现错误 检查发送端逻辑,延续帧必须跟在首帧后

我的习惯:在解析帧时,我会先校验 opcode 的合法性。比如收到 0x3-0x7 或 0xB-0xF 这些保留值,直接断开连接。别给攻击者留机会。

好了,WebSocket 数据帧的核心内容就这些。从帧格式到掩码处理,再到分片重组,每一步都有它的设计道理。你写代码时,多想想「为什么这么设计」,而不是「怎么实现」,这样进步会更快。

公众号:蓝海资料掘金营,微信deep3321