ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

嵌入式uIP协议栈源码移植与2KB RAM裁剪实践

嵌入式uIP协议栈源码移植与2KB RAM裁剪实践

简介:围绕轻量级嵌入式TCP/IP协议栈uIP 1.0整理的源码与配套资料包,面向正在学习嵌入式网络编程、准备移植或裁剪协议栈的开发者。内容覆盖TCP、UDP、ICMP、IPv4及ARP、DHCP、DNS、HTTP服务器、Web客户端、Telnet等模块,核心实现集中在uip.c、uip_arp.c、resolv.c、dhcpc.c、httpd.c、webclient.c、psock.c等文件中,可据此梳理事件驱动模型、小型化内存管理与模块化输入处理流程,理解资源受限设备如何完成网络通信。压缩包共304个文件、约1.55MB,以html文档、h头文件、c源文件为主,另有png示意图、pdf资料、txt说明及Makefile构建脚本,便于对照阅读和编译实验。已有680人学习,适合按目录拆解协议栈、做移植裁剪和二次开发参考。

1. 从 2KB RAM 的板子说起:嵌入式 uIP 协议栈源码到底砍掉了什么

第一次把嵌入式 uIP 协议栈源码接进一块只有 2KB RAM 的 8 位单片机时,最让人意外的不是它能不能跑,而是它凭什么能跑。TCP 握手、超时重传、窗口推进、ARP 应答,这些在通用系统里要靠内核几万行代码兜住的事情,在这份源码里被压进了几个 C 文件、几千字节机器码。它靠的是把灵活性全部换成确定性:一个收发共用的缓冲区、编译期写死的连接数、用回调代替线程。

这个取舍直接决定了适用面。只需要一两条可靠连接、一个固定端口、把采集到的数据推出去就收工的设备,用它最合适;要几十路并发、要在栈里跑加密握手、要传大文件的场景,它撑不住,硬上就是给后面埋雷。下面把这份源码的骨架拆开,再落到能编译、能抓包、能排错的移植路径上,参数怎么倒推、驱动只写哪几个函数、翻车了看哪里,一条条讲。

2. 拆开 uIP 源码的三件套:单缓冲区、连接表与事件回调怎么配合

uIP 的全部设计都压在三样东西上:一个叫uip_buf的全局数组、一张编译期定长的连接表、一个叫UIP_APPCALL的函数宏。看懂这三者之间的调用顺序,源码里剩下的部分基本都是协议细节的填空。反过来,绝大多数移植失败也不是协议没读懂,而是没搞清楚「谁在什么时候动这块缓冲区」。

2.1 一个 uip_buf 走天下:收包到回包的完整路径

uIP 没有内存池,也没有分片重组队列。网卡收到一帧,驱动直接把它拷进uip_buf,把长度写进全局变量uip_len,然后调用uip_input()。协议栈就地解析 IP 头、TCP 头,找到对应的连接表项,把uip_appdata指向载荷起始位置,再回调应用函数。应用如果决定发数据,就调uip_send()把内容填到同一个缓冲区的载荷区,函数返回后协议栈补上 TCP 头和 IP 头,更新uip_len,驱动再把这块内存发出去。

整套流程里只有一块内存。好处是 RAM 占用等于零动态分配,坏处是三个硬约束:应用回调期间不能长时间阻塞,否则下一帧没地方放;不能把uip_appdata指针存起来跨回调使用,那次数据早被覆盖了;不能在回调里再触发一次接收处理。这三条不理解,调试时会遇到非常玄学的现象——明明代码逻辑没错,偶尔丢一包、偶尔回错数据。

下面是驱动层最小的轮询骨架,把它当成 uIP 与硬件之间的唯一接口:

/* 网卡驱动与 uIP 的胶合层:只做搬运,不碰协议 */ int main(void) { uip_ipaddr_t addr; uip_init(); /* 清空连接表、监听表、ARP 表 */ uip_ipaddr(addr, 192, 168, 0, 10); /* 本机地址,按现场网段改 */ uip_sethostaddr(addr); uip_ipaddr(addr, 192, 168, 0, 1); /* 网关,只跨网段时才用得上 */ uip_setdraddr(addr); uip_ipaddr(addr, 255, 255, 255, 0); uip_setnetmask(addr); uip_listen(HTONS(80)); /* 监听端口,占一个监听表项 */ for(;;) { uip_len = netdev_read(uip_buf); /* 没有帧时返回 0 */ if(uip_len > 0) { if(BUF->type == htons(UIP_ETHTYPE_IP)) { uip_arp_ipin(); /* 顺手学习源 MAC,少一次 ARP 往返 */ uip_input(); /* 内部会触发 UIP_APPCALL */ if(uip_len > 0) { /* 应用填了数据才会大于 0 */ uip_arp_out(); /* 补以太网头 */ netdev_send(uip_buf, uip_len); } } else if(BUF->type == htons(UIP_ETHTYPE_ARP)) { uip_arp_arpin(); if(uip_len > 0) netdev_send(uip_buf, uip_len); } } else if(timer_expired(&periodic)) { timer_reset(&periodic, 200); /* 200ms 一次,见 3.3 的取值理由 */ for(i = 0; i < UIP_CONNS; i++) { uip_periodic(i); /* 推进重传、超时、TIME_WAIT 回收 */ if(uip_len > 0) { uip_arp_out(); netdev_send(uip_buf, uip_len); } } } } }

逻辑上有两处容易被忽略。第一,uip_input()返回后必须重新判断uip_len,因为它可能被协议栈清零(比如收到纯 ACK、或者包被丢弃),此时千万不要往网卡写空帧。第二,uip_periodic()必须对每一条连接槽都调一遍,哪怕当前没建立连接,它内部会判断状态位直接返回,成本只有几十个周期。定时周期建议 100~500ms,太密浪费 CPU,太疏会让重传粒度变粗,弱网下表现为「明明网是通的,一次要等两三秒才重发」。

uip_buf的长度在源码里由UIP_BUFSIZE加上链路层头余量决定,具体定义看BUF宏怎么写的,别自己另建一个数组去接包——一旦两份缓冲并存,你就得自己维护一致性,等于把 uIP 最省事的部分重写了一遍。

2.2 连接表与监听端口表:内存是靠编译期省出来的

uIP 用struct uip_conn的静态数组uip_conns[UIP_CONNS]存所有 TCP 连接,用uip_listenports[UIP_LISTENPORTS]存监听端口。没有 malloc,没有链表,新连接来了就在数组里找一个空槽填进去,槽位号就是uip_periodic()的参数。

每条连接要记住的东西并不多,但每一项都不能省:对端 IP、两端端口、接收序号rcv_nxt、发送序号snd_nxt、对端通告的窗口、当前 TCP 状态、重传定时器计数、MSS、以及留给应用自由使用的appstate。appstate这个字段是整个设计里最实用的一个口子——它通常是个void *或者一个指针大小的整数,你可以拿它存这个连接的状态机阶段、已发送字节数、甚至一个结构体指针。

字段典型字节数作用常见误用
ripaddr4对端 IP多网卡时忘了每连接独立
lport / rport2 + 2本地 / 对端端口存成主机序后比较失败
rcv_nxt / snd_nxt4 + 4序号推进手动改它导致对端重置
len / mss2 + 2待发长度 / 报文段上限mss 大于缓冲区,必崩
tcpstateflags1状态 + 重传计数位直接赋状态值抹掉重传位
timer1~2重传倒计时超时重传永远不触发
appstate4应用私有数据存了跨连接的全局指针

tcpstateflags这个字节是血泪经验集中地:它高 4 位是状态,低 4 位是重传次数。有些分支里如果你图省事直接conn->tcpstateflags = UIP_ESTABLISHED,会把重传计数区一起清零甚至写脏,表现就是连接建立正常、发数据永远不重传。改状态一律用源码提供的宏,别裸赋值。

监听表更简单,每一项只是个端口号,新连接匹配时线性扫一遍。UIP_LISTENPORTS给 1 就够大多数设备用,如果你确实要 Web 加 telnet 两个口,给 2,多一个表项成本大约 2 字节,几乎不用纠结。

