3. Bootloader 开发基础:启动流程、向量表重映射、跳转逻辑实现

Bootloader 这东西,说白了就是芯片上电后跑的第一段用户程序。它的任务很单纯——把真正的应用程序从存储介质里捞出来,放到 RAM 里,然后跳过去执行。但就是这看似简单的几步,里面坑不少。我早年做第一款量产产品时,就在跳转逻辑上栽过跟头,板子死活跑不起来,最后发现是向量表没挪对地方。

这一章,咱们就把 Bootloader 的启动流程、向量表重映射、跳转逻辑这三个核心点掰开揉碎。你把这些搞懂了,后面做固件升级心里就有底了。

3.1 芯片上电后到底发生了什么?

先看一张图,把整体流程串起来。这样你脑子里有个框架,后面讲细节才不会晕。

Bootloader 启动与跳转全流程 ① 上电 / 复位 ② 从 0x0000 取 SP / PC ③ 执行 Reset_Handler ④ 初始化时钟 / 外设 / 内存 ⑤ 检查升级标志 有升级 无升级 ⑥a 接收固件 → 写入 Flash ⑥b 重映射向量表 → 跳转 完成后 ! 向量表重映射是跳转前的关键一步,漏了必死

这张图把 Bootloader 的完整生命周期画出来了。从复位到跳转,一共六步。其中第⑤步是分岔路口——决定是进升级流程还是直接启动 App。咱们接下来逐个拆解。

3.2 启动流程:从复位向量到 Reset_Handler

ARM Cortex-M 系列内核上电后,硬件会自动做两件事:

  1. 从地址 0x00000000 读取栈指针(SP),初始化主栈。
  2. 从地址 0x00000004 读取程序计数器(PC),也就是复位向量,然后跳过去执行。

嗯,这里要注意:这个 0x00000000 和 0x00000004 并不是真正的 Flash 物理地址。芯片内部有映射逻辑,通常会把 Flash 的起始地址映射到 0x00000000。但有些芯片支持从 SRAM 或系统存储器启动,映射关系会变。我建议你拿到新芯片后,第一件事就是看它的启动配置章节,别想当然。

复位向量的内容,就是 Reset_Handler 函数的地址。这个函数通常由启动文件(startup_xxx.s)提供,里面做了三件核心事:

  • 拷贝 .data 段从 Flash 到 SRAM
  • 清零 .bss 段
  • 调用 main()

说白了,C 语言能跑起来,全靠这段汇编代码给你铺好了路。你在 Bootloader 里写 main() 之前,这些初始化工作已经完成了。

我的习惯: 在 Bootloader 的 Reset_Handler 里,我会加一段非常精简的硬件自检——检查 CRC、验证关键寄存器。不是为了炫技,而是为了在升级中途断电重启后,能快速判断 Flash 里的固件是否完整。这个习惯帮我挡过好几次售后返修。

3.3 向量表重映射:为什么必须做?

你想想看,芯片上电后默认从 0x00000000 取向量表。但你的应用程序可能放在 0x08010000(假设 Flash 偏移了 64KB)。如果中断来了,CPU 还是去 0x00000000 找中断服务函数,那不就乱套了吗?

所以,在跳转到应用程序之前,必须告诉 CPU:「嘿,向量表搬家了,新地址在 0x08010000」。这个操作就叫向量表重映射。

Cortex-M3/M4 内核提供了一个寄存器——SCB->VTOR(Vector Table Offset Register)。你只需要把新的基地址写进去就行:

/* 假设 App 的向量表在 0x08010000 */
#define APP_VECTOR_TABLE  ((uint32_t)0x08010000)

/* 关闭全局中断,防止重映射过程中断响应异常 */
__disable_irq();

/* 设置向量表偏移 */
SCB->VTOR = APP_VECTOR_TABLE;

/* 重新使能全局中断 */
__enable_irq();

就这么几行代码。但我曾经犯过一个低级错误——写 VTOR 之前忘了关中断。结果重映射写到一半,一个 SysTick 中断来了,CPU 拿着半新的 VTOR 值去查表,直接 HardFault。从那以后,我每次重映射前都会先 __disable_irq(),完事再开。

注意: 有些芯片(比如 STM32F1 系列)不支持 VTOR 寄存器。这种情况下,你需要通过修改 Flash 的映射地址来实现重映射,或者使用芯片提供的专用重映射功能。做项目前一定先查芯片参考手册的「System and memory overview」章节。

3.4 跳转逻辑实现:从 Bootloader 到 App

向量表重映射搞定后,下一步就是跳转到应用程序的 main()。但这里有个关键点——你不能直接 call 一个函数就完事。因为应用程序的 main() 依赖正确的栈指针和中断向量表。

正确的做法是:

  1. 从 App 向量表的起始地址(0x08010000)读取新的栈指针。
  2. 从 App 向量表的偏移 4 字节处(0x08010004)读取复位向量地址。
  3. 用函数指针跳转到复位向量。

代码实现如下:

/* 定义函数指针类型 */
typedef void (*pFunction)(void);

/* 跳转到应用程序 */
void JumpToApp(uint32_t app_addr)
{
    uint32_t sp_addr;      /* 新的栈指针 */
    uint32_t reset_addr;   /* 新的复位向量 */
    pFunction app_entry;

    /* 1. 关闭全局中断 */
    __disable_irq();

    /* 2. 关闭所有外设中断(如果有使能的话) */
    /* 这一步容易被忽略,但很重要 */
    for (int i = 0; i < 8; i++) {
        NVIC->ICER[i] = 0xFFFFFFFF;
        NVIC->ICPR[i] = 0xFFFFFFFF;
    }

    /* 3. 设置向量表偏移 */
    SCB->VTOR = app_addr;

    /* 4. 从 App 向量表读取 SP 和 PC */
    sp_addr   = *(volatile uint32_t *)app_addr;
    reset_addr = *(volatile uint32_t *)(app_addr + 4);

    /* 5. 设置主栈指针 */
    __set_MSP(sp_addr);

    /* 6. 获取跳转地址并执行 */
    app_entry = (pFunction)reset_addr;
    app_entry();

    /* 理论上不会执行到这里 */
}

这段代码里,第 2 步很多人会漏掉。你想想看,Bootloader 里可能使能了定时器、UART、DMA 等外设中断。如果不清理干净就跳转,App 启动时这些中断还在 pending,一旦开中断就会触发异常。我建议你在跳转前把所有外设时钟也关掉,让 App 自己重新初始化。

避坑指南: 我曾经在跳转前忘了关 SysTick 中断。App 启动后,SysTick 中断还在按 Bootloader 的周期触发,但中断向量表已经指向 App 的 SysTick_Handler。结果 App 的 SysTick 处理函数里访问了一个还没初始化的全局变量,直接 HardFault。排查了整整两天才找到原因。所以,跳转前把所有定时器中断都关了,包括 SysTick。

3.5 双区 Bootloader 与跳转策略

实际产品中,Bootloader 和 App 的布局通常有两种方案:

方案 Flash 布局 优点 缺点
单区方案 Bootloader + App(App 覆盖剩余 Flash) 实现简单,占用空间小 升级失败后无法回滚
双区方案 Bootloader + App_A + App_B 支持回滚,升级安全 Flash 占用翻倍

我个人更倾向于双区方案,尤其是做 OTA 升级的产品。你想想看,如果升级过程中断电了,单区方案里 App 被擦了一半,板子就变砖了。双区方案里,Bootloader 可以检查 App_A 和 App_B 的 CRC,哪个完整就启动哪个,稳得很。

双区方案的跳转逻辑需要加一个选择步骤:

/* 伪代码:双区跳转选择 */
uint32_t app_addr;

if (CheckAppCRC(APP_A_ADDR) == PASS) {
    app_addr = APP_A_ADDR;
} else if (CheckAppCRC(APP_B_ADDR) == PASS) {
    app_addr = APP_B_ADDR;
} else {
    /* 两个区都坏了,进入恢复模式 */
    EnterRecoveryMode();
}

JumpToApp(app_addr);

这个 CRC 检查我建议放在 Bootloader 的 main() 最前面,不要等到初始化完外设再查。早检查早决定,万一两个区都坏了,还能省点电进入低功耗等待模式。

3.6 跳转后的注意事项

跳转成功后,Bootloader 就「功成身退」了。但有几个细节你需要注意:

  • 外设状态: Bootloader 里配置过的 GPIO、时钟、DMA 等外设,App 里最好重新初始化。不要依赖 Bootloader 留下的状态。
  • 中断优先级: 有些芯片的中断优先级分组(Priority Grouping)是全局的。Bootloader 和 App 必须使用相同的分组方式,否则中断行为会乱掉。
  • RAM 内容: Bootloader 使用的全局变量在跳转后不会被清零。如果 App 依赖某些 RAM 数据(比如升级标志),需要提前约定好地址和格式。
我的做法: 在 Bootloader 和 App 之间定义一个共享结构体,放在 RAM 的固定地址(比如 SRAM 末尾)。Bootloader 把升级状态、固件版本、CRC 结果写进去,App 启动后直接读取。这样既避免了全局变量冲突,又方便调试。