简介:一份基于uIP协议栈的TCP客户端与服务器端程序,源自ALIENTEK战舰STM32开发板实验52,面向STM32嵌入式开发者与物联网学习者。资源配套ENC28J60以太网模块,演示如何在资源受限的MCU上实现TCP/IP通信,涵盖客户端主动连接与服务器监听响应的完整流程。包内共223个文件,以C/H源码为主(含uIP协议栈、STM32标准库驱动),另附PDF文档、网页说明、PNG/GIF示意图及可直接烧录的Hex固件,整体压缩包2.59MB,目录结构清晰便于按模块查阅。已有739人学习下载,属于实用性较强的入门实战例程。通过该实验可掌握uIP内存管理、ENC28J60的SPI接口配置、STM32中断与定时器设置,以及TCP三次握手、四次挥手等核心机制;参考源码与文档可快速搭建网络通信实验环境,为后续物联网应用开发打下基础。
1. uIP+tcp_client+tcp_server:从一个设备上报与一个远程控制场景说起
一个 8 位或者低端 32 位 MCU,Flash 剩 20KB,RAM 剩下不到 4KB,却要同时干两件事:主动连上外网服务器上报数据,还要在本地开一个端口,让上位机随时连进来做参数配置。Linux 那套 socket 接口在这个资源量级根本不可能装下,而 uIP 就是为这种场景准备的开源嵌入式 TCP/IP 协议栈:整个协议栈加在一起只占几十 KB ROM 和几 KB RAM,裸机就能跑。uip+tcp_client+tcp_server 这个组合,说直白一点,就是让同一个 MCU 既当一个主动找人说话的客户端,又当一个等着别人来找它的服务器,两个角色共用同一个事件回调入口。这个方案适合谁?适合正在做远程数据采集、设备远程配置、调试口透传,或者单纯想把一个没有网络协议栈的单片机低成本并入局域网的开发者。但下手之前得先认清楚一件事:uIP 不是把 socket 接口搬进嵌入式,它是一套事件驱动的状态机集合,用顺了很顺手,用不顺就是在各种回调标志里碰运气。
2. 事件驱动回调模型:为什么客户端和服务器都挤在一个回调里
2.1 一个回调函数,两份连接状态
uIP 在资源受限环境里能立足,核心是它不模仿操作系统的 socket 模型。它维护一张 TCP 连接表,表里每个元素是一个struct uip_conn,保存这个连接的远端 IP、端口、序列号、定时器和应用状态。关键点在于:无论这个连接扮演的是客户端还是服务器,无论它正处于握手、传数据还是关闭阶段,协议栈只调用一个应用入口函数,就是通过UIP_APPCALL宏映射出去的那个函数。你在这个函数里通过判断uip_connected、uip_newdata、uip_acked、uip_poll、uip_rexmit、uip_closed这些标志位,来区分当前到底发生了什么事。
很多人第一次接触 uIP 都会有一种“玄学感”,就是因为 uIP 是反着来的:不是你的主程序去调用协议栈接口拿数据,而是协议栈在合适的时机来调用你的函数,让你去读uip_appdata指向的缓冲区和uip_datalen()的字节数,然后再用uip_send()把回应写回去。整个交互模型是“你永远在被调用,而不是去调用别人”。这个认知翻转是第一道坎,也是后面所有代码设计的基础。如果你还带着“先 connect,再在一个循环里 recv”的思维去写 uIP,后面每一步都会觉得别扭。
那为什么客户端和服务器两个功能可以塞在同一个回调里?因为 uIP 的应用回调是每个连接都共享的一个入口,它并不区分“这是客户端连接”还是“这是服务器连接”。真正区分角色的是连接本身的状态——是主动 connect 成功的,还是被动 accept 进来的。你只需要在回调里根据标志位和连接表里的应用状态去分流。也就是说,两个角色需要的不是两套代码,而是一个分派逻辑。
2.2 主循环、周期定时器与 UIP_APPCALL 的三方协作
裸机上跑 uIP,主循环通常只有两个触发源:一个是有网络包到达,另一个是周期定时器到期。网络包到达时,底层网卡驱动把数据读进全局缓冲区uip_buf,然后调用uip_eth_input()做 ARP 和 IP 层处理。如果处理完发现这个包属于某个已建立的 TCP 连接,或者是一个新建连接请求,协议栈就会在合适时机调用 UIP_APPCALL 对应的那个应用函数。这里有个典型姿势,也是所有基于 uIP 做客户端和服务器共存的工程都绕不开的主循环骨架:
#include "uip.h" #include "tapdev.h" #include "timer.h" static struct timer periodic_timer; int main(void) { uip_init(); tapdev_init(); /* 周期定时器设为 250ms 一次 */ timer_set(&periodic_timer, CLOCK_SECOND / 4); while (1) { /* 从网卡读一包,返回 0 表示没有新包 */ uip_len = tapdev_read(); if (uip_len > 0) { /* 带 ARP 解析的 IP 输入,内部会按情况触发 UIP_APPCALL */ uip_eth_input(); if (uip_len > 0) { /* 协议栈把回应写到 uip_buf,网卡发出 */ tapdev_send(); } } else if (timer_expired(&periodic_timer)) { timer_reset(&periodic_timer); /* 每个 TCP 连接轮流被唤醒,处理超时并回调应用层 */ for (int i = 0; i < UIP_CONNS; i++) { uip_periodic(&uip_conns[i]); if (uip_len > 0) { tapdev_send(); } } /* UDP 连接同样需要周期驱动 */ for (int i = 0; i < UIP_UDP_CONNS; i++) { uip_udp_periodic(&uip_udp_conns[i]); if (uip_len > 0) { tapdev_send(); } } } } }这个循环里最容易被新手忽视的是uip_len > 0这个判断:tapdev_read()返回的是接收包长度,而uip_eth_input()处理完之后,uip_buf里可能被协议栈改写成需要发出的回应包。所以同一个uip_len变量,前面是接收长度,后面是发送长度。主循环发完回应包之后,记得把uip_len清 0,否则下一次uip_periodic()会误以为缓冲区里还有待发送的数据。
周期定时器的间隔一般取CLOCK_SECOND / 4,也就是 250ms。这个值不是随手定的:uIP 的 TCP 重传定时器是建立在周期事件之上的,如果你把周期调成 50ms,网络响应会变快,但同一个连接在单位时间内会被唤醒很多次,应用回调里的负载会被放大;反过来,如果周期调到 1 秒,断线检测和重传反应都会慢得让人抓狂。工程上常见做法是 200ms 到 500ms 之间选一个,除非你改了协议栈内部的重传计时常数。
2.3 uIP 连接 API 的能力边界
uIP 的 API 量很少,但每个 API 都有明确的使用约束,用错地方事故率极高。下面这张表是客户端和服务器场景里最常碰到的几个接口,以及它们的边界。
| API 或标志 | 对应场景 | 边界与注意事项 |
|---|---|---|
uip_connect(ipaddr, port) | 客户端发起主动连接 | 返回struct uip_conn *,返回 NULL 说明连接表已满 |
uip_listen(port) | 服务器注册监听端口 | 部分移植版本要求端口做字节序转换,按代码里的风格保持一致 |
uip_send(data, len) | 在回调内向对端发数据 | 只能在 UIP_APPCALL 回调内调用,一次事件只能调一次 |
uip_close() | 主动关闭当前连接 | 只作用于当前uip_conn,如果要关别的连接要先uip_set_conn |
uip_abort() | 强制重置连接 | 不等待 FIN 握手,直接发 RST,断线场景常用 |
uip_stop()/uip_restart() | 暂停或恢复接收 | 用于背压控制,暂停后对端发了也不收 |
uip_send()的“一次事件只能调一次”是很多人踩坑的地方。uIP 的收发共用一块全局缓冲区,你在一个回调里边发完第一包再立刻发第二包,第二包会把第一包覆盖掉,最终线上只看到第二包。稍后我会在第五章用一条避坑详讲这个问题。先把这些边界刻在脑子里,再往下看客户端代码就不会觉得我写的分支多余。
3. 用 uIP 写 TCP 客户端:主动连接、上报与断线重连的最小框架
3.1 客户端状态机:把多次回调变成一条业务线
uIP 的回调是“散装”的,每次进来只告诉你一个碎片事件:连接建立了、收到数据了、上次发送被确认了、周期轮询到了。如果你不把这些事件串成一个状态机,代码就会写成一堆 if 堆叠,最后自己也分不清当前到底处于什么阶段。我的习惯是给客户端定义几个明确的业务状态,把这些分散的回调按状态归拢。
状态定义的顺序基本是:初始空闲态,发起连接后的等待态,连接成功后的可发送态,以及断线后的重连等待态。uIP 回调里遇到uip_connected()表示三次握手完成,遇到uip_acked()表示对端确认了上一次发送的数据,遇到uip_closed()或者uip_timedout()表示连接已经没了。把这些事件映射到状态迁移上,客户端逻辑就从一团乱麻变成一条清晰的主线:空闲时定时尝试连接,连接建立后立刻发首包,收到 ACK 再发下一包,断开后等一段时间再重试。
这里要提醒一点:uIP 回调内部不是重入安全的,状态机上任何迁移都不能在里面阻塞等待。比如发起连接之后,你等着uip_connected()回来,这中间主循环可能已经跑了十几圈,你不能靠一个 while 死等,必须用一个状态变量记下来,等下一次回调自己送上门。这就是裸机事件驱动和 RTOS 下线程阻塞模型最本质的区别。
3.2 最小可跑的 TCP 客户端回调代码
下面这段代码是我在类似项目里常用的一个最小骨架,改一改填充业务就能用。它完成的工作是:设备上电后定时尝试连接服务器,连接成功后立即上发一帧数据,收到 ACK 再继续发下一帧,连接异常断开后自动等待 5 秒重连。
#include <string.h> #include "uip.h" #include "timer.h" #define UIP_APPCALL app_client_call #include "uipopt.h" #define SERVER_ADDR1 192 #define SERVER_ADDR2 168 #define SERVER_ADDR3 1 #define SERVER_ADDR4 100 #define SERVER_PORT 8080 #define RECONNECT_MS 5000 enum { CLIENT_IDLE = 0, /* 空闲,等待重连定时器 */ CLIENT_CONNECTING, /* 已调用 connect,等握手完成 */ CLIENT_WAIT_ACK, /* 无待发数据,等服务器指令 */ CLIENT_SENDING, /* 已发数据,等 ACK */ CLIENT_OFFLINE /* 连接失败,进入退避 */ }; static uint8_t client_state = CLIENT_IDLE; static uint8_t send_buf[96]; static uint16_t send_len; static uint8_t need_reconnect; static struct timer reconnect_timer; /* 填充下一帧内容,返回长度;返回 0 表示暂时没数据可发 */ static uint16_t fill_next_packet(uint8_t *buf) { uint16_t n; buf[0] = 0xAA; /* 固定帧头 */ buf[1] = device_id; /* 设备 ID */ n = build_sensor_frame(buf + 2); /* 拼接传感器数据 */ return n + 2; } static void start_reconnect_timer(void) { timer_set(&reconnect_timer, RECONNECT_MS); need_reconnect = 1; } static void try_connect(void) { uip_ipaddr_t server_addr; uip_ipaddr(&server_addr, SERVER_ADDR1, SERVER_ADDR2, SERVER_ADDR3, SERVER_ADDR4); struct uip_conn *conn = uip_connect(&server_addr, SERVER_PORT); if (conn == NULL) { /* 连接表被服务器连接占满,稍后重试 */ start_reconnect_timer(); client_state = CLIENT_OFFLINE; return; } client_state = CLIENT_CONNECTING; need_reconnect = 0; } void app_client_call(void) { if (uip_connected()) { /* 三次握手完成,立刻发第一帧 */ send_len = fill_next_packet(send_buf); uip_send(send_buf, send_len); client_state = CLIENT_SENDING; return; } if (uip_newdata()) { /* 服务器主动下发的指令 */ server_recv_len = uip_datalen(); memcpy(server_recv, uip_appdata, uip_datalen()); parse_server_cmd(server_recv, server_recv_len); return; } if (uip_acked()) { /* 上一帧已被确认,缓冲空闲,尝试发下一帧 */ send_len = fill_next_packet(send_buf); if (send_len > 0) { uip_send(send_buf, send_len); client_state = CLIENT_SENDING; } else { client_state = CLIENT_WAIT_ACK; } return; } if (uip_poll()) { /* 250ms 进来一次,用来驱动重连试探 */ if (need_reconnect && timer_expired(&reconnect_timer)) { timer_reset(&reconnect_timer); try_connect(); } return; } if (uip_closed() || uip_aborted() || uip_timedout()) { /* 无论对端主动关还是超时断,统一回空闲并启动重连 */ uip_close(); client_state = CLIENT_IDLE; start_reconnect_timer(); return; } }这段代码要解释几个关键分支。uip_connected()分支里直接调用了uip_send(),这是合法的,因为uip_connected()本身就是一次回调事件,uip_send()只能在回调事件里调用。连接建立后立即发首包是很多应用上报即时数据的好姿势,不用等下一个轮询周期。uip_acked()分支是客户端发送节奏的核心:只有当对端 ACK 了上一次数据,send_buf这个应用缓冲才算空闲,才能填充下一帧再发。uip_poll()分支则负责在连接还没建立时周期性地驱动重连尝试,注意重连计数器不是每个 poll 都重置,而是靠timer_expired()判断,这样重连间隔才是稳定的 5 秒。
有几个细节需要单独点出来。第一,fill_next_packet()里的build_sensor_frame()是业务层函数,你需要按自己上报协议去实现,返回的字节数要包含帧头和整个协议体。第二,try_connect()里如果uip_connect()返回 NULL,说明UIP_CONNS配的连接槽已经满了,最常见的原因就是服务器角色占用了太多连接,后面避坑章我会专门讲这个。第三,如果uip_connected()一直不来,说明 SYN 包丢了或者服务器不可达,uIP 内部有连接超时机制,最终会通过uip_timedout()走关闭分支,你不需要额外处理这个超时。
3.3 发送节奏与 ACK 联动:没确认就不要发下一包
uIP 不像 lwIP 或 Linux 内核那样自带发送缓冲区。uip_send()做的事情本质上是把数据段拷进uip_buf,告诉协议栈“这次事件的回应内容是这个”。一旦这个事件结束,下一包到达或者周期定时器触发,这段数据的内容就不存在了。换句话说,uIP 没有帮你暂存待发数据的能力,必须由应用层自己保存。
所以客户端上报多帧数据时,最怕的就是写成了这样:服务器连接一建立,代码把所有数据一股脑连续调用十几次uip_send()。结果线上实际收到的只有最后一次uip_send()的内容,因为每一次调用都在同一个缓冲区上覆盖前一次的内容,协议栈拿uip_buf发出去的永远是最后一手数据。正确做法就是代码里展示的那样:一次回调只发一帧,发完立刻切换到等待 ACK 的状态,收到uip_acked()才允许填充下一帧。这是 uIP 上跑任何可靠上传场景的底线——把发送节奏和 ACK 绑定,宁可慢也不能丢。
还有个容易忽略的点:TCP 的 ACK 可能是捎带在服务器回传数据里的,也可能是单独一个纯 ACK 包。uIP 对这两种情况都能触发uip_acked(),所以你不必关心 ACK 到底以什么形式回来,只要看到标志位就去发下一帧就行。但如果你的上报频率很高,一秒钟要发几十上百帧,uIP 的往返确认模型会成瓶颈,这种场景就该考虑把数据在应用层攒成一个较大的协议帧,用低频大包代替高频小包。
4. 用 uIP 写 TCP 服务器:监听端口、多连接识别与主动下发
4.1 监听端口与应用回调的触发时机
服务器角色的起步动作就一行:在初始化时调用uip_listen(port)。这一行注册完之后,来自该端口的 TCP 连接请求就不再占用客户端代码的精力,uIP 会主动完成三次握手,然后通过同一个 UIP_APPCALL 入口把“新连接已建立”事件通知你的应用。这就是它和 Linux socket 的一个明显差异——uIP 没有 accept() 这一步,你不需要在回调里主动“接住”连接,协议栈已经把连接接好了,只是通知你来初始化这个连接的业务状态。
#define LISTEN_PORT 1024 void server_init(void) { /* 注册监听端口,新连接自动进入协议栈连接表 */ uip_listen(LISTEN_PORT); }在前面客户端代码里,我通过uip_connected()识别握手完成;在服务器场景里同样靠这个标志识别新连接进入。区别只在连接表里的连接方向不同:客户端连接是主动向外连,服务器连接是被动等进来的。实战里最直观的判断方法是看本地端口号——如果当前连接的本地端口等于LISTEN_PORT,它就是一个服务器角色连接;如果是临时分配的高位端口,那就是客户端主动连接的产物。
很多人在这个阶段会困惑:为什么我的服务器端应用函数和客户端应用函数是同一个?因为 UIP_APPCALL 在整份工程里只能有一个映射,你不能给客户端写一个入口、给服务器写另一个入口。想要逻辑清晰,就得在回调函数入口做一次分流,然后各自跳转到对应处理函数。我自己的代码会让服务器相关逻辑多一个前缀标志,比如srv_,这样同屏写两个角色时不容易看混。
4.2 多连接状态用连接私有区隔离
服务器与客户端最大的区别是多连接。客户端同一时刻通常只有一个主动连接,而服务器可能要同时面对好几个调试工具、上位机或者网关卡。uIP 的每个连接槽都预留了一块“应用状态区”,就是uip_conn里的appstate字段,其占用大小由UIP_APPSTATE_SIZE配置。服务器处理多连接时,必须把每个连接各自的业务状态存放在这个区域里,否则连接 A 发来的数据就会被连接 B 的状态覆盖。这也是很多服务器程序“跑着跑着就串线”的根本原因。
我的常见做法是定义自己的连接状态结构体,然后用UIP_APPSTATE_SIZE扩成这个结构体的大小,让每个连接自带一个状态副本:
#define UIP_APPSTATE_SIZE sizeof(struct server_app_state) struct server_app_state { uint8_t used; /* 连接是否已初始化 */ uint8_t cmd_seq; /* 业务命令序号 */ uint8_t pending_len; /* 待下发数据长度 */ uint8_t pending[16]; /* 待下发数据缓冲 */ };在uip_connected()分支里初始化这个状态区:
if (uip_connected()) { struct server_app_state *state = (struct server_app_state *)uip_conn->appstate; memset(state, 0, sizeof(struct server_app_state)); state->used = 1; }之后在任何回调分支里,先取出当前连接的appstate指针,再基于它做业务处理:
if (uip_newdata()) { struct server_app_state *state = (struct server_app_state *)uip_conn->appstate; memcpy(state->pending, uip_appdata, uip_datalen()); state->pending_len = uip_datalen(); }这里有个容易忽略的点:appstate这块空间是连接槽的一部分,连接关闭后不会被清空。所以每次uip_connected()进来时都先memset一把,否则新建连接会带着上一个连接遗留的脏数据,轻则业务状态判断错乱,重则把上一轮的待发数据误发给新连接。
4.3 服务器主动下发:遍历连接表逐一分发
服务器除了被动应答客户端请求之外,还有一个常见需求是主动向所有在线终端下发配置或者控制指令。这个动作在 uIP 里要稍微绕一下:因为回调函数运行时,协议栈只认为“当前连接”是正在处理的那个,你要给别的连接发数据,必须先切换当前连接指针。uIP 提供了uip_set_conn()用于切换,配合遍历连接表,就能实现对每一个在线连接的主动下发。
void server_push_config(uint8_t *data, uint16_t len) { int i; for (i = 0; i < UIP_CONNS; i++) { struct uip_conn *conn = &uip_conns[i]; /* 只发送已建立的连接 */ if ((conn->tcpstateflags & UIP_ESTABLISHED) == 0) { continue; } /* 把当前连接切到目标连接,之后 uip_send 作用于它 */ uip_set_conn(conn); struct server_app_state *state = (struct server_app_state *)conn->appstate; if (!state->used) { continue; } if (len <= sizeof(state->pending)) { memcpy(state->pending, data, len); uip_send(state->pending, len); } } }这段代码要注意几个边界。第一,uip_set_conn()切换之后,uip_send()生成的数据包并不会立刻发出,它只是把段写进uip_buf,真正发送发生在主循环里那次tapdev_send()。所以遍历发送多个连接时,最后只保留最后一个连接的数据发出去,实际会丢包。正确的做法是:不要在一个普通函数里连续给多个连接uip_send(),而是把“需要向所有连接下发”这个意图记录下来,在uip_poll()事件里逐个连接地切换、发送,这样每次回调事件只准备一包,主循环发完一包,下一次 poll 再准备下一包。
第二,server_push_config()这种普通函数往往由业务主逻辑调用,但uip_send()只能在 UIP_APPCALL 回调内部使用。所以更稳妥的设计是把主动下发拆成两段:普通业务代码只负责把待发数据挂到每个连接的appstate->pending里,然后在uip_poll()分支里检查有没有 pending 数据,有就把当前连接切过去并发送。用这个思路,主循环切到哪个连接,哪个连接就尝试把 pending 数据发出去,自然就实现了分时轮询发送,不会发生一次循环覆盖多个连接包的问题。
5. uIP TCP 调试与避坑:5 个能把连接搞死的细节
5.1 在回调之外调用 uip_send,数据静默丢失
现象:代码里在 main 循环的业务函数中调用了uip_send(),编译正常,断点也进去了,但 PC 端抓包或者服务器日志里就是收不到数据。
原因:uip_send()依赖当前事件上下文里的uip_conn指针和uip_buf缓冲区。在 UIP_APPCALL 回调之外,uip_conn可能指向的是上一个事件遗留的连接,甚至可能是空指针,uip_buf也不是一个有效的待发送上下文。你调用了uip_send(),数据只是被写进一块没人处理的缓冲区,主循环可能还没来得及发就被下一次收包覆盖了。
解决:所有发送动作一律搬进 UIP_APPCALL 回调内部,至少要保证在回调的分支里完成。业务层需要发送时,先在外部填充自己的应用缓冲和管理标志,等uip_poll()或者uip_acked()事件进来后再执行真正的uip_send()。我自己的代码里,业务层暴露出去的只负责“准备数据”,发送永远只发生在回调分支里。
5.2 uip_rexmit 不重发旧数据,连接卡在重传黑洞
现象:连接建立后第一次通信正常,模拟一次弱网丢包之后,连接就再也发不出数据了,直到超时被系统踢掉。
原因:TCP 丢包后靠重传兜底。uIP 检测到超时后会触发uip_rexmit()事件,这个事件的含义是“请把上一次发过的那段数据原样再发一次”。如果你没有在uip_rexmit()分支里调用uip_send()重新提交旧数据,协议栈就无包可发,对端一直等不到重传的包,连接就卡死了。
解决:在回调里增加一个if (uip_rexmit())分支,把上次发送的原始数据用相同内容、相同长度重新提交一次。前提是应用层得保存“当前正在发送的这段数据”,比如我在客户端场景里维护的send_buf和send_len,重传时直接原样调用uip_send(send_buf, send_len)就行。
5.3 服务器单方面关闭后,客户端状态没复位
现象:服务器重启或者主动断开后,客户端没有立刻重连,而是等到下一次业务上报才卡住,或者重连成功后收到的首帧数据被当成旧数据丢弃。
原因:客户端回调里可能只处理了uip_closed(),却漏了uip_aborted()和uip_timedout()。服务器重启时很可能发出的是 RST,对应uip_aborted();如果服务器宕机无响应,则最终是uip_timedout()。漏掉任何一种关闭来源,客户端状态都会停留在之前的CLIENT_SENDING或CLIENT_WAIT_ACK,下一次连接建立后还带着旧状态,业务收发全乱。
解决:把uip_closed()、uip_aborted()、uip_timedout()三个标志写到同一个if里处理,分支内统一uip_close()、复位状态机、启动重连定时器。凡是“连接已经没戏了”的事件,全部走同一个出口,不留死角。
5.4 UIP_CONNS 共池:服务器吃满连接,客户端连不上
现象:服务器角色能正常接入多台设备,但客户端发起连接时uip_connect()返回 NULL,重连一直失败;关掉服务器功能后客户端立刻恢复。
原因:uIP 的 TCP 连接表是客户端和服务器共用的,总量由UIP_CONNS决定。服务器每接入一个终端就占用一个槽位,槽位被占满后,客户端主动连接自然申请不到空间。默认的UIP_CONNS通常只有几个到十几个,接满就翻车。
解决:先把UIP_CONNS按“服务器并发上限 + 客户端连接数 + 2 个余量”来配,给客户端留出专用槽。再就是在服务器侧给空闲连接做超时回收:长时间无数据的连接主动uip_close(),别让它一直占着槽位。如果业务本身就是服务器需要接纳大量短连接,可以把UIP_CONNS调大,但代价是每加一个连接槽都会增加 RAM 消耗,具体预算在第六章算一下。
5.5 周期定时器间隔和 TCP 重传超时打架
现象:把主循环的周期定时器调成 50ms 后,网络稍微拥塞,连接就频繁断开;调成 1s 之后,断网检测变得很迟钝,重连要好几分钟。
原因:uIP 的 TCP 重传定时器是基于主循环的周期事件跳动的,内部用“几个周期”来累计超时。周期太短,同一个重传窗口内能被触发的次数就多,轻微乱序就可能触发快速重传导致链路被自己打断;周期太长,每次重传尝试的间隔被拉得很大,一个包丢了要等半天才能被重传。uIP 出厂设计里用的基准时钟一般是 2Hz 到 4Hz 的节拍,也就是 250ms 到 500ms。
解决:周期定时器默认保持CLOCK_SECOND / 4,不要为了“响应快”盲目调小。如果你确实需要更快的轮询频率,应该单独开一个业务定时器,而不是压缩协议栈自己的周期。网络层和业务层的节奏要分开,这是裸机多任务设计里经常被忽略的一点。
6. 内存预算与验证:把 uIP 这两个角色调稳的进阶手段
6.1 内存预算:每加一个连接多花多少字节
uIP 的内存模型和 lwIP 有个显著区别:它没有为每条连接分配独立的收发缓冲区,而是所有连接共享同一块UIP_BUFSIZE大小的uip_buf。这块缓冲是全局的,不管是接收包还是发送包,都在里面暂存。因此做内存预算时别按“连接数 × 缓冲大小”去算,那是 lwIP 的算法。uIP 实际做的是“一块全局收发缓冲 + 一张连接状态表 + 每连接应用私有区”。
| 内存项 | 配置参数 | 说明 |
|---|---|---|
| 全局收发缓冲区 | UIP_BUFSIZE | 固定一块,容量影响能处理的最大帧长 |
| TCP 连接表 | UIP_CONNS | 每个元素一个struct uip_conn,含状态和定时器 |
| 每连接应用私有区 | UIP_APPSTATE_SIZE | 乘上UIP_CONNS就是你自定义状态的总开销 |
| UDP 连接表 | UIP_UDP_CONNS | 不用 UDP 可以直接设为 0 省内存 |
| 协议栈代码 | ROM 占用 | 一般不超过几十 KB,具体看裁剪程度 |
我一般先把UIP_BUFSIZE定在 600 到 1500 字节之间,根据网卡 MTU 和应用帧长调整;再根据“服务器期望并发 + 1 个客户端 + 余量”定UIP_CONNS;最后把UIP_APPSTATE_SIZE尽量压到最小,能放 16 字节就绝不放 64 字节。这里有一个常被忽略的细节:服务器多连接时,每连接的应用私有区是乘上UIP_CONNS的,如果你给每个连接留一块 256 字节的缓冲,8 条连接就是 2KB RAM,在小内存单片机上这个占比相当可观。所以待下发数据别在appstate里开大数组,宁可设一个短的 pending 标志,把真正的数据放在全局业务缓冲里按连接 ID 分发。
6.2 验证手段:用 PC 脚本给板子当对端
客户端和服务器的联调验证,最直接的方案是用 PC 上的 Python 脚本扮演对端。验证板子的 TCP 客户端时,PC 起一个监听服务,等板子主动连上来收数据:
# 验证板子的 TCP 客户端角色:PC 先起监听 import socket srv = socket.socket() srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind(('0.0.0.0', 8080)) srv.listen(1) print('waiting for board...') conn, addr = srv.accept() print('connected from', addr) data = conn.recv(1024) print('received:', data.hex()) conn.sendall(b'ok') conn.close() srv.close()验证板子的 TCP 服务器角色时反过来,PC 主动连接板子的监听端口,发一条查询指令并读取回应:
# 验证板子的 TCP 服务器角色:PC 主动接入 import socket cli = socket.create_connection(('192.168.1.50', 1024), timeout=3) cli.sendall(bytes.fromhex('AA01 00 10'.replace(' ', ''))) resp = cli.recv(256) print('server response:', resp.hex()) cli.close()这两个脚本一个验证主动上报方向,一个验证被动响应方向,拼起来覆盖了标题里 client 和 server 两条链路。我在项目里还会习惯性地跑一条端口扫描式的连接测试:瞬间连进板子再断开,连续循环几十次,观察服务器侧连接槽是否被死连接堵死。这种压力测试能提前暴露UIP_CONNS配置过小、关闭分支没复位这类阴间问题。整套 uIP 调稳的路线,说到底就是“内存算清楚、状态机画明白、关闭分支做干净”这三件事。有一回我在某个项目里查一个“每天固定时间掉线重连失败”的案子,最后发现就是周期定时器被人改成 20ms 后重传超时被压缩,把参数拨回 250ms 之后一个礼拜再没犯过。希望你接手的项目少一些这种隐藏坑,真遇到了也能顺着这几条经验快速定位。希望帮到你。
本文还有配套的精品资源,点击获取