连接数才是要算的账。假设一条uip_conn在 32 位对齐下占 36 字节左右(以你手上源码编译出来为准,用后面 6.1 的sizeof方法确认),给 2 条就是 72 字节,给 8 条就是 288 字节。在一台总共只有 2KB RAM 的板子上,这已经是七分之一。所以先问清楚:这个设备真的需要同时保持两条以上连接吗?多数传感器节点是短连接,一来一回就关,UIP_CONNS = 2留一条给 TIME_WAIT 兜底足够。

2.3 事件回调模型:UIP_APPCALL 是怎么被反向调用的

uIP 不给你线程,也不给你阻塞式的recv()。应用逻辑被写成一个函数,由协议栈在合适的时候调用,函数里通过一组uip_*()判断这次是什么事件:

判断函数触发时机应用通常做什么
uip_connected()三次握手完成初始化 appstate,准备收请求
uip_newdata()收到新载荷读uip_appdata,长度uip_datalen()
uip_acked()上次发送被确认推进发送进度,发下一块
uip_rexmit()对端没确认,需要重发把上次那块原样再uip_send一次
uip_poll()定时轮询到期做超时判断、心跳
uip_closed()/uip_aborted()/uip_timedout()连接终止释放 appstate 里的资源

注意uip_acked()和uip_rexmit()是一对:只要你调过uip_send(),这两个事件里必然会来一个。如果你在uip_rexmit()里什么都不做,那条连接就会一直卡在「发了但没确认」的状态,直到协议栈放弃并uip_aborted()——这就是「能连上、发一条就断」的典型成因。

最小回调骨架长这样:

/* UIP_APPCALL 指向它,一个连接一次调用,必须快速返回 */ void app_call(void) { struct app_state *s = (struct app_state *)uip_conn->appstate; if(uip_connected()) { /* 新连接,先挂上上下文 */ s->phase = PHASE_RECV; return; } if(uip_newdata()) { /* uip_appdata 只在下一次回调前有效,必须当次拷走 */ s->rx_len = uip_datalen(); memcpy(s->rx_buf, uip_appdata, s->rx_len); s->phase = PHASE_SEND; } if(uip_rexmit()) { /* 重传:把上次那块原样再挂一次 */ uip_send(s->tx_buf, s->tx_len); return; } if(uip_closed() || uip_aborted() || uip_timedout()) { s->phase = PHASE_IDLE; /* 槽位由协议栈回收,别自己清 */ } }

UIP_APPCALL在uipopt.h里定义成宏,比如#define UIP_APPCALL app_call。多个端口想分流,就再加一个UIP_UDP_APPCALL,或者干脆在app_call()里根据uip_conn->lport分支。宏必须在包含uip.h之前定义好,顺序错了链接期会报找不到符号,这个坑新手几乎必踩一次。

3. 移植第一关:uipopt.h 里按 RAM 倒推出来的五个配置项

移植 uIP 的绝大部分工作量不在写代码,在于把uipopt.h里那几个数字定对。定错了不会编译失败,只会在跑起来之后以各种奇怪的方式出错——连接建立不了、大包传一半断、内存越界踩到别的变量。这一章按「先算 RAM、再定窗口、最后写驱动」的顺序把参数推导一遍。

3.1 按 RAM 倒推 UIP_CONNS、UIP_BUFSIZE 和 TCP 窗口

正确的推导方向是从缓冲区倒推,不是从连接数正推。缓冲区大小决定了单次能收发的最大报文,报文上限又限制了 MSS,MSS 再和窗口一起决定吞吐。

先定UIP_BUFSIZE。它要能装下 IP 头(20)+ TCP 头(20)+ 你打算一次发送的最大载荷。假设设备上报的数据包是 128 字节的 JSON,那就取 20+20+128 = 168,向上取整到 200 留点余量。如果你还想让对端能发较大的请求过来,比如一个 HTTP 请求头动辄两三百字节,那就得给到 500 以上,否则对端的分片会被丢弃,表现为「浏览器打开页面超时,但 ping 得通」。

