ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++ WebSocket服务器源码拆解:从握手到数据帧协议实战

C++ WebSocket服务器源码拆解:从握手到数据帧协议实战 简介基于C的WebSocket服务器完整源码工程面向需要学习网络协议编程或自建实时通信服务的开发者可在VS2017环境中直接打开并调试运行。核心代码基于C原生socket编写不依赖第三方库覆盖WebSocket协议握手、数据帧解码与传输并支持通过在线测试网页快速验证服务器联通性特别适合研究底层网络交互原理。压缩包内共40个文件包括5个C源文件、4个头文件、VS2017工程与解决方案文件、编译生成的exe可执行程序以及pdb、obj、tlog等调试构建文件整体大小约62.17MB文件构成完整readme说明文档可帮助快速上手。目前已有587人学习下载中高级C开发者可对照源码与调试信息深入理解握手报文构造、掩码处理与消息收发流程并在此基础上便捷移植或扩展为聊天、推送等真实服务模块。1. 把 C WebSocket 服务器源码拆开给你看这不是玩具项目提到 C 做 WebSocket 服务器很多人的第一反应是“直接用库不就行了”但如果你真正经历过线上连接被服务端莫名其妙掐断、抓包也看不出问题的场景就会明白一件事不懂协议细节出了问题你连黑匣子都撬不开。这个源码包就是我当年自己用 C 原生 socket 从零写的 WebSocket 服务器实现了握手、数据帧解码与传输能在 VS2017 下直接编译运行配套一个浏览器在线测试页。它不依赖任何第三方 WebSocket 库适合想搞懂协议本质的 C 开发者、需要把 WebSocket 嵌进自有网络服务的从业者也适合用它作为面试前快速温习协议细节的素材。2. 先把协议地基搞清楚WebSocket 握手本质上是一次带 Upgrade 的 HTTP 请求2.1 连接是怎么从 HTTP 变成 WebSocket 的WebSocket 的魔法发生在一个非常朴素的地方TCP 连接建立后客户端先发一坨普通的 HTTP 请求服务端识别出里面的Upgrade: websocket头就知道“这家伙不是来要网页的是要升级协议”。这个过程叫握手而握手成功后这条 TCP 连接上的数据格式就彻底换成 WebSocket 帧格式不再有 HTTP 的痕迹。很多人第一次写服务端时最容易犯的错是以为握手是某种“WebSocket 专属协议”于是去找什么 WebSocket 握手包格式。其实完全没有它就是一段文本形式的 HTTP/1.1 GET 请求。源码里对应实现的部分本质上是做了一个 HTTP 请求头的解析。浏览器发出的握手请求长这样GET /chat HTTP/1.1 Host: 127.0.0.1:8080 Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ Sec-WebSocket-Version: 13其中Sec-WebSocket-Key是客户端生成的 16 字节随机数经过 Base64 编码后的值服务端要拿它做一道数学题算出来的结果放在响应头里返回给客户端。客户端收到后会做校验校验通过才会认为握手成功。2.2 握手响应算出来Sec-WebSocket-Accept 的 SHA-1 Base64服务端要做的事只有三步把客户端传来的Sec-WebSocket-Key拼上一个固定 GUID 字符串做 SHA-1 哈希再 Base64 编码拼到响应头的Sec-WebSocket-Accept字段里。这个固定 GUID 是 RFC 6455 写死的没有任何玄学就是规定。// 计算 Sec-WebSocket-Accept // 固定 GUID 来自 RFC 6455无法修改 const char* WS_MAGIC_GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11; std::string compute_accept(const std::string sec_key) { std::string raw sec_key WS_MAGIC_GUID; // 1. 拼接 unsigned char hash[SHA_DIGEST_LENGTH] {0}; SHA1(reinterpret_castconst unsigned char*(raw.c_str()), raw.size(), hash); // 2. SHA-1 return base64_encode(hash, SHA_DIGEST_LENGTH); // 3. Base64 }这里用到的 SHA-1 来自 OpenSSL 的sha.h如果你不想引 OpenSSL也可以自己写一个纯 C 的 SHA-1 实现网上现成的很多注意字节序别搞反。base64_encode同样可以自己写标准库没有现成的 Base64 编码函数源码里自带了一份。响应头长这样HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbKxOo状态码必须是 101不是 200。这个101 Switching Protocols是“协议切换成功”的唯一正确标识。响应头里的Upgrade和Connection也原样带回去客户端会核对这几个头缺任何一个都可能握手失败。2.3 源码里的握手实现应该怎么读拿到源码后先别急着编译先找到处理握手逻辑的那个函数。一般来说它会做这几件事读取客户端发来的 HTTP 请求头用\r\n\r\n切出 header 部分逐行查找Sec-WebSocket-Key字段然后调用类似compute_accept的函数算出返回值最后构造 101 响应写回 socket。前几年流行过一个 WebSocket 在线测试工具很多人拿它连自己的服务器发现连不上。究其原因就是那个测试工具比如在线测试页面为了兼容不同实现发出来的握手头字段顺序不完全固定服务端如果用了“先找GET再找Sec-WebSocket-Key”的顺序一般没事但如果你按“固定在第 3 行找 Key”去解析就很容易翻车。所以源码里的 header 解析一定要做成逐行扫描、按字段名匹配不要依赖行号。2.4 握手阶段几个容易忽略的细节握手阶段有三个细节看起来小但每一个都能让你 debug 到怀疑人生。第一Sec-WebSocket-Key不是明文随机数而是 Base64 编码后的字符串。服务端计算 accept 时直接拿这个字符串拼 GUID不需要解码。如果你手贱先把 Key 做 Base64 解码再用算出来的 accept 永远是错的。第二握手请求里可能带Sec-WebSocket-Protocol头表示客户端希望使用的子协议。服务端如果支持需要在响应头里原样返回一个Sec-WebSocket-Protocol字段如果不返回浏览器会认为子协议协商失败直接断开连接。第三握手阶段之后 30 秒内客户端一般不会发数据服务端第一次recv会一直阻塞。所以握手代码前面最好先把接收超时设短比如 5 秒握手完成后立刻把超时关掉或重新设置。3. 把数据帧格式吃透Mask、Opcode 与 7-bit / 16-bit / 64-bit 长度分支3.1 帧结构到底长什么样握手之后的 WebSocket 通信数据不是按“消息”直接裸奔的而是被切成一小块一小块的“帧”。WebSocket 帧格式很紧凑前后一共就几个字段但你得逐位拆。帧的第一个字节最高位是 FIN表示这是不是消息的最后一帧接着 3 位是 RSV正常情况下全是 0我们直接忽略。低 4 位是 Opcode0x1 表示文本帧、0x2 表示二进制帧、0x8 是关闭帧、0x9 是 Ping、0xA 是 Pong。第二个字节更关键最高位是 MASK 标志客户端发给服务端的帧 MASK 必须是 1服务端发给客户端的帧 MASK 必须是 0。剩下 7 位是 payload 长度值小于 126长度就是它本身等于 126真实长度藏在接下来 2 字节里16 位无符号整数等于 127真实长度藏在接下来 8 字节里64 位无符号整数。这里最容易踩的坑是字节序。WebSocket 协议规定所有多字节整型都是网络字节序也就是大端序。你从 socket 里读出来的字节流如果直接按小端序强转成uint16_t或uint64_t长度会变成一个天文数字recv会一直等永远等不满看起来就像服务器卡死了。3.2 服务端收帧解析头部 掩码反转服务端解码一帧的核心逻辑可以拆成这么几步// 假设 buffer 里已经至少攒了 2 字节 bool decode_frame_header(const char* data, int len, FrameHeader hdr) { if (len 2) return false; hdr.fin (data[0] 7) 0x01; hdr.opcode data[0] 0x0F; hdr.mask (data[1] 7) 0x01; hdr.payload_len data[1] 0x7F; int offset 2; // 长度扩展位 if (hdr.payload_len 126) { // 需要至少 2 字节扩展长度 if (len 4) return false; hdr.payload_len (unsigned char)data[2] 8 | (unsigned char)data[3]; offset 4; } else if (hdr.payload_len 127) { // 需要至少 8 字节扩展长度 if (len 10) return false; hdr.payload_len 0; for (int i 0; i 8; i) { hdr.payload_len (hdr.payload_len 8) | (unsigned char)data[2 i]; } offset 10; } hdr.header_len offset; if (hdr.mask) { // mask key 固定 4 字节紧跟在扩展长度后面 memcpy(hdr.mask_key, data offset, 4); hdr.header_len offset 4; } return true; }payload_len的类型要用uint64_t不然超过 4GB 的帧长度会溢出。实际上业务场景里很少真有这么大的单帧但协议要支持到位。判len 4和len 10这样的提前退出是为了应对 TCP 粘包和拆包——一次recv拿到的字节数不一定是完整一帧甚至可能一帧都没满。拿到头部之后还要做掩码反转。客户端发的数据是用 4 字节掩码异或过的服务端要把 payload 的每个字节跟掩码按data[i] ^ mask_key[i % 4]还原。注意掩码反转只作用于 payload 部分头部的 opcode 和长度字段不受影响。for (uint64_t i 0; i hdr.payload_len; i) { payload[i] ^ hdr.mask_key[i % 4]; }3.3 服务端发帧不回 MASK 的响应帧服务端往客户端塞数据的时候MASK 位置 0长度直接编码。构造过程比解码简单得多但仍要小心长度字段的三种形态。std::string build_frame(const void* data, uint64_t len, int opcode) { std::string frame; frame.push_back(0x80 | (unsigned char)opcode); // FIN opcode if (len 126) { frame.push_back((unsigned char)len); } else if (len 0xFFFF) { frame.push_back(126); frame.push_back((unsigned char)((len 8) 0xFF)); frame.push_back((unsigned char)(len 0xFF)); } else { frame.push_back(127); for (int i 7; i 0; --i) { frame.push_back((unsigned char)((len (i * 8)) 0xFF)); } } frame.append(static_castconst char*(data), len); return frame; }这段代码里 FIN 直接置 1表示这是单帧消息。如果你将来要做消息分片比如一个 10MB 的二进制数据分 8 帧发那第一帧到倒数第二帧 FIN 都要置 0最后一帧 FIN 置 1且中间所有帧的 opcode 要用 continuation frame0x0只有第一帧 opcode 才是 0x2。这是很多人写分片时踩的坑。3.4 参数改动的关键节点126 和 127 的临界值调试的时候建议把 126、127 这两个分支都测到。小于 126 字节的帧走最短路径测试代码好写但生产环境里一个稍微像样的 JSON 就轻松超过 126 字节所以 16 位长度分支才是日常主力。64 位长度分支极少触发但如果你的服务端要转发大文件流最好也测一下。验证方法很简单构造 125、126、127 字节三种负载各发一次抓包看帧头的第二字节和扩展长度字节是否符合预期。如果 126 和 127 这两个边界值发出去后客户端收不到或解析乱码十有八九是字节序写反了。4. 服务器主循环设计socket 生命周期、缓冲区管理与多客户端共存4.1 从 listen 到 accept一连接一线程还是 select 轮询源码里用的是传统的阻塞 socket 每连接一线程模式这种模式在连接数在几十上百的场景下完全够用代码也直观。别一上来就上 epoll、iocp先把协议跑通再考虑优化。主循环的逻辑就是 bind、listen 后不停地 accept每来一个连接就开一个std::thread处理。while (true) { SOCKET client accept(listen_sock, (sockaddr*)addr, addr_len); if (client INVALID_SOCKET) continue; std::thread(handle_client, client).detach(); }这里detach是为了不让线程生命周期跟主循环纠缠。连接数少的时候没问题但要注意detach后的线程如果访问了主线程的共享变量要自己做同步。我一般习惯用std::shared_ptrClientSession把每个连接的 socket 和缓冲区包起来线程函数传这个智能指针就不会出现连接关闭后 socket 被复用的悬空问题。4.2 收数据的核心粘包与半包的正确处理姿势TCP 是字节流没有“消息边界”。一次recv返回 100 字节这 100 字节可能包含 1.5 个 WebSocket 帧也可能只是某个大帧的前半截。所以服务端必须有一个接收缓冲区把每次recv到的数据 append 进去然后循环尝试“从缓冲区里解析出完整的一帧”解析成功就处理并消费掉对应字节解析失败就等下一批数据来继续测。char tmp[4096]; int n recv(client, tmp, sizeof(tmp), 0); if (n 0) { /* 连接断开 */ } recv_buffer.append(tmp, n); while (true) { FrameHeader hdr; // 先解析头部数据不够就退出 if (!decode_frame_header(recv_buffer.data(), recv_buffer.size(), hdr)) break; // 数据不够一帧也退出 if (recv_buffer.size() static_castsize_t(hdr.header_len hdr.payload_len)) break; // 此时一帧完整取出 payload std::vectorchar payload(recv_buffer.begin() hdr.header_len, recv_buffer.begin() hdr.header_len hdr.payload_len); if (hdr.mask) apply_mask(payload.data(), hdr.payload_len, hdr.mask_key); process_frame(hdr.opcode, payload); // 分发处理 recv_buffer.erase(0, hdr.header_len hdr.payload_len); // 消费掉这一帧 }while (true)这个循环就是防止一次recv里吞了多帧。每消费一帧缓冲区前移直到剩余字节数不足以构成完整帧才停。千万别在这里加break只处理一帧就返回否则一定会出现数据滞留然后越积越多最后内存飙升。recv_buffer用什么容器源码里一般用std::string当字节流缓冲区因为erase和append都方便。数据量大了之后erase(0, n)会把剩余字节往前挪产生复制开销但几百 KB 的缓冲区内这点复制完全可以忽略。真要调优再换环形缓冲区或dequechar不迟。4.3 多个连接共用一个缓冲区别这么干我见过一个翻车案例有人把所有连接的recv数据全往一个全局std::string里塞然后用客户端标识去切分结果两个客户端同时发数据时互相污染消息错乱。正确做法是每个连接维护自己的recv_buffer也就是上面的ClientSession结构里各自放一个std::string互不干扰。客户端管理还要处理的一件事是关闭帧。收到 opcode 0x8 的帧时服务端应该回一个相同 opcode 的关闭帧然后关闭 socket。注意浏览器在关闭页面时通常会主动发关闭帧如果服务端不处理浏览器那边会显示连接异常断开然后自动重连造成“明明什么都没做服务端日志里全是新连接”的假象。4.4 断线检测与心跳服务端怎么知道客户端还活着很多人的第一版服务器没有心跳机制然后线上跑两天发现连接数只增不减。原因是 TCP 连接一端崩了另一端不一定立刻知道。比如手机断网系统不会马上发 FIN服务端的 socket 还挂着你以为客户端还在线其实它早就失联了。WebSocket 协议自带 Ping/Pong 帧服务端可以定期发 opcode 0x9 的 Ping 帧客户端收到后会自动回 Pong 帧浏览器自动响应不需要 JS 代码参与。服务端每收到一次 Pong就给对应连接打一个“存活时间戳”超过 N 秒没收到就主动断开。这就是最常用的心跳机制。// 每 60 秒检查一次所有连接 for (auto pair : sessions) { ClientSession s pair.second; if (now - s.last_pong_time 90) { send_close_frame(s.sock); // 发送关闭帧 closesocket(s.sock); sessions.erase(pair.first); } else if (now - s.last_ping_time 30) { send_ping_frame(s.sock); // 发送 Ping 帧 s.last_ping_time now; } }心跳间隔怎么设我一般习惯 30 秒发一次 Ping90 秒没收到 Pong 就判定死亡。这个参数要结合业务场景调局域网内可以 5 秒一次公网环境建议放宽到 30 秒以上因为移动网络下客户端可能短暂失去网络但很快恢复间隔太短会误杀大量正常连接。5. 避坑与排查调试 WebSocket 服务器时我踩过的五个真实问题5.1 浏览器连接失败状态码 200 而不是 101现象浏览器控制台报Error during WebSocket handshake: Unexpected response code: 200在线测试页直接连不上。原因服务端在握手响应里写了HTTP/1.1 200 OK而不是HTTP/1.1 101 Switching Protocols。我第一次写的时候顺手用了个 HTTP 响应模板忘了改状态行。解决把状态行改成HTTP/1.1 101 Switching Protocols同时确保响应头里有Upgrade: websocket和Connection: Upgrade两行换行符是\r\n不是\n。很多客户端对换行符敏感纯\n分隔的响应头里也能解析成功但为了保险起见统一用\r\n。5.2 服务端 recv 一直阻塞退出不了现象线程调用了recv后就卡死程序退出时无法关闭线程只能强制杀进程。原因没有设置 socket 接收超时。自然流量下客户端不一定立刻发数据recv默认会永远阻塞。程序想优雅退出时这个阻塞的线程根本响应不了关闭指令。解决给 socket 设置SO_RCVTIMEO根据平台不同用setsockopt的第二个参数传SOL_SOCKET和SO_RCVTIMEO。Windows 下超时结构体是DWORD毫秒Linux 下是timeval结构体。设置超时后recv超时返回SOCKET_ERROR配合WSAETIMEDOUT或EAGAIN判断是不是“暂时没数据”不要直接当成断开处理。// Windows 平台 int timeout_ms 5000; setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, (const char*)timeout_ms, sizeof(timeout_ms));5.3 中文消息乱码现象客户端发来的中文文本消息服务端收到后打印出来是乱码服务端发回中文浏览器显示的也是乱码。原因文本帧的 payload 是 UTF-8 编码但服务端在printf或者std::cout输出时用了本地代码页Windows 下 GBK导致显示错乱。服务端组装响应时如果用了sprintf把 UTF-8 字节直接拼进字符串也存在同样问题。解决服务端处理文本帧时永远把 payload 当成 UTF-8 字节流。打印调试时显式转成 UTF-8 控制台输出或者复用 hex dump 函数查看原始字节。发送前不要把 UTF-8 转成 GBK浏览器默认按 UTF-8 解析文本帧转了反而错。5.4 客户端发大消息服务端内存疯涨现象客户端一次性发送 100MB 数据服务端recv_buffer无限增长占满内存。原因decode_frame_header能解析出 64 位长度但如果recv_buffer里累积了大量不完整帧的数据同时没有对单帧最大长度做限制恶意或异常客户端可以把缓冲区撑到无限大。解决在缓冲区 append 之前检查长度超过设定阈值比如 16MB直接断开连接。同时在解析头部后判断payload_len是否超过业务可接受范围超了就回一个 close 帧然后掐断连接。WebSocket 协议本身没有强制限制但业务层必须加这个护栏。5.5 在线测试能通但自己的客户端连不上现象用浏览器在线测试页连接服务器一切正常换用自己写的 Python 或 Node.js 客户端就握手失败。原因浏览器会自动设置Origin头也会自动带上正确的协议头和随机 Key。自己写的客户端多半简化了握手请求比如漏了Connection: Upgrade头或者Sec-WebSocket-Key不是合法 Base64。解决别只测浏览器。准备一个最小化的测试脚本用curl加--include手搓握手请求或者写一个简单的 Python socket 客户端手工发送握手头。这样能精确控制每个字段排查到到底是哪一行头缺失。我后来把这段测试脚本固化成了一个顺手的小工具每改一次服务端就全量测一遍握手和数据收发。6. 在线测试与压测验证把浏览器当协议调试器用源码包里自带了一个readme.txt里面提到可以通过在线网页测试服务器。实际用起来这个测试页的工作原理就是浏览器里跑 JavaScript用new WebSocket(ws://127.0.0.1:8080)建立连接然后在onmessage回调里把收到的内容显示在页面上同时在输入框里输入文本后调用ws.send()发出去。这比你自己写客户端要省事得多因为浏览器把所有协议细节都封装好了服务端只要握手正确、帧格式合规消息就能双向流通。但浏览器测试有个盲区它不会告诉你帧细节到底对不对只要握手失败控制台就一行报错具体的帧内容根本看不见。所以我在这个源码的基础上补了一个自测脚本用原生 socket 模拟客户端自己拼握手请求自己构造带掩码的数据帧发出去后检查服务端返回的帧头和数据。这样能精确验证第 3 章里那套帧解析逻辑尤其是长度分支和掩码反转。import socket, base64, os, struct s socket.create_connection((127.0.0.1, 8080)) key base64.b64encode(os.urandom(16)).decode() req ( GET / HTTP/1.1\r\n Host: 127.0.0.1:8080\r\n Upgrade: websocket\r\n Connection: Upgrade\r\n fSec-WebSocket-Key: {key}\r\n Sec-WebSocket-Version: 13\r\n\r\n ) s.sendall(req.encode()) resp s.recv(4096) assert b101 in resp.split(b\r\n)[0], resp # 构造一个带掩码的文本帧内容为 hello payload bhello mask b\x11\x22\x33\x44 masked bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) frame b\x81 bytes([0x80 | len(payload)]) mask masked s.sendall(frame) data s.recv(4096) # 期望收到服务端返回的文本帧FIN1, opcode1 assert data[0] 0x81 assert data[1] len(data) - 2 print(data[2:].decode())这个脚本里0x81是 FIN 文本帧的意思第二字节0x80 | len(payload)表示 MASK 位是 1 且 payload 长度小于 126。这里要注意 Python 的bytes与bytearray的区别bytes([])是只读的做异或运算必须生成新对象不能原地修改。再往后走如果你要把这个服务器用到生产环境我建议先补三件事一是给每个连接加上应用层心跳处理收到 Ping 自动回 Pong同时定期主动发 Ping二是处理半关闭状态客户端断开后recv返回 0 要立刻清理资源三是加日志模块记下每次握手的关键字段、收发帧的 opcode 和长度排障的时候能少掉一半头发。我在自己的项目里就这样干过一版心跳缺了跑了一晚上第二天看连接数有两千多条全是僵尸连接内存涨到几百兆。从那以后我每次把这类服务器拿去部署之前都强制走一遍浏览器连通性测试、Python 脚本帧格式验证、长连接稳定性压测这三步确认没跳坑才敢交出去。这套源码作为一个学习起点和基础骨架能把 WebSocket 协议的关键路径完整摸一遍希望你拿它调试的时候能少走我走过的这些弯路省下来的时间去做真正的业务逻辑。希望帮到你。本文还有配套的精品资源点击获取
返回列表