手撕 WebSocket 协议栈:C++ 原生实现与帧解析实战
2026/9/13 2:10:15 网站建设 项目流程

简介:本资源是一份基于C++ Socket编程实现的WebSocket服务器源码工程,面向具备C++基础与网络编程经验的开发者,用于深入理解WebSocket协议握手机制、帧格式解析及双向通信实现原理。项目已在VS2017环境下完整编译运行,配套在线测试网页,适合协议学习、服务端开发实践与教学演示场景。压缩包共40个文件,含5个核心cpp源文件、4个头文件(h)、1个解决方案sln及可执行exe,辅以调试所需的pdb、obj等中间文件,整体体积62.17MB,结构体现典型Visual Studio C++项目的构建逻辑与调试配置。已有587人学习下载,读者可直接编译运行服务端,结合readme.txt快速上手;源码层次清晰,涵盖握手响应、掩码解码、数据帧解析等关键模块,便于逐层剖析协议细节并二次扩展功能。

1. 这不是封装库,而是一份手撕 WebSocket 协议栈的 C++ 实战源码

你打开 VS2017,加载WebSocket4.0.sln,看到Debug目录下生成的可执行文件,用浏览器访问ws://localhost:8080——连接成功,发消息,收响应。但真正值得细看的,是readme.txt里那句:“主要实现了 WebSocket 协议握手,以及基于 WebSocket 协议格式数据的解码与传输”。这不是调用 Boost.Beast 或 libwebsockets 的封装项目,而是用原生 Win32 socket + 手写状态机完成的协议解析闭环:HTTP Upgrade 请求校验、Sec-WebSocket-Key 签名计算、掩码(masking)逆向解包、FIN/RSV/OPCODE 字段拆解、payload length 多字节变长解析、UTF-8 有效载荷校验。它不依赖第三方网络框架,不抽象 IO 多路复用层,所有字节级逻辑裸露在.cpp文件中。适合想穿透 WebSocket 表层、理解 RFC 6455 第 5 节帧结构、排查code: 1006类连接异常、或为嵌入式/低资源环境定制轻量通信模块的 C++ 开发者。如果你正被stream disconnected before completiononclose, reason:空字符串这类问题卡住,这份源码就是协议层的 X 光片。

2. 从 TCP socket 到 WebSocket 握手:Win32 原生实现细节拆解

2.1 为什么选 Win32 socket 而非跨平台抽象层?

项目明确要求 VS2017 环境,且readme.txt未提及任何跨平台适配逻辑。这意味着开发者选择 Win32 API 是出于对 Windows 平台底层控制力的优先考量:直接使用WSAStartup()初始化、socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)创建套接字、bind()+listen()启动监听,避免了跨平台库(如 POCO、QtNetwork)引入的隐式线程模型或内存管理策略。这种选择带来两个关键优势:一是调试时可直接在accept()返回的SOCKET句柄上设置setsockopt(SO_RCVTIMEO)控制读超时,规避recv()阻塞导致的主线程挂起;二是握手阶段的 HTTP 解析无需处理 POSIX 的epoll/kqueue差异,所有send()/recv()调用行为确定。但代价是代码无法直接移植到 Linux ——若需跨平台,必须将#include <winsock2.h>替换为<sys/socket.h>,并重写closesocket()close(),同时处理ioctlsocket()fcntl()的非阻塞模式切换差异。

2.2 WebSocket 握手请求的 HTTP 头部解析与合法性校验

握手本质是 HTTP Upgrade 请求,源码中关键校验点集中在HandleHandshake()函数(位于WebSocketServer.cpp)。其核心逻辑并非简单匹配Upgrade: websocket,而是逐字段验证:

// 示例:从 recv 缓冲区提取 Sec-WebSocket-Key 并校验长度 char headerBuffer[2048] = {0}; int nRecv = recv(clientSocket, headerBuffer, sizeof(headerBuffer)-1, 0); if (nRecv <= 0) return false; // 查找 Sec-WebSocket-Key 字段(注意冒号后空格) const char* keyPos = strstr(headerBuffer, "Sec-WebSocket-Key:"); if (!keyPos) return false; keyPos += strlen("Sec-WebSocket-Key: "); // 跳过前导空格 while (*keyPos == ' ') keyPos++; // 提取 key 值(直到回车换行) char clientKey[64] = {0}; int keyLen = 0; while (keyPos[keyLen] != '\r' && keyPos[keyLen] != '\n' && keyLen < 63) { clientKey[keyLen++] = keyPos[keyLen]; } clientKey[keyLen] = '\0'; // RFC 6455 要求 key 必须是 base64 编码的 16 字节随机数(即 24 字符) if (strlen(clientKey) != 24) return false; // 关键校验:长度不符直接拒绝

提示:此处strlen(clientKey) != 24是硬性校验。很多调试失败源于客户端(如 JavaScriptnew WebSocket())发送的 key 不符合 RFC 规范,或代理服务器篡改了头部。若遇到握手失败,先用 Wireshark 抓包确认Sec-WebSocket-Key是否为 24 字符 base64 字符串。

2.3 Sec-WebSocket-Accept 签名生成:SHA-1 + Base64 的精确实现

RFC 6455 规定服务端需将客户端 key 与固定字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼接后做 SHA-1 哈希,再 Base64 编码。源码中GenerateAcceptKey()函数(WebSocketUtil.cpp)严格遵循此流程:

#include <openssl/sha.h> // 注意:源码实际使用 Windows CryptoAPI,此处为等效逻辑示意 #include <openssl/bio.h> #include <openssl/evp.h> std::string GenerateAcceptKey(const std::string& clientKey) { std::string guid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; std::string concat = clientKey + guid; // SHA-1 哈希(20 字节输出) unsigned char hash[SHA_DIGEST_LENGTH]; SHA1((const unsigned char*)concat.c_str(), concat.length(), hash); // Base64 编码(OpenSSL 实现) BIO *b64 = BIO_new(BIO_f_base64()); BIO *mem = BIO_new(BIO_s_mem()); b64 = BIO_push(b64, mem); BIO_write(b64, hash, SHA_DIGEST_LENGTH); BIO_flush(b64); char* encoded; long len = BIO_get_mem_data(mem, &encoded); std::string result(encoded, len); BIO_free_all(b64); return result; }

注意:VS2017 默认不链接 OpenSSL,实际源码使用 Windows CryptoAPI 的CryptCreateHash()CryptHashData()。若编译报错LNK2019: unresolved external symbol CryptAcquireContext,需在项目属性 → 链接器 → 输入 → 附加依赖项中添加crypt32.lib。这是 Windows 平台实现加密哈希的标准方式,比引入 OpenSSL 更轻量。

2.4 握手响应构造:HTTP 101 状态码与必需头部

生成Sec-WebSocket-Accept后,需构造完整 HTTP 响应报文。源码中SendHandshakeResponse()函数(WebSocketServer.cpp)拼接如下:

std::string response = "HTTP/1.1 101 Switching Protocols\r\n" "Upgrade: websocket\r\n" "Connection: Upgrade\r\n" "Sec-WebSocket-Accept: " + acceptKey + "\r\n" "\r\n"; // 注意末尾双换行 send(clientSocket, response.c_str(), response.length(), 0);

关键点在于:

  • 状态行必须为HTTP/1.1 101 Switching Protocols,不可省略HTTP/1.1
  • UpgradeConnection头部必须存在且值严格匹配(大小写敏感)
  • Sec-WebSocket-Accept值必须为上一步生成的 Base64 字符串
  • 响应体为空,但\r\n\r\n分隔符不可省略,否则浏览器认为响应不完整

若浏览器控制台显示Error during WebSocket handshake: Unexpected response code: 200,说明服务端返回了 200 而非 101,通常因send()调用前未正确设置响应字符串。

3. WebSocket 帧解析与数据传输:掩码、opcode 与 payload length 的手写状态机

3.1 WebSocket 帧结构解析:FIN、RSV、OPCODE 字段的位操作解包

WebSocket 数据帧以 2 字节起始,源码中ParseFrameHeader()函数(WebSocketFrame.cpp)通过位运算提取关键字段:

