4. Bootloader架构设计:双区架构、跳转表与中断向量表重映射

好,咱们进入第四章。这一章是Bootloader的骨架设计。说白了,你写的Bootloader能不能稳定跑起来,能不能安全升级,全靠这章讲的三块内容:双区架构、跳转表和中断向量表重映射。

我在项目里见过太多“升级升死”的板子。一断电,程序就起不来了。为什么?因为只有一个区,擦写的时候把启动代码也干掉了。所以,双区架构不是锦上添花,是保命用的。

4.1 双区架构(Bank0 / Bank1)

双区架构,顾名思义,就是把Flash分成两个独立的区域。一个跑当前固件,另一个用来接收新固件。升级完成后,再切换过来。

我习惯这么划分:

区域 起始地址 大小 内容
Bootloader区 0x08000000 32 KB 启动代码、烧录协议、跳转逻辑
Bank0(App区) 0x08008000 256 KB 当前运行的应用固件
Bank1(备份区) 0x08048000 256 KB 新固件下载区 / 回滚备份
参数区 0x08088000 4 KB 启动标志、版本号、校验值

你想想看,为什么要把参数区单独拿出来?因为Bootloader启动时,第一件事就是读参数区,判断该启动哪个Bank。如果参数区跟App混在一起,擦写App时参数也被清了,那就乱套了。

核心原则:Bootloader区 + 参数区,永远不能被App擦写操作覆盖。这是底线。

双区的工作流程其实不复杂:

  1. 上电,Bootloader先跑。读参数区,看有没有“待升级”标志。
  2. 如果有,就从串口接收新固件,写入Bank1。
  3. 校验通过后,把参数区的启动地址改成Bank1,然后复位。
  4. 下次启动,Bootloader发现启动地址是Bank1,直接跳过去。
  5. 如果Bank1校验失败,就回退到Bank0。这就是“安全启动”。

我曾经在一个工业控制器项目里,就是因为没做双区,现场升级时网络断了,固件写到一半。结果设备变砖,只能派人去现场拆机烧录。那次之后,我所有项目都强制上双区。

我的习惯:Bank0和Bank1的大小必须一致。这样切换时地址计算简单,不容易出错。另外,参数区我一般用两个备份,防止写入时掉电导致参数损坏。

4.2 跳转表设计

Bootloader跳转到App,不是简单的“goto 0x08008000”。你得先准备好环境:关中断、关外设、设置栈指针、清理寄存器。然后才能跳。

跳转表,其实就是一张函数指针表。Bootloader和App约定好,某些功能(比如串口发送、Flash擦写)的入口地址放在固定位置。这样App也能调用Bootloader里的函数。

我常用的跳转表结构:

// 跳转表结构体定义
typedef struct {
    uint32_t    stack_addr;     // 新栈顶地址
    void        (*reset_handler)(void); // 复位处理函数
    void        (*uart_send)(uint8_t *data, uint32_t len);
    void        (*flash_erase)(uint32_t addr, uint32_t size);
    uint32_t    (*flash_write)(uint32_t addr, uint8_t *data, uint32_t len);
    uint32_t    (*crc_check)(uint32_t addr, uint32_t len);
} JumpTable_t;

// 跳转表放在固定地址(例如 0x08000000 + 0x100)
#define JUMP_TABLE_ADDR     (0x08000100)

// Bootloader 初始化跳转表
const JumpTable_t g_jump_table __attribute__((section(".jump_table"))) = {
    .stack_addr     = APP_STACK_ADDR,
    .reset_handler  = APP_RESET_HANDLER,
    .uart_send      = boot_uart_send,
    .flash_erase    = boot_flash_erase,
    .flash_write    = boot_flash_write,
    .crc_check      = boot_crc_check,
};

App启动时,先读跳转表,拿到函数地址。这样App就不用自己实现Flash驱动了,直接调用Bootloader里的。好处很明显:节省App空间,而且驱动版本统一。

