STM32双Bank切换实现OTA零停机:基于Flash双区映射的细节与陷阱

一、为什么需要双Bank?

传统OTA方案通常采用A/B分区:运行在A区,升级写入B区,完成后通过软件跳转或复位切换。但复位期间存在数百毫秒停机,且若切换逻辑出错,设备可能变砖。STM32的双Bank特性(如STM32H743、L4R9、G474)在硬件层面提供两个独立的Flash Bank(Bank1和Bank2),每个Bank可独立擦写,且支持硬件Bank切换——通过修改选项字节或寄存器,让CPU从当前Bank无缝映射到另一个Bank的同一地址,无需复位,实现真正的零停机升级。

二、双Bank映射原理

2.1 物理与逻辑地址

以STM32H743为例,Flash总容量2MB,分为Bank1(0x08000000-0x080FFFFF)和Bank2(0x08100000-0x081FFFFF)。默认情况下,CPU从0x08000000启动,即Bank1。当启用双Bank模式并配置为Bank切换后,硬件会将Bank2映射到0x08000000,而Bank1映射到0x08100000。这样,应用程序无需修改链接地址,只需切换Bank即可运行新固件。

2.2 切换机制

切换通过修改Flash选项字节中的DBANK位和SWAP_BANK位实现。DBANK决定Flash是单Bank还是双Bank模式,SWAP_BANK决定是否交换Bank映射。切换操作需要解锁Flash,写入选项字节,然后触发系统复位(或使用特殊寄存器实现软切换)。但注意:复位会中断执行,因此真正的零停机需要利用STM32的实时Bank切换特性(部分系列支持),通过FLASH_CR寄存器的SWAP_BANK位,在无复位情况下切换映射。

三、配置步骤

3.1 硬件与软件准备

  • 硬件:STM32H743 Nucleo板(或任何双Bank系列)
  • 软件:STM32CubeIDE + HAL库
  • 固件:两个测试固件(v1.0和v2.0),编译时链接地址均为0x08000000,但分别烧录到Bank1和Bank2。

3.2 启用双Bank模式

首次烧录前,需通过选项字节启用双Bank。使用STM32CubeProgrammer或代码设置:

void Flash_Enable_DualBank(void) {
    FLASH_OBProgramInitTypeDef ob;
    HAL_FLASH_Unlock();
    HAL_FLASH_OB_Unlock();
    ob.OptionType = OPTIONBYTE_BANK;
    ob.Banks = FLASH_BANK_1; // 配置Bank1选项
    ob.BankConfig = FLASH_BANK_BOTH; // 双Bank模式
    HAL_FLASH_OB_Program(&ob);
    HAL_FLASH_OB_Launch(); // 触发选项字节加载,会复位
    HAL_FLASH_OB_Lock();
    HAL_FLASH_Lock();
}

注意:此操作会复位,因此应在首次烧录时执行。

3.3 固件升级流程

假设当前运行在Bank1(地址0x08000000),新固件已写入Bank2(地址0x08100000)。升级流程如下:

  1. 接收固件并写入Bank2:通过UART/网络接收数据,直接写入Bank2地址。
  2. 验证固件:计算CRC或哈希,确保数据完整。
  3. 执行Bank切换:设置SWAP_BANK位,实现映射交换。
void Jump_To_Bank2(void) {
    // 确保Bank2固件有效(检查栈顶地址)
    uint32_t stack_addr = *(volatile uint32_t *)0x08100000;
    if ((stack_addr & 0xFFF00000) != 0x20000000) return; // 栈顶应在RAM区

    // 解锁Flash
    HAL_FLASH_Unlock();
    // 设置SWAP_BANK位(以H7为例)
    FLASH->CR |= FLASH_CR_SWAP_BANK;
    // 等待操作完成
    while (FLASH->SR & FLASH_SR_BSY);
    // 注意:H7需要设置SWAP_BANK_IE?实际无需复位,但需确保没有正在执行的代码在Bank1中。
    // 切换后,PC指针仍指向旧地址,但映射已交换,因此需要跳转到新固件入口。
    // 实际上,切换后0x08000000指向Bank2,因此直接软复位或跳转即可。
    NVIC_SystemReset(); // 为简化,使用复位,但会停机。若需零停机,见下文。
}

3.4 实现真正的零停机切换

零停机要求切换期间CPU不中断。STM32H7的实时切换需要满足:

  • 代码必须运行在RAM中(因为切换时Flash映射变化,若代码在Flash中执行会取指失败)。
  • 切换函数需放在RAM中执行。
