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

以太网 UDP 通信的工程化:
心跳、掉线判定与重连

在实验室里让 UDP 通,通常只要十几行代码;让它在现场连续跑下去不出问题,要处理的是另外几件事—— 怎么知道对端还在、断了以后怎么回来、以及网线根本没插的时候别把自己卡死。 这篇笔记记录我在 FreeRTOS + LwIP 上做板间通信时,对这几个问题的理解、实际取值和踩过的坑。

LwIP UDP FreeRTOS 心跳 掉线重连 已发布

一、为什么选了 UDP 而不是 TCP

我最早接触这套板间通信的时候,第一反应是“用 TCP 更稳”——毕竟 TCP 有确认、有重传、有顺序保证, 听起来就是“不会丢数据”的代名词。真正做下去才发现,这里要传的东西根本不在乎“一条都不丢”, 它在乎的是“我拿到的是不是此刻的值”。

具体场景是这样的:一块控制面板板负责本地人机输入,扫描 20 个按键、11 路 ADC (9 个亮度旋钮加操纵杆的 X / Y 两轴);另一块控制内核板负责整机控制。 面板每 20 ms 把当前所有输入值打包送上去,内核按 Modbus TCP 的报文格式解析,站号约定为 0x0b。也就是说:Modbus TCP 的报文格式,跑在 UDP 的 socket 上。

这个组合第一次看到会觉得别扭,但用下来我认为它在这个场景里是合理的,理由有三条:

  1. 时效性优先于完整性。按键值和旋钮值都是“此刻的状态”,20 ms 之后就有新的。 丢一包,等下一包就自愈了;而 TCP 的重传会把后面排队的新数据一起堵住(队头阻塞), 对控制来说,“迟到的正确值”远不如“按时到达的当前值”。
  2. 无连接让恢复路径变短。现场调试时会直接拔网线、重启对端设备。 UDP 没有握手状态,对端回来了就继续发;TCP 则必须显式走完重建连接的过程。
  3. 资源占用更小。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 秒不是理论值,是我在看显控界面时的容忍上限定出来的—— 状态显示卡住超过两秒,我就会开始怀疑链路,所以心跳必须比这个更快。

链路循环节拍无变化时的计数上限兜底心跳周期
内核 → 显控 PC100 ms20 次2 秒
面板 → 内核(板间输入)20 ms100 次2 秒
上下两条时间线,上线:数据有变化就立即发送,下线:数据无变化时按固定周期发送心跳包,标注心跳周期
图 1 · 心跳与按需上报的时序图

五、变化即发:用“结构体逐字节 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 的两个前提

我个人的理解是,这种“把结构体当成字节序列来比较”的做法有两个前提,缺一个就会出怪问题:

  1. 结构体必须保证没有“随机填充字节”。编译器为了对齐会在成员之间插入填充, 而填充字节的内容是不确定的;如果不打包,就会出现“什么都没改,diff 却永远为真”的现象。 网络协议的结构体我一律用 #pragma pack(push,1) 包起来。
  2. 结构体里不能有“每包都在变”的字段。比如本地时间戳、序列号自增这一类, 放进去之后 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 与绑定, 不在旧连接上继续挣扎。

状态圆框串联:等待链路 → 创建套接字 → 收发 → 超时 → 关闭并重建 → 回到创建,标注每个状态的进入条件与超时行
图 2 · 网络通信状态机图

七、关闭 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 会丢弃双方缓冲区里还没有交付的数据, 对端看到的是“连接被复位”而不是“对方正常收工”。 所以它只适合一种场景:我已经判定这条连接不可用了,现在要立刻重建。

如果是正常的收尾(比如设备要主动下线、先把最后一份状态发完再走), 就应该老老实实走正常关闭流程,让对端收到完整的结束信号。 把“强制复位”当成默认的关闭方式,迟早会丢掉一份本来能送到的重要数据。

八、没插网线时不能卡死:初始化顺序的调整

这个问题是被现场的一次使用逼出来的:设备不接网线的时候,整个程序起不来。 现象很直接——上电以后什么都不做,串口也没有任何输出,像是卡在某处。

原因并不复杂:原来的启动流程是“初始化网络 → 确认网络可用 → 继续做后面的事”, 而“确认网络可用”依赖物理链路。网线没插的时候,链路永远不会就绪, 于是启动流程就死等在一个永远不会发生的事件上。

