轻量级C WebSocket服务器库cWebsocket移植与1006异常排查
2026/9/16 2:06:19 网站建设 项目流程

简介: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_sha1aw_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)Opcode=1 是文本帧,Opcode=8 是关闭帧
字节 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.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_tws_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=* EOF

version 和 author 我这里用了环境变量占位,避免把不确定的版本号写成事实,打包前按 README 里的实际信息填进去。architectures=*表示不限定板卡,如果只给特定开发板用,可以改成esp32avr。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_beginserver_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负载超过接收缓冲区限制

正确的服务端行为是:收到 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,要么帧解析时没有解开掩码。把这一步放在回归测试里跑,比每次都开浏览器点按钮可靠得多。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询