// 将切换函数放到RAM中
__attribute__((section(".ramfunc"))) void Bank_Switch_ZeroDowntime(void) {
    // 关闭中断
    __disable_irq();
    // 设置SWAP_BANK位
    FLASH->CR |= FLASH_CR_SWAP_BANK;
    while (FLASH->SR & FLASH_SR_BSY);
    // 切换后,0x08000000映射到Bank2,但PC仍指向旧地址(现在对应Bank2的相同偏移)。
    // 因此,需要跳转到新固件的复位向量。
    uint32_t jump_addr = *(volatile uint32_t *)(0x08000004); // 新复位向量
    // 设置主栈指针
    __set_MSP(*(volatile uint32_t *)0x08000000);
    // 跳转
    void (*jump)(void) = (void (*)(void))jump_addr;
    jump();
    // 不会返回
}

注意:此代码需在RAM中执行,且关闭中断,避免切换过程中断。

四、完整代码示例

以下是一个简化但完整的双Bank升级示例,包含写入、验证和切换。

// main.c
#include "stm32h7xx_hal.h"
#include <string.h>

#define BANK2_BASE 0x08100000
#define APP_SIZE   0x100000 // 1MB,根据实际调整

void Flash_Write_Bank2(uint8_t *data, uint32_t len) {
    HAL_FLASH_Unlock();
    uint32_t addr = BANK2_BASE;
    for (uint32_t i = 0; i < len; i += 8) {
        uint64_t val = 0;
        memcpy(&val, data + i, 8);
        HAL_FLASH_Program(FLASH_TYPEPROGRAM_DOUBLEWORD, addr + i, val);
    }
    HAL_FLASH_Lock();
}

uint32_t Calc_CRC(uint8_t *data, uint32_t len) {
    // 使用硬件CRC或软件实现
    return 0; // 占位
}

void OTA_Upgrade(void) {
    // 假设已通过通信接收固件到RAM缓冲区
    extern uint8_t new_fw[];
    extern uint32_t new_fw_len;

    // 擦除Bank2(需先擦除再写)
    HAL_FLASH_Unlock();
    FLASH_EraseInitTypeDef erase;
    erase.TypeErase = FLASH_TYPEERASE_SECTORS;
    erase.Banks = FLASH_BANK_2;
    erase.Sector = 0;
    erase.NbSectors = 8; // 根据Bank2扇区数
    erase.VoltageRange = FLASH_VOLTAGE_RANGE_3;
    uint32_t error;
    HAL_FLASHEx_Erase(&erase, &error);
    HAL_FLASH_Lock();

    // 写入新固件
    Flash_Write_Bank2(new_fw, new_fw_len);

    // 验证CRC
    if (Calc_CRC((uint8_t*)BANK2_BASE, new_fw_len) != expected_crc) {
        Error_Handler();
    }

    // 切换Bank(零停机)
    Bank_Switch_ZeroDowntime();
}

// 链接脚本中需将Bank_Switch_ZeroDowntime放到RAM

五、关键陷阱与注意事项

5.1 地址重映射与中断向量

  • 切换后,新固件的中断向量表必须位于0x08000000(因为映射后Bank2在此地址)。因此,新固件编译时需设置VECT_TAB_OFFSET=0,且向量表首地址为0x08000000。
  • 若使用RTOS或自定义向量表,需在启动时重新设置SCB->VTOR

5.2 擦写时序与双Bank并发

  • 双Bank模式下,两个Bank可以独立擦写,但不能同时擦写同一Bank的不同扇区
  • 擦除操作会阻塞CPU,若在实时系统中,需考虑调度。建议在空闲时间或使用DMA进行擦写(部分系列支持)。

5.3 切换时的代码执行位置

  • 零停机切换要求切换代码在RAM中执行,否则切换瞬间Flash映射变化会导致取指失败。
  • 确保切换函数及其调用的所有函数(如HAL库函数)都在RAM中,或使用__attribute__((ramfunc))

5.4 选项字节与复位行为

  • 修改选项字节后,必须调用HAL_FLASH_OB_Launch(),这会触发一次系统复位。若不想复位,可使用FLASH_CRSWAP_BANK位(仅部分系列支持)。
  • 某些系列(如F4)不支持实时切换,只能通过选项字节+复位,此时无法零停机。

5.5 固件有效性验证

  • 切换前必须验证新固件的栈顶指针和复位向量是否合法,否则跳转会HardFault。
  • 建议在Bank2的头部存储固件版本、CRC、长度等信息,便于管理。

5.6 电源与时钟稳定性

  • 擦写Flash时,若电源波动可能导致烧写失败。建议使用内部稳压器或确保电源稳定。
  • 切换期间关闭所有外设中断,避免干扰。

六、总结

双Bank切换技术为OTA升级提供了硬件级支持,通过合理设计可实现零停机更新。但需注意:并非所有STM32系列都支持实时切换,需查阅参考手册;切换代码必须驻留RAM;固件验证不可省略。掌握这些细节,你的设备将具备更可靠的远程升级能力。