4、双备份架构实战:基于STM32的双Bank Flash实现A/B升级与回滚
双备份架构,圈里人常叫它A/B系统,或者乒乓升级。说白了,就是你在Flash里准备两份一模一样的固件副本。一份跑着,另一份闲着。升级的时候往闲的那份写,写完了切过去。万一新的跑不起来,还能切回旧的。
我最早接触这个方案是在一个医疗设备项目上。那会儿设备已经量产了,突然发现有个临界bug必须修。OTA升级包发出去之后,有3%的设备变砖了。你想想看,医疗设备变砖是什么概念?从那以后,我再也不敢用单分区原地升级了。双备份虽然多占一倍Flash,但换来的是一句「随便升,升挂了算我的」的底气。
4.1 双Bank Flash的硬件基础
STM32系列里,像STM32L4、STM32G4、STM32H7这些型号,内部Flash是支持双Bank模式的。所谓双Bank,就是把Flash物理上切成两个独立的区,每个区可以单独擦写、单独锁定。
我习惯把Bank A叫做「运行区」,Bank B叫做「升级区」。正常工作时CPU从Bank A取指令执行。升级时把新固件写入Bank B。校验通过后,把启动标志位一改,下次复位就从Bank B启动了。
| 特性 | 单Bank模式 | 双Bank模式 |
|---|---|---|
| Flash利用率 | 高(100%可用) | 低(50%可用) |
| 升级安全性 | 低(升级失败即变砖) | 高(可回滚) |
| 升级期间可用性 | 不可用(需停机) | 可继续运行(后台升级) |
| 回滚能力 | 无 | 有(一键回滚) |
| 实现复杂度 | 低 | 中 |
关键点:双Bank模式要求两个Bank的大小完全一致,且起始地址对齐。以STM32L476为例,1MB Flash被分成两个512KB的Bank。Bank A从0x08000000开始,Bank B从0x08080000开始。
4.2 A/B升级的核心流程
整个流程其实就四个步骤。我把它画成了一张图,你看一眼就能记住。
流程看着简单,但有几个坑我得跟你聊聊。
4.3 启动标志位的设计
启动标志位决定了CPU复位后从哪个Bank启动。我见过有人把这个标志放在Flash的某个固定地址,每次升级时去改它。这么做有个问题——如果写标志位的时候突然断电了,标志位处于一个不确定状态,设备就不知道该从哪启动了。
我曾经踩过的坑:第一次做双备份时,我把启动标志放在Bank A的最后4个字节。结果有一次升级过程中Bank A被意外擦除了,标志位也跟着没了。设备彻底变砖,只能通过SWD重新烧录。后来我改成把标志位放在Flash的Option Bytes里,或者放在一个独立的EEPROM扇区,跟固件区物理隔离。
我个人推荐两种做法:
- Option Bytes法:利用STM32的Option Bytes中的nBOOT位来切换启动Bank。这个区域有ECC保护,写入时即使断电也不会损坏原有值。
- 备份标志法:在Bank A和Bank B的末尾各存一份标志位副本。启动时先读Bank A的标志,再读Bank B的标志,取多数决。这样即使一个Bank被擦除了,另一个还能救场。
4.4 代码实现要点
下面这段代码是我在一个量产项目上提炼出来的核心逻辑。它实现了从Bank A切换到Bank B,并做了基本的防呆处理。
/* 双Bank切换核心函数 */
typedef enum {
BANK_A = 0,
BANK_B = 1
} bank_t;
/* 当前运行Bank */
bank_t current_bank;
/* 目标Bank(待切换) */
bank_t target_bank;
/* 启动标志存储地址(以Option Bytes为例) */
#define BOOT_BANK_ADDR 0x1FFF7800 /* STM32L4 Option Bytes地址 */
/**
* @brief 切换启动Bank
* @param bank: 目标Bank
* @retval 0: 成功, -1: 失败
*/
int switch_boot_bank(bank_t bank) {
uint32_t option_val;
/* 读取当前Option Bytes */
option_val = *(__IO uint32_t *)BOOT_BANK_ADDR;
if (bank == BANK_B) {
/* 设置Bank B启动 */
option_val &= ~(0x3 << 2); /* 清除nBOOT位 */
option_val |= (0x1 << 2); /* 设置nBOOT=1,从Bank B启动 */
} else {
/* 设置Bank A启动 */
option_val &= ~(0x3 << 2);
option_val |= (0x0 << 2); /* 设置nBOOT=0,从Bank A启动 */
}
/* 写入Option Bytes(需要解锁) */
HAL_FLASH_OB_Unlock();
if (HAL_FLASHEx_OBProgram(&option_val) != HAL_OK) {
HAL_FLASH_OB_Lock();
return -1;
}
HAL_FLASH_OB_Lock();
/* 验证写入结果 */
if ((*(__IO uint32_t *)BOOT_BANK_ADDR) != option_val) {
return -1; /* 写入失败,不执行复位 */
}
return 0;
}
/**
* @brief 执行升级切换
* @note 调用此函数前需确保Bank B固件已校验通过
*/
void perform_firmware_switch(void) {
if (switch_boot_bank(BANK_B) == 0) {
/* 写入成功,执行系统复位 */
__disable_irq();
NVIC_SystemReset();
} else {
/* 切换失败,保持当前Bank运行 */
error_handler("Boot bank switch failed!");
}
}
小技巧:在调用NVIC_SystemReset()之前,我习惯先往备份寄存器里写一个「正在切换」的标记。复位后Bootloader读到这个标记,就知道这次复位是主动切换,而不是异常复位。如果发现标记不对,可以触发回滚流程。
4.5 回滚机制的实现
回滚说白了就是「把启动标志改回去」。但什么时候触发回滚?我总结了三种场景:
- 启动超时回滚:设备从Bank B启动后,如果5分钟内没有收到主机的「心跳确认」,Bootloader自动把启动标志切回Bank A,然后再次复位。
- 看门狗回滚:新固件如果跑飞了,看门狗会触发复位。Bootloader在复位后检查复位原因,如果是看门狗复位且当前是Bank B,就执行回滚。
- 手动回滚:用户通过按键或串口命令触发回滚。这个一般用于调试阶段。
我个人最常用的是「启动超时回滚」。实现起来很简单:
/* Bootloader中的回滚检测 */
void check_rollback(void) {
uint32_t reset_cause = RCC->CSR;
uint32_t boot_attempt = 0;
/* 读取备份寄存器中的启动尝试次数 */
boot_attempt = RTC->BKP0R;
if ((reset_cause & RCC_CSR_WDGRSTF) || (boot_attempt > 3)) {
/* 看门狗复位 或 连续启动失败超过3次 → 执行回滚 */
if (current_bank == BANK_B) {
switch_boot_bank(BANK_A);
/* 清除备份寄存器 */
RTC->BKP0R = 0;
NVIC_SystemReset();
}
} else {
/* 正常启动,递增尝试次数 */
RTC->BKP0R = boot_attempt + 1;
}
}
/* 应用程序启动成功后,清除尝试计数 */
void app_confirm_boot_success(void) {
RTC->BKP0R = 0; /* 清零,表示启动成功 */
}
关键设计原则:回滚操作必须由Bootloader执行,而不是由应用程序执行。因为应用程序可能已经跑飞了,根本执行不到回滚代码。Bootloader是最后一道防线,它必须在任何情况下都能正常工作。
4.6 实际项目中的避坑指南
做了这么多年固件,双备份架构的坑我基本都踩过一遍。挑几个典型的说说:
- Bank大小不一致:有些芯片的双Bank要求两个Bank大小严格相等。如果你用的芯片是1MB Flash,但实际固件只有300KB,另一个Bank空着也是空着。别想着把剩余空间挪作他用,否则切换时会出问题。
- 中断向量表偏移:从Bank B启动时,必须把VTOR寄存器指向Bank B的起始地址。我见过有人忘了改这个,结果中断全跑飞了。
- Flash读保护:如果开启了Flash读保护(RDP),双Bank切换时要注意解锁顺序。有些芯片要求在切换前临时降低保护等级,切换完再恢复。
- OTA下载中断:下载新固件到Bank B的过程中如果断电了,Bank B里是一段不完整的固件。下次启动时Bootloader要能识别出Bank B无效,自动切回Bank A。
我曾经遇到的一个诡异问题:有个设备在低温环境下(-20°C)频繁触发回滚。查了两天才发现,是Flash在低温下写入时间变长,导致写启动标志位时超时了。后来我把写入超时时间从10ms改成了100ms,问题就解决了。嗯,硬件也有脾气,你得顺着它来。
4.7 双备份 vs 单备份:什么时候该用
双备份不是银弹。它多占一倍的Flash,还多了切换逻辑的复杂度。我个人的选型原则是这样的:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 消费类产品(成本敏感) | 单备份 + CRC校验 | Flash容量小,变砖了返厂换新 |
| 工业设备(远程部署) | 双备份 | 设备在偏远地区,没人去现场刷机 |
| 医疗/汽车(安全关键) | 双备份 + 硬件看门狗 | 升级失败可能导致人身安全风险 |
| IoT网关(数据重要) | 双备份 + 外部Flash备份 | 网关挂了,下面一堆传感器全断联 |
说白了,双备份是用Flash空间换可靠性。如果你的Flash够大,我建议你直接用双备份。省心。