简介:libwebsockets 4.0 稳定版源码包是一个采用纯 C 语言编写的 WebSocket 服务器与客户端库。它提供轻量级且高效的双向通信能力,适合嵌入式系统、资源受限设备以及需要高并发实时交互的 C/C++ 项目,能明显降低 WebSocket 对接与移植的复杂度。压缩包含 1248 个文件、11.76MB,核心为 360 个 C 源文件和 144 个头文件,同时提供 152 份 Markdown 文档、103 份文本说明、36 个 Shell 脚本,覆盖完整源码、构建配置、示例程序和调用文档。包内带 HTTP/2、MQTT、SSH 等扩展模块,以及 TLS 证书和调试工具,可支持开发者在实际工程中直接参考及二次开发。已有 1200 人学习/下载,适合需要引入稳定 WebSocket 通信方案的 C 语言开发者。
1. 拿到libwebsockets-4.0.0.tar.gz之后:这个库能帮你省掉什么
拿到 libwebsockets-4.0.0.tar.gz,说明你已经在考虑怎么在 C 程序里接 WebSocket 了。这个库是嵌入式和服务端场景里最常见的 C 语言 WebSocket 实现,握手、分帧、掩码、分片、Ping/Pong、TLS 这些协议细节它全包了,你只需要注册几个回调函数,剩下的交给事件循环。它解决的是两类问题:一类是拿裸 socket 自己写 WebSocket,被协议细节反复折磨;另一类是项目里塞进一个重量级框架,交叉编译和内存占用都让人头疼。适合的人也很明确:手上有 C 工程、想加 WebSocket 通道,或者在嵌入式设备上做实时通信的开发者。这篇内容我从编译、接入到常见坑,把这条路线完整走一遍,你可以照着落地。
2. 认识libwebsockets:事件驱动架构与协议处理的边界
2.1 从WebSocket协议到库的职责边界
先说 WebSocket 协议本身。客户端建立连接时发的是 HTTP Upgrade 请求,服务端要解析 Sec-WebSocket-Key,做一次 SHA-1 + Base64 计算,返回 Sec-WebSocket-Accept。之后的每个数据帧有 FIN 位、opcode、mask 位、长度字段,服务端收到的帧必须处理掩码,客户端发出去的帧必须加掩码;长消息涉及 7 位超长长度、分片和重组;连接维护要靠 Ping/Pong 心跳和关闭握手。这些细节单独看都不难,但拼在一起会出现一个很现实的问题:任何一个字节错位,连接就废了,而且很难定位。
libwebsockets 把这条边界划得很清楚。它负责 socket 生命周期、HTTP 升级、帧解析、分片重组、控制帧处理和 TLS 加解密;业务代码只处理两件事:连接建立与关闭事件、应用层负载的收和发。你不需要关心底层缓冲区里一个帧是 4 字节还是 10 字节,回调拿到的 in 指针和 len 长度就是完整的一帧应用数据,这个前提让上层代码干净很多。
2.2 理解Context、协议和会话:三个概念串起整个API
用这个库之前,建议先把三个概念搞清楚,否则后面看示例代码容易懵。
第一个是 lws_context,全局上下文,一个进程通常只有一个。它持有监听 socket、epoll/poll 的事件循环、日志级别、TLS 证书、vhost 列表等全局状态。lws_create_context 是入口,lws_service 是它的心跳函数,lws_context_destroy 是出口。
第二个是 lws_protocols,协议描述符数组。它不是一个协议实现,而是一个"连接收发的处理配置":每个元素包含协议名、回调函数指针、per_session_data_size、rx_buffer_size。协议名会跟在 URL 路径后面做匹配,比如 ws://127.0.0.1:8080/myproto 走的是名字为 myproto 的那个协议。数组最后一个元素必须是全 NULL 的哨兵。
第三个是连接与附加数据。每次连接建立,库会为这个连接分配一块 per_session_data_size 大小的内存,回调里的 user 参数就是这块内存的指针。你可以在里面放会话状态,比如收了一半的包、缓冲区、用户标识。连接关闭时这块内存由库回收,但你在里面 malloc 出来的资源要自己在 LWS_CALLBACK_CLOSED 里清理。
这三个概念对应到代码上,就是 main 里创建 context、注册 protocols 数组、回调里用 user 存取会话数据。理解了这个结构,后面所有示例都离不开这个骨架。
2.3 选型理由:为什么不自写、不换其他C库
我接触过不少团队一开始想自己写,理由通常是"WebSocket 协议不复杂"。协议确实不复杂,但连接管理复杂:断线重连、半包粘包、TLS、心跳超时、多路复用、高并发下的文件描述符管理,这些才是真实工作量和踩坑点。自写方案往往在第一版能跑,在并发上来之后开始出各种偶发问题。
市面上 C 语言层面的方案大致可以分三类,我列个对比:
| 方案 | 协议处理 | socket/事件循环 | TLS | 适用场景 |
|---|---|---|---|---|
| 自写 | 需要自己实现 | 自己管理 | 自己接 | 学习、极短生命周期 |
| 协议库 | 只做帧解析 | 不提供 | 需要自己接 | 已经有事件循环的重度定制项目 |
| libwebsockets | 全包 | 内置 poll/epoll | 内置 | 嵌入式、服务端、通用接入 |
选它的理由对我来说是三条:纯 C,交叉编译简单,头文件只有 libwebsockets.h 一个;无强制依赖,不需要 SSL 可以把 LWS_WITH_SSL 关掉,整个库编出来非常干净;服务端和客户端都支持,一套 API 两侧通吃。4.0.0 是 2020 年前后的稳定版本,虽然版本不算新,但 API 骨架和现在的版本保持一致,拿它上手,后续升级迁移成本也低。
提示:如果项目只需要服务端不需要客户端,CMake 里有对应开关可以裁掉客户端相关代码,减少镜像体积和编译时间。
3. 从tar.gz到跑通第一个WebSocket服务:编译安装与最小示例
3.1 环境准备与CMake配置:关键开关解释
前提环境:Linux 机器、gcc、cmake、make。如果要用 wss(TLS),还需要 OpenSSL 开发头文件。Debian/Ubuntu 上先检查这三样:
gcc --version cmake --version openssl version没有装的话:
sudo apt update sudo apt install -y build-essential cmake libssl-dev装完之后解压源码包,进入目录,最简配置是这样:
tar zxf libwebsockets-4.0.0.tar.gz cd libwebsockets-4.0.0 cmake -S . -B build \ -DCMAKE_BUILD_TYPE=Release \ -DLWS_WITH_SSL=ON \ -DLWS_WITH_MINIMAL_EXAMPLES=ON这里每个参数都有讲究。CMAKE_BUILD_TYPE 设成 Release 是必须的,Debug 版自带大量断言和未优化代码,性能差好几倍,自己测试看不出问题,压测时数据很难看。LWS_WITH_SSL 控制 TLS 支持,需要 wss 时打开,系统里没有 libssl-dev 就先关掉,跑通再加。LWS_WITH_MINIMAL_EXAMPLES 是编译库自带的最小示例程序,第一次验证环境建议打开,后面自己写代码时再用 LWS_WITHOUT_TESTAPPS 裁掉。
第一次先别加太多开关,用默认值让整个流程走一遍,能编出库来,再考虑裁剪。
3.2 编译安装与产物验证:装完后先做两件事
配置完成之后直接编译,可以并行:
cmake --build build -j$(nproc)编译完成后安装到系统路径:
cmake --install build如果安装目录不在用户权限内,cmake --install 前面加 sudo。安装完成后按顺序做两件事。第一件,确认库文件:
ls /usr/local/lib | grep -i websockets正常情况下看到一个 libwebsockets.so 或 libwebsockets.a,版本号形式可能是 libwebsockets.so.17 这种。第二件,确认 pkg-config 配置:
pkg-config --modversion libwebsockets如果输出 4.0.0,说明安装完整;如果提示 not found,看有没有装到 /usr/lib/pkgconfig 或 /usr/local/lib/pkgconfig,把 PKG_CONFIG_PATH 指过去。
这一步很多人跳过,后面自己写 Makefile 时猜链接参数,很浪费时间。先花 30 秒确认 pkg-config 可用,后续编译命令会省很多事。
3.3 运行库自带示例:用Python客户端验证回显
安装完先不急着写代码,把库自带的示例程序跑起来,验证环境本身没问题。程序在 build/bin 下:
ls build/bin/找一个名字看起来像 minimal-websocket-server 的可执行文件,不同版本名字略有差异。直接运行:
./build/bin/minimal-websocket-server程序启动后会一直运行不退,这是事件循环在等连接。它监听的端口通常会在启动日志里打印,一般默认是 7681 或 8080,记下这个端口号。
然后写一个最简 Python 客户端做连接和回显验证。需要 websockets 库,没有就 pip install websockets:
import asyncio import websockets async def main(): # 端口按服务端日志里的实际值修改 async with websockets.connect("ws://127.0.0.1:7681") as ws: await ws.send("hello from python client") reply = await ws.recv() print("recv:", reply) asyncio.run(main())这段代码逻辑很简单:connect 建立 WebSocket 连接,send 发送一帧文本,等 recv 收到服务端回显,打印出来。如果服务端是回显类示例,输出就是 hello from python client。能跑通说明编译的库、事件循环、帧收发整个链路都正常,接下来可以放心往自己项目里接。
如果连接失败,先看服务端有没有报错输出,再用 lsof -i :7681 确认端口监听状态。demo 程序大多自带日志,出错时会打印连接拒绝、握手失败的具体原因。
4. 把libwebsockets嵌入自己的项目:回调模型与最小接入代码
4.1 集成方式怎么选:外部依赖还是源码级接入
把库接入自己的工程,常见做法有两种。
做法一:依赖已安装的库,用 CMake 的 pkg-config 方式。4.0.0 装好后自带 pkg-config 文件,接入逻辑写这几行:
find_package(PkgConfig REQUIRED) pkg_check_modules(LWS REQUIRED IMPORTED_TARGET libwebsockets) add_executable(my_app main.c) target_link_libraries(my_app PRIVATE PkgConfig::LWS)这种方式的优点是好维护,库升级时替换动态库即可;缺点是换了机器要重新安装库,交叉编译时还要给目标板单独编译一份。适合服务端、工具类程序。
做法二:源码级接入,把 libwebsockets-4.0.0 解压后放进自己工程的 third_party/libwebsockets 目录,在根 CMakeLists 里加一句:
add_subdirectory(third_party/libwebsockets)然后你的目标链接它生成的 target,target 名一般是 websockets。这种方式的好处是一次编译,交叉编译时只要目标工具链能编过,库就跟着走了;缺点是首次配置第三方库参数时需要多花点时间。嵌入式项目我一般用做法二,因为目标板上的磁盘空间和依赖都受控。
4.2 回调事件流:从连接建立到一帧数据收发的完整路径
libwebsockets 是单线程事件驱动模型,主循环就是 lws_service 反复跑。理解事件流,比记 API 清单更重要。
一次完整的收发路径是这样的。客户端 TCP 连上来,库完成 HTTP Upgrade,触发 LWS_CALLBACK_ESTABLISHED,此时你可以初始化会话数据。之后客户端发来数据,库解析完一帧后触发 LWS_CALLBACK_RECEIVE,参数 in 指向负载、len 是负载长度。你想回数据,不能直接在 RECEIVE 里写,正确做法是先调 lws_callback_on_writable(wsi),请求一个可写事件。下次事件循环发现这个连接可写,触发 LWS_CALLBACK_SERVER_WRITEABLE,在这里面调 lws_write 把数据发出去。最后客户端断开或超时,触发 LWS_CALLBACK_CLOSED,在这里面释放资源。
这就是整个模型最核心的约束:不要在回调里做耗时操作,也不要跨回调保存 wsi 后乱写。所有写操作都通过 on_writable + WRITEABLE 事件去执行,库才能保证发送缓冲区的状态是对的。违反这个约束,最典型的现象就是数据发不出去或者连接被强制关闭。
4.3 最小回显服务代码拆解:三个回调足够干活
下面这段代码包含完整的服务端逻辑:连接建立打日志,收到文本原样回发,连接关闭打日志。保存为 ws_echo.c。
#include <libwebsockets.h> #include <stdio.h> #include <string.h> /* 每个连接一份的会话数据,库分配,回调里通过 user 拿到 */ struct session_data { char buf[4096]; int len; }; static int ws_callback(struct lws *wsi, enum lws_callback_reasons reason, void *user, void *in, size_t len) { struct session_data *sdata = (struct session_data *)user; switch (reason) { case LWS_CALLBACK_ESTABLISHED: printf("client connected\n"); break; case LWS_CALLBACK_RECEIVE: if (sdata == NULL) break; /* 把收到的数据暂存到会话缓冲区,等可写事件再发 */ if (len > sizeof(sdata->buf)) len = sizeof(sdata->buf); memcpy(sdata->buf, in, len); sdata->len = (int)len; if (lws_callback_on_writable(wsi) < 0) return -1; break; case LWS_CALLBACK_SERVER_WRITEABLE: if (sdata != NULL && sdata->len > 0) { if (lws_write(wsi, (unsigned char *)sdata->buf, sdata->len, LWS_WRITE_TEXT) < 0) return -1; sdata->len = 0; } break; case LWS_CALLBACK_CLOSED: printf("client closed\n"); break; } return 0; } static struct lws_protocols protocols[] = { { "myproto", ws_callback, sizeof(struct session_data), 4096, 0, NULL, 0 }, { NULL, NULL, 0, 0, 0, NULL, 0 } }; int main(void) { struct lws_context_creation_info info; struct lws_context *context; memset(&info, 0, sizeof(info)); info.port = 8080; info.protocols = protocols; info.gid = -1; info.uid = -1; context = lws_create_context(&info); if (context == NULL) { fprintf(stderr, "lws_create_context failed\n"); return 1; } while (1) lws_service(context, 50); lws_context_destroy(context); return 0; }代码逻辑说明:protocols 数组第一个元素注册了协议 myproto,回调是 ws_callback,per_session_data_size 是会话结构体大小,rx_buffer_size 是接收缓冲 4096。URL 用 ws://地址:8080/myproto 时才能匹配到这个协议。LWS_CALLBACK_RECEIVE 里只做暂存和注册写兴趣,实际发送在 LWS_CALLBACK_SERVER_WRITEABLE 里执行,这是事件驱动模型的标准写法。lws_write 第三个参数是帧类型,LWS_WRITE_TEXT 表示文本帧;发二进制数据用 LWS_WRITE_BINARY。返回值 0 表示回调处理正常,返回 -1 会被库视为错误并关闭连接。
编译命令用 pkg-config 拿到头文件和链接库,省去手动猜测:
gcc -o ws_echo ws_echo.c $(pkg-config --cflags --libs libwebsockets)如果 pkg-config 不可用,手动链接:
gcc -o ws_echo ws_echo.c -I/usr/local/include \ -L/usr/local/lib -lwebsockets -lpthread -lssl -lcrypto运行:
./ws_echo然后用刚才的 Python 客户端把端口改成 8080,路径改成 /myproto,收到原样回显就说明接入成功了。这一步跑通后,后续业务逻辑只需要在 RECEIVE 里做解析、在 WRITEABLE 里做应答。
5. libwebsockets避坑指南:四个把新手拦住的常见问题
5.1 客户端连上就断:在回调里做了阻塞操作
现象:客户端握手成功,日志里 ESTABLISHED 打了,下一秒连接就断;或者服务端整个进程卡住,其他连接也没响应。
原因:回调是在事件循环线程里执行的。在 RECEIVE 或 ESTABLISHED 里做 sleep、写文件、请求外部接口,都会让 lws_service 停在那里,后续所有连接的读写都排队。超时一长,库的自我保护机制会直接断开连接,表现为连上就断。
解决:回调里只做内存操作。耗时逻辑丢到线程池,处理完把结果写回这个会话的缓冲区,再调 lws_callback_on_writable 触发写事件。如果项目的线程池还没搭起来,至少保证回调里不 sleep、不做同步磁盘 IO。
5.2 编译报OpenSSL头文件找不到:装错依赖包
现象:cmake 配置阶段报 OPENSSL_INCLUDE_DIR 相关错误,或者编译阶段报 openssl/ssl.h: No such file or directory。
原因:系统里装了 openssl 运行库,但没有装开发头文件。libssl-dev 和 openssl 是两个包,前者才有 ssl.h。
解决:开发机装 libssl-dev:
sudo apt install -y libssl-dev然后回到 build 目录重新 cmake 一次,不要直接继续 build,让 CMake 重新探测:
cmake -S . -B build -DLWS_WITH_SSL=ON cmake --build build -j$(nproc)如果是交叉编译,OPENSSL_ROOT_DIR 要指向目标板的 SDK 路径,或者直接 LWS_WITH_SSL=OFF。嵌入式场景如果不需要 wss,关掉 SSL 是最省事的做法。
5.3 内存只涨不降:连接关闭后附加数据没有释放
现象:程序长时间运行,用 top 或 cat /proc/pid/status 看 RSS 持续增长,即使客户端都断开了。
原因:per_session_data 本身由库管理,连接关闭会自动回收。但你在 user 里 malloc 出来的指针,比如一块大缓冲区、一个结构体链表,库管不到。如果没在 CLOSED 回调里 free,每连一次就漏一次。
解决:在 LWS_CALLBACK_CLOSED 里把所有手动分配的资源释放掉,user 指针本身不要 free。还有一个习惯建议:不要把大缓冲区直接嵌在 session_data 里,而是用指针指向 malloc 的内存,这样释放路径更清晰,也避免每个连接都占满 4096 字节。
5.4 高并发下吞吐上不去:日志输出拖垮了事件循环
现象:并发 200 个连接时勉强能跑,500 个连接时延迟翻倍,CPU 先上去了,stderr 疯狂刷新。
原因:库默认日志级别包含 LL_NOTICE,每个连接建立、关闭都会打一行。printf 到终端是带锁的系统调用,高频连接场景下事件循环大部分时间在等终端。
解决:main 里一启动就调 lws_set_log_level 把级别压到错误和警告:
lws_set_log_level(LL_ERR | LL_WARN, NULL);第二个参数可以传自定义日志回调,不传就用默认的 stderr。压测时连 warning 都可以关,线上保留 ERR 和 WARN 就够排障用了。
6. 用Python写个500并发压测脚本:验证连接稳定性与P95延迟
6.1 脚本与判定标准
脚本内容:
import asyncio import statistics import time import websockets URL = "ws://127.0.0.1:8080/myproto" TOTAL = 500 PAYLOAD = "hello" * 100 async def one_request(i, latencies): t0 = time.perf_counter() try: async with websockets.connect(URL, open_timeout=10) as ws: await ws.send(PAYLOAD) await asyncio.wait_for(ws.recv(), timeout=10) latencies.append(time.perf_counter() - t0) except Exception: latencies.append(-1.0) async def main(): latencies = [] t0 = time.perf_counter() tasks = [asyncio.ensure_future(one_request(i, latencies)) for i in range(TOTAL)] await asyncio.gather(*tasks, return_exceptions=True) total_time = time.perf_counter() - t0 ok = [x for x in latencies if x >= 0] fail = TOTAL - len(ok) print(f"total={TOTAL}, ok={len(ok)}, fail={fail}") print(f"elapsed={total_time:.2f}s, rps={TOTAL / total_time:.1f}") if ok: ok.sort() p95_index = int(len(ok) * 0.95) - 1 print(f"p50={statistics.median(ok) * 1000:.1f}ms, " f"p95={ok[p95_index] * 1000:.1f}ms") asyncio.run(main())这段脚本的作用是:开 500 个并发 WebSocket 连接,每个连接完成一次"发送文本帧 + 接收回显"的完整往返,统计成功率、总耗时、RPS、P50 和 P95 延迟。open_timeout 和 wait_for 是为了防止某个连接卡住拖垮整体测试。
判定标准我一般这样看:成功率 100%,P95 在 50ms 以内(本机 loopback 环境通常只有几毫秒到十几毫秒),服务端 RSS 在测试前后基本不变。如果 P95 偏高,先确认服务端的日志级别是否已经调低,这一条踩过的人不少;再确认回调里有没有多余的格式化字符串操作。
注意:压测要和开发环境分开跑,别在调试终端开着日志的情况下做数据,出来的数字会误导你调半天也定位不到问题。
最后说一个习惯:我在接入这个库之后,习惯把 lws_service 的超时从 50 改成 10,让事件循环更敏感一些,代价是主循环的 CPU 占用会高一点,但交互响应提升明显。具体值按产品的实时性要求去调。每次改完事件循环参数,都用这套脚本对比一次 P95,不看感觉只看数字。希望帮到你。
本文还有配套的精品资源,点击获取