做了这么多年物联网设备的开发,最让人头疼的Bug排行榜里,"OTA下载data时设备网络重启"绝对排得上前三。我手头有个做智能终端的项目,设备用的是4G Cat.1模组,固件包大概20MB,上线OTA之后升级成功率一直卡在70%出头。用户那边看到的现象很统一:进度条跑到四分之一,设备突然重启,重启完又得从头开始下,有些设备甚至一来一回反复横跳,最后直接把升级流程跑死了。
这个问题的坑在于它不像普通业务逻辑Bug那样好定位。你说代码有问题吧,设备平时联网、上报、消息收发都正常;你说硬件有问题吧,常规功能跑起来电流电压都没毛病。偏偏就是在OTA下载data这一瞬间,网络模块像被踩了尾巴一样快速重启。后来我把这个问题的排查思路、根因分析、以及最终的改造方案整理了一遍,其实就是几条主线:供电余量、内存压力、网络交互策略、看门狗和状态恢复。今天把这些经验完整写出来,希望能帮到被同类问题折磨的同行。
1. 问题现象与根因定位思路
1.1 网络重启的三种表现
先说现象。所谓"网络重启",在实际项目中通常表现为三种不同的形态,很多人一开始把它们混为一谈,导致排查方向完全跑偏。
第一种是模组级重启。这种最明显,你在日志里能看到模组的开机URC上报,比如4G模组重新注册网络、重新获取IP地址,AT指令通道在某一瞬间直接卡死,然后恢复。这种一般能从模组的AT指令响应中看到重启痕迹,比如复位原因寄存器记录了欠压复位或看门狗复位。
第二种是设备MCU级重启。这种是整机重启,日志直接断片,重启后从打印信息的特征看是MCU冷启动。什么模组重启、网络断开都只是结果,根子在主控这边。比如代码跑飞、栈溢出、看门狗超时。这种需要根据重启原因寄存器和日志断点去分析。
第三种是连接层假死。设备没重启,模组也没重启,但OTA下载连接中断,TCP链接异常断开,或者HTTP交互超时,下载进程反复报错。这种最迷惑人,因为设备活着、网络也注册着,但就是传输层面不稳定,表现为"进度条卡住"或"下载失败重试"。
我在第一次排查时犯过一个错误:看到设备重启日志就只顾着追看门狗,忽略了模组的欠压复位记录。结果改了一圈喂狗逻辑,问题压根没解决。后来把日志里"模组启动原因"的字段拉出来对比,才发现有一大批设备是模组自身的欠压复位,问题根源根本不是代码逻辑,而是供电。
所以在动手前,先通过日志区分这三类现象,能把排查范围缩小一半以上。
1.2 先查重启原因,不用瞎猜
很多兄弟遇到这种问题第一反应是"我改改代码试试",但代码毕竟不是薛定谔的猫,盲目改只会让问题更难定位。正确顺序应该是先把重启原因搞清楚。
对模组来说,绝大多数蜂窝模组都支持查询复位原因/关机原因,常见的AT指令路径包括查询欠压记录、看门狗记录、硬件复位引脚记录等。不同厂家指令不一样,但通常都有类似AT+CERST或者专门的AT%RST之类的扩展指令,能读到上次复位是软件复位、看门狗复位、还是欠压复位。
对MCU侧来说,同样有重启标志寄存器,比如某常见MCU的RCC->CSR寄存器,能区分上电复位、外部复位、看门狗复位、软复位。如果板子支持RTC备份寄存器,还可以在启动时写入上一次复位原因、运行时间、复位的任务ID、关键变量,这样重启后第一件事就是读出来上报或打印。
我自己的习惯是启动后前200ms内就完成复位原因采集,打进日志系统里,格式类似:
boot_reason=watchdog, last_task=ota_download, progress=4096/20480KB别小看这一行信息,它能让远程问题的定位时间从几天缩短到几小时。连设备都卖到客户现场了,没法现场抓波形,这种"飞行数据记录仪"式的手段几乎是唯一可靠的信息来源。
注意:排查阶段千万不要直接在干净环境里反复跑OTA,试图"复现一下看看"。很多问题与现场的信号环境、电源质量、运营商网络状态强相关,本地复现不出来很正常。正确做法是靠日志、计数器、重启原因把现场证据收集全,建立假设逐一验证。
2. 下载任务设计:如何避免“下载即重启”
2.1 下载data时的三重压力叠加
OTA下载之所以比其他业务更容易引发网络重启,是因为它同时给设备施加了三重压力,平时单独跑都没事,一叠加就出问题。
第一重是功耗压力。蜂窝模组在数据下载时需要持续保持射频接收,加上IMSI/TMSI更新、信号测量等底层行为,电流不是恒定的小值。尤其在弱信号场景下,模组为了维持链路预算,发射功率会拉到比较高,瞬时电流峰值非常可观。普通业务是短促的上下行报文,而OTA下载是持续几分钟到十几分钟的高负载状态。如果供电链路余量不足、储能电容不够,VBAT跌到模组的欠压阈值以下,直接触发模组关机重启。这就是前面说的模组级复位最常见的原因。
第二重是内存压力。OTA下载的典型实现是"边收边写Flash",但接收缓冲、协议栈缓冲、业务任务栈都要吃内存。很多老平台本身RAM就紧张,比如我那个项目用的主控RAM只有几百KB,跑着协议栈、GUI、传感器采集、网络服务,还剩不到100KB。如果下载模块一次性申请大buffer,或者在HTTP接收回调里做大量数据搬移,很容易把堆内存耗尽、碎片化加剧,严重时malloc失败还硬着头皮写,直接段错误触发MCU复位。
第三重是网络链路的长时间占用。运营商无线网络对大流量、长时间的数据连接有各种隐性的策略限制,比如空闲超时、会话保活时长、QoS调度。普通业务报文短、频率低,触发不了;OTA这种连续大流量下载,就可能被网络侧中断或进入低速模式。加上设备在下载过程中可能发生小区重选、跨区切换,TCP连接在这种场景下丢包重传、窗口收缩,表现到应用层就是"连接不稳定""下载卡住"。
理解了这三重压力,方案设计的逻辑就出来了:给供电留余量,给内存做减法,给网络交互做容错。
2.2 分块下载与断点续传的必要性
我在早期做过一个方案:直接把整份固件拿到内存里,校验完再写Flash。结果可想而知,在资源紧张的平台上基本跑不动,稍大一点的固件就是灾难。后来才明白一个道理:OTA的正确姿势不是"一次性搬完",而是"分块搬运,每块都留有余地"。
分块下载有四个好处。
好处一,内存开销固定。每下载一块,校验一块,写入一块,内存里始终只有一块固件数据的缓冲,不会因为固件大而暴涨。常见的块大小从64KB到512KB不等,按设备可用RAM来定,我一般推荐256KB这个平衡点:不算太小、写入效率能接受,也不算太大、内存占用可控。
好处二,天然支持断点续传。HTTP协议本身支持Range请求头,每块下载失败后,重新发起带上Range: bytes=<偏移>~,服务端就从指定位置返回数据,不需要重头再来。这个能力是服务器标配,不需要特殊改造。
好处三,颗粒度小,失败代价低。一整份20MB固件下载失败和256KB一块下载失败,重试代价完全不是一个量级。分块后每块的网络超时、重试次数都可以单独控制,失败只重来这一块。
好处四,便于做进度记录。把已下载字节数、块序号、块校验值周期性写入Flash或非易失区,断电重启后还能接着下。这是高成功率OTA的基础能力。
配套的设计是块间间隔。每下载一块写入Flash后,适当停一下(比如100~500ms),让模组的射频链路喘口气,同时降低整体的平均功耗和设备发热。很多模组规格书里其实也有类似的建议,但很少有人在实际代码里执行。
2.3 升级状态机与异常恢复
OTA下载过程不是简单"download complete"一个函数搞定,应该是一个状态机,至少包含这几个状态:
IDLE -> CHECK_VERSION -> PRE_DOWNLOAD (检查电量/信号/空间) -> DOWNLOADING -> DOWNLOAD_DONE -> VERIFY_IMAGE -> APPLY_UPGRADE (标记新固件) -> REBOOT每个状态都允许超时、失败、异常退出,退出时记录原因,回到安全状态等待下次触发。
这里有个关键但是容易被忽略的点:升级请求来了不是无条件答应的。DOWNLOAD之前要检查:
- 电量:低于阈值(比如30%)优先保证通信,不能因为升级下到一半断电导致Flash写入中断。
- 信号强度:太弱时启动下载大概率失败率高,不如先提示"网络弱,稍后重试"。
- 存储空间:Flash可写区域够不够,不够先清理再开始。
状态机里还必须处理"升级失败后的回退"。正常流程是下载完成后校验通过,把新固件标记为启动固件;但万一启动失败,Bootloader要能检测到并回滚到上一个可用固件。这个机制在"升级后反复重启"类问题里是救命稻草。
3. 实操落地:一个可复现的改造方案
3.1 供电优化:给模组留够峰值电流余量
前面说了供电是这个问题里最容易被忽略却最常见的根因。我那个项目在做供电改造时,实测信号较弱时模组发射电流峰值可以到1.5A以上,而原电路用的是标称1A左右的DCDC,加上走线和电容不足,VBAT在下载瞬间直接跌了0.8V,模组欠压关机。
改造方案比较标准,建议直接按下面几项来做:
第一,DCDC选型要留足余量。模组规格书给的峰值电流通常按最大发射功率算,比如Cat.1模组发射峰值接近2A,那电源芯片建议选额定3A以上的,不要卡着规格选。降额设计不是浪费,是为稳定买单。
第二,VBAT端储能电容要加大。电路上近距离放一个大容量电解电容(比如470uF),再并一个100nF的高频瓷片电容。电解电容应对毫秒级的电流瞬态,瓷片电容应对高频噪声。实测这个组合能把Vbat跌落从0.8V压到200mV以内。
第三,VBAT走线要短、要粗。PCB上尽量用铺铜替代细线,减少寄生电阻和电感。别看米欧级电阻小,瞬态大电流下压降很可观。
第四,用示波器抓波形验证。在VBAT测试点上抓下载过程,观察是否有掉坑、毛刺。没有示波器的话,也可以通过模组的欠压复位记录来间接判断,但波形最直观。
注意:很多模组的欠压阈值是有一个滞回区的,比如3.4V左右关机、3.6V以上才能重启。如果VBAT只在3.5V附近徘徊,设备可能表现为偶尔重启、偶尔正常,特别像软件问题。只看日志容易懵,别嫌麻烦,抓波形是说服所有人的硬证据。
3.2 网络参数与下载块大小怎么配
网络策略这块没有统一模板,跟模组型号、运营商网络、固件大小都有关系,但参数配置思维是通用的。我以一个典型配置为例:
HTTP连接超时: 10秒 HTTP读超时: 30秒 写入Flash超时: 看实际擦写时间 + 2秒 单块大小: 256KB 块间间隔: 200ms 单块失败重试: 3次 整体失败最大重试: 5轮 重试退避策略: 30s / 1min / 5min / 30min / 1h连接超时和读超时不能设得太短。蜂窝网络下RTT本来就偏高,加上弱信号时TCP重传,太短的超时会让下载频繁误判失败。但也不能太长,否则一旦链路假死,一个块能卡十几分钟,状态机没法及时恢复。
断点续传记录建议每成功下载一块就做一次。记录内容包括:已下载偏移量、块总数、目标固件版本号、固件大小、校验值。这些数据存到独立Flash区或文件系统里,确保掉电不丢。
3.3 看门狗与任务栈的重新设计
很多设备的"网络重启"其实是主控MCU在看门狗复位。原因通常有两种:一是下载任务处理大块数据、写Flash时阻塞了系统主循环,喂狗不及时;二是下载回调里做太多事,导致单个循环周期超过喂狗时限。
正确的设计思路是把OTA下载放独立任务,并让看门狗专门由一个节拍任务负责,不要在下载循环里临时补喂狗。补喂狗看起来解决了问题,实际上掩盖了主循环卡死的真Bug,危险得很。
任务栈大小也要单独规划。下载任务涉及HTTP解析、JSON处理、Flash读写,函数调用深度不小。栈空间建议比普通业务任务大,比如普通任务1280字节栈,下载任务给到2048甚至4096字节,具体根据实际调用链和编译器输出调整。栈溢出检测功能一定要开,否则死得不明不白。
在OTA下载过程中,建议暂停或降频非关键业务任务,比如UI刷新降帧、传感器采样降频、日志输出缓存起来。这样既降低主控负载,也减少无线网络并发业务,让模组的射频资源专注在下载上。
3.4 断点续传与Flash写入的实现要点
断点续传的代码核心思路倒不复杂。下面给出一个简化但完整的示意结构,我实际项目中差不多就是按这个框架来的:
typedef struct { uint32_t magic; // 记录区标识,防止误读垃圾数据 uint32_t version; // 记录结构版本,便于后续扩展 uint32_t total_size; // 固件总大小 uint32_t offset; // 已下载字节偏移 uint32_t chunk_index; // 当前块序号 uint32_t checksum; // 已下载数据的累积校验值 } ota_progress_t; // 下载前恢复进度 int ota_restore_progress(ota_progress_t *prog) { if (flash_read(OTA_PROGRESS_ADDR, prog, sizeof(*prog)) != 0) { return -1; } if (prog->magic != OTA_PROGRESS_MAGIC) { return -1; // 无有效记录,从头下载 } if (prog->offset >= prog->total_size) { // 已经下完但可能没启动升级,直接进入校验阶段 } return 0; } // 下载一块 int ota_download_chunk(ota_progress_t *prog) { uint32_t chunk_end = min(prog->offset + CHUNK_SIZE - 1, prog->total_size - 1); char range_hdr[64]; snprintf(range_hdr, sizeof(range_hdr), "Range: bytes=%u-%u", prog->offset, chunk_end); int rc = http_get_with_range(range_hdr, block_buf, &block_len); if (rc != 0) { return rc; } // 校验这一块的完整性(比如CRC32或sha256) if (check_block(block_buf, block_len) != 0) { return -ERR_CHECKSUM; } // 写入Flash if (flash_write(OTA_APP_ADDR + prog->offset, block_buf, block_len) != 0) { return -ERR_FLASH; } // 更新进度 prog->offset += block_len; prog->chunk_index++; prog->checksum = accumulate_checksum(prog->checksum, block_buf, block_len); // 保存进度到Flash flash_write(OTA_PROGRESS_ADDR, prog, sizeof(*prog)); return 0; }这里有几个实际操作中的细节值得单独说明。
第一,Flash的擦除粒度。很多嵌入式Flash擦除单位是4KB或64KB,写入前你以为是按字节写的,实际是按扇区擦的。如果每下载一个小块就擦一次,效率极低。我的做法是先在RAM里攒满一个擦除扇区大小的数据,再一次性擦写。
第二,断电安全。进度记录区和固件区如果不是同一个Flash扇区,那么掉电时进度记录可能指向旧值。比较稳妥的做法是固件区写入成功一块后再更新进度记录,这样即使进度略滞后,也只是重复下载一小块,不会出现"跳着写、固件缺块"的情况。
第三,整个固件下载完还要做一次整体校验。分块校验只能保证每一块传输过程没出错,不能保证所有块拼接后的固件一定完整。整体校验建议用MD5或SHA256,从服务器下发的固件信息里带一个预期的哈希值,下载完比对。这个环节能拦截大量"下载成功但升级失败"的疑难Case。
4. 常见问题与排查技巧实录
4.1 下载到一半设备重启,断点记录也丢了
现象:设备下载到某一块之后重启,重启后日志显示从0开始下载。用户很崩溃,等于前面几MB白干了。
这种问题通常出在进度记录的保存时机和原子性上。常见的坑有三个:
一是进度记录写入Flash时,恰好和电源异常撞在一起,写了一半,读出来magic不对,程序只能判断为无记录,从头下。解决办法:使用双备份记录区,两区交替写,读的时候先校验magic,再比较两个备份的offset和checksum,取合理的最新值。
二是进度记录保存得太频繁导致Flash擦写次数耗尽。Flash的擦写寿命是有限的,每下载一块写一次,如果设备反复失败重启,几百次写下来就可能坏扇区。解决办法是减少保存频率,比如每5块或每1MB保存一次进度,损失一点续传精度,换Flash寿命。
三是代码里把进度记录写在"下载完成"回调里,结果下载任务被异常中断,回调根本没执行。解决办法是把保存操作放在下载主流程里,并且独立于网络回调。
4.2 网络连接还在,进程却挂了
这种"假死"现象特别迷惑人:设备能联网、能发心跳,但OTA下载就是一直失败,报超时或连接重置。
排查时先确认一个经常被忽略的点:TCP长连接和HTTP下载连接是不是同一个连接被复用了。如果业务代码里复用同一个HTTP/TCP连接,下载大文件时网络中转设备或运营商策略可能对这个连接做了限速或中止,导致后续所有请求都异常。我的建议是:OTA下载用独立的短连接,一次下载一块,建立连接、请求、收完、断开。虽然建立连接有额外开销,但隔离了风险,失败定位也简单。
如果还是失败,就要看TCP层参数。比如底层协议栈的TCP窗口、重传次数、Nagle算法有没有为"大数据传输"调优。有些LwIP默认配置偏保守,可以在下载期间临时调整TCP发送/接收缓冲区、开启NO_DELAY,提高吞吐。
最后还有一个冷门但很现实的坑:OTA服务器端的上传带宽或单连接限速。你用服务器下载固件速度只有几十KB/s,超时设个10秒肯定不够,下载一块256KB就超时重试、重试又超时。建议做一下实际测速,再按最差速度留3倍余量设超时,别拍脑袋定数。
4.3 弱信号场景下的下载策略调整
有兄弟反馈:固定场景测试都好的,到了客户现场就频繁网络重启。一看日志,模组信号一直在-105dBm上下抖动,小区的RSRP很差。
蜂窝网络弱信号下,OTA下载频繁失败几乎是必然的。单纯靠重试是笨办法,更合理的思路是让设备根据信号质量自适应:
- 开始下载前判断信号阈值。比如RSRP低于-100dBm,暂不启动下载;已经在下载了,发现掉到阈值以下,主动暂停,等信号恢复再续传。
- 下载过程中检测到小区重选或切换,主动放慢节奏,增加块间间隔。
- 弱信号场景下适当降低单块大小。比如信号好时256KB一块,弱信号时64KB一块,失败重传的代价小,也更符合TCP在小窗口下的实际吞吐。
这些策略都不难,难的是把"信号质量"数据真正接入OTA流程,而不是只在日志里打一行。
4.4 升级完成后设备反复重启怎么办
这个问题字面上不叫"网络重启",但实际中经常和OTA一起爆发,我之前也栽过一次。现象是:下载data成功、校验通过、设备自动重启,重启后新固件跑起来,但几秒后又重启,或者不断尝试进入升级流程。
排查路径按顺序走:
第一,确认Bootloader是否真的跳到了新固件分区。如果引导逻辑没改好,可能一直在引导程序和新固件之间循环。
第二,检查升级标志位的清理时机。有些实现是在Bootloader里设置"新固件待启动"标志,然后跳到应用;如果应用启动后没有及时清除标志,Bootloader会认为每次启动都是"新固件待校验",反复回滚或者反复重启。这种Bug特别隐蔽,因为代码逻辑单看都没毛病。
第三,新固件本身有没有跑起来。如果新固件启动后初始化失败,比如外设寻址变了、配置文件不兼容,导致内狗或异常复位,Bootloader按照策略回滚,就会表现出"升级后重启了几次变回旧版本"的现象。这种情况要结合应用日志分析。
第四,看Bootloader回滚策略是否合理。好的做法是:新固件跑起来后先进入"试运行"状态,运行3~5分钟无异常,才正式提交为当前固件;期间一旦复位,就自动回滚到旧固件。不要上来就永久切换,给自己留后路。
注意:升级标志位的读写要加CRC校验。Flash里1变0、0变1的翻转在极端电磁环境下并不罕见,一个被干扰的标志位,就可能让设备陷入"升级-重启-升级"的死循环。
我在这个项目里花了近三个月才把所有坑趟平,最深的体会是:OTA问题很少有单一根因,通常都是多个因素叠加。排查时要有点刑侦思维,先把证据链建立起来,再用排除法锁定核心矛盾。供电、内存、网络、状态机、回滚策略,每一个环节都可能成为那个"压死骆驼的最后一根稻草"。最后再分享一个经验:做完改造后,建议在你的设备上做一轮"断电测试",下载到每个块时随机断电,连续跑几十次,看断点续传和回滚逻辑是不是真的可靠。这一关能过,OTA升级成功率不上99%都难。