// 假设 frameBuffer[0] 为第一个字节 bool fin = (frameBuffer[0] & 0x80) != 0; // 最高位(bit 7) bool rsv1 = (frameBuffer[0] & 0x40) != 0; // bit 6 bool rsv2 = (frameBuffer[0] & 0x20) != 0; // bit 5 bool rsv3 = (frameBuffer[0] & 0x10) != 0; // bit 4 uint8_t opcode = frameBuffer[0] & 0x0F; // 低 4 位 // 第二个字节:MASK 和 payload length bool isMasked = (frameBuffer[1] & 0x80) != 0; // 最高位表示是否掩码 uint64_t payloadLen = frameBuffer[1] & 0x7F; // 低 7 位为长度基础值

RFC 6455 定义opcode含义:

  • 0x0: Continuation frame(续帧)
  • 0x1: Text frame(UTF-8 文本)
  • 0x2: Binary frame(二进制数据)
  • 0x8: Connection close(关闭帧)
  • 0x9: Ping(心跳)
  • 0xA: Pong(心跳响应)

提示:源码中opcode == 0x8的处理逻辑在HandleCloseFrame()中。若客户端发送close帧但服务端未响应,浏览器会报code: 1006(异常关闭)。务必确保收到0x8帧后,立即发送0x8帧回应并调用closesocket()

3.2 Payload length 的多字节变长解析:7-bit、7+16-bit、7+64-bit 三种模式

payloadLen基础值frameBuffer[1] & 0x7F决定后续长度字段长度:

  • payloadLen < 126:长度即为此值(7-bit)
  • payloadLen == 126:后续 2 字节为uint16_t长度(网络字节序)
  • payloadLen == 127:后续 8 字节为uint64_t长度(网络字节序)

源码中GetPayloadLength()函数(WebSocketFrame.cpp)实现:

uint64_t GetPayloadLength(const uint8_t* frameBuffer, size_t& offset) { uint64_t len = frameBuffer[1] & 0x7F; if (len < 126) { offset = 2; // 头部共 2 字节 return len; } else if (len == 126) { // 后续 2 字节:网络字节序转主机序 uint16_t len16 = (frameBuffer[2] << 8) | frameBuffer[3]; offset = 4; // 头部共 4 字节 return len16; } else if (len == 127) { // 后续 8 字节:取低 4 字节(RFC 6455 限制应用层最大 2^31-1) uint32_t len32 = (frameBuffer[2] << 24) | (frameBuffer[3] << 16) | (frameBuffer[4] << 8) | frameBuffer[5]; offset = 10; // 头部共 10 字节 return len32; } return 0; }

注意len == 127时仅使用低 4 字节,因 Windowsuint32_t足够覆盖常见场景(最大 4GB),避免uint64_t在 32 位系统上的兼容问题。

3.3 掩码(masking)解包:客户端强制掩码的 XOR 运算实现

RFC 6455 规定:客户端发送的所有帧必须掩码,服务端发送的帧不得掩码。源码中UnmaskPayload()函数(WebSocketFrame.cpp)执行 XOR 解包:

void UnmaskPayload(uint8_t* payload, size_t len, const uint8_t* maskingKey) { for (size_t i = 0; i < len; ++i) { payload[i] ^= maskingKey[i % 4]; // 4 字节掩码密钥循环 XOR } }

掩码密钥位于帧头之后、payload 之前,固定 4 字节。解包步骤:

  1. frameBuffer + headerOffset提取 4 字节maskingKey
  2. payload区域每个字节执行payload[i] ^= maskingKey[i%4]
  3. 解包后payload才是原始数据(文本需 UTF-8 校验,二进制直接使用)

若收到乱码或解析失败,首要检查isMasked标志是否为true(客户端帧必须掩码),并确认maskingKey读取位置正确(紧随帧头之后)。

3.4 Text Frame 的 UTF-8 校验:避免非法字符导致连接中断

源码中IsValidUTF8()函数(WebSocketUtil.cpp)对解包后的文本帧进行校验:

bool IsValidUTF8(const uint8_t* data, size_t len) { size_t i = 0; while (i < len) { uint8_t byte = data[i]; if (byte <= 0x7F) { // 1-byte i++; } else if ((byte & 0xE0) == 0xC0) { // 2-byte if (i + 1 >= len || (data[i+1] & 0xC0) != 0x80) return false; i += 2; } else if ((byte & 0xF0) == 0xE0) { // 3-byte if (i + 2 >= len || (data[i+1] & 0xC0) != 0x80 || (data[i+2] & 0xC0) != 0x80) return false; i += 3; } else if ((byte & 0xF8) == 0xF0) { // 4-byte if (i + 3 >= len || (data[i+1] & 0xC0) != 0x80 || (data[i+2] & 0xC0) != 0x80 || (data[i+3] & 0xC0) != 0x80) return false; i += 4; } else { return false; // 非法首字节 } } return true; }

提示:若客户端发送非 UTF-8 编码的字符串(如 GBK),校验失败会导致服务端主动关闭连接(发送0x8帧),浏览器报code: 1007(无效数据)。调试时可在校验前打印data十六进制,确认编码来源。

4. 实战调试:用 Chrome DevTools 和 Wireshark 定位常见连接异常

4.1 浏览器控制台onclose事件分析:code 1006 的真实含义

当 WebSocket 连接意外断开,Chrome 控制台常显示:

WebSocket connection to 'ws://localhost:8080/' failed: WebSocket is closed before the connection is established. ... close event: code: 1006, reason: ""

code: 1006在 RFC 6455 中定义为"connection closed abnormally",即连接未按规范流程关闭。源码中触发此错误的典型场景:

  • 握手阶段:recv()未收到完整 HTTP 头部,或Sec-WebSocket-Key校验失败,服务端直接closesocket()而未发送101响应
  • 数据阶段:收到0x8关闭帧后,服务端未及时回应0x8帧,导致客户端超时断连
  • IO 错误:recv()返回SOCKET_ERRORWSAGetLastError() == WSAECONNRESET(连接被对方重置)

验证方法:在WebSocketServer.cppProcessClient()循环中,在recv()后添加日志:

int nRecv = recv(clientSocket, buffer, sizeof(buffer)-1, 0); if (nRecv == 0) { printf("Client %d disconnected gracefully\n", clientID); break; } else if (nRecv == SOCKET_ERROR) { int err = WSAGetLastError(); printf("recv error %d on socket %d\n", err, clientID); if (err == WSAECONNRESET || err == WSAETIMEDOUT) { // 记录为异常断连 } break; }

4.2 Wireshark 过滤 WebSocket 流量:快速定位握手失败点

在 Wireshark 中设置过滤表达式:

tcp.port == 8080 && tcp.len > 0

然后右键某 TCP 包 → “Follow” → “TCP Stream”,可查看完整 HTTP 握手交互。关键检查点:

  • 客户端请求是否含Upgrade: websocketSec-WebSocket-Key
  • 服务端响应是否为HTTP/1.1 101且含Sec-WebSocket-Accept
  • 若响应为HTTP/1.1 200,说明服务端逻辑未进入握手分支(检查strstr(headerBuffer, "Upgrade:")是否匹配)

注意:Wireshark 本身不解析 WebSocket 帧,但可通过“Decode As” → “WebSocket” 将 TCP 流强制解码,查看FIN,Opcode,Payload Length字段是否符合预期。

4.3 VS2017 调试技巧:在recv()send()处设置条件断点

WebSocketServer.cppProcessClient()函数中:

  • recv()行设置条件断点:nRecv <= 0,捕获连接异常
  • send()行设置条件断点:strstr(response.c_str(), "101") != nullptr,确认握手响应发出
  • 使用“内存视图”观察headerBuffer内容,确认Sec-WebSocket-Key是否被截断(recv()未一次性收全头部)

调试时启用 VS2017 的“仅我的代码”选项(调试 → 选项 → 常规 → 启用仅我的代码),避免跳入 Win32 API 内部。

4.4 常见部署问题:打包为 App 后连接失败的根源与修复

打包为app连接不了是高频问题,根源在于:

  • 防火墙拦截:Windows Defender 防火墙默认阻止新 EXE 的入站连接。解决方案:在WebSocket4.0.exe属性 → 兼容性 → 以管理员身份运行,并在防火墙设置中允许该程序通过
  • 端口占用bind()失败返回WSAEADDRINUSE。源码中CreateServerSocket()应添加setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, ...)
    int opt = 1; setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, (const char*)&opt, sizeof(opt));
  • 路径问题readme.txt提供的测试网页若用file://协议打开,现代浏览器禁止file://页面建立 WebSocket 连接(CORS 策略)。必须通过http://localhost:8080/test.html访问测试页。
问题现象根本原因检查命令修复方案
net::ERR_CONNECTION_REFUSED端口未监听或防火墙拦截netstat -ano | findstr :8080检查进程 PID,关闭冲突程序;配置防火墙规则
Error during WebSocket handshake: net::ERR_CONNECTION_RESET服务端send()前已closesocket()Wireshark 查看服务端是否发101确保握手逻辑无提前退出
WebSocket is closed before the connection is established客户端 JS 未等待onopen即发消息浏览器控制台console.log(ws.readyState)ws.onopen = function() { ws.send(...); }中发送

5. 进阶优化:支持多客户端连接与心跳保活机制

5.1 从单线程阻塞模型到select()多路复用

当前源码WebSocketServer.cppaccept()recv()均为阻塞调用,一次只能处理一个客户端。要支持并发,需改造为select()模型。核心修改点:

// 初始化 fd_set fd_set readfds; struct timeval timeout = {1, 0}; // 1 秒超时 while (running) { FD_ZERO(&readfds); FD_SET(serverSocket, &readfds); // 将所有 clientSocket 加入 readfds for (auto& client : clients) { FD_SET(client.socket, &readfds); } int activity = select(0, &readfds, NULL, NULL, &timeout); if (activity < 0) continue; // 检查 serverSocket 是否就绪(新连接) if (FD_ISSET(serverSocket, &readfds)) { SOCKET newClient = accept(serverSocket, NULL, NULL); clients.push_back({newClient, time(nullptr)}); } // 检查各 clientSocket for (auto it = clients.begin(); it != clients.end();) { if (FD_ISSET(it->socket, &readfds)) { ProcessClient(it->socket); // 处理数据 } ++it; } }

提示select()nfds参数在 Windows 上可设为0,无需计算最大 socket 号。此模型避免了线程创建开销,适合中小规模连接(<1000)。

5.2 实现 Ping/Pong 心跳:防止 NAT 超时断连

NAT 设备通常 30-60 秒清理空闲连接。源码需添加定时器发送0x9Ping 帧:

// 发送 Ping 帧(无 payload) void SendPing(SOCKET sock) { uint8_t pingFrame[6] = {0x89, 0x00}; // FIN=1, OPCODE=0x9, LEN=0 send(sock, (char*)pingFrame, 2, 0); } // 在 ProcessClient() 中检测 Pong 帧(OPCODE=0xA) if (opcode == 0xA) { // 收到 Pong,更新 lastPongTime client.lastPongTime = time(nullptr); continue; // 不处理 payload }

主循环中定期检查:

for (auto it = clients.begin(); it != clients.end();) { if (time(nullptr) - it->lastPongTime > 45) { // 超过 45 秒无 Pong closesocket(it->socket); it = clients.erase(it); } else { ++it; } }

5.3 文本帧广播优化:避免重复内存拷贝

当前源码对每个客户端send()一次,若 100 个客户端,同一消息拷贝 100 次。可预分配共享缓冲区:

// 全局共享帧缓冲区(线程安全需加锁) static std::vector<uint8_t> broadcastBuffer; void BroadcastText(const std::string& msg) { // 构造 WebSocket 帧(此处简化,实际需计算 length、mask 等) size_t frameSize = 2 + msg.length(); broadcastBuffer.resize(frameSize); broadcastBuffer[0] = 0x81; // FIN=1, TEXT=0x1 broadcastBuffer[1] = (uint8_t)msg.length(); memcpy(&broadcastBuffer[2], msg.c_str(), msg.length()); // 广播给所有客户端 for (auto& client : clients) { send(client.socket, (char*)broadcastBuffer.data(), frameSize, 0); } }

此优化减少内存分配次数,提升高并发下的吞吐量。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询