LV022-MCU无BankSwap落地方案
一、 硬件约束与分区规划
1. STM32F103ZET6 资源盘点
| 资源 | 参数 | 对 OTA 的影响 |
|---|---|---|
| 内部 Flash | 512 KB,单 Bank | 空间够分两槽,但没有硬件 Bank 切换 |
| Flash 页大小 | 2 KB | 擦除以页为单位,参数区正好各占一页 |
| SRAM | 64 KB | 直接 RAM 运行需要 App 极小,多数场景不可行 |
| 内核 | Cortex-M3 | 支持 SCB->VTOR,可重定位向量表,这是双槽方案的前提 |
| 通信接口 | 无网络外设 | 需外挂 ESP8266 / 4G 模组,走 UART |
结论:可以做真 A/B,但需要软件方案绕开"单 Bank + 地址绑定"这两个限制。
2. 分区表与链接脚本
下面这张分区表对应本节的方案一(双槽),其余方案的布局差异在第二节各自给出。
起始地址 大小 分区 用途
──────────────────────────────────────────────────────────────
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)关键片段:
/* 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 启动的第一时间把它指到自己的槽位:
// 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 运行必然失败——向量表里的入口地址、绝对寻址的常量池都指向原地址。
因此这个方案要求构建系统产出两份镜像:
构建产物
├── app_slotA.bin 链接地址 0x08004000(运行在 A 槽时使用)
└── app_slotB.bin 链接地址 0x08040000(运行在 B 槽时使用)在构建脚本里把链接地址作为参数传入,一次构建出两个 bin:
# 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"
doneOTA 时的选择规则很简单:
- 当前运行在 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 复制过去:
┌───────────┬────────────────────┬──────────────────┐
│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:
内部 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. 工程结构与内存布局
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 擦写封装
// 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 起来后会立刻收到一堆来历不明的中断:
// 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 反查自己在哪个槽:
// 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:
// 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× App | App ≤ 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 / NuttX | scratch swap 可在无 Bank Swap 芯片上实现 A/B | 搬运量大、切换慢、需要 scratch 区 |
| wolfBoot | 跨厂商,资源受限 MCU | 体积小、依赖少,支持 A/B 与差分 | 部分特性依赖 wolfSSL 商业授权 |
| ST X-CUBE-SBSFU | STM32 | 双槽 + 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 技能辅助生成