news 2026/9/14 12:47:03

STM32以太网传感器固件重构:三态状态机实现数据可靠传输与断网补报

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32以太网传感器固件重构:三态状态机实现数据可靠传输与断网补报

1. 一次被网络故障逼出来的重构:从串行轮到三态状态机

大概两年前,我做了一款用于机房监控的以太网温湿度传感器。方案本身没什么新鲜的:STM32F103通过I2C读SHT30,数据用lwIP走TCP上报到局域网里的监控平台。当时我的想法很简单,这就是个"读传感器加发网络包"的活,难点最多在协议栈配置上。直到设备在客户现场稳定跑了一周之后,各种各样的网络幺蛾子开始冒出来,我才意识到固件里最考验人的根本不是传感器,也不是以太网链路,而是"网络不稳的时候,数据到底怎么保住、保多少、什么时候能补回来"。

服务器偶尔重启、网线被人误拔、交换机端口闪断,任何一个故障都会直接打击我上一版那个"采集完立即上报"的固件。那版固件逻辑非常朴素:while大循环里顺序执行读传感器、组包、TCP发送三步,看起来干净利落。可网络一旦抖动,整个循环就卡在TCP发送环节,温湿度采样周期从设定的5秒漂移成几十秒;服务端恢复之后,固件只发最新一条数据,中间那几分钟甚至十几分钟的采样点全部消失。客户不关心你的网络拓扑里谁出了故障,他只看到监控平台曲线里出现了一段断崖,然后一句"你的设备不行"就把你的整个方案打上问号。

重构这套固件时,我给自己定的核心目标就一句话:把采集、存储、上报三个动作彻底解耦,让网络故障不再拖累采集,让采样数据在极端条件下也不丢。最终落地的是一个三态状态机架构,设备已经连续稳定运行超过一年。这篇文章把这个过程中踩过的坑、验证过的细节、最后沉淀下来的代码骨架完整记录下来,给正在用STM32、GD32这类MCU做网络传感器的同行一个可直接参考的样本。

1.1 旧固件为什么撑不住网络故障

先复盘一下旧固件失败的具体机制。它几乎是新手做联网传感器最典型的写法:一个while(1)大循环,循环里依次完成阻塞读I2C、拼接数据帧、TCP发送。从代码结构看逻辑很顺,三步一个周期,但它有三个非常致命的问题。

问题一,I2C读取不是零耗时。SHT30这类数字温湿度传感器的单次读取通常在几毫秒到十几毫秒,前提是总线上一切正常。可一旦线序接触不良或者总线上有其他设备抢答,驱动里的超时重试机制会把一次读取拉长到几百毫秒,采样周期立刻失真。

问题二,TCP发送的耗时完全不可控。lwIP虽然是事件驱动的协议栈,但用socket API编写时,connect和send都可能因为对端无响应而长时间阻塞。客户那台监控平台偶尔会重启升级,重启过程持续几分钟甚至十几分钟,期间TCP连接根本建立不起来。而旧固件的处理方式是不断重试,整个while循环全部卡死在网络部分,新一轮I2C采样完全停止,数据连续性从"偶尔断一条"恶化成"整段空白"。

问题三,也是最要命的,数据没有任何落地保护。旧固件不保存历史数据,每次采样完成直接组包发送,发送失败就丢弃。即使网络恢复,也只能上报最新的一条,中间积压的采样点全部丢失。平台端看到的是一条断开的曲线,而设备端没有留下任何可以追溯的记录。客户来投诉的时候,我连"这段时间设备其实采到了数据、只是没发出来"的辩解都拿不出实据。

我当时总结下来,这套方案的病根就是三个字:串行化。采集、存储、上报三个环节彼此强依赖,前一个环节的故障会被直接放大到后一个环节。要治本,只能把三者的执行节奏彻底拆开。

1.2 三态状态机的基本设计逻辑

重构后的架构不再用一个串行的大循环包办所有事情,而是把固件的工作划分成三个独立状态来管理:采集态、存储态、上报态。

  • 采集态:负责按固定周期读取传感器、做数据滤波和合理性判断、为每条数据打上时间戳,生成一条完整的数据记录。
  • 存储态:作为采集和上报之间的数据中转站。新采集到记录先写入内存环形缓冲区,同时按策略写入外部Flash;上报成功的记录再从缓冲区中清除。
  • 上报态:只在有数据待发且网络可用时运行,负责从存储区批量取数据、按协议发给服务器、等待服务器返回确认,再决定是否重发或清除。

严格来说,采集状态机和上报状态机是两个并行运行的有限状态机,存储态更像是一个由两者共享的数据服务。没有把它们合并成一个"采集—存储—上报"独热码大状态机,是因为这三个动作的时间诉求完全不同:采集要求准时,存储要求安全,上报要求可靠。把三者放进同一套状态流转里,等于把网络故障的低概率问题放大成采集故障的确定性问题,这是我绝对不想再看到的。

有了这个总体框架,下面每个状态的具体实现、边界处理和踩坑细节,我按实际开发顺序展开讲。

2. 采集态:让每个采样点都稳定、可信且带着时间戳

2.1 传感器驱动:把"读一次"拆成两个非阻塞状态

采集状态机的运行节奏用一个周期标志驱动。定时器每5秒置一次"采集心跳"标志,状态机轮询到这个标志才从IDLE进入读取流程。

这里最关键的决策是:整个读取过程里没有任何delay阻塞,而是把一次I2C读取拆成TRIGGER和READ_POLL两个子状态。TRIGGER状态做的事是向传感器发送测量启动命令,这个操作本身只需要几微秒,完成后立刻返回主循环;READ_POLL状态则每隔几个毫秒检查一次I2C是否完成、DMA搬运是否结束。SHT30从触发到数据就绪通常只要几毫秒,但把等待拆成多次短轮询之后,整个采集流程对主循环的占用时间被压缩到几十微秒量级。网络协议栈的报文处理、按键扫描、LED心跳,再也不会因为传感器读取而被长时间卡住。

选择SHT30没有太多纠结,I2C接口接线简单,驱动代码成熟,精度湿度±2%RH、温度±0.3℃,对机房、仓库、温室这类场景完全够用。类似可选的还有AHT20、HDC1080,逻辑都差不多。唯一要提醒的是:拿到任何一颗传感器,第一天就应当用逻辑分析仪或者示波器确认时序波形,确认测量完成标志位在真实总线上可靠,不要只对着数据手册上的"最大转换时间"写一个想当然的超时等待。时序验证不到位,后面所有状态机的稳定都无从谈起。

2.2 滤波与异常值识别:别让偶发坏数污染整段曲线

SHT30的原始读数直接用,会遇到两类问题。第一类是噪声毛刺。机房空调压缩机启停、开关门造成的气流扰动,让湿度数据频繁出现小幅跳变。我在采集状态机里做了一阶滞后滤波:

filtered = filtered + 0.2f * (raw - filtered);

这个滤波器实现极简、不占用额外内存,但能有效压掉高频抖动。系数0.2在5秒采样周期下对应的时间常数大约25秒,不会让温度变化看起来太迟钝,又能把短促的毛刺削平。实测下来,湿度曲线比原始读数平滑得多,监控平台上的图表观感提升非常明显。

第二类是偶发异常值。I2C在MCU中断频繁时时序偶尔会被破坏,SHT30可能返回温度85℃、湿度-5%这种明显非法的组合。我在写入存储之前加了一轮合理性判断:温度范围限定在-40到85℃,湿度范围限定在0到100%,同时相邻两次读数的跳变幅度不能超过物理可能的上限。比如5秒内温度跳变超过20℃,直接判定异常。超限的记录丢弃,本次采集进入ERROR_WAIT,等下一个采样周期再继续。连续3次采集失败则置一个告警标志,随数据帧的alarm_flag字段上报,让平台端知道现场传感器可能出了问题。这套逻辑把"偶发坏数污染整段曲线"的概率降到了几乎为零。

2.3 时间戳:最容易忽略却又决定补报价值的设计

一条温湿度记录如果只有温度和湿度两个字段,那它只适合"实时上传、服务器收到时打戳"这种最理想的应用。但在我这个需要断网补报的方案里,记录必须自带"数据产生时刻",补报才有意义。如果断网期间缓存了20分钟数据,恢复后一股脑发给服务器,服务器却不知道这些数据各自是什么时候采集的,只能按收到时刻打戳,整段曲线就全部错位。

设备没有外部RTC芯片,局域网里也没有NTP服务器,时间戳的来源需要自己设计。我的方案是:TCP连接建立后,服务器在握手阶段下发一次Unix时间戳,固件记录本地时钟与服务器时间之间的初始偏差,之后靠系统tick维持走时。单次校时的误差主要来自晶振漂移,24小时累计大约1到2秒,对温湿度监控场景完全够用。如果以后部署的设备需要跨地域严格同步,可以把校时改成周期性的,每24小时校一次,误差能进一步压缩。

特别注意一点,时间戳必须在采集态写入记录时固化,而不是在发送时才填充。因为一条记录可能在缓冲区里待几十分钟,如果发送时才打时间戳,所有缓存记录的时间会全部变成发送时刻,失去了历史数据的意义。

3. 存储态:内存环形缓冲加Flash兜底的双层结构

3.1 为什么不能"采到就立刻上报"

把存储作为独立的一态来设计,是因为网络故障是常态而不是异常。如果采集到的数据不落地,那么上报失败等同于数据失踪。存储态在这个架构里承担两个职责:短期缓冲,给上报模块提供待发数据;长期保存,防止设备掉电后历史数据全部蒸发。

第一层存储是内存环形缓冲区。结构如下:

#define SENSOR_BUF_CAPACITY 96 typedef struct { sensor_record_t records[SENSOR_BUF_CAPACITY]; uint16_t head; uint16_t tail; uint16_t count; } ring_buffer_t;

head是写入指针,由采集状态机使用;tail是读取和删除指针,由上报状态机使用。单MCU轮询架构下,两个状态机不会在同一时刻运行,所以不需要加锁。缓冲区容量定96条,对应5秒采样周期下的8分钟,能覆盖绝大多数短暂网络故障。缓冲区写满时,新数据覆盖最旧数据,因为对实时监控来说,最近的数据价值更高,而较旧的数据如果还没报出去,Flash里通常还留有备份。

3.2 Flash存储:轮流扇区策略与页写入时机

第二层存储是外部SPI Flash,我选的是W25Q16,容量16Mbit,用来保存未上报记录的深后备。选择外部Flash而不是MCU内部Flash,是因为内部Flash频繁擦写会影响程序存储区可靠性,而且容量和寿命都不适合做数据日志区。

W25Q16有512个4KB扇区,我划出8个扇区共32KB作为温湿度历史区。数据量方面,5秒一条记录、一天就是17280条,全存不现实,所以策略是"尽量保留未上报记录,上报成功后及时清除"。Flash写入按页进行,每页256字节,能放8条记录,一条记录32字节固定大小,页内不跨页,这样即便写到一半掉电,最多丢当前页的最后几条,之前的数据完好。

这里有一个关键的工程细节:W25Q16的页写入时间大概在1ms以内,但扇区擦除可能需要几百毫秒,绝对不能放在中断或者采集状态机的主路径上执行。我的做法是:采集状态机攒够8条记录后,只把"这一页需要落Flash"的事件挂到任务标志位,由主循环在空闲时段调Flash写入模块执行。这样即使Flash正在擦除,采集也不会被阻塞,三态之间的独立性能保持住。

磨损均衡方面,8个扇区轮流写,写满一个就切到下一个,回卷时覆盖最旧的扇区。每个扇区头部写一个带CRC的扇区头标记,记录当前写入位置和扇区序号。考虑到温湿度数据在正常场景下补报迅速,Flash擦写次数不会增长太快,这套简化版轮流策略已经足够可靠。

3.3 记录帧格式:固定32字节,字段布局必须一次想清楚

所有存入Flash和上报到服务器的记录,使用同一个固定32字节结构:

#pragma pack(1) typedef struct { uint8_t frame_header; // 0xAA uint8_t device_id; // 设备编号 uint32_t timestamp; // Unix时间戳(秒) int16_t temperature; // 温度,乘10存储 uint16_t humidity; // 湿度,乘10存储 uint8_t alarm_flag; // 告警标志 uint8_t reserve[3]; // 预留字节,写入前必须清零 uint16_t crc16; // CRC16-CCITT } sensor_record_t; #pragma pack()

这个字段布局有几个值得说道的地方。温度用int16乘10存储,湿度用uint16乘10存储,是为了避开浮点数在Flash和网络传输里的字节序、对齐填充问题。0.1℃精度对温湿度监控足够了,整数运算在MCU上也更快。CRC16覆盖从frame_header到reserve的整段数据,用查表法计算,CPU开销可以忽略。服务器收到数据后先校验CRC再解析,坏包在源头就被拦截。

另一个必须强调的点:结构体要加#pragma pack(1)强制单字节对齐。如果不加,在默认对齐规则下这个结构体实际占40字节,而代码里按32字节写入Flash、按32字节组网络包,服务端按32字节解析,结果就是帧头对得上、后面的字段全部错位。这个坑我真实踩过,会让你在联调时怀疑人生。另一个字段设计上的细节是,reserve预留字节字段在写入前必须用memset清零整个结构体,否则Flash擦除态0xFF会残留,给后续版本兼容性埋雷。这个问题的具体后果后面踩坑部分细说。

3.4 掉电重启后如何恢复待上报数据

Flash层设计的最终目的是掉电重启后数据不丢。固件上电初始化时,会扫描这8个扇区的扇区头,定位当前有效扇区,然后从扇区的最后一条有效帧向前追溯,把未上报记录全部搬回内存待上报队列。完成之后,上报状态机才被允许启动。

实际操作中有一个必须处理的意外情况:掉电瞬间如果正好处于擦除或写入动作中间,扇区头可能不完整。解决办法是扇区头本身带CRC,启动扫描时如果发现CRC校验失败,就放弃当前扇区、回退到上一个扇区继续寻找有效数据。温湿度数据连续性强,少个几页不至于伤筋动骨,但保住绝大多数数据对客户来说非常重要。这个恢复流程在上电时要保证时序正确,不能在待上报队列还没有完全恢复时就允许上报状态机运行,否则会出现"先发了一部分、另一部分还没挂上队列"的并发错乱。

4. 上报态:真正和网络搏斗的战场

4.1 确认机制:服务器说收到了,才算收到

写设备上报时,很多人引用"TCP是可靠传输"这句话,然后把send函数返回值当作数据到达服务器的依据。这是误解。TCP的send成功只代表数据进入了协议栈的发送队列,服务器应用层是否真的收到了、写库了,TCP是不负责的。尤其是设备到服务器之间还隔着交换机、路由器这些中间节点,应用层ACK必不可少。

这里的协议设计其实很简单,客户端发一批记录,服务器在同一TCP连接里回一个确认帧:

typedef struct { uint8_t ack_header; // 0x55 uint8_t device_id; uint16_t ack_count; // 被确认的记录条数 uint32_t last_timestamp; // 最后一条确认记录的时间戳 uint16_t crc16; } ack_frame_t;

固件只有在收到ACK之后,才会删除内存缓冲区和Flash中已被确认的记录。ACK里带last_timestamp而不是单纯靠ack_count计数,是为了精确对齐"服务器确认到哪一条"。如果上一批已经确认过的记录因为网络原因又重发了一次,靠计数就会出现偏差,靠时间戳能准确定位边界。

4.2 批量上报和指数退避重传

上报状态机的完整流转逻辑如下:状态在IDLE、CONNECTING、SEND_BATCH、WAIT_ACK、ACK_CLEAN之间切换。IDLE状态下检查待上报队列,空则保持空闲;有数据时进入CONNECTING,非阻塞等待TCP连接建立;连接成功后在SEND_BATCH一次发送最多30条记录;然后进入WAIT_ACK等待服务器确认,同时启动超时计时;收到ACK后在ACK_CLEAN阶段按last_timestamp清理缓冲区,回到IDLE。如果WAIT_ACK超时,状态回到CONNECTING重连,并进入指数退避。

重传退避的参数是:第一次失败等2秒,第二次4秒,第三次8秒,最高封顶30秒。退避的目的不是拖延,而是防止在网络不稳定或者服务器高负荷时,设备像无头苍蝇一样高频重试,反而进一步加重链路拥塞。退避期间,采集和存储照常运行,所以最坏情况下只是补报延迟,数据一条都不会丢。

批量上报本身也值得展开。一次TCP连接处理30条记录,和30次连接各处理1条相比,省掉的是连接建立和拆除的开销,更关键的是减少了ACK的往返次数,整体吞吐量完全不在一个量级。批次数定为30,是因为单包大小控制在1KB以内,不容易触发lwIP的分片和滑动窗口的边界问题。

4.3 幂等设计:ACK丢了也不能产生重复数据

网络环境里最阴险的场景是:固件发出去的一批记录,服务器其实已经收到并且写库了,但服务器回给固件的ACK在链路中途丢失。固件超时后会重发同一批记录,如果服务器不做幂等设计,库里就会出现一整批重复数据,后面画曲线、算均值全乱套。

所以协议从第一天设计时就要假设两件事:ACK会丢,数据会被重发。对应到服务器端,落库逻辑必须以(device_id + timestamp + 随机序号)作为唯一键,写入前先查唯一索引,已存在则跳过但依然回复ACK。这样固件无论重发多少次,最终库里只有一份数据。随机序号的加入是吸取了教训,因为纯timestamp可能在同一秒内有两条记录,仅靠设备ID加时间戳解析去重会误杀真数据,这个坑后面第四节详细展开。

这个幂等设计的代价是服务器端多一条唯一索引查询,但对一周几十万条级别的监控数据来说完全不构成瓶颈。反过来讲,如果固件端坚持"收到ACK才删、没收到就重发",那么服务器端无论如何都必须做到同样数据的重复写入不产生重复记录。这是TCP不可靠链路上构建应用层可靠传输的基本功。

5. 表驱动状态机的具体实现骨架

5.1 采集状态机:查表执行,而不是switch-case满天飞

状态机的实现我选了表驱动而不是switch-case。理由不是追求炫技,而是表的结构天然把"当前状态、处理函数、超时后的下一状态"绑定在一起。新增一个状态只需要往表里加一行,不需要修改N个case分支;表是静态只读数据,放在Flash里不占RAM;调试时打印状态编号也比打印调用栈容易得多。

采集状态机的状态表和驱动函数如下:

typedef enum { COLLECT_IDLE, COLLECT_TRIGGER, COLLECT_READ_POLL, COLLECT_VALIDATE, COLLECT_WRITE_BUF, COLLECT_ERROR_WAIT, COLLECT_STATE_MAX } collect_state_t; typedef struct { collect_state_t state; void (*process)(void); collect_state_t timeout_state; } collect_state_table_t; static const collect_state_table_t collect_table[COLLECT_STATE_MAX] = { { COLLECT_IDLE, collect_idle_process, COLLECT_IDLE }, { COLLECT_TRIGGER, collect_trigger_process, COLLECT_ERROR_WAIT }, { COLLECT_READ_POLL, collect_read_poll_process, COLLECT_ERROR_WAIT }, { COLLECT_VALIDATE, collect_validate_process, COLLECT_IDLE }, { COLLECT_WRITE_BUF, collect_write_buf_process, COLLECT_IDLE }, { COLLECT_ERROR_WAIT, collect_error_wait_process, COLLECT_IDLE }, }; void collect_run(void) { collect_state_table_t *entry = &collect_table[g_collect_state]; entry->process(); if (collect_has_timeout()) { g_collect_state = entry->timeout_state; } }

每个process函数都遵循一句原则:能干多少干多少,干不完就返回。COLLECT_TRIGGER的process只是发起I2C读取请求,设置超时计数,立刻返回;COLLECT_READ_POLL的process检查读取完成标志,没完成就直接返回,由主循环下一轮再次进入同一个状态。这种"每轮只做一小步"的写法,是整个三态架构能在单个循环里并行运转的前提。如果任何一个process函数里出现忙等,三态并行立刻崩成串行,前功尽弃。

5.2 上报状态机的驱动来源:主循环加网络回调

上报状态机同样是表驱动,但它的驱动来源有两个:一个是主循环的周期调用,处理超时计时和退避逻辑;另一个是lwIP的网络回调,比如连接成功回调、可写回调、数据接收回调。我尽量让网络回调里的工作越少越好,只设置状态机需要的标志位或保存数据指针,真正的数据解析和状态转移放到主循环的上报状态机里做。

lwIP在单MCU场景下没有多线程,所有回调都在同一个上下文里执行,天然没有锁竞争,但回调函数里绝对禁止做耗时操作,比如Flash擦除、长字符串处理。这是嵌入式网络开发的基本纪律。另一个容易踩坑的点是tcp_write的返回值处理,这个细节在踩坑记录里专门讲。

5.3 状态机边界条件:必须显式处理的四个场景

状态机的正常路径写完只算完成了一半,真正体现设计水平的是边界条件。我在这个项目里反复被边界问题教训,整理出四个必须在编码阶段就处理的点:

第一,采集超时。I2C读取发起后50ms内没有完成,强制超时并将状态置回COLLECT_IDLE。宁可少采一条,也不能让整个采集状态机卡死在等待上,因为卡住一次,后面所有采样点都会跟着偏移。

第二,上报队列为空时不能发起TCP连接。有些新写的代码在SEND_BATCH之后立刻进入WAIT_ACK,完全不检查缓冲区的count,网络一恢复就发出大量空连接,白消耗资源不说,还会让服务器日志里堆满无意义的连接记录。

第三,Flash待上报区回卷时的覆盖策略。要保证上报成功的记录能及时从Flash中清除腾出空间,否则长时间断网后,新数据会覆盖掉尚未上报的旧数据,等于丢了历史。

第四,上电恢复顺序。Flash扫描和待上报队列初始化必须在进入上报状态机之前完成。如果上报先启动,可能覆盖尚未恢复的数据,或者把队列状态搞成半个满的尴尬局面。

6. 一年运行数据与三个必须写下来的坑

6.1 实测参数:99.7%上报成功率和100%数据连续率

设备在客户机房已经连续运行超过一年,我有几个统计数字可以分享。

  • 采样周期:5秒,长期运行无漂移。
  • 上报成功率:按批次统计约99.7%,剩余0.3%主要是服务器主动维护窗口期间的失败重试。
  • 数据连续率:按采样点统计100%,未出现采集缺口。
  • 单批次端到端上报延迟:正常情况50ms以内收到ACK。
  • 服务器最长的维护停机时间:8分钟,期间设备把96条采样点压在内存缓冲,恢复后2秒内一次性补报完成。

掉电模拟场景也验证过Flash兜底。拔掉网线70分钟再插回,期间记录全部落入Flash,网络恢复后分3批补报,每批间隔约5秒退避,服务端最终落库数与设备端本地记录完全一致,按唯一键去重后无重复、无缺失。这些数据让客户很满意,也让团队在后面的项目中把"采集、存储、上报三态解耦"定为了标准架构。

6.2 坑一:lwIP内核发送缓冲区满导致发送卡死

早期版本的上报模块,我在SEND_BATCH状态里直接调用tcp_write后接tcp_output,认为只要协议栈初始化正确,数据总能发出去。直到某个客户现场出现设备间歇性死机,排查了很久才发现,lwIP内核的发送缓冲区是有限的,当发送窗口变小且缓冲区满时,tcp_write返回ERR_MEM。我的第一版代码对这个返回值的处理是立即重试,结果在高负载下陷入死循环,整个设备主循环被卡死在重试里,看起来和死机一模一样。

正确做法是:tcp_write返回ERR_MEM时,把要发送的payload挂到应用层发送队列,注册tcp_sent回调。内核腾出空间后,tcp_sent触发,再从队列里取出payload继续发送。发送队列和上报状态机之间的同步也要处理好,避免出现"底层已经发出去、应用层队列还在等待重传"的重复发送问题。这部分代码虽然多花了一点调试时间,但之后一年多再也没有出现过发送卡死。

6.3 坑二:Flash擦除态0xFF与新老结构体不一致

有一段时间,设备升级固件后补报的历史数据时间戳大面积异常。排查方向从CRC一路转到时间戳字节序,最后发现根因让人哭笑不得:Flash擦除后的默认状态是0xFF,而新版record结构体里预留字段reserve在老固件中没有被显式初始化,残留的0xFF导致新旧固件对同一块Flash区域的解析不一致。只要一升级固件,老固件写入的未上报记录中reserve字段全为0xFF,新版固件收到这些记录后某些逻辑会产生误判。

修复方式有两个方向。一是在record结构体里增加结构版本号字段,升级时检测到版本变化就强制清空待上报区,重新开始采集,稳妥但是会丢掉升级前未上报的历史数据。二是统一在写入前用memset把整个struct清零再填充字段,我后来两条都做了,双保险。这个坑表面看是字段初始化疏漏,本质上是Flash擦除态和应用层默认值的隐性冲突,写Flash存储的固件尤其容易中招。

6.4 坑三:ACK在交换机里排队引发的重复数据

这个坑是在客户现场暴露的。某客户的交换机开启了队列缓冲,业务包和ACK包在某个方向上的队列里互相挤压,固件发出一批数据后迟迟等不到ACK,直到超时重发。服务器端当时做去重用的唯一键是device_id加timestamp,理论上没什么问题,但实现时timestamp精度是秒,而同一个批次里恰好有两条记录落在同一秒内,于是这两条被误判为重复,其中一条真数据被丢弃。

教训有两条。去重主键必须保证即使在极端情况下也不会碰撞,单纯device_id加秒级时间戳在批量上报场景下不够安全,加入随机序号后问题彻底消失。服务器端去重逻辑一定要写测试用例,覆盖"同一批次内多条记录时间戳相同"的边界场景,别想当然认为这不会发生。这个坑让我重新审视了所有跨设备的唯一性设计——只要存在重发机制,去重主键就必须假设会碰撞。

做这个项目一年多,最深的体会是:所谓"采集、存储、上报三态",本质上是在为一句话服务——把网络不可靠这个外部风险,用固件内部的结构化解掉。三态边界划得越清晰,网络该闹的故障让它闹,设备该采的数据照样采,互相不拖累。这个思路不只适用于以太网温湿度传感器,任何带网络通信的记录类设备,哪怕以后换Wi-Fi、换4G模块,核心框架都可以复用。如果你也在做类似的东西,建议动手写代码之前,先把存储态的恢复流程和上报态的确认协议想透,这两个点是数据连续性的根基。状态机和协议栈的坑踩过一次就能长记性,但数据连续性的口碑,丢失一次要很长时间才能补回来。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/14 12:45:33

可口可乐经典广告《Hilltop》的音乐营销技术解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/14 12:40:13

OpenClaw+腾讯云实战:广告营销Agent基建部署与成本优化复盘

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/14 12:38:58

基于狐獴搜索算法的无人机三维路径规划MATLAB实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华