简介:基于uIP协议栈的TCP客户端与服务器端工程,面向嵌入式或物联网开发者,以战舰STM32开发板与ENC28J60模块为载体,演示如何在资源受限设备上实现可靠TCP通信。压缩包内共223个文件,c源文件与h头文件构成核心工程,另有PDF说明文档、PNG截图、HTML页面、Makefile脚本与示例配置,整体大小约2.59MB。已有739人学习,适合需要快速上手STM32网络编程的初学者,也可作为物联网项目移植TCP通信的参考。工程内容对应开发板教材第五十二章网络实验,包含uIP内存管理、数据包处理与协议转换、ENC28J60的SPI接口初始化、STM32中断与定时器设置,以及TCP应用层调用API完成建连、收发与释放的完整代码和说明。通过该工程可直观理解三次握手、四次挥手等传输层机制,同时获得ENC28J60移植、uIP协议栈调用和嵌入式网络调试的实践参考。
1. uIP 上的 TCP 客户端与服务器:资源紧张的 MCU 如何跑通网络通信
uIP 可能是目前嵌入式 TCP/IP 实现里体积最小的一档。整个协议栈代码加上 ARP、回调框架,编译后也就几十 KB 固件空间,运行时只占用一个收发缓冲区加少量连接状态,几个连接槽位加起来通常不超过 1KB RAM。这让它成为 8 位 MCU 和低规格 32 位 MCU 上做以太网通信的常见起点。
标题里同时出现 tcp_client 和 tcp_server,说明开发者要的是一套双向能力:设备既能主动连服务器上报数据,也能被管理端找上门来读写配置。我按自己的移植经验,把 uIP 的事件驱动机制、客户端实现、服务器实现和常见坑一次讲透,新手能照着敲,老手能对着排查。
2. 理解 uIP 的事件驱动:主循环、回调与 TCP 状态流转
2.1 为什么 uIP 不用 socket 阻塞模型:事件驱动适合裸机
做 PC 网络编程的人习惯了一套 connect、send、recv 的阻塞式写法,到了 uIP 上往往第一反应是找类似的 API。但 uIP 从设计上就不是这个路子。它面向的是没有操作系统的裸机环境,或者只有轻量调度器的 RTOS,主循环里不能因为等待网络事件把所有任务卡住。所以 uIP 采用事件驱动:协议栈本身不创建线程,也不阻塞等待,而是由主循环周期性调用 uip_periodic(),由网卡驱动在收到包时调用 uip_input(),协议栈处理完一个包之后,如果有应用层需要关心的事件,就回调一次 UIP_APPCALL,应用代码在回调里决定做什么。
这种模型的代价是应用逻辑被打散成“事件分支”,但换来的是极低的内存占用。TCP 连接状态不需要为每个 socket 单独维护一套阻塞缓冲,数据包进协议栈时用同一个全局缓冲区收包和转发,处理完立即交给应用,应用拿完数据就清空。对 MCU 来说,这套设计的稳定性取决于两条铁律:第一,回调里不能做耗时过长的操作;第二,回调里看到的 uip_appdata 指针是全局缓冲区里的地址,需要长期保存的数据必须马上复制走。
uIP 主循环的标准形态大致是这样:
while (1) { /* 周期性驱动 TCP 定时器,推进各连接的超时与重传 */ if (timer_expired(&periodic_timer)) { timer_reset(&periodic_timer); for (i = 0; i < UIP_CONF_MAX_CONNECTIONS; i++) { uip_periodic(i); if (uip_len > 0) { uip_arp_out(); network_driver_send(); } } uip_arp_timer(); } /* 网卡收到数据时,把数据交给 uip_input 处理 */ if (network_driver_poll()) { uip_len = network_driver_read(); if (uip_len > 0) { uip_input(); if (uip_len > 0) { uip_arp_out(); network_driver_send(); } } } }这里的关键变量是 uip_len。它既是输入长度也是输出长度:调用 uip_input() 之前,你把它设为网卡收到的字节数;协议栈处理完如果需要回包,会把回包写进缓冲区并修改 uip_len,主循环看到 uip_len 大于 0 就调用 uip_arp_out() 补上以太网和 ARP 头,再由网卡驱动发送。uip_periodic(i) 的处理方式相同,只不过它推进的是连接 i 的定时状态,例如超时重传、窗口更新。网卡驱动这一层可以是 SPI 接口的独立以太网控制器,也可以是内部 MAC 加 PHY,只要提供 read 和 send 两个函数就能接进这个循环。
2.2 uip_conn 连接结构与 TCP 状态映射:连接槽位怎么分配
uIP 里每个活跃的 TCP 连接对应一个 struct uip_conn,这个结构在 uip.h 里定义,主要字段包括本端端口 lport、远端端口 rport、远端 IP 地址 ripaddr、当前状态 tcpstateflags、发送窗口和重传计时器。连接槽位的数量由 UIP_CONF_MAX_CONNECTIONS 决定,服务器监听端口则单独由 UIP_CONF_MAX_LISTENPORTS 控制。客户端发起连接、服务器接受连接,最终都会占用一个 uip_conn 槽位,区别只在于是本端先发 SYN,还是对端先发 SYN。
TCP 状态机在 uIP 中被压缩成 tcpstateflags 的几位标志,UIP_TS_* 系列宏表示 SYN_RCVD、ESTABLISHED、FIN_WAIT 等状态。应用层不需要直接操作这些标志,而是在 UIP_APPCALL 里通过 uip_connected()、uip_newdata()、uip_closed()、uip_aborted()、uip_timedout() 这些宏判断当前到达的是什么事件。这种设计把复杂状态机封装成几个布尔判断,应用代码写起来比较直观。
当 TCP 连接从被动打开进入 ESTABLISHED,或客户端握手成功时,回调里 uip_connected() 为真,此时应用层可以打印连接信息或初始化协议上下文。数据到达时 uip_newdata() 为真,uip_appdata 指向数据起点,uip_datalen 是数据长度。对端关闭连接时 uip_closed() 为真,可以在这里释放资源。多数嵌入式业务逻辑就围绕这几个分支展开,不需要理解状态机内部细节。
2.3 uipopt.h 关键参数:先照着这张表配
uIP 的裁剪集中在 uipopt.h 里,改完参数重新编译即可。以下是一份我常用的配置参考,对应 2KB 缓冲区、6 个连接、2 个监听端口的场景:
| 配置项 | 推荐值 | 说明 |
|---|---|---|
| UIP_CONF_MAX_CONNECTIONS | 6 | TCP 连接槽位数,客户端与服务器共用 |
| UIP_CONF_MAX_LISTENPORTS | 2 | 可同时监听的服务端口数 |
| UIP_CONF_BUFFER_SIZE | 2048 | 协议栈收发共享缓冲区,含 MAC/IP/TCP 头 |
| UIP_CONF_TCP_MSS | 1024 | 单个 TCP 段最大数据长度 |
| UIP_CONF_TCP_FIN | 1 | 允许发送 FIN 正常关闭连接 |
| UIP_CONF_TCP_TIMEOUT | 20 | 重传超时阈值,单位与定时器周期相关 |
这里最影响体验的是 UIP_CONF_BUFFER_SIZE。它决定了单包最大尺寸,uIP 收到一个超过缓冲区长度的数据包会被丢弃,IPv4 分片重组也不支持,所以实际项目中要把 MSS 调得比缓冲区头部开销小,避免对端发来整包超过缓冲区的情况。UIP_CONF_MAX_CONNECTIONS 则直接影响并发能力,每多一个槽位大约多占用几十字节 RAM,8 位 MCU 上建议控制在 4 到 8 个之间。
如果业务里需要同时跑 Modbus TCP 网关和远程配置通道,2 个监听端口会显得紧,可以把 UIP_CONF_MAX_LISTENPORTS 调到 4,代价是监听状态数组多占一点 RAM。UIP_CONF_BUFFER_SIZE 在条件允许时尽量配到 2048,因为标准以太网帧最大 1518,加上 VLAN 标签和字节对齐,2048 能从容覆盖绝大多数场景,也让上层协议解析少一些边界判断。
2.4 UIP_APPCALL 回调里有哪些状态宏:事件分支怎么写
UIP_APPCALL 是一个宏,默认指向 uip_tcp_appcall(),应用层替换宏名后实现自己的函数。回调内部的状态宏大致分成三类。连接类事件包括 uip_connected()、uip_closed()、uip_aborted()、uip_timedout(),其中 uip_aborted() 表示 RST 终止,uip_timedout() 表示多次重传失败;数据类事件包括 uip_newdata() 表示收到新数据、uip_rexmit() 表示协议栈要求重传上一次发送的数据、uip_poll() 表示连接空闲轮询;发送类则是 uip_acked() 返回对端 ACK 的长度,可以依赖它做应用层确认。
回调返回后,协议栈会检查应用层是否调用过 uip_send()。如果你调用了,uip_len 会被设置为待发送数据的长度,随后协议栈负责加 TCP 头、IP 头,交给主循环的网卡发送。这里有个重要细节:在一个回调周期内只能调用一次 uip_send(),第二次调用会覆盖第一次的数据。如果一次回调中既想回复当前请求,又想推送额外数据,需要把两段数据拼接到同一个应用缓冲区后再发送。
回调里还要注意时序问题。uip_poll() 不是每毫秒都来的,它的频率由主循环里 uip_periodic() 的调用节奏决定。如果在 uip_newdata() 里做了 Flash 擦写、传感器轮询这类耗时操作,整个协议栈的定时器都会停摆,TCP 重传和保活全部延迟,表现就是连接时通时断。正确做法是回调里只做数据搬移和状态标记,重活放到主循环慢速处理。
3. 让设备主动上报:uIP TCP 客户端从连到收发的完整代码
3.1 初始化协议栈:uip_init 与网卡驱动的对接
在写 TCP 客户端之前,先把底座铺好。uIP 的初始化非常简短:调用 uip_init() 初始化协议栈内部状态,调用 uip_arp_init() 清空 ARP 表,然后给网卡驱动配置本机 IP、子网掩码和默认网关。下面是一个完整的最小初始化代码:
#include "uip.h" #include "uip_arp.h" #include "network_driver.h" /* 自研或第三方网卡驱动 */ static struct uip_eth_addr my_mac = {{0x02, 0x00, 0x00, 0x12, 0x34, 0x56}}; void network_init(void) { /* 1. 协议栈与 ARP 表复位 */ uip_init(); uip_arp_init(); /* 2. 配置本机 IP 地址 */ uip_ipaddr_t ipaddr; uip_ipaddr(&ipaddr, 192, 168, 1, 200); /* 本机 IP */ uip_sethostaddr(ipaddr); /* 3. 配置子网掩码和默认网关 */ uip_ipaddr(&ipaddr, 255, 255, 255, 0); uip_setnetmask(ipaddr); uip_ipaddr(&ipaddr, 192, 168, 1, 1); uip_setdraddr(ipaddr); /* 4. 网卡驱动初始化并写 MAC */ network_driver_init(); network_driver_set_mac(my_mac); }uip_ipaddr() 是一个宏,接受四个字节参数组成一个 32 位 IP 值,uip_sethostaddr() 把该值写入本机地址。网卡驱动的 network_driver_init() 负责复位外设和建立收发链路,不同硬件差异很大,但只要上层能通过 network_driver_read() 拿到一帧数据、通过 network_driver_send() 发出一帧数据,uIP 就不关心底层是 SPI 还是并行总线。
初始化完成后,主循环里的事件驱动力已经具备。接下来写客户端,本质上是三个动作:申请连接、等待握手成功、在回调里收发数据。uIP 没有类似 socket 的 connect+send 顺序调用,连接请求只是告诉协议栈“我想连这个地址”,真正的握手由后续的 uip_periodic() 定时驱动完成。
3.2 发起连接:uip_connect 的调用姿势与握手确认
客户端发起连接的入口是 uip_connect()。它返回一个指向 struct uip_conn 的指针,同时把连接状态置为 SYN_SENT。下面是一段从应用层命令触发连接、并在回调里确认的代码:
static struct uip_conn *server_conn; void tcp_client_connect_to_server(void) { uip_ipaddr_t server_ip; if (server_conn != NULL) { return; /* 已在连接中或已连接,避免重复发起 */ } /* 填充服务器 IP,例如 192.168.1.100 */ uip_ipaddr(&server_ip, 192, 168, 1, 100); server_conn = uip_connect(&server_ip, htons(8080)); if (server_conn == NULL) { /* 连接槽位已满,考虑关闭空闲连接或增大 MAX_CONNECTIONS */ app_report_error("no free conn slot"); } }uip_connect() 内部会遍历 UIP_CONF_MAX_CONNECTIONS 个槽位,找到一个空闲的就登记远端地址和端口,然后置状态为 SYN_SENT。如果槽位全满,返回 NULL,调用方要自己决定策略:要么关掉一个不活跃连接,要么增大槽位数。还有一点容易忽略,uip_connect() 成功后握手尚未完成,此时不要往连接里写应用数据,要等回调中 uip_connected() 为真。
注意端口号是网络字节序,所以用 htons() 转换。uIP 在无 OS 环境下自己也提供了 htons 宏,有些移植版本直接定义在 uip.h 里。服务器地址如果和本机不在同一子网,uIP 会把包交给默认网关,但前提是网关 IP 已通过 uip_setdraddr() 设置,且 ARP 能解析到网关 MAC。
握手是否成功,回调是最好的观测点。uip_connected() 返回真说明三次握手已完成,此时 uip_conn->rport 和 ripaddr 都已经是对端的真实信息。如果一段时间后收到 uip_timedout(),说明 SYN 重传超过阈值,常见原因是对端 IP 不通、端口拒绝或 ARP 解析失败。想在失败时做诊断,可以在超时事件里打印 uip_conn 的远端地址和端口,再回查网卡驱动有没有把包真正发出去。
3.3 数据收发:回调里的 uip_send 与 uip_newdata 配合
连接建立后所有收发都发生在 UIP_APPCALL 里。真实设备往往要周期上报数据,典型做法是在主循环里置一个标志,回调里检测到 uip_poll() 或 uip_acked() 时发送。下面这段代码演示了客户端收到服务器请求后回复状态,同时处理新数据:
#define UIP_APPCALL uip_tcp_appcall void uip_tcp_appcall(void) { if (uip_connected()) { /* 握手成功,此时才能安全发送应用数据 */ send_device_hello(); return; } if (uip_newdata()) { /* 收到服务器数据,uip_appdata 指向共享缓冲区 */ handle_server_cmd(uip_appdata, uip_datalen); return; } if (uip_poll()) { /* 连接空闲轮询,适合做周期上报 */ if (report_pending) { uip_send((uint8_t *)report_buf, report_len); report_pending = 0; } return; } if (uip_rexmit()) { /* 协议栈要求重发之前调用过 uip_send 的数据 */ uip_send((uint8_t *)pending_buf, pending_len); return; } }handle_server_cmd() 里如果只是解析命令并立即驱动硬件,可以直接从 uip_appdata 读;但如果要把数据缓存到队列或数据库,必须先 memcpy 到自己的 buffer。原因是 uip_appdata 指向的收发缓冲区在回调返回后很快被下一个包覆盖。uip_send() 同样要求数据指针在回调返回前有效,协议栈内部会复制到发送缓冲区,所以不需要自己额外维护一份副本,但数据量较大时建议预先拼好再一次性发送。
uip_poll() 是周期性空闲事件,间隔受 uip_periodic() 调用频率控制。主循环里每 50ms 调一次 uip_periodic(),那么 uip_poll() 大约每 500ms 来一次,具体要看定时器配置。周期上报用 uip_poll() 加标志位的方式,比在外部直接调用 uip_send() 安全得多。应用层千万不要绕过回调直接写协议栈内部缓冲区,那样 uip_len 会被误改,发出的包长度和内容全部乱掉。
3.4 断线重连:关闭类事件后的资源清理
TCP 在真实链路里一定会遇到服务器重启、网线断开、对端 RST。uIP 会把异常拆成 uip_aborted() 和 uip_timedout() 两个事件,加上正常的 uip_closed(),统一处理释放资源。下面这段代码是客户端侧断线处理的模板:
if (uip_closed() || uip_aborted() || uip_timedout()) { /* 连接已结束,必须把指针清空,否则无法发起重连 */ server_conn = NULL; report_pending = 0; reconnect_timer = RECONNECT_DELAY_MS; return; }这里最关键的教训是:任何关闭类事件后,uip_conn 槽位会被协议栈回收,应用层保存的 server_conn 指针变成野指针,如果不置 NULL,下次 uip_connect() 会因判断“已连接”而不发起新连接,表现为设备断线后永远无法恢复。重连策略我一般做两级:快速重连 3 次间隔 1 秒,之后退避到 10 秒一次,避免服务器恢复期间客户端反复 SYN 造成网络拥塞。
还有个细节,连接关闭后 uIP 不会自动重发 SYN,一切重连动作都要应用层主动调用 uip_connect()。因此主循环里要有独立的断线检测逻辑,比如用一个 countdown 定时器,计时到 0 且 server_conn 为 NULL 时重新发起连接。这个定时器要独立于协议栈的 TCP 定时器,否则主循环一忙就忘了重连。
4. 让管理端找上门:uIP TCP 服务器的监听与多连接调度
4.1 开启监听:uip_listen 注册端口与被动打开流程
uIP 服务器的启动动作比客户端更简单:调用 uip_listen() 注册一个监听端口,之后协议栈在处理收到的 SYN 时自动分配空闲连接槽位。监听端口的编号从 0 开始,受 UIP_CONF_MAX_LISTENPORTS 限制,这一点和普通 socket 按端口号区分的方式不太一样。
void tcp_server_init(void) { /* 监听 TCP 8080 端口,监听槽位由 uIP 内部管理 */ uip_listen(htons(8080)); }uip_listen() 的返回值在不同移植版本里不一致,有的直接返回 void。我在代码里不依赖返回值,而是在编译期用静态断言保证 UIP_CONF_MAX_LISTENPORTS 大于实际监听数量。端口一旦注册,任何发往该端口的 SYN 都会进入协议栈的被动打开流程,应用层不需要参与三次握手的构造,等握手完成,回调里 uip_connected() 自然为真。
服务器侧不需要像客户端那样保存一个 uip_conn 指针,因为槽位由协议栈自动分配。应用层要区分的是“这个回调属于哪个连接”。这里要提醒一点:uip_listen() 只负责监听,不会主动为一个连接预留槽位。如果多个客户端同时接入而槽位耗尽,多余的 SYN 会被协议栈丢弃,表现为客户端 connect 超时。
4.2 区分多个客户端:从 uip_conn 读 IP 和端口
多个客户端同时接入时,UIP_APPCALL 会被多个连接复用。区分方式不是靠回调参数,而是通过 uip_conn 指针读取当前连接信息。uip_conn 在回调中指向当前触发事件的那个连接:
void uip_tcp_appcall(void) { struct uip_conn *c = uip_conn; if (uip_connected()) { uint8_t *ip = (uint8_t *)&c->ripaddr; uint16_t port = ntohs(c->rport); /* 用 ip[0..3] 和 port 登记这个新客户端 */ client_table_add(ip, port); return; } if (uip_newdata()) { struct client_entry *e = client_table_find(ip, port); if (e) { /* 把数据交给对应客户端上下文处理 */ e->on_data(uip_appdata, uip_datalen); } return; } if (uip_closed() || uip_aborted() || uip_timedout()) { client_table_remove((uint8_t *)&c->ripaddr, ntohs(c->rport)); return; } }ripaddr 是 16 位数组形式存储的 IPv4 地址,可以直接强转成 4 字节访问,也可以使用辅助宏转换成点分十进制。rport 是远端端口,lport 是本端端口。实战中我习惯用远端 IP 加端口作为连接标识,因为 uIP 支持同一客户端开多个连接,只记 IP 会串数据。
uip_conn 结构里还有一个 appstate 字段,很多移植会忽略它。实际上这个字段是给应用层保存与连接绑定的私有数据用的,可以把客户端上下文指针、协议解析状态直接塞进去,比单独维护一张查找表省心。多端口服务器尤其推荐用这个字段,回调里拿到 uip_conn,从 appstate 取出自己的业务对象,不用再按 IP 遍历查找。
4.3 服务器主动推送:用 uip_poll 找发送时机
服务器不能只等客户端来请求,很多场景要求主动下发控制指令或状态广播。uIP 服务器主动推送和客户端发送在机制上一样,都是等 uip_poll() 事件到达后在回调内调用 uip_send(),区别在于发送内容的选择。当多个连接都想推送时,我用一个简单的轮询表:
static struct uip_conn *push_conn[4]; static uint8_t *push_data; static uint16_t push_len; void uip_tcp_appcall(void) { int i; if (uip_poll()) { for (i = 0; i < 4; i++) { if (uip_conn == push_conn[i] && push_len > 0) { uip_send(push_data, push_len); push_len = 0; return; } } } }push_conn 数组保存活跃连接的 uip_conn 指针,主循环需要推送时,先填充 push_data 和 push_len,再设置 push_conn 数组为目标连接。等该连接的下一次 uip_poll() 到来时数据就被发出去。这个方案的优点是发送完全在协议栈允许的时机进行,不会出现应用层抢占缓冲区导致的数据错乱,代价是要记住在连接关闭事件里把 push_conn 对应项置空,否则会向已失效的连接发送。
uip_poll() 的频率决定了主动推送的延迟。假设主循环 50ms 调一次 uip_periodic(),那么一个连接大约每 500ms 会得到一次 poll 机会,推送延迟最大 500ms。如果业务要求更快的主动下发,可以把主循环周期缩小,或对目标连接单独调用 uip_periodic()。后者能显著降低延迟,但要注意 TCP 定时器的整体节奏会变化,重传超时等时间参数要重新核算。
4.4 一个完整的最小命令响应服务器
把上面的机制拼起来,一个完整的最小服务器如下。它完成的事情是:注册监听端口,收到一行“GET”命令返回设备状态,收到“SETV:”命令更新内部变量,连接断开时清理客户端表:
#define UIP_APPCALL uip_tcp_appcall static uint16_t device_status; static void server_on_newdata(uint8_t *data, uint16_t len) { /* 简单协议:字符串命令 */ if (len >= 3 && memcmp(data, "GET", 3) == 0) { uip_send((uint8_t *)"STATUS:OK", 9); } else if (len >= 5 && memcmp(data, "SETV:", 5) == 0) { device_status = (uint16_t)atoi((char *)data + 5); uip_send((uint8_t *)"ACK", 3); } } void uip_tcp_appcall(void) { struct uip_conn *c = uip_conn; if (uip_connected()) { client_table_add((uint8_t *)&c->ripaddr, ntohs(c->rport)); return; } if (uip_newdata()) { server_on_newdata(uip_appdata, uip_datalen); return; } if (uip_closed() || uip_aborted() || uip_timedout()) { client_table_remove((uint8_t *)&c->ripaddr, ntohs(c->rport)); return; } }server_on_newdata 里直接调用 uip_send 是安全的,因为这里正处于回调上下文内部。从代码风格上可以看出来,uIP 应用层写多了之后,会把回调函数维护成一张事件分发表,每个状态宏对应一个处理函数,而不是堆大括号嵌套。收到数据后,如果命令需要耗时处理,比如擦写 Flash,不要在这里同步做,先把命令和数据存下来,返回主循环后慢慢执行,执行完再置推送标志,否则回调占用时间过长,TCP 的 ACK 和重传定时器都会被拖乱,表现就是设备偶尔丢连接。
5. uIP TCP 避坑指南:5 个高频翻车点与排查思路
5.1 服务器重启后客户端连不上:ARP 缓存失效
现象:设备第一次开机连接服务器一切正常,服务器重启后,客户端反复连接失败,抓包能看到设备在持续发送 ARP 请求,但 SYN 一直发不出去。
原因:服务器重启后网卡 MAC 没变,但客户端 ARP 表可能因为服务器换网口、换虚拟 MAC 而过期。uIP 的 ARP 表是固定长度静态表,条目超时会清空,而 SYN 如果无法解析到目标 MAC 就会被丢弃,不会进入 TCP 层。
解决:在 uip_connect() 之前或重连失败时主动刷新 ARP,调用 uip_arp_update() 更新对应 IP 的 MAC 映射。更稳妥的办法是主循环里周期调用 uip_arp_timer() 让 ARP 表按年龄淘汰,同时把服务器 IP 对应的静态 ARP 条目在初始化时写入。排查时抓包看两层:网卡驱动是否真的发出了 ARP 请求,协议栈是否在 uip_len 里填了数据。
5.2 发送大于 MSS 的数据被截断:缓冲区与分片
现象:客户端调用 uip_send() 发送 2KB 数据,对端只收到一部分,或者抓包里出现 TCP 分片重组失败。
原因:uip_send() 一次能发送的最大长度受限于 UIP_CONF_TCP_MSS,超过 MSS 的部分会被静默截断。uIP 不负责把超大应用数据拆成多个 TCP 段,UIP_CONF_TCP_MSS 看起来配了 1024,但如果 UIP_CONF_BUFFER_SIZE 只有 1460,头一压,有效载荷不到 1400,实际发送上限更小。
解决:应用层在调用 uip_send() 前自己做分片。维护一个发送队列,每次只把不超过 MSS 的数据交给 uip_send(),剩余数据等下一次 uip_poll() 或 uip_acked() 再发。同时确认 UIP_CONF_BUFFER_SIZE 大于等于 UIP_CONF_TCP_MSS 加 54 的余量,54 是典型的以太网头、IP 头加 TCP 头总开销,再加上可能的选项字段,实际建议留 100 字节以上的余量。
5.3 多个连接只有一个能收发:连接槽位与指针管理
现象:服务器开了两个监听端口,第一个端口连接正常,第二个端口建连后收不到任何回调;或者第一个连接断开后新客户端连不上。
原因:UIP_CONF_MAX_CONNECTIONS 决定了总槽位数,服务器每接受一个客户端就占一个槽位,监听端口多、客户端多的时候槽位很快就满。更常见的坑是应用层在 uip_closed() 或 uip_aborted() 里忘了清自己维护的连接上下文,协议栈认为槽位已释放,应用层却还拿着旧指针做判断,导致新连接到来时数据被路由到错误的处理逻辑。
解决:每次连接关闭事件里,除了清 server_conn 或客户端表条目,还要同步检查应用层保存的所有 uip_conn 指针,该置空的全部置空。可以把 UIP_CONF_MAX_CONNECTIONS 从 4 临时调到 8 验证是否槽位不足,如果是,再把这些 uip_conn 指针全部打印出来对照。uIP 没有自动回收应用层资源的机制,这部分只能自己管理。
5.4 接收到的应用数据错位:共享缓冲区覆盖
现象:客户端连续收到多包数据,打印出来发现第一包正确、第二包开头混入第一包尾部的内容;或者接收后延迟几毫秒再访问 uip_appdata,数据已经变成别的包。
原因:uIP 整个协议栈只有一个收发缓冲区,收包、解析、发送都在这块内存上操作。uip_appdata 是这块缓冲区里指向 TCP 载荷的指针,如果你不拷贝而只保存指针,下一个包一来内容就被覆盖。触发这个坑的典型写法是在 uip_newdata() 里把 uip_appdata 指针存进队列,消费队列时才去读。
解决:在 uip_newdata() 回调内立即 memcpy 到应用层自己的接收缓冲区。接收缓冲区建议用环形队列,uIP 单包最大就是 MSS,环形队列大小设为 2 到 4 倍的 MSS 足够应对突发。调试时可以在 memcpy 前后各打印一遍数据和指针,确认拷贝完成前没有新包进入。
5.5 服务器主动发数据没反应:uip_send 的调用时机
现象:主循环里调用 uip_send() 想给某个客户端推送数据,函数返回正常,但抓包看不到任何 TCP 数据包;或者数据被发给了错误的连接。
原因:uip_send() 本身不做实际的发送,它只是把待发送数据登记到当前协议栈上下文的缓冲区,真正的发送发生在 UIP_APPCALL 回调返回后。如果在主循环里直接调用 uip_send(),当时并没有活跃的协议栈上下文,数据不会进入 TCP 发送流程。另外,服务器有多个连接时,uip_send() 会把数据挂到当前 uip_conn 对应的连接上,如果当前上下文不是目标客户端,数据就发错了。
解决:不要在回调外调用 uip_send(),按 4.3 节的做法,把待发送数据存进应用层队列,在目标连接的下一次 uip_poll() 回调里再调用 uip_send()。如果对延迟敏感,可以在主循环里对该连接单独调用一次 uip_periodic() 来制造一个 poll 机会。核对这个坑的办法很简单:在 uip_send() 前后打印 uip_conn->lport,确认目标端口是自己要推的那一个。
6. 进阶调参与验证:内存紧张时先动哪个参数
6.1 用 PC 端抓包验证三次握手
写完客户端或服务器,第一步不是接真机,而是在 PC 上开一个 TCP 服务端或客户端配合测试。设备连上后,先在 PC 端抓包确认 SYN、SYN-ACK、ACK 三包完整。重点看两个指标:一是 SYN 重传次数,如果超过 2 次说明链路层或 ARP 有问题;二是窗口大小,uIP 默认窗口很小,PC 端显示窗口值只有几百字节是正常的,不要误判为协议栈故障。
抓包还能快速定位 5.1 的 ARP 问题。设备反复请求同一个 IP 的 MAC,说明 ARP 表没有正确更新;如果抓包只有设备发出的 SYN,PC 端没有回应,说明 SYN 根本没到 TCP 层,问题出在网卡驱动或 IP 配置。
6.2 内存紧张时的调参优先级
当 RAM 吃紧,优先调低 UIP_CONF_MAX_CONNECTIONS,每减一个连接槽位能省几十字节。其次调低 UIP_CONF_TCP_MSS,它直接影响收发缓冲的压力。最后再去动 UIP_CONF_BUFFER_SIZE,这个值至少要能容纳一个完整的 TCP 段,建议保留 600 字节以上作为底线,否则握手包虽然能通过,业务数据一多就会出现各种丢包错位。我的习惯是:先保证缓冲区能放下标准以太网帧,再按业务并发削减连接数,这样既不影响互联互通,又能把内存压住。
配合上面的参数,最好在 PC 端连续做三轮验证:第一轮 100 字节小包收发,第二轮 1KB 连续传输,第三轮同时开两个客户端连接测试并发。三轮都通过再移植到真实硬件,出现问题时先回到 PC 端复现,能定位到底是 uIP 配置问题还是网卡驱动问题。这套流程走下来,uIP 的 TCP 客户端和服务器基本就不会再半夜给你打电话了,遇到翻车,九成是 ARP 或者缓冲区覆盖。希望帮到你。
本文还有配套的精品资源,点击获取