一、为什么选了 UDP 而不是 TCP
我最早接触这套板间通信的时候,第一反应是“用 TCP 更稳”——毕竟 TCP 有确认、有重传、有顺序保证, 听起来就是“不会丢数据”的代名词。真正做下去才发现,这里要传的东西根本不在乎“一条都不丢”, 它在乎的是“我拿到的是不是此刻的值”。
具体场景是这样的:一块控制面板板负责本地人机输入,扫描 20 个按键、11 路 ADC
(9 个亮度旋钮加操纵杆的 X / Y 两轴);另一块控制内核板负责整机控制。
面板每 20 ms 把当前所有输入值打包送上去,内核按 Modbus TCP 的报文格式解析,站号约定为
0x0b。也就是说:Modbus TCP 的报文格式,跑在 UDP 的 socket 上。
这个组合第一次看到会觉得别扭,但用下来我认为它在这个场景里是合理的,理由有三条:
- 时效性优先于完整性。按键值和旋钮值都是“此刻的状态”,20 ms 之后就有新的。 丢一包,等下一包就自愈了;而 TCP 的重传会把后面排队的新数据一起堵住(队头阻塞), 对控制来说,“迟到的正确值”远不如“按时到达的当前值”。
- 无连接让恢复路径变短。现场调试时会直接拔网线、重启对端设备。 UDP 没有握手状态,对端回来了就继续发;TCP 则必须显式走完重建连接的过程。
-
资源占用更小。LwIP 的 TCP 控制块和发送缓冲要在堆里占一份,
而这个工程给 FreeRTOS 的堆是 100 KB(
configTOTAL_HEAP_SIZE = 100*1024), 省下来的每一块都要留给应用。
代价是清楚的,而且必须自己补上:UDP 只有一个 16 位校验和,不保证到达、不保证顺序、不保证不重复。 所以我在这条链路上补了三件事——应用层自己的帧头 + 长度 + CRC32 校验(判断“这一包是不是完整有效”)、 心跳与掉线判定(判断“对端还在不在”)、以及“最新值覆盖”而不是“队列累积”的数据模型(顺序问题直接用覆盖绕过去)。
但这不是一条“UDP 比 TCP 好”的结论。同一个系统里,内核和面板向显控 PC 上报整机状态用的就是 TCP 长连接,因为那份数据要的是完整和顺序。选哪种,取决于数据“过期得快不快”。 关于这套系统整体的分层,我在 《多板卡控制系统:以太网做板间,RS485 做现场》 里做了完整记录。
二、socket 的两种用法:短报文用 UDP、状态上报用 TCP 长连接
同一套 LwIP + FreeRTOS 的移植层,在这个系统里被用成了两种完全不同的形态。 把它们放在一张表里对比,比分别看两段代码清楚得多:
| 链路 | 传输方式 | socket 类型 | 角色 | 最在意什么 |
|---|---|---|---|---|
| 面板 → 内核(板间输入) | UDP 承载 Modbus TCP | SOCK_DGRAM |
面板是主机、内核是从站(站号 0x0b) |
低延迟、丢包可容忍、必须能判掉线 |
| 内核 / 面板 → 显控 PC(状态上报) | TCP 长连接 | SOCK_STREAM |
两块板都作 client | 完整、有序、断线要能重连 |
| 下行命令(显控 PC → 板卡) | 搭在同一条 TCP 长连接上 | SOCK_STREAM |
由板卡的接收分支处理 | 定长、必须校验通过才生效 |
UDP 这一侧的调用顺序很朴素:socket(AF_INET, SOCK_DGRAM, 0) →
bind 本地端口 → 之后一直用 sendto / recvfrom,
所有收等待都用 select 做超时。TCP 那一侧多两步:setsockopt(SO_REUSEADDR)
之后 bind 本地端口、再 connect 到显控 PC 的端口。
一份协议代码,两个角色
这里有一个我自己比较满意的设计:内核工程和面板工程用的是同一份 Modbus 解析代码。 内核那边是从站(解析请求、返回寄存器),面板那边是主机(组请求、收响应), 差别只在初始化时调用的那个函数不同。
两边共用代码带来的直接好处是“寄存器区的理解天然一致”:内核侧的保持寄存器里, 前 20 个是按键值、接下来 11 个是 ADC 值,正好对应面板上的 20 个按键和 11 路 ADC。 这个对应关系如果靠文档维护,早晚会错位;靠同一份代码约束,就几乎不会错。
TCP 上必须用“定长 + 收满为止”
TCP 是字节流,一次 recv 到底能读到多少字节,是协议栈和网络状况决定的,不是发送方决定的。
所以那条链路上我用的读法是 recv_fixed():知道这一帧应该是多少字节,
就循环读到收满为止;下行命令帧固定 12 字节,就一次读满 12 字节再交给解析。
这一点和串口上的“帧同步”问题本质上是同一类问题,只是边界判据不同。 我在《串口通信的帧同步:四种“怎么知道一帧收完了”的做法》 里整理过串口侧的四种做法,TCP 上最省事的就是“定长”这一种。
三、select 超时:把“阻塞收”变成“带节奏的收”
最初我的接收代码就是一句 recvfrom()。它工作得很好,直到我需要在这个任务里做第二件事——
那时才发现问题:阻塞的 recvfrom 会把整个任务钉死在那里,
对端在不在、要不要重发、超时该不该报警,全都没机会判断。
改成 select 之后,这个任务的性质就变了:从“等数据”变成“每 3 秒检查一次”。
这里要分清它和 vTaskDelay 的区别——vTaskDelay(3000) 是“我主动让出 CPU 三秒”,
而 select 是“我盯着这个 socket,最多三秒”。前者等的是时间,后者等的是事件。
/* 收一包:把“阻塞等”换成“最多等 3 秒”(按个人理解重写的最小片段) */
static int udp_recv_with_timeout(int sock, uint8_t *buf, int len, int timeout_s)
{
fd_set readfds;
struct timeval tv;
int ret;
FD_ZERO(&readfds);
FD_SET(sock, &readfds);
tv.tv_sec = timeout_s; /* 3 秒:一次“对端还在不在”的观察窗口 */
tv.tv_usec = 0;
ret = select(sock + 1, &readfds, NULL, NULL, &tv);
if (ret > 0) {
/* 真的有数据可读。这里用 recvfrom 而不是 recv,
UDP 侧不关心来源地址,但保留这个调用形式便于以后多端点扩展 */
return recvfrom(sock, buf, len, 0, NULL, NULL);
}
if (ret == 0) {
return 0; /* 超时:不是错误,交给调用方决定“再等”还是“重发” */
}
return -1; /* 出错:socket 通常已经不可用,走重建流程 */
}这段代码里我认为最值得说的是三行返回值的语义分工:
> 0:有数据,长度就是返回值,交给解析;== 0:超时。它是“这一次没等到”,不是“坏了”;< 0:出错。到这一步基本可以认为这个 socket 已经不可用了,继续用它只会一直错。
同一个“超时”,在不同位置的含义不一样
这一点我踩过:同样的 select 返回 0,在从站等请求时意味着“暂时没人来问我”(完全正常,应该继续等),
而在主机等回复时意味着“对端可能不在了”(异常,应该计数器加一)。
也就是说,超时的语义是调用方定义的,不是 select 定义的。
如果把这层语义混在一起写,就会出现“没人问我也报警”或者“对端没了却当没事”这两种相反的毛病。
我的做法是:把“等多久”作为一个参数传进去,把“等超时算不算失败”留给调用方。 封装函数只负责“等”,不负责“判断”。
四、心跳:什么时候必须有,什么时候可以省
判断“要不要做心跳”,我后来总结成一句话: 当接收侧无法区分“没有新数据”和“对端已经没了”的时候,心跳就是必须的。
面板这条链路正好落在这个条件里:按键不按、旋钮不转的时候,输入值一整天都不变。 如果采用“只在变化时发送”的策略,内核那边就会长时间收不到任何报文, 而它没有任何办法判断这是“操作员没有动作”还是“面板掉线了”。 这两种情况在上层是完全不同的处理——前者什么都不用做,后者要报故障、要把执行机构降级。
反过来,心跳也可以省:如果数据本身在持续变化(例如连续采集的数据流),那每一包天然就是心跳, 不需要额外再发一份“我还活着”。所以我不认为“所有 UDP 链路都必须加心跳”, 而是要看这条链路上“数据的自然更新频率”够不够高。
我用心跳的方式:不定义单独的报文,而是“照常发一包”
我最后没有定义一种专门的心跳报文,而是在状态结构体里留了一个心跳位: 数据没有变化的时候,仍然照常打包发送,只是把这个位置 1。 这样接收侧只有一条解析路径,不需要区分“这是心跳还是数据”。
代价也要说清楚:心跳包是整包长度的,所以它并没有省流量。 “按需发送”省下的是“变化时只发变动区间”,而心跳仍然是全量的一份。
兜底周期在两块板上是这样定的:内核侧是“无变化时最多等 20 次 × 100 ms”, 面板侧是“最多 100 次 × 20 ms”。两边的循环节拍差了 5 倍,但算下来都落在 2 秒。 这个 2 秒不是理论值,是我在看显控界面时的容忍上限定出来的—— 状态显示卡住超过两秒,我就会开始怀疑链路,所以心跳必须比这个更快。
| 链路 | 循环节拍 | 无变化时的计数上限 | 兜底心跳周期 |
|---|---|---|---|
| 内核 → 显控 PC | 100 ms | 20 次 | 2 秒 |
| 面板 → 内核(板间输入) | 20 ms | 100 次 | 2 秒 |
五、变化即发:用“结构体逐字节 diff”省带宽
这套系统里的数据是“一组寄存器值”,而绝大多数时刻只有一两个值在变。 所以我用的策略是:每次都把当前值拷进一份新的结构体,然后和上一次的结构体比较, 只把发生变化的那一段区间发出去。
面板侧的实现是按 16 位逐个下标比较:第一个不同的下标记为起始地址,最后一个不同的下标记为结束地址, 中间的部分一次性用 Modbus 的“写多个寄存器”功能码下发。核心循环大概是这样:
/* 变化即发:按 16 位逐下标比较,求出变化区间(按个人理解重写的最小片段) */
static int get_input_diff(const uint16_t *now, const uint16_t *last, uint16_t words,
uint16_t *start, uint16_t *stop)
{
uint16_t i;
uint16_t first = 0xFFFF;
uint16_t final = 0;
for (i = 0; i < words; i++) {
if (now[i] != last[i]) {
if (first == 0xFFFF) {
first = i; /* 第一个变化的寄存器 */
}
final = i; /* 一直更新到最后一个变化的 */
}
}
if (first == 0xFFFF) {
return 0; /* 一个寄存器都没变 */
}
*start = first;
*stop = final;
return (int)(final - first + 1); /* 本次需要下发的寄存器个数 */
}两个边界情况是实际写的时候才想清楚的:
- 只有一个寄存器变化时,起始和结束是同一个下标,长度算出来是 1。 如果直接用“结束减起始”当长度,这里就会变成 0,从站会收到一个长度为 0 的请求, 表现为“偶尔有一包没生效”。所以数量必须算成“结束 − 起始 + 1”。
- 完全没有变化时,就把起始设为 0、结束设为整表长度,改成整包发送—— 这一包就是上一节说的心跳。
诚实地说,这个规模下 diff 省的不是带宽
面板上传的是 20 个按键值加 11 路 ADC,一共 31 个 16 位字;就算全量发出去也就 62 字节。 在 20 ms 的节拍下,这点流量对以太网来说完全可以忽略。 所以“变化即发”在这个系统里的真正收益不是带宽,而是语义: 它让“数据有没有变”这件事在接收侧变得可观测,也让状态上报天然带上了“变化就立刻推、不变就心跳”的行为。
如果把这套做法挪到几百上千个寄存器的场景(比如整机参数表),那省下的就是实打实的带宽和 CPU 了。 换句话说:小表用它买语义,大表用它买性能,两种理由都成立,但不要把后者当成前者的借口。
逐字节 diff 的两个前提
我个人的理解是,这种“把结构体当成字节序列来比较”的做法有两个前提,缺一个就会出怪问题:
-
结构体必须保证没有“随机填充字节”。编译器为了对齐会在成员之间插入填充,
而填充字节的内容是不确定的;如果不打包,就会出现“什么都没改,diff 却永远为真”的现象。
网络协议的结构体我一律用
#pragma pack(push,1)包起来。 - 结构体里不能有“每包都在变”的字段。比如本地时间戳、序列号自增这一类, 放进去之后 diff 会永远为真,按需发送就退化成全量发送了,而且不容易发现。
六、掉线判定与重连:三次、六秒这些数字是怎么定下来的
掉线判定的核心不是“用哪个信号”,而是不要用单次事件下结论。 网络上偶尔丢一包、任务调度偶尔抖动一下,都会造成一次失败; 拿一次失败就判定“设备没了”,系统会变得神经质。 所以这里的判据统一是“同一件事连续失败 3 次”。
| 环节 | 取值 | 为什么是这个值 |
|---|---|---|
| 发送后等回复 | 3 秒 | 一次“对端还在不在”的观察窗口;再长会拖慢上报节拍,再短容易把慢响应误判成失败 |
| 判定掉线 | 连续 3 次失败 | 单次失败可能只是干扰或调度抖动;3 次 × 3 秒,最坏约 9 秒下结论 |
| 断开后重连间隔 | 6 秒 | 对端可能还没起来,立刻重连会变成连接风暴;6 秒把重试密度压到每分钟十次以内 |
| 连接失败重试 | 3 秒 | 连接阶段失败通常是“对端没就绪”,给一点时间 |
| 绑定端口失败重试 | 1 秒 | 本地资源问题,通常很快就恢复,不必等太久 |
| 板间 Modbus 单次超时 | 1000 ms | 板内网的响应时间远小于此,超时定短一点能更快发现问题 |
哪些失败会计入重试?我这里是三种:接收超时、包长不对、 CRC32 校验不过。三者任一发生就重试计数加一,连续到 3 次就断开重连。 一个容易忽略的点是:包长和校验失败同样值得重试, 因为它们和丢包一样属于“这一次的传输不可信”,而不是“请求本身不合法”。
分级重试:在线重试 3 次,离线只试 1 次
这条经验是从 RS485 那一侧带过来的,但通用性很强: 如果所有设备都按“失败重试 3 次”处理,那么一台已经掉线的设备, 每一轮轮询都要白等 3 个超时,整个轮询周期会被它一个人拖垮。
所以后来统一改成:设备在线时,超时重试 3 次;一旦判定离线,每轮只发一次、不等重试, 把时间还给其他设备。这个改动同时应用到了光源、断路器和云台三条链路上。 在网络侧的道理是一样的——一条已经断掉的连接,不值得再往里压三个 3 秒。
重连必须“关掉重建”,不能只“再发一次”
这一点我是看代码时想明白的:socket 关闭之后,它的描述符是会被回收再分配的。 如果继续拿着旧的描述符发送,很可能发到了一个已经被别的 socket 占用的号上, 表现就是“发送返回成功,但对端永远收不到”——这种问题比直接报错难查得多。
所以重连路径我是这样写的:关闭当前 socket → 等待 6 秒 → 重新
socket / bind / connect,
把“连接状态”整体重建一遍,而不是在原连接上做补救。
面板侧的 Modbus 主机也是同样的处理:连续失败超过 3 次就跳出去重建 socket 与绑定,
不在旧连接上继续挣扎。
七、关闭 socket 的坑:为什么要在意 TIME_WAIT
TCP 的正常关闭是四次挥手,主动关闭的一方在挥手完成后会进入
TIME_WAIT 状态,并且停留 2 倍报文最大生存时间——在常见的通用操作系统上是一分钟量级。
它的本意是好的:确保最后的确认能到达、让本次连接的迟到报文自然消亡。
但在嵌入式设备上,它带来一个很具体的麻烦: 断开之后想立刻用同一个本地端口重新绑定,会失败(地址已被占用)。 而这类设备的端口是固定的(要和上位机约定一致),不像通用程序可以随便换端口, 于是“重连”就变成了“等一分钟”。
我用了两个办法,它们是配套的:
-
SO_REUSEADDR:允许绑定处于TIME_WAIT的本地地址, 让重建连接不被残留状态挡住。 -
SO_LINGER且超时设为 0:关闭时直接发 RST,跳过四次挥手, 本次连接不进入TIME_WAIT。
/* 关闭连接:让这次连接“干净地死掉”,再交给上层重建(按个人理解重写的最小片段) */
static void conn_drop(int sock)
{
struct linger lg;
shutdown(sock, SHUT_RDWR); /* 先停掉收发两个方向 */
lg.l_onoff = 1;
lg.l_linger = 0; /* 0 秒 = 关闭时直接发 RST,不进 TIME_WAIT */
setsockopt(sock, SOL_SOCKET, SO_LINGER, &lg, sizeof(lg));
closesocket(sock);
/* 调用方接下来延时 6 秒,然后重新 socket() / bind() / connect() */
}这个做法是有代价的,不能到处用
RST 和正常关闭在语义上完全不同:RST 会丢弃双方缓冲区里还没有交付的数据, 对端看到的是“连接被复位”而不是“对方正常收工”。 所以它只适合一种场景:我已经判定这条连接不可用了,现在要立刻重建。
如果是正常的收尾(比如设备要主动下线、先把最后一份状态发完再走), 就应该老老实实走正常关闭流程,让对端收到完整的结束信号。 把“强制复位”当成默认的关闭方式,迟早会丢掉一份本来能送到的重要数据。
八、没插网线时不能卡死:初始化顺序的调整
这个问题是被现场的一次使用逼出来的:设备不接网线的时候,整个程序起不来。 现象很直接——上电以后什么都不做,串口也没有任何输出,像是卡在某处。
原因并不复杂:原来的启动流程是“初始化网络 → 确认网络可用 → 继续做后面的事”, 而“确认网络可用”依赖物理链路。网线没插的时候,链路永远不会就绪, 于是启动流程就死等在一个永远不会发生的事件上。
改法是把网络初始化从“启动阶段的一次性动作”改成“空闲时反复尝试的状态迁移”:
- 用一个标志位表示“网络是否已经初始化完成”,初始为 0;
- 在空闲处理里检查这个标志:如果是 0,就去读 PHY 的状态寄存器, 判断链路是否已经建立(读的是 PHY 基本状态寄存器里的链路状态位);
- 链路没建立就延时 1 秒再来看一次,并打印一行“等待网线插入”的提示——这样人一眼就知道设备在等什么;
- 链路建立之后,按顺序做三件事:初始化协议栈 → 初始化板间通信 → 初始化状态上报, 完成后把标志置 1,这段逻辑以后不再进入。
这张表是这个改动前后的对比,我觉得它比代码本身更值得记:
| 等待的对象 | 阻塞式写法(有问题) | 轮询式写法(改后) |
|---|---|---|
| 网线接入 / 链路建立 | 启动时一次等待,不成功就卡在那里 | 空闲时每秒读一次 PHY 状态,未就绪就继续等,就绪了再往下走 |
| 协议栈与任务创建 | 作为启动流程的固定阶段,必须走完才能进主循环 | 作为“链路就绪”之后的动作,用标志位保证只做一次 |
| 人的可观测性 | 设备毫无反应,只能猜 | 周期性打印“等待网线插入”,一眼看出在等什么 |
三个实现细节
- 顺序不能乱。协议栈初始化、板间通信初始化、状态上报初始化这三步有依赖关系, 必须按顺序执行;后一步依赖前一步建好的 socket 与任务。
-
任务创建那段要放进临界区。我在创建上报任务时用
taskENTER_CRITICAL()/taskEXIT_CRITICAL()把这段临界区包起来。 原因是新建的任务优先级可能比当前任务高,如果不加保护,调度器可能在“任务创建到一半”的时候切走, 让新任务在资源还没准备好时就开始跑。 - 用轮询读 PHY 而不是等中断。轮询要占一点 CPU,但每秒一次的开销可以忽略, 而且省掉了 PHY 中断引脚的依赖,少一个硬件约束。
把这条经验一般化一下就是:凡是“等待外部世界就绪”的初始化,都应该写成“可重入的轮询状态”, 而不是“阻塞等待”。外接传感器、下位机、上位机软件,本质上都和“网线有没有插”是一类问题—— 它们都不由你控制,所以你不能把整个系统的启动押在它们身上。
九、几条我反复用到的经验
- 先问“这份数据过期得快不快”,再决定用 UDP 还是 TCP。 过期快、丢一包能自愈的用 UDP;要完整、要顺序的用 TCP。
-
不要把阻塞收当成默认写法。用
select加上超时, 任务才有机会做第二件事,也才有可能判断对端在不在。 - 超时的语义由调用方定义。封装函数只负责“等多久”,不负责“超时算不算失败”。
- 心跳的判据是“接收侧能不能区分没数据和没对端”。 能区分就可以省,不能区分就必须有。
- 心跳一定要能被外部观测。抓包、对端计数、日志,至少留一种, 否则“写了但没生效”是最常见的结局。
- 重试的判据是“连续失败次数”,不是“单次失败”。 单次失败分不清干扰和对端消失。
- 在线重试、离线降级,这个分层要显式写出来。 否则一台掉线设备就能拖垮整个轮询周期。
- 重连要重建 socket,不要在旧连接上补救。 描述符会被回收再分配,“发送成功但对方收不到”就是这么来的。
-
用
SO_LINGER绕开TIME_WAIT,但要清楚它丢的是数据。 它只适用于“判定连接已不可用、立刻重建”的场景。 - 任何等待外部世界的初始化都要能被打断。 把它改成轮询状态,并且打印出你在等什么。
参考资料与说明
- LwIP 官方文档与源码中的 socket API、
select实现说明(LwIP 为开源协议栈,其文档与头文件为公开发布内容)。 - FreeRTOS 官方文档中关于任务、延时与临界区的说明(FreeRTOS 为开源实时内核)。
- 以太网 PHY 数据手册中关于基本状态寄存器与链路状态位(Link Status)的定义,属于公开的芯片资料。
- Modbus 应用协议规范(Modbus Application Protocol Specification)中关于功能码与报文格式的定义。
- 关于代码来源:文中描述的网络框架最初移植自一份第三方的以太网例程, 其中包含 LwIP 网络驱动与 FreeRTOS 的移植层,该部分的知识产权属于原作者。 本文只描述整体架构与我在应用层所做的改动(心跳与掉线判定、按需上报、重连策略、初始化顺序调整等), 不转载对方代码,也不包含其版权声明。文中的代码片段是按个人理解重写的最小示例。
- 文中涉及的芯片与 PHY 型号仅用于说明技术方案,与相关厂商无隶属或授权关系; 示例代码不代表任何产品或交付代码。