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升级的核心流程

整个流程其实就四个步骤。我把它画成了一张图,你看一眼就能记住。

双Bank A/B升级与回滚流程图 当前运行 Bank A 下载新固件到 Bank B 校验 Bank B(CRC/SHA) 校验成功 → 切换启动标志 校验失败 → 保持 Bank A 成功 失败 复位 → 从 Bank B 启动 回滚:切回 Bank A 启动标志 回滚路径 说明:回滚时只需修改启动标志位,无需重新擦写Bank A的固件

流程看着简单,但有几个坑我得跟你聊聊。

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 回滚机制的实现

回滚说白了就是「把启动标志改回去」。但什么时候触发回滚?我总结了三种场景:

  1. 启动超时回滚:设备从Bank B启动后,如果5分钟内没有收到主机的「心跳确认」,Bootloader自动把启动标志切回Bank A,然后再次复位。
  2. 看门狗回滚:新固件如果跑飞了,看门狗会触发复位。Bootloader在复位后检查复位原因,如果是看门狗复位且当前是Bank B,就执行回滚。
  3. 手动回滚:用户通过按键或串口命令触发回滚。这个一般用于调试阶段。

我个人最常用的是「启动超时回滚」。实现起来很简单:

/* 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够大,我建议你直接用双备份。省心。