一、裸机上加网络,为什么大多数人是移植协议栈而不是自己写
我一开始有过一个很天真的想法:控制报文只有几十个字节,收发逻辑也不复杂, 那是不是可以自己写一套“够用的”以太网收发,绕开协议栈这个庞然大物? 真正算了一遍工作量之后我放弃了这个念头。
自己写意味着要处理的东西至少包括:ARP 的请求与应答、IP 头的校验和与分片重组、 ICMP 的至少一个子集(不然 ping 不通,现场排查会非常痛苦)、UDP 的伪首部校验和, 以及如果要用 TCP,那还要把连接状态机、滑动窗口、超时重传、慢启动、MSS 协商、 延迟确认这一整套全部实现一遍。这些不是“写出来能跑”就够的, 它们的价值恰恰在于各种异常分支——重复包、乱序包、窗口为 0、对端半开连接—— 而这些分支只有在现场跑上几个月才会暴露出来。
相比之下,移植的成本是一次性的,而且大部分是“照着做”的工作量。 我用的是 LwIP 2.1.2:纯 C、可裁剪、内存占用可控、资料多; 上面配 FreeRTOS(两个工程分别是 V9.0.0 和 V10.3.1)。 LwIP 采用修改版 BSD 三条款许可,FreeRTOS 采用 MIT 许可, 都属于可以放进商业固件的宽松许可——这一点在选型时是要先确认的。
需要说明的是:我这两个工程的网络框架部分移植自第三方的开源例程, 它把网卡驱动、LwIP 源码、FreeRTOS 的适配层和几个示例任务打包在一起。 本文只描述这套框架的结构和我自己在上面做的改动, 不展示它的代码,也不把它当成我自己的原创。
至于“这个系统到底该不该用以太网”,是另一个更靠前的选择题。 我在 《多板卡系统的通信分层:什么时候用以太网,什么时候用 RS485》 里做过完整对比,这里不再重复;本文只讨论“已经决定用以太网了,协议栈该怎么用”。
二、先把三块地基铺好:驱动接口、内存缓冲、OS 适配层
协议栈跑不起来,绝大多数时候不是 LwIP 本身的问题,而是这三块地基里有一块没铺平。 它们的分工可以用一句话概括:驱动接口负责“帧进帧出”,内存缓冲负责“包住在哪”, OS 适配层负责“谁来加锁、谁来唤醒”。
地基一:网卡驱动接口(ethernetif)
LwIP 用一个网卡对象(netif)来表示一块网卡,驱动要做的事情就是给这个对象
挂上几个回调:初始化硬件、把一帧发出去、在收到一帧时把它交给协议栈。
这里有一条约定我认为是整个移植里最重要的:
中断服务函数里不要做解析,只做投递。
收到帧之后,中断应该只是把数据挂进接收缓冲、然后通过一个信号量或邮箱
唤醒协议栈线程,剩下的解析、查表、回包全部在协议栈线程里做。
我把这条约定违反过一次:为了让“收到就立刻回”的延迟看起来更低, 我在中断里直接调用了发送路径,结果是在网络流量大的时候偶发死机。 原因是发送路径内部会去申请缓冲、可能触发阻塞,而中断上下文里不允许阻塞。
地基二:内存与缓冲——pbuf、池与堆
LwIP 里所有的包都用 pbuf 表示,而 pbuf 是链式的:
一个大包可以由多个固定大小的小块串起来。这个设计直接决定了配置项该怎么理解:
PBUF_POOL:固定大小的池,分配与释放都是常数时间,中断里也能安全分配, 所以接收路径优先用它。PBUF_RAM:从协议栈自己的堆里分配,大小任意,但分配可能失败、也可能产生碎片, 一般用在发送路径上。- 其余类型(引用型、只读型)主要用于“不拷贝数据、只引用”的场景,我在这两个工程里没有直接用到。
池不够用的时候,协议栈的行为是丢包,而不是等待。 这一点很重要:它意味着“内存配小了”的表现不是变慢,而是偶发丢包, 而且往往只在网络有突发流量的时候出现,非常难查。
地基三:操作系统适配层
LwIP 在设计上把一个 RTOS 需要提供的东西抽象成了几个接口:信号量、邮箱、 互斥量和线程创建。在 FreeRTOS 上,这一层就是一个几百行的适配文件, 而它做的事可以用一句话说清:把“应用线程想操作协议栈”变成“给协议栈线程发一条消息, 然后等它做完”。
理解了这一层,很多“奇怪的现象”就有解释了。比如:为什么在回调里不能再调用会阻塞的 socket 接口?因为回调本身就跑在协议栈线程里,再阻塞就等于把协议栈自己冻住了。 再比如:为什么协议栈线程的优先级一般不建议设得比应用任务低太多? 因为它要负责收发和定时器,它被饿住的时候,表现出来的是“网络整体变慢”。
| 地基 | 对接点 | 没铺好时的典型表现 |
|---|---|---|
| 网卡驱动接口 | netif 的初始化 / 发送 / 接收回调 |
ping 不通、能收不能发、大流量偶发死机(在中断里做了不该做的事) |
| 内存与缓冲 | pbuf 池数量、单块大小、协议栈堆 | 能通但偶发丢包;连接一多就建不起来;表现为“网络时好时坏” |
| 操作系统适配层 | 信号量 / 邮箱 / 互斥量 / 线程创建 | 随机卡死、断言触发、时序上“晚一拍”;关掉 RTOS 单独测又正常 |
三、LwIP 的三种编程接口:raw / netconn / socket
LwIP 提供三套写法,而且它们是层层包装的关系:socket 建立在
netconn 之上,netconn 建立在 raw 之上。
知道这个层次关系,“选哪一套”就不再是风格偏好问题,而是
“我愿意为可读性付多少资源”的问题。
| 接口 | 易用性 | 代码量 | 是否阻塞 | 需要 OS | 适合谁用 |
|---|---|---|---|---|---|
raw |
最难:要自己写回调、自己管连接状态 | 最多(但每处都很小) | 不阻塞,靠回调驱动 | 不需要,裸机也能跑 | 不用 RTOS、或者一个设备要扛几百条连接、RAM 极紧的场合 |
netconn |
中等:顺序式写法,一个连接一个结构体 | 中等 | 默认阻塞,可设接收超时 | 需要 | 连接数少、协议流程固定、想把“等连接 / 等数据”写成直线代码的场合 |
socket |
最容易:和 PC 上写网络程序几乎一样 | 最少 | 默认阻塞,可用 select 做超时 |
需要(且要打开 socket 选项) | 要同时用 UDP 和 TCP、要在多个设备型号间共用一份代码、熟悉 BSD 接口的人 |
我在两个工程里各选了一套,理由并不一样。
多板卡控制系统用了 socket。这个工程同时存在两条链路:
板与板之间用 UDP 承载 Modbus TCP 报文(要的是低延迟和“丢一包无所谓”),
板与上位机之间用 TCP 长连接做状态上报(要的是完整和有序)。
两套链路共用同一个移植层、同一批工具函数,select 的语义在两边都能直接用,
这让“一个循环里同时看两个 socket”变得很自然。
这段链路的工程化细节我写在
《以太网 UDP 通信的工程化:心跳、掉线判定与重连》
里,这里只说结论:只要项目里出现了“两种传输方式并存”,socket 的省心程度就值回票价。
电池管理主控用了 netconn。这个工程的角色很单一:
对外只提供一路 Modbus TCP 服务,没有 UDP,没有第二条链路,
并且 lwipopts.h 里直接把 UDP 关掉了。
它的协议流程是一条直线:等连接 → 给这条连接设一个接收超时 → 循环处理请求 →
超时或出错就关掉、回到等连接。用 netconn_accept 这种阻塞式调用写出来,
代码形状和流程形状完全一致,几乎不需要注释。
另一个现实原因是:它用的那套 Modbus TCP 从站实现,网络移植层本身就是 netconn 写法,
换成 socket 反而要自己写一层适配。
这里可以顺手回答一个常见问题:同一套 Modbus 解析代码能不能同时被 socket 和 netconn 使用? 可以,只要把“收一帧”和“发一帧”抽象成两个函数指针,协议解析部分就与传输层无关了。 我在第二个工程里没有做这层抽象,因为只有一种传输方式; 但如果下次再出现“两种都要”的情况,我会先把这层抽象做出来。
四、lwipopts.h 里真正要动手改的那些项
lwipopts.h 是 LwIP 的裁剪与定量文件。我的经验是:
不要凭感觉调数字,先想清楚这个数字在哪条路径上被消耗。
下面这张表是我实际改过、并且认为值得改的项,以及我判断的依据。
| 配置项 | 作用 | 改大 / 改小的后果 | 我的选择依据 |
|---|---|---|---|
MEM_SIZE |
协议栈自己那块堆的大小 | 改小:pbuf 分配失败、连接建不起来;改大:直接吃掉 MCU 的 RAM | 按“同时在途的缓冲 + 应用侧还要留多少”倒推,而不是先把堆开满 |
PBUF_POOL_SIZE |
接收池的元素个数 | 改小:突发流量下丢包(不报错,只是丢);改大:每一块都在吃 RAM | 按最坏情况下“同一时刻有多少帧在协议栈里没处理完”来估,宁可按峰值估 |
PBUF_POOL_BUFSIZE |
单个池元素能装多少字节 | 小于一个完整以太网帧:一帧要跨多个 pbuf,链变长、拷贝变多 | 至少覆盖一个 MTU 大小的帧,尽量避免链式拼包 |
TCP_SND_BUF / TCP_WND |
发送缓冲与接收窗口 | 改小:吞吐上不去;改大:一条慢连接就能占住一大块内存 | 控制报文场景只需要“能放几帧”,够用即可,不追吞吐 |
TCP_MSS |
单个 TCP 段的最大数据长度 | 超过路径 MTU 会导致分片,反而更慢 | 按以太网 MTU 减去 IP 头与 TCP 头来定,不凭感觉调 |
| TCP 重传次数相关项 | 协议栈自己重传多少次才放弃 | 次数多:一次断链要等很久才报错;次数少:网络抖动时误判断开 | 与应用层的“重试几次就断开重连”对齐,别让两层各等一遍、时间叠加 |
LWIP_UDP / LWIP_TCP |
要不要把对应协议编进来 | 关掉能同时省 Flash 和 RAM;开着但不用,纯属浪费 | 只用 TCP 的工程就把 UDP 关掉(我第二个工程就是这么做的),反之亦然 |
LWIP_SO_RCVTIMEO |
让接收支持超时 | 关掉:接收只能永久阻塞,掉线时就卡死在那里 | 必须打开——这是本文第五节和第六节所有做法能成立的前提 |
| 链路状态回调类开关 | 网线插拔时给应用一个通知 | 关掉:只能自己轮询 PHY 寄存器,或者干脆不知道链路断了 | 需要“检测到网线再初始化”的场景建议打开,省掉一个轮询任务 |
| 统计类开关 | 协议栈内部的收发/丢包/内存失败计数 | 开着要额外内存和打印通道;关掉就失去了现场诊断的依据 | 调试期打开、用来定位丢包与内存失败;定型后再关掉省资源 |
| DHCP 类开关 | 要不要编一个 DHCP 客户端进来 | 开着多一个状态机与超时流程;关掉少一块代码 | 地址写在参数区里的工业设备直接关掉,见本文第九节 |
五、阻塞与非阻塞:select 的用法与超时取值
socket 默认是阻塞的:recvfrom 一旦调用,如果没有数据就一直等下去。
这在“一个任务只干一件事”的例程里没问题,但在真实固件里几乎一定会出事,
因为这条任务通常还要负责心跳、状态机推进、按键响应之类的工作。
select 解决的就是这个问题:它把“收”拆成两步——
第一步问“现在能不能收”,带一个超时;第二步才是真的收。三个返回值必须分开处理:
- 大于 0:有可读事件,接着调用
recv;注意recv仍然可能返回错误或 0,不能省掉判断。 - 等于 0:超时。这是正常分支,不是错误——应该走“这一轮没有数据”的处理, 比如累加一次无数据计数、检查是否需要重连。
- 小于 0:出错,而且要看错误码:是被信号打断、是描述符已经无效、还是对端复位。 不同原因对应不同处理,一律当“断开”处理会让偶发的一次打断也触发重连。
超时值怎么定?我的方法是从上层节拍倒推,而不是拍一个整数:
- 先看调用这个 socket 的任务本身多久跑一轮。收超时不应该明显大于这个周期, 否则“这一轮该做的事”会被一次收等待拖过去。
- 再看对端“多久会说话一次”。如果对端本来就按固定的节奏发心跳或状态, 收超时可以设成心跳间隔的若干倍——这样“连续几次超时”就等价于“对端可能不在了”。
- 最后看人。现场调试时希望“拔了对端之后多久能看出来”,这个时间要能接受, 它是超时值与重试次数的乘积。
必须避免的是没有超时的阻塞读。它会造成三种后果,每一种我都遇到过:
- 任务被冻住:收不到数据就永远停在这一行,连掉线判定和心跳都停了, 从外部看就是“设备活着,但网络功能死了”。
- 察觉不到异常断开:对端断电、拔网线这类情况不会发出 FIN,
协议栈也不知道连接已经无效,
recv会一直等下去,永远等不到“返回 0”。 - 把关闭路径堵死:想关掉这条连接时,如果任务还挂在收上,
closesocket根本轮不到执行。
/* 按个人理解重写的通用片段:带超时的可读等待
返回 1 = 可读,0 = 超时(正常分支),-1 = 出错(由调用者决定重连还是退出) */
static int wait_readable(int sock, unsigned int timeout_ms)
{
fd_set readfds;
struct timeval tv;
int ret;
FD_ZERO(&readfds);
FD_SET(sock, &readfds);
tv.tv_sec = (long)(timeout_ms / 1000u);
tv.tv_usec = (long)((timeout_ms % 1000u) * 1000u);
ret = select(sock + 1, &readfds, NULL, NULL, &tv);
if (ret > 0 && FD_ISSET(sock, &readfds)) {
return 1;
}
if (ret == 0) {
return 0;
}
return -1;
}
还有两个细节值得记一笔。第一,select 的第一个参数在 BSD 语义里是
“最大描述符加一”,不是“描述符个数”,写成 sock 而不是 sock + 1
是新手最常见的错误之一,而且在描述符恰好比较小的时候可能“看起来是对的”。
第二,超时结构体 timeval 在有些实现里会被 select 就地修改,
所以每次调用前都要重新填,不要指望它保持原值。
六、连接的生命周期:建立、异常断开、重连
一条长连接在固件里其实有三个阶段要分别写代码,而例程通常只演示第一个。
建立:允许失败,并且退避
主动发起连接的一方,connect 失败是常态而不是异常——
对端还没上电、上位机软件还没启动、交换机刚重启,这些都会导致失败。
所以建立连接的代码必须是一个循环 + 延时的结构,
而不是“失败就报错退出”。同理,作为服务端等待连接的一方,那个“等”也必须有超时,
否则设备启动顺序一旦不对(对端比自己晚起来),任务就会永久挂在那里。
异常断开:三种“断”要分开认
- 对端正常关闭:
recv返回 0。这是明确信号,直接走重连。 - 对端复位:
recv返回小于 0 且错误码表示连接被复位。 这也是明确信号。 - 对端“消失”:拔网线、断电、中间交换机故障。协议栈完全不知道, 连接状态还写着已建立。这一种只能靠超时 + 重试来识别, 也就是为什么第五节的超时是必须的。
重连:断开之后不要立刻重来
我曾经把重连写成一个不带延时的循环,结果是对端一挂,这块板就在那儿以最快的速度 反复发起连接。这在实验室里只是让日志刷屏,在现场就变成了“把自己的网络打满”, 甚至影响到同一台交换机上别的设备。后来改成固定的秒级间隔, 同时保留“最多连续几次”的门槛。这个取舍的背后是一句朴素的话: 设备重启和网络恢复都需要时间,重连太快只是在浪费双方的资源。
重连还有一条容易漏掉的细节:断开之后要重新走完整流程——
重新创建 socket、重新设置选项、重新绑定本地地址、再连接。
只调用一次 connect 往往不行,因为上一次的本地绑定状态、选项状态可能还留在那里。
最后是一条我认为最通用的经验:凡是“被动等待”的地方都要有超时。 在这两个工程里,被动等待出现的位置至少有五处:等对端连接、等对端数据、 等自己的应答、等串口发送完成、等外设就绪。它们的共同点是: 如果对端永远不出现,程序必须自己走出来。
connect,
没有重新建 socket。上一次的绑定还在(因为绑定的是固定本地地址),
于是新的连接一直建立不起来。现象是“重连逻辑看起来在执行但永远不成功”,
把断开路径改成“关掉 → 重建 → 重设选项 → 再连”之后就正常了。
七、关连接为什么要设置停留选项(SO_LINGER)
默认情况下,关闭一条 TCP 连接并不是“立刻结束”:
close 只是把控制权交还给协议栈,协议栈会把还没发出去的数据发完、
走完正常的四次挥手,而主动关闭的一方会进入 TIME_WAIT 状态,
持续大约两倍的报文最大生存时间。
这段时间为什么会影响重连?因为 TIME_WAIT 期间,
同一个四元组(本端地址端口 + 对端地址端口)不能被重复使用。
如果这条连接是“主动关闭”的一方,而本地端口又是固定的
(工业设备上很常见:为了让人能从外面找到自己,或者为了方便防火墙放行),
那么在这段时间里重新连接就会失败。表现出来是“断线之后要等一会儿才能连上”,
而这个“一会儿”在现场是很难向别人解释的。
解决办法是设置停留选项:把停留时间设为 0,close 会立即返回,
协议栈直接发一个复位(RST)出去,跳过四次挥手,也就不进入 TIME_WAIT。
/* 按个人理解重写的通用片段:异常退出路径上的快速关闭
代价是未发完的数据被丢弃、对端可能看到连接被复位,所以只用在"这条连接本来就要放弃"的地方 */
struct linger lg;
lg.l_onoff = 1;
lg.l_linger = 0;
setsockopt(sock, SOL_SOCKET, SO_LINGER, &lg, sizeof(lg));
closesocket(sock);这里要讲清楚代价,不然很容易滥用:强制复位意味着 缓冲区里还没发出去的数据全部丢掉,对端看到的是“连接被异常中断”而不是“对方礼貌地说再见了”。 所以我只在异常退出路径上用它——比如连续几次超时或校验失败、已经判定这条链路不可用了。 正常的收尾路径还是让协议栈自己走完握手,那样数据才是可靠的。
另外两个相关的选项值得一起记住:SO_REUSEADDR 解决的是
“绑定一个刚刚被用过的本地地址”这件事,它和 TIME_WAIT 是不同层面的问题,
不能互相替代;而如果只是想减少小报文被延迟发送,那要动的是 TCP 的延迟确认相关行为,
又是另一个话题了。
八、有线网络的物理层现实:网线没插时会怎样
这是我认为最容易被忽略、但最影响现场体验的一节。
网线没插的时候会发生什么?如果代码是按例程的顺序写的,通常是这样的: MAC 初始化成功、PHY 初始化成功、IP 地址配好、协议栈起来、 然后开始连接对端——而这一切全都“成功”了,因为从协议栈的角度看, 网卡是好的,只是没有任何回应。于是设备会在“连接失败 → 重试”的循环里安静地打转, 如果日志走的是网络,连日志都发不出来。
根因在于:MAC 层初始化完成,不等于链路建立。 链路是否建立是 PHY 的事,结果放在 PHY 的标准状态寄存器的链路状态位里。 所以要做的第一件事,是在初始化协议栈之前先读这个位:
/* 按个人理解重写的通用片段:读 PHY 标准状态寄存器判断链路是否建立
0x01 是标准状态寄存器地址,链路状态位是其中的 bit2 */
#define PHY_BSR 0x01u
#define PHY_LINK_STATUS 0x0004u
if ((ETH_ReadPHYRegister(phy_addr, PHY_BSR) & PHY_LINK_STATUS) == 0u) {
/* 链路还没建立:先不要初始化协议栈,延时后下一轮再查 */
}第二件事是把网络初始化挪到“检测到链路之后”。 我原来的顺序是“上电就把协议栈初始化好”,改过之后是 “上电先等链路 → 链路建立 → 再初始化协议栈 → 再创建 socket”。 这么改有三个好处:
- 没有链路的时候不做无用功——ARP、协议栈定时器、各种重传都不会空跑。
- 不会出现“协议栈已经起来,但网卡其实是 down 的”这种自相矛盾的状态,
也就省掉了
netif上下线的处理。 - 现场体验完全不同:插上网线之前,日志里是平和的“等待网线插入”; 插上之后,网络自动起来。而原来的写法是刷屏的连接失败。
九、静态 IP 还是 DHCP:工业设备常见的取舍
这个问题在“联网设备”里几乎没有悬念,但在“设备联网”里是有取舍的。 两个工程我都用了静态地址,而且地址存在参数区里,可以由上位机改写。 理由如下表。
| 维度 | 静态 IP | DHCP |
|---|---|---|
| 上电到可通信的时间 | 确定的,配好就能用 | 不确定,要等服务器回应,超时后还要退回 |
| 地址从哪来 | 写在设备参数区里,跟着设备走 | 由服务器分配,设备自己不知道下次会拿到什么 |
| 现场没有服务器时 | 不受影响 | 拿不到地址就永远不上线,必须自己做兜底 |
| 换网段时 | 要逐台改配置(可以通过上位机批量下发) | 自动适应,不用改 |
| 地址冲突 | 靠人工记录避免,存在重号风险 | 由服务器统一管理,基本不会冲突 |
| 适合的设备 | 位置固定、有人维护、要能被上位机主动找到的现场设备 | 数量大、位置常变、由 IT 统一管理的设备 |
具体到我的场景:设备是装在机柜里、由上位机按固定地址去连的, 现场也不一定有 DHCP 服务器。这种情况下用 DHCP 只会带来不确定性, 所以地址、掩码、网关这三个值都跟其他参数一起放在参数结构体里, 改完之后重新初始化网络(或者干脆提示重启网络任务)。
如果一定要保留 DHCP,我的建议是必须带兜底: 在一个明确的等待时间之内没有拿到地址,就退回到参数区里的静态地址。 否则“现场没有 DHCP 服务器”这一个条件,就足以让设备永远不上线, 而且从设备自己角度看,一切都很正常——这正是最难排查的那类故障。 还要注意的是,静态地址和 DHCP 在协议栈里是两种互斥的地址来源, 切换的时候要把前一种停掉,不能只是叠加。
十、一个够用就好的性能观
先说清楚场景:这两个工程的报文都是几十个字节量级, 发送节奏是“有变化就发、没变化就发心跳”,一秒钟几包到几十包。 这个量级下,百兆以太网的带宽利用率连千分之一都不到, 协议栈占用的 CPU 时间也远小于打印日志的开销。 所以在这个场景里追求“网络性能”本身是错的方向。
我认为值得做的优化只有几条,而且都不是“提速”:
- 少发。用“数据变了才发 + 长时间没变就发一包心跳”代替“定时全量上报”。 省下的不是带宽,是对端的解析开销和日志量。这是投入产出比最高的一条。
- 把校验放在最前面。先判帧头、长度、整体校验,再进业务解析。 一条明显错误的报文不值得走完整条解析链。
- 一次拷贝到位。把收到的数据整块拷进一个固定缓冲,之后所有解析都从这个缓冲读, 而不是在 pbuf 链上逐段取值。这样上层代码不需要知道 pbuf 的存在。
- 中断里只投递。这条在第二节说过,它既是正确性问题,也是性能问题。
- 给“这条连接是不是还活着”留一个廉价的判据。 有时序的超时加上一个失败计数,比任何复杂的探测都有效。
不值得做的优化,我也列一下,用来提醒自己别跑偏:
- 为了省一次拷贝去重写 pbuf 的处理逻辑——省下的时间远小于引入 bug 的风险。
- 把窗口和缓冲开大来“提高吞吐”——在这个数据量下没有意义,只是白占内存。
- 给报文做压缩——几十字节的报文,压缩后的收益还不够抵消代码复杂度。
- 在没有测量数据的情况下调整 MSS、重传参数——很可能把“偶发丢包”调成“频繁断线”。
我的判断标准很朴素:先量一下“一秒钟到底有多少字节、协议栈花了多少时间”, 再决定要不要优化。如果这两个数都很小,那真正该花时间的地方是 “断线了能不能自己回来”和“出问题了能不能看出来”,而不是让正常的路径再快一点。
十一、一份 LwIP 上手清单
下面这张表是我现在拿到一块新板子、要给它加网络功能时会按顺序过一遍的清单。 它不完整,但覆盖了我在两个工程里真正踩过的坑。
| 阶段 | 检查项 | 漏掉会怎样 |
|---|---|---|
| 移植层 | 网卡初始化 / 发送 / 接收三个回调是否都通;接收是否只在中断里投递 | ping 不通,或大流量下偶发死机 |
| 内存 | 接收池个数与单块大小是否覆盖最坏突发;协议栈堆与 FreeRTOS 堆是否分别核算 | 偶发丢包、连接建不起来;两块堆互相挤占 |
| 裁剪 | 不用的协议(UDP 或 TCP)、不用的功能(DHCP、统计)是否关掉 | 白占 Flash 和 RAM,多一份没人维护的代码路径 |
| 接口选型 | 选定 raw / netconn / socket 中的一种,并说明为什么不选另外两种 | 两种写法混在一个工程里,资源与风格都不一致 |
| 初始化顺序 | 是否先检测 PHY 链路、再初始化协议栈、最后创建 socket | 没插网线时设备在“连接失败”里空转,日志也发不出来 |
| 超时 | 所有被动等待是否都有超时:等连接、等数据、等应答 | 对端消失后任务永久卡住,心跳和掉线判定一起停摆 |
| 关闭与重连 | 异常退出是否走“强制关闭”;重连是否重建 socket 而不是只 connect | 断开后要等很久才能连上,或者永远连不上 |
| 诊断 | 统计计数、链路状态、连接状态能不能从日志或参数区看到 | 现场只能靠抓包,而现场往往没有抓包条件 |
| 参数 | 地址、掩码、网关是否与其他参数一起持久化,改完是否需要重启网络 | 换个网段就要重新烧固件 |
十二、几条我反复用到的经验
- 先弄清楚“这个数字在哪条路径上被消耗”,再去改它。 配置项不是越大越好,它们吃的是同一块 RAM。
- 所有被动等待都要有超时。等连接、等数据、等应答、等发送完成,一个都不能漏。
- 把
select返回 0 当成正常分支。超时是设计的一部分,不是错误。 - 先看链路,再起协议栈。网线没插的时候,最好的行为是安静地等,而不是反复重试。
- 断开之后重建整条链路,而不是只重连一次。选项、绑定、缓冲都要重新来。
- 强制关闭(复位 + 跳过
TIME_WAIT)只用在异常路径上。 正常收尾还是让协议栈把话说完。 - 选接口的时候先看“有几种传输方式”,再看“资源有多紧”。 两种传输方式并存时,socket 的省心程度通常超过它多占的那点内存。
- 把“通信是否正常”做成可以被外部看到的东西。 一个在线标志、一个失败计数,往往比一堆日志有用。
参考资料与说明
- LwIP 官方文档与源码(lwIP - A Lightweight TCP/IP stack,采用修改版 BSD 三条款许可)。
配置项的确切语义以官方文档与
opt.h中的注释为准,本文只记录我的取舍经验。 - FreeRTOS 官方文档与源码(采用 MIT 许可),本文涉及的是它作为 LwIP 底层适配的用法。
- Modbus 应用协议规范与 Modbus TCP 实现规范中关于功能码与异常码的定义,属于公开标准。
- 关于以太网 PHY 的标准状态寄存器与链路状态位,属于 IEEE 802.3 第 22 条规定的公开内容。
- 本文涉及的网络框架部分移植自第三方开源例程, 文中只描述其架构与我自己做的改动,不展示该例程的代码,也不主张其著作权。
- 文中出现的芯片与 PHY 型号仅用于说明技术方案,与相关厂商无隶属或授权关系; 示例代码为按个人理解重写的最小片段,不代表任何产品或交付代码。
- 出于对个人项目实现细节的保护,本文只公开方法与思路; 涉及核心实现的部分以步骤与字段说明代替代码,示例代码为按个人理解重写的通用片段。