拓冰建站拓冰建站
首页 / 资讯中心 / 正文

STM32F103 AB双分区OTA升级完整教程:Bootloader与回滚机制详解

在做嵌入式开发这几年OTA升级一直是个绕不开的硬骨头。尤其当产品开始量产、设备已经铺到现场之后没法用ST-Link一个个去刷固件这时候如果没有一套稳定可靠的升级方案运维成本能把人压垮。我最初接触STM32F103的AB OTA是在一个工业数据采集项目上设备装在高空机柜里拆一次极其痛苦所以对“升级失败必须能自动回滚”这件事有着非常深的执念。这个项目做完之后我把整套逻辑在F103上完整复现了一遍写成了这篇教程希望能帮到正在为远程升级发愁的朋友。这篇文章会从零开始把STM32F103标准外设库V3.5上实现AB双分区OTA的完整过程讲透包括Flash分区设计、自定义Bootloader开发、串口升级协议制定、应用层改造配合、固件打包工具编写以及最关键的AB切换和回滚机制。无论你是刚接触IAP的新手还是已经做过单分区升级、想进阶到AB方案的老手这篇教程都能给你一套可以直接抄作业的落地路径。1. 项目概述为什么要在STM32F103上做AB OTA1.1 从一次惨痛的量产事故说起先讲个真实经历。我早期做一个远程抄表设备用的是经典的单分区IAP方案Bootloader 一个App区升级时直接擦掉App区写入新固件。当时觉得方案简单够用结果量产之后遇到一个有bug的固件版本部分设备远程升级后直接变砖只能派人带着烧录器到现场拆机。后来排查发现是升级包在传输过程中被干扰Flash写入了一半设备既跑不了新程序也回不到旧程序单分区方案在这种场景下就是赌运气。那次之后我下决心做AB分区方案。所谓AB就是Flash里放两份独立的应用程序镜像一份叫A区当前运行区一份叫B区备份区。升级时把新固件写入B区写完校验通过再切换启动B区万一B区跑不起来Bootloader自动回滚到A区。整个过程无论哪一步出错设备都至少有一份能用的固件彻底解决“变砖”问题。1.2 AB OTA和传统IAP的区别在哪里传统IAPIn-Application Programming是Bootloader 单App的经典结构Bootloader负责通过串口、CAN或网络接收固件写入App区后跳转执行。它的优点是实现简单缺点是容错能力差升级过程中只要断电、通信中断、写入错误原固件往往已经被擦掉了设备立刻失去可用性。AB OTA在结构上多了“双区冗余”和“启动管理”两层设计。Bootloader不再只是简单的下载器它要负责判断当前哪个区是有效的、哪个区是待升级的App也不再是写完就跑它要在运行后上报状态确认自己工作正常才把“本次启动成功”的标志记录下来。这两个改动带来的收益是很直接的升级失败可自动恢复不用人工干预支持版本回退新版本有问题可以切回旧版升级过程断电不影响当前运行区业务连续性更好1.3 这套方案适合哪些场景AB OTA在消费电子、工业控制、智能家居、物联网网关等场景都适用但并不是所有项目都有必要上。如果你的设备是开发板、验证原型、或者升级失败不会造成严重后果单分区IAP反而更省事。但如果你的设备有以下特征我建议认真考虑AB方案设备安装在难以到达的位置拆机成本高固件直接关系生产安全或核心业务不能长时间停摆需要支持远程批量升级且无法保证升级过程中的网络质量产品有版本回退的售后需求STM32F103作为一颗出货量极大的Cortex-M3芯片资源够用、资料丰富、价格低廉非常适合作为AB OTA的学习和实践平台。我在这套方案里用的就是最常见的STM32F103ZET6板载512KB Flash空间充裕分区规划起来很舒服。2. 总体设计Flash分区与升级流程2.1 为什么选STM32F103和标准外设库STM32F103虽然发布了很多年但至今仍是低成本工业产品的主流选择。72MHz主频、512KB Flash、64KB RAM跑一个带协议栈的应用程序完全够用。更关键的是它的Flash是分页结构的支持按页擦除非常适合做IAP分区管理。在软件框架上我选了标准外设库V3.5Standard Peripheral Library。有人可能会问现在HAL库不是主流吗为什么还用标准库原因是F103标准库非常轻量Flash操作、串口、GPIO这些外设接口清晰直接对Bootloader这种对执行效率和代码体积有要求的场景更友好。而且V3.5这个版本非常稳定网上资料也多出问题容易查。这个项目我完全没用CubeMX全是手写寄存器级调用反而觉得对F103的理解更深了。这里要插一句很多教程喜欢用HAL库但不是所有场景都适合。Bootloader处于整个设备的最底层一旦出错设备就废了代码尽量精简、可控、可读才是第一原则。标准库在这点上优势很明显编译出来的镜像也更小。2.2 Flash分区方案详解AB OTA的第一步是把Flash规划清楚。分区设计直接影响整个方案的可靠性和可维护性一定要在写代码之前就想明白。以STM32F103ZET6为例它的Flash从0x08000000开始总大小512KB页大小2KB规划如下分区名称起始地址大小说明Bootloader0x0800000032KB引导启动、执行升级流程App A0x08008000224KB当前运行区可切换App B0x0803C000224KB备份/升级区可切换参数区0x0807C00016KB存储启动标志、版本信息等有几点我需要特别说明。Bootloader放最前面上电后CPU从0x08000000取出栈顶指针和复位向量这是F103的硬件固定行为不能改。App A和App B大小完全一样因为两者要能互换角色如果一大一小管理起来非常麻烦。参数区放在Flash末尾用独立的几页专门存放OTA状态结构体平时App不允许乱动这部分。你可能会问为什么App区要设置224KB这么大应用工程编译出来通常只有几十KB留这么大有浪费。实际上分区大小主要考虑的是未来产品功能扩展固件只会越来越大而且大分区对Flash磨损均衡也有好处。如果Flash资源紧张比如用STM32F103C8T664KB Flash那AB方案会很吃力建议要么换大Flash型号要么只做单分区升级。2.3 AB切换与回滚机制的设计思路AB切换是整个方案的核心逻辑想清楚这个后面的代码就是体力活了。系统上电后Bootloader做的事情是读取参数区的启动标志 → 判断当前应该启动A还是B → 检查目标区固件头是否合法 → 合法就跳转不合法则启动另一个区。这里的关键是“目标区不合法则启动另一个区”这是回滚的第一层保障。第二层保障在App运行阶段。App正常启动后应该在一个“启动完成确认”的函数里把参数区的状态标记为“当前区运行正常”。如果App因为固件缺陷在初始化阶段就崩溃Bootloader会检测到一种“连续启动失败”的计数每次从同一区启动时计数加1如果App运行正常则清零。当计数超过阈值比如3次Bootloader判定这个区有问题强制切到另一个区启动。为了更严密我还在设计中加了一个硬件看门狗IWDG。App启动后必须周期性喂狗如果App卡死了看门狗会复位MCU从而触发Bootloader的失败计数逻辑。这套机制在工业现场非常管用哪怕新固件有隐藏bug导致随机死机设备最终也能自己回到稳定版本不需要人工干预。升级流程大致这样设备运行在A区 → 收到升级指令和固件镜像 → 将新固件写入B区 → 校验整个B区无误 → 更新参数区把启动目标切到B → 软复位 → Bootloader启动B区。如果B区正常标记成功如果失败下次复位自动切回A。整个过程对用户透明对运维人员来说省心太多了。3. 开发环境准备与工程配置3.1 工具链整体清单做这个项目需要的工具并不复杂但每一样都很关键硬件STM32F103ZET6开发板或最小系统板、ST-Link V2下载器、USB转TTL串口模块开发环境Keil MDK5我用的是5.36版本固件库STM32F10x标准外设库V3.5烧录工具STM32 ST-LINK Utility烧Bootloader用上位机调试助手正点原子XCOM、SSCOM或者自己写个Python脚本硬件上要提醒一句ST-Link和USB转TTL不要同时占用同一个串口调试时经常有人在这里搞混。我习惯用ST-Link下载程序用USB转TTL接USART1做OTA通信两者独立互不干扰。3.2 Bootloader工程搭建要点Bootloader工程我建议单独建一个MDK工程不要和应用工程混在一起。工程创建后有几个配置项必须仔细设置第一芯片型号选择STM32F103ZE。在MDK的Options for Target里选对芯片型号Flash大小就成了512KB后面分区地址定义都以这个为基准。第二编译输出设置。Bootloader最终要烧到Flash的最前面不需要调整起始地址因为它的链接地址默认就是0x08000000。但建议把编译优化级别设为-O2甚至-O3因为Bootloader代码越小越好留出的空间给App区。第三工程中添加标准库的必要文件。包括核心的core_cm3.c、system_stm32f10x.c、stm32f10x_flash.c、stm32f10x_gpio.c、stm32f10x_rcc.c、stm32f10x_usart.c以及对应的头文件。这里不需要加全部外设驱动用多少加多少保持代码简洁。3.3 上位机升级工具的选型OTA升级必须要有上位机配合才能把固件包发到设备里。最简单的做法是直接用串口助手手动分包发送但效率太低而且很容易出错。我建议直接用Python写一个升级脚本把固件读取、协议组包、发送、应答处理全自动化。如果你的应用场景不需要定制协议网上也有很多现成的IAP上位机工具比如FlyMcu、STC-ISP界面友好、支持常用的协议格式。但商用项目我建议还是自己写一版一方面可以完全掌控协议细节另一方面后续要扩展加密验签也方便。我自己的升级工具是从一个简单的Python脚本起步的核心就是串口收发和协议解析后面我会把打包工具的设计思路单独拿出来详细讲。4. Bootloader核心代码实现4.1 Flash擦写驱动封装Bootloader对Flash的操作是最底层的这部分写不好后面所有上层逻辑都是空中楼阁。STM32F103的Flash操作有几个特点写入必须以半字16bit为单位、擦除必须以页为单位2KB/页、操作前需要解锁、操作完成后需要等待忙标志清除。我先封装了三个基础函数void flash_unlock(void) { FLASH_Unlock(); } void flash_lock(void) { FLASH_Lock(); } void flash_erase_page(uint32_t addr) { FLASH_ErasePage(addr); FLASH_WaitForLastOperation(0xFFFFFFFF); } void flash_write_data(uint32_t addr, uint16_t *data, uint16_t len) { for (uint16_t i 0; i len; i) { FLASH_ProgramHalfWord(addr i * 2, data[i]); FLASH_WaitForLastOperation(0xFFFFFFFF); } }有几个坑必须说清楚。写入前一定要确认地址是2字节对齐的FLASH_ProgramHalfWord要求addr必须是偶数擦除和写入后要等待操作完成否则连续操作会失败每次操作Flash前要解锁操作完加锁防止应用程序误写Flash区段。我在项目里还加了一层写保护。Bootloader写完固件后会把App区设为读保护或者写保护防止应用层的异常代码误擦除Bootloader本身。这个操作在ST-Link Utility里也能做但在代码里做的好处是可以批量生产。4.2 串口升级协议设计串口协议是整个升级链路的中枢设计得好不好直接影响升级的稳定性和调试的便利性。我采用的帧格式结构简单但足够可靠字段大小说明帧头2字节固定0xAA 0x55命令字1字节帧类型定义数据长度2字节大端模式数据域N字节具体载荷CRC162字节对命令字到数据域末尾的校验命令字总共定义了6种握手0x01、擦除0x02、写入0x03、校验0x04、跳转0x05、应答0x80。每条指令都会得到应答帧应答帧里的状态字段区分成功0x00、CRC错误0x01、地址错误0x02、Flash写入失败0x03等。这里重点说一下写入命令。上位机把固件分成多个数据包每个包大小我设置为256字节。数据域里包含包序号4字节、数据内容256字节和包内CRC2字节Bootloader收到后先校验CRC再写入Flash对应位置。写入成功后回ACK上位机继续发下一包直到整个固件发送完毕。在实际传输中有一个非常关键的点串口包在传输过程中可能丢失或被干扰。所以我在协议里加了超时重传机制。上位机发送一个包后等待应答超过200ms没收到就重发连续重发3次仍然失败则终止升级。Bootloader这边如果收包不完整超过一段时间也会自动复位接收状态机等待新的帧头不会卡死。整个过程用一句话总结就是可靠传输靠的是“确认-重传”状态机是主角CRC是守门员。4.3 固件校验与启动跳转Bootloader在写完全部数据后不能直接跳转必须先对整个App区做一次完整性校验。我用的是CRC32校验上位机在固件包头部会附带整个固件的CRC32值Bootloader逐字节扫描App区重新计算一遍比对结果一致才允许跳转。启动跳转的实现非常关键代码也很短但背后的原理要理解typedef void (*pFunction)(void); void jump_to_app(uint32_t app_addr) { uint32_t stack_addr *(volatile uint32_t *)app_addr; uint32_t reset_vector *(volatile uint32_t *)(app_addr 4); pFunction jump; if ((stack_addr 0x2FFE0000) ! 0x20000000) { // 栈顶地址不在RAM范围固件头非法 return; } __disable_irq(); SysTick-CTRL 0; for (int i 0; i 8; i) { NVIC-ICER[i] 0xFFFFFFFF; NVIC-ICPR[i] 0xFFFFFFFF; } jump (pFunction)reset_vector; __set_MSP(stack_addr); jump(); }这个跳转函数里有几个细节容易被忽略。检查栈顶地址是否在RAM范围这一步能避免跳到一块空Flash或者错误地址导致HardFault跳转前关闭全局中断、关闭SysTick、清空NVIC是为了防止中断在切换过程中产生异常最后设置MSP为固件头里保存的栈顶值这是Cortex-M3的规定动作App的启动代码会立即使用这个栈顶值初始化栈。5. 应用层改造与固件打包5.1 应用工程与Bootloader的配合很多人做完Bootloader后发现App跳转过去还是有问题大部分原因出在应用工程没有做对应配置。应用工程有两个地方必须改缺一不可。第一是链接地址。在MDK的Options for Target → Target → IROM1中要把起始地址改成App区的地址。比如App A区是0x08008000那么IROM1的Start就是0x08008000Size要根据Flash分区设置。如果应用工程不修改这个地址编译出来的代码默认放在0x08000000和Bootloader冲突跳转必然失败。第二是中断向量表偏移。STM32F103的中断向量表默认在0x08000000App跑在别的地址必须把向量表重定位到App区地址。在标准库环境下可以在main函数最开始执行SCB-VTOR APP_ADDR;这里的APP_ADDR要和链接地址一致。如果你用的是Keil还需要注意system_stm32f10x.c里VECT_TAB_OFFSET定义最好直接把它改成App区的偏移值这样工程初始化时就会自动设置。应用工程改完代码不能直接在Flash里调试因为程序是从地址0x08008000之后开始跑的栈顶位置也和独立运行不一样。我建议用RAM调试模式先验证跳转逻辑再实机跑Flash。RAM调试需要额外配置分散加载文件对于OTA项目我更推荐直接烧录测试把Bootloader烧进去然后通过串口升级App这样整个链路都验证了。5.2 升级包格式设计原理固件打包在整个流程中经常被忽视但同样重要。我从Flash里导出的bin文件是纯粹的机器码没有版本信息、没有校验信息如果直接把裸bin发给Bootloader它无法验证固件的身份和完整性。所以我设计了一个简单的固件包格式在原始bin文件前面添加了一个自定义头部字段大小说明幻数4字节0xA5A5A5A5用于识别固件包版本号4字节主版本次版本例如0x00010001固件长度4字节原始bin文件的长度CRC324字节对原始bin文件的CRC32校验保留4字节预留扩展比如加签名算法标识Bootloader在擦除App区前会先检查头部幻数是否正确、长度是否在允许范围内、CRC32是否通过全部通过才执行后续操作。这个过程相当于给固件上了一份“完整保险”。如果产品对安全性有更高要求可以在头部增加签名字段用非对称加密算法对固件摘要做签名Bootloader端验签通过才允许升级。这个我在量产项目中做过能有效防止非法固件被灌入设备但会增加Bootloader的代码复杂度和Flash占用。如果设备只在内网运行、没有太强的安全诉求CRC32校验已经够用。5.3 用Python脚本一键完成打包为了方便每次编译后直接生成升级包我写了一个Python脚本核心逻辑如下import struct, zlib, sys, os def build_package(bin_path, version): with open(bin_path, rb) as f: firmware f.read() crc zlib.crc32(firmware) 0xffffffff header struct.pack(III, 0xA5A5A5A5, version, len(firmware)) header struct.pack(I, crc) header struct.pack(I, 0) with open(bin_path .ota, wb) as f: f.write(header firmware) if __name__ __main__: build_package(sys.argv[1], int(sys.argv[2], 16))用法很直接MDK编译生成bin文件后运行python build_ota.py app.bin 0x00010001就会生成一个带头的app.bin.ota文件。在MDK的后期处理器里添加这条命令每次编译完自动打包非常方便。唯一的注意事项是MDK默认编译输出的是hex文件不是bin文件。需要在Options for Target → User → After Build/Rebuild里添加fromelf命令fromelf --bin --outputapp.bin app.axf。这样编译完成后就会自动生成纯二进制文件。6. 完整联调流程与回滚验证6.1 从零到一的联调步骤联调阶段最容易翻车因为Bootloader、App、上位机任何一环出错都会表现成“设备没反应”或者“跳转失败”。我的建议是分步推进每步都要有明确的验证方法。第一步先分别验证Bootloader和App能否独立工作。把Bootloader单独烧录到0x08000000接串口看是否有通信命令响应把App单独烧录到0x08008000接复位引脚看能否正常启动。这步确保两个程序本身是编译正确的。第二步验证Bootloader跳转App。Bootloader上电后按预期跳转到AppApp串口输出启动日志说明跳转逻辑正确。如果卡死回到4.3节逐条排查栈顶地址检查、向量表偏移、外设复位、中断屏蔽。第三步验证串口OTA升级链路。先用Bootloader自带的串口命令把App A区烧录进去正常运行后通过上位机工具把新版本的固件包发到设备观察擦除、写入、校验、跳转每一步是否正常。第四步模拟断点和异常。在升级过程中按复位键、断电验证设备是否还能从另一个分区正常启动。这一步是AB OTA最核心的验证要做多次。我实测下来前两步半天能完成第三步一到两个小时第四步需要反复跑场景测试建议留出半天时间。6.2 如何验证AB切换和回滚是否有效验证AB切换的正确性我有一套自己的测试流程。初始状态A区运行的是版本1B区运行的是版本2。通过上位机下发升级指令把版本3写入B区此时A区还是版本1然后手动复位设备。正常情况下Bootloader应该跳转到B区的版本3。如果版本3在启动后故意设置一个指令导致死机比如无限循环但不喂狗IWDG复位后Bootloader应该检测到B区启动失败自动切回A区。整个流程如果在日志中都能看到明确的“跳转A区/跳转B区”记录说明AB切换和回滚都正常。为了便于观察这个过程我在Bootloader里把跳转动作通过串口打印了出来每次上电都会输出当前有效区编号、目标区编号、跳转状态。这个日志在量产时是关掉的调试时非常实用。同样的信息也可以通过一个LED灯的状态来表示比如B区启动时LED常亮、A区启动时LED闪烁用最简单的硬件就能实时监控。需要留意的是回滚触发条件设置得越严格越安全但也可能导致误判。比如App因为网络波动临时无法连接服务器不代表固件有问题如果这时候就回滚反而造成不必要的版本变化。因此我设计的成功标志不是“App活着”而是“App完成网络注册、业务自检通过”这样回滚只在真正影响功能时才触发。6.3 实测数据参考这套方案在我项目里已经跑了半年多我记录了一组有参考价值的实测数据项目数值固件大小128KB擦除时间每页约40ms128KB共64页写入时间115200bps下256B/包约18秒校验时间CRC32扫描128KB约60ms总升级耗时约25秒含握手、擦除、写入、校验校验成功率正常网络环境下99.5%以上我第一次做OTA时升级耗时60多秒后来优化了串口包大小、增大了数据帧长度、减少了应答等待时间才降到25秒左右。如果使用更快的波特率如460800或921600写入时间可以进一步缩短但对线材质量要求会提升。工业场景我建议保持115200稳定性优先。7. 常见问题与排查技巧实录7.1 跳转失败或卡死的系统级排查这个问题出现的频率最高症状是Bootloader看起来执行了跳转但App就是不跑或者跑一下卡死。我的排查顺序是固定的首先检查栈顶地址。在跳转函数里用条件判断拦住非法地址是第一步但很多人在调试阶段没有加这个判断导致跳到非法地址后HardFault。在MDK的调试模式里看寄存器MSP的值是不是落在RAM范围内0x20000000~0x20010000如果MSP指向了Flash地址说明从固件头读出来的栈顶值不对最常见的原因是App区地址配置错了。其次检查向量表偏移。如果App已经启动但一进中断就死机八成是向量表没有重定向。把SCB-VTOR设置成App区地址这行代码要确保在main最开始、任何外设初始化之前执行。在F103上还有个容易踩的坑如果使用标准库的system_stm32f10x.c有些版本里VTOR宏定义没有开启需要手工在system_stm32f10x.c里设置VECT_TAB_OFFSET宏。7.2 Flash操作相关异常在升级过程中Flash擦写是最容易出问题的环节我把实际遇到的异常情况整理成了下面这张表现象可能原因解决方案FLASH_ErasePage超时Flash忙标志未清停电或上电时序导致在擦除前加FLASH_WaitForLastOperationFLASH_ProgramHalfWord写入不对地址没有2字节对齐检查地址都是偶数地址写入内容全为0xFF写操作前没有擦除先擦除再写入写入内容错误但CRC正确写入地址和上位机规划的地址不一致核对App区起始地址定义跳转后App区读保护报错Flash设置了读保护RDP用ST-Link Utility解除读保护或检查option byte顺便提醒一个和工具相关的点ST-Link在某些情况下会把Flash的读保护打开导致代码里无法访问Flash。如果出现这种现象先用ST-Link Utility把option byte里的RDP等级设为Level 0再试。7.3 协议层与上位机的常见坑协议联调时遇到的问题经常不在MCU端而在上位机和传输链路。我整理几个典型问题第一串口波特率不匹配。Bootloader用115200如果上位机设成9600或者57600协议握手永远不成功。这个检查优先级最高。第二CRC16的初值和多项式不一致。MCU端用的CRC16-MODBUS初值0xFFFF、多项式0x8005和上位机Python里用binascii.crc_hqx或pycrc计算出来的结果经常不一样。解决方式是两边用同一个库或者在文档里把CRC参数写明。我在项目里干脆自己在MCU端实现了一个CRC16查表法上位机Python也按同样的表计算彻底统一。第三串口缓冲区溢出。Bootloader如果是在中断里逐字节收到数据存进环形缓冲接收处理速度跟不上发送速度就会丢包。我建议Bootloader接收用环形缓冲区FIFO主循环里统一处理不要在使用串口中断里做Flash擦除这种耗时操作。7.4 其他实战建议和避坑经验最后说几个零散但很实用的经验。第一个是关于软件复位的。跳转到App之前我建议先执行NVIC_SystemReset()让整个MCU以干净的状态启动App。有些Bootloader直接跳转外设状态残留会导致App初始化异常软复位能规避很多奇怪问题。第二个是升级日志的重要性。Bootloader阶段没有操作系统、没有文件系统所有调试信息基本靠串口打印。但要注意正式发布时Bootloader的调试串口打印一定要关掉否则每次启动都打印一长串内容可能干扰外接设备的通信。我一般用一个宏控制比如#define BOOT_LOG_ENABLE 1发版时改成0重新编译。第三个是升级过程中给设备加一个“升级中请勿断电”的指示。音频、LED、或者LCD提示都行。不要小看这个现场人员不知道你在升级觉得设备卡了就断电再好的AB机制也扛不住升级过程中掉电。第四个是OTA参数校验不能省。参数区里不仅要存启动标志还要存一份参数区本身的CRC校验值每次读写参数区时都校验一下。Flash在长期使用中可能出现位翻转如果启动标志位错了轻则启动错误分区重则整个设备无法启动。这套AB OTA方案做完之后我最大的感受是OTA本身不难难在设计一套“出错也能兜回来”的机制。AB分区只是基础回滚逻辑、启动计数、参数校验、通信可靠性这些细节才决定了方案在真实环境中站不站得住。如果你正在为设备做远程升级我建议一定把回滚机制放在首位而不是先把下载流程跑通就收工。设备出厂后我们永远不知道现场会发生什么尽量让系统自己具备容错能力才是对用户负责也是对自己少添麻烦。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门