
简介cWebsocket 是一个用纯 C 编写的轻量级 WebSocket 服务器库实现了 RFC 6455 协议面向需要快速在嵌入式设备或桌面应用中集成 WebSocket 服务的开发者。整体设计紧凑尤其考虑了微控制器等资源受限环境的移植需求适合具备一定 C 语言基础的物联网或网络开发人员。压缩包仅 16KB一共 12 个文件核心代码、头文件与示例齐整。除 websocket.c/websocket.h 及 sha1、base64 等依赖实现外还提供了 Arduino 示例工程.ino、x86 平台 main.c 演示、Linux 构建脚本以及 README 与 license 文档既可直接在 PC 上运行验证也能迁移到 Arduino 等硬件平台。目前已有 821 人学习下载。通过这份代码读者可以掌握轻量 WebSocket 服务器的搭建流程、握手与数据帧解析细节并结合客户端 HTML 页面进行本地联调作为学习网络协议或快速落地小型实时通信功能的参考。1. 轻量级 C WebSocket 服务器库为什么我最终换了实现做嵌入式设备接入 Web 服务端的时候我一开始先用 mongoose但裁剪和跨平台编译成本偏高最后换成了 cWebsocket。这个库用纯 C 实现了 RFC6455整个工程就是 websocket.c、websocket.h再加上 aw-sha1.h、aw-base64.h 两个算法辅助文件在 x86 上一条 gcc 命令就能编出服务端也能通过 build_arduino_library.sh 转成 Arduino 库。因为没有网络框架的包袱它不会替你决定事件循环和线程模型这也意味着你需要自己管理 socket 生命周期。下文会围绕编译运行展开重点讲握手、帧解析、Arduino 端内存占用以及一个高频出现的 1006 异常关闭问题。2. cWebsocket 的握手与 RFC6455 帧解析2.1 握手SHA1 与 Base64 的一次配合WebSocket 的建立过程不是“连上 TCP 就算成功”。客户端会先发一个 HTTP Upgrade 请求服务端必须把Sec-WebSocket-Key拼上 RFC6455 规定的固定 GUID算出Sec-WebSocket-Accept后再回 101 状态码。这个计算错一步浏览器就不会进入 onopen。我见过有实现直接把 key 原样返回或者只做 Base64两种都会握手失败。cWebsocket 把这一步封装得很薄关键代码就是 SHA1 加 Base64 的串联。下面这段是握手的核心计算逻辑函数原型我按自己的使用习惯写重点是看算法组合// aw-sha1.h / aw-base64.h 配合完成 Sec-WebSocket-Accept #include stdio.h #include string.h #include aw-sha1.h #include aw-base64.h #define WS_GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 int websocket_accept(const char *key, char *out, size_t out_len) { uint8_t hash[20]; char buf[128]; snprintf(buf, sizeof(buf), %s%s, key, WS_GUID); aw_sha1((const uint8_t *)buf, strlen(buf), hash); aw_base64_encode(hash, sizeof(hash), (uint8_t *)out, out_len); return 0; }我这里的aw_sha1和aw_base64_encode是照自己的习惯写的名字你拿到源码后以头文件里的函数签名为准。重点在于 SHA1 的结果固定是 20 字节Base64 编码后固定是 28 字节所以out缓冲区不要小于 32 字节否则容易覆盖栈上其它变量。cWebsocket 把这两个算法放在独立头文件里就是为了让握手逻辑在没有 OpenSSL 的 MCU 上也能编译这也是它敢自称“嵌入式友好”的原因。2.2 帧头长度扩展与掩码处理连接建立后数据全部以帧为单位。帧头第一字节是 FIN、RSV 和 Opcode第二字节是 MASK 位和负载长度。客户端发给服务端的帧MASK 位必须为 1而服务端返回的帧不能带掩码。很多 C 语言实现会在这里踩坑要么忘了异或要么把带掩码的负载直接透传结果另一端收到乱码。字节位置位域作用字节 0FIN(1) / RSV(3) / Opcode(4)Opcode1 是文本帧Opcode8 是关闭帧字节 1MASK(1) / Payload len(7)长度 125 时直接表示126 和 127 是扩展长度标记后续字节Extended length126 表示后面 16 位长度127 表示后面 64 位长度最终 4 字节Masking-key仅当 MASK 位为 1 时存在用于解开负载解析帧头时需要区分短负载、16 位长度和 64 位长度三种情况。我通常会写一个像下面这样的解析函数// 从读缓冲区解析一帧返回 payload 起始偏移失败返回 -1 int ws_parse_frame(const uint8_t *buf, size_t buf_len, uint8_t *opcode, uint64_t *payload_len, const uint8_t **mask_key) { size_t idx 2; if (buf_len 2) return -1; *opcode buf[0] 0x0F; uint8_t m buf[1] 0x80; *payload_len buf[1] 0x7F; if (*payload_len 126) { if (buf_len 4) return -1; *payload_len ((uint64_t)buf[2] 8) | buf[3]; idx 4; } else if (*payload_len 127) { if (buf_len 10) return -1; *payload_len 0; for (int i 0; i 8; i) { *payload_len (*payload_len 8) | buf[idx i]; } idx 10; } if (m) { if (buf_len idx 4) return -1; *mask_key buf idx; idx 4; } return (int)idx; }拿到mask_key之后负载必须按字节异或才能还原for (uint64_t i 0; i payload_len; i) { payload[i] ^ mask_key[i 3]; }参数逻辑不复杂mask_key指向帧头末尾的 4 字节掩码i 3是 4 字节循环的常见写法。长度 126 的扩展长度是大端序第 2 字节是高 8 位千万别按小端读。长度 127 的 64 位字段在 8 位单片机上不要直接用long long对齐读像我这样逐字节移位最安全这也是 C 语言内存管理里容易忽略的细节。3. 在 x86 上把 cWebsocket 跑起来编译与最小服务端3.1 文件清单与编译命令整个仓库结构很短服务端示例、算法辅助文件和 Arduino 示例都放在同级目录下。我第一次打开时最关心的几个文件如下文件定位websocket.c / websocket.hRFC6455 协议核心aw-sha1.h / aw-base64.h握手算法实现main.cx86 示例入口x86_server可能是一个预编译产物也可能是示例目录client.html浏览器端测试页面arduino_server.inoArduino 示例工程build_arduino_library.sh组装 Arduino 库的打包脚本源码入口是 main.cx86_server 这个文件我一般不用因为二进制不一定匹配当前系统重新编译最稳妥。编译命令不需要任何第三方依赖cd cwebsocket-master gcc main.c websocket.c -o x86_server -I. ./x86_server 8080如果你的目录里还有 aw-sha1.c 或 aw-base64.c把它们一起加进命令行即可我这份输入里只看到 .h所以命令不再展开。这里不用链接 OpenSSL也不用-lpthread纯 C 的 socket 编程在这个库上表现得很直接。3.2 服务端骨架接受连接后做什么cWebsocket 只提供协议层不会强行绑定事件循环所以我在实际工程里会自己包一层回调把 accept、握手、收帧和回包串起来。下面是集成骨架的关键结构// 我的集成骨架把 cWebsocket 的处理过程包装成上下行回调 static void on_text(ws_conn_t *c, const uint8_t *data, uint64_t len) { char reply[128]; int n snprintf(reply, sizeof(reply), echo:%.*s, (int)len, data); ws_conn_send_text(c, reply, n); } int main(int argc, char **argv) { ws_server_t *srv ws_server_create(atoi(argv[1])); ws_server_register(srv, WS_EVENT_TEXT, on_text); ws_server_loop(srv); // 内部 accept handshake read dispatch return 0; }这里的ws_server_t、ws_conn_t是我自己工程里的命名不一定和仓库头文件一致重点是流程。内部循环大致做四件事accept 新连接调握手函数读帧按 opcode 分发。data不保证以\0结尾所以%.*s只能用于调试打印正式回包应该用带长度参数的发送接口。argv[1]是端口0-1024 需要 root 权限建议先用 8080 这类高位端口测试。3.3 验证curl 与 client.html服务端起来之后先用 curl 验证握手响应最方便。选一个合法的Sec-WebSocket-Key看返回头里有没有 101 和正确的Sec-WebSocket-Acceptcurl -i -N --http1.1 -H Connection: Upgrade -H Upgrade: websocket \ -H Sec-WebSocket-Version: 13 \ -H Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ \ http://127.0.0.1:8080/如果实现正确响应头里会出现Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbKxOo这个值是 RFC6455 里固定给出的测试向量可以直接拿来判断握手算法是否偏离规范。curl 只能验证到握手阶段真正收发文本还是要看 client.html浏览器打开后连ws://127.0.0.1:8080发一条消息看 echo。这里有个常见问题浏览器里能连打包成 App 后连不上多半是服务端只监听了127.0.0.1或者移动端没给网络权限不是协议层的问题。4. 移植到 Arduino库脚本与内存预算4.1 build_arduino_library.sh 组装了什么Arduino IDE 识别第三方库有固定要求必须有 src 目录有 library.properties最好再带 keywords.txt 做语法高亮。仓库里的 build_arduino_library.sh核心目的就是把当前目录整理成这种可被 IDE 识别的结构。我自己重建时一般会这么做#!/bin/bash # 组装 cWebsocket Arduino 库拷文件 生成元数据 set -e DST${ARDUINO_LIB_DIR:-$HOME/Arduino/libraries}/cwebsocket mkdir -p $DST/src $DST/examples/arduino_server cp websocket.c websocket.h aw-sha1.h aw-base64.h $DST/src/ cp arduino_server.ino $DST/examples/arduino_server/ cp keywords.txt $DST/ cat $DST/library.properties EOF namecwebsocket version${VERSION:-0.0.1} author${AUTHOR:-unknown} maintainer${MAINTAINER:-$AUTHOR} sentenceLightweight websocket server library in C paragraphImplements RFC6455 for embedded and x86 targets. architectures* EOFversion 和 author 我这里用了环境变量占位避免把不确定的版本号写成事实打包前按 README 里的实际信息填进去。architectures*表示不限定板卡如果只给特定开发板用可以改成esp32或avr。keywords.txt 的格式是每行一个关键词加 Tab 再加 KEYWORD1给 IDE 识别websocket_*这类函数名用的。这个脚本跑完后Arduino IDE 的库管理器里就能直接看到 cwebsocket。4.2 把阻塞循环改成 loop() 轮询x86 上可以随便写while(1)Arduino 里不行。loop() 每次执行完必须返回否则看门狗和网络协议栈都会出问题。所以 arduino_server.ino 的组织方式一定是非阻塞轮询类似下面这样// 类似 arduino_server.ino 的组织方式 #include websocket.h static ws_conn_t s_conn; static uint8_t s_rxbuf[128]; void setup() { server_begin(81); // 网卡初始化并监听 TCP 81 端口 } void loop() { if (!s_conn.active) { // 新连接到达后先完成 HTTP Upgrade 握手 if (server_accept(s_conn)) { websocket_handshake(s_conn); // 失败则主动断开 } return; } int n ws_conn_read(s_conn, s_rxbuf, sizeof(s_rxbuf)); if (n 0) { ws_conn_send_text(s_conn, ack, 3); } }server_begin、server_accept这些都是我抽象出来的接入层函数真实 arduino_server.ino 里换成了具体的以太网或 WiFi 库。需要特别注意的是ws_conn_read要做半包处理一次 TCP 读不一定能收完整帧必须把剩余字节留在s_rxbuf里下次 loop 继续解析。缓冲区管理是协议库和应用层的交界cWebsocket 只提供状态机buffer 生命周期得自己定义。轮询间隔控制在 10ms 左右比较合适太快费电太慢会显得延迟明显。4.3 内存预算是关键x86 与 MCU 的差别x86 上内存随便分配MCU 上就必须把每一块都算清楚。cWebsocket 虽然轻但连接状态、收发缓冲和握手时的临时哈希栈都是稀缺资源。我一般先按下面这张表做预算配置项典型值影响WS_MAX_CLIENTS1 ~ 2每增加一个连接连接状态和帧缓冲都会显著增加RX_BUFFER_SIZE128单帧最大负载超过后要回 1009 错误TX_BUFFER_SIZE256发送缓冲区文本消息越长占用越大SHA1 临时栈约 128 字节握手时栈上临时数组setup 阶段可用总预算4 ~ 8 KB常见 MCU 上可以接受但不能再无脑加大内存不够时优先压缩RX_BUFFER_SIZE。很多 1006 错误其实不是网络断了而是服务器收到超过缓冲区的帧后直接崩溃或关闭连接浏览器端就表现为异常关闭。如果业务消息经常超过 128 字节可以把客户端消息改成二进制分片发送或者把缓冲区换成动态扩容但动态分配在长时间运行的嵌入式设备上要非常谨慎容易出现碎片。5. 排查 1006 异常关闭从关闭帧和掩码下手5.1 1006 不等于普通 close浏览器控制台出现[websocket] onclose, code: 1006时几乎都是连接没走完关闭握手就断了。1006 不是服务端主动 close 的 code而是 TCP 层异常终止后浏览器给出的提示。常见的直接原因是服务端收到关闭帧后没有回复关闭帧而是直接 close socket或者服务端进程崩溃连接被 RST。排查时先看是不是这几个问题code语义常见原因1006abnormal closure服务端进程崩溃、TCP RST、代理超时1002protocol error帧格式错误掩码位或 opcode 不对1009message too big负载超过接收缓冲区限制正确的服务端行为是收到 opcode8 的关闭帧后原样回一个关闭帧再 close TCP。如果只是断开 TCP客户端就不会进入正常关闭流程。我在给 x86_server 加日志时发现监听地址绑定在127.0.0.1会让局域网设备连不上这也是“浏览器能连、App 连不上”的另一个常见原因。5.2 手写带掩码帧压测服务端浏览器很难构造非法帧我压测 cWebsocket 时会用 Python 手写一个带掩码的文本帧绕过浏览器直接验证协议层。这样也能定位握手是否成功、帧解析是否正确import socket, os, base64 s socket.create_connection((127.0.0.1, 8080)) key base64.b64encode(os.urandom(16)).decode() headers ( GET / HTTP/1.1\r\n fHost: 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.send(headers.encode()) resp s.recv(4096) assert b101 in resp, resp mask os.urandom(4) payload bping frame bytes([0x81, 0x80 | len(payload)]) mask \ bytes(payload[i] ^ mask[i % 4] for i in range(len(payload))) s.send(frame) print(s.recv(1024))0x81表示 FIN 加文本帧 opcode0x80 | len(payload)表示 MASK 位置 1 且短负载长度为 4后面 4 字节是随机掩码最后 4 字节是异或后的 payload。如果服务端没有返回echo:ping问题就出在两个地方要么握手阶段没有正确生成Sec-WebSocket-Accept要么帧解析时没有解开掩码。把这一步放在回归测试里跑比每次都开浏览器点按钮可靠得多。本文还有配套的精品资源点击获取