1. 为什么嵌入式 TCP Client 总在“连上又掉线”之间反复横跳
如果你在做 ESP32、STM32+网口、或者 Linux 网关这类联网设备,大概率遇到过这种场景:设备刚上电时能正常连上服务器,跑几个小时甚至几分钟后,连接就悄悄断了,数据也不再上报。更麻烦的是,程序本身没崩,串口也没打印错误,就是“静默失联”。这类问题在物联网设备里非常典型,因为现场网络环境远比实验室复杂:路由器会回收空闲连接、运营商 NAT 会超时、服务器端也可能主动踢掉长时间无数据的客户端。
要解决它,核心就三件事:连接建立要可靠、数据收发要有节奏、断线之后要能自动重连。用 C 语言手写 socket 当然可以,但你要自己处理非阻塞、事件循环、缓冲区管理、重连状态机,代码量很快就上去了。这时候 Mongoose 就很合适——它就是一个.c加一个.h,把 TCP、UDP、HTTP、MQTT 这些协议的事件都封装成统一的mg_mgr_poll循环,你只需要在事件回调里写业务逻辑。
这篇内容面向的是嵌入式与物联网设备联网场景,目标很明确:给你一份可以直接复用的 Mongoose TCP Client 工程配置,覆盖连接建立、数据收发、心跳保活、断线重连的完整链路,并且给出可复制的初始化参数、事件回调骨架和连通性验证动作。你不需要先成为网络编程专家,只要能把 Mongoose 的两个文件加进工程,跟着下面的步骤改回调函数,就能跑出一个稳定的长连接客户端。
我试过在资源受限的 MCU 上直接用裸 socket 写重连,状态一多就容易乱;换成 Mongoose 的事件模型之后,逻辑清晰很多,因为“连接成功”“收到数据”“连接关闭”这些状态它都帮你区分好了。下面从工程接入开始讲,每一步都给到具体代码和参数。
2. Mongoose TCP Client 工程接入与 mg_mgr 初始化配置
Mongoose 的接入方式非常轻量。你只需要从官方仓库拿到mongoose.c和mongoose.h两个文件,放进你的工程目录,然后在编译时把mongoose.c一起编进去即可。它不依赖复杂的构建系统,CMake、Makefile、Keil、IAR 都能用。对于嵌入式场景,建议在编译选项里定义MG_ENABLE_LOG=0来关掉日志,节省 Flash 和串口带宽;调试阶段再打开。
初始化的核心是struct mg_mgr,你可以把它理解成“事件管理器”。所有连接都挂在它下面,mg_mgr_poll负责驱动整个事件循环。下面是一段可以直接复制的最小初始化骨架:
#include "mongoose.h" static struct mg_mgr mgr; static struct mg_connection *s_conn = NULL; static const char *s_url = "tcp://192.168.1.123:80"; static void ev_handler(struct mg_connection *nc, int ev, void *ev_data); int main(void) { mg_mgr_init(&mgr); // 初始化事件管理器 s_conn = mg_connect(&mgr, s_url, ev_handler); if (s_conn == NULL) { // 连接对象创建失败,通常是 URL 格式问题 return -1; } for (;;) { mg_mgr_poll(&mgr, 1000); // 每 1000ms 轮询一次事件 } mg_mgr_free(&mgr); return 0; }这里有几个参数值得说明。mg_mgr_poll的第二个参数是超时时间,单位毫秒。它决定了事件循环在没有事件时最多阻塞多久。对于需要定时发心跳的场景,这个值不要设得太大,否则心跳间隔会不准;一般设成 100 到 1000 之间比较合适。如果你用的是 RTOS,可以把mg_mgr_poll放在一个独立任务里,超时设小一点,让出 CPU。
mg_connect的 URL 格式是tcp://host:port。注意这里不要写成http://,否则 Mongoose 会按 HTTP 协议处理。如果你的服务器地址是动态的,可以像下面这样用一个函数拼出来:
static void get_tcp_url(char *buf, size_t len) { snprintf(buf, len, "tcp://%s:%d", "192.168.1.123", 80); }另外,Mongoose 默认会为每个连接分配接收缓冲区。在内存紧张的设备上,可以通过mg_mgr_init之后设置mgr.recv_mbuf_limit来限制单连接缓冲区大小,避免一条异常大报文把内存吃光。这个值根据你的协议最大包长来定,比如你的协议包不超过 512 字节,就设成 1024 留点余量。
工程接入阶段还有一个容易忽略的点:mongoose.c里默认可能开启了某些你用不到的功能,比如 HTTP 服务端、WebSocket。如果编译后体积偏大,可以在mongoose.h之前的编译单元里定义MG_ENABLE_HTTP_SERVER=0、MG_ENABLE_WEBSOCKET=0之类的宏来裁剪。具体宏名以你使用的 Mongoose 版本为准,建议先全开跑通,再逐步裁剪。
3. 事件回调骨架:连接、收发、心跳与断线重连的完整配置
Mongoose 的所有网络行为都通过事件回调驱动,核心就是ev_handler里的switch (ev)。下面这份骨架把连接建立、数据接收、心跳发送、断线重连都串起来了,你可以直接拿去改。
static void ev_handler(struct mg_connection *nc, int ev, void *ev_data) { switch (ev) { case MG_EV_CONNECT: { int status = *(int *) ev_data; if (status != 0) { // 连接失败,打印原因,等待 MG_EV_CLOSE 触发重连 fprintf(stderr, "connect failed: %d\n", status); } else { // 连接成功,立即发送一帧上线请求 const unsigned char hello = 0xFF; mg_send(nc, &hello, 1); } break; } case MG_EV_RECV: { // nc->recv_mbuf 里是本次收到的数据 // 处理完记得清空,否则会一直累积 handle_payload(nc->recv_mbuf.buf, nc->recv_mbuf.len); nc->recv_mbuf.len = 0; break; } case MG_EV_CLOSE: { // 连接关闭,标记需要重连 s_conn = NULL; reconnect_later(); break; } default: break; } }这里有几个关键点。第一,MG_EV_CONNECT的ev_data是一个int *,0 表示成功,非 0 是错误码。连接失败时不要在这里直接重连,因为 Mongoose 随后还会触发MG_EV_CLOSE,统一在MG_EV_CLOSE里处理重连更干净。
第二,MG_EV_RECV里处理完数据后必须把nc->recv_mbuf.len置 0。Mongoose 的接收缓冲区是累积的,如果你不清空,下次收到数据时旧数据还在,解析就会错位。这是新手最容易踩的坑之一。
第三,心跳保活。很多现场断线是因为长时间没有数据,中间设备把连接回收了。解决办法是定时发送心跳包。Mongoose 没有内置定时器,但你可以用mg_mgr_poll的超时配合一个时间戳来实现:
static uint64_t s_last_heartbeat = 0; static void heartbeat_tick(struct mg_mgr *m, uint64_t now_ms) { if (s_conn == NULL) return; if (now_ms - s_last_heartbeat >= 30000) { // 30 秒一次心跳 const unsigned char hb = 0xFE; mg_send(s_conn, &hb, 1); s_last_heartbeat = now_ms; } }然后在主循环里调用:
for (;;) { uint64_t now = mg_millis(); mg_mgr_poll(&mgr, 200); heartbeat_tick(&mgr, now); }心跳间隔要根据你的服务器和中间网络设备来定。一般 NAT 超时在 30 秒到 5 分钟之间,所以心跳设成 30 秒以内比较保险。如果你的设备功耗敏感,可以适当放长,但要确保小于链路空闲回收时间。
第四,断线重连。重连不要立即执行,否则服务器没恢复时会疯狂重试,既耗电又可能被服务器拉黑。建议加一个退避策略:
static void reconnect_later(void) { static uint32_t backoff_ms = 1000; mg_millis(); // 占位,实际用系统延时或状态机 // 简化写法:延时后重新 mg_connect // 实际工程建议用非阻塞状态机,避免 sleep 卡住事件循环 s_conn = mg_connect(&mgr, s_url, ev_handler); if (s_conn == NULL) { backoff_ms = backoff_ms * 2; if (backoff_ms > 30000) backoff_ms = 30000; } else { backoff_ms = 1000; } }注意,上面用sleep或阻塞延时在事件循环里是大忌,会卡住其他连接的事件处理。正确做法是用一个“下次重连时间戳”,在主循环里判断时间到了再调mg_connect。这样整个客户端始终是非阻塞的。
如果你用的是 Cline MCP 或 Codex 这类工具辅助生成配置,记得把三件套写全:Base URL、Key、Model ID。比如在settings.json里配置模型端点时,Base URL 填https://taotoken.net/api,Key 填你申请到的密钥,Model ID 按你实际使用的模型名填。这三项缺一不可,否则请求会直接 401。
4. 连通性验证:从 mg_printf 发送到 analyzeRealtime 解析的实测步骤
配置写完之后,必须做连通性验证,否则你无法确认到底是网络不通、服务器没起、还是代码逻辑有问题。下面是一套可跟做的验证步骤。
第一步,确认服务器在监听。在服务器侧用nc -l 80或者你自己的服务程序起一个 TCP 监听,确保端口可达。如果服务器在另一台机器上,先用telnet 192.168.1.123 80确认能连上。
第二步,在客户端MG_EV_CONNECT成功分支里加一条日志,确认连接建立:
case MG_EV_CONNECT: { int status = *(int *) ev_data; if (status == 0) { printf("connected to %s\n", s_url); const unsigned char hello = 0xFF; mg_send(nc, &hello, 1); } break; }第三步,在MG_EV_RECV里把收到的原始字节打印出来,确认数据真的到了:
case MG_EV_RECV: { printf("recv %u bytes: ", (unsigned) nc->recv_mbuf.len); for (size_t i = 0; i < nc->recv_mbuf.len; i++) { printf("%02X ", (unsigned char) nc->recv_mbuf.buf[i]); } printf("\n"); handle_payload(nc->recv_mbuf.buf, nc->recv_mbuf.len); nc->recv_mbuf.len = 0; break; }第四步,验证心跳。让程序跑起来,观察串口或终端是否每 30 秒打印一次发送心跳的日志。同时可以在服务器侧看是否收到0xFE。
第五步,验证断线重连。手动把服务器停掉,观察客户端是否打印MG_EV_CLOSE,然后过一段时间是否重新发起连接。再把服务器起回来,确认客户端能自动恢复。
这里给一个handle_payload的解析骨架,你可以按自己的协议改:
static void handle_payload(const char *buf, size_t len) { if (len < 2) return; unsigned char cmd = (unsigned char) buf[0]; switch (cmd) { case 0x01: // 处理实时数据 analyze_realtime((const unsigned char *) buf + 1, len - 1); break; case 0x02: // 处理配置下发 break; default: break; } }实测下来,这套验证流程能覆盖 90% 以上的“连不上”或“收不到”问题。如果连接成功但收不到数据,优先检查服务器是否真的发了、以及recv_mbuf.len有没有被清空。如果连接反复断开,优先检查心跳间隔是否大于链路空闲回收时间。
5. 常见报错排查:401、local proxy failed、reading choices 与 OAuth 问题
在接入和调试过程中,你会遇到一些典型报错。下面按现象、原因、解决方式逐一说明。
401 Unauthorized:这个通常出现在你调用云端 API 或模型服务时。原因一般是 Key 没填、填错、或者 Base URL 和 Key 不匹配。检查你的配置文件里 Base URL 是否为https://taotoken.net/api,Key 是否完整复制,Model ID 是否拼写正确。三件套任何一项不对都会导致 401。如果你用的是auth.json或settings.json,确认字段名和层级没有写错。
local proxy failed:这个报错一般出现在本地代理或转发配置场景。先确认你的客户端确实在监听本地端口,再确认目标地址可达。如果你在代码里用了mg_connect连本地代理,检查 URL 格式是否为tcp://127.0.0.1:port。另外,某些环境会限制本地回环访问,确认防火墙没有拦截。
reading choices 相关报错:这类报错通常出现在解析响应体时,说明返回的数据格式和预期不符。可能是服务器返回了错误页、或者协议版本不匹配。解决办法是先把原始响应打印出来,确认实际内容,再调整解析逻辑。不要直接假设返回一定是 JSON。
OAuth 相关报错:如果你在接入需要 OAuth 的服务,报错往往是因为 token 过期或 scope 不足。检查 token 有效期,确认申请时勾选了正确的权限范围。如果是设备端,建议把 token 刷新逻辑做成独立的重试流程,不要和 TCP 重连混在一起。
连接建立后立即收到 MG_EV_CLOSE:这通常不是代码问题,而是服务器主动关闭。可能是服务器要求先认证、或者你的首包格式不对。检查你发的第一帧数据是否符合服务器协议。
心跳发了但连接还是断:检查心跳包是否真的被服务器接受。有些服务器要求心跳包必须是特定格式,随便发一个字节可能被忽略甚至被判定为异常流量。确认心跳格式和间隔符合服务器文档。
排查时建议打开 Mongoose 的日志,把MG_ENABLE_LOG设为 1,能看到连接状态变化的详细过程。定位到具体事件之后,再针对性修改。
6. 稳定长连接的工程化建议与后续接入路径
把上面的骨架跑通之后,你已经有了一个可用的 Mongoose TCP Client。但要真正放到现场设备上长期运行,还有几个工程化细节值得注意。
第一,重连状态机要独立。不要把重连逻辑塞在MG_EV_CLOSE里直接调mg_connect,而是用一个状态变量记录“当前是否已连接”“下次重连时间”,在主循环里统一调度。这样即使mg_connect失败,也不会阻塞事件循环。
第二,接收缓冲区要设上限。前面提到recv_mbuf_limit,现场如果遇到异常流量,没有上限的缓冲区会直接把内存吃光。设一个合理上限,超限时主动断开重连。
第三,心跳和业务数据要区分。心跳包尽量短,业务包按协议走。如果服务器支持,可以用 TCP Keepalive 作为补充,但不要完全依赖它,因为 Keepalive 的默认间隔通常太长。
第四,日志要可开关。现场设备不可能一直开详细日志,但出问题时需要能快速打开。建议用编译宏或运行时变量控制日志级别。
如果你后续要接入云端模型服务做设备侧智能处理,可以把 TCP 通道收到的数据转发到 API。接入时用https://taotoken.net/api作为 Base URL,配合你的 Key 和 Model ID。需要调试模型返回时,可以用模型对话页面直接验证请求格式。如果是长期运行的编码或 Agent 类任务,可以考虑 Coding Plan 来管理调用额度。接入文档里有完整的请求示例和字段说明,照着改就能把设备数据和模型能力串起来。
最后提醒一句:TCP 长连接的稳定性,一半靠代码,一半靠对现场网络的理解。心跳间隔、重连退避、缓冲区上限这三个参数,一定要根据你的实际部署环境调,不要照搬默认值。