尧图精选

C++ WebSocket服务器实战:从RFC 6455握手到帧解析的完整实现

🕒 发布时间:2026/9/1 12:53:22 📁 来源:尧图网络
简介这是一份在Visual Studio 2017下编译通过的原生C WebSocket服务器源码面向希望理解WebSocket底层协议或搭建轻量级服务端的中高级C开发者。资源共41个文件压缩包约80.15MB包含6个cpp与4个h核心源码、Visual Studio工程文件.sln/.vcxproj、调试生成文件、readme说明及一个用于验证的test_client.html页面目录结构完整可直接打开编译运行。实现覆盖HTTP升级握手、帧格式解析、掩码处理、FIN/opcode识别以及数据解包与编码传输不依赖任何第三方网络库便于逐行对照学习TCP之上协议封装细节。配套说明文档给出了网页测试建议可快速验证连接建立、消息收发与连接保持行为。目前已有14人学习适合作为教学示例或二次开发的通信基础框架。 最近在VS2017下整理了一份可以编译运行的C WebSocket服务器源码核心是完整实现了RFC 6455里最关键的握手和帧解析两个环节。这里把整个项目从协议原理、代码设计到工程编译的细节都拆开讲一遍适合想在Windows平台上用C做实时推送、局域网通信或者想搞懂WebSocket底层机制的朋友参考。整个项目不依赖第三方库用WinSock原生API实现你用VS2017打开就能跑跟着文章走一遍基本就能在自己的程序里嵌入WebSocket服务能力了。1. 项目概述与整体设计思路1.1 WebSocket协议到底解决了什么问题WebSocket不是凭空冒出来的协议它解决的是HTTP协议“一个请求一个响应”带来的实时性痛点。传统HTTP轮询会不断建立TCP连接服务端没法主动推送数据延迟和带宽浪费都很明显。WebSocket在HTTP基础上通过一次握手协商升级协议之后两端就能在一条TCP连接上全双工通信服务端可以随时把数据推给客户端。这个项目要做的就是把这个协商和后续通信过程在C里手工实现一遍。网上的WebSocket库不少但直接扒开看源码反而容易迷糊因为很多库把网络层、协议层都封装得太完善。自己从socket往上写能真正理解握手请求怎么解析、帧字节怎么解读这对排查线上问题特别有用。1.2 为什么选择VS2017和WinSock方案选VS2017没有太多高深理由就是工程上够用。VS2017支持C11/14的大部分特性代码写起来比老VC6舒服很多同时没有VS2022那么重。如果你机器上只有VS2019或VS2022代码也完全可以兼容因为项目没有用到编译器特有语法。网络模型用的是WinSock的阻塞模式加select多路复用而不是IOCP或异步事件模型。这样设计主要是为了突出协议解析本身单线程里同时维护多个客户端连接select负责监听可读事件逻辑简单不会一上来就被异步回调绕晕。等整个协议栈跑通再换成IOCP或者线程池就是顺手的事。1.3 源码模块划分与整体流程项目里我按功能拆成了几个相对独立的块模块职责socket_server创建监听socketaccept新连接维护客户端列表http_handshake解析HTTP Upgrade请求计算Accept响应头frame_codec数据帧的编解码包括掩码处理、分片重组message_buffer每个连接独立的读写缓冲区处理粘包半包server_main初始化、主循环、关闭清理整体流程可以简单概括为启动监听select检测新连接和已连接socket的可读事件新连接先走握手模块成功后进入帧解析模块帧解析完整一条消息后交给消息处理回调默认是原样回传给客户端也就是echo服务。这样模块边界清楚后续加心跳、加业务逻辑都不需要大改。2. 核心细节HTTP升级握手实现2.1 解析客户端握手请求WebSocket握手本质上是一个完整的HTTP GET请求。客户端会发送类似下面的内容GET /chat HTTP/1.1 Host: 127.0.0.1:8080 Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ Sec-WebSocket-Version: 13我在服务端接收到这个请求后会先在缓冲区里查找\r\n\r\n因为HTTP头到此结束。如果没有找到说明数据没收全继续等待。找到之后按行拆分逐行解析只关心关键的几个字段Upgrade必须是不区分大小写的websocketConnection里要包含UpgradeSec-WebSocket-Key需要原样保留。这里要特别提醒一下请求头字段名和值都要忽略大小写很多初学者直接strcmp判断然后就握手失败。我习惯把所有请求头转成小写再比较或者直接用_stricmp。2.2 计算Sec-WebSocket-Accept并返回101这是握手最核心的一步。服务端拿到Sec-WebSocket-Key之后拼接固定的GUID字符串const std::string wsGUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11; std::string acceptRaw secWebSocketKey wsGUID;然后对这个拼接后的字符串做SHA-1哈希再把20字节的哈希结果用Base64编码得到Sec-WebSocket-Accept响应头。在Windows上可以直接用CryptoAPI的CryptAcquireContext和CryptHashData来算SHA-1省去自己实现哈希函数的工作量。核心代码类似下面#include windows.h #include wincrypt.h #include vector std::string sha1_base64(const std::string input) { HCRYPTPROV prov 0; HCRYPTHASH hash 0; CryptAcquireContext(prov, nullptr, nullptr, PROV_RSA_FULL, CRYPT_VERIFYCONTEXT); CryptCreateHash(prov, CALG_SHA1, 0, 0, hash); CryptHashData(hash, (const BYTE*)input.data(), (DWORD)input.size(), 0); DWORD hashSize 20; std::vectorBYTE sha1Buf(hashSize); CryptGetHashParam(hash, HP_HASHVAL, sha1Buf.data(), hashSize, 0); CryptDestroyHash(hash); CryptReleaseContext(prov, 0); // 然后对 sha1Buf 做 Base64 编码 }Base64编码网上有很多现成实现自己写一个也不难。我之前图省事直接用CryptBinaryToStringA做Base64编码记得加上CRYPT_STRING_BASE64标志并且把末尾的换行去掉。响应包要严格按照HTTP格式回HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: 计算出来的值 \r\n注意最后一定要多出一个空行也就是响应结束后再补一个\r\n否则客户端一直等不到响应头结束。2.3 握手阶段容易踩的坑我最早调试的时候在握手阶段卡了整整一个晚上最后发现是响应头里多了个Content-Length。HTTP服务器加这个头习惯了但在WebSocket握手响应里不能加因为101响应没有消息体加了这个头反而会让浏览器解析异常。另一个坑是请求行里的路径被忽略。如果你要实现同一个端口下多个服务路径比如/chat和/data就得解析请求行中的URI在握手时根据路径决定是否同意升级。我在这个版本里只做了最简单的路径放行但代码里预留了requestPath字段方便扩展。3. 核心细节帧解析与封包3.1 帧格式从两个字节开始握手成功之后数据就不再是HTTP文本而是一个个二进制帧。帧格式最关键的是前两个字节第一个字节FIN(1bit) RSV(3bit) opcode(4bit) 第二个字节MASK(1bit) payload len(7bit)opcode有几种常用值0x1表示文本帧0x2表示二进制帧0x8表示连接关闭0x9表示ping0xA表示pong。客户端发给服务端的帧必须设置MASK位为1而服务端发给客户端必须设置MASK为0这是协议强制的。我在代码里用一个结构体保存解析结果struct ws_frame_header { bool fin; uint8_t opcode; bool masked; uint64_t payload_len; uint8_t mask_key[4]; };解析时先读两个字节如果payload len等于126就再读2字节作为实际长度等于127就再读8字节。注意这些长度都是大端序需要手动转换。3.2 掩码处理与分片重组从客户端收到的payload是经过掩码异或的必须用mask key还原。还原算法很简单每个字节按顺序和mask key的第i % 4个字节异或。如果不还原文本消息会变成乱码。分片重组是另一个容易漏掉的地方。当一个大消息被拆成多个帧发送时第一帧的FIN是0opcode是实际类型中间和最后一帧的opcode是0continuation最后一帧FIN是1。服务端应按照这个规则把多个帧的payload拼接成一个完整消息。我在代码里维护了一个fragmented_message缓冲处理逻辑大致如下if (opcode 0x0) { // 是continuation帧把payload追加到之前的消息缓冲 if (!fragmented) { // 没有起始帧就收到continuation直接断开连接 return false; } append_to_fragment(payload); if (fin) { // 完整消息重组完成交给回调 handle_message(buf, len); fragmented false; } } else { if (fin) { // 单个帧就是一个完整消息 handle_message(payload, len); } else { // 开始一个分片消息 fragmented true; reset_fragment(); append_to_fragment(payload); } }这里要注意ping、pong和close帧不能作为分片消息的开始协议规定它们不允许被分片。所以在收到中间控制帧时不能把opcode当作continuation处理。3.3 粘包、半包与缓冲区设计TCP是流式协议应用层看到的不是消息边界。一个WebSocket帧可能被拆成两个TCP包到达也可能多个帧一次性到达。如果处理不好解析就会出错。我的做法是每个连接维护一个std::vectorchar接收缓冲区每次recv到的数据先追加到这个缓冲区末尾然后循环尝试从缓冲区头部解析帧头和数据能解析出一帧就处理一帧并把已消费的数据从缓冲区中移除。这样无论收包怎么切分都能正确拼出完整帧。缓冲区不能无限增长我在代码里设置了最大消息长度限制默认64KB超过就直接断开连接防止恶意客户端发超大帧拖垮服务。这在Windows 8之前还会因为整数溢出导致内存分配异常所以需要先判断长度再分配。4. 实操过程在VS2017中搭建与运行4.1 创建工程和基础配置打开VS2017新建一个“Windows控制台应用程序”项目项目名称随便取。关键配置有几处第一工程属性里要链接ws2_32.lib。因为WinSock函数在系统库中不链接会报一堆unresolved external symbol。在“链接器-输入-附加依赖项”里加上ws2_32.lib即可。第二在源码顶部定义_WIN32_WINNT我这边直接#ifndef _WIN32_WINNT #define _WIN32_WINNT 0x0601 #endif #include winsock2.h #include ws2tcpip.h注意winsock2.h一定要放在windows.h之前否则会因为宏冲突报一堆错。最好连windows.h都不要显式包含需要的话放在winsock2.h之后。4.2 编译链接常见错误我第一次编译时遇到一个很怪的错误error C2011: fd_set : struct type redefinition查了半天才发现是先包含了windows.h再包含winsock2.h导致的。解决方法就是调整头文件顺序或者在项目里禁用windows.h的winsock版本。另一个常见错误是C4996VS2017把一些老函数标记为不安全比如strcpy、sprintf。我的建议是在文件最前面加#define _CRT_SECURE_NO_WARNINGS否则编译时满屏警告看着很烦。至于inet_addr、htons这些网络函数在Windows下没问题不用特殊处理。4.3 运行测试用浏览器和脚本验证项目编译通过后启动监听在127.0.0.1:8080。打开Chrome的控制台直接执行JavaScript代码测试let ws new WebSocket(ws://127.0.0.1:8080); ws.onopen () { console.log(connected); ws.send(hello); }; ws.onmessage (e) { console.log(recv: e.data); };如果服务端工作正常控制台会先后输出connected和recv: hello。因为项目默认是echo模式把收到的消息原样回传。没有浏览器的时候可以用Python写个临时客户端但要注意Python的websocket库可能需要安装。更轻量的做法是用在线websocket测试工具填上地址就能连。实测下来原生的web浏览器验证最靠谱因为浏览器对握手和帧格式检查最严格只要浏览器能正常收发基本说明协议解析没问题。5. 常见问题与排查技巧实录5.1 握手总是返回400或者连接直接失败这类问题90%出在Sec-WebSocket-Accept计算错误或者响应头格式不对。我排查时会先用浏览器开发者工具看网络面板找到WebSocket连接请求点开“响应头”看有没有Sec-WebSocket-Accept字段以及状态码是不是101。还有种情况是和HTTP代理有关本地测试一般不会触发。如果代码里解析HTTP头不够健壮遇到请求头里字段顺序变化就解析失败建议把请求头打印到日志里逐行核对。5.2 连接成功但收到的消息是乱码乱码基本可以确定是掩码解析没做。客户端发给服务端的帧一定带MASK服务端必须用mask key逐字节异或。我在调试时会加一段日志把帧头两个字节以十六进制打印出来如果第二个字节最高位是1但代码里没做掩码还原那就是问题所在。另外还要检查是不是把掩码处理里的i % 4写成了i % 8这种低级错误特别容易忽略。5.3 连接建立后立刻断开或者收发大消息异常这类情况大概率是分片重组没实现。某些客户端发送较大消息时会自动分片服务端如果只处理FIN1的单帧消息就会把分片消息中间的空数据当作完整消息处理或者因为无法解析continuation帧而断开连接。按照前面帧解析的逻辑把opcode 0x0处理好就能解决。大消息还有可能是缓冲区越界。用vector的data()和resize时要小心先resize再memcpy不要靠push_back逐字节追加性能会差很多。5.4 避坑清单速查现象原因解决办法连接返回404握手路径未匹配检查请求行统一放行或按路径分发连接返回500响应头格式错误确保末尾有空行不要加Content-Length消息是乱码未做掩码异或解析payload前逐字节与mask key异或大消息只收到一半未处理分片实现continuation帧拼接粘包导致解析错位缓冲区未消费干净每处理一帧就erase已用字节客户端一直收不到数据服务端响应帧忘记清MASK位服务端发帧时MASK必须为0编译报redefinitionwinsock2.h包含顺序错误调整头文件顺序定义_WIN32_WINNT6. 项目扩展与个人心得这个版本虽然简单但我实际测试下来非常稳定在局域网内同时挂十几个客户端收发小消息没什么压力。如果要用于生产环境有几个方向可以继续做加TLS支持变成wss加心跳机制处理断线把echo回调改成具体业务分发。网络模型也可以从select升级到IOCP吞吐量会明显提升。我个人在调试这套代码时最深的体会是WebSocket协议卡住人的从来不是那些花哨的框架而是握手和帧结构这些最不起眼的细节。只要把这两个核心环节吃透后面无论换成Java、Go还是Node.js都能很快读懂对应库的源码。希望这份源码能帮你少走点弯路有问题欢迎在评论区聊。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →