Skip to content

LV022-MCU无BankSwap落地方案 ​

一、 硬件约束与分区规划 ​

1. STM32F103ZET6 资源盘点 ​

资源参数对 OTA 的影响
内部 Flash512 KB,单 Bank空间够分两槽,但没有硬件 Bank 切换
Flash 页大小2 KB擦除以页为单位,参数区正好各占一页
SRAM64 KB直接 RAM 运行需要 App 极小,多数场景不可行
内核Cortex-M3支持 SCB->VTOR,可重定位向量表,这是双槽方案的前提
通信接口无网络外设需外挂 ESP8266 / 4G 模组,走 UART

结论:可以做真 A/B,但需要软件方案绕开"单 Bank + 地址绑定"这两个限制。

2. 分区表与链接脚本 ​

下面这张分区表对应本节的方案一(双槽),其余方案的布局差异在第二节各自给出。

STM32F103 双槽分区与升级写入路径

text
起始地址      大小      分区            用途
──────────────────────────────────────────────────────────────
0x08000000   16 KB     Bootloader      选槽、校验、跳转、恢复通道
0x08004000   240 KB    Slot A          应用固件 A(链接地址 A)
0x08040000   240 KB    Slot B          应用固件 B(链接地址 B)
0x0807C000   2 KB      参数副本 1       槽位状态 boot_ctrl_t,双副本之一
0x0807C800   2 KB      参数副本 2       槽位状态副本,防掉电
0x0807D000   2 KB      断点记录页       下载断点,追加式磨损均衡
0x0807D800   10 KB     预留            未来扩展或升级日志
0x08080000   —         Flash 结束      512 KB 边界

末尾三块存储为什么这样分(槽位状态用双副本、断点记录用追加式、两者必须分页隔离)的依据见 LV016-选槽与回滚机制 第二节。

App 的链接脚本(Keil scatter / GCC ld)关键片段:

text
/* app_stm32f103.ld —— App 链接脚本(Slot A 变体) */
MEMORY
{
  FLASH (rx)  : ORIGIN = 0x08004000, LENGTH = 240K   /* Slot B 变体改为 0x08040000 */
  RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 64K
}

SECTIONS
{
  .isr_vector : { KEEP(*(.isr_vector)) } > FLASH
  .text       : { *(.text*) }            > FLASH
  .rodata     : { *(.rodata*) }          > FLASH
  .data       : { *(.data*) }            > RAM AT> FLASH
  .bss        : { *(.bss*) }             > RAM
}

【注意】

参数区必须占各自独立的页。若两个副本放在同一页,一次擦除会同时损毁两份,双副本机制形同虚设。

3. 向量表重定位(VTOR) ​

Cortex-M3 的向量表默认固定在 0x00000000(映射到 Flash 起始),双槽方案必须在 App 启动的第一时间把它指到自己的槽位:

c
// system_stm32f1xx.c —— App 启动早期设置向量表偏移
#define APP_BASE_ADDR   0x08004000u        /* Slot B 变体改为 0x08040000 */

void app_relocate_vector_table(void)
{
    SCB->VTOR = APP_BASE_ADDR;             /* 必须在使用任何中断之前 */
}

设置 VTOR 的时机在 main() 的最前面(早于外设初始化与中断使能),否则中断会跳到旧向量表,取到错误的中断入口。

二、 四种落地方案 ​

第一节的两个限制(单 Bank、地址绑定)有多种绕法,下面四种覆盖了绝大多数场景。取舍对比见本篇第四节,那里还会给出第五条路:不自研,直接复用开源 Bootloader。

1. 方案一:双槽 + 编译两个地址变体 ​

1.1 原理与构建方式 ​

Flash 上放两份完整体,但App 的链接地址是固定的:链接在 0x08004000 的镜像放到 0x08040000 运行必然失败——向量表里的入口地址、绝对寻址的常量池都指向原地址。

因此这个方案要求构建系统产出两份镜像:

text
构建产物
├── app_slotA.bin   链接地址 0x08004000(运行在 A 槽时使用)
└── app_slotB.bin   链接地址 0x08040000(运行在 B 槽时使用)

在构建脚本里把链接地址作为参数传入,一次构建出两个 bin:

bash
# build_app.sh —— 同一份源码,两个链接地址
SRC="src/*.c"
CFLAGS="-mcpu=cortex-m3 -mthumb -Os -ffunction-sections -fdata-sections"

for slot in A B; do
  if [ "$slot" = "A" ]; then ADDR=0x08004000; else ADDR=0x08040000; fi
  arm-none-eabi-gcc $CFLAGS -Wl,--defsym=APP_BASE=$ADDR \
      -T app_stm32f103.ld -o "build/app_slot$slot.elf" $SRC
  arm-none-eabi-objcopy -O binary "build/app_slot$slot.elf" "build/app_slot$slot.bin"
done

OTA 时的选择规则很简单:

  • 当前运行在 A 槽 → 下发 app_slotB.bin 写入 B 槽
  • 当前运行在 B 槽 → 下发 app_slotA.bin 写入 A 槽

服务器侧可以在一个升级包里同时携带两个变体,由设备按当前槽位自取;也可以让设备上报槽位,服务器下发对应变体。

1.2 代价与适用场景 ​

  • 代价:构建流程与版本管理复杂度翻倍(同一个逻辑版本对应两个二进制、两个哈希、两份签名);升级包若同时带两个变体则体积翻倍
  • 收益:真正的 A/B 双活——旧固件全程不被破坏,回滚天然安全
  • 适用:App 体积超过 Flash 的 40%,或 RAM 放不下整个 App 时,这是首选方案

2. 方案二:单 App 区 + 暂存复制 ​

2.1 流程 ​

如果 App 太大(例如接近 400 KB),Flash 不够分两份,可以只保留一个 App 区,升级包先落到暂存区,再由 Bootloader 复制过去:

text
┌───────────┬────────────────────┬──────────────────┐
│Bootloader │  App 区(唯一)      │  暂存区(新固件)   │
│  16 KB    │  240 KB            │   240 KB         │
└───────────┴────────────────────┴──────────────────┘
                    ▲  复制覆盖  ◀──┘

流程:

(1)App 下载新固件到暂存区,校验 CRC 与签名

(2)置参数区标志"下次启动执行复制",重启

(3)Bootloader 校验暂存区镜像有效后,擦除 App 区并逐页复制

(4)复制完成并回读校验,更新参数区为"从 App 区启动",跳转

2.2 掉电风险与兜底 ​

这个方案的核心缺陷是:复制一开始,旧固件就没了。复制中途掉电 → App 区残缺、暂存区可能也残缺 → 设备变砖。

必须做的兜底:

(1)复制前先擦暂存区之外不动:复制是"从暂存区读一页 → 写 App 区一页",暂存区全程只读,因此暂存区里的新固件始终完整

(2)复制失败可重来:Bootloader 每次上电检查参数区状态,若处于"复制中"则重新执行复制(暂存区完整即可重来)

(3)复制完成前不跳转:状态机保证只有 COPY_DONE 才跳转 App

(4)恢复通道:若暂存区也损坏(例如写暂存区时掉电且校验失败),Bootloader 停在恢复模式(UART 等待上位机重发固件)

即便有这些兜底,方案二仍然没有版本回滚能力——旧版本在复制开始时就已被擦除。它换来的只是 Flash 空间的节省。

3. 方案三:外挂 SPI Flash ​

在方案二的基础上外挂一颗 SPI NOR(W25Q64 / W25Q128 成本仅几元),把"暂存"和"备份"挪到外部,内部 Flash 全部留给 App:

text
内部 Flash:┌───────────┬──────────────────────────────┐
            │Bootloader │      App 区(约 490 KB)      │
            └───────────┴──────────────────────────────┘
外部 Flash:┌──────────────┬──────────────┬───────────┐
            │ 新固件暂存区   │  旧固件备份区  │  日志/参数  │
            └──────────────┴──────────────┴───────────┘

升级流程:下载到外部 Flash → 校验 → 备份当前 App 到外部 Flash → 烧写新固件到内部 App 区 → 启动失败则从备份恢复。

  • 优点:内部 Flash 利用率最高;恢复与外挂器件成本低;回滚能力可以补回来
  • 缺点:SPI 读写比内部 Flash 慢(复制 400 KB 需数秒);SPI Flash 有坏块与寿命问题,需要基本校验
  • 适用:App 大于 240 KB、且希望保留回滚能力的场景,是多数量产项目的折中首选

4. 方案四:拷贝到 RAM 运行 ​

