提到以太网温湿度采集,很多人第一反应是“接根网线,把温湿度数据发到平台就行”。真做过多协议设备的人会明白,重头戏从来不在一开始的一两个数据帧,而在断线重连和断点续传。仓库、机房、冷链这类场景里,网络不可能永远稳定,交换机可能半夜自动重启,光纤收发器偶发丢包,物业维修还会拔错网线。你的采集终端这时候如果不会自动恢复,或者恢复之后把数据从零开始重传,平台上的温度曲线就会缺失、乱序,甚至整台设备要人工断电重启。这篇文章就围绕一套典型的以太网温湿度采集通讯系统,讲讲为什么需要多协议支持,断线重连怎么做,断点续传的落点在哪里,以及我在实际调试中踩过的坑。
1. 整体设计思路:为什么多协议、断线与续传会成为核心
1.1 温湿度采集设备的真实工作场景
很多朋友把温湿度采集想成办公室环境,网线一插就稳定了。实际上工业、医疗、冷链现场的以太网接口,物理条件要恶劣得多:设备往往部署在机柜、桥架、冷库夹层里,网线可能和动力电缆走同一个竖井;网络侧是百兆工业交换机,长时间高负载运行后偶尔死机;光纤收发器供电不稳时丢包;更常见的是施工改造时直接把某一段网线剪断。设备采集温度、湿度之后,既可能被本地组态软件通过 Modbus TCP 轮询,又要把数据上报到云平台。可以说,断线在这里不是概率问题,而是时间问题。
在这种工况下,用户真正关心的是三件事:第一,断网一段时间后,设备能不能自动回来,不需要现场人员按复位键;第二,断网期间的数据能不能补回来,如果不能补,这个监测系统就失去了意义;第三,现场以后可能换平台,设备能不能通过配置切换协议,而不是重新烧一版固件。这三个需求直接引出多协议、断线重连、断点续传三个关键词。它们不是三个独立功能,而是一套数据链路完整性的组合设计。
1.2 多协议选型:Modbus TCP、MQTT、HTTP 怎么选
多协议不是把能用的协议全部堆上去,而是要给设备留一个协议抽象层,让同一套采集逻辑、缓存逻辑和断点续传逻辑,可以通过配置切换不同的“外发通道”。我在实际项目中通常只考虑三种:Modbus TCP、MQTT、HTTP/HTTPS。它们的典型对象和侧重点完全不同。
| 协议 | 典型对接对象 | 适合场景 | 重连侧重点 |
|---|---|---|---|
| Modbus TCP | PLC、SCADA、组态软件 | 本地局域网 | 轮询超时、异常连接清理 |
| MQTT | 云平台、IoT 网关 | 广域网长连接 | keepalive、cleanSession、QoS |
| HTTP/HTTPS | 自建平台、REST API | 低频上报、批量补传 | token 过期、幂等键、批量游标 |
选择的时候有一个原则:如果平台方明确支持 Modbus TCP,优先用 Modbus,因为工业现场最看重兼容性,调试工具遍地都是;如果平台是云端物联网平台,MQTT 是标准做法,心跳和会话机制比你自己造的长连接可靠得多;如果只是对接一个内部管理系统,HTTP 最省事,服务端甚至不用维护长连接。设备固件里把这三种协议注册成三个驱动,上层统一使用“连接、发送、接收、确认”四类接口,切换协议时只需要改配置,不需要动缓存和断点续传逻辑。
1.3 断线重连与断点续传在整体架构中的位置
如果把整个温湿度采集终端分成两层来看,断线重连管的是链路,断点续传管的是数据。链路是水管,数据是水。断线了要重新接水管,接好后不能只把新水送过去,还要把断管期间存在水罐里的水按顺序补送。所以架构上从上到下分为采集模块、缓存模块、协议模块、连接管理层。采集模块负责定时读取温湿度并打点;缓存模块负责把没有确认的数据落盘;协议模块决定这一条数据走 Modbus、MQTT 还是 HTTP;连接管理层负责探测链路、实施重连策略。
这里还要说清楚一个容易混淆的概念:断点续传和完整上传是两回事。完整上传是断网恢复后,把缓存里的数据全部重新发给平台,平台自己想办法去重;断点续传是设备记录一个“已确认收到的位置”,只发送这个位置之后的数据。打个比方,一批货要从 A 仓运到 B 仓,快递车半路抛锚,重新派车后从哪一单继续送?只送还没确认签收的,而不是从第一单重新装车。这个“确认签收”的机制,就是断点续传的核心。
2. 核心机制拆解:断线重连与断点续传的实现原理
2.1 怎么判断链路还活着,以及重连间隔怎么设
判断链路是否存活,不能只靠一个信号。我一般分三层来看:物理层看以太网 PHY 的 link 状态,网线断开、交换机断电时 link 会 down,但网线完好、核心交换机重启时,物理层可能仍然是 up 的,所以不能只依赖 link 状态。TCP 层看 socket,读返回 0、写返回 EPIPE 或者 ECONNRESET,都能说明连接断了,但这属于事后通知,太被动。真正可靠的是应用层心跳:MQTT 有 PINGREQ,Modbus TCP 可以周期性读一个保持寄存器当探活,HTTP 则靠定时上报来隐式探活。
心跳间隔建议设在 15 到 30 秒,连续 3 次无响应就判定链路故障,主动 close 当前连接,进入重连流程。重连不能固定间隔,否则多台设备同时检测到故障后,会在同一时刻发起重连,把交换机和服务器的连接数瞬间打满。我常用的策略是指数退避加随机抖动:第一次 1 秒,第二次 2 秒,第三次 4 秒,到最大 30 秒封顶,每次再乘一个 0.8 到 1.2 的随机系数。公式可以写成delay = min(30s, 1s * 2^n) * random(0.8, 1.2),n 是连续失败次数。抖动的作用是把几十台设备的重连请求在时间轴上散开,避免“故障恢复瞬间大家都来抢连接”。
另一个容易被忽略的点是:重连成功不等于可以立刻补传。我习惯在 TCP 建连成功之后,先做鉴权或者发送一条探测消息,确认这 1 到 2 秒内链路稳定,再开始批量补传。否则网络处于“刚恢复又断掉”的临界状态时,数据发到一半又丢,反而加剧了后续的乱序和重复。
2.2 断点续传的本质:记录“已发到哪里”
断点续传一句话就能说清楚:记录平台已经确认收到的数据序号,只发送这个序号之后的数据。为了做到这一点,设备里每一条温湿度数据都要有独立的序号和采样时间。我在 MCU 上定义的数据结构大致是下面这样:
typedef struct { uint32_t seq; // 序号,单调递增 uint32_t timestamp; // 采样时间,Unix 时间戳 int16_t temperature; // 温度,单位 0.1℃ uint16_t humidity; // 湿度,单位 0.1% uint8_t sensor_id; // 传感器通道号 } sensor_data_t;设备发送线程维护一个last_acked_seq,每次从last_acked_seq + 1开始取数据发送;服务端每收到一批,就返回一个ack_seq,表示“这个序号之前的数据我都收到了”。设备收到ack_seq后更新水位线,并把小于等于ack_seq的缓存释放掉。如果断网期间缓存了 1000 条数据,网络恢复后只需要从第一条未确认数据开始补,而不需要把所有缓存重新推一遍。这样做的好处很直观:节省带宽、降低平台压力、让数据按原始序号有序到达。
这个设计还要考虑掉电重启的情况。MCU 内部 SRAM 里的水位线,一掉电就没了,所以我要求水位线必须持久化到 Flash 或者外部存储里。每次成功收到ack后,不需要立刻擦写 Flash,可以先记在内存里,等积累到一定数量或者掉电前统一提交。但如果想要“任何时候掉电都不重不漏”,就得把last_acked_seq和写指针一起保存,并且带 CRC 校验。
2.3 时序对齐、幂等上报与“完整上传”的差异
断点续传做得再好,如果设备时间不准,补回来的数据曲线也会是乱的。设备在断网期间只能靠 RTC 走时,RTC 本身有精度误差,长时间离线后误差可能累积到分钟级。我的做法是:每条数据都使用设备产生的采样时间戳,但落库最终以服务端收到时校验后的时间戳为准;设备一旦联网,首先做 NTP 校时或者通过协议同步时间,再开始补传,这样能最大限度降低时间偏差。平台侧如果发现某条数据的seq和采样时间发生冲突,以seq为准来判断先后,因为seq是设备内单调递增的,时序不会乱。
至于幂等,这是解决重复问题的关键。网络超时重发、ack 丢失、协议切换,任何一个环节都可能让平台收到同一条数据两次。所以设备上报的每条数据必须有一个全局唯一标识,我用(sensor_id, seq)作为联合主键,服务端数据库直接针对这两个字段建唯一索引。重复发送时,后到的那一条会被数据库忽略,或者服务端主动查重后丢弃。这正是断点续传和完整上传在协议层面的根本区别:完整上传只告诉平台“我有一堆数据,你看着收”,断点续传则把“我已经确认到哪里、接下来从哪开始”作为协议的一部分显式传递出去。
3. 实操:三种协议下的重连与续传配置
3.1 Modbus TCP 轮询超时与重试参数
如果你的采集终端是作为 Modbus TCP 服务端,对上位机组态软件提供数据,那寄存器规划很重要。我一般把温度放在保持寄存器地址 0x0100,湿度放 0x0101,状态字放 0x0102,寄存器数值用 int16,温度值乘以 10 表示,避免浮点位数的歧义。设备端要关注的并不是传统意义上的“重连外网”,而是及时清理已经死掉的客户端连接:上位机主动断开后,设备必须 close 对应的 socket,否则连接数会被慢慢占满,新上位机连不上来。
如果设备是作为 Modbus TCP 客户端,去轮询下面的温湿度探头或者远程 IO,那重点就是超时和重试。我常用的参数是:功能码 03 读取保持寄存器,响应超时 500ms 到 1s,单次失败重试 2 到 3 次;连续 5 次轮询失败才判定链路故障,进入断线重连状态。不能一次超时就立刻重连,现场网络抖动很常见,误重连反而会让采集器在多个网络节点之间反复横跳。链路恢复后,不要急着补业务数据,先读一次设备型号或者状态寄存器确认链路正常,再恢复轮询。
3.2 MQTT 连接参数与会话保持的坑
MQTT 本身自带断线重连机制,但参数配不好,重连回来照样出问题。最关键的几个参数是 clientID、keepalive、cleanSession 和 QoS。clientID 必须唯一,我直接用设备 MAC 地址或者出厂序列号;如果有两台设备共用 clientID,broker 会把后连接的那台踢掉,现场排查起来非常莫名其妙。keepalive 我设 30 秒,broker 如果在 1.5 倍时间也就是 90 秒内收不到 PINGREQ,就会判定连接断掉。QoS 我选 1,既能保证消息不丢,又不像 QoS2 那样带来两倍以上的交互开销。
这里要说一个我踩过的坑:cleanSession 设 false 时,broker 会保存客户端离线期间订阅主题的消息,等设备重连上线后一股脑推过来。如果设备本地又有断点续传,同一批数据就会出现两条路同时补,平台收到两份。我后来做了取舍:后端平台本身有按(device_id, seq)去重的机制,所以直接用 cleanSession = true,离线期间的消息全部不要,完全靠设备本地缓存补传。这样逻辑更单一,也更可控。另外 LWT 遗嘱消息要配上,断线时 broker 会在离线状态 topic 发一条“设备离线”,平台侧可以马上感知,不用干等心跳超时。
3.3 HTTP 增量补传接口怎么设计
HTTP 本身无状态,断线重连的逻辑最简单:用定时上报作为探活,上报失败就进入退避重试。真正需要动脑子的是补传接口。我不建议用“上传全部缓存”这种接口,而是设计成批量增量补传,核心在请求里带start_seq和一批数据,响应里带ack_seq。一个典型的请求长这样:
POST /api/v1/device/TH200/telemetry/batch { "start_seq": 1520, "items": [ {"seq": 1520, "ts": 1710000000, "temp": 25.1, "hum": 60.2}, {"seq": 1521, "ts": 1710000010, "temp": 25.2, "hum": 60.1} ] }服务端处理完后返回:
{ "ack_seq": 1521 }设备收到ack_seq = 1521,就知道 1521 及之前的数据都确认了,下一次从 1522 开始。如果一批数据太大,网络传输容易失败,我一般控制在 100 条以内,或者限制请求体 64KB,超了就拆包。重试时要注意 HTTP 状态码:5xx 和网络超时可以重试,4xx 说明请求本身有问题,比如鉴权过期、数据格式错,这种重试多少次都没用,要进入故障状态上报日志,不能死循环。
4. 工程落地:状态机、存储保护与采样策略
4.1 通信状态机与多协议抽象层
多协议、断线重连、断点续传一起做,最怕业务逻辑散落在各个协议回调里。我会在固件里定义一个统一的状态机,任何协议都跑同一套状态流转。核心状态就 5 个:IDLE、CONNECTING、ONLINE、RETRYING、DRAIN。IDLE 是上电初始化;CONNECTING 正在建连;ONLINE 是正常收发;RETRYING 是等待下一次重连;DRAIN 是链路刚恢复、正在补传缓存。状态之间的关系大概是下面这个伪代码:
void comm_poll(comm_state_t *s) { switch (*s) { case IDLE: load_config(); *s = CONNECTING; break; case CONNECTING: if (protocol_connect() == OK) *s = DRAIN; else *s = RETRYING; break; case DRAIN: if (flush_cache() == CACHE_EMPTY) *s = ONLINE; else *s = RETRYING; break; case ONLINE: if (!protocol_heartbeat_ok()) *s = RETRYING; break; case RETRYING: delay(next_backoff()); *s = CONNECTING; break; } }这样做的最大好处是,更换协议时只需要实现connect()、send()、recv()、ack_handle()四个接口,状态机、缓存机制、重连策略完全不用动。我在项目中从 MQTT 切到 HTTP,只改配置和注册表,一版固件同时支持三种协议,现场的维护成本低了很多。
4.2 掉线缓存的数据结构与掉电保护
断点续传的缓存不能只放内存,必须考虑掉电。最稳妥的方案是在 Nor Flash 里划分两个数据区,交替写入。数据区的尾部放一个结构体,记录写位置和确认位置:
typedef struct { uint32_t magic; uint32_t write_idx; uint32_t last_acked_seq; uint32_t crc32; } storage_footer_t;写入时先写数据,再更新 footer,最后写 magic;启动时先校验 magic 和 CRC,如果发现当前区损坏,就回退到上一个完整区。这样即使写数据时突然掉电,最坏情况也只是丢掉最后几条未确认数据,而不是整个缓存全部报废。要注意 Flash 是扇区擦除的,不能每来一条数据就写一次。我一般是攒够一批,比如 10 条或者 128 字节,再提交一次,写间隔控制在秒级,这样既保证完整性,又不磨损 Flash。
这里的结构还要和协议无关。不管是用 MQTT 还是 HTTP 补传,读的都是同一份缓存,发送后从同一个last_acked_seq推进。千万别为每个协议单独维护一个水位线,否则切换协议时会出现“MQTT 已经确认到 2000,HTTP 还从 1500 开始补”的重复上报,平台会被搞疯。
4.3 采样速度、缓存容量与溢出策略估算
设计缓存容量之前,先算清楚断网多久数据不能丢。我给一个简单的计算基准:一条温湿度记录按前面的结构大约 12 字节,不包括文件系统开销。如果 10 秒采一次,一天是 8640 条,大约 101KB;30 秒采一次,一天 2880 条,大约 34KB;60 秒采一次,一天 1440 条,大约 17KB。按这个算,1MB 的 Flash 在 10 秒采样间隔下大概能存 9 天多,8MB 能存 70 多天,绝大多数现场都够用了。
如果断网时间实在太长,缓存满了怎么办?不能什么都不做,那样新数据也存不进去。我常用的策略分三级:第一级,断线超过半小时后自动降低采样频率,从 10 秒降到 30 秒,省缓存;第二级,缓存使用率达到 80% 后,主动丢弃最旧但尚未确认的数据,同时把故障状态上报到平台;第三级,如果丢数据量超过阈值,本地 LED 故障灯点亮,等着现场处理。说实话,真到这一步说明网络故障已经不是一天两天,继续死撑没有意义,不如把“缓存溢出、有数据丢失”这个事实明确告诉平台,避免后面数据分析时拿不完整曲线当完整曲线用。
5. 现场常见问题与排查经验
5.1 半开连接:TCP 没报错,数据就是不出去
最常见的问题不是网线断了,而是网络中间设备重启导致半开连接。现象是采集器侧 socket 还显示 established,但平台侧早已没有这个连接;采集器一直发数据,平台一直收不到。我现场排查时用netstat -an看到连接状态是 ESTABLISHED,但抓包发现 TCP 重传了很多次,一直没有 ACK 回来。这种情况下应用程序如果只等着收数据,可能永远等不到错误事件。
解决思路就是前面说的三层探活:物理层 link、TCP keepalive、应用层心跳,缺一不可。Linux 下可以用SO_KEEPALIVE,但默认很难用,我一般把空闲时间调到 10 秒、探测间隔 5 秒、探测次数 3 次;MCU 上直接自己实现应用层心跳更可控。连续 3 次心跳无响应,就主动 close 当前 socket,进入 RETRYING。宁可主动断掉一个可能还活着的连接,也不能让数据在一个假连接上无限阻塞。
5.2 补传重复、乱序怎么根治
补传重复的直接原因,是设备发的 ack 确认在网络上丢了,设备超时后从旧水位线重发,平台收到两条一模一样的数据。如果平台没有去重机制,库里就会出现重复记录,温度曲线出现毛刺。乱序则往往是因为补传线程和实时上报线程同时工作,实时数据 seq 大的先到了,补传 seq 小的后到。这两个问题本质上都是协议设计问题,可以在两端同时解决。
设备端坚持“单线程补传”,补传期间暂停实时上报,或者把实时数据也合并进同一个发送队列,保证seq严格递增。平台端必须给每个设备维护一个连续序号表,落库时以(device_id, seq)为主键,重复直接忽略;如果收到 2000 号数据但 1980 号还没到,可以把 2000 先放到一个乱序缓冲里,等这个设备的 seq 缺口补齐后再写正式表。补传窗口可以做得小一点,比如一次 100 条,服务端返回ack_seq后设备基于最新的ack_seq继续,这样就算重复,范围也很可控。
5.3 协议切换后的状态残留
支持多协议的设备,最怕现场从 MQTT 切到 HTTP 后,旧 MQTT 连接还在偷偷发数据。原因通常是协议切换逻辑只改了配置,没有把旧协议的发送线程停掉,也没有关闭旧连接。我做过一个比较极端的现场,后台配置已经切到 HTTP,但设备里 MQTT 线程因为一直没有收到断开指令,还在按自己的重连策略往老 broker 上连,结果同一批温湿度数据同时从两个协议通道到了平台,查了半天才发现是协议切换状态残留。
正确的切换流程是:下发新配置后,先把旧协议连接主动 close,等发送线程退出,再清空当前发送队列,但千万不能清空last_acked_seq。断点续传的“断点”是全局的,应该跟着设备走,而不是跟着协议走。新协议上线后从同一个last_acked_seq + 1开始补传,才能做到不丢不重。另外 MQTT 连接切换时还有一个隐蔽坑:旧连接没有正常断开时,新连接复用同一个 clientID 会被 broker 判定冲突,直接踢掉。先 close 旧连接,再等待几秒,和 broker 完成会话清理后再 connect 新连接,这个问题就消失了。
最后说一点个人体会。我在实际测试里,不会只拿正常网络的电脑试设备,一定会造几种故障:拔网线、交换机断电、后台服务重启、路由器改 IP、同时给多台设备断电再上电。每一轮测试下来,断线重连和断点续传都能暴露几个新问题。刚开始做这套设计的时候,我也觉得“只要把协议打通就行”,后来发现链路的生命周期管理才是真正拉开设备稳定性的地方。你现在做以太网温湿度采集,如果还没有专门写一个断线重连状态机和last_acked_seq的概念,建议尽早补上,越早设计,后面现场维护越是省心。