3. Bootloader 开发基础:启动流程、向量表重映射、跳转逻辑实现
Bootloader 这东西,说白了就是芯片上电后跑的第一段用户程序。它的任务很单纯——把真正的应用程序从存储介质里捞出来,放到 RAM 里,然后跳过去执行。但就是这看似简单的几步,里面坑不少。我早年做第一款量产产品时,就在跳转逻辑上栽过跟头,板子死活跑不起来,最后发现是向量表没挪对地方。
这一章,咱们就把 Bootloader 的启动流程、向量表重映射、跳转逻辑这三个核心点掰开揉碎。你把这些搞懂了,后面做固件升级心里就有底了。
3.1 芯片上电后到底发生了什么?
先看一张图,把整体流程串起来。这样你脑子里有个框架,后面讲细节才不会晕。
这张图把 Bootloader 的完整生命周期画出来了。从复位到跳转,一共六步。其中第⑤步是分岔路口——决定是进升级流程还是直接启动 App。咱们接下来逐个拆解。
3.2 启动流程:从复位向量到 Reset_Handler
ARM Cortex-M 系列内核上电后,硬件会自动做两件事:
- 从地址 0x00000000 读取栈指针(SP),初始化主栈。
- 从地址 0x00000004 读取程序计数器(PC),也就是复位向量,然后跳过去执行。
嗯,这里要注意:这个 0x00000000 和 0x00000004 并不是真正的 Flash 物理地址。芯片内部有映射逻辑,通常会把 Flash 的起始地址映射到 0x00000000。但有些芯片支持从 SRAM 或系统存储器启动,映射关系会变。我建议你拿到新芯片后,第一件事就是看它的启动配置章节,别想当然。
复位向量的内容,就是 Reset_Handler 函数的地址。这个函数通常由启动文件(startup_xxx.s)提供,里面做了三件核心事:
- 拷贝 .data 段从 Flash 到 SRAM
- 清零 .bss 段
- 调用
main()
说白了,C 语言能跑起来,全靠这段汇编代码给你铺好了路。你在 Bootloader 里写 main() 之前,这些初始化工作已经完成了。
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(),完事再开。
3.4 跳转逻辑实现:从 Bootloader 到 App
向量表重映射搞定后,下一步就是跳转到应用程序的 main()。但这里有个关键点——你不能直接 call 一个函数就完事。因为应用程序的 main() 依赖正确的栈指针和中断向量表。
正确的做法是:
- 从 App 向量表的起始地址(0x08010000)读取新的栈指针。
- 从 App 向量表的偏移 4 字节处(0x08010004)读取复位向量地址。
- 用函数指针跳转到复位向量。
代码实现如下:
/* 定义函数指针类型 */
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 自己重新初始化。
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 数据(比如升级标志),需要提前约定好地址和格式。