
两年前我第一次在产线上看到一整批设备因为升级中途断电变砖的时候我才意识到 OTA 在嵌入式产品里不是锦上添花而是保命的底线。今天要复现的就是一套基于 STM32F103 的 AB 双分区 OTA 方案核心思路是让固件在 A/B 两个分区里交替运行升级失败能自动回滚从根源上避免“升级一次、返修一批”的惨剧。这套方案在工业设备、车联网终端、智能家电里非常常见代码逻辑我按标准库重新整理过下面从分区规划、引导逻辑到传输协议一步步说清楚适合想在 F103 工程里加入 OTA 能力的同学直接参考。1. 项目概述为什么我选择在STM32F103上做AB OTA1.1 AB方案的本质用“双槽位思维”替代“单区覆盖”很多刚开始接触 OTA 的朋友会问单片机直接往 App 区写新固件不就行了为什么非要搞 AB 两个分区这个问题背后其实藏着一个很容易被忽略的血泪教训。假设你的 App 区只有一个升级过程是“擦除旧固件 - 写入新固件”那么只要中间任何一步出错——串口断线、电压跌落、看门狗复位、上位机崩溃——Flash 里残留的就是一个半成品固件。设备复位后 bootloader 找不到完整有效的 App只能老老实实待在 boot 模式或者直接变砖。对于需要人工到现场处理的设备这个成本根本不是一颗单片机多少钱能覆盖的。AB 方案做的事情很简单在 Flash 里同时准备两个能独立运行的 App 分区。常态下设备跑 A 区升级时把新固件完整写入 B 区等所有数据都写完、校验也通过了才把启动标志切到 B 区再复位。这样无论升级过程怎么折腾A 区的旧固件都完好无损。万一新固件在 B 区校验失败bootloader 直接忽略 B 区继续跑 A 区。这就是 AB 方案最核心的价值升级动作本身永远不会破坏当前正在运行的系统。实际项目中我用过单区 OTA 也用过 AB OTA结论很直接凡是现场维修人力成本超过两颗 MCU 价格的设备AB 方案都是必然选择。不过 AB 方案也不是没有代价最主要的代价就是 Flash 容量翻倍。F103 这代芯片的 Flash 从 64KB 到 512KB 都有如果你的型号只有 64KB做 AB 分区就会非常紧张。所以我的建议是做 AB OTA 至少选 128KB 以上的 F103 型号比如 STM32F103RCT6 的 256KB 或者 ZET6 的 512KB这样分区规划起来才从容。这也是“用空间换可靠性”在嵌入式领域最典型的一个例子。1.2 一套能落地的AB OTA由哪几部分组成把 AB OTA 拆开看其实一个完整方案由四个部分构成缺一不可Bootloader 引导程序放在 Flash 最开头负责上电初始化、检查升级标志、校验固件有效性、跳转到 A 区或 B 区。它是最小、最稳定的程序因为所有恢复逻辑都压在它身上。双 App 区A 区和 B 区两块容量相同的固件存储区里面分别是正常运行的应用固件。同一时刻只会运行其中一个另一个要么是待更新的目标要么是上一次运行的旧版本。参数/标志存储区保存当前激活分区编号、待更新分区编号、升级状态、版本号等信息。这块区域要求能单独按页擦写不能和代码区混在一起因为升级过程中要频繁修改这些标志。上位机或云平台侧负责把固件分包发送到设备。实际项目中可能是 PC 串口助手、手机蓝牙工具、TCP 服务器甚至 MQTT 云平台协议层设计好之后传输通道可以随意替换。整套流程跑起来就是一条闭环设备正在 A 区运行 - 收到新的固件包 - 逐包写入 B 区 - 写完做整包校验 - 置位“下次从 B 区启动”标志 - 软复位 - bootloader 看到标志后校验 B 区 - 跳转 B 区运行。如果 B 区校验失败bootloader 就把标志清掉继续跳回 A 区用户侧几乎无感知。2. 核心方案设计分区规划与升级流程拆解2.1 Flash分区怎么分最合理Flash 分区是整个方案的地基分区设计不合理后边写代码全是坑。STM32F103 的 Flash 起始地址是 0x08000000不同容量的型号每页大小不一样小容量和中容量系列每页 1KB大容量系列每页 2KB。分区规划时尽量按页对齐尤其是参数区因为 Flash 擦除的最小单位是页如果你要改一个字节也得整页擦掉重写。以我常用的 STM32F103RCT6256KB Flash为例分区表是这样的分区起始地址大小用途Bootloader0x0800000016KB引导跳转、校验、升级入口参数区0x080040004KB分区标志、版本信息、升级状态App A0x0800500096KB当前运行固件 AApp B0x0801D00096KB待更新/回滚固件 B预留0x0803500043KB日志、参数存储、小文件系统为什么 Bootloader 只给 16KB因为 bootloader 只需要串口接收、Flash 写入、校验跳转这几件事16KB 绰绰有余。为什么要单独留一个 4KB 参数区因为升级过程中要反复修改“当前运行区”和“目标更新区”这两个标志如果这个标志放在 Bootloader 区里每次修改都要擦除 Bootloader 页面稍有不慎连引导程序都会被破坏单独划一个参数页Bootloader 的代码区就可以写保护。App 区大小则要根据你的实际固件大小来定。我一般建议 App 区设置为编译产物最大尺寸的 1.2 倍以上留出后续功能迭代的余量。如果固件做出来已经 80KB却只分配 80KB 的区下一次需求稍微加几个功能就放不下了。用 Keil 编译的话看编译日志里的Program Size: Code... RO-data... RW-data... ZI-data...把前三个相加就是 Flash 占用。另外提醒一点F103 的大容量系列每页是 2KB分区地址如果只做 1KB 对齐也能用但擦除时间会变长而且容易把别的分区数据误伤。所以分区地址最好按 2KB 对齐参数区至少要分配 2 个页以上避免磨损均衡问题。2.2 Bootloader 引导逻辑与 AB 切换Bootloader 的引导逻辑是整个方案里对“返回值”要求最严格的部分它必须在任何异常情况下都能做出正确决策。我设计的状态图可以简化成下面这个流程上电初始化先读取参数区的启动标志。如果标志是BOOT_TO_A校验 A 区固件头部的 Magic、版本号、CRC。校验通过就跳转 A 区不通过就尝试校验 B 区。如果标志是BOOT_TO_B同样先校验 B 区、再尝试 A 区。如果两个区都校验失败停留在 Bootloader等待串口升级命令或者按键进入升级模式。如果标志是UPGRADE_PENDING说明上次升级尚未完成优先检查目标分区是否完整完整就跳转目标分区不完整则回滚到另一个分区。这里最关键的是“回滚优先级”问题。我的经验是以当前运行分区为主而不是以标志为主。也就是说如果标志说要从 B 区启动但 B 区校验失败不要傻乎乎地去清标志而是直接回滚到 A 区因为 A 区就是上一次正在运行的固件它大概率是好的。实际项目中我还见过一种情况A 区升级到 B 区后B 区运行不稳定导致连续复位这种问题靠 bootloader 静态校验是发现不了的需要 App 自身在运行早期做健康检查如果连续启动 N 次都失败就把“当前运行区”标志写回另一个分区形成一层软件看门狗。这个机制在后面回滚部分详细展开。跳转到 App 区之前的最后一步也容易出错。F103 的向量表默认在 0x08000000App 区起始地址不是 0x08000000 的话必须先设置向量表偏移同时要关闭全局中断、复位 SysTick、清空中断控制器里可能挂起的中断否则 App 启动过程中一个残留的中断就能让系统死机。2.3 固件包格式与升级协议设计固件包格式决定了 Bootloader 怎么识别、校验和写入。我通常把一次升级的完整数据拆成两个层级固件镜像层和传输帧层。固件镜像层是原始的.bin文件加上一个自定义头部结构如下偏移长度内容04Magic固定 0xA5A5A5A5用于识别合法固件42固件版本号如 0x0101 表示 1.162App 分区编号0 表示 A 区1 表示 B 区84固件总长度124整体 CRC32 校验值1616签名、保留字段可按需扩展32N实际固件二进制数据Bootloader 收到这个头部后先检查 Magic 和版本号再按长度逐包接收数据接收完后计算 CRC32与头部字段比较全部通过才允许切换启动分区。这里版本号的检查很容易被忽略一定要禁止降级升级。否则你在产线上调试时误发了一个旧版本固件设备直接回退后面所有状态都可能对不上。传输帧层则是通信通道上的最小数据单位。以串口 YMODEM 协议为例每一帧包含帧头、序号、数据、校验。串口 UART 最大优点就是简单可靠Bootloader 里不用解析太多协议但要注意波特率不要选太高我用 115200 有概率在干扰严重的环境中丢字节后来又加了一层 CRC 重传机制才稳定。实际项目中我还用 CAN 总线做过 OTA那套方案帧结构基本类似只是传输层从 UART 换成了 CAN FD好处是速率更高、带硬件 CRC坏处是 Bootloader 里要调用 CAN 驱动复杂度高不少。选择哪种链路取决于产品的通信接口协议层设计成“传输无关”是最理想的状态。3. 从零搭建Bootloader与App工程的实操要点3.1 Bootloader 工程跳转、校验、Flash写入写 Bootloader 第一个要解决的问题是跳转。网上能搜到很多跳转函数但真正能直接用的不多。我这里贴一个经过多个项目验证的版本typedef void (*pFunction)(void); static void jump_to_app(uint32_t app_addr) { uint32_t msp_value *(volatile uint32_t *)app_addr; pFunction app_reset (pFunction)(*(volatile uint32_t *)(app_addr 4)); // 检查栈顶指针是否合法必须落在 RAM 区间 if ((msp_value 0xFFF00000u) ! 0x20000000u) { return; } // 关闭全局中断避免残留中断进入 App __disable_irq(); // 复位 SysTick SysTick-CTRL 0u; SysTick-LOAD 0u; SysTick-VAL 0u; // 清空中断控制器中所有挂起/使能状态 for (uint32_t i 0u; i 8u; i) { NVIC-ICER[i] 0xFFFFFFFFu; NVIC-ICPR[i] 0xFFFFFFFFu; } // 设置主栈指针并跳转 __set_MSP(msp_value); app_reset(); // 正常情况不会执行到这里 while (1u) { } }这段代码里有几个细节值得强调。第一判断msp_value是否落在 RAM 区间是最容易被忽略的检查如果不加而 Flash 里恰好是一堆 0xFF读出来的栈指针根本不是合法 RAM 地址跳转后立刻 hardfault。第二__disable_irq()和后面清 NVIC 的步骤是配套的只关中断不清 NVIC之前挂起的 PendSV 或 SysTick 中断会跟着进 App。第三__set_MSP必须在跳转函数前完成因为 App 复位向量要使用它。Flash 写入函数建议放在 Bootloader 里复用。F103 的 Flash 写入要注意两条一是写入前必须解锁 Flash 控制器写入完成后重新上锁二是官方FLASH_ProgramHalfWord只能按 16 位写入写 32 位数据需要拆两次。更高效的做法是一次读 8 字节或 16 字节进缓冲区再按FLASH_ProgramWord写入这样能显著缩短升级耗时。有一点必须提醒写 Flash 期间不要进入中断尤其是串口接收中断否则如果在擦除/写入过程中被打断Flash 控制器会进入忙状态轻则写入失败重则把当前页写坏。所以我的 Bootloader 在调用 Flash 擦写函数时会先__disable_irq()写完再恢复。3.2 App 侧中断向量重映射与固件信息App 工程最关键的一行代码是设置向量表偏移SCB-VTOR APP_START_ADDR; // 例如 0x08005000对于 F103 来说VTOR 寄存器是存在的可以直接设置。但有个前提App 工程的链接脚本必须把代码加载地址指定到对应的分区起始地址。用 Keil MDK 的话在 Option for Target - Target 页签里设置IROM1的 Start 为0x08005000Size 为0x1800096KB。这一步经常有人漏掉结果编译出来的 App 还是按 0x08000000 生成了Bootloader 跳转过去自然跑飞。除了设置 VTOR我建议在 App 的 main 函数最开始加一段“固件信息检查”逻辑。因为 App 也可能发生 Flash 读取异常或者版本不匹配的情况启动时再自查一遍版本号是不是自己期望的如果不一致就主动软件复位让 Bootloader 重新决策。这种“双重保险”在实际产线验证中帮过大忙。另外要注意App 里如果要实现执行期间动态修改中断向量表需要在修改前等待所有中断处理完毕最简单的方式是修改 VTOR 后立即执行一条__DSB()和__ISB()指令确保流水线中不会拿到旧向量表的数据。这也是踩坑后才补回来的细节。3.3 升级状态机与传输层实现升级过程本质上是一个有限状态机。状态定义得越清晰代码越不容易绕晕。我常用的一组状态如下状态含义进入条件退出条件OTA_IDLE空闲上电/复位收到升级开始命令OTA_START开始升级收到头部帧头部校验通过写入目标分区OTA_DOWNLOAD数据下载头部校验通过收到结束帧OTA_VERIFY整包校验下载完成CRC/版本校验通过或失败OTA_APPLY切换分区校验通过写入启动标志软复位OTA_ERROR升级失败任一环节异常清除临时数据回滚/重试Bootloader 和 App 里都跑这个状态机只是职责不同。Bootloader 的下载状态机在收到升级命令后直接把数据写入“非当前运行区”App 的下载状态机则更常见——因为实际项目中设备大部分时间都在正常运行OTA 升级指令往往先到达 App再由 App 负责接收、存储到外部 Flash 或者直接写入另一分区最后再软复位交给 Bootloader。传输层实现时我强烈建议给每一帧都加上序号、长度、CRC 校验并实现“超时重传”机制。串口场景下如果一帧丢了整个升级直接中断体验非常差加一个简单的 ACK/NAK 应答Bootloader 侧留个 1 秒超时重发就能把误码率降好几个数量级。之前的项目里我还用了一个小技巧接收缓冲区和 Flash 写入缓冲区分离串口 DMA 接收到 2KB 就触发一次 Flash 写入写完后立刻清空缓冲区继续收这样升级速度能达到 30KB/s 以上远快于逐字节轮询写入。3.4 回滚与校验机制的实现细节回滚机制是 AB OTA 的灵魂设计不到位等于白做。我的实现思路分三层第一层Bootloader 静态校验回滚。每次复位后 Bootloader 先看启动标志再对目标分区做 Magic CRC 校验。校验失败就自动切到另一个分区同时把标志改回去。这一层解决的是“固件没写完/写错”的问题。第二层App 启动自检回滚。有些固件能通过 CRC 校验但跑起来就因为配置错误或者硬件不兼容死机。策略是 App 启动后的一段初始化阶段被称为“危险期”危险期内如果发生 hardfault 或者看门狗复位App 自身把启动标志回退到上一个分区再软复位。可以用一段“启动次数计数”实现每次复位次数加一超过 3 次就自动回滚。第三层运行期动态回滚。设备持续运行过程中如果 App 检测到关键业务连续报错比如通信模块反复掉线可以主动把升级标志写回旧分区并复位。这一层适合对可靠性有更高要求的场景相当于把“系统健康度”纳入了回滚决策。在代码实现上把“写入启动标志”和“清空启动标志”这两个函数封装成独立的模块。启动标志建议用双字冗余存储写的时候先写备份值再写主值读的时候两个值一致才算有效防止写入过程中掉电导致标志损坏。这个细节非常重要我在一次高低温测试中遇到过标志区写入一半断电恢复后 Bootloader 既不能进 A 区也不能进 B 区后来加了冗余存储才彻底解决。4. 完整复现从空工程到E2E升级演示4.1 环境准备与工程初始化复现这套方案需要准备以下环境和工具硬件STM32F103RCT6 或 ZET6 开发板需要引出 UART1PA9/PA10和一个按键用于进入强制升级模式。工具链Keil MDK 5或 GCC MakefileSTM32 标准外设库 3.5。调试工具ST-Link/J-Link串口调试助手。工程目录建议这样组织Boot/和App/两个独立 Keil 工程。两个工程共用一套 Flash 驱动和通信驱动源码通过宏定义区分启动地址避免重复造轮子。初始化时先把 Flash 分区地址宏定义好// common_flash_map.h #define FLASH_BASE_ADDR 0x08000000u #define BOOT_SIZE 0x00004000u #define PARAM_RESERVED_ADDR 0x08004000u #define PARAM_RESERVED_SIZE 0x00001000u #define APP_A_ADDR 0x08005000u #define APP_A_SIZE 0x00018000u #define APP_B_ADDR 0x0801D000u #define APP_B_SIZE 0x00018000u参数区规划了两页一页存主标志一页存备份标志。初始化时如果发现两页内容不一致以主标志为准并重新修复备份页。这套逻辑在工程启动时执行成本极低但安全性提升明显。4.2 全流程演示升级成功与异常回滚假设当前设备运行在 A 区版本 V1.0要升级到 V2.0。完整流程如下上位机通过串口向设备发送升级开始命令App 收到后进入升级状态解析固件头部。校验头部 Magic 为0xA5A5A5A5版本 V2.0 高于当前 V1.0目标分区为 B 区因为当前运行 A 区。App 向 B 区逐包写入固件数据每写完一包返回 ACK上位机收到 ACK 后发下一包。所有数据发送完成App 对 B 区固件做整包 CRC 校验与头部字段比较一致。App 写入启动标志BOOT_TO_B同时备份页写入同样的值。执行NVIC_SystemReset()设备复位。Bootloader 上电读取标志为BOOT_TO_B校验 B 区固件头部和 CRC全部通过。Bootloader 跳转到 B 区设备运行 V2.0。异常场景就更有意思了。假如刚才第 4 步 CRC 校验失败App 判为升级失败直接清除临时数据复位后 Bootloader 看到标志仍然是BOOT_TO_A校验 A 区完好继续跑 A 区。整个过程设备只是短暂重启了一下业务没有受到任何影响。另一种异常场景B 区固件 CRC 通过但运行后 V2.0 初始化失败触发 hardfault。为了避免反复死循环App 在进入 main 后第 100ms 内会累加一个 RAM 标志如果连续 3 次复位都走到这里就把启动标志改成回退 A 区并复位。这个回退动作要在业务初始化之前完成否则初始化失败本身会再次触发复位永远走不出来。4.3 关于加签验签与更高阶的安全机制热搜里反复出现“汽车嵌入式软件 OTA 加签验签”这一点在工业、车规场景确实是刚需。前文用的 CRC 只能检错不能防篡改一个懂行的人完全可以伪造一个通过 CRC 校验的固件包发给设备。所以在安全要求较高的场景建议至少做到以下两步固件加密对固件二进制做 AES-128-CBC 加密密钥烧录在 Bootloader 的指定区域运行时解密写入 App 区。这样即使有人抓到了串口数据也没法直接提取出可用固件。固件签名用哈希算法如 SHA-256计算固件摘要再用 RSA 或 ECDSA 私钥签名。Bootloader 内置公钥跳转前先验签再放行。签名和加密组合使用就是完整的安全启动方案。不过在 F103 上跑 RSA/SHA-256 一定要算好性能账。F103 内核没有硬件加密加速纯软件 RSA 验签可能要几百毫秒甚至一秒以上升级体验会打折。如果项目确实需要强安全我会建议上位机只做 HMAC-SHA256 的 MAC 校验或者把验签算法换成 ECC 曲线计算量比 RSA 小很多。另一个折中方案是“分段校验”Bootloader 启动时只验证固件头部很小一块的签名剩余部分边运行边按段校验这样既保证安全又不拖慢启动速度。这算是我在实际项目中摸索出来的兼顾安全与性能的做法供你参考。5. 常见问题排查与经验速查5.1 典型问题与解法速查表现象可能原因排查/解决思路跳转后 App 跑飞向量表偏移未设置确认 App 工程的 IROM1 起始地址和SCB-VTOR设置跳转后系统卡死残留中断进入 AppBootloader 跳转前完整清空 NVIC 和 SysTick串口升级到一半卡住缺 ACK/超时重传机制加帧序号、ACK/NAK、超时重发CRC 校验总是失败Flash 写入数据错位检查缓冲区是否按 4 字节对齐避免写半字升级成功后仍启动旧固件启动标志写入失败检查标志区是否被 Bootloader 区覆盖或参数区页大小不匹配软件复位后无法跳转复位前未清除挂起中断复位前__disable_irq()跳转前恢复 NVICApp 内无法擦写 Flash掉电保护未解锁写 Flash 前解锁写后上锁检查是否在中断上下文中执行新固件能启动但反复复位App 启动自检逻辑误判增加“启动次数计数”和回退阈值这张表是我在多个项目里积累下来的问题库覆盖了 90% 以上 AB OTA 初跑失败的情况。真正排查时如果现象诡异第一步永远是用调试器看 PC 指针停在哪里比盲改代码高效得多。5.2 实测中踩过的坑与排查思路第一个大坑是 Flash 擦写时间造成的串口丢包。F103 擦除一页的时间大概在 20ms 到 40ms如果期间 UART 接收不停止字节就丢了。早期的方案我用的是查询方式接收结果升级过程中每擦一页就丢一批数据。后来把串口改成 DMA 环形缓冲接收Flash 擦写期间不关串口DMA 先把数据收进缓冲区擦写完成后统一处理彻底解决了丢包问题。第二个坑是看门狗。App 在升级过程中往往还在跑业务如果系统开了 IWDG 看门狗且没在升级流程里喂狗下载一个大固件的时候足够触发看门狗复位导致升级中途设备重启。解决方式是在升级下载循环里同步喂狗但要注意不能在 Flash 写入函数内部喂狗因为写 Flash 时中断是关闭的喂狗代码挤不进去。合理做法是在接收每一帧数据的空闲缝隙里喂狗而不是在写 Flash 的临界区内。第三个坑和“启动标志位置”有关。我最初把启动标志放在 App A 区的末尾理由是省一块独立分区。结果有一次升级 A 区时把整个 A 区擦除重写标志位被顺带清掉了Bootloader 启动后找不到有效标志只好停在 boot 模式。后来改成独立参数区才彻底解决。这里也想提醒你任何和启动相关的重要数据都不要放在会被升级操作擦除的区域内否则迟早会被自己的升级逻辑坑一次。第三个坑和 Fl 第四个坑是版本号的比较逻辑。版本号如果用字符串比较比如V1.9和V1.10字典序会认为 1.9 大于 1.10导致升级被拒绝。后来我统一用十六位无符号整数表示版本号主版本高字节、次版本低字节比较逻辑变成简单的new_ver old_ver不仅代码简单逻辑也清晰。5.3 个人经验与后续扩展建议在做完这套 F103 AB OTA 之后我最大的体会是OTA 方案的复杂度不在代码而在异常处理的设计。正常路径大家都写得出来真正拉开差距的是“升级写到一半断电能怎么样”“新固件一启动就死机能怎么样”“网络抖动能怎么样”。所以我在写 Bootloader 之前会先把所有异常场景列成表格每个场景想清楚应对动作再开始写代码。后续想扩展的话有这几个方向可以继续深入从串口 OTA 扩展到网络 OTA把传输层从 UART 换成 TCP/MQTT/HTTPBootloader 和 App 之间的协议可以完全不变只需要在上位机侧部署升级服务器。F103 跑 HTTP 客户端稍微吃紧但配合 AT 指令的 WiFi/4G 模组很成熟我这套代码已经在多个物联网产品上验证过。引入外部 Flash 做大固件暂存如果固件太大AB 双区方案也放不下可以在方案中加入外部 SPI Flash 作为下载暂存区App 下载到外部 Flash校验通过后由 Bootloader 一次性搬运到内部 App 区。这样既能保持 AB 回滚能力又能支持更大固件。多区 OTAAB 是两个槽位进阶玩法是做成三区轮换一个当前区、两个备用区升级失败后仍有两个可用版本适合对可用性要求极高的设备。就我个人经验而言这套 F103 AB OTA 方案自从上线后现场因为升级导致的返修率从之前单区方案的千分之几直接降到几乎为零。OTA 这事看起来是个“锦上添花”的功能但真正到了远程运维阶段你就会发现它是产品生命周期里最值得投入的底层能力之一。希望这篇从零复现的记录能帮你少走我当年踩过的那一堆弯路。