做温湿度采集系统这些年,真正让我折腾到头秃的地方,从来不是传感器精度不够,而是通信链路本身。早期接一个冷链仓储项目,现场用普通以太网线连了几十个温湿度采集终端,原本觉得有线比无线稳多了,结果上线第一周就撞上交换机夜间自动重启,第二天早晨一堆终端同时恢复连接,不仅排队把数据砸向服务器,还因为内存缓存溢出丢掉了一部分关键历史数据。从那以后我就把多协议接入、断线重连、断点续传三件事作为通信层的头等大事来设计,这篇文章就把整套机制的设计思路、状态机逻辑、缓存结构和实测排障过程整理出来,希望对正在做类似物联网采集设备的同行有用。
1. 项目背景与需求拆解:为什么通信可靠性成了温湿度采集的“生死线”
1.1 一个看似成熟场景里反复翻车的通信问题
温湿度采集本身是非常成熟的场景,传感器、ADC、温度换算、上报,这些环节都有现成方案。但真正到了现场,问题往往不出在采集上,而是出在“数据怎么送到服务器”这件事上。
最常见的问题是断网。以太网虽然比无线稳定,但交换机重启、网线被叉车碰松、施工误拔机柜跳线、光缆被挖断——这些都真实发生过。一旦断网,终端采集到的数据要往哪里放?放在内存里,断网一长就会溢出;放在Flash里,要考虑写入寿命和掉电安全;网络恢复后,是先补历史数据还是先发实时数据?这些细节如果不提前设计,几乎必然在某个深夜的告警电话里暴露出来。
我接手过一个机房环境监控改造项目。原有终端只有一种私有TCP上报协议,现场工程师配置时漏改了一行服务器端口,整个片区的设备全都在重试连接,每秒一次,持续了一个多小时,直接把前置机卡死。这事的根因不是配置错——配置错总是难免的——而是系统对“连不上”没有任何合理的应对策略,暴力重试把一个小问题放成了大事故。
1.2 需求拆解:一采集、二上传、三不丢
重新设计通信层之前,我把需求拆成了三条,写进了需求文档,后面的所有设计都是围绕这三条展开的:
- 采集粒度可配置:温湿度数据本身变化慢,采样周期从10秒到5分钟都能接受,但不同现场要求不一样,不能写死。
- 上报通道必须支持多协议:有的现场要对接车间里的WinCC组态系统,用Modbus TCP最方便;有的要接公司自建的MQTT云平台;还有的是政府监管或第三方平台的HTTP上报要求,各家接口格式都不一样。
- 断网期间数据不能丢、不能乱、不能和实时数据混淆:断网可能是几秒,也可能是一整天。终端的存储能力、续传顺序、实时数据优先级都必须提前设计清楚。
这三条需求单看都不难,但把它们同时塞进一块低成本的MCU固件里,难度就全集中在通信架构的细节设计上了。
2. 多协议接入:Modbus TCP、MQTT、HTTP 的分工与统一封装
2.1 三种协议在温湿度场景中各自的定位
很多做采集终端的同行会纠结“到底该支持什么协议”,我的观点是:别问支持什么最好,要问现场可能遇到什么。常见的三种协议各有各的主场:
| 协议 | 典型接入方 | 特点 | 适用场景 |
|---|---|---|---|
| Modbus TCP | PLC、组态软件、SCADA | 请求-响应式,从站被动,寄存器读写 | 车间、机房接老系统,上位机轮询读取 |
| MQTT | 云平台、物联网中间件 | 主动上报,有QoS,broker持久会话 | 自建云平台、阿里云/腾讯云IoT |
| HTTP/REST | 第三方平台、Web服务 | 简单直接,防火墙友好 | 政务监管平台、Web接口上报 |
这里要特别说明的是Modbus TCP。它和我们常说的“主动上报”不太一样——它是被人拉的。上位机作为主站,终端作为从站响应寄存器读取。所以Modbus这条通道本身不存在“断线续传”的概念,它的续传要反过来设计:把终端缓存区的积压量和最新未确认序列号映射到几个只读寄存器里,上位机轮询时发现数据缺口,主动补齐。实际上在冷链仓储和机房场景里,很多老系统就是这么做的,终端要把“我还有多少数据没被读走”暴露给主站看。
MQTT和HTTP则是典型的主动上报通道,终端作为客户端,断线重连和断点续传主要作用在这两条链路上。
2.2 协议抽象层:把采集逻辑和传输逻辑解耦
为了让一套固件同时支持三种协议,我在应用层和协议栈之间加了一个很薄的抽象层。用C实现的话,形式就是一个协议操作接口:
typedef struct { int (*init)(void *cfg); int (*connect)(void); int (*disconnect)(void); int (*send_data)(const data_point_t *point); int (*get_ack_status)(uint64_t *last_seq); int (*is_connected)(void); } proto_ops_t;每个协议模块实现这一组函数,采集主循环只关心“往协议层塞数据”,不关心今天跑的是MQTT还是HTTP。
当时团队里有人觉得这个抽象层是过度设计,多一层封装就多一分复杂度。但实测下来,项目的价值恰恰藏在这层封装里:客户在项目中期提出“那台设备能不能同时也往本地数据库写一份”,或者“改成先发到临时测试平台”,这时候只需要写一个新的适配器,主循环一行不动。后期我甚至把平台切换做成了远程配置下发,现场不用烧固件就能切协议,运维成本下降得很明显。
2.3 配置驱动与通道降级
配置我用一个JSON文件管理,固件启动时解析,也支持远程下发修改。核心配置项包括三块:
- 主协议(primary protocol):默认走哪条通道上报;
- 备用协议(fallback protocol):主通道连续失败N次后切入;
- 各协议独立参数:MQTT的broker地址、topic、clientId、QoS等级;HTTP的URL、鉴权token;Modbus的从站地址和寄存器映射表。
协议切换采用“成功优先”策略:主协议一直正常就用主协议;主协议连续连接失败超过阈值(比如连续3次退避重连都没成功),自动切到备用协议;切过去之后定时探一下主协议是否恢复,恢复后再切回来。
这里有一个特别容易踩的坑:切换协议时不能直接清掉该协议的序列号状态。比如MQTT通道上次已经发到seq=5000,切到HTTP后又发到了seq=6000,切回MQTT时如果从全局游标继续,就会漏发MQTT那一段数据。所以每个协议适配器必须维护自己的确认游标,而不是全局共享一个。这个设计细节正是断点续传能跨协议工作的基础,第4章会展开讲。
3. 断线重连机制:指数退避、状态机与心跳保活
3.1 为什么“每秒重试一次”会把系统拖死
最简单的断线重连就是while循环里sleep(1)再connect,我见过不少设备固件就是这么写的。但工程上绝对不能这么干,原因有两条。
第一是重连风暴。现场几十台终端同时断网,网络恢复时如果都按同一个频率重试,服务器会瞬间涌进一堆SYN包,TCP连接队列被打满,一部分终端始终握手失败,然后进入“失败-重试-失败”的恶性循环。哪怕服务器扛得住,前置机和数据库也会被冲垮。
第二是持续性损耗。如果对端服务器已经宕机、DNS解析失败、网段被防火墙封禁,每秒重试只会白白消耗MCU的CPU和网络模块功耗,还会堆积大量半开连接,反过来影响后续正常重试。
3.2 指数退避加随机抖动:重连状态机的核心
最终我采用的是经典的指数退避加随机抖动方案,这也是很多工业通信协议默认采用的重连策略。基本规则如下:
- 初始重试间隔:1秒;
- 每次失败后间隔翻倍:1s、2s、4s、8s、16s、30s、60s;
- 上限60秒,到了就不再翻倍;
- 每次实际等待时间 = base_interval * (0.8 + random(0, 0.4)),也就是加上正负约20%的随机抖动。
随机抖动极其重要。如果所有设备都按完全相同的退避节奏重连,几轮之后大家又会撞在一起。随机化让同一时刻只有部分设备发起连接,显著降低同步碰撞的概率。实测下来,50台终端同时离线再恢复,带抖动方案的平均重连完成时间比无抖动方案快了接近一倍。
重连流程我统一用一个状态机管理,避免代码里到处sleep导致的混乱。状态定义如下:
IDLE → CONNECTING → CONNECTED ⇄ RECONNECT_WAIT- 应用启动,进入CONNECTING;
- 连接握手成功,进入CONNECTED;
- 连接失败、心跳超时、或收到协议错误码,进入RECONNECT_WAIT;
- RECONNECT_WAIT等待退避计时结束后,回到CONNECTING。
CONNECTING阶段必须设置超时时间,比如TCP连接最多等10秒,MQTT CONNECT最多等15秒。千万不能让connect()阻塞住整个主循环,否则链路假死时系统没有任何反应。
3.3 心跳保活:如何识别“假死”连接
TCP自带的keepalive默认要等很久才能发现对端异常,应用层必须自己加心跳。我在每条主动上报链路上做了两个层面的保活:
- 应用层心跳:每20秒发一个轻量心跳包。MQTT走PUBLISH心跳topic,HTTP走一次带短超时的GET或HEAD请求;
- 响应超时判断:如果连续3个心跳周期(约60秒)没收到任何响应,判定链路假死,主动断开连接并进入重连状态机。
心跳间隔不能太频繁也不能太稀疏。太短会占用带宽和服务器连接资源;太长,链路假死时发现得慢,断点续传的触发就会滞后。20秒是我在多个项目里验证下来比较合适的折中值。
注意:不同协议的“响应”定义完全不一样。MQTT的响应是broker返回的PUBACK或PINGRESP,HTTP的响应是状态码和响应体,Modbus TCP则是功能码应答。断线重连逻辑不能跨协议共用一套心跳机制,必须由适配器各自实现,否则会误判链路状态。
4. 断点续传机制:本地缓存、确认游标与增量续传
4.1 断点续传和“全量上传”的本质区别
很多人口中的断点续传,实际做的是全量上传——断网期间存下来的数据,恢复后一股脑全发一遍。这在数据量小、断网时间短的时候还能凑合,但数据量一大就完全失控。
真正的断点续传,核心是“从上次被确认的序列号之后继续发送”,而不是从头到尾再传一遍。拿实际场景举例:终端采集频率30秒一条,断网3小时,积压了360条数据。全量上传是登录后一次性把这360条丢过去;断点续传是终端知道自己上次已经发到seq=4500,服务器也确认过4500,这次就从4501开始一条条或一批批发,每收到一次确认就推进游标。
这样做有几个直接好处:
- 网络恢复后的传输量只取决于断网期间新增的数据量,不重复传历史已确认部分;
- 支持跨协议续传——MQTT确认到4500,切到HTTP后就从4501继续;
- 极端情况下(比如服务器换库、数据需要回滚),再考虑人工触发一次全量同步接口兜底。
这里也能看出来一个关键点:服务器端对历史数据的确认状态,本质上就是“每个终端每个协议各自的上次已确认序列号”。只要把这个游标存下来,断点续传就变成了一件非常确定的事。
4.2 本地缓存区设计:环形缓冲、CRC校验与断电安全
断点续传的前提是数据能可靠地囤积在本地。我用的方案是外部SPI Flash(W25Q64,8MB)里划出一块独立缓存区,做成环形缓冲,存储结构如下:
每条记录结构: [魔数 2B] [设备ID 4B] [序列号 8B] [采集时间戳 8B] [温度 4B] [湿度 4B] [CRC16 2B] [有效标志 1B] 总量约33B,实际按64B对齐存储,方便擦写管理环形缓冲的特点是写指针循环移动,写满后覆盖最旧的数据。覆盖策略按现场需求定:冷链和机房这种场景,最新环境数据比几天前的旧数据重要,所以要保留新数据;如果客户有审计追溯需求,那就反过来保留旧数据。我在默认配置里保留新数据,同时把旧数据被覆盖的次数统计出来,供后续分析断网频率。
Flash写入最怕掉电。擦除到一半断电,恢复后读到的可能是半成品数据,如果程序直接解析就会得到脏数据。我的解决办法是每条记录加CRC16校验,同时把“数据有效标志”单独拆出来:
- 先写数据区;
- 再写数据有效标志;
- 启动扫描时,只认有效标志为真且CRC校验通过的记录。
这样即使写数据时掉电,最坏情况也只是丢弃末尾一条不完整记录,不会污染整个缓冲区。
4.3 实时数据与历史续传数据的发送优先级
断网恢复后,终端既要发积压的历史数据,又要继续采集实时数据。如果一股脑先发历史数据,最新环境信息可能延迟十几分钟才到服务器,告警系统就完全失灵了。
我采取的调度策略是分两个通道:
- 实时数据通道:网络一旦恢复,立即发送最新一条采集数据,保证服务器端能看到当前状态;
- 历史数据通道:按批次发送缓存区里的未确认数据,每批10条,发送后等待该协议确认;收到确认后推进游标,再发下一批。
为了避免历史数据长时间霸占带宽,我给历史传输设置了“交错比例”:历史数据每发5条,就插一条最新实时数据。这个比例可以在配置里调,服务器性能好的现场可以改成10:1,网络比较差的现场改成3:1。
关于确认和重传,有一条经验非常值钱:如果连续发送3个批次都没有收到任何确认,先暂停历史数据发送,退回断线重连状态机检查链路是否假死。历史数据发送本身也是一种链路探活,连续无确认基本说明链路又不行了,继续发只会加重网络负担。
5. 实测踩坑记录:丢数据、乱序和时钟漂移这三件事
5.1 掉电瞬间缓存损坏:CRC校验和有效标志缺一不可
这个坑在我早期版本里真实翻过车。第一版设计时我认为给每条记录算个CRC就够了,但实测发现掉电瞬间Flash可能写到一半,CRC字段本身也是残缺的。有一次意外断电后启动扫描,遇到CRC错误我直接整块丢弃,结果那一晚上采集的数据全没了。
排查过程我印象很深。先手工复现断电场景,发现Flash在收到写命令、但数据没写完整之前断电,那一页的状态既不是全FF也不是完整数据,而是半成品。我读出整块Flash数据逐条校验,发现损坏记录集中在断电前后的瞬间。最后把写入流程改成“先写数据、再写有效标志、启动时双重校验”,再反复做随机断电测试,损坏记录只影响断电瞬间那一条,且能被明确识别丢弃。
这个坑的教训是:Flash掉电安全性不能靠“写入命令短、概率低”来赌,必须从数据结构上设计容错。有效标志位单独占一个扇区,这个扇区还要做磨损均衡,避免频繁断电导致标志位所在的块提前坏掉。
5.2 服务器端数据乱序:序列号与幂等重传
另一个印象深刻的问题是数据乱序。现象是断网恢复后,服务器收到的数据出现时间戳倒挂,比如先收到10:00:30的记录,再收到10:00:00的记录。开始我怀疑是终端发送顺序有问题,后来抓包才发现真相:终端发送批次A后没收到确认,触发超时重传,但批次A其实已经到达服务器;此时终端又开始发批次B,两条逻辑并发执行,服务器端自然就乱了。
搞清楚根因后,我在设计上做了两个约定:
- 每个数据点携带全局唯一递增的序列号,服务器端以序列号为准做缓冲排序,不依赖网络到达顺序;
- 重传的批次携带相同的序列号集合,服务器收到重复序列号直接丢弃,从而实现幂等。
这其实和TCP里的“序号+确认+重传”思路一脉相承,只是我们把这一层逻辑搬到了应用层数据里。好处是,不管MQTT的QoS1重复投递,还是HTTP的短超时重试,都不会造成重复数据入库。
5.3 RTC时钟漂移:时间戳补偿策略
第三个坑属于时间长尾问题。终端如果长期断网断电,RTC会逐渐漂移,尤其低成本MCU上用的外部RTC芯片,一个月能偏出几分钟。断网一个月后恢复,续传数据的时间戳可能比真实时间晚十几秒甚至几分钟,服务器按小时统计平均温度时就全偏了。
我的处理是两级补偿:
- 联网校准:MQTT或HTTP通道每次成功握手后,用服务器返回的标准时间校准本地RTC;
- 采集序号推算:因为采集周期是固定间隔(比如30秒),服务器端收到续传数据后,以最新一条记录的时间戳为基准,按序列号递减反推出前面每条记录的标准时间。
简单说,服务器端不要盲目信任终端原始时间戳,要结合序列号和采样周期做一致性校验,发现明显漂移时以最新校验值为基准重算历史时间戳。这个策略在客户现场验证下来,可以把长时间断网后的时间误差控制在秒级以内。
6. 一套可落地的参数配置与稳定性经验
6.1 推荐配置参数表
整套机制调试稳定后,我把参数固定成一组默认配置作为出厂基线,不同现场可以在此基础上调整:
| 配置项 | 默认值 | 说明 |
|---|---|---|
| 采样周期 | 30秒 | 温湿度变化慢,30秒足够,缓存寿命更长 |
| 心跳间隔 | 20秒 | 链路假死检测频率 |
| 心跳超时次数 | 3次 | 连续3次无响应判定假死 |
| 重连初始间隔 | 1秒 | 指数退避起点 |
| 重连最大间隔 | 60秒 | 退避上限 |
| 随机抖动范围 | ±20% | 降低重连同步碰撞 |
| 历史续传批次大小 | 10条/批 | 每批等待确认后继续 |
| 历史/实时交错比例 | 5:1 | 每5条历史插1条实时 |
| 缓存容量 | 8MB Flash | 30秒周期可存约30天数据 |
| 主协议 | MQTT | 默认上报通道 |
| 备用协议 | HTTP | 主协议连续失败3次切换 |
这套参数不是拍脑袋定的。采样周期30秒是对功耗、存储寿命和业务实时性的平衡;批次大小10条是对确认开销和重传代价的折中。如果现场要求更强实时性,可以把采样周期降到10秒,但Flash缓存剩余天数会变成三分之一,这时必须重新评估现场可能的最长断网时长。
6.2 几条常规文档里不会写的小经验
最后分享几条我在多个现场验证过的经验,本来不适合写进正式设计文档,但对实际运维非常有帮助。
固件里必须加看门狗,喂狗位置要在主循环,不能放在中断或协议栈回调里。曾经有一次我在协议栈回调里喂狗,协议栈阻塞时看门狗照样被喂,整个系统看起来活着,实际已经不干活了,排查了很久才发现。
每个协议的连接状态和缓存游标要分开管理,切换协议时不能顺手清了游标。我亲眼见过因为“重新初始化协议栈”时清了游标,导致一批数据被重复发送的情况,幸好服务器端有序列号去重通道,否则就静默产生重复数据了。
服务器端数据库表结构里,温湿度数据表一定要给“设备ID+序列号”建唯一索引,这是防止重复数据的最后一道保险。千万别把唯一索索引建在时间戳上,因为时间戳经过补偿后可能变动,唯一性不可靠。
运维上建议给终端留一个远程诊断接口,能实时查询当前协议、重连次数、缓存剩余空间、最近一条ACK的序列号。这些信息是排查“某台设备数据为什么没上来”的第一手线索,比到现场抓包快得多。
那套系统上线运行半年后,中间经历了一次交换机更换、一次光纤损坏和一次机房断电改造,所有终端都能在网络恢复后的几分钟内把断网期间的数据补齐,没有一条丢失,也没有一条重复。温湿度采集终端真正的价值,从来不在传感器精度那一个小数点上,而在通信机制能不能在各种意外情况下守住数据完整性。如果你正在做类似的采集终端,建议先把断线重连状态机和缓存确认游标这两块想清楚,再去纠结协议选型和传感器型号。底子扎实了,后面加任何协议都只是适配器的事。