Bootloader 内存分配进阶:三区 A/B 分区与 OTA 实战

系列:Bootloader 内存分配 · 进阶篇

平台:STM32F103RCT6(256 KB Flash / 48 KB RAM)

工具链:GCC ARM + CMake / STM32CubeIDE 均可

前置:已理解双区模型、链接脚本与 VTOR 基本概念

本文在双区 Bootloader 基础上,介绍三区 A/B 分区方案:通过 Bootloader + App1(运行槽)+ App2(下载/备份槽)实现安全 OTA、槽位切换、CRC 校验与故障回滚。

目录

  1. 进阶目标与整体架构
  2. Flash 三区内存映射
  3. Cortex-M 向量表与 VTOR 深度解析
  4. Boot 元数据与槽位切换
  5. OTA 升级完整流程
  6. CRC 校验与简易签名
  7. 回滚与故障恢复
  8. 完整工程目录与源码
  9. 编译、烧录与调试
  10. 从双区迁移到三区
  11. 常见问题与排错

1. 进阶目标与整体架构

1.1 双区的局限

问题

说明

擦写中断

升级过程中断电,App 区可能半写入,设备变砖

无回滚

新固件有 bug 时无法快速恢复旧版本

单点故障

只有一个 App 槽,校验失败即无法启动业务

1.2 三区 A/B 模型

将 Flash 划分为 Bootloader + App1(运行槽)+ App2(下载/备份槽):

0x0800_0000  ┌──────────────────────┐
             │   Bootloader  32KB   │  上电入口,校验与跳转
0x0800_8000  ├──────────────────────┤
             │   App1 (Slot A)      │  当前运行固件
             │        112KB         │
0x0802_4000  ├──────────────────────┤
             │   App2 (Slot B)      │  OTA 下载区 / 备份区
             │        112KB         │
0x0804_0000  └──────────────────────┘
             (剩余空间可用于参数区、日志等)

1.3 系统数据流

上电 → Bootloader → 读取 Boot Meta → CRC/签名校验
  ├─ 校验通过 → 跳转 Active Slot → Application 运行
  └─ 校验失败 → 尝试另一 Slot → 跳转

OTA 下载 → 写入 App2(inactive 槽)→ 重启进 Bootloader
  → 校验并切换 Active → 跳转新固件

2. Flash 三区内存映射

2.1 统一头文件 memory_map.h

所有工程(Bootloader / App1 / App2)必须引用同一份地址定义,避免链接地址与跳转地址不一致。

#ifndef MEMORY_MAP_H
#define MEMORY_MAP_H

#include <stdint.h>

#define FLASH_BASE_ADDR         0x08000000U
#define BL_FLASH_SIZE           (32U  * 1024U)
#define APP_SLOT_SIZE           (112U * 1024U)

#define BL_START_ADDR           FLASH_BASE_ADDR
#define APP1_START_ADDR         (BL_START_ADDR + BL_FLASH_SIZE)
#define APP2_START_ADDR         (APP1_START_ADDR + APP_SLOT_SIZE)

#define APP1_VTOR_OFFSET        (APP1_START_ADDR - FLASH_BASE_ADDR)
#define APP2_VTOR_OFFSET        (APP2_START_ADDR - FLASH_BASE_ADDR)

#define BOOT_META_ADDR          0x0803F000U
#define BOOT_META_SIZE          (4U * 1024U)
#define BOOT_MAGIC              0xB007DA7AU

typedef enum {
    SLOT_APP1 = 0,
    SLOT_APP2 = 1,
} app_slot_t;

#endif

宏定义

说明

BL_FLASH_SIZE

32 KB

Bootloader 区

APP_SLOT_SIZE

112 KB

每个 App 槽大小

APP1_START_ADDR

0x08008000

Slot A 起始地址

APP2_START_ADDR

0x08024000

Slot B 起始地址

BOOT_META_ADDR

0x0803F000

Boot 元数据区(Flash 末尾 4 KB)

2.2 链接脚本对照

/* Bootloader — STM32F103RCTx_FLASH.ld */
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 32K
RAM  (xrw): ORIGIN = 0x20000000, LENGTH = 48K

/* App1 — STM32F103RCTx_APP1.ld */
FLASH (rx) : ORIGIN = 0x08008000, LENGTH = 112K

/* App2 — STM32F103RCTx_APP2.ld */
FLASH (rx) : ORIGIN = 0x08024000, LENGTH = 112K

【注意】App1 与 App2 源码相同,仅链接脚本 ORIGIN 不同。编译时通过 -T 参数或 CMake 变量切换。

3. Cortex-M 向量表与 VTOR 深度解析

3.1 向量表结构

偏移

内容

+0x00

初始 SP(栈顶)

+0x04

Reset_Handler 地址

+0x08

NMI_Handler

+0x0C