再定 MSS。有些分支用UIP_CONF_TCP_MSS显式指定,有些直接从接收窗口推。无论哪种,必须满足两条不等式:MSS 小于等于UIP_BUFSIZE减去 40,同时接收窗口大于等于 MSS。违反了第一条,uip_send()写入的数据会越过缓冲区尾部,把后面的全局变量改掉——这类内存越界在单片机上不会崩,只会让某个计数器莫名跳变,是排查成本最高的一类问题。

连接数放在最后算。把 RAM 总量减去栈(留 256~512 字节)、减去串口缓冲和应用自己的数组,剩下的才是协议栈的预算。预算除以单条uip_conn的大小,就是连接数上限。算出来小于 2 的时候,优先砍缓冲区而不是砍连接数,因为 2 条连接是保证 TIME_WAIT 期间还能接新连接的最低要求。

/* uipopt.h 摘录:这几个值必须按上面的顺序倒推着填 */ #define UIP_CONF_MAX_CONNECTIONS 2 /* 同时存在的 TCP 连接上限 */ #define UIP_CONF_MAX_LISTENPORTS 1 /* 监听端口表项,通常 1 个够用 */ #define UIP_CONF_BUFFER_SIZE 200 /* 收发共用,含 IP+TCP 头 */ #define UIP_CONF_UDP 1 /* 完全不用 UDP 就关掉,省代码 */ #define UIP_CONF_UDP_CONNS 1 #define UIP_CONF_LLH_LEN 14 /* 以太网头长度,点对点时写 0 */ #define UIP_CONF_BYTE_ORDER UIP_LITTLE_ENDIAN #define UIP_CONF_TCP_MSS 32 /* 必须 <= 缓冲区 - 40 */

不同分支的宏名可能带UIP_CONF_前缀,也可能是裸的UIP_,以你手上uipopt.h里的实际写法为准,别照抄名字照抄错了编译不进去还以为是源码问题。

UIP_CONF_BYTE_ORDER这一项也值得单独确认。它是编译期断言,写错不会报错,但校验和计算全错,结果就是「包发出去了,对方全当垃圾扔掉」,抓包能看到帧但看不到任何回应。做法是写死之后,用 3.2 的单测函数验证一遍。

3.2 uip_arch.c 与校验和实现:先单测再联调

协议栈的 IP 头校验和、ICMP 校验和、TCP/UDP 伪首部校验和,最终都落到一个 16 位反码求和函数上。uIP 把这部分留在uip_arch.c(有的分支叫uip_arch.h里的内联实现)等你自己填,因为不同 MCU 上能不能用 32 位累加器、能不能用 DMA 硬件校验,差别很大。这是整个移植里唯一一个必须做单元测试的函数,别跳过。

/* 16 位反码求和 + 取反,IP/ICMP/TCP/UDP 校验和的基础 */ uint16_t uip_chksum(uint16_t *data, uint16_t len) { uint32_t sum = 0; while(len > 1) { sum += *data++; /* 数据按网络序拼成 16 位后读入 */ len -= 2; } if(len > 0) { /* 奇数长度补一个 0 字节,别漏 */ sum += (uint16_t)(*(uint8_t *)data); } while(sum >> 16) { /* 进位要反复折叠,折一次不够 */ sum = (sum & 0xFFFF) + (sum >> 16); } return (uint16_t)(~sum); }

三个参数细节。第一,data传进来的字节序必须已经按网络序拼好了 16 位;如果你的 MCU 是小端,而缓冲区里是大端字节流,读成uint16_t就是反的,算出来的校验和永远不对。稳妥做法是先用两个uint8_t手工拼:((uint16_t)p[0] << 8) | p[1]。第二,奇数长度的补零位置在末尾,不是开头。第三,折叠进位的while不能改成if,累加和溢出两次的情况是真实存在的。