如果 App 足够小、RAM 装得下,可以让固件只在 Flash 里存储,运行时整体拷贝到 RAM 执行,从而把"存储地址"与"运行地址"解耦:

  • 单份镜像即可 A/B(镜像只链接到固定的 RAM 地址)
  • 回滚天然安全(旧槽不被触碰)
  • 代价:64 KB RAM 要装下代码 + data + bss + 栈堆,实际可用不到 50 KB;每次上电多一步拷贝
  • 适用:功能简单的小固件;RAM 更大的型号(STM32F4/F7/H7)上可用性显著提升

F103 的 64 KB RAM 让这个方案在它自己身上基本不可行,它的真正适用对象是 RAM 充裕的型号。XIP 与 RAM 运行的完整对比见 LV004-升级方案维度与选型 第三节。

三、 Bootloader 完整实现 ​

1. 工程结构与内存布局 ​

text
bootloader/
├── main.c             主流程:选槽、校验、跳转(实现在 LV016 第三节)
├── boot_ctrl.c/.h     启动参数区双副本读写(实现在 LV016 第二节)
├── flash_if.c/.h      片内 Flash 擦写封装
├── image_check.c/.h   镜像 CRC 与签名校验(验签实现在 LV019)
└── recovery.c/.h      恢复模式(UART 等待重刷,协议见 LV013)

本节只给 F103 独有的部分:Flash 寄存器级擦写、跳转前的中断清理、槽位自识别、CRC32 实现与 App 改造清单。选槽决策、参数区双副本、回滚状态机这些与芯片无关的逻辑统一在 LV016-选槽与回滚机制,不在这里重复。

Bootloader 自身的链接地址为 0x08000000,大小控制在 16 KB 以内(不含签名验签库时通常 8 KB 左右;引入 mbedTLS 验签后建议预留 32 KB 并把 Slot 起点后移)。

2. 片内 Flash 擦写封装 ​

c
// flash_if.c —— STM32F103 片内 Flash 擦写(寄存器级实现,不依赖 HAL)
#include "flash_if.h"
#include "stm32f10x.h"

#define FLASH_KEY1   0x45670123u
#define FLASH_KEY2   0xCDEF89ABu
#define FLASH_PAGE_SZ 2048u
#define FLASH_BASE_ADDR 0x08000000u

static void flash_wait_ready(void)
{
    while (FLASH->SR & FLASH_SR_BSY) {
        /* 等待操作结束 */
    }
}

int flash_if_unlock(void)
{
    if (FLASH->CR & FLASH_CR_LOCK) {
        FLASH->KEYR = FLASH_KEY1;
        FLASH->KEYR = FLASH_KEY2;
    }
    return (FLASH->CR & FLASH_CR_LOCK) ? -1 : 0;
}

void flash_if_lock(void)
{
    FLASH->CR |= FLASH_CR_LOCK;
}

/* 擦除一页(2 KB) */
int flash_if_erase_page(uint32_t addr)
{
    flash_wait_ready();
    FLASH->CR |= FLASH_CR_PER;
    FLASH->AR  = addr;
    FLASH->CR |= FLASH_CR_STRT;
    flash_wait_ready();

    int ret = (FLASH->SR & (FLASH_SR_WRPRTERR | FLASH_SR_PGERR)) ? -1 : 0;
    FLASH->SR |= FLASH_SR_EOP | FLASH_SR_WRPRTERR | FLASH_SR_PGERR;  /* 清标志 */
    FLASH->CR &= ~FLASH_CR_PER;
    return ret;
}

/* 写入一段数据(必须半字对齐,长度偶数) */
int flash_if_write(uint32_t addr, const void *data, uint32_t len)
{
    const uint16_t *p = (const uint16_t *)data;
    flash_wait_ready();
    FLASH->CR |= FLASH_CR_PG;

    for (uint32_t i = 0; i < len / 2u; i++) {
        *(volatile uint16_t *)(addr + i * 2u) = p[i];
        flash_wait_ready();
        if (FLASH->SR & (FLASH_SR_WRPRTERR | FLASH_SR_PGERR)) {
            FLASH->SR |= FLASH_SR_WRPRTERR | FLASH_SR_PGERR;
            FLASH->CR &= ~FLASH_CR_PG;
            return -1;
        }
    }
    FLASH->SR |= FLASH_SR_EOP;
    FLASH->CR &= ~FLASH_CR_PG;
    return 0;
}

