资讯动态

STM32 OTA升级实战:AB分区方案与Bootloader回滚机制全解析

发布时间:2026/9/7 2:13:15 来源:尧图企业网站定制
嵌入式产品做到后期几乎躲不开一件事固件升级。更准确地说是“怎么让固件升级这件事不再让人提心吊胆”。如果你写过带Bootloader的STM32程序大概率经历过这种场景App跑着跑着要升级结果升级过程中串口线被碰了一下、电源抖了一下、或者固件传输到一半网络断了。等再上电设备黑屏变砖了。传统方案Bootloader 单App区Bootloader一旦跳转到写入一半的App整个产品就废了。想要救回来多半得返厂或者拿烧录器重新擦。这篇教程要聊的就是STM32F103上从零复现一套AB双分区OTA方案。不依赖云平台、不需要额外上系统、不涉及专门加密芯片纯靠MCU内部Flash分区、标志位、跳转逻辑和一套简单的串口通信协议把“升级变砖”这个隐患从根上解决掉。方案原型来自我自己在多个量产项目里跑过的写法你完全可以在标准库V3.5工程里照着复刻用常用开发板就能验证。文章会照顾到两边读者只听说过AB OTA但没自己写过的人可以完整走一遍流程已经写好跳转逻辑但回滚总是不稳定的人也能从后面的问题排查部分找到一些没注意到的细节。全程按代码、配置、实测三个维度展开。1. AB OTA整体设计与思路拆解1.1 为什么AB分区比“Bootloader单App区”更值得做多数人理解OTA升级脑子里浮现的是一条线Bootloader接收固件擦掉现有App区把新固件写进去然后跳转。这条链路看着简单实际上一旦开始接收数据旧固件就已经被擦掉或部分覆盖了。新固件不完整、传输中断、校验出错任何一个小意外都意味着设备失去了可运行的固件。AB分区方案把逻辑反过来Flash里始终有两个App区一个正在运行一个等待写入。OTA升级时Bootloader或者正在运行的App把新固件完整写入空闲区。只有在完整性校验通过后才通过标志位让设备重启切换到新分区。万一校验失败或者新固件跑不起来另一边的旧固件还在系统随时可以回滚。这就像写字楼的双路供电。一路电源突然出问题另一路立刻顶上用户几乎无感知。AB分区的核心价值不是“升级更快”而是“任何时候都保证有一块能启动的固件”。1.2 Flash分区规划与技术要点STM32F103的Flash从0x08000000开始容量从64KB到512KB不等。为了让AB分区有实际意义建议至少选256KB以上的型号。给个我常用的512KB布局型号对应STM32F103ZET6或RCT6都适用。区域起始地址大小用途Bootloader0x0800000064KB启动引导、OTA接收、擦写、回滚App区A0x08010000224KB正式版本固件App区B0x08048000224KB备用版本固件标志区0x0807F8002KB分区有效性标志、升级状态Bootloader单独占一段是因为它承担了最关键的启动决策和刷写逻辑不能让升级过程中任何意外波及到它。A/B两个App区各224KB对大部分STM32F103应用来说很宽裕。如果你想做产品化可以再预留一些富余量避免后期功能膨胀导致装不下。标志区单独放在最后2KB不用跟任何App区混在一起。这样设计的好处是刷写App区的时候即使把地址算错了一点点也只会碰到相邻的App区不会把标志数据冲掉。访问Flash时一定要注意STM32F103写Flash是以半字为单位页擦除是1KB一页。所以标志区哪怕只用几个字节也会占用整整两页Flash。1.3 升级与回滚的核心机制正常升级流程可以拆成下面几步设备运行在App区A。用户触发升级App区A将新固件分包写入App区B。全部写入完成对固件做整体CRC校验。校验通过在标志区写入“B区有效”标志。系统软复位Bootloader读取标志后跳转到App区B。App区B启动后运行一段时间比如30秒确认功能正常再写入“运行确认”标志。回滚的过程发生在第5步之后。如果App区B在上电后起不来——跑飞、HardFault、看门狗没喂——Bootloader会在下一次复位时发现B区没有“运行确认”标志自动跳回App区A。这个设计里最关键的一个理念是新固件在“试用期”内还不算正式生效经过确认后才转正。很多OTA方案只做了前4步丢掉了第6步的确认机制回滚等于形同虚设。后面实现代码时会看到这个确认步骤其实非常简单但对稳定性的提升是质变。2. 环境准备与工程搭建2.1 软硬件准备清单硬件方面一块STM32F103最小系统板或者正点原子/野火开发板都行因为AB方案用到的就是片上资源跟具体板子关系不大。核心要求有几个STM32F103Flash容量256KB以上一个USB转TTL模块用于串口OTA传输一个LED接在某个GPIO上作为状态指示一个按键用于强制进入升级模式软件环境我用的是Keil MDK 5 ST标准外设库V3.5.0。标准库虽然老但STM32F103的生态资料最全的就是它网上搜问题能快速找到答案。串口调试助手用XCOM或者SSCOM都行这里建议带文件发送功能的方便后面直接发送OTA固件包。2.2 Bootloader工程配置Bootloader的工程需求很明确跳转、烧录、串口通信。新建工程以后需要开启的模块是GPIO、USART1、FLASH和看门狗如果做超时回滚。编译地址设置在这里格外重要。打开Options for Target Target把IROM1的起始地址设为0x08000000大小设为0x10000也就是64KB。这一步决定了Bootloader程序会被编译到固定起始地址的Flash区域。不修改这个值后面跳转时地址全对不上。编译设置和普通工程一样勾选Create HEX File方便生成可以直接烧录的HEX文件。串口用USART1波特率建议115200数据位8停止位1无校验。这个波特率在STM32F103上配合外部8MHz晶振很好算不容易出频率误差。2.3 APP工程配置与中断向量偏移APP工程要管理两件事编译地址改到App区A的起始地址中断向量表也要跟着偏移。先说编译地址。假设用App区A起始地址就是0x08010000大小是0x38000。Keil的IROM1里按这个范围填。RAM不用动STM32F103的SRAM是统一编址的从0x20000000开始。再说中断向量表偏移。单片机复位后CPU从0x08000000读取栈顶指针和复位向量这是Bootloader的区域。当Bootloader把控制权交给App后App里所有的中断请求比如串口接收中断、定时器中断都还指向0x08000000的中断向量表。如果不把向量表重定位到App区一进中断就跳去执行Bootloader的代码直接跑飞。标准库V3.5的做法是在系统初始化后调用NVIC_SetVectorTable(NVIC_VectTab_FLASH, 0x10000);或者直接操作寄存器SCB-VTOR 0x08010000;注意这句必须在启动早期执行最好放在main函数最开始的位置。晚了的话在设置之前来的中断就已经出问题了。另一个容易忽略的点是链接脚本里要确保App的起始地址跟VTOR的偏移量是同一个值否则程序一进中断就不知道跑哪去了。3. Bootloader功能实现3.1 启动流程与版本标志判断Bootloader的main函数是所有AB OTA方案的决策中心。上电后第一步是初始化时钟和必要的GPIO然后读取标志区。根据标志区的状态决定是正常跳转App还是进入升级模式。流程可以抽象成三层判断读升级模式触发源按键是否被按住、串口是否收到升级命令。读分区标志A区和B区哪个有效。检查运行确认状态有效分区是否被确认过能正常工作。APP工程要管理两件事编译地址改到App区A的起始地址中断向量表也要跟着偏移。先说编译地址。假设用App区A起始地址就是0x08010000大小是0x38000。Keil的IROM1里按这个范围填。RAM不用动STM32F103的SRAM是统一编址的从0x20000000开始。再说中断向量表偏移。单片机复位后CPU从0x08000000读取栈顶指针和复位向量这是Bootloader的区域。当Bootloader把控制权交给App后App里所有的中断请求比如串口接收中断、定时器中断都还指向0x08000000的中断向量表。如果不把向量表重定位到App区一进中断就跳去执行Bootloader的代码直接跑飞。标准库V3.5的做法是在系统初始化后调用NVIC_SetVectorTable(NVIC_VectTab_FLASH, 0x10000);或者直接操作寄存器SCB-VTOR 0x08010000;注意这句必须在启动早期执行最好放在main函数最开始的位置。晚了的话在设置之前来的中断就已经出问题了。另一个容易忽略的点是链接脚本里要确保App的起始地址跟VTOR的偏移量是同一个值否则程序一进中断就不知道跑哪去了。3.1 启动流程与版本标志判断Bootloader的main函数是所有AB OTA方案的决策中心。上电后第一步是初始化时钟和必要的GPIO然后读取标志区。根据标志区的状态决定是正常跳转App还是进入升级模式。流程可以抽象成三层判断读升级模式触发源按键是否被按住、串口是否收到升级命令。读分区标志A区和B区哪个有效。检查运行确认状态有效分区是否被确认过能正常工作。标志区的数据结构我用了一个结构体在Flash末地址2KB范围内偏移存储#define FLAG_BASE_ADDR 0x0807F800UL typedef struct { uint32_t magic; // 固定值0xA5A5A5A5表示标志区已被正确写入 uint32_t active_slot; // 1表示引导A区2表示引导B区 uint32_t boot_confirm; // 当前分区是否已确认运行成功 uint32_t boot_count; // 连续启动计数用于异常回滚保护 } ota_flag_t;active_slot就是Bootloader决定跳哪个区的依据。boot_confirm是整个回滚机制的核心它由App运行一段时间后主动写入。boot_count则是用来处理“新分区能启动但马上崩溃”这种情况。每次Bootloader跳转到未确认分区时boot_count加一超过设定次数就强制切回旧分区防止系统无限重启。3.2 跳转APP的实现细节跳转是整个方案技术上最关键的部分。网上能搜到很多版本但核心就几行代码typedef void (*pFunction)(void); void JumpToApp(uint32_t app_addr) { uint32_t app_sp *(volatile uint32_t *)app_addr; uint32_t app_pc *(volatile uint32_t *)(app_addr 4); pFunction app_reset; // 简单合法性校验防止跳到一个空的Flash区域 if ((app_sp 0xFFF00000) ! 0x20000000) { return; } if ((app_pc 0xFFF00000) ! 0x08000000) { return; } app_reset (pFunction)app_pc; // 跳转前关闭全局中断把外设清干净 __disable_irq(); SysTick-CTRL 0; SysTick-LOAD 0; SysTick-VAL 0; __set_MSP(app_sp); app_reset(); }为什么要重新设置MSP因为复位后MSP一直指向Bootloader的栈顶。App程序在编译链接时栈顶地址是按App自己的起始地址计算的。如果沿用Bootloader的栈一旦App调用函数压栈数据可能会写进Bootloader的工作变量区轻则变量被覆盖重则栈溢出直接HardFault。跳转前关闭全局中断这一点也有讲究。如果在跳转瞬间还有中断进来中断向量表指向的还是Bootloader区域跳转动作会被打断。加上__disable_irq()之后等App自己初始化完中断再打开才能保证第一次进中断时的环境是干净的。3.3 Bootloader进入升级模式的策略Bootloader不能只会“跳”还得会“接”。产品实际使用中用户不一定按得住按键所以升级触发方式得做两手准备上电时检测升级按键按键按住3秒以上进入升级模式。运行中的App收到升级指令后主动在标志区写入“进入升级模式”标记然后软复位。Bootloader看到这个标记就不跳转而是停留等待OTA。进入升级模式后Bootloader需要跟发送端进行简单的握手。握手成功后才开始接收固件。使用串口中断接收每收到一帧解析一帧写入临时缓冲区。这里有一个经验不要在串口中断里直接做Flash擦写因为Flash写入会阻塞较长时间容易造成丢帧。正确做法是中断里只收数据放到FIFO主循环里解析和写Flash。4. APP端OTA功能实现4.1 升级触发与接收协议实现App端的OTA功能本质上是把Bootloader的一部分能力复制过来它知道自己要往“对面那个分区”写数据。触发方式可以根据产品形态自由发挥。按键触发、上位机命令、服务器下发指令都可以。核心动作是设置标志区中的升级标记然后调用NVIC_SystemReset()复位进Bootloader。我在项目里常用串口命令比如收到“OTA_B”就表示要升级到B区。接收协议按我实践的简洁帧格式来字段长度说明帧头2字节0xAA 0x55命令字1字节0x01握手0x02数据帧0x03结束帧数据长度2字节小端模式数据区0~256字节固件内容或其他数据CRC162字节从命令字到数据区的CRC校验每帧最大256字节是因为STM32F103的RAM有限缓冲区开太大不划算太小又导致发送端频繁等待确认。256字节在中速串口下效率算平衡点实测115200波特率下传输速度很理想。握手流程也很简单App复位进Bootloader后Bootloader向上位机发送0x01 0x01上位机回相同帧双方确认后就进入数据传输阶段。数据帧按序号排列Bootloader收到后回复ACK发送端收到ACK后发下一帧。这个简单停等协议虽然效率不高但胜在逻辑简单、调试容易在串口这种可靠链路上完全够用。4.2 Flash擦除与写入关键代码写入Flash的代码是整个方案最容易出差错的地方。STM32F103的标准库已经把封装做得很好了关键是要理解它背后的限制。代码层面是这样uint8_t OTA_WriteFlash(uint32_t addr, uint16_t *buf, uint32_t halfword_count) { uint32_t page_base; uint32_t i; // Flash写入前必须解锁、擦除 FLASH_Unlock(); // 计算目标地址所在页并擦除 page_base addr 0xFFFFFC00; FLASH_ErasePage(page_base); // 按半字写入 for (i 0; i halfword_count; i) { if (FLASH_ProgramHalfWord(addr i * 2, buf[i]) ! FLASH_COMPLETE) { FLASH_Lock(); return 1; } } FLASH_Lock(); return 0; }这里有个关键点一个地址只能被擦除后写入一次。换句话说不能对一个已写入过的地址再次写入除非擦掉整页。所以接收固件时不能每收到一帧直接写对应地址而应该先把整页数据收齐攒在RAM缓冲区里再一次性擦除、写入。否则就会出现局部数据覆盖导致的Flash写入错误。实际操作中我是把接收缓冲区设成1KB正好对齐一页攒满一页就擦写一页。最后一页不足1KB也没关系补0xFF填充成整页再写。擦除页的计算要注意地址对齐。STM32F103的页大小是1KB所以页起始地址就是地址去掉低10位。直接用addr 0xFFFFFC00来算页基址简单又正确。4.3 固件校验与标志位设置固件写完不等于升级成功完整性和正确性校验是最后一道防线。我用的校验分两层第一层是每帧CRC16这个在传输过程中就会做确保每一帧都没有被串口噪声破坏。第二层是整体CRC32在全部帧写完后对整片固件区重新读取计算CRC32跟固件包头里携带的CRC32值比对。只有两者一致才允许设置“B区有效”标志。固件包头我单独定义了一个结构体放在升级包最前面typedef struct { uint32_t magic; // 0x544F4142标识这是一个OTA升级包 uint32_t total_len; // 固件总长度 uint32_t crc32; // 固件内容的CRC32 uint32_t version; // 版本号 } ota_header_t;这样做的好处是定位问题快。如果传输中断了包头里的magic不对Bootloader一看就知道不是合法升级包直接忽略并跳回老分区。如果CRC32不对就说明固件在传输过程中被破坏了同样回跳。设置标志位时由于Flash写入必须先擦后写而标志区只有2KB擦写次数有限所以不能每次升级都写。我写了一个擦写次数保护逻辑只有标志区里没有任何有效标志时才执行擦除操作有标志时直接把对应位置写0x00更新。这样能显著降低标志区Flash的磨损。5. 完整复现流程与实测记录5.1 编译烧录Bootloader与APP按前面配置编译两个工程会得到Bootloader.hex和AppA.hex。编译完成后先用ST-Link或者J-Link把Bootloader烧到0x08000000再把AppA烧到0x08010000。烧录完成后接上串口打开串口助手按下复位键。如果一切正常串口会收到App的打印信息LED按App里的逻辑跑起来。这一步验证了Bootloader跳转成功是后面所有OTA操作的基础。接下来用Python脚本把AppA.hex转成AppB.bin升级包加上包头和CRC32。这里我再提一个实战建议升级包最好在构建服务器上自动生成别用图形化工具手工转否则版本多了很难追溯。5.2 开始第一次OTA升级打开串口助手的文件发送功能选择生成的AppB升级包。我的升级包做成了特定格式所以也可以自己写个小工具来发送目前项目里用Python脚本直接打包发送。实际操作顺序是当前运行AppA串口发送OTA_B命令。AppA收到命令设置“进入升级模式”标志软复位。Bootloader检测到升级标志停留等待OTA。发送端等500ms后先发握手帧跟Bootloader建立连接。Bootloader回ACK后发送端开始按256字节一帧发数据。全部发完后Bootloader对整片B区做CRC32校验。校验通过Bootloader设置“B区有效”标志复位。Bootloader引导到B区运行AppB。实测在115200波特率下224KB的升级包大约耗时50秒左右。这个速度对大多数应用来说完全能接受。升级过程中LED按特定频率闪烁表示处于OTA状态升级成功后就切换成新的呼吸灯模式。5.3 制造故障验证回滚验证回滚是AB OTA方案里最重要的一步。测试时不能只看“升级成功”这半边更要验证“升级失败能回来”。我常用的故障注入方法有两个第一个直接改错CRC32。脚本生成升级包时故意把CRC32字段改成错值。正常升级流程走完Bootloader在最后CRC校验那一步会失败然后自动跳回A区。A区固件照常运行就像什么都没发生一样。第二个把新固件改成会在启动后主动触发HardFault的版本。Bootloader正常引导到B区但B区代码一跑就崩系统复位。Bootloader发现B区boot_confirm没有置位经过几次复位后强制跳回A区。这个过程用户完全无感最多看到设备多闪了两下灯。这两个测试跑通了AB OTA才算是真正闭环。我遇到过不少项目只测了升级成功链路回滚链路没测结果上生产后第一批设备就出问题原因基本都是新固件启动异常又回不去。6. 常见问题与排查技巧实录6.1 问题速查表实际操作中积累了一些高频问题整理成速查表方便对照现象可能原因解决思路跳转后白屏/全无反应MSP设置错误、App向量表没偏移检查设置MSP的地址是否是0x08010000开头检查SCB-VTOR升级过程中掉线串口波特率误差大、FIFO溢出确认晶振频率计算波特率误差增大接收缓冲区或改用16位FIFOFlash写入HardFault未解锁、地址越界、已写入地址重复写入检查FLASH_Unlock是否配对确认地址在有效Flash范围内CRC32总是不一致固件长度包含尾填充、CRC计算范围不对计算CRC时严格限制在固件实际长度不含包头和填充字节回滚后仍引导到坏分区boot_confirm标志没清除在切换分区前把boot_confirm清零防止旧标志干扰判断升级到一半按键复位后变砖Bootloader不完整或标志区被覆盖升级硬件前必须验证Bootloader能独立跳转标志区地址避开App区终点6.2 踩坑记录第一个坑是跳转前的时钟初始化问题。Bootloader里用SystemInit()初始化了系统时钟跳转后App的SystemInit()会再次设置时钟。正常情况没问题但如果Bootloader和App用的主频配置不同比如Bootloader是72MHz、App是36MHz就会出现跳转后外设工作不正常的情况。解决方法是统一时钟频率或者App启动后彻底重新初始化时钟。第二个坑是串口中断和Flash擦写的冲突。一开始我把Flash擦写放在了串口中断处理函数里。串口每收满一页数据就在中断里擦写一次整整阻塞了大约几十毫秒。这个时间足够串口硬件FIFO溢出好几次导致丢帧。后来改成“中断收数据主循环写Flash”的模式才彻底解决。第三个坑比较隐蔽App里的看门狗。如果App程序里开了独立看门狗OTA写Flash期间看门狗没有被及时喂狗写了一半MCU就复位了。升级操作虽然还在继续但Bootloader可能已经收到被剪断的固件最后CRC校验失败。这个问题的处理方式是在进入升级模式之前把看门狗关掉或者保证Bootloader在OTA期间能够喂狗。6.3 可靠性的几个细节补充除了解决问题还有几个提升可靠性的细节值得加上。Flash操作期间尽量关掉电源的中断源尤其是那些有低频外部中断的模块比如RTC闹钟、外部按键中断。STM32F103的Flash控制器在擦写时如果被频繁打断虽然不一定出错但会降低操作的确定性。升级包传输时最好做断点续传虽然串口链路下实现起来麻烦但可以从帧序号下手。Bootloader记录当前已正确写入的帧号复位后从最后一帧重新开始。我实际项目里的简化解法是复位后直接放弃本次升级等待发送端重新发起完整升级。因为升级耗时也就几十秒没必要为了断点续传增加很多复杂性。最后是量产阶段的考虑。产品出货时Bootloader和App区A都要烧录工厂固件。App区B可以先留空也可以跟A区烧一样的内容。留空的做法更干净因为B区在出厂时没有有效标志Bootloader不会引导到空区。有的团队为了出厂时双备份两个区都烧入固件这样虽然也可以但要注意首次OTA时目标区的旧标志必须提前清掉。写在最后的一个经验我在最初做这套AB OTA方案时花了很多时间在“跳转代码怎么写”上后来发现跳转反而最简单真正让方案稳定落地的是那套状态机和异常恢复逻辑。你现在照着这个教程复现如果做完后测试“升级成功”和“升级失败能回滚”两条链路都跑通再去想怎么优化就已经比很多只做了升级流程的产品靠谱了。还有一个小技巧可以后续扩展在AB分区之外再规划一个1KB的出厂区存放一个最小可运行固件。日常升级就算把AB两个区都搞坏了用户按住某个按键3秒以上Bootloader就会从出厂区启动。这个区平时永远不参与OTA是彻彻底底的保命后手。做完这套你对STM32F103的资源掌控和系统可靠性设计都会有跟以前不一样的体会。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价