测试方法很简单:构造一个已知答案的小包,比如 IP 头固定 20 字节、校验和应该是某个确定值,跑一遍比对;再用「校验和字段置零后整体求和结果应为 0xFFFF」这个性质验证反码和的正确性。这一步花二十分钟,能省掉后面半天的抓包排查。

3.3 网卡驱动只做三件事:收、送、喂定时器

回到 2.1 的骨架,驱动层真正要实现的函数只有三个:一个把帧从硬件搬到uip_buf并返回长度,一个把uip_buf里的uip_len字节发出去,一个提供毫秒或百毫秒级的定时信号。其余全部是协议栈的事。

收帧函数要处理两件事:一是长度上限判断,硬件报的长度超过缓冲区大小就直接丢掉,别硬拷;二是把链路层头保留在uip_buf起始处,因为BUF宏就是按这个偏移去读帧类型的。

发帧函数要处理的是「发送完成前不能覆盖缓冲区」。很多 SPI 网卡写完后要等一个状态位,如果你提前返回,主循环下一轮把新帧拷进uip_buf,正在发的那帧就被改了。做法是在发送函数里忙等完成标志,或者在中断里回调,但无论如何不能在未完成时返回。

定时器周期取 200ms 是我一般会用的值。uIP 的重传定时器单位就是一次uip_periodic()的调用,若源码里的初始超时值是 4,那实际首包重传就是 800ms 左右,和真实 TCP 的 1 秒量级比较接近。改成 50ms 会让重传过于激进,弱网下反而增加拥塞;改成 1 秒,一次超时要等 4 秒,用户体验很差。

ARP 表的维护也要挂在这个定时路径上,每个周期递减一次老化计数,别单独再开一个定时器。ARP 表项数用UIP_ARPTAB_SIZE(或带CONF前缀的同名宏)控制,一般 4~8 项,每项 10 字节左右。同一网段里只有一个对端时,2 项就够。

4. 从 uip_listen 到 uip_send:写一个能跑的 HTTP 回显服务

把协议栈跑通、能 ping 之后,第二个门槛是写出一个「能连、能收、能回、能关」的应用。这里用一个最小的 HTTP 回显服务做样板,它足够简单能一次看明白,也足够完整能覆盖连接建立、数据接收、应答发送、重传、关闭五条路径。

4.1 应用入口 UIP_APPCALL 的状态轮询写法

先定义每个连接自己的上下文结构,把它挂到appstate上。这个结构里存的东西宁少勿多,因为每个连接都要占一份:

/* 每个 TCP 连接一份,跟着 uip_conn 走 */ struct http_state { uint8_t phase; /* 0 空闲 1 已连 2 等待应答 */ uint16_t req_len; /* 收到的请求长度 */ uint16_t resp_len; /* 待发的应答长度 */ char req[192]; /* 请求暂存,尺寸要 <= 缓冲区 */ char resp[192]; }; static struct http_state http_pool[UIP_CONF_MAX_CONNECTIONS];

http_pool是个静态数组,槽位号怎么和连接对应?两种做法:一是在uip_connected()里遍历找一个未使用槽,把指针塞进appstate;二是直接用连接在uip_conns数组里的下标。前者更灵活,后者更省事。新手建议用前者,因为槽位复用逻辑写反了会出现「A 连接的数据跑到 B 连接的应答里」这种极难复现的错。

然后写回调本体。这里的关键是:每个事件分支做完自己的事就return,不要在同一个回调里既处理uip_newdata()又处理uip_acked(),因为一次回调只会给出一个主要事件,多写分支等于写了死代码,还会让状态机难以推理:

void http_appcall(void) { struct http_state *s = (struct http_state *)uip_conn->appstate; if(uip_connected()) { /* 握手完成,分配上下文 */ s = http_alloc(); if(s == NULL) { uip_close(); return; } /* 没槽位就主动关 */ s->phase = 1; return; } if(uip_newdata()) { s->req_len = uip_datalen(); if(s->req_len > sizeof(s->req)) s->req_len = sizeof(s->req); memcpy(s->req, uip_appdata, s->req_len); /* 当次拷走,别存指针 */ s->resp_len = http_build(s->req, s->req_len, s->resp); uip_send(s->resp, s->resp_len); /* 立刻挂上应答 */ s->phase = 2; return; } if(uip_rexmit()) { /* 对端没收到,原样再挂一次 */ uip_send(s->resp, s->resp_len); return; } if(uip_acked()) { /* 应答被确认,主动关闭 */ uip_close(); return; } if(uip_closed() || uip_aborted() || uip_timedout()) { http_free(s); /* 只清自己的槽,连接槽归协议栈 */ } }