HardFault_Handler

...

其他中断向量

Bootloader 位于 0x08000000,向量表天然正确。App 链接在 0x08008000 或 0x08024000,硬件默认仍从 0x08000000 取向量,必须在 App 中重定位 VTOR。

3.2 VTOR 寄存器

/* CMSIS: SCB->VTOR,bit[29:7] 为向量表基址(512 字节对齐) */
#define SCB_VTOR_Msk   (0x1FFFFF80UL)

static inline void vtor_set(uint32_t base)
{
    SCB->VTOR = base & SCB_VTOR_Msk;
}

/* system_stm32f1xx.c — App 工程 */
void SystemInit(void)
{
#if defined(APP_SLOT1)
    SCB->VTOR = APP1_START_ADDR;
#elif defined(APP_SLOT2)
    SCB->VTOR = APP2_START_ADDR;
#endif
}

3.3 Bootloader 跳转前的清理清单

void bl_jump_to_app(uint32_t app_addr)
{
    uint32_t sp = *(__IO uint32_t *)app_addr;
    uint32_t pc = *(__IO uint32_t *)(app_addr + 4U);

    __disable_irq();
    SysTick->CTRL = 0; SysTick->LOAD = 0; SysTick->VAL = 0;

    for (uint32_t i = 0; i < 8; i++) {
        NVIC->ICER[i] = 0xFFFFFFFFU;
        NVIC->ICPR[i] = 0xFFFFFFFFU;
    }

    SCB->VTOR = app_addr;

    if ((sp & 0x2FFE0000U) != 0x20000000U) return;

    __set_MSP(sp);
    ((void (*)(void))pc)();
}

步骤

操作

目的

1

__disable_irq()

关闭全局中断

2

关闭 SysTick

清除定时器状态

3

清除 NVIC ICER/ICPR

取消所有中断使能与挂起

4

SCB->VTOR = app_addr

向量表指向 App 区域

5

校验 SP 在 RAM 范围

防止跳转到无效固件

6

__set_MSP + 跳转 PC

进入 App Reset_Handler

4. Boot 元数据与槽位切换

4.1 元数据结构

元数据单独占用 Flash 末尾 4 KB 页,与 App 区隔离:

typedef struct __attribute__((packed)) {
    uint32_t magic;           /* BOOT_MAGIC */
    uint8_t  active_slot;     /* SLOT_APP1 or SLOT_APP2 */
    uint8_t  pending_slot;    /* OTA 完成后待切换槽,0xFF=无 */
    uint16_t reserved;
    uint32_t app1_size;
    uint32_t app1_crc32;
    uint32_t app2_size;
    uint32_t app2_crc32;
    uint32_t boot_count;      /* 启动计数,用于回滚判定 */
    uint32_t crc_meta;        /* 本结构体 CRC(不含此字段) */
} boot_meta_t;

字段

说明

active_slot

当前运行槽(SLOT_APP1 / SLOT_APP2)

pending_slot

OTA 完成后待切换的槽,0xFF 表示无

app1/app2_size

各槽有效固件字节数

app1/app2_crc32

各槽固件 CRC32

boot_count

启动计数,配合回滚机制

crc_meta

元数据自身完整性校验

4.2 读写实现要点

  • boot_meta_load():从 BOOT_META_ADDR 读取,校验 magic 与 crc_meta
  • 首次使用:magic 无效时初始化默认值(active=SLOT_APP1)并写入
  • boot_meta_save():擦除元数据页 → 半字编程写入 → 更新 crc_meta

4.3 槽位切换策略

策略

流程

特点

A — 指针切换(推荐)

OTA 写 inactive 槽 → 校验 → 更新 active_slot → 重启

不拷贝 Flash,速度快

B — 拷贝切换

OTA 写 App2 → 校验 → 拷贝覆盖 App1 → active=SLOT_APP1

兼容只认 App1 的旧 Bootloader

本工程示例采用策略 A(指针切换)。

5. OTA 升级完整流程

5.1 状态机

Idle(正常运行)
  → Downloading(收到 OTA 命令,写入 inactive 槽)
  → Verifying(下载完成,计算 CRC)
  → PendingSwitch(CRC 通过,写 pending_slot)
  → Reboot(NVIC_SystemReset)
  → BootloaderRun(校验 pending,切换 active_slot)
  → Running(跳转新固件)

5.2 App 侧 OTA 接收框架

/* ota_receiver.c — 运行在 active App 内 */
int ota_begin(uint32_t firmware_size)
{
    boot_meta_load(&meta);
    g_ota.write_addr = inactive_slot_addr(&meta);  /* 写非 active 槽 */
    flash_erase_range(g_ota.write_addr, APP_SLOT_SIZE);
    return 0;
}