/* 擦除并写入,带读回校验 */
int flash_if_write_verified(uint32_t addr, const void *data, uint32_t len)
{
    uint32_t page_start = addr & ~(FLASH_PAGE_SZ - 1u);
    uint32_t end        = addr + len;
    int ret;

    for (uint32_t p = page_start; p < end; p += FLASH_PAGE_SZ) {
        ret = flash_if_erase_page(p);
        if (ret != 0) return ret;
    }
    ret = flash_if_write(addr, data, len);
    if (ret != 0) return ret;

    return (memcmp((const void *)addr, data, len) == 0) ? 0 : -2;
}

【注意】

片内 Flash 擦写期间 CPU 从 Flash 取指会被总线阻塞,STM32F1 手册要求擦写流程(尤其是中断服务)不能位于正在被擦写的 Bank。Bootloader 的这段代码自身位于 0x08000000 区,擦写 App 区不受影响;若在 App 内擦写自身所在区,必须把这段代码搬到 RAM 执行。

3. 跳转前的硬件清理 ​

选槽决策与主流程的权威实现在 LV016-选槽与回滚机制 第三节,那里给出的是与芯片无关的骨架。本节只补 F103 上必须做的硬件清理——Bootloader 自己开过的 SysTick 和外设中断不会跟着跳转消失,App 起来后会立刻收到一堆来历不明的中断:

c
// jump.c —— F103 版跳转:清理 Bootloader 遗留的中断状态
#define NVIC_ICER_COUNT  8u     /* F103 有 240 个可屏蔽中断,8 组寄存器 */

void jump_to_app(uint32_t addr)
{
    uint32_t msp   = *(volatile uint32_t *)(addr);
    uint32_t reset = *(volatile uint32_t *)(addr + 4u);

    __disable_irq();
    SysTick->CTRL = 0;                              /* 关掉 Bootloader 的滴答 */
    for (uint32_t i = 0; i < NVIC_ICER_COUNT; i++) {
        NVIC->ICER[i] = 0xFFFFFFFFu;                /* 关外设中断使能 */
        NVIC->ICPR[i] = 0xFFFFFFFFu;                /* 清挂起标志 */
    }
    SCB->VTOR = addr;                               /* 向量表指向目标槽 */
    __set_MSP(msp);
    ((void (*)(void))reset)();
}

【注意】

ICER 关的是使能,ICPR 清的是挂起。只关使能不清挂起,App 一旦重新使能同一中断,之前积压的 pending 会立刻触发一次;反之只清挂起不关使能,Bootloader 里已经 enable 的中断在 App 初始化完成前就可能进它的向量表。两者必须都做。

4. App 端的槽位自识别 ​

App 侧"申请试用"与"确认转正"的两个函数实现在 LV016-选槽与回滚机制 第四节。在 F103 上调用 app_confirm_running(self_slot) 时,self_slot 不需要编译期写死——单变体方案下 A、B 两槽跑的是同一份镜像,写死就把回滚能力废了,正确做法是用 VTOR 反查自己在哪个槽:

c
// app_slot.c —— 运行时自识别槽位,A/B 两槽可共用同一份镜像
uint8_t app_self_slot(void)
{
    return (SCB->VTOR >= SLOT_B_ADDR) ? SLOT_B : SLOT_A;
}

这个判断同时是双变体方案的保险:即便构建时链接错了地址,VTOR 反映的仍是真实运行位置。

5. CRC32 校验的完整实现 ​

前面的代码里 crc32_calc 与 image_crc32 是外部依赖,这里给出完整实现。CRC32 采用与以太网 / PNG / zlib 相同的多项式 0xEDB88320:

c
// crc32.c —— 查表法 CRC32(256 字节表,速度快、占用小)
#include <stdint.h>
#include <stddef.h>

static uint32_t crc_table[256];
static int      table_ready = 0;

static void crc32_init_table(void)
{
    for (uint32_t i = 0; i < 256; i++) {
        uint32_t c = i;
        for (int k = 0; k < 8; k++) {
            c = (c & 1u) ? (0xEDB88320u ^ (c >> 1)) : (c >> 1);
        }
        crc_table[i] = c;
    }
    table_ready = 1;
}

uint32_t crc32_calc(const void *data, uint32_t len)
{
    if (!table_ready) crc32_init_table();

    const uint8_t *p = (const uint8_t *)data;
    uint32_t crc = 0xFFFFFFFFu;
    for (uint32_t i = 0; i < len; i++) {
        crc = crc_table[(crc ^ p[i]) & 0xFFu] ^ (crc >> 8);
    }
    return crc ^ 0xFFFFFFFFu;
}

