资讯动态

VS2017手写C++ WebSocket服务端:握手与帧解析源码实现

发布时间:2026/10/18 23:35:41 来源:尧图企业网站定制
简介这份资源面向希望深入理解WebSocket底层通信机制的C开发者与网络编程学习者提供一套在Visual Studio 2017下编译运行的原生Socket服务器实现。代码不依赖任何第三方网络库完整覆盖HTTP升级握手流程、WebSocket帧格式解析含掩码处理、FIN与opcode识别、数据解包与编码传输等协议关键环节适合作为轻量级服务端基础框架或协议学习范本。压缩包共41个文件约80.15MB包含6个cpp源文件、4个头文件、工程解决方案与项目配置、Debug调试输出及readme说明文档另附网页测试页面便于快速验证连接建立、消息收发与连接保持等行为。目前已有14人学习。通过阅读源码读者可掌握TCP层之上协议封装的完整思路理解握手校验与帧解析的细节实现并借助配套测试页面完成端到端调试为后续扩展自定义协议或排查连接异常提供可复用的参考。1. 从零手写 WebSocket 服务端为什么在 VS2017 下跑通握手与帧解析值得做很多做工业上位机、内网工具或桌面端联调的开发者都会遇到同一个尴尬客户端想用 WebSocket 实时推数据但服务端要么依赖一个庞大的第三方库要么干脆跑在另一台机器上。如果你手上只有 VS2017 和一台 Windows 工控机想自己掌控整条链路那么「VS2017 下可运行的 C WebSocket 服务器源码含握手与帧解析实现」这件事就非常值得投入。它不依赖任何外部运行时编译出来就是一个 exe握手和帧解析全部自己写出问题能直接断点调试而不是对着黑匣子猜。这篇文章面向两类人一类是刚接触网络编程、想找一个能编译能跑的最小闭环的新手另一类是用过现成库、但被版本兼容和部署折腾过的熟手。我会把重点放在 RFC6455 的握手校验、帧头位运算、掩码处理、分片与粘包这几个真正会翻车的地方给出可直接抄的代码结构和参数取值。读完你应该能在自己的 VS2017 工程里复现一个能收发文本帧的服务端并知道每个字段为什么这么设。2. 握手阶段把 HTTP Upgrade 请求变成 101 响应的完整链路WebSocket 连接建立的第一步本质是一次带特殊头字段的 HTTP 请求。客户端发过来的东西长这样请求行是 GET路径随意关键在Upgrade: websocket、Connection: Upgrade、Sec-WebSocket-Key和Sec-WebSocket-Version: 13。服务端要做的不是解析完整 HTTP而是从这段文本里抠出 Key拼上固定 GUID做一次 SHA-1再 Base64回一个 101 响应。听起来简单但 VS2017 环境下有几个坑WinSock 的初始化顺序、recv 一次读不全、以及 SHA-1 和 Base64 没有标准库可用。2.1 用 WinSock 搭一个能收全握手请求的 TCP 骨架先别急着写协议先把 TCP 层跑通。VS2017 里用 WinSock2需要在项目属性里链接ws2_32.lib代码里#include winsock2.h和ws2tcpip.h。我一般会写一个阻塞式 accept 循环每个连接开一个std::thread这样调试时线程栈清晰不会因为 IOCP 把调用链打散。// websocket_server.cpp #include winsock2.h #include ws2tcpip.h #include thread #include string #include iostream #pragma comment(lib, ws2_32.lib) // 读取直到遇到 \r\n\r\n握手请求一定以空行结束 std::string recv_http_request(SOCKET client) { std::string buf; char tmp[1024]; while (buf.find(\r\n\r\n) std::string::npos) { int n recv(client, tmp, sizeof(tmp), 0); if (n 0) break; // 对端关闭或出错 buf.append(tmp, n); if (buf.size() 8192) break; // 防御超大请求 } return buf; } int main() { WSADATA wsa; WSAStartup(MAKEWORD(2, 2), wsa); // 必须最先调用 SOCKET listenSock socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); sockaddr_in addr{}; addr.sin_family AF_INET; addr.sin_port htons(9002); // 监听端口按需改 addr.sin_addr.s_addr INADDR_ANY; bind(listenSock, (sockaddr*)addr, sizeof(addr)); listen(listenSock, SOMAXCONN); while (true) { SOCKET client accept(listenSock, nullptr, nullptr); if (client INVALID_SOCKET) continue; std::thread([client]() { std::string req recv_http_request(client); // 后续握手与帧处理在这里展开 closesocket(client); }).detach(); } WSACleanup(); return 0; }这段代码的关键点有三个。第一WSAStartup必须在任何 socket 调用之前否则socket()直接返回INVALID_SOCKET而且错误码是WSANOTINITIALISED新手很容易忽略。第二recv_http_request用find(\r\n\r\n)判断结束因为 TCP 是流握手请求可能分两次到达不能假设一次 recv 就完整。第三端口我习惯用 9002 这类高位端口避免和系统服务冲突INADDR_ANY表示监听所有网卡内网联调够用。2.2 从请求头提取 Sec-WebSocket-Key 并生成 Accept拿到完整请求后用简单的字符串查找提取 Key。注意 Key 后面可能跟\r\n也可能跟其他头字段所以截取到行尾即可。然后拼接固定 GUID258EAFA5-E914-47DA-95CA-C5AB0DC85B11做 SHA-1 得到 20 字节再 Base64 编码。#include string #include vector // 从请求头中提取指定字段的值 std::string get_header(const std::string req, const std::string key) { size_t pos req.find(key); if (pos std::string::npos) return ; pos key.length(); size_t end req.find(\r\n, pos); return req.substr(pos, end - pos); } // 伪代码示意SHA1 与 Base64 需自行实现或引入小型实现 std::string make_accept(const std::string clientKey) { const std::string guid 258EAFA5-E914-47DA-95CA-C5AB0DC85B11; std::string raw clientKey guid; unsigned char digest[20]; sha1((const unsigned char*)raw.data(), raw.size(), digest); // 20 字节 return base64_encode(digest, 20); }这里要强调SHA-1 和 Base64 在 VS2017 里没有现成标准库常见做法是引入一份单文件实现或者自己写。SHA-1 输出固定 20 字节Base64 后是 28 个字符这是 RFC6455 规定的不能改。get_header里我用了find而不是正则因为请求头格式固定字符串查找更快也更好调试。如果 Key 提取为空说明客户端不是合法 WebSocket 请求直接关闭连接不要继续。2.3 回写 101 响应字段顺序和大小写都不能马虎响应必须包含HTTP/1.1 101 Switching Protocols、Upgrade: websocket、Connection: Upgrade和Sec-WebSocket-Accept。字段名大小写不敏感但值必须精确。回写时用send一次性发出末尾两个\r\n不能少。std::string build_101(const std::string accept) { std::string resp HTTP/1.1 101 Switching Protocols\r\n Upgrade: websocket\r\n Connection: Upgrade\r\n Sec-WebSocket-Accept: accept \r\n\r\n; return resp; } // 调用send(client, resp.data(), (int)resp.size(), 0);参数说明Sec-WebSocket-Accept的值就是上一步 Base64 的结果不能带换行。如果客户端收到 101 后立刻断开优先检查 Accept 是否算错尤其是 GUID 有没有拼错或 SHA-1 实现有没有把长度算错。我见过一次翻车是 SHA-1 里把消息长度按位算成了按字节结果 Accept 完全不对客户端直接报握手失败。3. 帧解析从字节流里还原文本帧的位运算与状态机握手成功后数据以帧为单位传输。RFC6455 的帧头最少 2 字节最多 14 字节后面跟掩码客户端发来的帧必须带掩码和负载。解析的核心是位运算第一个字节的高 4 位是 opcode最低位是 FIN第二个字节最高位是 MASK低 7 位是 payload 长度。长度 126 表示后面 2 字节是真实长度127 表示后面 8 字节。这些规则必须按顺序判断不能跳。3.1 帧头字段的位运算拆解与长度分支先定义一个结构体存帧信息然后写一个解析函数。注意网络字节序是大端VS2017 在 x86/x64 上都是小端所以 16 位和 64 位长度要手动转。struct WsFrame { bool fin; uint8_t opcode; bool masked; uint64_t payloadLen; uint8_t maskKey[4]; std::vectoruint8_t payload; }; // 从缓冲区解析一帧返回消耗的字节数0 表示数据不够 size_t parse_frame(const uint8_t* data, size_t len, WsFrame out) { if (len 2) return 0; out.fin (data[0] 0x80) ! 0; out.opcode data[0] 0x0F; out.masked (data[1] 0x80) ! 0; uint64_t plen data[1] 0x7F; size_t pos 2; if (plen 126) { if (len pos 2) return 0; plen (data[pos] 8) | data[pos 1]; // 大端转主机序 pos 2; } else if (plen 127) { if (len pos 8) return 0; plen 0; for (int i 0; i 8; i) plen (plen 8) | data[pos i]; pos 8; } out.payloadLen plen; if (out.masked) { if (len pos 4) return 0; memcpy(out.maskKey, data pos, 4); pos 4; } if (len pos plen) return 0; // 负载还没收全 out.payload.resize((size_t)plen); for (uint64_t i 0; i plen; i) { uint8_t b data[pos i]; out.payload[(size_t)i] out.masked ? (b ^ out.maskKey[i % 4]) : b; } return pos (size_t)plen; }逻辑说明data[0] 0x80取 FIN 0x0F取 opcode。长度分支里126 和 127 是协议规定的扩展标记不是真实长度。掩码是 4 字节负载每个字节与maskKey[i % 4]异或还原。返回值是这一帧占用的总字节数调用方用它来移动缓冲区偏移。如果返回 0说明当前缓冲区不够一帧需要继续 recv 再拼。3.2 处理粘包与半包缓冲区累积和循环解析TCP 不保证一次 recv 对应一帧可能两帧粘在一起也可能一帧分两次到。常见做法是维护一个std::vectoruint8_t缓冲区每次 recv 追加然后循环调用parse_frame直到返回 0 或缓冲区空。std::vectoruint8_t buffer; char tmp[4096]; while (true) { int n recv(client, tmp, sizeof(tmp), 0); if (n 0) break; buffer.insert(buffer.end(), tmp, tmp n); size_t offset 0; while (offset buffer.size()) { WsFrame frame; size_t used parse_frame(buffer.data() offset, buffer.size() - offset, frame); if (used 0) break; // 半包等下次 recv offset used; handle_frame(client, frame); // 业务处理 } buffer.erase(buffer.begin(), buffer.begin() offset); // 移除已消费 }参数说明tmp大小 4096 是经验值太小会增加 recv 次数太大浪费栈空间。offset记录已解析位置循环结束后把已消费部分 erase 掉保留半包。注意buffer.erase在数据量大时会有拷贝开销如果追求性能可以用环形缓冲区但内网工具场景下这样写足够清晰。3.3 文本帧、关闭帧与 Ping/Pong 的最小处理opcode 为 0x1 是文本帧0x8 是关闭帧0x9 是 Ping0xA 是 Pong。文本帧直接当字符串处理关闭帧要回一个关闭帧再关连接Ping 要回 Pong。分片帧FIN0需要把后续同 opcode 的 continuation 帧拼起来但简单场景可以先不支持遇到就关闭。void handle_frame(SOCKET client, const WsFrame f) { if (f.opcode 0x1) { std::string text(f.payload.begin(), f.payload.end()); std::cout recv text: text std::endl; send_text(client, echo: text); // 回显 } else if (f.opcode 0x8) { send_close(client); closesocket(client); } else if (f.opcode 0x9) { send_pong(client, f.payload); // Pong 负载与 Ping 相同 } }这里send_text需要自己构造帧服务端发往客户端的帧不加掩码第一个字节 0x81FIN1opcode1第二个字节是负载长度小于 126 直接写否则用 126 分支。关闭帧 opcode 0x8Pong 是 0xA。如果收到未知 opcode按协议应关闭连接不要静默忽略。4. 避坑与排查握手和帧解析里最容易翻车的 5 个点这一章是我自己踩过的坑按「现象 → 原因 → 解决」写你遇到问题时可以逐条对照。4.1 客户端一直停在 connecting服务端没收到任何数据现象浏览器或客户端显示连接中服务端 accept 到了 socket但recv_http_request一直阻塞或返回空。原因通常是防火墙拦了入站或者服务端绑定了127.0.0.1而客户端从另一台机器连。解决把sin_addr.s_addr改成INADDR_ANY并在 Windows 防火墙里给 exe 放行入站规则。如果是本机测试确认端口没被其他进程占用用netstat -ano | findstr 9002查一下。4.2 握手返回 101 但客户端立刻断开现象服务端日志显示已发送 101但客户端马上 close。原因九成是Sec-WebSocket-Accept算错。检查三点GUID 是否完整拼在 Key 后面、SHA-1 输出是否 20 字节、Base64 是否对 20 字节编码。另一个可能是响应里少了\r\n\r\n结尾或者Upgrade值写成了WebSocket大小写不敏感但拼写不能错。解决把客户端 Key 和算出的 Accept 打印出来和在线工具对比一次确认算法无误后再查网络层。4.3 收到帧但解析出的文本是乱码现象opcode 和长度都对但 payload 转字符串后是乱码。原因通常是忘了异或掩码或者掩码偏移用错。客户端发来的帧 MASK 位一定是 1必须用maskKey[i % 4]逐字节异或。如果掩码位是 0 却按有掩码处理也会乱。解决在parse_frame里打印masked和maskKey前 4 字节确认异或逻辑。另外注意长度 126/127 分支里读的是大端别直接 memcpy 到整数。4.4 大帧收不全parse_frame 一直返回 0现象小消息正常超过 125 字节或 65535 字节的消息收不到。原因是没有处理 126 和 127 的长度扩展或者缓冲区在半包时被错误清空。解决确认parse_frame在len pos plen时返回 0并且调用方在返回 0 时不要 erase 缓冲区。如果消息超过 64KB检查 127 分支的 8 字节读取是否用了循环移位而不是只读 4 字节。4.5 多线程下 send 和 recv 交叉导致崩溃现象一个连接开线程后偶尔在send或closesocket处崩溃。原因是多个线程同时操作同一个 socket或者主线程关闭了 socket 而工作线程还在用。解决每个连接只由一个线程负责读写关闭时先shutdown(client, SD_BOTH)再closesocket并确保工作线程退出后再释放资源。VS2017 下可以用std::atomicbool标记连接状态避免重复关闭。5. 进阶技巧把帧解析做成可测试的纯函数并验证边界写到能跑只是第一步真正省时间的是把parse_frame做成不依赖 socket 的纯函数这样你可以用单元测试覆盖各种边界而不是每次改代码都开客户端手动点。我一般会构造几组字节数组直接调用解析函数断言 opcode、长度和 payload。下面是一个最小验证思路。// 构造一个带掩码的文本帧 Hi掩码 0x01 0x02 0x03 0x04 std::vectoruint8_t make_test_frame() { std::vectoruint8_t f {0x81, 0x82, 0x01, 0x02, 0x03, 0x04}; uint8_t payload[2] {H ^ 0x01, i ^ 0x02}; f.insert(f.end(), payload, payload 2); return f; } // 测试解析后 payload 应为 Hi void test_parse() { auto f make_test_frame(); WsFrame out; size_t used parse_frame(f.data(), f.size(), out); assert(used f.size()); assert(out.opcode 0x1); assert(std::string(out.payload.begin(), out.payload.end()) Hi); }这段测试的价值在于它把「掩码异或」「长度 126 以下」「FIN 和 opcode 提取」三件事一次性验证了。你可以再补一个长度 126 的帧、一个长度 127 的帧、一个半包只给前 3 字节的用例确认返回 0。参数上make_test_frame里的掩码可以随便取但必须和 payload 异或后再放入否则解析出来不是原文。另一个进阶点是控制帧的插入。Ping/Pong 可能夹在数据帧中间解析循环必须能处理任意 opcode不能假设只有文本帧。我习惯在handle_frame里对 0x9 立即回 Pong对 0x8 回关闭帧后标记连接结束对 0x1 和 0x2 走业务。如果收到 0x0continuation而前面没有未完成的分片按协议应关闭连接这个边界很多手写实现会漏。最后说一个我自己的习惯每次改完帧解析先跑一遍构造的边界用例再开真实客户端。因为真实客户端不会帮你测半包和 64 位长度而这些恰恰是上线后最容易出问题的地方。这套代码在 VS2017 下编译不需要额外依赖唯一要自己补的是 SHA-1 和 Base64网上有很多单文件实现选一个能编译过、输出长度正确的即可。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