int ota_write_chunk(const uint8_t *data, uint32_t len)
{
    flash_program(g_ota.write_addr + g_ota.received, data, len);
    g_ota.running_crc = crc32_update(g_ota.running_crc, data, len);
    g_ota.received += len;
    return 0;
}

int ota_finish(void)
{
    meta.pending_slot = target;  /* 标记待切换槽 */
    boot_meta_save(&meta);
    NVIC_SystemReset();          /* 重启,由 Bootloader 完成切换 */
    return 0;
}

5.3 Bootloader 启动决策

bl_select_slot() 决策逻辑:

  1. 若有 pending_slot:校验 pending 槽 CRC → 通过则切换为 active
  2. 校验当前 active 槽 → 通过则跳转
  3. active 失败:尝试另一槽(fallback)
  4. 两槽均无效:进入 UART 救砖模式

int main(void)
{
    HAL_Init();
    boot_meta_load(&meta);

    app_slot_t slot = bl_select_slot(&meta);
    if (slot <= SLOT_APP2) {
        bl_jump_to_app(slot_addr(slot));
    }

    recovery_mode_run();  /* 救砖:UART YMODEM */
    return 0;
}

6. CRC 校验与简易签名

6.1 CRC32 实现

uint32_t crc32_compute(const uint8_t *data, uint32_t len)
{
    uint32_t crc = 0xFFFFFFFFU;
    for (uint32_t i = 0; i < len; i++) {
        crc = (crc >> 8) ^ crc32_table[(crc ^ data[i]) & 0xFFU];
    }
    return crc ^ 0xFFFFFFFFU;
}

采用标准 IEEE 802.3 多项式 0xEDB88320 查表法实现。

6.2 固件头部(版本 + 签名)

#define FW_HEADER_MAGIC   0x46574D52U  /* 'FWMR' */

typedef struct __attribute__((packed)) {
    uint32_t magic;
    uint32_t version;       /* 如 0x00010203 = v1.2.3 */
    uint32_t image_size;
    uint32_t image_crc32;
    uint32_t build_timestamp;
    uint8_t  signature[32]; /* HMAC-SHA256,可选 */
} firmware_header_t;

简易 HMAC 验证流程:

  1. PC 端对(header 不含 signature + image)计算 HMAC-SHA256
  2. 将 signature 写回头部,生成 .signed 固件
  3. Bootloader 内置相同密钥校验(生产环境应使用 Secure Boot + OTP 存密钥)

# tools/sign_firmware.py
sig = hmac.new(SECRET, header + image, hashlib.sha256).digest()

7. 回滚与故障恢复

7.1 启动计数回滚

新固件切换后,App 在 main() 早期调用 app_boot_confirm() 清零 boot_count;若连续 N 次启动未确认,Bootloader 自动回滚到另一槽。

/* App — 启动确认 */
void app_boot_confirm(void)
{
    boot_meta_load(&meta);
    meta.boot_count = 0;
    boot_meta_save(&meta);
}

/* Bootloader — 递增计数,达阈值则回滚 */
if (meta->boot_count >= 3) {
    meta->active_slot = 切换另一槽;
    meta->boot_count  = 0;
}

7.2 救砖模式(Recovery)

Bootloader 在双槽均无效时进入 UART 救砖:支持 YMODEM 接收 .bin 直写 App1,或通过 SWD 重新烧录(开发阶段)。

void recovery_mode_run(void)
{
    uart_print("Bootloader Recovery Mode\r\n");
    while (1) {
        if (ymodem_receive(APP1_START_ADDR, APP_SLOT_SIZE) == 0) {
            /* 更新 meta 并重启 */
            NVIC_SystemReset();
        }
    }
}

8. 完整工程目录与源码

8.1 工程树

stm32-ab-bootloader/
├── Common/
│   ├── Inc/  memory_map.h, boot_meta.h, crc32.h, bl_jump.h
│   └── Src/  boot_meta.c, crc32.c, bl_jump.c
├── Bootloader/
│   ├── Core/Src/main.c
│   └── STM32F103RCTx_FLASH.ld
├── Application/
│   ├── Core/Src/main.c, ota_receiver.c
│   ├── STM32F103RCTx_APP1.ld
│   └── STM32F103RCTx_APP2.ld
├── tools/  sign_firmware.py, pack_ota.py, flash_all.sh
└── CMakeLists.txt

8.2 Application main.c 示例

int main(void)
{
    HAL_Init();
    SystemClock_Config();
    app_boot_confirm();   /* 防止 Bootloader 回滚 */

    GPIO_Init();
    uart_init(115200);

#if defined(APP_SLOT1)
    uart_print("Running on APP1\r\n");
#elif defined(APP_SLOT2)
    uart_print("Running on APP2\r\n");
#endif

    while (1) {
        ota_poll_uart();
        HAL_Delay(100);
    }
}

8.3 CMake 构建片段