uip_close()发的是 FIN,进入主动关闭流程;对端回 FIN 之后协议栈会回调一次uip_closed()。有些分支里关闭后还会经过 TIME_WAIT,此时槽位被占用但连接已不可用,这正是 5.3 要讲的坑。

http_build()就是拼一个最简单的响应头,注意Content-Length必须和实际字节数一致,否则浏览器会一直等:

/* 拼一个最小可用的响应,长度必须精确 */ static uint16_t http_build(const char *req, uint16_t req_len, char *out) { const char *body = "ok"; uint16_t n = 0; /* 注意:这里用 snprintf 需要栈空间,资源紧就手写拼接 */ n += snprintf(out + n, 96, "HTTP/1.1 200 OK\r\n"); n += snprintf(out + n, 96, "Content-Type: text/plain\r\n"); n += snprintf(out + n, 96, "Content-Length: %u\r\n", (unsigned)strlen(body)); n += snprintf(out + n, 96, "Connection: close\r\n\r\n"); memcpy(out + n, body, strlen(body)); return n + strlen(body); }

snprintf在部分 8 位工具链上很吃栈,几百字节起步。资源紧的时候老老实实手写拼接,或者用查表把一个常量响应整体存进 flash。

4.2 用 protothread 把回调写成顺序代码

回调写法最别扭的地方是状态机得自己拆。uIP 附带的pt.h提供了另一条路:用一组宏把「等待」表达成PT_WAIT_UNTIL,函数体写起来像顺序代码,实际每次进入都从上次停下的switch标签处继续。它是靠switch和__LINE__实现的协程,不占额外栈,代价是局部变量在等待期间不保存。

/* 用 protothread 重写同一个流程,可读性高很多 */ static struct pt http_pt; PT_THREAD(http_thread(void)) { struct http_state *s = (struct http_state *)uip_conn->appstate; PT_BEGIN(&http_pt); while(1) { /* 一直等到有数据或需要重传 */ PT_WAIT_UNTIL(&http_pt, uip_newdata() || uip_rexmit() || uip_closed() || uip_aborted()); if(uip_closed() || uip_aborted()) break; if(uip_newdata()) { s->req_len = uip_datalen(); memcpy(s->req, uip_appdata, s->req_len); s->resp_len = http_build(s->req, s->req_len, s->resp); uip_send(s->resp, s->resp_len); } else { /* uip_rexmit():原样再发一次 */ uip_send(s->resp, s->resp_len); } /* 等确认,或者等连接被关掉 */ PT_WAIT_UNTIL(&http_pt, uip_acked() || uip_closed() || uip_aborted()); if(uip_closed() || uip_aborted()) break; uip_close(); } PT_END(&http_pt); }

三个必须记住的限制。第一,PT_WAIT_UNTIL之间声明的局部变量,跨等待之后值不保证还在,凡是需要跨等待保留的一律放http_state里。第二,PT_THREAD声明的函数不能有返回值,需要返回结果就把结果写进appstate。第三,同一个 protothread 不能被两条连接同时使用——每条连接必须有自己的struct pt,这也是为什么http_state里应该放一个struct pt pt而不是用文件级静态变量。

如果一次只服务一条连接(UIP_CONNS = 1或2),用文件级静态变量图快也行;一旦连接数上去,那种写法会在第二个连接进来时表现成「第一个连接的流程从中间被重置」,排查起来极其恶心。

4.3 UDP 侧:uip_udp_conn 与 uip_udp_send 的最小例子

UDP 路径比 TCP 短得多,因为没有连接状态机,uip_udp_conn只是一条「往哪发」的路由记录。用固定远端地址的写法最省事:

