简介:一份面向ESP32开发者的WebSocket通信实现源码包,覆盖基于C/C++建立WebSocket客户端与服务器的完整流程,适合物联网项目或需要实时双向通信的开发者参考。资源共8个文件,包含C源码、头文件、Makefile配置、sdkconfig以及Markdown说明,压缩包仅15KB,轻量易用,便于对照代码与配置快速上手。内容以基础应用为核心,从引入WebSocket库开始,到回调函数处理、连接管理与数据收发均有涉及。开发者可直接参考或修改示例代码,快速验证双向通信功能;配置文件能减少环境搭建时间,Markdown说明则梳理了关键步骤与注意事项。目前已有826人学习。无论是刚接触ESP32的初学者,还是希望完善项目实时性的进阶用户,都能从中获得可落地的参考思路。
1. ESP32 WebSocket 开发指南:选 C 还是 C++ 之前,先看链路
设备端凡是做「状态实时上收 + 指令下推」的功能,第一反应往往是 HTTP 轮询。轮询在 ESP32 上很快会碰到两个硬问题:一是 RAM 和 WiFi 功耗撑不住 1 秒一次的 GET;二是服务器侧要等断路器超时、要维护大量半开连接。WebSocket 在 ESP32 上的价值不是省几条消息,而是把连接从「每次重建」变成「一条长连接上的双向帧」,握手只发生在建立时,后续数据走数据帧,这正是嵌入式设备配网页、配 App 时最常用的实时通道。
用 C 还是 C++ 取决于你手里的 SDK。ESP-IDF 的官方组件本来就是 C 接口,纯 C 写服务端和客户端都顺;如果项目里已有 C++ 业务层,把 WebSocket 封装成类也不难。真正决定开发体验的,是库的选型和帧处理的边界条件。这篇文章就沿着「库选择 → 最小服务端 → 客户端直连 → 调试手段」这条路线,把 ESP32 上 WebSocket 的建立过程完整过一遍。
2. C/C++ 场景下 ESP32 的 WebSocket 选型:HTTP Server、官方客户端还是 Mongoose
2.1 ESP-IDF 自带的 WebSocket 能力到底覆盖到什么程度
ESP-IDF 从 4.x 开始就把 WebSocket 拆成两块:服务端挂在esp_http_server组件下,走的是httpd_ws_*接口;客户端在esp_websocket_client组件下,走的是esp_websocket_client_*接口。注意这两个是独立组件,服务端不需要额外引入客户端组件,反过来也一样。
很多刚接触的人会误以为 WebSocket 是一个独立协议栈,其实它在 ESP-IDF 里仍然是 LwIP 之上的一层应用协议。握手阶段用 HTTP Upgrade,之后帧传输直接走 TCP,所以选型时不需要引入额外协议栈,只要把下面这个关系想清楚:
| 角色 | 组件 | 核心 API | 依赖 |
|---|---|---|---|
| 服务端 | esp_http_server | httpd_register_uri_handler+httpd_ws_recv_frame | mbedTLS(wss 时) |
| 客户端 | esp_websocket_client | esp_websocket_client_start+ 事件回调 | mbedTLS(wss 时) |
| 双端通用 | Mongoose | mg_ws_connect/mg_wss_listen | 自备 TCP 栈 |
如果你的需求是「ESP32 自己开一个 /ws 端点,网页或手机 App 来连」,esp_http_server是首选,因为它和 HTTP 文件服务、REST API 共用同一个 server 句柄,不用额外管理端口和 socket。如果需求是「ESP32 作为客户端去连云端 WebSocket 网关」,那就用esp_websocket_client,它内部已经把重连、心跳、TLS 握手封装好了。
2.2 第三方库 Mongoose 什么时候才值得用
Mongoose 在嵌入式 WebSocket 圈子里热度很高,它的优势是跨平台,同一套 C 代码能在 ESP32、Linux、MCU 裸机上跑。如果你项目里有部分模块跑在非 ESP32 平台上,或者你不想依赖 IDF 的组件版本节奏,Mongoose 可以保证协议行为一致。
但用它要付成本。Mongoose 的 socket 事件循环和 IDF 的事件循环是两套机制,接入时你得把mg_mgr_poll放到一个任务里循环调用,这相当于自己维护一个网络线程;而用esp_websocket_client时,事件分发走 IDF 的 event loop,和系统其它部分天然衔接。我的建议是:只有双端代码需要复用、或者现有代码已经是 Mongoose 体系时才引入,否则 IDF 自带方案足够。
2.3 工程配置里必须动的那几个开关
无论哪条路线,menuconfig里有几个配置直接影响 WebSocket 能不能建立:
CONFIG_HTTPD_MAX_OPEN_SOCKETS:esp_http_server 最大并发 socket 数,默认值偏小,开了 WebSocket 后每个连接要占一个 socket,连接数不够会握手失败。CONFIG_HTTPD_WS_SUPPORT:老版本 IDF 需要显式打开 WebSocket 支持,新版本默认开,但升级 SDK 后最好确认一次。CONFIG_MBEDTLS_SSL_IN_CONTENT_LEN:只用 ws:// 不用管;用 wss:// 时这个值影响 TLS 握手阶段可一次性读入的数据长度。
设置完这些之后,编译环境里要注意components路径。用idf.py create-project建立工程时,WebSocket 相关头文件都在默认组件里,不需要额外EXTRA_COMPONENT_DIRS。
3. 用 C 在 ESP32 上实现最小 WebSocket 服务端:帧接收与发送
3.1 注册 WebSocket URI Handler 的完整 C 代码
先写一个能跑起来的服务端骨架。假设你已经通过esp_netif_init和esp_wifi_start拿到了 IP,接下来在httpd_start之后注册一个/ws端点:
#include "esp_http_server.h" #include "esp_log.h" static const char *TAG = "ws_server"; static esp_err_t ws_handler(httpd_req_t *req) { if (req->method == HTTP_GET) { // GET 到达这里时,说明是 WebSocket 握手请求 // esp_http_server 内部会完成 Upgrade 应答,此处只做日志 ESP_LOGI(TAG, "WebSocket handshake from %s", req->host); return ESP_OK; } // 走数据帧处理 httpd_ws_frame_t ws_pkt = {0}; size_t buf_len = 0; // 第一次调用:不传 payload,只拿帧头里的长度 esp_err_t ret = httpd_ws_recv_frame(req, &ws_pkt, &buf_len); if (ret != ESP_OK) { ESP_LOGE(TAG, "recv frame header failed: %s", esp_err_to_name(ret)); return ret; } if (buf_len == 0) { // 空帧可能是 ping,回一个空 pong 保持连接 httpd_ws_frame_t pong = { .fin = true, .type = HTTPD_WS_TYPE_PONG, .payload = NULL, .len = 0 }; return httpd_ws_send_frame(req, &pong); } // 再分配实际 payload 缓冲 char *buf = calloc(1, buf_len + 1); if (buf == NULL) { return ESP_ERR_NO_MEM; } ws_pkt.payload = (uint8_t *)buf; ret = httpd_ws_recv_frame(req, &ws_pkt, &buf_len); if (ret != ESP_OK) { free(buf); ESP_LOGE(TAG, "recv frame payload failed: %s", esp_err_to_name(ret)); return ret; } // 这里 buf 里就是完整帧内容,ws_pkt.type 区分 TEXT / BINARY ESP_LOGI(TAG, "got ws frame type=%d len=%d payload=%.*s", ws_pkt.type, (int)buf_len, (int)buf_len, buf); // 回显一帧 httpd_ws_frame_t resp = { .fin = true, .type = ws_pkt.type, .payload = (uint8_t *)buf, .len = buf_len }; ret = httpd_ws_send_frame(req, &resp); free(buf); return ret; } static void start_ws_server(void) { httpd_handle_t server = NULL; httpd_config_t cfg = HTTPD_DEFAULT_CONFIG(); cfg.server_port = 80; cfg.max_open_sockets = 4; if (httpd_start(&server, &cfg) == ESP_OK) { httpd_uri_t ws_uri = { .uri = "/ws", .method = HTTP_GET, .handler = ws_handler, .user_ctx = NULL, .is_websocket = true }; httpd_register_uri_handler(server, &ws_uri); } }这段代码的核心逻辑在两次httpd_ws_recv_frame调用上。第一次传payload = NULL和len = 0,内核只解析帧头;收到实际长度后再分配内存,第二次调用填充 payload。很多写出stream disconnected报错的人,问题就出在第一次调用时急着给了固定缓冲,导致帧超过缓冲长度时直接断开连接。
is_websocket = true这个字段不能漏。它告诉esp_http_server对GET /ws不要按普通 HTTP 处理,而是执行 WebSocket Upgrade 握手。否则浏览器会收到 400 应答,WebSocketonclose直接报code: 1006。
3.2 关于文本帧和二进制帧的取舍
上例中回显类型用的是ws_pkt.type,也就是客户端发什么就回什么。实际产品里这是有讲究的:文本帧适合 JSON 命令、状态描述;二进制帧适合传感器原始数据、OTA 分包、音频 PCM。
在 WebSocket 协议里,文本帧必须按 UTF-8 解析,客户端 JavaScript 端拿到Blob和ArrayBuffer的处理逻辑完全不同。因此建议服务端在发送时固定好类型:控制命令一律HTTPD_WS_TYPE_TEXT,大块数据一律HTTPD_WS_TYPE_BINARY,不要在两者之间摇摆。
fin位也是容易踩坑的点。上面代码里帧的fin = true表示这是完整帧,没有分片。如果某个协议栈实现里分片了,fin = false的帧需要和后续帧拼起来,而esp_http_server不会自动拼分片。嵌入式设备自己收发时最好所有帧都单帧发送,不要让 payload 超过 MTU 后依赖分片。
4. 让 ESP32 WebSocket 真正跑起来:客户端直连、App 断连与心跳处理
4.1 用 esp_websocket_client 连上自己的服务端
上面服务端建好之后,下一步就是拿客户端连它。最简单的客户端就是浏览器new WebSocket("ws://192.168.1.100/ws")。想用 C 验证就写一个esp_websocket_client例子,在 ESP32 上连另一个 ESP32:
#include "esp_websocket_client.h" static void ws_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data) { esp_websocket_event_data_t *data = (esp_websocket_event_data_t *)event_data; switch (event_id) { case WEBSOCKET_EVENT_CONNECTED: ESP_LOGI(TAG, "connected"); break; case WEBSOCKET_EVENT_DATA: ESP_LOGI(TAG, "recv: %.*s",>wscat -c ws://192.168.1.100/ws连上后输入{"action":"ping"},能看到 ESP32 日志打印出收到 text 帧,随后原样回显,说明握手和帧收发链路是通的。要验证二进制帧,可以配合wscat -b或直接用 Python 的websockets库写个几行脚本:
import asyncio, websockets, json async def test(): async with websockets.connect("ws://192.168.1.100/ws") as ws: await ws.send(json.dumps({"cmd": "status"})) recv = await asyncio.wait_for(ws.recv(), timeout=3) print(recv) asyncio.run(test())asyncio.wait_for加上超时,是为了防止 ESP32 侧不回复时脚本卡死。这个脚本可以稳定复现问题,也能作为 CI 里的冒烟测试。
5.2 排查 1006 和 stream disconnected 的实际顺序
WebSocket onclose code 1006的意思是连接异常关闭,客户端没收到服务端的 Close 帧。排查顺序我一般固定为:先看手机上是不是ws://与wss://混用;再看 ESP32 日志里有没有httpd_ws_recv_frame返回失败;最后抓包确认。
热词里那条stream disconnected before completion: failed to send websocket request: io,常见于把ws://地址传给了esp_http_client或者esp_websocket_client的 URI 里路径写错。esp_websocket_client对 URI 的解析严格区分ws:和http:前缀,前缀错误时 TLS 层或 HTTP 层会在握手阶段直接中断,报 io 错误。处理办法是把.uri改回ws://开头,并确认路径里没有拼写大小写问题。
Internet 上另一个高频报错是websocket onclose code 1006 reason: reconnect: true,这其实不是错误,而是客户端自己发起的重连日志。esp_websocket_client的reconnect_timeout_ms配置决定了断线后多久重连,如果这个值设得太小,比如几百毫秒,会看到不断重连、把日志刷掉,干扰判断。先把它调到 3 秒以上再抓现场。
5.3 帧边界与内存占用:最后要盯的两个指标
WebSocket 调试的最后一道关是看帧格式。抓包时在 Wireshark 里过滤websocket协议,重点看帧头第一个字节的FIN和opcode。如果抓到的帧 opcode 是0x0,说明是分片消息的中间段,ESP32 端没拼直接当业务数据用,会出现乱码;opcode 是0x8是 Close 帧,之后连接就该收尾。
内存方面,esp_http_server模式下每个 WebSocket 连接要额外占约 6-8KB RAM,包含发送缓冲和 TLS 上下文。一个 ESP32 模块上开 4 个连接已经是比较激进的配置。在menuconfig里把CONFIG_HTTPD_SESS_COUNT调小的同时,记得代码里free()每个业务帧的 payload 缓冲,否则连续收发几十帧后堆碎片会直接让calloc失败,表现就是连接越用越卡、最终复位。
本文还有配套的精品资源,点击获取