# App1:-DAPP_SLOT1 + STM32F103RCTx_APP1.ld
target_compile_definitions(app1.elf PRIVATE APP_SLOT1)
target_link_options(app1.elf PRIVATE
    -T${CMAKE_SOURCE_DIR}/Application/STM32F103RCTx_APP1.ld)

# App2:同源码,-DAPP_SLOT2 + APP2.ld
target_compile_definitions(app2.elf PRIVATE APP_SLOT2)
target_link_options(app2.elf PRIVATE
    -T${CMAKE_SOURCE_DIR}/Application/STM32F103RCTx_APP2.ld)

9. 编译、烧录与调试

9.1 首次烧录顺序

cmake -B build -DCMAKE_TOOLCHAIN_FILE=cmake/gcc-arm-none-eabi.cmake
cmake --build build

# 1. 烧录 Bootloader
openocd ... -c "program build/bootloader.elf verify reset exit"

# 2. 烧录 App1(出厂固件)
openocd ... -c "program build/app1.bin 0x08008000 verify reset exit"

# 3. 初始化 Boot Meta
python tools/init_meta.py --app1 build/app1.bin

9.2 OTA 测试流程

  1. 设备运行 App1 v1.0.0
  2. PC 端 pack_ota.py 打包 app2.bin --version 1.1.0
  3. 通过 UART 发送 OTA 包
  4. 设备重启,Bootloader 校验 App2 并切换
  5. 串口打印 Running on APP2

9.3 调试要点

现象

可能原因

排查

HardFault 进 App

VTOR 未设置 / SP 非法

查 SystemInit、链接脚本 ORIGIN

跳转后无输出

UART 未重新初始化

App main() 内重新 init 外设

OTA 后仍跑旧版

meta 未更新 / pending 未处理

读 BOOT_META_ADDR 内容

CRC 失败

下载丢包 / size 不一致

对比 PC 端与设备端 CRC

【内存查看】0x08000000 Bootloader 向量表 | 0x08008000 App1 向量表 | 0x0803F000 Boot Meta

10. 从双区迁移到三区

10.1 迁移步骤

  1. 重新规划链接脚本:新增 App2.ld,确认 App1 起始地址
  2. 增加 Boot Meta 区:确保不与 App 重叠(可能需缩小槽 4 KB)
  3. Bootloader 增加槽位逻辑:jump_to_app() 改为 bl_select_slot()
  4. App 增加 OTA 模块:写入 inactive 槽而非自覆盖
  5. 出厂流程:仍只烧 App1,App2 留空,Meta 默认 active=SLOT_APP1

10.2 兼容性对照

双区

三区

APP_START_ADDR

APP1_START_ADDR + APP2_START_ADDR

单 app.ld

APP1.ld + APP2.ld

OTA 直写 App 区

OTA 写 inactive 槽

无 meta

boot_meta_t 管理槽位

无回滚

boot_count + 双槽 fallback

11. 常见问题与排错

问题

解答

App1 和 App2 必须编译两份吗?

源码一份,编译两次,分别指定 APP_SLOT1/2 宏和对应链接脚本

两个槽能链接到同一地址吗?

不能,物理地址不同,向量表必须对应各自 ORIGIN

RAM 要不要分区?

一般不分,跳转前清 NVIC/SysTick,App 重新初始化 HAL

F103C8(64KB)能做三区吗?

空间极紧(约32+16+16KB),仅适合演示;推荐≥256KB Flash

与 MCUboot 的关系?

本文是裸机轻量方案;量产可迁移 MCUboot,分区思想一致

附录 A:Flash 擦写辅助函数

int flash_erase_range(uint32_t addr, uint32_t size)
{
    /* F103 每页 2KB,逐页擦除 */
    while (page_addr < end) {
        HAL_FLASHEx_Erase(&erase, &err);
        page_addr += 0x800U;
    }
}

int flash_program(uint32_t addr, const uint8_t *data, uint32_t len)
{
    /* 半字(16位)编程 */
    HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, addr + i, half);
}

附录 B:推荐阅读

  • ARM Cortex-M3 TRM — 向量表与 VTOR 章节
  • STM32F103 Reference Manual — Flash 模块
  • AN2606 / AN3155 — STM32 内置 Bootloader 协议
  • Bootloader 内存分配 · 入门篇(双区模型)

总结

主题

进阶要点

三区划分

Bootloader + App1 + App2,inactive 槽接收 OTA

VTOR

App 必须重定位;跳转前清 NVIC/SysTick

Boot Meta

持久化 active/pending 槽、CRC、启动计数

OTA

下载 → 校验 → pending → 重启 → 切换

可靠性

双槽 fallback + boot_count 回滚 + UART 救砖

掌握以上内容后,可在产品级 STM32 项目上实现安全的 A/B OTA,并根据 Flash 容量灵活调整槽位大小。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