
直接说结论这个项目做下来最值钱的部分不是“能升级”而是“升级失败还能回来”。基于STM32F103的AB OTA本质上就是给MCU划出两个固件运行区一个跑当前版本一个用来接收新固件升级完成后再原子切换。哪怕中途掉电、App崩溃、固件被写坏Bootloader也能靠分区状态自动回滚到上一个能跑的版本。这篇文章我会按照自己复现时的顺序从分区设计、Bootloader实现、App侧OTA服务、到掉电保护和调试避坑把整个方案完整拆开讲清楚。适合正在做IOT设备、想给存量设备加远程升级能力、或者刚接触OTA想找一套能落地代码的嵌入式开发工程师参考。1. 项目概述与方案总览1.1 传统单分区升级的痛点与AB方案的思路很多同学一开始接触OTA下意识会想Bootloader里写一套接收程序把新固件写入App区最后跳过去不就行了吗早期我也这么干过后来在产品上踩了坑才明白单分区方案最大的问题在于“没有后悔药”。最常见的一个事故是设备在升级过程中拔了电Flash刚好擦到一半旧固件没了新固件也没写完Bootloader一上电检查发现App区校验不过直接停摆。这种砖头机要是批量部署在现场运维成本极其难看。AB OTA的思路则是把“当前运行区”和“目标写入区”彻底分开。比如当前运行在A区升级时把新固件完整写入B区写完之后先不切换等App本身确认固件完整、能正常跑起来再通过标志位让Bootloader下次启动切到B区。两个区在物理上完全隔离写坏了B区最多导致回滚到A不会影响设备当前能用的版本。这套思路在ESP32的OTA机制里其实也有对应概念ESP32把分区表里设计为ota_0和ota_1两个槽位通过bootloader选择启动哪个原理大同小异。STM32F103没有芯片原生支持但用Flash分区和标志位完全可以手工实现而且逻辑清晰之后移植到其他MCU平台也就是换一套Flash驱动的事。1.2 方案选型F103、HAL库还是标准库为什么选STM32F103作为复现平台因为这块芯片存量太大了。不管是做工业控制板、传感器采集器还是小家电主控F103系列都有大量现成产品在跑。而且CubeMX HAL库对于Bootloader这种“外设相对少、流程相对固化”的程序特别友好生成串口、Flash、GPIO初始化都很省事。至于用HAL库还是标准外设库V3.5其实不影响AB OTA的核心逻辑。如果你手头的老项目还在用标准库V3.5没必要为了OTA专门迁移到HAL。在本文里我以HAL库为例写代码但后面讲的核心函数——Flash擦写、跳转、标志位管理——用标准库改起来也非常直接无非是把HAL_FLASH_Program换成FLASH_ProgramHalfWord这种层面的替换。我个人建议Bootloader和App尽量共用一套HAL库版本这样两边对Flash操作的底层行为完全一致排查问题的时候少一个变量。1.3 实际产物与适用读者这个项目最终做出来的东西包括三部分一个能接收固件、擦写分区、校验并跳转的Bootloader程序一个在App侧运行的OTA升级管理模块负责接收固件、写入非运行区、设置切换标志一份清晰的Flash分区规划与标志位状态机支撑AB切换和故障回滚。做完以后你有什么串口、RS485、CAN、网络、甚至无线模块只要往OTA模块里替换一个“收包接口”就能对接自己的传输链路。如果未来想接入云平台做远程升级这套本地AB逻辑可以直接作为底层升级内核。2. 硬件准备与工程环境搭建2.1 最小系统与硬件要点STM32F103的最小系统并不复杂做OTA实验时我强烈建议别用开发板直接搞而是按最小系统原理图自己搭一块原因后面会说。最小系统必备几个东西电源3.3V电流余量至少200mA以上、8MHz晶振、两个22pF负载电容、BOOT0和BOOT1引脚电平配置、NRST复位电路、SWD四线下载口以及给调试用的串口。有一个很容易被忽略的点如果你打算用AB双区方案F103C8T6这种只有64KB Flash的芯片会非常挤。A区、B区各分28KBBootloader再占8KB基本是极限操作稍微加个功能就爆Flash。所以做AB OTA实验我推荐至少用256KB Flash的型号比如STM32F103RCT6或者ZET6。虽然C8T6实际有一部分批次能读出128KB Flash但那是非规格行为用在项目上不可控。2.2 CubeMX初始化配置参考用CubeMX生成工程的时候建议只开四个东西RCCHSE外部晶振后续时钟树配到72MHzSYSDebug选Serial Wire保留SWD口否则下载一次后第二次就烧不进程序了USART1PA9为TXPA10为RX115200-8-N-1中断开启GPIO一个普通输出口接LED辅助显示Bootloader运行状态。时钟树配到72MHz这一步不要跳AB切换里Bootloader和App如果时钟配置不一致跳转后很容易出现串口乱码或者定时器跑飞。CubeMX生成工程后注意链接脚本文件.ld或者.sct经常会被CubeMX重新生成覆盖后面要手动改的地方记得在改完后把工程生成策略设为“不重新生成链接脚本”或者干脆用文本编辑器维护一个独立ld文件。2.3 启动文件与RAM尺寸确认启动文件用来定义中断向量表和芯片上电后的启动流程在零基础复现AB OTA时很容易被忽略但偏偏它是跳转能否成功的关键之一。选择启动文件要按照芯片的实际Flash容量来比如RCT6属于大容量产品用startup_stm32f103xe.s如果是C8T6中容量则用startup_stm32f103xb.s。选错启动文件的一个典型现象是程序编译正常烧进去也能跑但进入中断回调时莫名其妙死在HardFault里。同时要确认RAM长度。RCT6的RAM是48KBZET6是64KB不同型号在ld文件里的RAM长度不一样定义栈顶地址时别写死。Bootloader在跳转前要校验App首地址里存放的栈顶值是否落在RAM范围内如果这一步只判断“高位是0x2000”而不判断具体数值很容易放进去一个非法地址导致App一启动就崩溃。3. Flash分区规划与ld脚本修改3.1 分区地址划分与计算公式AB OTA第一个核心工程决策就是Flash怎么分。以STM32F103RCT6的256KB Flash为例我建议这样划分分区起始地址大小用途Bootloader0x0800000032KB启动选择、升级接收、校验与跳转APP_A0x0800800096KBSlot A默认运行区APP_B0x0802000096KBSlot B升级目标区FLAG0x0803800032KBAB状态、版本号、候选/确认标志地址计算有个通用规律所有分区起始地址必须按“擦除页大小”对齐。F103大容量芯片Flash页是2KB中容量是1KB如果分区不对齐擦除A页时会把相邻分区的数据也干掉。我在一次实验里吃过这个亏把APP_A写在0x08007C00结果每次擦A区前面一块数据Bootloader的最后几页就被连带擦掉表现像“升级几次后设备变砖”。这是必须靠计算和查表确认的。如果芯片容量只有128KB可以把Bootloader压到24KB分页对齐每个App分48KBFlag区放最后8KB这种比例对轻量固件足够。总之先算好再动手写代码。3.2 App工程的ld文件修改方法App的链接脚本是整个工程里改动最核心的地方。以GCC工具链为例App工程的ld文件需要这样写MEMORY { FLASH (rx) : ORIGIN 0x08008000, LENGTH 96K RAM (xrw) : ORIGIN 0x20000000, LENGTH 48K }Bootloader的ld文件保持ORIGIN 0x08000000不变。这里容易出问题的点在于很多人只在代码里设置了SCB-VTOR偏移却没有对应修改ld文件结果导致一个现象编译出来的App固件内部所有的向量地址、全局地址都还是基于0x08000000。烧到0x08008000后跳转过去PC确实指向对了但启动文件里调用的SystemInit函数地址映射还是旧的整个程序运行就乱套。如果用Keil MDK就在Options for Target里把IROM1的Start改成0x08008000。用IAR则是在链接配置里改ROM起始地址。只要保证“App编译出来的地址空间与它实际烧录的物理地址一致”中断向量表偏移设置才有意义。3.3 标志区数据结构设计AB切换依赖标志区它不是简单存一个字节“当前跑A还是跑B”而是要存足够的信息支撑启动选择、候选确认、掉电恢复。我的建议是定义一个固定结构体typedef struct { uint32_t magic; // 0xA50B00D5区分合法标志区 uint32_t version_a; // A区固件版本号 uint32_t version_b; // B区固件版本号 uint32_t status; // 当前状态机 uint32_t boot_count; // 当前分区尝试启动次数 uint32_t checksum; // 以上字段的简单和校验 } boot_state_t;status是核心状态字段我会在第六章详细讲状态机。为什么要加checksum因为标志区本身存在Flash里擦写过程中如果掉电读出来的数据可能半新半旧。Bootloader上电时先算一遍校验和不过直接走“保守策略”选择上一次确认成功的分区启动而不是试图分析那些已经损坏的状态位。4. Bootloader实现4.1 Bootloader启动流程概览Bootloader的代码逻辑相对独立上电后顺序为初始化时钟、串口、LED读取标志区校验合法性根据status与boot_count选择要启动的Slot如果某个Slot固件头部不合法自动切换另一个等待主机握手请求如果有升级指令则进入升级接收流程没有升级请求且存在可启动Slot延时300ms后跳转。这里有一个设计细节Bootloader不要在每次启动时都做“长等待”。如果用户没打算升级等300ms已经能覆盖绝大多数上位机握手指令。一旦App起来由于Bootloader已经跳走它占用的UART外设会被彻底释放不会跟App抢串口。4.2 自定义传输协议还是YmodemBootloader接收固件可以用Ymodem协议开发上位机很省事。但我个人更推荐自定义简单协议尤其当你后续想对接自己的云平台、网关或4G模块时私有帧更好扩展。我用的是这样一帧格式字段大小说明帧头2字节固定0xAA55命令字1字节握手、开始传输、数据块、结束、跳转负载长度2字节小端模式负载数据N根据命令不同含义不同CRC162字节校验整个帧帧长控制在256字节以内一帧最多携带240字节数据。这样Bootloader内部用一个静态数组成员接收不需要动态内存分配。配合串口空闲中断或者逐字节状态机都能稳定组包。4.3 Flash擦写函数与页对齐处理STM32F103对Flash的编程要求是半字16位对齐写入擦除则按页进行。你要把接收缓冲区的内容连续写入目标地址不能不停锁Flash和解锁Flash那样会拖慢速度长时间占用Flash总线还会导致中断响应卡顿。一个相对完整的写入函数如下void flash_write_page(uint32_t page_addr, uint8_t *buf, uint32_t len) { uint32_t err 0; FLASH_EraseInitTypeDef erase; erase.TypeErase FLASH_TYPEERASE_PAGES; erase.PageAddress page_addr; erase.NbPages 1; HAL_FLASH_Unlock(); HAL_FLASHEx_Erase(erase, err); for (uint32_t i 0; i len; i 2) { uint16_t half_word buf[i] | ((uint16_t)buf[i 1] 8); HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, page_addr i, half_word); } HAL_FLASH_Lock(); }这里有个坑固件二进制长度不一定会按偶数对齐最后剩一个字节时你也要凑成半字写进去但后面多写的那一字节是垃圾数据。解决办法是接收固件时把每个分区的最后一块按“实际固件长度对齐填充”传输确保填充值固定为0xFF。擦除方面还有一个经验擦除整页可能耗时几十毫秒如果你的Bootloader开启了看门狗一定要在擦除期间暂停喂狗或者把喂狗周期延长到大于最大擦除时间。我自己就碰到过擦除一半看门狗复位的情况表现是升级到80%突然设备重启日志里永远查不到明确错误。4.4 跳转到App的完整代码跳转是整个Bootloader里最容易写崩的部分核心就是重新设置主栈指针MSP和跳转到Reset_Handler。以下是经过验证的跳转函数typedef void (*p_function)(void); void jump_to_app(uint32_t app_addr) { uint32_t msp_addr *(volatile uint32_t *)app_addr; uint32_t reset_addr *(volatile uint32_t *)(app_addr 4); if ((msp_addr 0xFFF00000) ! 0x20000000) { return; // 栈顶不在RAM范围固件不合法 } __disable_irq(); HAL_UART_DeInit(huart1); HAL_RCC_DeInit(); SysTick-CTRL 0; SysTick-LOAD 0; SysTick-VAL 0; __set_MSP(msp_addr); p_function jump (p_function)reset_addr; jump(); }为什么要先__disable_irq()跳转前如果不关中断跳过去之后App还没设置好中断向量表一个串口中断到来就会让CPU取到错误的中断函数地址死机姿势千奇百怪。还有一点跳转前要把外设全都反初始化否则App重新初始化串口、GPIO时会先遇到一堆残留在寄存器里的状态最典型的就是串口DMA缓冲错乱。5. App端实现5.1 App侧的分工与边界App要做的事远比Bootloader多——它平时要跑业务逻辑同时得在特定时刻接收固件、写入非运行区、校验并触发重启。为了不让OTA逻辑干扰业务我建议把它单独封装成ota.c模块只暴露三个接口void ota_init(void); void ota_handle_byte(uint8_t data); void ota_handle_frame(uint8_t *buf, uint16_t len);底层复用了Bootloader里类似的Flash写入逻辑。注意App和Bootloader虽然是两个工程Flash底层代码建议保持同一个版本尤其是擦除页大小、半字编程方式这些细节不要一边用HAL一边用寄存器容易埋雷。5.2 升级流程与版本上报实际操作中升级不是一瞬间完成的。App上电后第一件事是通过串口或者网络周期性上报自己的版本号上位机或云端判断出新版本后再把固件分包下发。到了App侧ota_handle_frame拿到数据帧后先校验命令和CRC再按偏移写入目标分区。我设计的流程分了这几步上位机发送开始传输命令数据里携带目标分区号、固件总长度和CRC32校验值App清空该分区的页并进入接收状态上位机逐帧发送固件数据App写入Flash全部发送完后上位机发送结束校验命令App重新读出Flash里的内容计算CRC一致后写入“候选切换”标志App调用NVIC_SystemReset()复位交给Bootloader完成切换。这样做的好处是升级过程中如果通信中断App仍然运行在A区业务不会中断设备还是好的只是B区留了半截数据。下次重新升级时App会先擦掉再写不影响当前运行。5.3 写非运行区时如何避免干扰业务App在接收固件的同时主循环可能还有其他任务在跑甚至有的项目里还跑了FreeRTOS。这个时候Flash擦写会阻塞总线时间长的话定时器、串口都容易丢数据。我建议在App侧做几个约束接收固件时优先在串口中断或者DMA空闲中断里把数据搬到RAM缓冲区而不是直接在中断里擦Flash每次擦写一个页之前先暂停高优先级业务任务完成后再恢复如果使用RTOS不要让OTA任务优先级太高避免擦写Flash期间其他关键任务完全得不到调度升级期间关闭低频看门狗或改成软件喂狗模式防止耗时的Flash操作触发复位。当然最保险的做法是把OTA模块独立成一个状态机每次处理完一帧立刻退出回到主循环。在非实时系统上不建议用一个大循环把整包固件收完再写Flash那样串口接收缓冲区大概率溢出。5.4 App的VTOR中断向量偏移设置App工程编译地址改到0x08008000之后中断向量表本身还是放在这个地址的起始位置但F103上电后默认从0x08000000取向量表。如果App里不设置偏移任何中断触发都会去0x08000000找中断函数结果找到的是Bootloader的向量表自然就乱套了。所以App的main函数第一行就要写SCB-VTOR 0x08008000; // 对应APP_A的起始地址如果你在跑RTOS或者使用了HAL库的延时这行设置一定要在调用任何可能开中断的库函数之前完成。我见过有人把它放在SystemInit之后、主循环之前结果中间串口中断已经触发过一次程序直接HardFault。还有一种做法是修改system_stm32f1xx.c里的VECT_TAB_OFFSET宏但手工在main里写死最直观也最不容易被CubeMX重新生成时覆盖掉。6. AB切换、回滚与掉电保护6.1 AB状态机定义AB切换不是简单存一个“当前启动A还是B”而是需要区分“当前有效分区”、“候选分区”、“已试运行分区”。我的状态机定义成五个状态状态值枚举含义0x00STATE_UNKNOWN标志区无效或首次启动0x01STATE_BOOT_A当前确认启动A区0x02STATE_BOOT_B当前确认启动B区0x03STATE_CANDIDATE_AA区是新写入的候选等待确认0x04STATE_CANDIDATE_BB区是新写入的候选等待确认Bootloader上电时遇到STATE_CANDIDATE_B就启动B区但不会立刻把它改成STATE_BOOT_B。App启动后正常运行几秒钟主动通过一个命令通知Bootloader“新固件工作正常”Bootloader才将状态更新为STATE_BOOT_B。如果几秒内App自己死机或者被看门狗复位Bootloader检测到boot_count达到上限就会自动把状态回退到STATE_BOOT_A。6.2 标志区写入与掉电原子性Flash的特点是只能写0不能直接改1所以想更新标志区不能简单地改写一个字段而是要先整块擦除再重写。掉电问题在这里很致命如果先擦除后写新数据擦完刚写一半就掉电标志区就是一个坏块。我的做法是标志区使用两个固定偏移位置交替写入。每次更新状态时先读取两个偏移里的数据找出“当前有效”的那一份擦除另一个偏移所在的页再把新状态写入该偏移。这样任意时刻至少有一份状态是完整的不会因为一次断电把状态区毁掉。代价是多占一点Flash空间但对OTA的可靠性来说非常值。6.3 回滚策略速查表Bootloader选择启动哪个Slot时逻辑相对固定我直接把规则整理成表标志区状态A区固件头B区固件头Bootloader行为无效合法非法启动A区无效非法合法启动B区BOOT_A合法合法启动A区BOOT_B合法合法启动B区CANDIDATE_B合法合法启动B区并递增计数超过阈值回滚ACANDIDATE_A合法合法启动A区并递增计数超过阈值回滚B“固件头合法”的判断除了查看首地址MSP是否落在RAM范围还可以增加一个固件魔数比如在App二进制偏移16字节处写一个固定值0x5AA5Bootloader跳转前校验这个值。加魔数能挡掉不少误烧写和抓错文件的问题。7. 调试方法与常见问题实录7.1 用调试器验证跳转的正确手法AB OTA最容易翻车的就是跳转环节建议先不接上位机直接用ST-Link配合调试器把跳转流程跑通。具体可以这样做在Bootloader的jump_to_app函数入口打断点查看msp_addr和reset_addr这两个变量是否和App工程编译出的首地址一致。再用STM32CubeProgrammer读取0x08008000处的数据确认那一块确实是App固件的二进制内容。如果跳转后进入了HardFault优先怀疑两个地方一是App的VTOR没有设置成0x08008000二是App的ld文件没有改偏移导致编译地址还是从0x08000000开始。这两个问题从表面上非常像但查起来一个看代码、一个看编译map文件定位思路完全不同。还有一个调试技巧在App工程的main函数入口处立即加一个GPIO翻转用示波器或逻辑分析仪量那根引脚确认App是否真的被跳过去执行了。如果引脚没反应先怀疑跳转本身如果引脚闪了一下就死再怀疑中断向量表或外设初始化问题。7.2 实测高频问题排查速查表问题现象可能原因解决办法升级到一半设备重启Bootloader或App里看门狗触发擦写Flash期间暂停喂狗或延长超时跳转后串口乱码时钟树配置不一致波特率误差变大保证Bootloader和App使用相同的PLL倍频系数App烧录后直接HardFault中断向量表偏移没设置在main最开始执行SCB-VTOR赋值写Flash返回错误Flash被写保护检查Option Bytes必要时执行全片擦除解除保护串口升级时偶发丢帧接收缓冲区太小或中断优先级低使用DMA空闲中断缓冲区至少512字节Bootloader启动后无法进入升级模式握手等待时间太短增加启动等待延时到300ms以上新固件跑起来后老固件无法回退未实现候选试运行机制按6.1节增加CANDIDATE状态和启动计数如果是在RS485总线上做升级需要特别处理收发方向切换。大多数485芯片收发方向由一个引脚控制发送完最后一个字节后不能立刻切到接收模式要等串口发送完成的TC标志置位再切换否则会吃掉自己发的最后几个字节导致帧校验失败。这个坑在Modbus RTU设备上极为常见做OTA时一样会遇到。如果后续升级链路是CAN还要注意波特率采样点、SJW同步跳跃宽度这些参数。OTA帧在总线上传输频繁如果同步段配置不好长报文容易在连续bit翻转时采样错误。这不是AB OTA本身的问题但传输层不稳定会让Flash那边收到一堆坏数据排查问题时就很容易甩错锅。7.3 给实际项目落地的几个优化建议如果实验版本跑通了要正式拿去产品上我会建议做几件额外的事。首先是在Bootloader里把握手协议做成带重传机制的上位机发握手指令Bootloader收到后回ACK上位机超时重发重试5次退出。没有重传机制现场电磁干扰稍微大一点升级就是一场灾难。其次是增加APP_B分区的日常巡检。很多人只在升级时写B区平时完全不管它结果某天升级时发现B区Flash出现坏块。可以在App启动时读一遍B区的CRC校验值如果和烧录时不一致至少能提前暴露问题而不是等到关键升级时才发现。最后是外部Flash的取舍。F103内部Flash空间有限如果产品固件本体已经很大AB双区方案会让可用Flash直接砍半。一个折中方案是把A/B双区放在外部串行Flash里Bootloader通过SPI或QSPI读取固件到内部SRAM执行但Copy到RAM执行对F103这种SRAM不大的芯片来说会受限不如直接换大容量MCU。说到底AB OTA的成本大头在Flash容量设计产品前先算清这笔账最重要。从我几次复现和踩坑的经验来看AB OTA这套方案的工程难点不在某一段代码而在于“分区、偏移、跳转、状态机”这四件事的全局一致性。任何一处地址没对齐行为都会延后到真机升级时才暴露。建议你先按这篇文章的地址规划在一个现成的F103板子上跑通最小流程再逐步替换成自己的通信链路。等状态机跑顺了你会发现后面不管接ESP32做局域网升级还是接云平台做远程推送底层这套双区回滚逻辑都可以原封不动搬过去。