STM32F103 AB双分区OTA升级实现与踩坑指南
1. 整体设计思路为什么选了AB双分区而不是回滚式OTA做嵌入式固件OTA的方案其实不少单分区拷贝式、双bank回滚式、AB分区原子切换式各有各的适用场景。这次我在STM32F103上复现的是AB分区方案原因很简单一方面它能让固件升级过程具备天然的原子性另一方面它对断电、写坏、校验失败这类突发情况的容忍度非常高用完了之后你会发现“回滚”这件事不再需要依赖任何额外逻辑。很多朋友初次接触OTA第一个疑问是既然是AB分区是不是就是Bootload App_A App_B三个段就完事了思路对但细节远不止分区地址这么简单。你还需要想清楚谁来标记当前哪个区是active的谁来校验固件的完整性什么情况下允许回滚以及Bootloader里跳转App时如何避免栈指针错乱、中断向量表失效等微妙的坑。这些细节才是AB方案真正花时间的部分。1.1 核心需求拆解目标硬件、工具链与预期结果这次项目的硬件基础是最常见的STM32F103C8T6就是那块Flash只有64KB、SRAM只有20KB的单片机。很多网上的教程都拿STM32F103ZET6来做AB分区因为Flash大、好分配但在C8T6这种小容量芯片上做双分区资源规划会紧张很多也更有代表性。我建议你如果条件允许先用C8T6验证再用大容量芯片做产品化因为小容量下能跑通大容量必然是降维打击。软件环境我使用的是标准外设库V3.5STM32F10x_StdPeriph_Lib编译工具链是Keil MDK 5.30以上版本烧录器是ST-Link V2。这里我刻意没有用HAL库因为标准库的跳转逻辑更接近寄存器底层你能看到每一条代码到底改了什么对理解OTA的本质非常有帮助。当然如果你平时用的是STM32CubeMX加HAL库整个思路完全通用只是API级别的差异罢了。预期要达成的效果是Bootloader上电后检查两个App分区中的固件完整性选择有效且版本号更高的那一份运行App运行过程中收到新的固件包时将其写入当前非活跃分区写入完成后置位待升级标志并复位重启Bootloader完成切换。这一整套流程不依赖外部Flash不依赖上位机工具完全在片内Flash上自洽完成。1.2 方案选型对比AB双分区、单备份区和串口直写各自的优劣在决定AB双分区之前我也认真评估过另外两种主流方案这里把它们的差异列出来方便你判断自己的项目到底适合哪种。方案Flash占用断电安全回滚能力逻辑复杂度适用场景串口直写Bootloader最低差无最低小批量产、调试期单备份区AB备份中等中等有条件回滚中等中低风险产品AB双分区高高天然支持较高远程升级为主的产品单备份区方案的思路是App A在运行新固件先下载到备份区校验完成后一次性覆盖App A。优点是Flash占用相对小缺点是覆盖过程中一旦断电App A已经被擦除备份区又不完整板子直接变砖。AB方案的思路则是App A在运行新固件写入App B写完后只需要把“启动标志”改一下下次Bootloader直接跑B。整个过程中A始终完好无损所以永远不会出现“没得跑”的情况。这里我补充一个容易被忽略的点AB分区的原子性不是靠“写Flash”本身保证的而是靠“切换启动标志”保证的。固件写入的任意时刻断电最多只是非活跃分区内容损坏活跃分区还在系统还能启动。正因如此启动标志区的写入策略也需要单独设计不能随便放在App代码段的尾部否则App自升级的时候可能把标志位一起擦掉了。2. 分区规划与启动流程把64KB Flash安排得明明白白2.1 分区表和地址映射的确定STM32F103C8T6的Flash基地址是0x08000000总大小64KB也就是0x08000000到0x0800FFFF。我最终的分区规划如下分区名称起始地址大小用途Bootloader0x0800000016KBOTA升级入口、App选择与校验App A0x0800400022KB业务固件AApp B0x0800980022KB业务固件BFOTA_Flag0x0800F4003KB启动标志、固件信息记录你可能会问为什么Bootloader用16KB这么大其实如果你只做跳转和Flash擦写6KB就够了但一旦涉及固件下载、完整性校验、通信协议解析空间就会紧张起来。而每个App分区我用22KB而不是把剩余空间对半分是因为我预留了3KB给启动标志区这样即使将来要记录多几个版本的升级历史也不用再调整分区表。这里有一个非常关键的约束分区起始地址必须按Flash的页大小对齐。STM32F103C8T6的Flash页大小是1KB所以每个分区的起始地址都应该是1KB的整数倍。上面表格里0x08004000、0x08009800都满足这个条件。很多人第一次写Bootloader时没注意这点结果Flash擦除函数传入的地址不是页对齐的轻则擦错页重则HardFault非常坑。2.2 Bootloader启动流程详解从复位到选择App的完整链路Bootloader的启动流程我把它分成五个阶段每个阶段都有明确的职责和退出条件。第一阶段是硬件初始化。包括时钟、串口、LED指示灯、以及最关键的系统软复位原因检测。为什么要检测复位原因因为Bootloader需要区分“上电首次启动”和“App请求升级后的复位启动”这两种场景下跳转策略不同。上电启动时Bootloader需要检查两个分区的固件有效性选择最合适的那个而升级复位启动时Bootloader应该优先跳转到新写入的分区否则用户会感觉“重启后还是旧版本”。第二阶段是固件有效性检查。我用的检查策略是读取App分区头部的固件头结构体校验魔术字、固件长度、CRC32校验值。只有全部通过该分区才算valid。这里我强烈建议你加上固件头结构体不要把固件直接裸烧到分区起始地址否则你连“这个分区里到底有没有固件”都判断不了。第三阶段是版本仲裁。如果A、B两个分区都有效Bootloader会对比版本号谁高跑谁。如果只有一个分区有效就跑那个有效的。如果两个都无效Bootloader进入串口下载模式等待新固件。第四阶段是固件加载与跳转。这一步的代码非常短但每一行都值得斟酌。核心逻辑如下typedef void (*pFunction)(void); void JumpToApp(uint32_t app_addr) { uint32_t msp_value; pFunction app_entry; // 检查栈顶指针是否在SRAM范围内 msp_value *(volatile uint32_t *)app_addr; if ((msp_value 0xFFF00000) ! 0x20000000) { return; } // 关闭全局中断防止跳转过程中中断打扰 __disable_irq(); // 设置主栈指针为App的初始SP __set_MSP(msp_value); // 获取App复位向量即App起始地址4处存放的Reset_Handler地址 app_entry (pFunction)(*(volatile uint32_t *)(app_addr 4)); // 调用App的Reset_Handler app_entry(); }这段代码里有三个细节值得多说几句。第一为什么栈顶指针要检查范围因为如果这个地址根本不在SRAM区间说明该分区的内容不是合法固件跳过去必然HardFault。第二为什么跳转前要__disable_irq()因为Bootloader在运行过程中可能开了串口中断、定时器中断如果不关闭跳转到App的瞬间这些中断请求可能触发但App的中断向量表还没准备好直接跑飞。第三在跳转前要确保SysTick被关闭并且清除PendSV、SVCall等异常挂起位。很多人跳转失败就是因为SysTick还在倒计时进App没几毫秒就进HardFault了。第四阶段之后Bootloader的生命周期理论上就结束了。App开始运行后Bootloader占用的内存、外设资源全部交还。这也是AB分区方案的一个隐藏优势Bootloader理论上只负责“选人”和“换人”不干涉App的任何行为所以App端的开发体验和裸机开发几乎没有区别。2.3 中断向量表重定向跳转成功但中断全失效的解决办法如果你发现App能跑起来main函数里的点灯逻辑正常但串口中断、定时器中断怎么都不触发那大概率是中断向量表没有重定向。STM32F103默认的中断向量表固定在0x08000000也就是Flash起始地址。Bootloader在0x08000000中断向量表指向的是Bootloader的向量表。App运行后任何中断事件比如串口收到一字节都会从Bootloader的向量表里找ISR地址结果自然找不到App里定义的中断处理函数。解决办法是在App工程的启动代码里或者在main函数一开始把中断向量表的偏移设置为当前App分区的起始地址。标准库工程里最快的方式是直接修改system_stm32f10x.c文件中的VECT_TAB_OFFSET宏#define VECT_TAB_OFFSET 0x4000这个0x4000就是十六进制的16KB即App A相对于Flash基址的偏移量。如果你编译的是App B的固件这个值就要改成0x9800。有人问能不能在代码里动态写SCB-VTOR FLASH_BASE | app_base当然可以但注意STM32F103的VTOR寄存器实际有效位有限VECT_TAB_OFFSET必须满足对齐到中断向量表大小的条件也就是64字节对齐。另外如果你跑的是RTOS比如FreeRTOS还要注意中断向量表重定向要在调度器启动之前完成否则某些架构上会产生神秘的问题。3. 固件包格式与下载校验AB包到底长什么样3.1 固件头结构设计魔术字、版本号、长度与CRC校验网上搜“AB包”这个词不同领域有完全不同的含义在OTA升级语境里AB包指的是带有额外头部信息的固件包而不是单纯的二进制内容。我给每个App分区的固件设计了一个16字节的头部结构typedef struct { uint32_t magic; // 魔术字固定为0xA5A5A5A5 uint32_t version; // 固件版本号如0x00000100 1.0.0 uint32_t length; // 固件有效数据长度不包含头部 uint32_t crc32; // 有效数据段的CRC32校验值 } firmware_header_t;在Bootloader校验固件时第一步先读起始4字节判断是否等于0xA5A5A5A5。如果不等直接判定分区无效。第二步读版本号和长度第三步根据长度计算出CRC校验范围用软件CRC32算法对固件数据段做校验和头部的crc32字段对比。这里我踩过一个坑CRC32算法有标准多项式0x04C11DB7也有各种变种比如初始值不同、结果异或值不同、输入是否反转等。Bootloader、上位机打包工具、App端校验三处的CRC算法必须完全一致否则就会出现“明明固件没写错但校验就是不过”的诡异情况。我最后的做法是在打包工具和单片机端都使用同一个开源CRC32标准实现并且通过一组固定测试向量验证两边结果一致后才开始联调。固件头部结构体还有一个作用它可以告诉Bootloader这个分区里固件的真实大小。为什么要知道大小因为Flash擦写是以页为单位如果固件实际长度是18KB而分区大小是22KB完整的擦写过程需要跨页处理。有了length字段Bootloader和App都知道要擦哪些页、写多少字节不会浪费时间去写那些空白区域。3.2 打包工具的实现用一个Python脚本生成带头固件在开发阶段我写了一个Python脚本用于把Keil生成的.hex或.bin文件转换成带固件头的正式发布包。对于生产环境Keil可以直接输出.bin文件配置方法是Options for Target - User - After Build/Rebuild - Run #1填上fromelf --bin --output.\Output\app.bin .\Output\app.axf。Python脚本的核心逻辑如下import binascii import struct import sys import zlib def build_firmware_package(bin_path, version, output_path): with open(bin_path, rb) as f: firmware_data f.read() magic 0xA5A5A5A5 length len(firmware_data) crc32 zlib.crc32(firmware_data) 0xFFFFFFFF header struct.pack(IIII, magic, version, length, crc32) with open(output_path, wb) as f: f.write(header) f.write(firmware_data) print(f固件包生成成功: {output_path}) print(f固件版本: 0x{version:08X}) print(f固件长度: {length} bytes) print(fCRC32校验: 0x{crc32:08X})注意这里使用了IIII的小端模式。STM32F103是小端处理器所以头部四个字段在Flash中的存储顺序和Python打包时的字节序必须一致。如果你在打包工具里用了大端模式那么Bootloader读出来的magic就会变成0xA5A5A5A5的字节序反转直接判定分区无效。3.3 固件下载链路的搭建串口YMODEM还是HTTP拉取AB分区本身和“固件怎么来的”是解耦的。你有两种常见路径要么通过UART这类有线接口接收PC上位机推送的固件要么通过ESP8266、ESP32这类WiFi模块或者4G模块从云端HTTP服务器下载。两种我都试过给你一个参考结论。如果走串口路径推荐用YMODEM协议。YMODEM的好处是自带CRC校验和分包传输128字节或1024字节一包每一包都有序号、数据、校验非常稳定。很多Bootloader方案里都内置YMODEM接收逻辑配合PC端的SecureCRT或专用工具就能直接传文件。坏处是传输速度一般115200波特率下传一个22KB的固件大约需要10秒以上而且传输过程中如果PC端串口助手不够稳定经常出现丢包。如果走网络路径流程就更接近产品化App运行中通过ESP8266用HTTP拉取服务器上的固件包存到非活跃分区然后置位标志位重启。这是“ESP32 OTA”或“腾讯连连 Arduino OTA”等方案的常见模式只不过把主控从ESP32换成了STM32F103WiFi功能外置。你需要定义一个简单的HTTP下载服务用Nginx或者Python HTTP服务器都行提供静态文件下载即可。这里要注意STM32F103的SRAM只有20KB无法一次性把整个固件包缓存到RAM里再写Flash所以必须采用边下载边写入Flash的流式方案。每收到一个数据块比如512字节就立刻通过Flash半字编程写入到当前写入地址同时更新写入偏移。我这里贴一段ESP8266与STM32F103通过串口对接时的简化示例展示数据块如何从串口缓冲区搬运到Flash#define APP_B_PAGE_START 0x08009800 #define DATA_BUFFER_SIZE 512 uint8_t data_buffer[DATA_BUFFER_SIZE]; uint32_t target_address APP_B_PAGE_START; uint32_t byte_count 0; void OnUartDataReceived(uint8_t *data, uint16_t len) { memcpy(data_buffer[byte_count], data, len); byte_count len; if (byte_count DATA_BUFFER_SIZE) { // 写入前必须先将目标页擦除且保持目标地址对齐到页 FLASH_Unlock(); for (uint32_t i 0; i DATA_BUFFER_SIZE; i 2) { FLASH_ProgramHalfWord(target_address i, data_buffer[i] | (data_buffer[i 1] 8)); } FLASH_Lock(); target_address DATA_BUFFER_SIZE; byte_count 0; } }这只是个框架级示例实际产品代码还要处理“跨页边界时需要先擦除新页”的逻辑。FLASH_ProgramHalfWord一次只能写16位这是STM32F103硬件决定的。如果缓冲区里的数据是奇数长度最后剩余的一个字节要单独处理不能漏掉。另外整页擦除后如果只写了部分数据未写区域是0xFF这本身没问题但如果你用了CRC校验0xFF也会参与校验结果肯定对不上。所以写入完成后必须把写入区域回读逐字节比较确认没有遗漏。4. 业务App侧的适配让运行中的固件能自己升级自己4.1 App如何判断自己处于哪个分区AB方案里App运行在哪个分区不是固定的。用户第一次烧录时Bootloader可能会选择App A启动但通过OTA升级后新版本可能跑在App B上。所以App代码里必须能判断自己当前运行在哪个分区否则“下载新固件应该写到哪个分区”这个问题就无解了。判断方法很简单——直接从当前程序地址推断uint32_t GetCurrentAppBase(void) { uint32_t pc_value (uint32_t)GetCurrentAppBase; if ((pc_value 0x08004000) (pc_value 0x08009800)) { return 0x08004000; } else if ((pc_value 0x08009800) (pc_value 0x0800F400)) { return 0x08009800; } return 0x08000000; }函数指针GetCurrentAppBase的地址就是当前程序计数器附近的代码地址因为这个函数本身是在App代码段里定义的它编译后的地址一定落在当前分区范围内。当然更严谨的做法是从VTOR寄存器里读当前中断向量表偏移但上面的做法胜在简单直观。拿到当前分区后目标分区就是另一个当前是A就写到B当前是B就写到A。这里涉及到一个经验点如果固件支持远程升级那么App必须保留“当前固件头”的完整信息至少要能在下载完成后把新固件的版本号、长度、CRC值写入FOTA_Flag区这样Bootloader启动时才知道新固件是可切换的。4.2 启动标志区的设计与写入一次切换不留后门很多AB方案的Bootloader是通过判断“FOTA_Flag区的某个魔数”来决定跳到A还是B的。我把这个标志区设计成三份冗余存储避免因为Flash写入过程中断电导致标志位损坏偏移地址用途0x0800F400待启动分区编号与魔数冗余10x0800F404待启动分区编号与魔数冗余20x0800F408待启动分区编号与魔数冗余3写入时先擦除整页然后连续写入三份相同内容。读取时Bootloader逐个读取并比较至少两个相同才采用该值。这个设计的本质是用空间换可靠性在OTA场景下非常值。实际项目中Bootloader和App对FOTA_Flag区的操作必须互斥简单做法是用一个全局的擦写状态标记双方约定只有在系统复位后Bootloader才会去读标志区App运行期间不读只写。所以写标志区最安全的时机是App已经完成固件下载和校验即将调用NVIC_SystemReset()之前。4.3 App跳转Bootloader的两种方式软复位与直接跳转App在完成固件写入后需要让系统重新进入Bootloader来完成分区切换。有两种方式第一种是配置一个标志然后调用NVIC_SystemReset()软复位。STM32F103复位后代码从0x08000000开始执行自然进入Bootloader。这是最推荐的方式因为复位过程会把所有外设、中断、状态寄存器都恢复到初始状态干净利落。第二种是直接调用Bootloader跳转函数类似从Bootloader跳App那样只不过方向反过来。但这种方式你需要在App代码里链接一个固定地址的跳转函数而且跳转前必须完整地复位外设否则Bootloader可能运行在“App已经初始化过的外设环境”里产生各种不可预期问题。除非极端情况否则我不推荐。这里还要注意一个问题Bootloader怎么知道App复位是因为OTA升级还是因为看门狗复位、异常复位如果只是因为看门狗超时复位Bootloader直接跑当前有效分区就行而不应该切到另一个分区。解决办法是在备份寄存器BKP里写一个特定值App在调用NVIC_SystemReset()之前把BKP寄存器设置为OTA升级标志Bootloader上电时读取该标志判断是否需要执行“切换分区”。STM32F103的BKP寄存器在备份域里只要VBAT有电复位后数据不丢失。但要注意BKP寄存器在首次使用前需要使能PWR和BKP时钟并且BKP的写入需要先取消写保护。5. 实操过程中最典型的六个坑与排查方法5.1 跳转进App后跑飞或进HardFault这个现象非常典型主要诱因有四类。第一栈顶指针检查没做直接跳转到了一个没有固件的分区。第二中断向量表偏移没设置App中断全部失效。第三App工程的Linker配置里起始地址没改App代码被编译到0x08000000了跳过去之后代码和Bootloader重叠必跑飞。第四跳转前没关全局中断SysTick和串口中断捣乱。排查时可以先用调试器在跳转前给app_entry()下断点确认msp_value和app_entry的值是否符合预期。然后在App的Reset_Handler第一行下断点看是否真的进入了App代码。如果断点不进App说明跳转地址算错了如果进了App但在SystemInit里跑飞多半是时钟配置或向量表问题。5.2 Flash擦写时程序卡死或死机STM32F103的Flash操作有几个硬性要求写操作必须在Flash未锁定的状态下进行擦除操作必须以页为单位擦写期间CPU不能从同一块Flash区域取指执行。如果Bootloader本身在0x08000000运行却要擦除0x08004000这个页硬件允许。但如果App要擦除自己正在运行的分区也就是当前代码所在的Flash区域这就会导致CPU取指失败直接死机。这也是为什么AB方案里App只能写非活跃分区绝不能写当前分区。另外Flash擦写期间必须关闭中断。如果此时发生中断中断向量表可能在Flash里CPU去取中断向量时Flash正忙直接卡死。常规做法是FLASH_Unlock()之后、FLASH_ErasePage()之前调用__disable_irq()写完后再__enable_irq()和FLASH_Lock()。5.3 固件校验不一致但Flash数据明明是对的之前强调过多个模块之间的CRC算法必须一致。这里的坑往往出在“长度不一致”上。假设上位机打包时计算CRC的对象是整个固件数据但Bootloader校验时误把分区剩余的所有空间包括0xFF都算进去了CRC自然对不上。解决方法是统一约定CRC只覆盖固件头中的length字段指定的数据长度。另一个坑是Keil默认生成的.hex文件包含地址信息如果你直接用.hex做固件包就会把地址信息也当作固件数据打包写进Flash后内容完全错位。正确做法是先把.hex转成.bin或者直接在Keil里配置生成.bin文件。我见过有朋友用第三方工具把hex转bin时没有设置正确起始地址结果生成的bin文件前边多了一段0x00或0xFFCRC怎么都对不上排查了很久才发现是工具链的问题。5.4 升级完成重启后仍然运行旧固件最常见的原因是Bootloader的分区仲裁逻辑里没有“比较版本号”的步骤或者版本号比较写反了。比如App A版本是1.0App B版本是2.0Bootloader检查时看到A有效就直接跳了AB压根没参与比较。另一个原因是BKP标志位没写成功Bootloader读到的还是“默认启动A”于是老老实实跳了A。排查时可以在Bootloader里通过串口打印两个分区的版本号和BKP标志值一目了然。5.5 串口接收固件时丢包严重YMODEM传输时如果串口中断里处理的逻辑太重或者使用了DMA但没有处理好空闲中断很容易丢包。我建议接收缓冲区开双缓冲中断只负责把数据塞进当前缓冲区主循环或DMA传输完成中断里再做Flash写入。串口波特率方面我实测115200在YMODEM下问题不大如果丢包依旧降到57600基本能稳定传输。此外串口引脚要避免使用PA9/PA10附近有大电容、长走线的板子干扰会导致字节错误。5.6 擦写次数过多导致Flash寿命焦虑STM32F103的Flash擦写寿命标称是1万次实际上大多数芯片都远超这个数。但OTA频繁测试时一天可能擦写几十次持续几个月就是几千次了。建议开发阶段用串口直接烧录到目标分区减少不必要的擦写只有联调OTA流程时才真正走一遍擦写逻辑。另外Flash磨损均衡在这个量级的场景下先不考虑产品的正常OTA频率远达不到寿命上限。6. 踩坑之后的几点心得这套方案后续还能怎么扩展项目做到这里AB OTA的基础框架已经完整了。回头看最值得沉淀的不是某一段代码而是从“能用”到“好用”的整个思考过程。我个人在实际操作中的体会是先跑通“手动升级流程”比什么都重要。我第一个版本完全是纯手动方案Bootloader通过串口接收YMODEM固件包手动指定写入哪个分区App完全没有参与升级过程。等这套链路稳定了才把“App自动下载固件并写非活跃分区”的功能加进去。如果你一上来就写完整版代码出了问题根本不知道是哪一环导致的。关于后续扩展方向我可以提供几条思路。如果你有ESP8266或ESP32模块可以做一个简单的HTTP下载逻辑STM32F103通过AT指令让ESP8266访问Nginx服务器上的固件链接用Content-Length预知数据长度Chunked模式边收边写。如果你的产品需要支持灰度发布或者强制升级可以在FOTA_Flag区增加一个升级策略字段Bootloader根据策略决定是否强制切换分区。如果你希望在升级失败时自动回滚到上一个版本AB方案天然支持——保持旧分区不变等新分区校验通过后再切换切换后新分区运行异常时可以用看门狗触发回滚。最后一个建议给Bootloader加上版本号也就是说Bootloader自己也应该具备可升级能力。否则随着项目迭代Bootloader里的Bug会永远留在用户的设备上。在Flash规划时预留一个Bootloader冗余区用同样的AB思想管理Bootloader更新虽然前期工作量大但对量产产品来说价值很大。这个做法涉及分区表的大幅调整建议在产品定义阶段就规划好否则后期改动涉及所有已售设备的升级路径复杂度会暴涨。