注意:跳转表里的函数,必须是位置无关代码(PIC),或者保证在跳转前后地址映射不变。否则App调用时就会跑飞。我踩过这个坑,查了两天才发现是函数里用了绝对地址变量。

跳转的执行代码,我一般用汇编写,确保干净:

__asm void jump_to_app(uint32_t app_addr)
{
    // 关全局中断
    CPSID   I

    // 关所有外设时钟(根据实际芯片调整)
    // 这里省略具体寄存器操作

    // 设置主栈指针
    LDR     R1, [R0]
    MSR     MSP, R1

    // 取复位向量地址
    LDR     R0, [R0, #4]

    // 跳转
    BX      R0
}

为什么要关中断?因为App的中断向量表跟Bootloader不一样。如果不关,跳转瞬间一个中断进来,PC指向的还是Bootloader的向量表,但栈已经切到App了,必死无疑。

4.3 中断向量表重映射

App跑起来之后,中断怎么办?默认情况下,CPU从0x00000000取中断向量。但App在0x08008000,所以你得告诉CPU:“嘿,向量表搬家了。”

ARM Cortex-M系列提供了向量表偏移寄存器(VTOR)。你只需要在App启动时,把VTOR改成App的基地址就行。

// App 启动代码中(一般在 Reset_Handler 最前面)
#define APP_BASE_ADDR       (0x08008000)

void Reset_Handler(void)
{
    // 重映射中断向量表到 App 区
    SCB->VTOR = APP_BASE_ADDR;

    // 设置栈指针(如果编译器没自动做)
    __set_MSP(*(uint32_t *)APP_BASE_ADDR);

    // 然后才是 C 运行时初始化
    main();
}

这里有个细节:VTOR必须在任何中断使能之前设置。否则,如果某个外设已经产生了中断请求,CPU去0x00000000取向量,发现是Bootloader的,那就乱套了。

我见过一个案例,同事把VTOR放在了main函数里,结果系统一跑就进HardFault。为什么?因为启动代码里使能了SysTick中断,而那时VTOR还没改。中断来了,CPU跳到了Bootloader的向量表,但栈和变量都是App的,直接崩了。

建议:VTOR设置放在Reset_Handler的第一条C语句,甚至用汇编在进入C之前就设好。别给中断任何可乘之机。

另外,有些芯片(比如STM32F1系列)不支持VTOR,或者有特殊限制。这时候你得用内存映射重映射,把Flash的某个地址映射到0x00000000。具体做法看芯片手册,原理是一样的:让CPU在0地址看到的是App的向量表。

4.4 整体架构图

下面这张图,把双区、跳转表、向量表重映射串起来了。你对照着看,应该能理解整个启动流程。

Bootloader 双区架构与启动流程 Bootloader 区 (0x08000000 - 0x08007FFF) 跳转表 | 烧录协议 | 启动逻辑 Bank0 (0x08008000 - 0x08047FFF) 当前运行的应用固件 Bank1 (0x08048000 - 0x08087FFF) 新固件下载区 / 回滚备份 参数区 (0x08088000 - 0x08088FFF) — 启动标志、版本号、CRC 启动流程 上电 → Bootloader 读参数区标志 有升级标志? 是 串口接收 → 写入 Bank1 校验 → 更新参数 否 跳转 跳转前:关中断 → 关外设 → 设置 VTOR → 取栈指针 → BX 到 App 复位向量

嗯,这张图基本把整个流程讲清楚了。你从上电开始,Bootloader先跑,读参数区,判断是直接跳Bank0,还是先升级再跳。跳转前别忘了关中断、设VTOR。这些步骤少一个,系统就可能起不来。

总结一下本章核心:

  • 双区架构是安全升级的基石。Bank0和Bank1互相备份,升级失败还能回滚。
  • 跳转表让App能复用Bootloader的驱动,节省空间,统一版本。
  • 中断向量表重映射(VTOR)必须在任何中断使能之前设置,否则必进HardFault。