WebSocket 协议、帧结构与 MTU 详解
目录
- [WebSocket 协议概述](#WebSocket 协议概述 "#websocket-%E5%8D%8F%E8%AE%AE%E6%A6%82%E8%BF%B0")
- [WebSocket 帧结构](#WebSocket 帧结构 "#websocket-%E5%B8%A7%E7%BB%93%E6%9E%84")
- [MTU 与网络分层](#MTU 与网络分层 "#mtu-%E4%B8%8E%E7%BD%91%E7%BB%9C%E5%88%86%E5%B1%82")
- 帧拆分与重组机制
- [libwebsockets 实现细节](#libwebsockets 实现细节 "#libwebsockets-%E5%AE%9E%E7%8E%B0%E7%BB%86%E8%8A%82")
- 实际传输流程示例
WebSocket 协议概述
WebSocket 是一种在单个 TCP 连接上进行全双工通信的协议。它允许客户端和服务器之间进行实时、双向的数据传输。
协议特点
- 全双工通信:客户端和服务器可以同时发送和接收数据
- 基于 TCP:建立在可靠的 TCP 连接之上
- 帧格式传输:数据以帧(Frame)为单位进行传输
- 支持消息分片:大消息可以被拆分成多个帧
WebSocket 帧结构
帧格式
WebSocket 帧由以下部分组成:
lua
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 ... |
+---------------------------------------------------------------+
帧头字段说明
| 字段 | 位数 | 说明 |
|---|---|---|
| FIN | 1 bit | 标识是否为消息的最后一个片段。1 = 最后片段,0 = 还有后续片段 |
| RSV1-3 | 3 bits | 保留字段,必须为 0 |
| Opcode | 4 bits | 帧类型: 0x0 = 延续帧 0x1 = 文本帧 0x2 = 二进制帧 0x8 = 连接关闭 0x9 = ping 0xA = pong |
| MASK | 1 bit | 是否使用掩码。客户端到服务器必须为 1,服务器到客户端为 0 |
| Payload Length | 7 bits | 载荷长度: 0-125 = 实际长度 126 = 后续 2 字节表示长度 127 = 后续 8 字节表示长度 |
| Masking-key | 0/4 bytes | 如果 MASK=1,则包含 4 字节掩码键 |
| Payload Data | 可变 | 实际数据载荷 |
帧类型示例
单帧消息(小消息)
ini
FIN=1, Opcode=0x1, Payload="Hello"
- 一条完整的文本消息,在一个帧中传输
多帧消息(大消息)
ini
帧1: FIN=0, Opcode=0x1, Payload="前3KB数据"
帧2: FIN=0, Opcode=0x0, Payload="中间4KB数据" (延续帧)
帧3: FIN=1, Opcode=0x0, Payload="后3KB数据" (延续帧)
- 第一条帧的 Opcode 表示消息类型(文本/二进制)
- 后续帧的 Opcode 必须为 0x0(延续帧)
- 只有最后一个帧的 FIN=1
MTU 与网络分层
MTU 概念
MTU (Maximum Transmission Unit) 是网络层能够传输的最大数据包大小。
- 以太网 MTU:通常为 1500 字节
- 实际 TCP 载荷 :MTU - IP 头(20字节) - TCP 头(20字节) = 1460 字节
网络协议栈分层
┌─────────────────────────────────────┐
│ 应用层: WebSocket 消息 (10KB) │
├─────────────────────────────────────┤
│ WebSocket 层: WebSocket 帧 │ ← 帧大小:几KB到几十KB
│ (受 libwebsockets 缓冲区限制) │
├─────────────────────────────────────┤
│ TCP 层: TCP 段 │ ← 段大小:受 TCP 发送缓冲区限制
│ (受 TCP 发送缓冲区限制) │
├─────────────────────────────────────┤
│ IP 层: IP 数据包 │ ← 包大小:受 MTU 限制 (1500字节)
│ (根据 MTU 自动分片) │
├─────────────────────────────────────┤
│ 以太网层: 以太网帧 │
└─────────────────────────────────────┘
各层的作用
- WebSocket 层:将消息拆分成帧,处理协议逻辑
- TCP 层:提供可靠传输,流量控制,拥塞控制
- IP 层:根据 MTU 自动分片,路由转发
- 以太网层:物理传输
帧拆分与重组机制
发送端:自动拆分
当发送大消息时,libwebsockets 会自动将消息拆分成多个帧:
拆分依据:
- 主要因素:libwebsockets 的内部缓冲区大小(通常 4KB-64KB)
- 次要因素:TCP 发送缓冲区大小
- 不是直接按 MTU 拆分:MTU 是 IP 层的事情,WebSocket 层不关心
拆分过程:
ini
完整消息 (10KB)
↓
libwebsockets 按缓冲区大小拆分
↓
帧1: FIN=0, Opcode=0x1, Payload=4KB
帧2: FIN=0, Opcode=0x0, Payload=4KB (延续帧)
帧3: FIN=1, Opcode=0x0, Payload=2KB (延续帧)
接收端:需要手动重组
接收端需要根据 FIN 标志判断消息是否完整:
重组逻辑:
- 收到帧时,检查
FIN标志 - 如果
FIN=0:将 payload 累积到缓冲区 - 如果
FIN=1:合并所有片段,得到完整消息
代码实现 (参考 CppWebSocket.cpp):
cpp
case LWS_CALLBACK_CLIENT_RECEIVE:
bool is_final = lws_is_final_fragment(wsi);
// 累积当前片段
receiveBuffer.append((const char *)in, len);
// 如果是最后一个片段,处理完整消息
if (is_final) {
std::string completeMsg = std::move(receiveBuffer);
receiveBuffer.clear();
callOnMessage(completeMsg); // 传递给上层
}
libwebsockets 实现细节
协议配置
在 CppWebSocket.cpp 中的配置:
cpp
static struct lws_protocols protocols[] = {
{
"cpp-websocket-protocol", // 协议名称
&CppWebSocket::Impl::lwsCallback, // 回调函数
sizeof(void *), // 每个连接的用户数据大小
4096, // rx_buffer_size: 接收缓冲区大小
0, // tx_packet_size: 发送包大小(0=自动)
nullptr, // 协议特定数据
0 // 协议索引
}
};
关键参数说明
| 参数 | 值 | 说明 |
|---|---|---|
| rx_buffer_size | 4096 | 接收缓冲区大小,影响每次 LWS_CALLBACK_CLIENT_RECEIVE 回调的最大数据量 |
| tx_packet_size | 0 | 发送包大小,0 表示由库自动决定(通常也是几KB) |
发送流程
cpp
// 1. 用户调用 send()
send("10KB完整消息");
// 2. 消息加入队列
sendQueue.push(message);
// 3. 触发写事件
lws_callback_on_writable(wsi);
// 4. 在 WRITEABLE 回调中发送
lws_write(wsi, buf.data() + LWS_PRE, msg.size(), LWS_WRITE_TEXT);
// libwebsockets 会自动拆分帧并发送
接收流程
cpp
// 1. libwebsockets 解析帧头
// 2. 提取 payload 数据
// 3. 调用 LWS_CALLBACK_CLIENT_RECEIVE
// 4. 用户代码检查 FIN 标志并重组消息
实际传输流程示例
场景:发送一条 10KB 的消息
第 1 层:WebSocket 层(应用层)
ini
原始消息: 10KB 文本数据
↓ libwebsockets 拆分(按内部缓冲区,假设 4KB)
WebSocket 帧1: FIN=0, Opcode=0x1, Payload=4KB
WebSocket 帧2: FIN=0, Opcode=0x0, Payload=4KB
WebSocket 帧3: FIN=1, Opcode=0x0, Payload=2KB
第 2 层:TCP 层
每个 WebSocket 帧 → TCP 段
TCP 段1: ~4KB (包含 WebSocket 帧1)
TCP 段2: ~4KB (包含 WebSocket 帧2)
TCP 段3: ~2KB (包含 WebSocket 帧3)
第 3 层:IP 层(根据 MTU=1500 字节分片)
yaml
TCP 段1 (4KB) → IP 数据包
IP 包1: 1460 字节 payload (1500 - 20 IP头 - 20 TCP头)
IP 包2: 1460 字节 payload
IP 包3: 1460 字节 payload
IP 包4: 220 字节 payload
TCP 段2 (4KB) → IP 数据包
IP 包5: 1460 字节 payload
IP 包6: 1460 字节 payload
IP 包7: 1460 字节 payload
IP 包8: 220 字节 payload
TCP 段3 (2KB) → IP 数据包
IP 包9: 1460 字节 payload
IP 包10: 540 字节 payload
接收端重组过程
ini
IP 层: 重组 IP 分片 → TCP 段
TCP 层: 重组 TCP 段 → WebSocket 帧
WebSocket 层: 根据 FIN 标志重组帧 → 完整消息
接收回调1: payload=4KB, FIN=0 → 累积到缓冲区
接收回调2: payload=4KB, FIN=0 → 累积到缓冲区
接收回调3: payload=2KB, FIN=1 → 合并完成,得到 10KB 完整消息 ✓
关键要点总结
1. WebSocket 帧大小 ≠ MTU
- WebSocket 帧:通常几 KB 到几十 KB(由 libwebsockets 缓冲区决定)
- MTU:通常 1500 字节(IP 层限制)
- WebSocket 帧可以大于 MTU,TCP/IP 层会自动分片
2. 为什么不是直接按 MTU 拆分?
- 减少帧头开销:WebSocket 帧头约 2-14 字节,按 MTU 拆分会产生更多帧头
- 提高效率:较大的帧减少协议处理次数
- 简化实现:由 TCP/IP 层处理 MTU 分片,应用层无需关心
3. 发送端 vs 接收端
| 端 | 处理方式 | 说明 |
|---|---|---|
| 发送端 | 自动处理 | libwebsockets 自动拆分帧,用户只需调用 lws_write() |
| 接收端 | 手动重组 | 需要根据 FIN 标志累积片段,直到收到完整消息 |
4. 实际配置建议
- rx_buffer_size : 根据应用场景调整(4KB-64KB)
- 太小:增加帧数量,增加处理开销
- 太大:增加内存占用,延迟增加
- 保持默认值:对于大多数应用,4KB 是合理的默认值
参考资料
文档版本 : 1.0
最后更新: 2025