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

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