简介: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 语言实现会在这里踩坑:要么忘了异或,要么把带掩码的负载直接透传,结果另一端收到乱码。
| 字节位置 | 位域 | 作用 |
|---|---|---|
| 字节 0 | FIN(1) / RSV(3) / Opcode(4) | Opcode=1 是文本帧,Opcode=8 是关闭帧 |
| 字节 1 | MASK(1) / Payload len(7) | 长度 <=125 时直接表示;126 和 127 是扩展长度标记 |
| 后续字节 | Extended length | 126 表示后面 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.h | RFC6455 协议核心 |
| aw-sha1.h / aw-base64.h | 握手算法实现 |
| main.c | x86 示例入口 |
| x86_server | 可能是一个预编译产物,也可能是示例目录 |
| client.html | 浏览器端测试页面 |
| arduino_server.ino | Arduino 示例工程 |
| build_arduino_library.sh | 组装 Arduino 库的打包脚本 |
源码入口是 main.c,x86_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-Accept:
curl -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: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=,这个值是 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 name=cwebsocket version=${VERSION:-0.0.1} author=${AUTHOR:-unknown} maintainer=${MAINTAINER:-$AUTHOR} sentence=Lightweight websocket server library in C paragraph=Implements 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_CLIENTS | 1 ~ 2 | 每增加一个连接,连接状态和帧缓冲都会显著增加 |
| RX_BUFFER_SIZE | 128 | 单帧最大负载,超过后要回 1009 错误 |
| TX_BUFFER_SIZE | 256 | 发送缓冲区,文本消息越长占用越大 |
| 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 | 语义 | 常见原因 |
|---|---|---|
| 1006 | abnormal closure | 服务端进程崩溃、TCP RST、代理超时 |
| 1002 | protocol error | 帧格式错误,掩码位或 opcode 不对 |
| 1009 | message too big | 负载超过接收缓冲区限制 |
正确的服务端行为是:收到 opcode=8 的关闭帧后,原样回一个关闭帧,再 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" f"Host: 127.0.0.1:8080\r\n" "Upgrade: websocket\r\n" "Connection: Upgrade\r\n" f"Sec-WebSocket-Key: {key}\r\n" "Sec-WebSocket-Version: 13\r\n" "\r\n" ) s.send(headers.encode()) resp = s.recv(4096) assert b"101" in resp, resp mask = os.urandom(4) payload = b"ping" 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 加文本帧 opcode,0x80 | len(payload)表示 MASK 位置 1 且短负载长度为 4,后面 4 字节是随机掩码,最后 4 字节是异或后的 payload。如果服务端没有返回echo:ping,问题就出在两个地方:要么握手阶段没有正确生成Sec-WebSocket-Accept,要么帧解析时没有解开掩码。把这一步放在回归测试里跑,比每次都开浏览器点按钮可靠得多。
本文还有配套的精品资源,点击获取