static struct uip_udp_conn *uc; void udp_appcall(void) { if(uip_newdata()) { /* 载荷在 uip_appdata,长度 uip_datalen() */ handle_packet(uip_appdata, uip_datalen()); /* 回给包的来源,地址已经填在 uc 里 */ uip_udp_send((void *)reply_buf, reply_len); } } void udp_init(void) { uip_ipaddr_t rip; uip_ipaddr(rip, 192, 168, 0, 20); /* 远端服务器地址 */ uc = uip_udp_new(&rip, HTONS(5000)); /* 远端端口 */ if(uc != NULL) { uip_udp_bind(uc, HTONS(6000)); /* 本地端口,不绑也行 */ } }

uip_udp_new()返回NULL表示 UDP 连接表满了,必须判断,否则后面uip_udp_send()会解引用空指针。还有一点:UDP 的校验和在 uIP 里是可选的,UIP_CONF_UDP_CHECKSUMS关掉能省一点代码,但如果对端是要求严格校验的实现,关了会被静默丢弃,抓包只能看到「发出去了但没回应」。

5. uIP 移植翻车排查:四类故障从抓包里怎么看、改哪个宏

协议栈跑不起来的时候,最没用的做法是在回调里加printf一路打印。uIP 的结构决定了它的故障几乎都能从三样东西上看出来:抓包文件、连接表状态、以及发送计数。下面四条是我和同事踩过、并且有明确复现路径的典型故障。

5.1 能 ping 通但 TCP 握手不回包

现象:抓包能看到本机正确回应 ICMP echo,说明收帧、IP 层、校验和都没问题;但对端发来的 SYN 没有任何 SYN-ACK 回应,客户端一直重试直到超时。

原因:最常见的是uip_listen()根本没被调用,或者调用在uip_init()之前(连接表和监听表被随后的uip_init()清空了)。第二常见的是端口字节序:uip_listen(80)写成了主机序,实际监听的是 20480 端口。第三种是UIP_LISTENPORTS设为 0,或者监听表已满,此时uip_listen()内部静默返回,不报错。

解决:在uip_init()之后立刻调用uip_listen(HTONS(端口)),并在旁边加一句编译期检查#if UIP_LISTENPORTS == 0 #error ... #endif。抓包里确认 SYN 的目的端口和代码里写的一致,这一条能排掉一半的「握手失败」。

5.2 小包正常、超过一百多字节必断

现象:发短请求能收到完整响应,请求一长,连接就停在某一方不再推进,最后超时断开。抓包里能看到某一侧发出的报文长度卡在同一个数值,反复重传。

原因:UIP_BUFSIZE小于「IP 头 + TCP 头 + 对端一次发来的数据量」,超出的部分被丢弃但序号已经推进,于是接收窗口错乱。另一个成因是 MSS 配置得比缓冲区能容纳的还大,本机发出的段在中途被分片,而部分链路上的中间设备对分片处理不友好。

解决:按 3.1 的不等式重新算。先确保UIP_BUFSIZE >= 40 + 预期最大单次载荷,再把 MSS 压到UIP_BUFSIZE - 40以下。如果对端是浏览器或通用 HTTP 客户端,它们会主动分片发送,你要做的是保证接收方向能容纳单个分片——把缓冲区提到 500 以上通常就能解决。实在要给小缓冲区,就把接收窗口也调小,让对端少发点,用吞吐换内存。

5.3 第一次连接正常,第二次永远连不上

现象:上电后第一次请求成功,第二次开始客户端一直重连失败,等一两分钟后又恢复正常。抓包能看到 SYN 到达但本机没有回应,或者回应了 RST。

原因:连接表被占满。主动关闭后连接进入 TIME_WAIT,槽位要等超时才会释放,UIP_CONNS = 1时这段时间内所有新 SYN 都无处安放。有些分支的 TIME_WAIT 时长是几十秒量级,正好对上「等一会儿又好了」的观感。