/* 对一段 Flash 区域计算 CRC32(供 image_check 使用) */
uint32_t image_crc32(uint32_t addr, uint32_t size)
{
    return crc32_calc((const void *)addr, size);
}

查表法把每个字节的计算降到一次查表加两次移位,240 KB 镜像在 72 MHz 的 F103 上约几十毫秒,完全够用。若要省掉 1 KB 表,可用按位计算版本(慢约 8 倍),Bootloader 场景也能接受。

6. App 端改造清单 ​

让现有 App 支持 OTA,除了实现升级代理,还有几处容易遗漏的"配套改造":

(1)链接地址与 VTOR:链接脚本改为从 Slot 起始地址开始;main() 第一行设置 SCB->VTOR,早于一切中断使能

(2)中断向量表拷贝:F103 的 VTOR 设置后,中断直接走新表,不要再依赖启动文件里的默认重定位;若使用 RTOS,确认其系统节拍(SysTick)中断也在新表内

(3)看门狗接入:参考 LV016-选槽与回滚机制 的看门狗策略,试用期"先不自检喂狗、自检后再正常喂狗"

(4)Flash 写保护:确认没有代码在正常运行时意外写 App 区;参数区写入用 LV016 的双副本接口

(5)版本号可上报:App 内置可读版本号(字符串或数值),OTA 查询时能准确返回当前版本

(6)低功耗场景:写 Flash 时如果用了睡眠模式,要确保 Flash 操作期间不休眠,否则写入会失败

(7)回滚后的兼容性:若新固件修改了 EEPROM / 参数布局,回滚后旧固件必须仍能读旧格式,必要时做双向迁移

四、 方案对比与选型建议 ​

1. 四种方案对比 ​

方案单份镜像回滚能力掉电安全空间开销推荐场景
双槽 + 两个变体否强强2× AppApp ≤ 240 KB,追求可靠性
单 App 区 + 暂存复制是无弱(需兜底)App + 暂存App 接近 400 KB 且无外部存储
双槽 + RAM 运行是强强Flash 2× / RAM 1×App 很小、RAM 充裕
外挂 SPI Flash是中~强中(取决于实现)内部 App + 外部器件App 大且要兼顾回滚

2. 第五条路:不自研,复用开源 Bootloader ​

前四种都在解决"自研 Bootloader 怎么布局",但真正要先问的是"该不该自研"。开源安全 Bootloader 已经把验签、回滚状态机、断电安全做透,自研的价值通常是理解机制而不是重新实现一遍。

其中最值得 F103 这类芯片借鉴的是 MCUboot 的 scratch swap:它不依赖硬件 Bank Swap,而是用"镜像 1、镜像 2、scratch 临时区"三块区域轮转搬运,在没有换位能力的芯片上用交换算法逻辑上模拟出 Swap 语义,从而拿到真 A/B 的回滚能力。代价是搬运量大、切换期间掉电要靠 scratch 区的状态记录续做,实现比本篇方案一复杂得多。

方案覆盖芯片适配本篇的关键点代价
MCUboot跨厂商,生态偏 Zephyr / NuttXscratch swap 可在无 Bank Swap 芯片上实现 A/B搬运量大、切换慢、需要 scratch 区
wolfBoot跨厂商,资源受限 MCU体积小、依赖少,支持 A/B 与差分部分特性依赖 wolfSSL 商业授权
ST X-CUBE-SBSFUSTM32双槽 + ECDSA 验签 + 版本计数防回滚 + YMODEM绑定 ST 工具链与目录结构

厂商专用套件(NXP SBL 等)的选择见 LV025-MCU有BankSwap落地方案 第五节。

3. 落地建议 ​

(1)先算空间:App 最大体积 × 2 + Bootloader + 参数区 ≤ 512 KB?满了就优先考虑外挂 SPI Flash

(2)再决定自研还是复用:能装下开源 Bootloader 就先用它跑通全流程,自研版本作为后续的裁剪优化,而不是一上来就自己写

(3)参数区一定要双副本,这是整套机制里唯一必须原子更新的数据

(4)Bootloader 里必须有恢复通道(UART 等待重刷),它是所有兜底方案的最终退路

(5)第一次上板务必验证断电场景:在擦除、复制、置标志的每个时刻拔电,确认设备都能恢复

(6)可选优化:App 内置"固件搬运"能力,即 App 直接把新固件写进非活动槽;这比让 Bootloader 承担复制更简单,因为 App 运行时 RAM 与中断都可用


本文档由 markdowncli 技能辅助生成