STM32F103 AB OTA升级实战:双区备份与回滚机制详解
发布时间:2026/9/6 8:41:24 锦皓数字建站

1. 为什么要在STM32F103上做AB OTA1.1 单分区升级的痛点聊STM32F103的OTA升级得先从一个我亲身经历的“事故”说起。早几年给一个设备做远程升级用的是传统方案Bootloader 单App分区。Bootloader只负责接收固件然后把数据直接写到App所在的Flash区域写完校验通过就跳转。这套方案看着没问题但实际跑起来有个致命弱点——如果升级过程中掉电或者固件传输中途CRC出错Bootloader可能会把一份残缺的代码写进App区设备直接变砖。而且最尴尬的是这种变砖还不是“彻底没救”需要人跑到现场用烧录器重新烧远程升级的优势全没了。后来接触了AB分区也叫双区备份方案之后我才彻底理解了为什么汽车电子、手机系统、路由器这些对可靠性要求高的设备都在用这套思路。AB OTA的核心思想很简单Flash里放两份独立的App镜像A区和B区。当前运行的App在A区升级时就把新固件写到B区写完校验通过后置一个升级标志位重启后Bootloader根据标志位跳到B区运行。如果B区运行失败比如看门狗没喂、启动标志没清除Bootloader还能自动回滚到A区。整个过程不会覆盖正在运行的程序所以“升级断电变砖”这个老问题从根本上被避免掉了。1.2 AB方案的核心理念与F103的资源现状AB方案在逻辑上确实是个“无脑但有效”的思路可它落到STM32F103上就不是那么轻松了。F103系列芯片最常用的C8T6只有128KB Flash、20KB RAM即使是高配的ZET6也就512KB Flash、64KB RAM。要在这点空间里塞下Bootloader、A区、B区、标志位区域还要预留固件升级时的接收缓冲这对分区规划的要求相当高。我见过不少人一上来就按“Bootloader 32KB App A区 48KB App B区 48KB”这种方式一刀切结果App稍微加了点功能就超出Flash容量。这里有一个基本公式可以帮大家做预判总Flash容量 Bootloader区 标志位区 A区 B区 必要的预留空间。A区和B区的大小必须按“App将来最大体积 × 1.3”来规划而不是看当前编译出来的bin有多少。因为固件功能只会越加越多一旦A/B区都写不下这个升级方案就等于废了。以F103C8T6的128KB为例比较合理的划分是Bootloader 16KB 标志位区 4KB A区 48KB B区 48KB 预留 12KB。如果芯片是ZET6这种512KB Flash分区就宽松很多甚至可以给A/B区各留到192KB以上。这里还要提醒一下STM32F103的Flash是每页1KB小容量或2KB大容量分区地址一定要按页对齐否则擦除操作会把你不想动的地方也抹掉。1.3 分区规划与关键决策分区规划这件事听着是个“画个地址就完事”的活儿实际操作中要考虑的决策点很多。我建议按F103全系列通用的一种布局来做假设Flash从0x08000000开始Bootloader区0x08000000 - 0x08003FFF占16KB存放Bootloader固件包含向量表和启动代码。这个区域不需要升级所以放在最前面最安全。标志位区0x08004000 - 0x08004FFF占4KB存放当前应启动哪个分区、升级状态、App版本号、升级计数等关键元数据。这个区要频繁擦写建议用两个扇区交替写防止写一半掉电把标志位弄坏。App A区从0x08005000开始这是第一个可运行的App。App B区紧接着A区和A区一样大。还有一个很多人会忽略的决策点Bootloader要不要也支持升级从可靠性角度讲Bootloader一旦支持升级理论上就有了把自己写坏的风险所以除非你的Bootloader有严重bug必须修复否则不建议让Bootloader进入升级链路。AB OTA保护的是App不是BootloaderBootloader请保持“只读”心态。我自己在实际项目里的做法是先编译一版什么都不干的空App测出它的Flash占用然后加上预计功能膨胀空间再反推Flash分区。不要拍脑袋定大小否则后面改分区会牵动Bootloader里的跳转地址、链接脚本、Flash擦写范围一改就全改相当痛苦。2. 升级包制作与传输链路设计2.1 固件包结构与校验策略不管你是用串口、WiFi模块还是CAN总线来传升级包固件的打包格式都得提前设计好。很多初学者直接把编译出来的.bin文件一股脑发给设备收完就写Flash也不管包里面是什么内容——这是非常危险的。固件升级包至少要包含一个包头用来描述这次升级的元数据。我在项目里常用的包头结构是这样的C语言结构体typedef struct { uint32_t magic; // 固定魔数比如0xA5A5A5A5用来快速判断包头是否有效 uint32_t version; // 固件版本号单调递增防止重复升级或回退 uint32_t bin_length; // 固件数据的有效长度单位字节 uint32_t bin_crc32; // 固件数据的CRC32校验值 uint32_t target_slot; // 目标分区0代表A区1代表B区 uint32_t timestamp; // 编译时间戳辅助信息 uint32_t reserved[2]; // 保留字段用于后续扩展比如固件签名等 } firmware_header_t;这个包头固定32字节放在固件数据前面。Bootloader或App收到升级包后先用magic判断是不是合法的OTA包再检查target_slot是不是指向“当前没有运行的那个分区”然后等全部数据收完后计算整个bin的CRC32和包头里的bin_crc32比对一致才允许写入Flash。这套流程基本能挡住传输错误、发错包、版本倒退这几类最常见的问题。提示CRC32的计算一定要覆盖整个固件数据区不含包头。包头的字段本身可以用一个简单的校验比如对包头前28字节再算一次CRC或累加和来保护避免因为包头被篡改或者传错导致后面全部白干。2.2 传输协议选择Y-Modem还是自定义协议传输层协议F103上最常用的就是串口Y-Modem或者自己定义一个带ACK的超时重传协议。Y-Modem的优点是现成PC端工具多比如SecureCRT、Xshell都支持缺点是嵌入式端代码量相对大而且Y-Modem本身是面向文件传输设计的对“断点续传”“重传机制”的支持并不灵活。我建议自己实现一个简单的分包ACK协议原理很简单上位机把固件包按512字节或1KB切成一帧一帧每帧包含帧序号、数据、帧CRC。下位机每收到一帧校验通过回ACK校验失败不回或回NAK。上位机超时未收到ACK就重发当前帧连续重发超过N次则终止升级。全部帧发完后上位机发送结束帧下位机做整体CRC校验上报结果。这种方式的好处是逻辑完全透明出问题好排查而且可以很方便地加入“中断续传”功能记录当前已收到哪个帧号重新握手后从断点继续发。如果你用的是ESP8266、4G模块做OTA本质上也是这套逻辑只是把物理链路从串口换成了TCP socket或HTTP下载协议栈换成自己的“包头分块CRCACK”核心思路完全一样。传输速率上F103用串口和上位机通信时115200bps算是一个比较稳妥的速率既不会太慢也不会因为中断频繁导致接收丢帧。如果用的是DMA接收可以尝试更高波特率但要注意F103的USART在高速率下如果处理不及时会触发溢出错误ORE这个在全速传输大固件时尤其容易出现。3. Bootloader与App双区升级的核心实操配置3.1 Bootloader要做的事跳转前必须处理的五件事Bootloader是整个AB OTA机制的“裁判”它的职责不只是跳转还包括校验标志位、双区App校验和升级回滚策略。我建议把Bootloader的启动流程固定成下面这个顺序初始化时钟和最基本的UART仅用于调试日志不是必须。读取标志位区解析当前启动状态。根据标志位决定启动A区还是B区并对目标区的App做完整性校验至少校验App头部的magic和CRC。如果目标区App有效清除升级标志位如果App是第一次启动然后跳转。如果目标区App无效尝试启动另一个区两个区都无效进入下载模式等待升级。跳转动作本身很简单麻烦的是跳转前的“现场清理”。F103的跳转代码网上一搜一大把但很多都是能跑不靠谱缺了关键的清理步骤。我整理了一个比较完整的跳转函数typedef void (*pFunction)(void); void jump_to_app(uint32_t app_addr) { uint32_t app_stack_addr *(volatile uint32_t *)app_addr; pFunction app_entry (pFunction)(*(volatile uint32_t *)(app_addr 4)); // 1. 关闭全局中断避免跳转过程中有中断进来 __disable_irq(); // 2. 关闭SysTick并清零计数器防止SysTick异常触发 SysTick-CTRL 0; SysTick-LOAD 0; SysTick-VAL 0; // 3. 关闭所有外设时钟把外设状态复位干净 RCC-AHBENR 0; RCC-APB1ENR 0; RCC-APB2ENR 0; // 4. 可选复位所有用到的外设 // 比如 RCC-APB2RSTR 0xFFFFFFFF; 等 // 5. 设置主栈指针跳转 __set_MSP(app_stack_addr); app_entry(); }这个函数有几点要特别说明关闭全局中断这一步很多人会漏掉但如果你在Bootloader里用了定时器、串口中断跳转时中断正好触发CPU就会在旧的向量表里执行中断处理函数而这时Flash里App的向量表可能还没生效轻则跑飞重则HardFault。SysTick也要关因为App通常会在启动早期重新配置SysTick如果Bootloader的SysTick还在跑两边冲突会产生不可预知的行为。跳转前还有一个容易踩的坑不需要对App区做“完整擦除”。AB OTA的核心优势就是不覆盖正在运行的区所以Bootloader跳转时不要动任何App区Flash只做校验和跳转。3.2 App侧的中断向量表重映射App侧最核心的配置就是中断向量表偏移。STM32F103上中断向量表默认在0x08000000如果你的App放在0x08005000那么CPU触发任何中断时仍然会去0x08000000找向量表而那里是Bootloader的代码不是App的中断处理会乱套。解决办法有两种。一种是通过SCB-VTOR寄存器设置向量表偏移这是Cortex-M3内核提供的标准功能。在App的main函数最前面加SCB-VTOR APP_BASE_ADDR;这里APP_BASE_ADDR就是你这个App所在Flash的起始地址比如0x08005000。但要注意F103早期的一些芯片特别是某些小容量版本虽然检测时显示有VTOR寄存器但实际硬件上不支持设置了不生效。这时候必须用第二种办法启动文件里复制向量表到RAM然后把VTOR指向RAM。// 在App的启动文件main函数之前或SystemInit里做 #define VECTOR_TABLE_SIZE (48 * 4) // 按需调整包含全部中断向量大小 void vector_table_relocate(void) { memcpy((void *)SRAM_BASE, (void *)APP_BASE_ADDR, VECTOR_TABLE_SIZE); SCB-VTOR SRAM_BASE; }这个方案是把Flash里的向量表拷贝到SRAM起始地址当然要在链接脚本里把RAM起始段空出来再把VTOR指向SRAM。SRAM的访问速度比Flash快中断响应甚至还会更快一点代价是占用了一小段RAM。我个人的建议是只要芯片版本支持VTOR指向Flash优先用第一种简单方案如果出现中断不进App的情况再检查是不是芯片VTOR支持有问题改用RAM方案。注意App在链接脚本.icf或.sct里的FLASH起始地址必须和Bootloader跳转的地址一致。如果Bootloader里写的是0x08005000但Keil或者IAR工程里链接脚本还是默认0x08000000那就跳了个寂寞——App认为自己就在地址0向量表设置也对不上号。3.3 Flash写入驱动与掉电安全Flash写入这件事在AB OTA里属于“看着简单做起来揪心”的部分。STM32F103的Flash操作有几个铁律需要牢记第一写Flash前必须先擦除。F103不能只覆盖写某个字节必须按页page整体擦除。擦除是耗时操作期间CPU会被暂停任何中断都不会被响应。所以如果你用串口接收数据是中断方式在擦除Flash期间串口来的数据就会丢。解决办法是在擦除Flash前关闭接收中断用DMA把串口数据缓存在RAM里等Flash写完再处理或者干脆用“边收边写”的策略收满一页就暂停接收、写Flash、写完再恢复接收但这要求上位机和下位机有很好的流控配合不然容易丢包。第二Flash编程的最小单位是半字16位不能按字节写。很多从8位单片机转过来的朋友习惯一个字节一个字节往Flash里写在F103上直接不工作。正确的姿势是把数据整理成16位格式用FLASH_ProgramHalfWord函数逐个写或者按32位用FLASH_ProgramWord。第三也是最重要的一点写Flash期间掉电可能导致整个扇区数据损坏而损坏的可能是正在运行的那个区吗不至于因为AB方案里你写入的永远是“非运行区”但标志位区如果正在写的时候掉电就麻烦了。所以我强烈建议标志位区用“双缓冲魔数”策略先写备份标志扇区确认无误后再写主标志扇区并且每个扇区开头加魔数比如0xAA55读取时优先用魔数有效的那个扇区。这样即使写一半掉电下次启动也能从备份扇区恢复出合理状态。4. 从编译到烧录AB OTA完整复现流程4.1 准备工程与工具链在这个部分我以最常见的标准外设库StdPeriph v3.5为例带你从零把一个AB OTA工程配出来。你需要准备一个STM32F103开发板C8T6或ZET6均可但Flash小了分区要小心。Keil MDK 5或IAR编译器版本随意。ST-Link或J-Link用于首次烧录Bootloader和初始App。一个USB转串口模块用于升级链路。上位机升级工具自己写个简单Python脚本或者用现成的串口工具配合手动发送。工程上建议建三个独立工程Bootloader工程、AppA工程、AppB工程。虽然AB方案里A和B是同一个App代码只是链接脚本和宏定义里的分区基址不同但分开建工程能避免混淆。App代码里可以用一个宏来控制当前编译的是A版还是B版#define APP_SLOT_A 0 #define APP_SLOT_B 1 #define APP_CURRENT_SLOT APP_SLOT_A // 编译时切换这个宏 #if (APP_CURRENT_SLOT APP_SLOT_A) #define APP_BASE_ADDR 0x08005000 #define APP_VERSION 0x00000001 #else #define APP_BASE_ADDR 0x0800B000 // 假设B区地址 #define APP_VERSION 0x00000002 #endif链接脚本里Flash起始地址要写成APP_BASE_ADDRROM大小改成你给App区划的大小。RAM不用改因为App运行时用的还是同一块SRAM。Bootloader工程相对独立不需要依赖App代码。它的Flash起始地址固定是0x08000000大小16KB。4.2 一次完整的A到B升级演练假设当前设备跑在A区版本0x00000001你想升级到B区版本0x00000002。完整流程如下**第1步编译App的B版本。**把宏APP_CURRENT_SLOT改成APP_SLOT_B编译出B区固件B.bin。同时准备一份A版固件A.bin备用用于回滚测试。**第2步制作升级包。**用Python脚本读取B.bin加上firmware_header_t包头算好CRC32输出成B_ota.bin。这个bin不是直接烧录用的而是通过串口上位机发送的数据。**第3步设备进入升级模式。**我常用的做法是App运行时收到一个串口命令比如0xAA55就停止业务逻辑进入“升级等待模式”上报自己当前版本号和所在分区然后开始等待升级包。更稳妥的姿势是配合按键设备断电按住升级按键上电Bootloader检测到按键后不跳转App直接停在下载模式。**第4步传输固件包。**上位机按自定义协议把B_ota.bin切帧发下去。App此刻还是老版本A在运行每收一帧校验一次收满一帧就写入B区Flash。注意写Flash的时候不能用B区里已有的数据因为B区可能存的是上一次升级的旧固件必须先把对应扇区擦掉再写。**第5步全部数据收完并校验通过。**App把整个B区的CRC和包头里的CRC做比对。如果通过就在标志位区写入“下次启动B区”的指令然后软复位。**第6步Bootloader接管。**Bootloader启动后读取标志位发现目标分区是B区就去校验B区头部的magic和CRC。校验通过清除“首次启动”标志然后跳转到B区。B区App启动后主动上报自己的版本号和分区上位机看到版本号是0x00000002且分区是B就说明升级成功了。**第7步反向验证回滚。**不经过正常升级流程直接手动在Bootloader标志位区写入“启动A区”复位后设备应该回到A区运行且不能出现A区CRC校验失败的假象。这一步能验证回滚逻辑是否真正可工作。4.3 回滚机制的正确触发方式回滚机制的触发条件建议设计成三种升级包校验失败传输过程中CRC对不上或者写完B区后整体校验失败Bootloader直接回退到A区。App启动后看门狗超时App在启动后规定时间内没有喂狗看门狗复位系统。复位后Bootloader检查到目标App没有清除“启动失败”标志就把启动目标改成另一个区。上位机主动要求回滚设备运行中收到了回滚指令可以是一条串口命令在下次复位时强制启动另一个分区。这个功能在远程运维时特别好用。实现上我在App的main函数开头会清一个“正在启动”标志位正常启动完成后置为“运行正常”。Bootloader在跳转前看一眼这个标志如果是“正在启动”说明上次启动并没有正常跑起来那就不跳转了直接切换分区。这里有个经验之谈标志位判断不要搞得太复杂用0xAA55和0x55AA这种“非对称魔数”比“0表示A、1表示B”更可靠因为Flash擦除后全是0xFF如果是全0xFF时被误判成“A区启动”万一A区刚好坏了就翻车了。5. 常见问题排查与避坑实录5.1 问题速查表F103 AB OTA典型故障定位清单现象可能原因排查与解决办法跳转到App后程序跑飞或卡死向量表没有正确重映射或跳转前没关中断/SysTick检查SCB-VTOR是否设置检查跳转函数是否关了SysTick和全局中断第一次调试时在App的main第一行加个LED翻转确认有没有进入mainApp能启动但串口中断不进VTOR在芯片上不生效改用向量表复制到RAM的方案确认RAM起始地址段没有存放其他全局变量升级传输到一半接收端开始丢数据擦除Flash期间CPU被暂停串口数据溢出改用DMA接收或调整协议加大帧间隔或采用“收满一块RAM缓存再暂停接收”的方式升级完成后Bootloader校验老失败包头CRC算法和发送端不一致或者接收时字节序不对统一CRC算法常见的是CRC32/MPEG-2或CRC-32/ISO-HDLC在PC端和MCU端打印CRC值比对B区能正常启动但升级到A区时失败A区Flash空间太小新固件超出了A区容量用.map文件查App实际Flash占用重新规划分区大小升级过程中掉电之后设备永远停在下载模式标志位区数据损坏或者写入标志位时断电检查标志位区是否用了双扇区备份加魔数判断无效数据让Bootloader能自动选择有效的标志扇区上位机显示固件发完了但设备没有任何反应可能是App没有进入升级等待模式或上位机发送的波特率/数据格式不匹配在App侧加上升级模式超时退出机制串口参数统一为8-N-1先跑通“一帧一应答”的最小链路表格里提到的每一项都是我实际踩过坑或帮别人排查过的问题。其中“跳转后卡死”是出现频率最高的十个AB OTA项目里有八个第一次调试都挂在它上面。我给的建议是第一次做跳转不要在App里放太多初始化逻辑先让App的main函数只做一件事——翻转一个LED。跳转成功了再逐步加外设初始化这样能快速定位问题到底出在跳转还是出在App初始化。另一个很隐蔽的坑是编译优化等级。默认情况下Keil或IAR在Debug版用-O0发布版用-O2/-O3。有些跳转代码在-O0下跑得好好的一开优化就出问题尤其是函数指针强制类型转换、MSP设置这类“底层操作”高优化等级可能把代码重排导致行为异常。遇到跳转诡异失败的先把优化等级改成-O0试试跑通了再分析有没有必要优化。5.2 从“能跑”到“稳定”再补几个冷门细节除了表格里的问题AB OTA要做得真正可靠还需要关注几个不太被新手注意的细节。第一个是看门狗策略。AB OTA方案里如果App里开了独立看门狗IWDG在升级过程中要格外小心。App在接收固件并写Flash期间如果是阻塞式写Flash一段时间内无法喂狗看门狗可能复位系统导致升级中断。我建议进入升级模式后直接关闭看门狗或者把升级过程做成状态机每写一页Flash后喂一次狗。千万不要依赖“升级期间暂停看门狗太久”这种侥幸心态Flash擦写时间在F103上是毫秒级别但大固件要擦的页多累积起来足够触发看门狗。第二个是App启动后的“首次运行自检”。这在回滚机制里非常关键。我的做法是App启动后不要急着清“正在启动”标志先延时几秒钟确认业务逻辑跑起来了比如串口上报一次心跳、ADC采样正常再清标志。如果App一启动就崩溃标志位还是“正在启动”Bootloader下次就会立刻回滚。延时几秒的成本完全可以接受但能大大降低“App能跳转但运行一秒就挂Bootloader却没发现”的尴尬局面。第三个是升级包的版本号校验。前面包头里有一个version字段但我还建议在Bootloader里维护一个“最低可启动版本号”的概念。原因很实际如果新固件有严重的逻辑bug用户升级之后发现不行手动用工具回滚到旧版——但旧版可能比Bootloader里的最低版本还老Bootloader要是不做版本限制旧版跑起来又会触发新的问题。加一个最低版本检查可以从源头挡住“版本跨度过大回退”这类隐性风险。第四个是量产时的初始烧录顺序。很多产品在工厂阶段只烧录一次BootloaderApp区全空。第一次启动时Bootloader发现A区和B区都没有合法固件就要进入下载模式等待升级。但工厂产线不一定有完整的OTA上位机环境所以我建议量产时直接把A区固件通过烧录器一起烧进去烧录完成后复位Bootloader检测到A区合法直接跳转A区设备就能正常工作。这样做的好处是产线简单可靠不需要额外跑OTA流程。5.3 我最终推荐的一个起步路径如果你跟我一样是第一次在STM32F103上做AB OTA我强烈建议不要一上来就追求“远程全自动升级”而是分三步走第一步先做Bootloader 单AppA区把跳转、向量表、串口打印这些基础链路跑通第二步增加B区把“App能把自己写到B区、Bootloader能选择B区启动”这部分打通第三步再上回滚、看门狗、断点续传这些进阶功能。这个三步走的过程我实测下来调试时间可以从两周压缩到一周左右。直接把AB全部一次做完一旦出问题你根本分不清是链路传输问题、Flash写入问题、跳转问题还是回滚逻辑问题排错成本反而更高。AB OTA在F103上并不是高不可攀的技术它最核心的资产其实是“清晰的流程规划”和“对Flash/MCU异常行为的敬畏”。把上面这些该注意的点都处理到位了这套方案在F103上跑个三五年都不会出什么幺蛾子。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。