简介:面向嵌入式网络开发初学者与需要为资源受限设备接入网络的工程师,这套uIP协议栈源码是理解轻量级TCP/IP实现机制的实用样本。包内共304个文件,其中29个c源文件与34个h头文件构成协议主体,183个html、3个css及若干png、gif图片组成配套文档与网页示例,另含makefile、doxyfile等构建与文档配置,压缩包约1.55MB,已有680人学习下载。源码按功能拆分得比较清楚:uip.c、uip_arp.c、uip-fw.c分别对应协议主流程、ARP解析与报文转发,tcp_input()、udp_input()等处理函数独立成篇;dhcpc、resolv、webclient、httpd、telnetd、smtp则给出可直接参考的网络服务范例,psock与protosocket展示了BSD套接字接口的裁剪方案。读者可据此梳理IPv4、ICMP、TCP、UDP在极小内存下的组织方式,理解事件驱动模型与紧凑内存管理策略,并裁剪移植到传感器节点、嵌入式路由或智能家居设备中,作为协议学习与二次开发的起点。
1. 嵌入式协议栈 uIP 源码:几百字节 RAM 里塞下一个 TCP/IP
前年帮某公司评估一块采集板,8 位 MCU、2KB RAM、32KB Flash,需求是在上面跑一个二十来行的网页配置页,外加一个能把现场数据吐出去的 TCP 服务。更完整的同类栈塞进去,光 RAM 就吃掉一半,留给应用只剩几百字节,稍微多点缓冲就会内存溢出。换成嵌入式协议栈 uIP 源码那一套,编译出来代码段三千多字节,所有连接状态加缓冲区一共占 1.2KB 左右,网页和 TCP 服务都跑起来了。uIP 的做法很极端:它不提供 socket,不提供阻塞收发,甚至不保证你能同时“持有”一个连接——所有回调都是在“当前连接”这一个全局上下文里执行。也正因为如此,读它的源码不是学术兴趣,而是想搞懂“一个 TCP/IP 到底哪些部分是必需的”。适合谁:手里有 8/16 位机、RAM 按 KB 算、又必须能 ping 通、能被浏览器打开页面的场景。不适合的人也很明确:要 TLS、要几十路并发、要大吞吐的,别在这上面耗时间。
2. uIP 的骨架:先认清哪五个文件在干活
打开源码包,目录结构朴素得有点让人失望——十几个 .c/.h 文件平铺在一起,没有 HAL,没有 BSP,没有 CMake。新手第一反应是“这是不是缺文件”,其实它就是这个体量:核心逻辑全部集中在 uip.c 一个文件里,读通了那一个,剩下的都是外围。
2.1 源码包里真正需要动的文件只有三个
先把文件按“读”和“改”两类分开,不然很容易一上来就陷进 uip.c 的四千行里出不来。
| 文件 | 职责 | 移植时是否要动 |
|---|---|---|
| uip.h | 连接结构体、状态宏、应用层接口(uip_send / uip_listen) | 不改 |
| uip.c | IP 头解析、ICMP、TCP 状态机、UDP 分发,全在这里 | 不改 |
| uip_arp.c | ARP 表、以太网头封装、ARP 请求应答 | 不改 |
| uip_arch.c | 32 位加法与校验和,可选汇编加速 | 视 CPU 而定 |
| uip-conf.h | 全部配置宏,连接数、缓冲大小、重传次数 | 必改 |
| uipopt.h | 配置宏的默认值兜底 | 不改 |
| uip_arch.h | 架构相关函数的声明 | 不改 |
我的习惯是先看 uip-conf.h 和 uipopt.h,把“这个栈被裁剪成什么样”摸清楚,再回头读 uip.c。因为 uip.c 里大量#if UIP_ACTIVE_OPEN、#if UIP_UDP的条件编译,配置没搞明白,读到的代码路径可能跟你实际编出来的完全不是一条。
提示:uipopt.h 里的每一个
#ifndef UIP_...都是默认值。uip-conf.h 应该在包含 uipopt.h 之前被包含,否则你改的宏会被默认值覆盖——这个顺序在 uip.h 顶部,别动它。
2.2 uip_conn 与那张连接表:uIP 为什么只认“当前连接”
uIP 最反直觉的设计在这儿。它确实维护了一个uip_conns[UIP_CONNS]数组,但应用层永远拿不到数组下标,拿到的只有一个全局指针uip_conn,指向“本次回调正在处理的连接”。这是它省 RAM 和栈的根本原因:不需要为每个连接保存一份调用栈。
/* uip.h 中连接结构的精简版,字段含义见下方说明 */ struct uip_conn { uip_ipaddr_t ripaddr; /* 对端 IP,4 字节 */ u16_t lport; /* 本地端口,网络字节序 */ u16_t rport; /* 对端端口,网络字节序 */ u8_t rcv_nxt[4]; /* 期待收到的下一个序号 */ u8_t snd_nxt[4]; /* 已发出的下一个序号 */ u16_t len; /* 待重传数据长度,0 表示发送队列空 */ u16_t mss; /* 本连接协商出的最大段长 */ u8_t state; /* UIP_ESTABLISHED / UIP_SYN_RCVD ... */ u8_t timer; /* 重传倒计时,单位由 uip_periodic 节拍决定 */ u8_t nrtx; /* 已重传次数 */ u8_t appstate[UIP_APPSTATE_SIZE]; /* 留给应用层存自己的状态 */ };appstate这个字段是整套设计的关键补偿。因为没有独立的 socket 上下文,应用层想在两次回调之间记住“我刚才发到第几行了”,只能借这块空间。UIP_APPSTATE_SIZE在 uip-conf.h 里定义,默认好像是 0,不定义的话uip_conn->appstate根本不存在,编译直接报错。这就是第一个血泪经验:只要你的应用回调不是“来一包回一包”的无状态逻辑,就得先把UIP_APPSTATE_SIZE开出来。
len和mss的关系也要提前理清。uIP 的发送缓冲区就是uip_buf里的应用数据区,uip_send(data, len)并不会拷贝,它只记住指针和长度(uip_sappdata、uip_slen),等回调返回后由 uip_process 在原地组 TCP 头。所以那个data指针指向的内存,在回调返回之前必须一直有效。很多人写成“把局部数组传给 uip_send”,栈一退就翻车。
2.3 uip_process 一次执行走完的路径
所有收包处理最终都汇到一个函数:uip_process(u8_t flag)。uip_input()只是uip_process(UIP_DATA)的包装,uip_periodic(conn)是uip_process(UIP_TIMER)的包装。这条路径建议拿张纸画一遍,比读十遍代码管用:
- 根据
uip_buf和uip_len,跳过UIP_LLH_LEN字节的链路层头,定位 IP 头; - 校验 IP 头长度、版本、目的地址是否本机;
- 按协议号分流:ICMP 走回显应答,TCP 走连接查表,UDP 走 UDP 端口表;
- TCP 查表时用
(rport, lport)四元组匹配uip_conns[],匹配不到且是 SYN,再看uip_listenports[]里有没有人在监听这个端口; - 命中后把
uip_conn指向该表项,根据报文内容置位uip_flags(UIP_NEWDATA / UIP_ACKDATA / UIP_REXMIT / UIP_CLOSED …); - 调用
UIP_APPCALL(); - 回调返回后,看
uip_slen是否有值,有就组 TCP 头、算校验和、填uip_len; - 函数返回,主循环负责把
uip_len字节发出去。
第 6 步到第 7 步之间是“应用层唯一能插手的地方”,也是所有坑的源头:回调里做的事必须是可重入的、非阻塞的、并且不能假设自己独占 CPU。
3. 把 uIP 跑起来:网卡对接、ARP 分流和那个 500ms 节拍
原理看完,落地就三件事:让uip_buf和网卡驱动对上、写对主循环里的协议分流、把定时器节拍调准。这三件事任何一件错了,现象都是“ping 不通”,但原因天差地别,所以我把每一步的判断依据都写清楚。
3.1 uip_buf 的头部预留:驱动第一行就得想清楚
先看配置。以太网场景下链路层头是 14 字节,这个值必须让协议栈知道:
/* uip-conf.h 片段:链路层头长度 */ #define UIP_CONF_LLH_LEN 14 /* 以太网:目的MAC6 + 源MAC6 + 类型2 */ #define UIP_CONF_BUFFER_SIZE 400 /* uip_buf 的总大小,按最大帧定 */然后是驱动收包。这一行写错,后面全白搭:
/* 网卡收包:整帧(含 14 字节以太头)拷进 uip_buf 起始处 */ uint16_t nic_read(void) { uint16_t len; /* uip_buf 是全帧缓冲,以太头在 [0..13],IP 头在 [14..] */ len = nic_recv_frame(uip_buf, UIP_BUFSIZE); if (len < 14) { /* 残帧直接丢,不然后面解析越界 */ return 0; } return len; } /* 网卡发包:uip_len 已经是含以太头的全长 */ void nic_send(void) { nic_send_frame(uip_buf, uip_len); }逻辑说明:uIP 全程只用一个缓冲区uip_buf,收发共用。收的时候,驱动把包含 14 字节以太头的完整帧拷进去,uip_len置为整帧长度;协议栈内部解析时用UIP_LLH_LEN跳过头部定位 IP。发的时候,uip_arp_out()会在 IP 头前面补上 14 字节以太头(这一步是在uip_buf内部往前挪,不是重新申请内存),然后把uip_len加上 14,驱动拿到的是完整帧。
参数说明:UIP_CONF_BUFFER_SIZE决定单帧上限,以太网 MTU 1500 时至少要 1514;但 RAM 紧张的项目通常把 MSS 压到 200~500,这里同步压到 400 就够,超过的包靠 IP 分片或 TCP 分段解决(uIP 默认不支持重组,见第 4 章)。
注意:如果你图省事,让驱动把 IP 包(不含以太头)拷到
uip_buf[0],uip_arp_out()会往uip_buf[-14]写,轻则覆盖全局变量,重则直接 HardFault。这个后悔药没得吃,改回来就行。
3.2 主循环里那二十行:ARP 与 IP 的分流
整个协议栈的“操作系统”就是这一段:
/* 主循环:收包处理 + 定时事件,无 RTOS 时放在 main 的 while(1) 里 */ int main(void) { uip_ipaddr_t ipaddr; int i; clock_time_t periodic_timer, arp_timer; nic_init(); uip_init(); /* 清空连接表和监听端口表 */ uip_arp_init(); /* 清空 ARP 表 */ /* 静态 IP:地址、掩码、网关,顺序固定,别写反 */ uip_ipaddr(ipaddr, 192, 168, 1, 10); uip_sethostaddr(ipaddr); uip_ipaddr(ipaddr, 255, 255, 255, 0); uip_setnetmask(ipaddr); uip_ipaddr(ipaddr, 192, 168, 1, 1); uip_setdraddr(ipaddr); uip_listen(HTONS(80)); /* 监听端口必须走 HTONS,网络序 */ clock_init(); timer_set(&periodic_timer, CLOCK_SECOND / 2); /* 500ms 一次 */ timer_set(&arp_timer, CLOCK_SECOND * 10); /* 10s 老化 ARP */ while (1) { uip_len = nic_read(); if (uip_len > 0) { if (BUF->type == HTONS(UIP_ETHTYPE_IP)) { uip_arp_ipin(); /* 顺手把对端 MAC 记进 ARP 表,能省一次询问 */ uip_input(); /* 走 TCP/ICMP/UDP 状态机 */ if (uip_len > 0) { /* 有回包才发,没回包 uip_len 是 0 */ uip_arp_out(); /* 补以太头 */ nic_send(); } } else if (BUF->type == HTONS(UIP_ETHTYPE_ARP)) { uip_arp_arpin(); /* ARP 请求自动应答,ARP 应答更新表 */ if (uip_len > 0) { nic_send(); } } } if (timer_expired(&periodic_timer)) { timer_reset(&periodic_timer); for (i = 0; i < UIP_CONNS; i++) { uip_periodic(i); /* 驱动该连接的重传、超时、关闭 */ if (uip_len > 0) { uip_arp_out(); nic_send(); } } if (timer_expired(&arp_timer)) { /* ARP 表项老化 */ timer_reset(&arp_timer); uip_arp_timer(); } } } }逻辑说明:这段代码有三条独立的处理路径。第一条是 IP 包,走uip_input(),处理完必须检查uip_len,因为 uip_input 内部可能在回调里没有调用uip_send,此时uip_len被置 0,表示“无应答”;第二条是 ARP 包,走uip_arp_arpin(),它对 ARP 请求生成应答、对 ARP 应答更新本地表;第三条是定时路径,uip_periodic逐连接推进,这是 TCP 重传、FIN 超时、SYN 超时唯一的驱动源。
参数说明:CLOCK_SECOND是你的时钟节拍宏,必须和实际定时器频率一致,否则后面所有超时时间都会等比例偏移。UIP_CONNS是编译期常量,循环次数固定,不要写成sizeof之类,因为 uIP 的连接表长度就是它。
3.3 那个 500ms 节拍:为什么调错会让你误判成“网络不行”
uip_conn.timer的单位不是毫秒,是“调用了几次uip_periodic”。uip.c 里的逻辑是:每次uip_periodic进来,timer--,减到 0 就重传、nrtx++、timer重置为UIP_RTO。而UIP_RTO默认是 3。
所以实际的第一次重传等待时间 =UIP_RTO × periodic 周期。周期 500ms 时就是 1.5 秒,跟 RFC 建议接近;如果你把周期写成 50ms(想着“响应更快”),重传间隔就变成 150ms,局域网里没问题,一到有丢包的现场就是疯狂重传、带宽打满,看起来像“协议栈有 bug”。
反过来的坑更隐蔽:有人为了省电把主循环节拍改成 1 秒甚至更久,三次握手 SYN 超时会被拉长,浏览器那边早就断了,板子还在傻等。我一般的取值是 100ms 到 500ms,实测 500ms 最省 CPU,对交互式网页也够用。
3.4 应用回调:把 uip_appcall 写成一个纯状态机
回调是这个栈唯一暴露给你的入口,它的写法决定了整个应用能不能稳。下面这个是一个最小可用的 TCP 回显 + 首包问候,能直接改:
/* uip-conf.h 里挂接应用回调,并给每个连接留 2 字节应用状态 */ #define UIP_APPCALL app_call #define UIP_APPSTATE_SIZE 2 /* app_call 由 uip_process 在中断/主循环上下文里调用,不能阻塞 */ void app_call(void) { /* 连接刚建立:uip_connected() 只在握手完成那一次为真 */ if (uip_connected()) { uip_conn->appstate[0] = 0; /* 0 = 空闲 */ uip_send("READY\r\n", 7); /* 问候语,字符串常量常驻 Flash */ return; } /* 收到新数据:uip_appdata 指向缓冲区里的载荷,长度 uip_datalen() */ if (uip_newdata()) { if (uip_datalen() > 0) { /* 原地回显:直接把收到的指针传回去,零拷贝 */ uip_send(uip_appdata, uip_datalen()); uip_conn->appstate[0] = 1; /* 标记“有未确认数据” */ } return; } /* 对端确认了:清状态,可以发下一段 */ if (uip_acked()) { uip_conn->appstate[0] = 0; return; } /* 需要重传:uip_rexmit() 为真时必须把上次的数据重新交出去 */ if (uip_rexmit()) { uip_send("RETRY\r\n", 7); return; } /* 连接结束的三条路径,一个都不能漏 */ if (uip_closed() || uip_aborted() || uip_timedout()) { uip_conn->appstate[0] = 0; return; } }逻辑说明:回调里没有循环、没有延时、没有动态内存。uip_send只是登记“我要发这些字节”,真正的组包在回调返回之后。uip_appdata指向的是uip_buf内部,回显时直接把它传回uip_send,等于数据原地不动,只改了 TCP 头——这就是 uIP 所谓的零拷贝,代价是应用层不能对这块内存做任何异步持有。
参数说明:UIP_APPSTATE_SIZE设 2 是留余量,实际只用了appstate[0];这个值每加 1,每个连接多占 1 字节 RAM,UIP_CONNS是 8 的时候就是 8 字节,不心疼但也没必要浪费。uip_datalen()是个宏,取的是uip_len - 头部长度,在回调里才有效,出了回调就过期。
4. 排查:五个让 uIP 停在半路的坑
这些坑我基本都亲手踩过一遍,现象都是“网络不通”,但根因分布在驱动、配置、时序三个层面,按现象往下查比按代码顺序读快得多。
4.1 ping 得通,TCP 握手不回 SYN-ACK
现象:同一网段能稳定 ping,用工具连 80 端口一直超时,抓包能看到 SYN 进来,没有 SYN-ACK 出去。原因通常是uip_listen()的参数没走HTONS,端口在表里存成了主机序,查表时永远匹配不上;或者UIP_LISTENPORTS配成了 0,监听表长度为 0,uip_listen直接越界写。解决:确认uip_listen(HTONS(80))带转换,把UIP_LISTENPORTS至少设成 1(实际用几个就设几个,多一个都占 RAM),并在uip_listen之后打印一次uip_listenports[0]的值确认是0x0050而不是0x5000。
4.2 第一包数据收到后连接就断
现象:握手正常,客户端发第一条请求,板子回了一包,然后连接立刻被 RST,或者干脆没后续。原因多半是uip_len在回包之后没有被正确重置,或者应用层把uip_appdata的指针存进了全局变量,等下次回调再去读,那块内存早被下一帧覆盖了。还有一种:uip_send传的是局部数组,回调返回后栈被复用,发出去的是垃圾数据,对端校验不过直接 RST。解决:所有要发出去的内容,要么是字符串常量,要么是uip_appdata本身,要么拷到静态缓冲区再传;回调返回前不要对uip_appdata做任何延迟使用。
4.3 回调里做了 50ms 的延时,然后开始疯狂重传
现象:一发数据就重传,抓包看到同一段数据被重复发送七八次,最后连接超时。原因是在app_call里写了delay_ms(50)去等 EEPROM 写完,主循环被卡住,uip_periodic推进不了,TCP 定时器停摆,对端超时后重发。解决:回调里只做“登记”,把耗时动作丢给主循环里的状态机,用一个appstate标志位串起来。这也是 8 位机上最容易被忽略的一条:uIP 没有中断上下文保护,所有回调都是“借用主循环的时间”,你占用多久,协议栈就停多久。
4.4 校验和算错,或者在某些核上直接 HardFault
现象:发出的包对端全部丢弃,或者程序跑到uip_arch_checksum就异常。原因是自己实现了uip_arch_checksum但忘了最后取反,或者用uint16_t*强转非对齐地址做 16 位读取。前者是纯逻辑错误,后者在只支持对齐访问的核上是硬件异常。解决:标准实现返回的是~sum,调用方拿到就直接用,不要二次取反;如果用 DMA 或字节流喂数据,先把地址对齐到 2 字节再转指针,或者干脆用逐字节累加的慢速版本,几 KB 的包在几十 MHz 的 MCU 上也只多花几十微秒。
4.5 ARP 表查不到,包发出去是广播或干脆丢掉
现象:能收不能发,或者发出去的目的 MAC 全 F。原因是静态 IP、掩码、网关三者有一个没设或设错,导致目的地址和本机不在同一网段时uip_arp_out找不到表项,而 uIP 在这种情况下不会主动发 ARP 请求,直接放弃。解决:三个地址按顺序设全,尤其是uip_setnetmask经常被漏;跨网段访问时确认uip_setdraddr设的是真实网关,并且网关设备确实回了 ARP。调试时可以在uip_arp_out前后打印uip_len,为 0 就说明没找到表项。
5. uip-conf.h 里真正该动的七个宏
配置这一块最容易被两种极端带偏:一种是全用默认值,结果 RAM 不够;另一种是看到宏就往上调,调完 Flash 爆了还不知道是谁吃的。下面这几个是按“改动收益 / 风险”排序的,前三个几乎必调。
5.1 连接数与监听端口:不是越大越好
UIP_CONNS决定连接表长度,每个表项大约 30 字节(4 字节 IP + 2+2 端口 + 4+4 序号 + 若干标志位),8 路就是 240 字节,对 2KB RAM 的片子已经是十分之一了。UIP_LISTENPORTS每个表项 2 字节,很便宜,但也别乱开。经验值:Web 配置页 + 一个数据服务,UIP_CONNS=4、UIP_LISTENPORTS=2足够;如果是“一次只服务一个客户端”的采集器,UIP_CONNS=2都嫌多。
| 宏 | 典型值 | 每个单位开销 | 定值依据 |
|---|---|---|---|
| UIP_CONNS | 4 | 约 30 字节 RAM | 同时在线的客户端峰值 + 1 |
| UIP_LISTENPORTS | 2 | 2 字节 RAM | 实际监听的端口数,不多开 |
| UIP_BUFSIZE | 400 | 每加 1 加 1 字节 | 单帧最大长度,含链路层头 |
| UIP_CONF_RECEIVE_WINDOW | 200 | 影响通告窗口 | 不超过 BUFSIZE 减 40 |
| UIP_CONF_TCP_MSS | 200 | 影响分段大小 | 不超过接收窗口 |
| UIP_RTO | 3 | 无 | 单位是 periodic 节拍数 |
| UIP_MAXRTX | 8 | 无 | 超过就断链,别设太大 |
5.2 接收窗口与 MSS:把吞吐和 RAM 摊开来算
这两个值是绑死的:接收窗口 ≤ UIP_BUFSIZE - 40(40 是 IP + TCP 头的最大长度),MSS ≤ 接收窗口。设大了没意义,因为缓冲区就那么大,通告出去的窗口超过实际能存的就是自欺欺人,对端按窗口发过来你直接丢包,反而触发重传。设小了吞吐上不去,200 字节窗口在 10Mbps 半双工链路上大约能跑到几十 KB/s,对采集类应用完全够。
/* uip-conf.h 实操片段,注释里写清楚每个值的推导 */ #define UIP_CONF_BUFFER_SIZE 400 /* 整帧缓冲,含 14 字节以太头 */ #define UIP_CONF_RECEIVE_WINDOW 200 /* 400 - 40(头)+ 余量,实际取 200 更保守 */ #define UIP_CONF_TCP_MSS 200 /* 等于窗口,避免对端发来要分片 */ #define UIP_CONF_MAX_CONNECTIONS 4 #define UIP_CONF_MAX_LISTENPORTS 2 #define UIP_CONF_RTO 3 /* 3 × 500ms = 1.5s,接近 RFC 建议 */ #define UIP_CONF_MAX_RETRIES 8 /* 约 12 秒后断链,够现场网络抖一抖 */逻辑说明:这段配置的目标是把总 RAM 控制在 1KB 出头的量级。uip_buf400 字节 + 连接表 4×30=120 字节 + ARP 表(通常 4~8 项,每项 10 字节左右)+ 少量全局变量。参数说明:UIP_CONF_RTO调大能容忍更差的链路,但断链判定也变慢;UIP_CONF_MAX_RETRIES调小会让偶发丢包直接断连,现场调试时很容易误判成“协议栈不稳”,实际是重传次数不够。
5.3 关掉用不上的功能,Flash 能省一半
uIP 的条件编译做得比较彻底,UIP_ACTIVE_OPEN关掉能省掉主动连接那一整段(uip_connect及其状态处理);UIP_UDP关掉省掉 UDP 分发;UIP_REASSEMBLY关掉省掉 IP 分片重组(这个功能默认就是关的,别乱开,它对缓冲区要求高)。还有一个容易被忽略的:如果 CPU 有 32 位指令,把UIP_ARCH_ADD32打开,让 32 位序号累加走硬件,能省掉一坨软件进位逻辑。
关掉之后一定要重新编译看 map 文件,确认.text真的小了。有条件的话,每次改动配置都记录一次代码段大小,形成自己的对照表——这个习惯比任何文档都靠谱。
6. 进阶:拿三个动作验证它真的能上现场
配置调完、ping 通、网页能打开,只说明“实验室里没问题”。真正决定这套源码能不能用的,是几个刻意制造的异常场景。我一般固定做三个动作,每次改完配置都跑一遍。
第一个动作是并发打。用两台机器同时开多个连接去打同一个端口,连接数超过UIP_CONNS时,观察新连接是被拒还是把老连接挤掉。uIP 的行为是“满了就不接”,表现出来的现象是新客户端一直重连,老客户端正常,这属于预期;如果出现老连接也断,那说明你的回调里有跨连接的状态污染,八成是用了全局变量存uip_conn之外的东西。
第二个动作是拔插网线。在传输过程中把网线拔掉 10 秒再插回,观察重传次数和恢复时间。正常情况下,UIP_MAXRTX次重传耗尽后连接被uip_timedout()清理,插回后客户端重连成功。如果插回后板子再也不响应任何请求,重点查uip_arp_timer有没有被调用——ARP 表项老化不掉,新来的 ARP 请求又填不进去,整张表就僵住了。
第三个动作是慢速客户端。用一个每 2 秒才发一个字节的脚本连上来,看板上会不会误判超时断开。这个场景专门用来验UIP_RTO和 periodic 节拍是否匹配,很多“偶发断连”的现场问题都是这个组合调错导致的。
下面这个表是我自己的验收清单,改配置后逐项打勾:
| 验证项 | 通过标准 | 失败时优先看 |
|---|---|---|
| 并发超过连接数 | 老连接不受影响,新连接被拒 | 回调里是否有跨连接全局状态 |
| 断网 10 秒恢复 | 自动断链后能重连 | ARP 老化、MAXRTX |
| 慢速客户端 | 不断连,正常收包 | RTO 与 periodic 节拍 |
| 大包传输 | 不发分片,靠 MSS 切 | MSS 与接收窗口 |
| 长时间空闲 | 内存不涨,连接可被回收 | TIME_WAIT 超时配置 |
最后一个技巧,也是我觉得最值钱的一条:uip_appdata那块内存是“借”来的,不是“给”你的。所有需要跨回调保存的数据,必须在回调里当场拷走,拷到你自己定义的静态缓冲区里,长度不超过UIP_CONF_BUFFER_SIZE。我早期的做法是给每个连接在appstate后面挂一个指针,指向一块全局缓存区,回调里 memcpy 过去,主循环再慢慢处理。这样既保住了零拷贝的收包路径,又把“耗时处理”挪出了回调。这套写法在一个只有 2KB RAM 的板子上跑了两年多没出过内存问题——代价是静态缓冲区只有 128 字节,超过就得自己拆包,属于典型的用空间换确定性。踩过的坑是:曾经为了省这 128 字节,直接在回调里解析协议,结果一个畸形报文让解析函数读了越界地址,板子当场重启,抓了两天才定位到。从那以后我的习惯是:回调里只做“搬”和“标记”,任何解析逻辑都放到主循环,宁可多占 128 字节,也不让协议栈的执行时间被应用逻辑拉长。希望帮到你。
本文还有配套的精品资源,点击获取