【C/C++】C 语言实现 WebSocket:握手、帧解析、掩码和回显

1. WebSocket 为什么要先握手
WebSocket 不是一开始就直接发送二进制帧,它先通过 HTTP 发起升级请求。浏览器会发送类似这样的请求头:
GET / HTTP/1.1
Host: 127.0.0.1:8080
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
服务端要读取 Sec-WebSocket-Key,按 RFC 6455 的规则生成 Sec-WebSocket-Accept,再返回 101 Switching Protocols。之后这条 TCP 连接才进入 WebSocket 帧通信阶段。
项目中的 WebSocket 状态用 conn->state 表示:
state == 0:还没有完成握手。state == 1:握手完成,等待 WebSocket 数据帧。state == 2:已经解析出一帧 payload,准备回包。
2. 握手:Key + GUID + SHA1 + Base64
WebSocket 协议规定服务端要把客户端的 Sec-WebSocket-Key 拼上固定 GUID:
#define GUID "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
然后做 SHA1,再 Base64:
char combined[512];
snprintf(combined, sizeof(combined), "%s%s", client_key, GUID);
unsigned char hash[SHA_DIGEST_LENGTH];
SHA1((unsigned char *)combined, strlen(combined), hash);
char accept_key[256];
base64_encode(hash, SHA_DIGEST_LENGTH, accept_key);
最后拼出 HTTP 101 响应:
int response_length = snprintf(conn->wbuffer, sizeof(conn->wbuffer),
"HTTP/1.1 101 Switching Protocols\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
"Sec-WebSocket-Accept: %s\r\n\r\n",
accept_key);
conn->wlength = response_length;
这段响应通过 Reactor 的 send_cb() 写回浏览器。浏览器收到合法的 101 响应后,ws.onopen 才会触发。
3. 从 HTTP 文本切换到 WebSocket 帧
websocket_request() 根据状态分两种处理:
int websocket_request(struct connection *conn) {
if (conn->state == 0) {
if (handshake(conn) < 0) {
return -1;
}
conn->state = 1;
} else if (conn->state == 1) {
ws_frame_t frame;
ws_parse_result_t result = ws_parse_frame((const uint8_t *)conn->rbuffer,
conn->rlength,
&frame);
if (result == WS_PARSE_OK) {
conn->state = 2;
for (uint64_t index = 0; index < frame.payload_len; ++index) {
conn->payload[index] = frame.payload[index] ^ frame.masking_key[index % 4];
}
conn->payload_length = (int)frame.payload_len;
}
}
return 0;
}
握手之前,收到的是 HTTP 文本;握手之后,收到的是 WebSocket 二进制帧。代码里最重要的转折点就是 conn->state = 1。
4. WebSocket 帧头结构
项目中定义了 ws_frame_t 保存解析结果:
typedef struct
{
uint8_t fin;
uint8_t rsv1;
uint8_t rsv2;
uint8_t rsv3;
uint8_t opcode;
uint8_t masked;
uint64_t payload_len;
uint8_t masking_key[4];
size_t header_len;
const uint8_t *payload;
} ws_frame_t;
WebSocket 帧的前两个字节非常关键:
- 第 1 字节:
FIN、RSV1/2/3、opcode。 - 第 2 字节:
MASK和 payload length code。 - 如果 length code 是 126,后面还有 2 字节长度。
- 如果 length code 是 127,后面还有 8 字节长度。
- 浏览器发给服务端的数据必须带 masking key。
项目里的解析代码先读前两个字节:
uint8_t b0 = buf[0];
uint8_t b1 = buf[1];
frame->fin = (b0 >> 7) & 0x01;
frame->rsv1 = (b0 >> 6) & 0x01;
frame->rsv2 = (b0 >> 5) & 0x01;
frame->rsv3 = (b0 >> 4) & 0x01;
frame->opcode = b0 & 0x0F;
frame->masked = (b1 >> 7) & 0x01;
uint8_t len_code = b1 & 0x7F;
5. 长度解析和协议校验
长度字段有三种情况:
if (len_code <= 125) {
frame->payload_len = len_code;
} else if (len_code == 126) {
if (len < pos + 2) {
return WS_PARSE_NEED_MORE;
}
frame->payload_len = read_be16(buf + pos);
pos += 2;
} else {
if (len < pos + 8) {
return WS_PARSE_NEED_MORE;
}
frame->payload_len = read_be64(buf + pos);
pos += 8;
}
项目也做了基础协议校验:
if (frame->rsv1 || frame->rsv2 || frame->rsv3) {
return WS_PARSE_PROTOCOL_ERROR;
}
if (!is_valid_opcode(frame->opcode)) {
return WS_PARSE_PROTOCOL_ERROR;
}
if (!frame->masked) {
return WS_PARSE_PROTOCOL_ERROR;
}
这里的 !frame->masked 判断非常关键。浏览器作为客户端发给服务端的 WebSocket 帧必须 mask;如果没有 mask,服务端应该认为协议错误。
6. 解除 mask 得到真实 payload
客户端 payload 并不是明文直接放在帧里,而是用 4 字节 masking key 做异或。项目里的还原逻辑很简洁:
for (uint64_t index = 0; index < frame.payload_len; ++index) {
conn->payload[index] = frame.payload[index] ^ frame.masking_key[index % 4];
}
conn->payload_length = (int)frame.payload_len;
如果浏览器发送文本 hello,服务端最终在 conn->payload 里拿到的才是真正的 hello。
7. 服务端打包响应帧
服务端回给浏览器的帧通常不需要 mask。项目里的 ws_pack_frame() 默认构造 FIN=1 的完整帧:
out[pos++] = 0x80 | (opcode & 0x0f);
if (payload_len <= 125) {
out[pos++] = 0x00 | (uint8_t)payload_len;
} else if (payload_len <= 0xffff) {
out[pos++] = 0x00 | 126;
write_be16(out + pos, (uint16_t)payload_len);
pos += 2;
} else {
out[pos++] = 0x00 | 127;
write_be64(out + pos, payload_len);
pos += 8;
}
memcpy(out + pos, payload, (size_t)payload_len);
然后在 websocket_response() 中把刚才解析出来的 payload 打包成文本帧返回:
ws_pack_result_t result = ws_pack_frame((uint8_t *)out_buf,
sizeof(out_buf),
&out_len,
WS_OPCODE_TEXT,
conn->payload,
conn->payload_length);
if (result == WS_PACK_OK) {
memcpy(conn->wbuffer, out_buf, out_len);
conn->wlength = out_len;
}
conn->state = 1;
这就形成了一个 WebSocket Echo Server:浏览器发什么文本,服务端解析后再封装成 WebSocket 文本帧回给浏览器。
8. 前端测试页面
项目里的 websocket.html 是一个非常简单的浏览器客户端:
<script>
let ws;
function doConnect(addr) {
ws = new WebSocket("ws://" + addr);
ws.onopen = () => {
document.getElementById("log").value += (" Connection opened\n");
};
ws.onmessage = (event) => {
document.getElementById("log").value += (" Receive: " + event.data + "\n\n");
};
}
</script>
启动服务端:
gcc reactor.c websocket.c -o websocket -lssl -lcrypto
./websocket
浏览器打开 websocket.html,把地址改成:
127.0.0.1:8080
点击连接后发送文本,页面日志里应该能看到服务端回显。
9. 小结
WebSocket 的核心流程可以概括为:
- HTTP Upgrade 请求进入服务端。
- 服务端根据
Sec-WebSocket-Key生成Sec-WebSocket-Accept。 - 服务端返回 101,连接升级完成。
- 后续数据不再是 HTTP 文本,而是 WebSocket 帧。
- 客户端到服务端的帧必须 mask,服务端要先解析再异或还原 payload。
- 服务端响应时重新打包帧,写回 TCP 连接。
项目把 WebSocket 接在 Reactor 上,代码层次比较清楚:reactor.c 管 fd 和事件,websocket.c 管协议状态和帧格式。
715

被折叠的 条评论
为什么被折叠?



