公司动态

单片机固件远程升级实战:Bootloader、双分区与OTA架构详解

📅 2026/8/27 6:49:36
单片机固件远程升级实战:Bootloader、双分区与OTA架构详解
去年年底我维护的一批数据采集终端在客户现场出现偶发死机定位半天发现是固件里一个缓冲区越界。代码修复只用了一小时真正的麻烦在设备本身——它们分布在三个省、十几个基站机房最近的也要跑两百公里。那一刻我意识到远程更新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覆盖。双分区双BankFlash划分为两个区域一个放当前正在运行的AppA区另一个专门用来接收新固件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物理上就是两个BankBank0和Bank1支持映射切换非常方便。如果用的是没有硬件双Bank的芯片比如STM32F1、GD32就需要自己把Flash分区手动管理“运行区”和“下载区”其实也能做只是切换时要多条搬运指令稍微复杂一点。我这边用的方案是这样的分区布局Flash区域起始地址大小作用Bootloader区0x0800000064KB引导程序标志位区0x080100004KB存放升级标志、启动计数、固件信息App A区0x08011000512KB当前运行的应用程序App B区0x08091000512KB新固件下载暂存区也是回滚时的备份区升级启动流程是这样的设备上电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_DOWNLOADINGOTA_DOWNLOADINGHTTP下载完成进入校验环节OTA_VERIFYINGOTA_DOWNLOADING下载过程中连续超时、断开次数超过阈值OTA_ERROROTA_VERIFYINGCRC32校验通过且ECDSA签名验签通过OTA_READY_TO_SWITCHOTA_VERIFYING校验失败OTA_ERROROTA_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, hashfunchashlib.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: bytesoffset-(offsetlen-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也比亡羊补牢强得多。上面这套方案花了大概两周时间落地之后每次出问题都能在办公室里远程把现场几百台设备全量更新那种感觉是真的踏实。