1. Mongoose TCP 客户端与服务器最小可跑通示例踩坑记录
Mongoose 是一个用 C 写的网络库,单个mongoose.c+mongoose.h就能把 TCP、UDP、HTTP、WebSocket、MQTT 的事件驱动 API 全带上,特别适合在树莓派、嵌入式 Linux、RTOS 这类资源不宽裕的环境里做网络通信。它把「连接」抽象成struct mg_connection,把「事件循环」抽象成struct mg_mgr,你只需要写回调,剩下的非阻塞收发、定时器、断线重连都交给mg_mgr_poll去驱动。这篇笔记聚焦 Mongoose TCP 客户端与服务器从连接建立到数据收发的完整流程,给出可直接复制的mg_config、事件绑定与回调骨架代码,并附本地回环验证步骤,帮你在十分钟内跑通一个「客户端连服务器、发消息、服务器回显、断开后自动重连」的最小可用示例。适合已经会写 C、但第一次接触事件驱动网络库的同学,也适合想把裸 socket 换成更省心方案的嵌入式开发者。
我试过在树莓派上直接make官方examples/tcp,第一次跑就遇到「客户端连上了但服务器没打印」的情况,后来发现是mg_mgr_poll的超时参数和定时器周期没对齐。下面把整个流程拆成可跟做的步骤,每一步都给出完整代码和验证方法。
2. 环境准备与 mg_config 编译配置:Mongoose TCP 示例编译报错怎么解决
Mongoose 的获取方式很简单,直接 clone 官方仓库即可,不需要额外依赖。它的设计哲学是「一个 .c 文件就是全部」,所以你甚至可以把mongoose.c和mongoose.h拷到自己的工程里,用任意构建系统编译。
git clone https://github.com/cesanta/mongoose.git cd mongoose/examples/tcp ls # main.c Makefile mongoose.c mongoose.h官方示例目录里已经带了mongoose.c和mongoose.h的软链接或副本,Makefile内容大致如下,核心就是编译main.c并链接mongoose.c:
PROG = example SOURCES = main.c ../../mongoose.c CFLAGS = -I../.. -W -Wall -Werror all: $(PROG) $(PROG): $(SOURCES) $(CC) $(SOURCES) $(CFLAGS) -o $(PROG) clean: rm -f $(PROG)如果你要把它集成进自己的工程,推荐用 CMake,把 Mongoose 的编译选项通过mg_config宏控制。Mongoose 支持在编译时用-DMG_ENABLE_XXX=0/1裁剪功能,比如你只做纯 TCP、不需要 TLS 和 HTTP,就可以关掉它们减小体积:
cc main.c mongoose.c -I. -o example \ -DMG_ENABLE_OPENSSL=0 \ -DMG_ENABLE_MBEDTLS=0 \ -DMG_ENABLE_HTTP=0 \ -DMG_ENABLE_HTTP_CLIENT=0 \ -DMG_ENABLE_HTTP_SERVER=0 \ -DMG_ENABLE_WEBSOCKET=0 \ -DMG_ENABLE_MQTT=0 \ -DMG_ENABLE_LOG=1这里有个容易踩的坑:MG_ENABLE_LOG=1必须打开,否则MG_INFO这类日志宏会被编译成空操作,你运行程序时什么都看不到,会误以为「连接没建立」。另外-W -Wall -Werror在部分 GCC 版本上会因为mongoose.c里的类型转换告警而编译失败,遇到error: unused parameter时,把-Werror去掉即可,或者加-Wno-unused-parameter。
如果你用的是 Windows + MSVC,编译命令换成cl main.c mongoose.c /I. /Fe:example.exe,注意 MSVC 对 C99 的支持需要/std:c11。树莓派上直接用系统自带的cc就行,我实测下来 GCC 10 和 GCC 12 都能正常编译。
编译成功后你会得到一个example可执行文件。在运行之前,先确认8765端口没有被占用,用ss -tlnp | grep 8765检查一下。如果被占用,改main.c里的s_lsn和s_conn两个字符串即可,它们必须指向同一个地址,否则客户端连不上服务器。
3. 可复制的 TCP 客户端与服务器骨架代码:mg_connect、mg_listen 与事件回调绑定
这一节给出完整的main.c,你可以直接复制保存。代码结构分四块:全局资源、客户端回调cfn、服务器回调sfn、定时器timer_fn和main。核心思路是「一个事件管理器 + 一个监听连接 + 一个定时器驱动的客户端连接」。
#include "mongoose.h" static const char *s_lsn = "tcp://localhost:8765"; // 服务器监听地址 static const char *s_conn = "tcp://localhost:8765"; // 客户端连接地址 // 客户端资源:i 用于轮询计数,c 保存当前连接 static struct c_res_s { int i; struct mg_connection *c; } c_res; // ---------- 客户端事件回调 ---------- static void cfn(struct mg_connection *c, int ev, void *ev_data, void *fn_data) { int *i = &((struct c_res_s *) fn_data)->i; if (ev == MG_EV_OPEN) { MG_INFO(("CLIENT has been initialized")); } else if (ev == MG_EV_CONNECT) { MG_INFO(("CLIENT connected")); *i = 1; // 标记连接已建立,开始轮询计数 } else if (ev == MG_EV_READ) { struct mg_iobuf *r = &c->recv; MG_INFO(("CLIENT got data: %.*s", r->len, r->buf)); } else if (ev == MG_EV_CLOSE) { MG_INFO(("CLIENT disconnected")); ((struct c_res_s *) fn_data)->c = NULL; // 置空,供定时器重连 } else if (ev == MG_EV_ERROR) { MG_INFO(("CLIENT error: %s", (char *) ev_data)); } else if (ev == MG_EV_POLL && *i != 0) { switch ((*i)++) { case 50: // 50 x 100ms = 5s 后发送数据 mg_send(c, "Hi, there", 9); MG_INFO(("CLIENT sent data")); break; case 100: // 再 5s 后关闭连接 c->is_draining = 1; break; } } } // ---------- 服务器事件回调 ---------- static void sfn(struct mg_connection *c, int ev, void *ev_data, void *fn_data) { if (ev == MG_EV_OPEN && c->is_listening == 1) { MG_INFO(("SERVER is listening")); } else if (ev == MG_EV_ACCEPT) { MG_INFO(("SERVER accepted a connection")); } else if (ev == MG_EV_READ) { struct mg_iobuf *r = &c->recv; MG_INFO(("SERVER got data: %.*s", r->len, r->buf)); mg_send(c, r->buf, r->len); // 原样回显 } else if (ev == MG_EV_CLOSE) { MG_INFO(("SERVER disconnected")); } else if (ev == MG_EV_ERROR) { MG_INFO(("SERVER error: %s", (char *) ev_data)); } (void) fn_data; } // ---------- 定时器:断线重连 ---------- static void timer_fn(void *arg) { struct mg_mgr *mgr = (struct mg_mgr *) arg; if (c_res.c == NULL) { c_res.i = 0; c_res.c = mg_connect(mgr, s_conn, cfn, &c_res); if (c_res.c == NULL) MG_INFO(("CLIENT cant' open a connection")); else MG_INFO(("CLIENT is connecting")); } } int main(void) { struct mg_mgr mgr; struct mg_connection *c; mg_log_set(MG_LL_INFO); mg_mgr_init(&mgr); // 15 秒周期定时器,立即执行一次 mg_timer_add(&mgr, 15000, MG_TIMER_REPEAT | MG_TIMER_RUN_NOW, timer_fn, &mgr); c = mg_listen(&mgr, s_lsn, sfn, NULL); if (c == NULL) { MG_INFO(("SERVER cant' open a connection")); return 0; } while (true) mg_mgr_poll(&mgr, 100); mg_mgr_free(&mgr); return 0; }几个关键点解释一下。mg_mgr_init初始化事件管理器,所有连接都挂在它下面。mg_timer_add注册一个 15 秒重复定时器,MG_TIMER_RUN_NOW让它注册后立刻跑一次,这样客户端不用等 15 秒就能发起连接。mg_listen创建监听连接,返回的c如果为 NULL 说明端口被占用或地址非法。mg_mgr_poll(&mgr, 100)是事件循环的心脏,它每次最多阻塞 100 毫秒,有网络活动时立即返回并分发事件。
客户端的MG_EV_POLL计数逻辑依赖mg_mgr_poll的 100 毫秒超时,所以case 50大约对应 5 秒。如果你把 poll 超时改成 50,那case 50就变成 2.5 秒,这个对应关系要记牢。c->is_draining = 1表示「发完剩余数据就关闭」,比直接c->is_closing = 1更优雅,不会丢数据。
4. 本地回环验证与成功结果:观察 MG_EV_CONNECT、MG_EV_READ 与断线重连日志
编译运行后,你应该看到类似下面的日志。这是判断「跑通了」的直接依据:
$ ./example 2fa28 2 main.c:53:sfn SERVER is listening 2fa8c 2 main.c:19:cfn CLIENT has been initialized 2fa8c 2 main.c:86:timer_fn CLIENT is connecting 2fa8c 2 main.c:21:cfn CLIENT connected 2fa8c 2 main.c:55:sfn SERVER accepted a connection 30e1b 2 main.c:40:cfn CLIENT sent data 30e1c 2 main.c:66:sfn SERVER got data: Hi, there 30e1c 2 main.c:29:cfn CLIENT got data: Hi, there 3201a 2 main.c:31:cfn CLIENT disconnected 3201a 2 main.c:69:sfn SERVER disconnected 33537 2 main.c:19:cfn CLIENT has been initialized 33538 2 main.c:86:timer_fn CLIENT is connecting 33538 2 main.c:21:cfn CLIENT connected 33538 2 main.c:55:sfn SERVER accepted a connection日志顺序对应的事件流是:服务器先监听,客户端初始化并连接,服务器接受连接;5 秒后客户端发送Hi, there,服务器收到并回显,客户端收到回显;10 秒后客户端主动断开,服务器感知断开;15 秒定时器触发,客户端重新连接,循环往复。
如果你想用外部工具验证,可以开两个终端。一个跑./example,另一个用nc手动连上去发消息:
# 终端 A ./example # 终端 B nc localhost 8765 hello mongoose # 你会看到回显 hello mongoose注意,用nc连上去时,服务器回调sfn的MG_EV_ACCEPT和MG_EV_READ都会触发,日志里会多出对应的行。这能帮你确认服务器端逻辑是独立于内置客户端的,不是「自己跟自己玩」。
还有一个验证断线重连的技巧:在程序运行时,用kill把服务器进程杀掉再重启,观察客户端是否在 15 秒内重新连上。不过因为示例里客户端和服务器在同一个进程,这个测试更适合把客户端和服务器拆成两个进程来做。拆分方法很简单,把main里的mg_listen那行注释掉,只保留客户端逻辑,编译成client;再写一个只保留mg_listen的server,两个进程分别跑,就能真实模拟网络断开。
5. 常见报错排查:local proxy failed、reading choices、OAuth 与 401 对照
虽然这个示例是纯本地 TCP,不涉及鉴权,但很多同学在把它改造成「连远程服务」时会遇到几类典型报错,这里一并对照排查。
第一类是local proxy failed或connect error: Connection refused。这通常是因为s_conn指向的地址没有服务在监听,或者端口写错。排查顺序:先用ss -tlnp | grep 8765确认服务器在听;再确认s_conn和s_lsn的 host、port 完全一致;如果跨机器,把localhost换成实际 IP,并检查防火墙。
第二类是reading choices或unexpected end of JSON input。这类报错一般出现在你把 Mongoose 当 HTTP 客户端去请求大模型 API 时,响应体不是合法 JSON。常见原因是请求头没带Content-Type: application/json,或者 body 长度和Content-Length不一致。用 Mongoose 发 HTTP 请求时,mg_http_reply和mg_printf的格式串要仔细核对。
第三类是401 Unauthorized。如果你把示例改成连远程 API,需要在请求头里带Authorization: Bearer <你的Key>。这时候推荐用 TaoToken 这类兼容 OpenAI 协议的中转服务来统一管理 Key 和模型路由,Base URL 填https://taotoken.net/api,Key 在控制台生成,Model ID 按文档填。三件套缺一不可:Base URL、API Key、Model ID。少任何一个都会 401 或 404。
第四类是OAuth相关报错,比如invalid_grant或token expired。这通常出现在用 OAuth 方式接入某些云服务的场景,Mongoose 本身不处理 OAuth,需要你自己在MG_EV_CONNECT之后发认证请求,拿到 token 再发业务数据。token 过期时间要自己维护,建议在MG_EV_POLL里做定时刷新。
第五类是编译期报错undefined reference to mg_tls_init。这是因为你在代码里写了 TLS 初始化,但编译时没开MG_ENABLE_OPENSSL或MG_ENABLE_MBEDTLS。要么打开对应宏并链接 OpenSSL,要么把 TLS 相关代码用#if包起来。
6. 从最小示例到可用服务:TaoToken 接入与 Coding Plan 长期编码实践
把上面的最小示例跑通之后,下一步通常是把它改造成真正能用的服务。比如你想让 Mongoose 客户端去调用大模型 API,就可以把cfn里的mg_send换成发 HTTP 请求,把MG_EV_READ里的回显换成解析 JSON 响应。这时候 Key 管理和模型路由就成了新问题。
我的做法是用 TaoToken 统一管理。模型对话入口在https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite,可以在里面先验证模型是否可用;接入文档在https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite,里面有完整的 Base URL 和请求示例;API Key 在https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite生成。如果你要长期做编码类 Agent,Coding Plan 在https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite,控制台在https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite。
具体到 Mongoose 代码里,你只需要在MG_EV_CONNECT之后构造一个 HTTP POST 请求,把Authorization和Content-Type头带上,body 用 JSON 格式。响应回来后在MG_EV_READ里用mg_json_get_str解析。这样一套事件驱动的网络层就复用了,不用为每个 API 重写 socket 逻辑。
最后留一个实用技巧:Mongoose 的mg_mgr_poll超时参数决定了你的程序响应速度和 CPU 占用。超时设 100 毫秒适合大多数场景;如果你要低延迟,设 10 毫秒,但 CPU 会上去;如果是电池供电设备,设 1000 毫秒,省电但延迟高。这个参数没有标准答案,按你的场景调。