手撕 WebSocket 协议栈:C++ 原生实现与帧解析实战
简介本资源是一份基于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 completion或onclose, 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.1Upgrade和Connection头部必须存在且值严格匹配大小写敏感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 frameUTF-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、716-bit、764-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 字节。解包步骤从frameBuffer headerOffset提取 4 字节maskingKey对payload区域每个字节执行payload[i] ^ maskingKey[i%4]解包后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[i1] 0xC0) ! 0x80) return false; i 2; } else if ((byte 0xF0) 0xE0) { // 3-byte if (i 2 len || (data[i1] 0xC0) ! 0x80 || (data[i2] 0xC0) ! 0x80) return false; i 3; } else if ((byte 0xF8) 0xF0) { // 4-byte if (i 3 len || (data[i1] 0xC0) ! 0x80 || (data[i2] 0xC0) ! 0x80 || (data[i3] 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_ERROR且WSAGetLastError() WSAECONNRESET连接被对方重置验证方法在WebSocketServer.cpp的ProcessClient()循环中在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: websocket和Sec-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.cpp的ProcessClient()函数中对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.cpp中accept()和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}; // FIN1, OPCODE0x9, LEN0 send(sock, (char*)pingFrame, 2, 0); } // 在 ProcessClient() 中检测 Pong 帧OPCODE0xA 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::vectoruint8_t broadcastBuffer; void BroadcastText(const std::string msg) { // 构造 WebSocket 帧此处简化实际需计算 length、mask 等 size_t frameSize 2 msg.length(); broadcastBuffer.resize(frameSize); broadcastBuffer[0] 0x81; // FIN1, TEXT0x1 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); } }此优化减少内存分配次数提升高并发下的吞吐量。本文还有配套的精品资源点击获取