STM32F103 AB OTA实战:双分区固件升级与自动回滚机制

发布时间:2026/9/11 1:18:20
STM32F103 AB OTA实战:双分区固件升级与自动回滚机制
前段时间有个设备要支持现场固件升级客户还专门强调过一句“升级不能把设备搞成砖。”这种需求在存量设备上其实挺常见的尤其是还在大量服役的STM32F103。那时候我脑子里冒出来的方案就是AB分区OTAA区跑正式固件B区收新固件下载校验完成之后再切过去新固件起不来还能自动回滚到旧区。网上搜STM32F103 AB OTA大多讲的还是传统IAP真正把双分区、固件头、回滚计数、编译地址偏移一次讲完整的很少所以我把自己从零复现的整个过程整理成这篇教程。这篇教程默认你已经会用标准外设库和Keil会建基本工程但不要求有很深的IAP基础。我会从为什么在F103上做AB分区、Flash资源怎么算到Bootloader分区表设计、App地址偏移、上位机下载脚本、启动回滚机制再到实测中踩过的坑一起过一遍。适合产品固件工程师、嵌入式爱好者也适合那些想把老设备升级方案重做一遍的团队。1. AB OTA在STM32F103上的真实价值与资源账本1.1 先搞清楚AB OTA到底解决什么问题AB OTA的思想是从安卓系统那边借来的也就是双系统分区。Flash里放两个可以独立运行的固件区升级时往当前不运行的那一个分区里写新固件校验完成后把启动标记切到新分区下次上电进新版。万一新固件启动失败或运行异常Bootloader还能自动切回旧分区设备不至于彻底变砖。对比传统单分区IAP单分区升级时如果新固件只有一半或者擦写过程中突然断电App区就是一堆残缺数据Bootloader跳过去只能跑到HardFault里。这个时候往往需要人跑到现场重新烧录非常被动。AB分区等于把一个固件升级动作拆成了“下载到备用区”和“切换启动区”两步下载失败不影响当前系统切换之后失败了也能再回到老版本。对远程升级设备来说这几乎是最稳妥的保底设计。很多朋友觉得STM32F103太老、Flash太小做AB OTA有点勉为其难。但事实上F103存量设备非常多而且很多设备并不需要跑多么复杂的GUIBootloader加业务代码控制在几十KB以内完全可行。关键是你得选对Flash容量型号并按分区需求把资源算清楚。1.2 资源账本C8T6、RCT6、ZET6到底怎么选STM32F103系列里最常见的三个型号C8T6是64KB FlashRCT6是256KB FlashZET6是512KB Flash。做AB分区不是简单在Flash里画两个区域Bootloader本身还要占一块区域另外还要留一块专门存放启动状态、版本信息、回滚计数的Flag区。经验上Bootloader至少留24KB如果串口协议和Flash驱动都写完整32KB更富余App分区按业务需求每个分区至少30KB到50KB才不算太憋屈。我整理了一张分区估算表方便你对照选型型号Flash总容量BootloaderApp AApp BFlag/配置结论STM32F103C8T664KB16KB20KB20KB4KB极限仅适合非常小的裸机程序STM32F103RCT6256KB32KB96KB96KB4KB合适大多数场景可使用STM32F103ZET6512KB32KB192KB192KB4KB富余推荐做完整AB OTA验证热词里有人搜“stm32f103最小系统”这里顺便说一下如果你是用ZET6最小系统板复现板载Flash就是512KB晶振用8MHz串口引出至少一个USART再加一个ST-Link或J-Link数据手册都不用翻太深。AB OTA本身和最小系统外围关系不大核心是把Flash分区和启动流程跑对。1.3 什么情况下其实不该硬上ABAB OTA虽然好但不是所有项目都适合。如果设备Flash只有64KB固件本来就超过40KB强行做AB会把App压得喘不过气又或者产品是几块钱的消费玩具升级失败大不了返厂这时单分区IAP加一个Bootloader入口烧录模式反而更简单。AB方案的主要成本不在代码量而在状态机、回滚策略、升级流程的调试上开发周期要比普通IAP多出不少。所以我一般这样判断固件能控制在80KB以内、又确实存在远程无人值守升级需求的上AB如果只是本地在线升级升级失败还能连上串口重烧的用传统IAP就好如果换新项目选型上直接避开C8T6这种64KB的从RCT6起步。STM32F103做AB不是不能但一定要留够资源余量。2. Bootloader骨架分区表、固件头与启动决策流程2.1 先定分区表代码才有依据这一步是整个AB OTA的地基。以STM32F103ZET6为例我最终用的是这套分区#define BOOT_ADDR 0x08000000 #define BOOT_SIZE (32 * 1024) #define APP_A_ADDR 0x08008000 #define APP_A_SIZE (192 * 1024) #define APP_B_ADDR 0x08038000 #define APP_B_SIZE (192 * 1024) #define FLAG_ADDR 0x08068000 #define FLAG_SIZE (4 * 1024)注意ZET6的Flash末地址是0x0807FFFFBootloader 32KB两个App各192KBFlag区4KB加起来420KB还剩接近90KB的空白足够以后扩展。如果你用RCT6Flash只有256KB那分区就要压缩Bootloader 24KB、App A/B各96KB、Flag区4KB地址可以改成0x08000000、0x08006000、0x0801E000、0x08036000FLAG区放到0x0803F000附近。分区设计有几个原则Bootloader必须位于0x08000000因为芯片复位后从那里执行App A/B之间最好留一点点隔离空间避免误擦写越界Flag区不要夹在两个App中间尽量放在所有分区之后这样升级时即使App下载写入出错也不会破坏Flag区里的状态信息。2.2 固件头版本、长度、CRC32不能省固件头是Bootloader判断“这个分区能不能跳转”的依据。有些方案把固件头放在App分区起始地址但那样会挤掉中断向量表。我更推荐把固件元数据存放在Flag区或者App分区末尾的保留区域App分区的0地址保留原生ARM向量表Bootloader跳转时直接读Address 0和Address 4就能拿SP和PC简单又不会干扰中断向量。不过在下载阶段还是要有一个固件头结构用来传递版本、长度、CRC32typedef struct { uint32_t magic; // 固定为 0x4F544142 OTAB uint32_t version; // 固件版本号 uint32_t length; // 有效固件载荷长度 uint32_t crc32; // 对有效载荷计算的CRC32 uint8_t status; // 0xA5表示可启动其他表示无效 uint8_t reserved[11]; } fw_header_t;上位机发送固件前先对原始bin文件计算CRC32和长度再把这个头部结构作为第一包数据发给Bootloader。Bootloader把它解析后存到Flag区的临时元数据里等整个固件下载完毕再对目标分区做一次全量CRC校验通过了才真正切换启动分区。2.3 Bootloader启动决策流程不是简单蹦过去就完了Bootloader的核心不是跳转本身而是“根据状态选择跳哪个区”。这里给出一个我在工程里验证过的启动流程读取Flag区状态包括当前使用的活动分区、待验证分区、启动尝试计数。如果Flag区CRC校验失败说明状态信息坏了直接回退到A区并恢复默认状态。检查A区与B区头部Magic和CRC如果某个分区不可启动强制选另一个。如果当前分区是刚切换过来的“待验证”分区那么Bootloader在跳转前先执行trial_count写回Flag区。如果trial_count超过3次说明这个新固件每次都起不来Bootloader将状态回滚到另一个旧判区并清零计数。满足条件后关闭全局中断读取目标分区的栈指针和应用入口跳转执行。这一步看起来简单但很考验细节。比如在跳转前写Flag区会增加启动时间而且每次启动都擦写Flash会影响寿命。所以我在设计时做了个小优化只有“待验证”状态下才递增计数正常情况下直接在Flag区读状态、不写Flash。3. Flash驱动与双分区互切跳转、擦写、回滚计数3.1 用标准库实现Flash擦写注意扇区大小STM32F103大容量型号Flash页大小是2KB标准外设库提供了FLASH_ErasePage、FLASH_ProgramWord这些接口。我的Bootloader里专门封装了三个函数flash_erase_region、flash_write_buf、flash_read_check。擦除函数的处理逻辑int flash_erase_region(uint32_t start, uint32_t len) { uint32_t addr; FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_BSY | FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); for (addr start; addr start len; addr FLASH_PAGE_SIZE) { if (FLASH_ErasePage(addr) ! FLASH_COMPLETE) { FLASH_Lock(); return -1; } } FLASH_Lock(); return 0; }写入时要注意地址对齐。FLASH_ProgramWord一次写入32位所以上位机发来的数据包要按4字节对齐长度不足的用0xFF填充。实测下来115200波特率传输100KB固件大约要10秒左右如果换成460800或者1M波特率能压到3秒以内F103的USART误差在接收端完全能容忍。3.2 App跳转和中断向量表重定位跳转代码只有几行但这几行是很多初学者翻车的地方typedef void (*app_entry_t)(void); void boot_jump_to_app(uint32_t app_base) { uint32_t app_stack *(volatile uint32_t *)app_base; app_entry_t app_pc (app_entry_t)(*(volatile uint32_t *)(app_base 4)); __disable_irq(); SysTick-CTRL 0; SCB-VTOR app_base; // 中断向量表偏移到App区 __set_MSP(app_stack); app_pc(); }这里的SCB-VTOR对于F103来说必须设置否则App里所有中断都会跑到Bootloader的向量表里串口中断、定时器中断一触发就进HardFault。还有一个细节跳转前要把用到的外设时钟和中断关掉尤其SysTick不然App初始化时系统时基可能错乱。我的做法是启动代码里关掉所有中断等App的main函数里自己重新初始化外设。App工程里也要设置向量表偏移。标准外设库的system_stm32f10x.c中支持VECT_TAB_OFFSET宏比如App A的偏移是0x08008000 - 0x08000000 0x8000App B的偏移是0x08038000 - 0x08000000 0x38000。注意F103的VTOR要求对齐实际工程里按0x100对齐基本不会踩坑分区起始地址也满足这个条件。3.3 回滚计数与Flag区磨损问题回滚机制是否可靠决定了AB OTA是不是真的能在现场救你一命。我在Flag区维护这样一个状态结构typedef struct { uint32_t magic; uint8_t active_slot; // 0App A, 1App B uint8_t trial_counter; // 当前分区启动尝试次数 uint8_t state; // 0idle, 1pending verify, 2ok uint8_t reserved; uint32_t crc32; } flag_area_t;每次Bootloader跳到一个“待验证”分区时trial_counter加1并写回。App启动后运行到稳定状态比如主循环跑几圈、关键外设自检通过再调用一个写Flag区的接口把trial_counter清零。如果新固件有问题App根本跑不到清零函数Bootloader在下一次启动时发现计数超过3次就自动回滚到另一个分区。这里有个磨损问题F103的标准库擦写Flash是按页擦除的Flag区如果只有一页反复写状态可能把Flash磨坏。我的做法是给Flag区留两个页交替写入每次写完后记录当前使用的页号启动时先读两页的版本号取较新的那个。对于4KB的Flag区分成两个2KB页后每页可以擦写一万次左右足够产品生命周期使用。4. 传输协议与上位机配合从串口到WiFi/nginx的扩展4.1 自定义下载协议帧格式、ACK和超时AB OTA的传输层并不需要多复杂关键是帧格式清晰、超时重传可靠。我设计了一套最简单的串口协议帧头0xAA 0x55命令0x01(INIT)、0x02(DATA)、0x03(END)、0x04(ABORT)长度数据域长度数据域按命令不同包含目标分区、序号、固件内容等CRC16对从帧头到长度字节之后所有数据进行校验上位机发送INIT帧时数据域放fw_header_t结构和目标分区号。Bootloader收到INIT帧后返回ACK并开始擦除目标分区如果Flash擦除失败返回NACK。接着上位机按256字节一块发送DATA帧每帧带一个序号。Bootloader每收到一帧就写入Flash写成功回ACK。最后发END帧Bootloader对目标分区做CRC32全量校验校验通过写入状态并软复位。4.2 上位机Python脚本多省事上位机我写了不到120行Python核心思路是这样import serial import struct import zlib import time def send_firmware(port, bin_path, target_slot1): fw open(bin_path, rb).read() crc zlib.crc32(fw) 0xFFFFFFFF header struct.pack(III, 0x4F544142, 0x00010001, len(fw)) init_data struct.pack(BIII, target_slot, len(fw), 0x00010001, crc) send_frame(port, 0x01, init_data) for offset in range(0, len(fw), 256): chunk fw[offset:offset256] chunk b\xff * (256 - len(chunk)) data struct.pack(H, offset // 256) chunk send_frame(port, 0x02, data) wait_ack(port) send_frame(port, 0x03, b)这里面有两个容易忽略的点一是CRC32要在上位机算好Bootloader最后做的CRC校验必须和它一致二是串口接收端要做一个固定超时比如50ms没收到下一帧就主动回NACK上位机收到NACK重发当前帧。实测下来115200波特率下100KB固件中间有几次校验失败重传整体也就十几秒。4.3 从串口扩展到WiFi模块和nginx静态文件服务很多产品升级不可能插着一根串口线所以实际项目里会把传输层换成ESP8266或4G模块。我复现时用过ESP8266透传到STM32串口服务器端用nginx挂一个静态固件目录由模块主动拉取固件字节流再通过串口喂给STM32的Bootloader。这样对F103来说根本不用自己解析HTTP协议Bootloader依然只需要处理字节流和自定义帧改动非常小。当然这里有个前提使用ESP8266透传时要处理TCP/UDP流的粘包和分包问题。我的做法是在Bootloader端维护一个简单的状态缓冲按帧头0xAA 0x55对齐不完整的数据先存着等下一个字节进来再继续解析。这个缓冲在F103的RAM里开64字节就够。nginx那边只需要把bin文件放到一个静态目录配置一下autoindex on即可真正下载固件的工作交给ESP8266和上位机脚本去配合STM32不接触HTTP细节资源压力小很多。5. Keil双App工程配置与完整复现步骤5.1 Bootloader、App A、App B三个工程怎么配置复现AB OTA最少要建三个工程其实App A和App B可以共用一套源代码只是IROM1起始地址和向量表偏移宏不同。我在Keil里的配置方法是Bootloader工程Target选项 - Target - IROM1起始地址0x08000000大小0x8000编译输出不生成bin用ST-Link直接烧到Flash起始地址App A工程IROM1起始地址0x08008000大小0x30000在C/C编译器宏定义中加入VECT_TAB_OFFSET0x8000fromelf --bin --outputapp_a.bin放到用户调用里App B工程IROM1起始地址0x08038000大小0x30000编译器宏定义中加入VECT_TAB_OFFSET0x38000同样生成app_b.bin这里最要命的是保证“IROM1地址”和“VECT_TAB_OFFSET”完全对应。如果App A的IROM1是0x08008000但宏定义写成了0x10000跳转会直接跑飞。我建议工程模板里做一个编译期断言#if (VECT_TAB_OFFSET ! (APP_BASE_ADDR - 0x08000000)) #error VECT_TAB_OFFSET does not match the app base address! #endif这种断言能避免很多低级错误。App A和App B的源码可以完全一样只在业务启动后通过串口打印当前区名方便测试确认到底跑在哪个区。5.2 从空芯片开始完整复现流程我给一个可以照着做的完整流程准备STM32F103ZET6最小系统板ST-Link连接SWDUSB-TTL连接USART1。编译Bootloader工程用ST-Link烧录到0x08000000。编译App A工程用ST-Link烧录到0x08008000。这里要注意直接烧录App A后复位执行的是BootloaderBootloader如果没有“强制跳转”逻辑可能停在等待串口下载状态。我给Bootloader加了一个启动判断如果上电时检测到BOOT0引脚或者板载按键被按住进入串口下载模式否则按Flag区状态启动App A或B。这样首刷后按住按键复位一次让Bootloader先跳到App A确认基本跳转流程OK。把新版本固件打包成app_b.bin用Python脚本通过串口发送到App B分区。发送结束后Bootloader自动校验并切换到App B。复位观察串口打印确认当前运行在App B。再把旧版或修正版固件发到App A分区继续验证来回切换和回滚。这套流程我在ZET6最小系统板上完整跑过第一次用传统IAP思路的人往往会卡在第3步和第4步因为在单片机开发里平时很少有“写了App还要等Bootloader批准才能启动”的思维。5.3 固件打包工具箱除了Keil编译还需要一个固件打包工具。我的做法是用Python脚本读取bin文件计算CRC32然后生成一个包含头部元数据的升级包python3 pack_firmware.py app_a.bin app_a_pkg.bin --version 0x10001 --target 0 python3 pack_firmware.py app_b.bin app_b_pkg.bin --version 0x10001 --target 1打包脚本除了写入fw_header_t还会把固件总长度、目标分区分段号一起写进去。上位机发送时直接读这个升级包Bootloader收到后解析头部分发给目标分区。注意如果你采用的是“固件头放在Flag区”方案下发到App分区的数据仍然是原始bin升级包里的头部只用于传输阶段不占分区空间。这一点在协议实现时容易混淆我专门在代码注释里标清楚了。6. 实测踩坑OTA过程中最容易翻车的几个细节6.1 跳转成功但中断不响应多半是VTOR没同步我最初在测试时遇到过这样一个问题Bootloader能跳转到AppLED也能闪但串口命令没有任何反应一进中断就HardFault。排查了很久发现是App工程的SystemInit里没有正确设置VTOR。标准库的system_stm32f10x.c默认使用VECT_TAB_FLASH和VECT_TAB_OFFSET如果你定义宏的时机不对VC编译器的宏没传到SystemInit向量表就留在Bootloader区。解决办法有两种一种是在Keil的全局宏里定义VECT_TAB_OFFSET另一种更直接在主程序main函数最前面自己调用SCB-VTOR APP_BASE_ADDR;但注意APP_BASE_ADDR必须和IROM1地址一致。经过这次踩坑我以后的工程都不依赖库里的默认宏而是显式在Reset_Handler或main入口设置VTOR查错更直观。6.2 Flash擦写和看门狗之间的房租角力Bootloader里如果开了IWDG擦写Flash期间忘记喂狗芯片会在擦除过程中复位。本来还在安心等上位机传固件结果Flash擦到一半就重启分区里留着半擦除的数据这是最让人头疼的问题。我后来规范了一把Bootloader串口接收和Flash操作循环里都放一个IWDG_ReloadCounter()但要注意别在Flash关键操作的中断里喂狗避免影响时序稳定性。实测擦除一个2KB页在ZET6上大概几十毫秒传输100KB固件时要擦除约50个页如果散在下载过程里擦总耗时会长不少。我的做法是INIT帧收到后就整块擦除目标分区全部擦完再开始接收数据。整块擦除需要的时间都在上位机等待ACK的过程里对协议来说很友好也不会擦到一半被看门狗打断。6.3 回滚计数写错把明明能用的固件也当成坏固件回滚计数最简单粗暴的做法是“每次启动都1App成功清零”。听起来没问题但实际测试时我发现一个现象App跑起来了也清零了但下一次Bootloader启动还是会读到一个很大或异常的计数。查到最后是因为清零接口写Flag区时我只改了寄存器里的变量没调用Flash擦写函数导致数据根本没落盘。后来我在Flag区写入函数的末尾强制加了一次读回校验写不进去就报错杜绝了这类静默失败。另外一个容易被忽略的点新固件启动后不要立刻清零最好等系统稳定运行一段时间。比如等外设初始化完成、任务调度器跑起来之后再过5秒或者干脆提供一个调试串口命令手动清零。真正在产品里我会在App的自检函数通过后清零并且在清零前打印一条状态日志方便远程排查。6.4 串口下载失败恢复也要有兜底策略AB OTA的好处是下载失败不至于变砖但如果你下载的目标分区是当前正在运行的分区那就有风险。我的Bootloader强制规定在任何情况下都不允许向当前活动分区写入固件。升级命令里带了目标分区号Bootloader校验发现等于当前active_slot就直接拒绝。这样即使上位机脚本写得再烂也很难把正在跑的固件区覆盖掉。下载过程中如果出现异常掉线前期擦除过的目标分区会变成“未校验”状态。下一次Bootloader启动时发现这个分区头部Magic不对就自动跳到另一个分区同时把Flag区里该分区的状态标记为坏。这个兜底逻辑是AB方案最值钱的部分一定不要省。6.5 体积不够时怎么办别忘了C8T6的替代思路如果你手里只有C8T664KB Flash也可以做一个小规模AB方案只是要在Bootloader里做减法把下载协议砍到只有初始化、数据、结束三个命令CRC32换成CRC16不打印调试信息Bootloader压缩到12KB以内。App A和B各分20KB加上4KB Flag区勉强能跑一个非常精简的裸机程序。设置好编译优化等级-O2把不用的标准库函数关掉还是有机会的。不过从量产角度看我不推荐现有产品硬改AB除非你愿意做大量的Flash空间裁剪。更好的做法是换到RCT6甚至更高容量的型号引脚兼容性多数情况下也能覆盖地板价差的成本换来的是更踏实的升级体验。这套AB OTA流程现在已经完整跑在我手头的几块测试板上。我个人的体会是STM32F103做AB OTA本身并不难难点在于状态切换的严谨性。如果你复现时只做到A/B都能跳转那本质上还是IAP把失败回滚、试启次数验证、分区保护这些做好才算是真正的AB OTA。建议你先在ZET6最小系统板上跑通串口版再考虑加WiFi模块和nginx下载真到了量产阶段再根据实际固件大小调整分区和协议。这个流程复现一次之后后续其他Cortex-M平台做AB升级就都是同一套思路了很值得投入几个小时去搭。