解决:UIP_CONNS至少给 2,给 3 更稳。同时检查关闭方向——如果本机先uip_close(),进入的是主动关闭,TIME_WAIT 在本机;如果让对端先关,本机进被动关闭,槽位释放得快得多。在uip_acked()里立刻uip_close()是一种常见写法,但它把 TIME_WAIT 留给了自己,短连接高频请求的场景下这就是个陷阱。

5.4 校验和算错导致的「全丢」

现象:抓包能看到本机发出的帧,格式完全正确,但对面一个回应都没有。或者反过来,对面发来的包本机全部丢弃,表现为 ping 不通但对端 ARP 解析正常。

原因:UIP_CONF_BYTE_ORDER写反、uip_chksum里忘了折叠进位、奇数字节没补零,或者伪首部里的长度字段用了主机序。这类错误在小端 MCU 上尤其容易发生,因为读uint16_t和网络序是相反的。

解决:回 3.2 做单测。用固定 IP 头验证一次,再用「校验和字段置零后整体反码和应为 0xFFFF」的性质验证一次。UDP 校验和是可选的,出问题时先把UIP_CONF_UDP_CHECKSUMS关掉,如果关掉就通了,问题一定在伪首部或长度字段,不用再怀疑别的地方。

现象首查项次要检查
ping 通、TCP 不回uip_listen是否调用端口是否用HTONS
小包通、大包断UIP_BUFSIZEMSS 是否超限
第二次连接失败UIP_CONNS是否 >= 2谁先发的 FIN
帧发出无回应字节序宏校验和单测

6. 把协议栈压进 2KB:裁剪顺序、验证命令与一份上板清单

资源不够的时候,裁剪顺序比裁剪本身更重要。先关功能、再减表项、最后才动缓冲区,因为前两步只影响代码体积和并发能力,动缓冲区会直接影响能不能正常通信。下面这个顺序是我按踩坑次数排出来的:

顺序裁剪项大概省下代价
1关 UDP(UIP_CONF_UDP 0)代码 1KB 量级用不了 DNS、NTP
2关 ARP,改点对点链路代码数百字节只能在同链路通信
3UIP_CONNS从 4 降到 2RAM 每条约 36 字节并发能力下降
4UIP_LISTENPORTS降到 1RAM 2 字节只能开一个服务端口
5关uip_split代码数百字节大段发送不再拆小
6缩缓冲区到 200 以下RAM 几十到几百字节MSS 随之下降,吞吐变差

前两步收益最大且风险最低,第 6 步风险最高,放到最后。多数卡在「RAM 差几百字节」的项目,走完前四步就够了,根本不需要动缓冲区。

验证方法不能靠「看起来能跑」。第一步用sizeof把真实占用打出来,在启动时通过串口打印,一次性确认:

/* 上板第一件事:把真实占用打出来,别靠估算 */ printf("conn=%u B, conns=%u, buf=%u\n", (unsigned)sizeof(struct uip_conn), (unsigned)(sizeof(struct uip_conn) * UIP_CONNS), (unsigned)(UIP_BUFSIZE + 2)); printf("listen=%u B\n", (unsigned)(sizeof(uip_listenports)));

第二步做三项线上验证。抓一次完整的握手加关闭,确认 FIN 的两个方向都出现了;连续发 100 次请求,确认没有一次失败(这一项专治 5.3);用大一点的请求跑一次,确认没有卡在某个长度(专治 5.2)。三项都过,基本可以认为移植稳了。

最后留一个小技巧:把uip_periodic()的调用周期做成可配置的编译宏,弱网现场调大一倍,局域网现场调小一半,比改代码重新烧写省事得多。协议栈这种东西,黑匣子属性很强,与其在回调里加日志,不如把关键状态通过一个调试端口暴露出去,抓包能看到的东西比任何日志都可信。

我自己头几年移植的时候,总是先怀疑协议栈源码有 bug,后来才明白,九成以上的问题都在自己写的那几十行胶合代码和uipopt.h那几个数字里。每次移植我都会先老老实实把那三个不等式算一遍,再动手写驱动,这大概是省下最多时间的一个习惯。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进