直接说结论:这个项目做下来,最值钱的部分不是“能升级”,而是“升级失败还能回来”。基于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区各分28KB,Bootloader再占8KB,基本是极限操作,稍微加个功能就爆Flash。所以做AB OTA实验我推荐至少用256KB Flash的型号,比如STM32F103RCT6或者ZET6。虽然C8T6实际有一部分批次能读出128KB Flash,但那是非规格行为,用在项目上不可控。
2.2 CubeMX初始化配置参考
用CubeMX生成工程的时候,建议只开四个东西:
- RCC:HSE外部晶振,后续时钟树配到72MHz;
- SYS:Debug选Serial Wire,保留SWD口,否则下载一次后第二次就烧不进程序了;
- USART1:PA9为TX,PA10为RX,115200-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是48KB,ZET6是64KB,不同型号在ld文件里的RAM长度不一样,定义栈顶地址时别写死。Bootloader在跳转前要校验App首地址里存放的栈顶值是否落在RAM范围内,如果这一步只判断“高位是0x2000”而不判断具体数值,很容易放进去一个非法地址,导致App一启动就崩溃。
3. Flash分区规划与ld脚本修改
3.1 分区地址划分与计算公式
AB OTA第一个核心工程决策就是Flash怎么分。以STM32F103RCT6的256KB Flash为例,我建议这样划分:
| 分区 | 起始地址 | 大小 | 用途 |
|---|---|---|---|
| Bootloader | 0x08000000 | 32KB | 启动选择、升级接收、校验与跳转 |
| APP_A | 0x08008000 | 96KB | Slot A,默认运行区 |
| APP_B | 0x08020000 | 96KB | Slot B,升级目标区 |
| FLAG | 0x08038000 | 32KB | AB状态、版本号、候选/确认标志 |
地址计算有个通用规律:所有分区起始地址必须按“擦除页大小”对齐。F103大容量芯片Flash页是2KB,中容量是1KB,如果分区不对齐,擦除A页时会把相邻分区的数据也干掉。我在一次实验里吃过这个亏,把APP_A写在0x08007C00,结果每次擦A区前面一块数据,Bootloader的最后几页就被连带擦掉,表现像“升级几次后设备变砖”。这是必须靠计算和查表确认的。
如果芯片容量只有128KB,可以把Bootloader压到24KB(分页对齐),每个App分48KB,Flag区放最后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 自定义传输协议还是Ymodem
Bootloader接收固件可以用Ymodem协议,开发上位机很省事。但我个人更推荐自定义简单协议,尤其当你后续想对接自己的云平台、网关或4G模块时,私有帧更好扩展。我用的是这样一帧格式:
| 字段 | 大小 | 说明 |
|---|---|---|
| 帧头 | 2字节 | 固定0xAA55 |
| 命令字 | 1字节 | 握手、开始传输、数据块、结束、跳转 |
| 负载长度 | 2字节 | 小端模式 |
| 负载数据 | N | 根据命令不同含义不同 |
| CRC16 | 2字节 | 校验整个帧 |
帧长控制在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”,而是需要区分“当前有效分区”、“候选分区”、“已试运行分区”。我的状态机定义成五个状态:
| 状态值 | 枚举 | 含义 |
|---|---|---|
| 0x00 | STATE_UNKNOWN | 标志区无效或首次启动 |
| 0x01 | STATE_BOOT_A | 当前确认启动A区 |
| 0x02 | STATE_BOOT_B | 当前确认启动B区 |
| 0x03 | STATE_CANDIDATE_A | A区是新写入的候选,等待确认 |
| 0x04 | STATE_CANDIDATE_B | B区是新写入的候选,等待确认 |
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区并递增计数,超过阈值回滚A |
| CANDIDATE_A | 合法 | 合法 | 启动A区并递增计数,超过阈值回滚B |
“固件头合法”的判断除了查看首地址MSP是否落在RAM范围,还可以增加一个固件魔数,比如在App二进制偏移16字节处写一个固定值0x5AA5,Bootloader跳转前校验这个值。加魔数能挡掉不少误烧写和抓错文件的问题。
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做局域网升级,还是接云平台做远程推送,底层这套双区回滚逻辑都可以原封不动搬过去。