本站为个人非经营性网站,仅用于技术学习与分享,不提供任何商品、服务、报价或收费咨询。 合规信息

LwIP 协议栈使用笔记:
三种 API、配置项取舍与常见坑

把 LwIP 跑起来不难,难的是“知道自己改的那个数字在影响什么”。 这篇笔记以我用过的两个带以太网的板卡工程为样本,整理三件事: 三种编程接口各自适合谁、lwipopts.h 里哪些项真的要动手、 以及在不插网线、对端异常断开、需要重连这些现实情况下应该怎么安排代码。

LwIP 以太网 FreeRTOS socket lwipopts 已发布

一、裸机上加网络,为什么大多数人是移植协议栈而不是自己写

我一开始有过一个很天真的想法:控制报文只有几十个字节,收发逻辑也不复杂, 那是不是可以自己写一套“够用的”以太网收发,绕开协议栈这个庞然大物? 真正算了一遍工作量之后我放弃了这个念头。

自己写意味着要处理的东西至少包括: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 单独测又正常
自上而下:三种应用编程接口(raw / netconn / socket)→ 传输层 → 网络层 → 网卡驱动接口 →
图 1 · LwIP 分层结构图

三、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 使用? 可以,只要把“收一帧”和“发一帧”抽象成两个函数指针,协议解析部分就与传输层无关了。 我在第二个工程里没有做这层抽象,因为只有一种传输方式; 但如果下次再出现“两种都要”的情况,我会先把这层抽象做出来。

三栏并排,各自画出一次收发的调用链,标注“是否阻塞”“是否需要操作系统”“代码量级”
图 2 · 三种编程接口的调用形态对比图

四、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:出错,而且要看错误码:是被信号打断、是描述符已经无效、还是对端复位。 不同原因对应不同处理,一律当“断开”处理会让偶发的一次打断也触发重连。

超时值怎么定?我的方法是从上层节拍倒推,而不是拍一个整数:

  1. 先看调用这个 socket 的任务本身多久跑一轮。收超时不应该明显大于这个周期, 否则“这一轮该做的事”会被一次收等待拖过去。
  2. 再看对端“多久会说话一次”。如果对端本来就按固定的节奏发心跳或状态, 收超时可以设成心跳间隔的若干倍——这样“连续几次超时”就等价于“对端可能不在了”。
  3. 最后看人。现场调试时希望“拔了对端之后多久能看出来”,这个时间要能接受, 它是超时值与重试次数的乘积。

必须避免的是没有超时的阻塞读。它会造成三种后果,每一种我都遇到过:

  • 任务被冻住:收不到数据就永远停在这一行,连掉线判定和心跳都停了, 从外部看就是“设备活着,但网络功能死了”。
  • 察觉不到异常断开:对端断电、拔网线这类情况不会发出 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 往往不行,因为上一次的本地绑定状态、选项状态可能还留在那里。

最后是一条我认为最通用的经验:凡是“被动等待”的地方都要有超时。 在这两个工程里,被动等待出现的位置至少有五处:等对端连接、等对端数据、 等自己的应答、等串口发送完成、等外设就绪。它们的共同点是: 如果对端永远不出现,程序必须自己走出来。

