去年年底,我维护的一批数据采集终端在客户现场出现偶发死机,定位半天发现是固件里一个缓冲区越界。代码修复只用了一小时,真正的麻烦在设备本身——它们分布在三个省、十几个基站机房,最近的也要跑两百公里。那一刻我意识到,远程更新(remote updating)能力不是“锦上添花”的功能,而是产品能不能低成本长期维护的分水岭。这篇我来聊聊我在单片机(microcontroller)上做固件(firmware)远程升级踩过的坑和最终落地的方案,适合已经能独立开发单片机、但还没系统做过OTA的工程师参考。文章里的方案基于我实际交付的一套代码,不涉及特定厂商的专用库,思路可以直接迁移到 STM32、ESP32、GD32 这类主流平台上。
1. 为什么单片机需要“远程更新”:从一次现场故障说起
1.1 “代码改好了,设备怎么救”才是真问题
嵌入式项目的开发阶段,大家都不太考虑远程更新,反正手里有调试器(JLINK、ST-LINK),改完代码直接烧进去就行。但产品一旦量产铺到现场,问题就完全不一样了。我那个数据采集终端,十几台设备挂在不同的基站机房里,每次现场升级要申请进站许可、协调客户窗口时间、工程师带着笔记本电脑跑过去、用串口线连上设备……光是路上的时间就够写两个版本了。后来我统计了一下,一次现场升级的直接成本(差旅、人工、协调)大约在两千到三千元,而且还有无法量化的时间成本——客户那边要停机等升级,业务受影响。
所以远程更新解决的问题不是“方便”,而是产品的可维护性。固件有Bug要修、通信协议要升级、安全漏洞要打补丁、现场参数要调节,这些如果不能远程做,产品的生命周期成本会高到无法接受。尤其是一些部署在偏远地区、无人值守的设备,远程升级几乎是唯一出路。
1.2 远程更新的适用前提:不是所有项目都适合
这个话可能有点逆耳,但做技术选型之前,真的要先想清楚你的产品有没有条件做OTA。我总结了一下,至少需要满足这几个前提:
- Flash容量要够。远程升级本质上是在本地保留一份新固件的副本,等校验通过后再切换运行。这意味着Flash至少要能装下“当前固件 + 新固件”两份镜像,严格说还需要一个独立的小Bootloader区域。如果芯片Flash本来就抠抠搜搜,连一份固件都勉强塞下,那OTA基本做不了,只能换更大容量的芯片或者用外挂Flash。
- 要有稳定的通信链路。设备能联网,这是OTA的基础。无论是Wi-Fi、以太网、4G Cat.1,哪怕是用LoRa这种窄带通信,只要链路稳定、带宽能接受,都可以考虑做远程更新。带宽太窄(比如纯LoRa,一个包就几十字节)就需要特殊设计分包和续传机制,难度会高很多。
- 能接受“升级过程不可用”。OTA期间设备必然要重启、要停业务。如果产品是7x24小时的强实时控制系统(比如医疗设备、工业运动控制),那OTA就不能简单粗暴地“下载完就复位”,得先做影子系统、双机热备或者灰度升级,这就已经超出普通单机固件升级的范畴了。
如果你的产品满足这几条,那就可以放心往下做了。
1.3 远程更新的核心架构:Bootloader + 双分区 + 下载通道
整体架构其实不复杂,三块内容:
- Bootloader(引导程序):上电后最先执行的代码,负责检查有没有新固件、校验固件合法性、然后跳转到应用程序(App)。Bootloader永远烧死在Flash最前面的区域,不会被App覆盖。
- 双分区(双Bank):Flash划分为两个区域,一个放当前正在运行的App(A区),另一个专门用来接收新固件(B区)。升级时新固件先落到B区,校验成功后由Bootloader决定是直接启动B区,还是把B区内容搬运到A区。
- 下载通道:设备从服务器获取新固件的途径。我比较推荐“MQTT下指令、HTTP下载文件”的组合,后面会有专门一节展开讲。
这三块配合好了,远程更新就是一套非常可靠的能力。
2. 升级的地基:Bootloader与双分区架构
2.1 Bootloader到底该干什么
很多刚接触OTA的朋友容易把Bootloader想得太复杂,觉得它要处理网络、处理协议栈、处理各种异常。我踩过一次坑后得出结论:Bootloader要尽量精简,职责只有两个——校验和跳转。
为什么?因为Bootloader是设备上“最后一道防线”。如果App崩了,Bootloader还在,至少还能重新刷机;如果Bootloader自己出问题了,那设备基本就变砖了,得上编程器才能救回来。所以Bootloader里能不放逻辑就不放逻辑,网络协议栈、文件系统、驱动这些统统不要放进去,只有一段纯粹的校验+跳转代码。
我最终落地的Bootloader功能清单只有这些:
- 初始化时钟和基础外设(UART用于调试输出)
- 检查复位原因(是上电复位还是看门狗复位还是软复位)
- 检查升级标志位(在Flash固定地址记录是否有待应用的新固件)
- 如果存在新固件,校验其头和校验值,通过则执行切换逻辑或直接跳转
- 如果不存在新固件,直接跳转到当前App启动地址
- 启动失败计数达到阈值时,自动回滚到上一个可用版本
就这么简单。网络下载、断点续传、签名验签这些逻辑我都放到App里去做了,Bootloader只做“结果验收”。
2.2 双Bank方案与启动流程
双Bank的划分方式,不同芯片略有差异,但核心思路是一样的。以STM32H7为例,它内部Flash物理上就是两个Bank(Bank0和Bank1),支持映射切换,非常方便。如果用的是没有硬件双Bank的芯片(比如STM32F1、GD32),就需要自己把Flash分区,手动管理“运行区”和“下载区”,其实也能做,只是切换时要多条搬运指令,稍微复杂一点。
我这边用的方案是这样的分区布局:
| Flash区域 | 起始地址 | 大小 | 作用 |
|---|---|---|---|
| Bootloader区 | 0x08000000 | 64KB | 引导程序 |
| 标志位区 | 0x08010000 | 4KB | 存放升级标志、启动计数、固件信息 |
| App A区 | 0x08011000 | 512KB | 当前运行的应用程序 |
| App B区 | 0x08091000 | 512KB | 新固件下载暂存区,也是回滚时的备份区 |
升级启动流程是这样的:
- 设备上电,CPU从0x08000000开始执行Bootloader。
- Bootloader读标志位区,检查“待升级标志”是否被App置位。
- 如果标志位没有置位,Bootloader直接跳转A区App运行。
- 如果标志位置位,说明B区应该已经有一份完整的新固件,Bootloader校验B区固件头(魔数、版本、CRC),通过后把B区固件整体搬运到A区(或直接切换Bank映射)。
- 搬运完成后,清掉“待升级标志”,跳转A区App。
- 如果校验失败,清除标志位,继续跳转A区旧固件,保证设备还能正常工作。
这一步我特别想强调:千万别在Bootloader里做解压、解密这些耗时的操作。有人说“我固件用LZMA压缩,Bootloader解压后执行”,听起来很省Flash,但实际上Bootloader体积会变得很大,而且一旦解压代码本身有Bug,后果是灾难性的。除非是Flash空间实在挤不出来,否则“下载原样固件、Bootloader直接搬运”是最稳妥的路线。
2.3 固件头结构设计与版本管理
固件不能“裸奔”下载到Flash里就完事,Bootloader需要靠固件头来验证这到底是不是一份合法的固件。我设计的固件头结构体长这样:
#define FW_HEADER_MAGIC 0x574D4657 /* "WFMW" */ typedef struct { uint32_t magic; /* 魔数,用来识别固件头 */ uint32_t version; /* 固件版本号,单调递增 */ uint32_t firmware_size; /* 固件数据区长度,不含头部 */ uint32_t crc32; /* 固件数据区CRC32校验值 */ uint8_t signature[64]; /* ECDSA P-256签名,64字节 */ uint32_t timestamp; /* 编译时间戳 */ uint32_t reserved[4]; /* 保留字段 */ } fw_header_t;Bootloader在启动时检查这个结构体里的magic是不是魔数,version是否大于等于当前版本,firmware_size是否在合理范围内,crc32是否和固件数据区算出来的一致。这些检查全过,才允许搬运和跳转。
版本管理上要特别注意:版本号必须单调递增,不允许降级。为什么?因为有时候新固件有Bug,你会想“要不先回退到旧版”,但“允许降级”这个口子一旦留开,就意味着攻击者可以把设备降级到有已知漏洞的旧版本,这在安全敏感的场景(比如支付终端、门禁系统)是绝对不允许的。我的做法是在Bootloader里做版本判断,凡是小于当前运行版本号的固件一律拒绝升级。真遇到必须回退的情况,我宁可发布一个新版本号、但代码内容等价于旧版本的固件,也不开放降级通道。
3. 传输链路怎么选:HTTP下载 + MQTT通知的组合打法
3.1 为什么实际项目不用MQTT直接传固件
很多人在一开始设计远程升级时,第一反应是“设备已经接了MQTT,那直接用MQTT把固件分包发下去不就行了?”这个想法看着简单,实际跑起来问题一堆。
MQTT是为小而频繁的消息设计的,QoS机制、心跳保活、Topic路由都很适合下指令,但固件文件动不动就是几十KB到几百KB,用MQTT分包传输有几个硬伤:
| 对比项 | MQTT分包传固件 | HTTP下载固件 |
|---|---|---|
| 断点续传 | 需要自己设计块序号和管理机制,丢失后处理非常麻烦 | HTTP Range头天然支持,服务端都不用改 |
| 传输效率 | 每条消息都有Topic和包头,有效载荷占比低 | HTTP头开销固定,传输大量数据时效率更高 |
| 服务端实现 | 需要额外的Broker存储大文件,容易触及消息大小限制 | 普通的Nginx/OSS对象存储就能托管固件 |
| 网络适应性 | 长连接在弱网环境容易断,断了要重新协商 | HTTP短连接每块独立,失败重试成本低 |
| 安全扩展 | TLS握手每包都要加密,内存占用大 | 下载走HTTP+签名校验,服务器压力小 |
所以我的方案是MQTT只下“升级通知”和“升级指令”,固件文件的传输交给HTTP。这好比MQTT是发号施令的调度员,HTTP是搬运货物的卡车司机,各干各的活,互不干扰。
3.2 后台下发链路设计
后台下发升级指令的完整链路长这样:
- 设备上电联网后,通过MQTT订阅自己的OTA指令Topic:
/ota/{device_id}/cmd。 - 运维在后台管理页面上传新固件,系统自动生成固件头、计算CRC、签名,然后推送到CDN或对象存储。
- 后台向目标设备的MQTT Topic发送升级指令,JSON格式大概是这样:
{ "cmd": "upgrade", "version": "210", "url": "https://ota.example.com/fw/app_210.bin", "md5": "e6a9c0f1a2b3c4d5e6f7a8b9c0d1e2f3", "timestamp": 1735689600 }设备端MQTT回调收到指令后,先做几个判断:当前版本是否低于目标版本、设备当前是否处于空闲状态(不在执行关键任务)、剩余电量是否充足(电池供电设备)。全部满足再启动下载流程。
设备通过HTTP从
url指定的地址下载固件,下载到B区,校验通过后置位“待升级标志”,然后主动软复位进入Bootloader完成切换。
这里有个经验之谈:升级指令里一定要带timestamp或某种一次性标识,防止设备重复处理同一条升级指令。我在早期版本里没加这个字段,设备断网恢复后把积压的MQTT消息全消费了一遍,导致同一条升级指令被反复执行,最后是靠重启次数计数和版本号判断才拦住,绕了一圈。
3.3 设备端下载状态机
设备端下载固件不能写成简单的“顺序执行”代码,我建议用一个状态机管理,否则任何一个环节断网、失败、重启,整个流程就乱了。我设计的下载状态机如下:
OTA_IDLE → OTA_DOWNLOADING → OTA_VERIFYING → OTA_READY_TO_SWITCH ↓ (校验失败) ↓ OTA_ERROR具体转移条件:
| 状态 | 触发条件 | 下一状态 |
|---|---|---|
| OTA_IDLE | 收到MQTT升级指令且版本合法 | OTA_DOWNLOADING |
| OTA_DOWNLOADING | HTTP下载完成,进入校验环节 | OTA_VERIFYING |
| OTA_DOWNLOADING | 下载过程中连续超时、断开次数超过阈值 | OTA_ERROR |
| OTA_VERIFYING | CRC32校验通过且ECDSA签名验签通过 | OTA_READY_TO_SWITCH |
| OTA_VERIFYING | 校验失败 | OTA_ERROR |
| OTA_READY_TO_SWITCH | 置位升级标志,软复位 | (进入Bootloader) |
状态机实现时,每一步都要能“断点续跑”。比如下载到一半断电了,重新上电后App正常启动,但启动流程里要检查“下载未完成标志”,如果标志存在且B区固件长度大于0,就接着上次的进度继续下载,而不是从头再来。这里的关键是把下载进度记录下来——我分配了一个4字节的Flash变量专门存“已下载字节数”,每写完一块数据就同步更新一次。
4. 全链路实操:从固件打包到应用跳转
4.1 固件签名与打包脚本
固件不能直接在IDE里编译完就传上去,得先在CI服务器上做一次“打包加工”:给二进制文件加上固件头、计算CRC、生成签名。我写了一个Python脚本放在CI流水线里,大概长这样:
#!/usr/bin/env python3 import hashlib import struct import sys from ecdsa import SigningKey, NIST256p def build_firmware(raw_bin: bytes, version: int, privkey, out_path: str): # 1. 计算CRC32 crc = zlib.crc32(raw_bin) & 0xFFFFFFFF # 2. 对固件数据区做SHA256,再用ECDSA签名 digest = hashlib.sha256(raw_bin).digest() signature = privkey.sign_digest_deterministic(digest, hashfunc=hashlib.sha256) # 3. 组装固件头 header = struct.pack('IIII', 0x574D4657, # magic version, # version len(raw_bin), # firmware_size crc # crc32 ) + signature # 4. 头部不足补零,然后拼接固件数据 header = header.ljust(128, b'\x00') with open(out_path, 'wb') as f: f.write(header) f.write(raw_bin) if __name__ == '__main__': privkey = SigningKey.from_pem(open('ota_private.pem', 'rb').read()) raw = open(sys.argv[1], 'rb').read() build_firmware(raw, int(sys.argv[2]), privkey, sys.argv[3])这个脚本有几处细节值得注意:
- 签名的数据是SHA256摘要,不是整个文件。ECDSA签名长度固定64字节,对摘要签名效率高,校验方也只需要算一次哈希。
- 固件头统一填充到128字节。这样读取固件头时可以固定按128字节读,不用处理不定长头部的问题。
- 签名私钥绝对不能进代码仓库。我把它放在CI服务器的密钥管理服务里,编译完成后注入构建环境,这样即使固件源码泄露,攻击者也拿不到私钥,伪造不了固件。
4.2 设备端升级主流程代码解析
设备端下载固件的核心流程,我用伪代码梳理一下(以RTOS环境为例,跑两个任务:主控任务和网络任务):
void ota_task(void *arg) { while (1) { // 等待MQTT升级指令 ota_cmd_t cmd = mqtt_wait_upgrade_cmd(); // 版本检查,当前版本必须低于目标版本 if (cmd.version <= get_current_fw_version()) { log("ignore old version: %d <= %d", cmd.version, get_current_fw_version()); continue; } // 进入下载状态,创建HTTP连接 ota_set_state(OTA_DOWNLOADING); http_client_t *http = http_connect(cmd.url); uint32_t offset = ota_get_download_progress(); // 断点续传 while (offset < cmd.fw_size) { // 每次读1KB int len = http_range_read(http, offset, block_buf, OTA_BLOCK_SIZE); if (len <= 0) { // 网络异常,重试3次,失败则退出 if (++retry_cnt > 3) { ota_set_state(OTA_ERROR); break; } continue; } // 写B区Flash,这里要考虑扇区擦写对齐 flash_write_bank(BANK_B, offset, block_buf, len); offset += len; ota_set_download_progress(offset); // 记录进度 retry_cnt = 0; } if (offset == cmd.fw_size) { // 下载完成,进入校验 ota_set_state(OTA_VERIFYING); if (verify_firmware_bank(BANK_B) == 0) { ota_set_state(OTA_READY_TO_SWITCH); set_upgrade_flag(1); // 置位升级标志 NVIC_SystemReset(); // 软复位进Bootloader } else { ota_set_state(OTA_ERROR); } } } }这段代码是我在实际项目中简化后的版本,几个关键点:
http_range_read是核心。它通过HTTP的Range: bytes=offset-(offset+len-1)头实现“跳到指定位置读取”,这样断点续传完全不需要服务端做特殊适配,Nginx和CDN天然支持。- 每次只读1KB,是因为我的设备RAM有限,预留的接收缓冲区就是1KB。如果RAM充裕,可以一次读4KB或8KB,减少HTTP请求次数,效率更高。
ota_set_download_progress不能省。一旦下载过程发生掉电重启,没有进度保存就得从头下载几百KB的固件,在弱网环境下几乎是灾难。
4.3 Flash写入细节与注意事项
Flash写入是整个OTA里最容易翻车的环节。我归纳了几个必须注意的点:
1. 写入地址要8字节对齐。以STM32H7为例,HAL的Flash写入接口HAL_FLASH_Program支持8字节(双字)写,如果你用4字节接口或者手写字节流,轻则写入慢,重则触发HardFault。我在正式写代码时,每块下载数据都会先放在RAM里凑够8字节的整数倍再写入。
2. 擦除不能跨扇区。Flash的擦除最小单位是扇区(STM32H7是8KB/128KB不等),写入前必须先把目标扇区擦除。如果一次写入的数据跨越了两个扇区,就要分别处理两个扇区。我习惯的做法是:下载数据先攒够一个扇区大小(比如4KB),然后统一擦除、统一写入。
void flash_write_with_erase(uint32_t dst_addr, const uint8_t *data, uint32_t len) { uint32_t sector = get_flash_sector(dst_addr); // 1. 擦除目标扇区 FLASH_EraseInitTypeDef erase_cfg = {0}; erase_cfg.TypeErase = FLASH_TYPEERASE_SECTORS; erase_cfg.Sector = sector; erase_cfg.NbSectors = 1; erase_cfg.VoltageRange = FLASH_VOLTAGE_RANGE_3; uint32_t error_sector = 0; HAL_FLASHEx_Erase(&erase_cfg, &error_sector); // 2. 写入(必须对齐到8字节) for (uint32_t i = 0; i < len; i += 8) { uint64_t data64; memcpy(&data64, data + i, 8); HAL_FLASH_Program(FLASH_TYPEPROGRAM_DOUBLEWORD, dst_addr + i, data64); } }3. Flash擦写在执行期间会阻塞CPU。一个大扇区的擦除可能耗时几十毫秒到上百毫秒,这段时间内MCU无法响应中断和喂看门狗。所以擦除前一定要先暂停喂狗,擦完再恢复,否则升级到一半设备被看门狗复位,前功尽弃。
5. 防“变砖”设计:验签、回滚与掉电保护
5.1 为什么签名验证是安全底线
很多开发者一上来就问:“远程升级要用HTTPS吧?”我的答案是:HTTPS可以做,但签名验证必须做。甚至可以说,如果你只能二选一,那签名验证比HTTPS更重要。
为什么?因为HTTPS保护的是“传输过程中有没有被篡改”,它依赖服务器的TLS证书;而签名保护的是“这份固件到底是不是你们公司发布的”,它依赖你手里的私钥。如果你的服务器被攻破、或者运维误操作把一个被篡改的固件传到了服务器上,HTTPS根本拦截不住——因为传输通道是加密的,但内容已经是毒药。签名验证则能在Bootloader层面直接拒绝任何没有正确私钥签名的固件。
我用的方案是ECDSA P-256签名 + Bootloader内置公钥。流程如下:
- 固件打包时,私钥对固件数据区的SHA256摘要签名,得到64字节签名,写入固件头。
- Bootloader里烧录了对应的公钥。
- 设备下载完固件后,Bootloader计算固件数据区的SHA256摘要,用公钥验签。验签通过才允许启动。
这个过程中,私钥是整个安全体系的命根子。用硬件安全模块(HSM)或密钥管理服务存私钥,别把私钥放在编译机上裸存,这是我之前吃过亏换来的教训——有一版CI服务器被挖矿病毒入侵,构建脚本被植入了恶意代码,好在那次私钥存在远程KMS里,攻击者只拿到了编译产物,没有拿到私钥,虚惊一场。
5.2 掉电中断与崩溃自动回滚
“变砖”这件事,最常见的原因不是固件本身有Bug,而是升级过程中掉电、或者新固件启动后跑飞了。针对这两种情况,我设计了双重保护:
第一重:掉电保护。下载过程中掉电,因为B区固件还没校验通过、升级标志也没置位,设备重新上电后Bootloader直接启动A区旧固件,B区留着一份“半成品”下次继续下。这依赖前面说的“下载进度保存”,只要进度记录在Flash里,就能续传。
第二重:启动失败自动回滚。新固件搬运到A区并启动后,如果跑飞了怎么办?靠看门狗。我的实现是:
- App启动后,在5秒内把自己的“健康标志”写入Flash(比如在标志位区写一个固定值)。
- Bootloader在每次启动App前,先读“启动失败计数”。如果计数大于等于3,说明App连续3次都没能成功跑到“健康标志”写入点,Bootloader就判定新固件不行了,自动从B区(升级前的老固件还在B区)回滚。
- 如果App正常写入健康标志,Bootloader会把“启动失败计数”清零。
/* Bootloader中的回滚判断逻辑 */ void boot_check_and_rollback(void) { uint8_t boot_count = read_boot_count(); uint8_t app_healthy = read_app_healthy_flag(); if (app_healthy == HEALTHY_MAGIC) { // App上次运行正常,清零计数器 write_boot_count(0); } else { // App上次启动失败,计数加1 boot_count++; write_boot_count(boot_count); if (boot_count >= 3) { log("boot failed too many times, rollback"); rollback_firmware(); // 从B区恢复旧固件到A区 write_boot_count(0); } } }这套机制我跑了半年,效果很好。唯一要注意的一点是:健康标志写入时机不能太早。我最初图省事,在App的main()入口第一行就写健康标志,结果新固件在初始化外设时崩了,健康标志已经写进去了,回滚机制完全没触发,设备死循环重启。后来我把健康标志放到了系统启动完成后、业务开始运行前的那一刻,才算真正可靠。
5.3 防降级攻击的版本号策略
前面提到版本号要单调递增,这里再补充一个细节:版本号的意义不只是给用户看的,它在安全上也有作用——防止攻击者把设备“降级”到有漏洞的旧版本。
比如你的2.1.0版修掉了一个缓冲区溢出漏洞,攻击者如果能把设备降级到2.0.0(有漏洞的版本),就可以利用这个漏洞发起攻击。所以Bootloader判断时,不但要拒绝“等于当前版本”的固件,还要拒绝“小于当前版本”的固件。
版本号本身不要放在固件头里就算完,建议在标志位区单独维护一个“当前有效版本号”的持久化变量,每次固件切换成功后由Bootloader更新这个变量。这样即使B区固件头里的版本被人为篡改,Bootloader比较的是标志位区里的真实版本,篡改固件头伪造不了“已安装版本”。
6. 实测踩坑记录:向量表重映射、Flash寿命与看门狗
6.1 中断向量表重映射的坑
这个坑我印象太深了。第一次把App放到0x08011000跑,结果板子一上电就进HardFault。查了半天,发现是中断向量表还指向0x08000000。
Cortex-M内核的中断向量表默认位于Flash起始地址,你的App在0x08011000,如果不把向量表重映射过去,一旦发生任何中断,CPU会从0x08000000读向量表,此时读到的是Bootloader的向量,中断处理自然错乱。
解决方案是启动App后立刻设置VTOR寄存器:
void app_init_vector_table(void) { SCB->VTOR = APP_START_ADDR; // 0x08011000 }如果是带Bootloader的项目,在App的SystemInit()函数里通常已经根据链接脚本设置好了,但千万别依赖“应该设好了”,我在实际调试时吃过好几次亏,最后还是老老实实在App启动第一件事调用SCB->VTOR = APP_START_ADDR,同时要求编译器把启动文件里的SystemInit覆盖掉,改成我们自己控制的版本。
还有个小细节:跳转到App之前,Bootloader最好把所有用到的中断都关掉,避免中断残留导致App启动就进异常。我在Bootloader跳转函数里加了一句__disable_irq(),然后在App启动过程中再打开,效果立竿见影。
6.2 Flash擦写寿命与写粒度
单片机的Flash是有擦写次数寿命的,STM32H7的数据手册通常标称10万次擦写。听起来很多,但OTA场景下,每次升级都要擦写几百KB的数据,如果进度保存做得不好,一块数据反复擦写,寿命消耗得很快。
我遇到过一个问题:设备频繁断网重连,导致下载进度老是回退,同一个Flash扇区被反复擦写。几个月后发现那块设备升级成功率明显下降,读取Flash内容偶尔出错,一查才发现是扇区寿命被提前消耗了。
解决方案有两个层面:
- 软件层面:降低擦写频率。下载进度不用每1KB都写Flash,我改成每写完64KB(即一个完整扇区)才更新一次进度,这样即便断点续传,最多回退64KB,损失可接受,但Flash擦写次数少了两个数量级。
- 硬件层面:预留“磨损均衡”空间。在B区后面多留一个扇区,用于交替写入进度信息,让Flash的擦写均匀分布到多个扇区,延长整体寿命。
6.3 看门狗与升级流程的冲突
看门狗是用来防程序跑飞的,但在OTA下载这种“长时间阻塞”的操作里,它反而会捣乱。
我之前在下载固件时用了独立看门狗(IWDG),默认超时时间2秒。HTTP下载一块数据加上Flash擦写,耗时可能超过2秒,结果每次下载几块就被看门狗复位,设备陷入了“下载一复位一下载”的死循环。查了半天才发现,问题出在擦写Flash期间CPU被阻塞,没法喂狗。
处理方式有几种:
- 升级期间暂停喂狗窗口,但这是危险的,一旦下载代码有死循环,设备就彻底瘫了。
- 把看门狗超时时间调长,比如30秒,覆盖最长的单块下载+擦写时间。这是比较稳妥的方案。
- 在Flash擦写前先喂一次狗,擦写完成后马上再喂,用软件保证擦写期间不超时,同时设置一个较长的总超时用于兜底。
我最终用的是第二种+第三种组合:看门狗超时增加到15秒,同时每次进入下载状态机前先喂狗,每下载完一块也喂狗。这样即使某块下载卡住了,15秒后设备重启,还能靠Bootloader的启动失败计数回滚,不至于变砖。
6.4 网络模块与下载速度的取舍
最后说一个很多教程不会提的坑。如果你用的是Wi-Fi模块(比如ESP8266)+ AT指令这种方式联网,下载几百KB固件会非常折磨人。AT指令模式下,数据是一帧一帧透传的,每帧数据都要走一遍AT指令解析,传输效率很低,实测下载256KB固件可能要几分钟到十几分钟。
如果项目对升级速度和稳定性有要求,我建议优先考虑两种方案:
- 用自带协议栈的主控(比如ESP32、带以太网的STM32H7系列),直接在主控上用原生Socket实现HTTP下载,效率高得多。
- 用串口速率更高的模块,并且把串口波特率提到921600,而不是默认的115200,能缩短不少时间。
顺带说一句,如果设备用量大、场景分散,升级流量费这块也要提前算清楚。一个300KB的固件,几百台设备全量升级,流量费可能比想象中高。我后来加了一个“按设备组灰度升级”的逻辑,先在测试组设备上跑一版,没问题再推生产组,既控风险也控成本。
我个人的体会是,远程固件升级不是一个可以“后面再补”的功能,一旦产品量产再想加,就要面临现场升级的巨大成本。所以在新产品研发阶段就把Bootloader和双分区架构预留好,哪怕第一版不启用OTA,也比亡羊补牢强得多。上面这套方案花了大概两周时间落地,之后每次出问题,都能在办公室里远程把现场几百台设备全量更新,那种感觉是真的踏实。