1. 项目概述:为什么我决定给F103做AB OTA
做嵌入式这几年,OTA升级一直是个绕不开的话题。STM32F103的OTA方案网上一搜一大把,但大部分都是“Bootloader + APP单分区”的玩法:Bootloader里收固件,写进Flash,校验完直接跳转,做完就完事。这种方案看起来简单,真正用到产品上就暴露问题了,最典型的就是升级到一半断电、固件刷坏了、新版本有bug跑不起来,设备直接变砖,只能返厂或者拿串口线去救。我这次要复现的就是一个能真正用在产品上的方案:STM32F103 + AB双分区 + OTA远程升级。标题写的是“AB_OTA”,核心思路是给固件准备两个独立运行槽位,平时在A槽跑,新固件下载到B槽,校验通过后切换启动槽位,新固件万一跑不起来,Bootloader能自动回滚到A槽。这套机制在Android和不少车规ECU上已经是非常成熟的做法,放到STM32F103上一样适用,只是受限于Flash容量和引脚资源,要做不少裁剪和取舍。
这篇文章适合谁看?正在给STM32F103项目加OTA功能、被单分区升级搞怕了的人,或者刚接触AB分区概念、想找一个完整可复现教程的嵌入式开发者。内容覆盖分区规划、Bootloader核心逻辑、APP端中断向量偏移、远程下载流程、回滚机制、问题排查,照着做就能把一个带AB OTA的工程跑起来。
先说清楚一个前提。AB双分区对Flash容量有硬性要求,我个人建议起步用STM32F103RCT6(256KB Flash),C8T6只有64KB Flash,做完Bootloader加两个槽位基本就没什么余量了。如果你手上只有C8T6,也不是完全没救,后面会讲一种“单运行区+暂存区”的变通做法,但体验和安全性跟真正的双分区还是有差距。
1.1 传统OTA方案最大的问题
传统的单分区OTA,一般把Flash分成Bootloader区和APP区。APP区里跑的固件要升级,流程通常是:Bootloader通过串口、WiFi或者其他接口接收新固件,一边收一边往APP区写,写完做一次CRC校验,校验通过就跳转执行。问题在于,整个升级过程是在“破坏旧固件”的基础上进行的,新固件还没完全写进去,旧的就已经被擦掉一部分了。这时候只要断电、通信超时、校验失败,APP区就是一个半成品状态,Bootloader跳过去也跑不起来,设备就卡死在启动阶段。
有人说单分区也能加“恢复模式”,用按键强制进Bootloader重新刷,但这对无人值守的远程设备来说没有意义。设备装在几十公里外的配电箱里、农业大棚里、或者用户家里,不可能派人跑过去按键刷机。更头疼的是,如果升级包本身有问题,能正常收下来、校验也通过、Bootloader也跳过去了,结果固件跑几秒就崩溃,设备又变砖了,这种场景单分区是完全没有容错能力的。
我最早做OTA的时候也被这个问题坑过。给一批设备远程升级,固件包没测透,升级完设备集体起不来,最后只能一个个拆机接串口线救。从那以后我就明白一件事:OTA方案绝对不能只考虑“怎么把新固件写进去”,必须把“新固件挂了怎么办”这个兜底问题一并设计进去,AB分区就是冲着这个需求来的。
1.2 AB双分区方案到底好在哪
AB分区的设计思路很直白:把APP区域拆成SlotA和SlotB两个独立槽位,每个槽位都能独立启动。系统平时在SlotA运行,升级的时候把新固件下载到SlotB,而不是直接覆盖当前正在运行的程序。下载完成并校验通过后,App或者用户主动发起“切换请求”,复位进入Bootloader,Bootloader检查SlotB的固件有效性,确认没问题就把启动位置切到SlotB。
这里有个很关键的设计点:固件下载到SlotB的过程中,SlotA一直保持完好,即使下载到一半断电,下次上电Bootloader检测到SlotB不完整,直接忽略它,继续从SlotA启动,设备完全不受影响。新固件在SlotB跑起来之后,也不是立刻就“生效”的,而是要在规定时间内向系统上报“确认信号”,证明自己真的能正常工作。如果新固件起不来、或者起来了但是没上报确认,Bootloader会在尝试次数耗尽后自动切回SlotA,实现回滚。
一套成熟的AB机制,在Bootloader层面做三件事就够了:判断哪个槽位有效、管理切换状态、控制尝试次数。业务层面的“下载新固件到后备槽位”反而可以完全放在App里做,不需要Bootloader懂网络协议、不用懂文件系统、不用懂差分包,这就让Bootloader异常精简,越简单的代码越不容易出错,这个道理做嵌入式的应该都懂。
2. 内存规划:F103这种小Flash也能塞下双分区
AB分区在PC和手机上非常标准,Android系统的A/B分区甚至还包括system、vendor、boot好几个分区。STM32F103的Flash一共才几十到几百KB,肯定不能照搬Android那套,必须按F103的资源特性重新规划。做之前先把F103的Flash操作特性搞清楚,不然后面写驱动的时候会踩一堆坑。
2.1 先认清F103的Flash资源
STM32F103不同型号的Flash容量差别很大,这里涉及两个关键点:总容量和页大小。中容量型号比如C8T6、RBT6,Flash是64KB到128KB,每页1KB;大容量型号比如RCT6、VCT6、ZET6,Flash是256KB到512KB,每页2KB。擦除操作是按页来的,调用标准库的FLASH_ErasePage时传的是页起始地址,地址必须按页对齐,不是按字节对齐,这个细节后面写代码的时候非常重要。
页大小直接影响整页擦除的时间,F103擦除一页大概需要20到40毫秒,写一个半字(16位)大概几十微秒。如果固件有60KB,用半字方式写入,光写数据就得几十毫秒不止,再加上擦除时间,整个升级过程会持续几秒甚至更久。规划看门狗超时时间、设计数据传输帧的时候,这些时间参数都要算进去。
Flash擦写寿命也是要做产品必须考虑的点。F103的Flash标称擦写寿命是1万次,对OTA场景来说,每次升级会擦写目标槽位的若干个页,一天升级一次也能用很多年,所以寿命本身不是瓶颈。真正的风险在于频繁擦写同一个标志位页,后面讲标志位存储的时候我会专门说这个问题。
2.2 我的分区表和固件头部设计
我这次选F103RCT6做样板,256KB Flash,规划如下:
| 区域 | 起始地址 | 大小 | 用途 |
|---|---|---|---|
| Bootloader | 0x08000000 | 32KB | 启动管理、切换逻辑、本地串口升级兜底 |
| SlotA | 0x08008000 | 64KB | 出厂固件槽位A,默认运行 |
| SlotB | 0x08018000 | 64KB | 升级槽位B,下载目标 |
| Flag区 | 0x08028000 | 2KB(1页) | 存放启动标志、尝试次数等 |
| 保留区 | 0x08028800 | 剩余空间 | 留给日志、参数存储或备用 |
这里留了32KB给Bootloader,实际代码一般只有8到12KB,多留空间纯粹是为了以后扩展方便,比如加命令行工具、加串口XModem协议、加更多诊断功能。两个槽位各64KB,编译完的App如果超过64KB,需要压缩代码体积或者改用更大Flash的型号。CRC32校验和固件头部会占掉每个槽位开头的一部分空间,所以实际能用的固件空间是64KB减去头部大小,我习惯留足余量,固件控制在56KB以内比较稳妥。
每个槽位开头放一个结构化的固件头部,Bootloader靠它判断这个槽位是否有效:
typedef struct { uint32_t magic; // 固定魔数 0x53544D41,即 "STMA" uint16_t version; // 固件版本号 uint16_t slot_id; // 目标槽位:0表示SlotA,1表示SlotB uint32_t app_size; // 固件数据长度,不含头部 uint32_t crc32; // 对app_size长度数据的CRC32校验值 uint32_t reserved; // 预留,可填0 } fw_header_t;烧录固件的时候,把编译出来的bin文件头部加上这24个字节再写入对应槽位。Bootloader启动时先读magic,不是魔数直接视为槽位无效;再检查app_size是否在合理范围内,避免读到垃圾数据后做超长CRC计算;最后对整个固件数据区做CRC32,和头部存的CRC32比对,全通过才认为是有效固件。
2.3 标志位存储与防丢策略
AB切换的核心状态都放在Flag区,包括当前活跃槽位是A还是B、有没有待切换的槽位、当前固件尝试启动了多少次、确认标志是否已经上报。这些标志变量必须掉电不丢失,所以只能存Flash。但F103的Flash不能像EEPROM那样按字节改,写入前必须先整页擦除,哪怕只改一个字节也要把整页擦了重写。
最简单可靠的做法是:单独划分一个页专门存标志结构体,每次修改都执行“擦除整页 -> 写入完整结构体”这两个步骤。OTA场景下,一次完整升级流程中标志位的写入次数很少,整个生命周期也就几十次上百次,单独占一页完全不会磨损Flash。
再多说一句防丢策略。标志区里我放了双份结构体,写入时写两份,读取时如果第一份CRC校验不过就读第二份,两份都坏了才判定为非法状态并执行默认启动逻辑。实际测试中极少出现两份同时损坏的情况,但对无人值守设备来说,这种冗余能多一道保险。标志位结构体定义大致如下:
typedef struct { uint32_t magic; // 0xA5A5A5A5 uint8_t active_slot; // 0: A槽,1: B槽 uint8_t pending_slot; // 0xFF表示无待切换槽位 uint8_t boot_attempts; // 当前槽位尝试启动次数 uint8_t confirmed; // 1表示新固件已确认正常 uint32_t crc32; // 对整个结构体的CRC32 } boot_flag_t;这种设计把运行时的状态管理全部收拢到Bootloader内部,App不需要关心Flag区怎么组织,只要调用统一的接口请求切换和上报确认就行。
3. Bootloader实现:启动切换和回滚是核心
Bootloader是AB OTA里最关键的软件模块,代码量不大,但逻辑必须非常严谨。写Bootloader的时候心里要有一个优先级:不管Flag区里是什么状态,最终都要保证设备能启动一个“已知有效”的固件,如果两个槽位都不可用,至少能进入一个可以本地刷机的维护模式,而不是死循环。
3.1 上电判断流程
Bootloader的main函数思路很清晰,大概分四步。第一步做必要的硬件初始化,包括时钟、串口、LED、按键检测,这个阶段尽量少初始化外设,够用就行。第二步读Flag区的boot_flag_t结构体,根据magic和CRC判断标志是否有效。第三步执行槽位选择逻辑,看当前有没有pending切换请求,如果有就校验目标槽位,校验通过则切换、校验失败则清除pending继续用原槽位。第四步根据确认状态和尝试次数决定启动哪个槽位,如果当前激活槽位连续启动失败超过阈值,就切到另一个槽位。
槽位选择逻辑用代码表示会更直观:
int select_boot_slot(void) { boot_flag_t flags; read_boot_flags(&flags); // 有pending切换请求 if (flags.pending_slot != 0xFF) { if (slot_valid(flags.pending_slot) == 0) { // 目标槽位校验失败,清pending,继续用原槽位 flags.pending_slot = 0xFF; save_boot_flags(&flags); } else { // 校验通过,切换当前激活槽位 flags.active_slot = flags.pending_slot; flags.pending_slot = 0xFF; flags.boot_attempts = 0; flags.confirmed = 0; save_boot_flags(&flags); return flags.active_slot; } } // 当前槽位有效则直接启动 if (slot_valid(flags.active_slot) == 0) { return flags.active_slot; } // 当前槽位无效或尝试次数耗尽,回滚到另一个槽位 int other = 1 - flags.active_slot; if (slot_valid(other) == 0) { flags.active_slot = other; flags.boot_attempts = 0; flags.confirmed = 0; save_boot_flags(&flags); return other; } // 两个槽位都不可用,进入维护模式等待本地刷机 enter_maintenance_mode(); return -1; }这里面尝试次数是回滚的关键。新槽位切换过来后boot_attempts从0开始,每次启动Bootloader都会把它加1,App正常运行后又会把confirmed置1,Bootloader看到confirmed为1就不再递增尝试次数。如果新固件有问题,每次上电跑几秒就复位,Bootloader每次都会发现confirmed还是0并且boot_attempts已经超过阈值(我一般设3次),于是判定这个固件“不合格”,自动切回旧槽位。
3.2 跳转APP的代码细节
跳转是Bootloader里最容易被忽视的环节。很多人写跳转就是两行代码,把栈指针和PC设一下就行,实际跑起来各种诡异问题。跳转前必须处理干净中断和外设状态,否则APP初始化的时候很容易出问题。
完整的跳转函数如下:
typedef void (*pFunction)(void); void jump_to_app(uint32_t app_addr) { uint32_t app_sp = *(volatile uint32_t *)app_addr; uint32_t app_pc = *(volatile uint32_t *)(app_addr + 4); // 检查栈指针是否在SRAM范围内,防止跳到非法地址 if ((app_sp & 0xFFF00000) != 0x20000000) { return; } // 跳转前关闭全局中断 __disable_irq(); // 反初始化Bootloader用到的外设 USART_DeInit(DEBUG_UART); I2C_DeInit(I2C1); SPI_DeInit(SPI1); TIM_DeInit(TIM2); SysTick->CTRL = 0; SysTick->LOAD = 0; SysTick->VAL = 0; // 设置中断向量表偏移 SCB->VTOR = app_addr; pFunction jump = (pFunction)app_pc; __set_MSP(app_sp); jump(); }注意几个细节。第一,取栈指针和复位向量要在跳转前完成,不是跳过去之后再从内存读。第二,栈指针的地址要落在SRAM区间里,F103的SRAM一般是0x20000000到0x2000FFFF,超出了就说明槽位里根本没有合法固件头。第三,Bootloader里所有用过的外设都要反初始化,尤其是串口,不关的话APP初始化串口时会发现寄存器状态不对,或者串口中断里突然蹦出一个Bootloader时期残留的数据。第四,SCB->VTOR要设置成APP的实际起始地址,这样APP里的中断向量才能被正确找到。中断向量表偏移的问题在APP端还有配套设置,后面专门讲。
跳转方式还有个小选择:通过设置MSP直接跳,还是通过修改VTOR后软复位启动。在标准AB方案里我推荐“直接跳转”,因为软复位会让Bootloader重新跑一遍启动判断,虽然也能进APP,但如果App确认标志还没写,这个复位过程可能多消耗一次尝试次数。直接跳转干净利落,省去Bootloader重新初始化的开销。
3.3 固件校验和有效性检查
槽位有效性检查函数slot_valid是Bootloader里调用最频繁的函数,它的实现直接决定系统容错能力。检查流程分三段:先读槽位起始的fw_header_t,检查magic和app_size合法性,再对槽位内app_size长度的数据算一遍CRC32,跟头部里的crc32字段比对。只有三个条件全部满足,才认为这个槽位可以启动。
CRC32算法使用查表法实现,F103的主频72MHz,跑几十KB数据的CRC计算大概只需要几十毫秒,完全可以接受。代码实现如下:
static const uint32_t crc32_table[256] = { /* 标准CRC32查表,生成多项式0xEDB88320 */ }; uint32_t crc32_calc(uint32_t crc, const uint8_t *buf, uint32_t len) { crc = ~crc; for (uint32_t i = 0; i < len; i++) { crc = (crc >> 8) ^ crc32_table[(crc ^ buf[i]) & 0xFF]; } return ~crc; } int slot_valid(uint8_t slot_id) { uint32_t base = (slot_id == 0) ? SLOT_A_ADDR : SLOT_B_ADDR; fw_header_t hdr; memcpy(&hdr, (uint8_t *)base, sizeof(hdr)); if (hdr.magic != 0x53544D41) return -1; if (hdr.app_size == 0 || hdr.app_size > (SLOT_SIZE - sizeof(hdr))) return -1; uint32_t calc_crc = crc32_calc(0, (uint8_t *)(base + sizeof(hdr)), hdr.app_size); return (calc_crc == hdr.crc32) ? 0 : -1; }这里要注意F103的Flash在CPU上是可以直接寻址的,所以直接用指针读取Flash内容完全没问题,不需要专门的读取API。Bootloader里能不用官方库的Flash读函数就不用,直接内存映射访问反而更简单高效。
Bootloader本身还预留了一个本地刷机维护模式,通过按键或者串口命令触发。维护模式里实现一个最简单的YModem接收协议,把PC上的bin文件下载到非活跃槽位,接收完成后做一次校验,校验通过就置pending切换标志并复位。这个功能平时用不太到,但真的遇到设备OTA挂了、主动上报心跳消失的时候,这个本地通道就是最后的救命稻草,不用开壳接JTAG就能恢复。
4. APP端改造:中断向量偏移和升级触发
Bootloader做得再好,APP端不配合也跑不起来。APP改造涉及两个核心点:一是编译配置要改成“可被Bootloader跳转”的模式,也就是中断向量表偏移;二是App内部要实现固件下载、写入非活跃槽位、请求切换、上报确认这一整套流程。
4.1 Keil链接脚本和VTOR设置
正常情况下F103的固件链接地址是0x08000000,中断向量表也在0x08000000。现在SlotA从0x08008000开始,SlotB从0x08018000开始,APP固件的链接地址就必须改到对应槽位的起始地址,中断向量表也跟着偏移。
我用的Keil MDK5,修改方式有两种。一种是在Options for Target -> Target页签里把IROM1的Start改成0x08008000,Size改成0x10000;另一种是直接改sct分散加载文件。第一种最常用。注意RAM地址不需要动,F103的SRAM还是从0x20000000开始,只是Flash地址变了。
链接地址改完还有一个极其关键的配置:system_stm32f10x.c文件里的VECT_TAB_OFFSET宏。默认是0x00000000,APP运行在SlotA时必须改成0x8000,运行在SlotB时改成0x18000。代码简化如下:
#define VECT_TAB_OFFSET 0x8000 // 根据当前槽位修改系统初始化函数SystemInit中会执行SCB->VTOR = FLASH_BASE | VECT_TAB_OFFSET,这样中断向量表就被重定位到槽位起始地址。这里有个很坑的点:同一个APP工程,烧到SlotA和SlotB需要的VECT_TAB_OFFSET不一样,如果你用一份代码打算两个槽位都能跑,不能硬编码这个偏移量。
我推荐的解决办法是:APP启动的最早期动态读取当前运行地址,用宏定义判断。因为Cortex-M3内核里PC寄存器最开始就在某个地址段,通过__get_MSP可以间接拿到启动位置。更直接的是在入口汇编或者SystemInit之前定义:
extern uint32_t __Vectors; uint32_t running_addr = (uint32_t)&__Vectors; // 链接时指定的向量表地址如果链接地址就是0x08008000,那VTOR直接用链接脚本里的__Vectors地址即可,不用关心它在哪个槽位。SystemInit里改成:
SCB->VTOR = (uint32_t)&__Vectors;这样不管链接到哪个地址都能自动适配,一份代码同时支持SlotA和SlotB,省去频繁改宏的麻烦。
4.2 下载通道与写入逻辑
固件下载通道我做了两条。第一条是串口通道,适合开发调试和产线灌装,通过一个自定义的简单协议收bin文件,每包256字节,带帧头、帧序号、CRC16。第二条才是真正意义上的OTA远程通道,App里通过ESP8266模块连接HTTP服务器,GET请求下载bin文件到内存缓冲区,然后逐段写入非活跃槽位。
写入非活跃槽位时,Flash操作必须遵守F103的硬件规则。写入前先FLASH_Unlock解锁,然后按页擦除目标槽位,之后用FLASH_ProgramHalfWord按16位写入数据,写完上锁。这里最容易犯的错误是有人直接用ProgramWord写32位,回报错“Operation error”,因为F103的Flash控制器只支持半字编程,这是芯片硬件决定的,没有参数可以改。
写的时候要自己维护写入偏移,目标槽位起始地址是固定的,固件头部占24字节要预留出来,App编译出来的纯bin数据从头部后面开始写,写完后在槽位起始处填好fw_header_t结构体,再把整份bin的数据区和头部里的CRC32计算出来填上,最后才是一个有效的槽位。写入流程的伪代码:
int download_firmware_to_slot(uint8_t slot, const uint8_t *fw_data, uint32_t fw_len, uint16_t version) { uint32_t slot_base = (slot == 0) ? SLOT_A_ADDR : SLOT_B_ADDR; uint32_t data_base = slot_base + sizeof(fw_header_t); uint32_t offset = 0; FLASH_Unlock(); // 按页擦除 while (data_base + offset < slot_base + SLOT_SIZE) { FLASH_ErasePage(FLASH_Addr_Page(data_base + offset)); offset += FLASH_PAGE_SIZE; } // 半字写入固件数据 offset = 0; while (offset < fw_len) { uint16_t halfword = fw_data[offset] | ((uint16_t)fw_data[offset + 1] << 8); FLASH_ProgramHalfWord(data_base + offset, halfword); offset += 2; } // 写固件头部 fw_header_t hdr; hdr.magic = 0x53544D41; hdr.version = version; hdr.slot_id = slot; hdr.app_size = fw_len; hdr.crc32 = crc32_calc(0, fw_data, fw_len); hdr.reserved = 0; write_flash_bytes(slot_base, (uint8_t *)&hdr, sizeof(hdr)); FLASH_Lock(); return 0; }关于页大小,前面说过不同型号不一样,这里的FLASH_PAGE_SIZE需要按芯片型号定义,RCT6是2KB,C8T6是1KB。擦页地址也要按页对齐,如果目标槽位起始地址或者固件数据起始地址不满足页对齐条件,擦除后写入时会有部分区域被误擦,最好的办法是槽位起始地址和头部大小都设计成页对齐的倍数。
4.3 激活确认与回滚计数
固件下载完成、校验通过后,App接下来要调用请求切换接口,把“待切换槽位”写入Flag区,然后软复位。这段逻辑可以封装成独立模块,比如fota.c:
void fota_request_switch(uint8_t target_slot) { boot_flag_t flags; read_boot_flags(&flags); flags.pending_slot = target_slot; flags.confirmed = 0; flags.boot_attempts = 0; save_boot_flags(&flags); NVIC_SystemReset(); }复位后Bootloader检测到pending_slot是有效的,就会把active_slot切到目标槽位,启动新固件。这时候新固件处于“试用期”,必须主动上报确认。我在App里用一个状态机处理:系统初始化完成后启动一个10秒的确认窗口,这个窗口内业务模块如果完成了关键初始化(比如联网成功、采集模块自检通过、心跳正常上报),就调用fota_confirm_current(),把confirmed置1。如果确认窗口超时没有确认,Bootloader在下一次启动时会累加尝试次数,超过3次直接回滚到另一个槽位。
这对应到一个工程上的核心认知:新固件“能启动”不等于“正常”,AB机制的确认环节就是要把这种业务层面的健康状态反馈给Bootloader,让系统自己判断是否需要回滚,而不是靠人在现场看。
5. 实测与排查:从板子到手到远程升级跑通
方案设计完,最终要落到板子上跑。这一节我记录一下完整的实测过程和踩坑经历,包含本地串口链路测试、WiFi远程链路测试、故障注入测试,以及我整理的一版排查手册。
5.1 测试流程实录
硬件准备:STM32F103RCT6最小系统板,一个USB转串口模块,一个ESP8266串口WiFi模块,ST-Link下载器。软件准备:Keil MDK5,标准外设库V3.5,一个nginx静态文件服务器用来存放bin升级包,串口调试助手。
第一步,先烧Bootloader,Bootloader编译后通过ST-Link烧写到0x08000000。烧完打开串口,按复位键,串口打印Bootloader版本和当前状态,正常情况下会提示“SlotA invalid”,因为SlotA还是空的。
第二步,通过Bootloader维护模式把v1.0固件下载到SlotA。我在Bootloader里做了个简单命令:串口发送“1”进入YModem接收模式,然后用SecureCRT的YModem发送APP bin文件。发送完后Bootloader自动校验并启动SlotA,串口打印“SlotA valid, jump to app addr 0x08008000”,屏幕开始输出App的日志,说明基础链路通了。
第三步,测试本地串口升级链路。修改App代码,把版本号从v1.0改成v2.0,编译生成新bin,通过串口把bin发送给App。App收到后写入SlotB,写入完成后控制台打印“download ok, crc match, request switch”。复位后Bootloader打印“pending slot B valid, switch active to B”,然后v2.0固件跑起来并在10秒内上报确认,串口显示“confirmed ok, boot_attempts reset”。
第四步,测试远程OTA。把v2.0的bin放到nginx服务器目录下,通过串口给App下发一条AT指令,让ESP8266连接家里的WiFi,然后App发起HTTP GET请求拉取bin,下载过程中我直接拔掉路由器电源模拟网络中断。重新上电后,设备正常从SlotA的v1.0启动,日志显示“download incomplete, SlotB invalid, keep boot from A”。这个测试非常关键,验证了AB最核心的优势:下载失败不影响当前固件。
第五步,故障注入测试。故意编译一个v3.0固件,在main函数开头加一个无限循环不初始化外设不确认,或者说加一个故意触发HardFault的代码。把v3.0OTA上去,Bootloader切到SlotB,设备跑起来后没确认,我连续手动复位3次,第4次上电Bootloader检测到boot_attempts超过3,直接把active_slot换回SlotA,设备恢复正常。这个回滚测试通过,AB机制才算真正闭环。
5.2 常见问题排查速查表
实测中踩了不少坑,整理成表格,按出现的概率和破坏力排了一下:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 跳转到APP后系统跑飞或进HardFault | 中断向量表偏移没设置、跳转前没关中断、外设未反初始化 | 检查SCB->VTOR是否等于槽位起始地址;跳转前加__disable_irq;跳转前调用外设DeInit |
| APP能跑但串口打印乱码 | 跳转前Bootloader串口没有DeInit,串口配置被污染 | 跳转前调用USART_DeInit并复位对应GPIO |
| 烧录时Keil报错“No Algorithm found” | 烧录地址超出芯片Flash范围或Flash算法配置错误 | 检查IROM1地址范围和ST-Link烧录算法选择,确保和型号匹配 |
| Flash写入报错或写不进 | 没调用FLASH_Unlock、写32位数据、页地址没对齐 | 确认按半字写,确认擦页地址按页对齐 |
| OTA下载完成后校验失败 | 下载过程中串口丢包、帧数据没拼接完整、bin文件本身被转换工具改坏 | 用STM32 ST-LINK Utility读出槽位内容,拿bin工具对比,检查CRC算法一致性 |
| 新固件起来了但很快回到旧固件 | Bootloader尝试次数耗尽,新固件没有及时上报confirmed | 检查App确认逻辑是否在预期时间内执行,把确认窗口调大再看 |
| 下载过程中断电,恢复后设备频繁重启 | 非活跃槽位留下残包,Bootloader每次启动都会校验并尝试清理 | 检查Bootloader逻辑:pending不为空时如果校验失败应直接清pending并启动原槽位,不要反复尝试 |
5.3 一些只有踩过坑才懂的细节
第一,OTA升级用的bin文件生成方式。Keil默认编译输出axf,需要配置生成bin。我习惯在Keil的User页签After Build/Rebuild里加一行命令:
fromelf.exe --bin --output=.\obj\app.bin .\obj\app.axf生成bin之前记得在Linker页签取消勾选Use Memory Layout from Target Dialog,必要时手动调整分散加载文件,否则有时候生成的bin大小和链接地址对不上。还有一个容易踩的坑:如果代码里用了C库的printf等浮点功能,生成的bin可能会包含大量C库初始化代码,实际固件大小比map文件里预估的更大,编译后一定要看app.bin的字节数,超过槽位容量就麻烦了。
第二,App和Bootloader共用一个串口打印时,要注意跳转前的状态清理。我在Bootloader里用USART1打印,跳转后App也用USART1,如果不做DeInit,App初始化串口时可能会发现USART1的CR1寄存器还开着TX中断,导致串口刚初始化就进中断,莫名其妙跑飞。这类问题调试起来极其痛苦,串口状态又看不到,排查了一个下午才发现是Bootloader没关干净。
第三,CRC算法必须全局统一。Bootloader和App里用的CRC32计算函数必须完全一致,包括初值、多项式、输出是否取反。我见过有项目在Bootloader里用标准CRC32,在App里用简化版CRC32,校验永远对不上,排查到最后发现是两个函数算出来的结果不一样。做方案的第一步就是把CRC算法固定下来,两边共用一套源码,不要各写各的。
第四,App上传确认的时机要设计好。如果确认窗口太短,新固件还没来得及初始化完就被判失败回滚;如果确认过于随意,比如开机就确认,那AB的回滚机制就形同虚设。我的经验是:把确认点放在“对外通信成功”这个业务节点上,比如设备联网成功后、或者心跳上报成功后,确认窗口至少给10秒。这样新固件真正能对外服务了,才被认为“合格”。
第五,开发阶段强烈建议留一个直接从Flash启动的旁路。我平时调试App,不想每次都从Bootloader跳转,就会在Keil里临时把链接地址改回0x08000000,通过ST-Link直接调试。这样App本身的断点、单步都方便,不用考虑Bootloader的影响。等App功能稳定了,再切回0x08008000做联合测试。这个方法帮我节省了大量联调时间。
AB OTA这套机制在F103上跑通之后,迁移到其他M3/M4芯片上非常容易,核心逻辑不变,只需要调整Flash起始地址、页大小、链接脚本这些硬件相关的参数。我现在手上几个新项目,不管是用GD32F103还是STM32F407,基本都是把这套Bootloader直接搬过去改改地址就能用。如果你正准备给自己的F103项目上OTA,我建议不要一开始就同时搞WiFi、加密、差分升级这些花活,先把最基础的AB双分区和回滚机制跑通,这个骨架立住了,后面想加什么功能都从容得多。