改法是把网络初始化从“启动阶段的一次性动作”改成“空闲时反复尝试的状态迁移”:

  1. 用一个标志位表示“网络是否已经初始化完成”,初始为 0;
  2. 在空闲处理里检查这个标志:如果是 0,就去读 PHY 的状态寄存器, 判断链路是否已经建立(读的是 PHY 基本状态寄存器里的链路状态位);
  3. 链路没建立就延时 1 秒再来看一次,并打印一行“等待网线插入”的提示——这样人一眼就知道设备在等什么;
  4. 链路建立之后,按顺序做三件事:初始化协议栈 → 初始化板间通信 → 初始化状态上报, 完成后把标志置 1,这段逻辑以后不再进入。

这张表是这个改动前后的对比,我觉得它比代码本身更值得记:

等待的对象阻塞式写法(有问题)轮询式写法(改后)
网线接入 / 链路建立 启动时一次等待,不成功就卡在那里 空闲时每秒读一次 PHY 状态,未就绪就继续等,就绪了再往下走
协议栈与任务创建 作为启动流程的固定阶段,必须走完才能进主循环 作为“链路就绪”之后的动作,用标志位保证只做一次
人的可观测性 设备毫无反应,只能猜 周期性打印“等待网线插入”,一眼看出在等什么

三个实现细节

  • 顺序不能乱。协议栈初始化、板间通信初始化、状态上报初始化这三步有依赖关系, 必须按顺序执行;后一步依赖前一步建好的 socket 与任务。
  • 任务创建那段要放进临界区。我在创建上报任务时用 taskENTER_CRITICAL() / taskEXIT_CRITICAL() 把这段临界区包起来。 原因是新建的任务优先级可能比当前任务高,如果不加保护,调度器可能在“任务创建到一半”的时候切走, 让新任务在资源还没准备好时就开始跑。
  • 用轮询读 PHY 而不是等中断。轮询要占一点 CPU,但每秒一次的开销可以忽略, 而且省掉了 PHY 中断引脚的依赖,少一个硬件约束。

把这条经验一般化一下就是:凡是“等待外部世界就绪”的初始化,都应该写成“可重入的轮询状态”, 而不是“阻塞等待”。外接传感器、下位机、上位机软件,本质上都和“网线有没有插”是一类问题—— 它们都不由你控制,所以你不能把整个系统的启动押在它们身上。

九、几条我反复用到的经验

  1. 先问“这份数据过期得快不快”,再决定用 UDP 还是 TCP。 过期快、丢一包能自愈的用 UDP;要完整、要顺序的用 TCP。
  2. 不要把阻塞收当成默认写法。用 select 加上超时, 任务才有机会做第二件事,也才有可能判断对端在不在。
  3. 超时的语义由调用方定义。封装函数只负责“等多久”,不负责“超时算不算失败”。
  4. 心跳的判据是“接收侧能不能区分没数据和没对端”。 能区分就可以省,不能区分就必须有。
  5. 心跳一定要能被外部观测。抓包、对端计数、日志,至少留一种, 否则“写了但没生效”是最常见的结局。
  6. 重试的判据是“连续失败次数”,不是“单次失败”。 单次失败分不清干扰和对端消失。
  7. 在线重试、离线降级,这个分层要显式写出来。 否则一台掉线设备就能拖垮整个轮询周期。
  8. 重连要重建 socket,不要在旧连接上补救。 描述符会被回收再分配,“发送成功但对方收不到”就是这么来的。
  9. 用 SO_LINGER 绕开 TIME_WAIT,但要清楚它丢的是数据。 它只适用于“判定连接已不可用、立刻重建”的场景。
  10. 任何等待外部世界的初始化都要能被打断。 把它改成轮询状态,并且打印出你在等什么。

参考资料与说明

  • LwIP 官方文档与源码中的 socket API、select 实现说明(LwIP 为开源协议栈,其文档与头文件为公开发布内容)。
  • FreeRTOS 官方文档中关于任务、延时与临界区的说明(FreeRTOS 为开源实时内核)。
  • 以太网 PHY 数据手册中关于基本状态寄存器与链路状态位(Link Status)的定义,属于公开的芯片资料。
  • Modbus 应用协议规范(Modbus Application Protocol Specification)中关于功能码与报文格式的定义。
  • 关于代码来源:文中描述的网络框架最初移植自一份第三方的以太网例程, 其中包含 LwIP 网络驱动与 FreeRTOS 的移植层,该部分的知识产权属于原作者。 本文只描述整体架构与我在应用层所做的改动(心跳与掉线判定、按需上报、重连策略、初始化顺序调整等), 不转载对方代码,也不包含其版权声明。文中的代码片段是按个人理解重写的最小示例。
  • 文中涉及的芯片与 PHY 型号仅用于说明技术方案,与相关厂商无隶属或授权关系; 示例代码不代表任何产品或交付代码。