七、关连接为什么要设置停留选项(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”。 这么改有三个好处:

  1. 没有链路的时候不做无用功——ARP、协议栈定时器、各种重传都不会空跑。
  2. 不会出现“协议栈已经起来,但网卡其实是 down 的”这种自相矛盾的状态, 也就省掉了 netif 上下线的处理。
  3. 现场体验完全不同:插上网线之前,日志里是平和的“等待网线插入”; 插上之后,网络自动起来。而原来的写法是刷屏的连接失败。

九、静态 IP 还是 DHCP:工业设备常见的取舍

这个问题在“联网设备”里几乎没有悬念,但在“设备联网”里是有取舍的。 两个工程我都用了静态地址,而且地址存在参数区里,可以由上位机改写。 理由如下表。

维度静态 IPDHCP
上电到可通信的时间确定的,配好就能用不确定,要等服务器回应,超时后还要退回
地址从哪来写在设备参数区里,跟着设备走由服务器分配,设备自己不知道下次会拿到什么
现场没有服务器时不受影响拿不到地址就永远不上线,必须自己做兜底
换网段时要逐台改配置(可以通过上位机批量下发)自动适应,不用改
地址冲突靠人工记录避免,存在重号风险由服务器统一管理,基本不会冲突
适合的设备位置固定、有人维护、要能被上位机主动找到的现场设备数量大、位置常变、由 IT 统一管理的设备

具体到我的场景:设备是装在机柜里、由上位机按固定地址去连的, 现场也不一定有 DHCP 服务器。这种情况下用 DHCP 只会带来不确定性, 所以地址、掩码、网关这三个值都跟其他参数一起放在参数结构体里, 改完之后重新初始化网络(或者干脆提示重启网络任务)。

如果一定要保留 DHCP,我的建议是必须带兜底: 在一个明确的等待时间之内没有拿到地址,就退回到参数区里的静态地址。 否则“现场没有 DHCP 服务器”这一个条件,就足以让设备永远不上线, 而且从设备自己角度看,一切都很正常——这正是最难排查的那类故障。 还要注意的是,静态地址和 DHCP 在协议栈里是两种互斥的地址来源, 切换的时候要把前一种停掉,不能只是叠加。

十、一个够用就好的性能观

先说清楚场景:这两个工程的报文都是几十个字节量级, 发送节奏是“有变化就发、没变化就发心跳”,一秒钟几包到几十包。 这个量级下,百兆以太网的带宽利用率连千分之一都不到, 协议栈占用的 CPU 时间也远小于打印日志的开销。 所以在这个场景里追求“网络性能”本身是错的方向。

我认为值得做的优化只有几条,而且都不是“提速”:

  1. 少发。用“数据变了才发 + 长时间没变就发一包心跳”代替“定时全量上报”。 省下的不是带宽,是对端的解析开销和日志量。这是投入产出比最高的一条。
  2. 把校验放在最前面。先判帧头、长度、整体校验,再进业务解析。 一条明显错误的报文不值得走完整条解析链。
  3. 一次拷贝到位。把收到的数据整块拷进一个固定缓冲,之后所有解析都从这个缓冲读, 而不是在 pbuf 链上逐段取值。这样上层代码不需要知道 pbuf 的存在。
  4. 中断里只投递。这条在第二节说过,它既是正确性问题,也是性能问题。
  5. 给“这条连接是不是还活着”留一个廉价的判据。 有时序的超时加上一个失败计数,比任何复杂的探测都有效。

不值得做的优化,我也列一下,用来提醒自己别跑偏:

  • 为了省一次拷贝去重写 pbuf 的处理逻辑——省下的时间远小于引入 bug 的风险。
  • 把窗口和缓冲开大来“提高吞吐”——在这个数据量下没有意义,只是白占内存。
  • 给报文做压缩——几十字节的报文,压缩后的收益还不够抵消代码复杂度。
  • 在没有测量数据的情况下调整 MSS、重传参数——很可能把“偶发丢包”调成“频繁断线”。

我的判断标准很朴素:先量一下“一秒钟到底有多少字节、协议栈花了多少时间”, 再决定要不要优化。如果这两个数都很小,那真正该花时间的地方是 “断线了能不能自己回来”和“出问题了能不能看出来”,而不是让正常的路径再快一点。

十一、一份 LwIP 上手清单

下面这张表是我现在拿到一块新板子、要给它加网络功能时会按顺序过一遍的清单。 它不完整,但覆盖了我在两个工程里真正踩过的坑。

阶段检查项漏掉会怎样
移植层 网卡初始化 / 发送 / 接收三个回调是否都通;接收是否只在中断里投递 ping 不通,或大流量下偶发死机
内存 接收池个数与单块大小是否覆盖最坏突发;协议栈堆与 FreeRTOS 堆是否分别核算 偶发丢包、连接建不起来;两块堆互相挤占
裁剪 不用的协议(UDP 或 TCP)、不用的功能(DHCP、统计)是否关掉 白占 Flash 和 RAM,多一份没人维护的代码路径
接口选型 选定 raw / netconn / socket 中的一种,并说明为什么不选另外两种 两种写法混在一个工程里,资源与风格都不一致
初始化顺序 是否先检测 PHY 链路、再初始化协议栈、最后创建 socket 没插网线时设备在“连接失败”里空转,日志也发不出来
超时 所有被动等待是否都有超时:等连接、等数据、等应答 对端消失后任务永久卡住,心跳和掉线判定一起停摆
关闭与重连 异常退出是否走“强制关闭”;重连是否重建 socket 而不是只 connect 断开后要等很久才能连上,或者永远连不上
诊断 统计计数、链路状态、连接状态能不能从日志或参数区看到 现场只能靠抓包,而现场往往没有抓包条件
参数 地址、掩码、网关是否与其他参数一起持久化,改完是否需要重启网络 换个网段就要重新烧固件

十二、几条我反复用到的经验

  1. 先弄清楚“这个数字在哪条路径上被消耗”,再去改它。 配置项不是越大越好,它们吃的是同一块 RAM。
  2. 所有被动等待都要有超时。等连接、等数据、等应答、等发送完成,一个都不能漏。
  3. 把 select 返回 0 当成正常分支。超时是设计的一部分,不是错误。
  4. 先看链路,再起协议栈。网线没插的时候,最好的行为是安静地等,而不是反复重试。
  5. 断开之后重建整条链路,而不是只重连一次。选项、绑定、缓冲都要重新来。
  6. 强制关闭(复位 + 跳过 TIME_WAIT)只用在异常路径上。 正常收尾还是让协议栈把话说完。
  7. 选接口的时候先看“有几种传输方式”,再看“资源有多紧”。 两种传输方式并存时,socket 的省心程度通常超过它多占的那点内存。
  8. 把“通信是否正常”做成可以被外部看到的东西。 一个在线标志、一个失败计数,往往比一堆日志有用。

参考资料与说明

  • LwIP 官方文档与源码(lwIP - A Lightweight TCP/IP stack,采用修改版 BSD 三条款许可)。 配置项的确切语义以官方文档与 opt.h 中的注释为准,本文只记录我的取舍经验。
  • FreeRTOS 官方文档与源码(采用 MIT 许可),本文涉及的是它作为 LwIP 底层适配的用法。
  • Modbus 应用协议规范与 Modbus TCP 实现规范中关于功能码与异常码的定义,属于公开标准。
  • 关于以太网 PHY 的标准状态寄存器与链路状态位,属于 IEEE 802.3 第 22 条规定的公开内容。
  • 本文涉及的网络框架部分移植自第三方开源例程, 文中只描述其架构与我自己做的改动,不展示该例程的代码,也不主张其著作权。
  • 文中出现的芯片与 PHY 型号仅用于说明技术方案,与相关厂商无隶属或授权关系; 示例代码为按个人理解重写的最小片段,不代表任何产品或交付代码。
  • 出于对个人项目实现细节的保护,本文只公开方法与思路; 涉及核心实现的部分以步骤与字段说明代替代码,示例代码为按个人理解重写的通用片段。