第4章 Bootloader设计原理(上):Bootloader与App分区、启动流程(向量表重映射)、双区备份机制(A/B分区)
好,咱们今天聊点硬核的。Bootloader,说白了就是汽车TCU上电后第一个跑起来的程序。它干的事不多,但每件都关乎生死——引导、校验、跳转、回滚。我做了这么多年车载,见过太多因为Bootloader设计翻车的案例。嗯,咱们一个一个说。
4.1 Bootloader与App分区
先搞清楚一件事:Bootloader和App是两段独立的程序,它们住在Flash的不同区域。你不能把它们混在一起,否则升级的时候一擦除,整个系统就变砖了。
我个人习惯把Flash分区画成下面这样:
| 分区名称 | 起始地址 | 大小 | 说明 |
|---|---|---|---|
| Bootloader区 | 0x08000000 | 64KB | 存放Bootloader固件,上电后首先执行 |
| App A区 | 0x08010000 | 512KB | 主应用区,正常运行时执行 |
| App B区 | 0x08090000 | 512KB | 备份应用区,用于A/B切换 |
| 参数存储区 | 0x08110000 | 64KB | 存放标定参数、升级标志等 |
你想想看,为什么Bootloader要单独占一块?因为它是最后一道防线。App区可以随便擦写,但Bootloader区我建议加上写保护。我在项目中遇到过,有同事为了省空间把Bootloader和App放在一起,结果一次升级失败,整个ECU都连不上了。最后只能拆壳子用JTAG救回来,那叫一个狼狈。
4.2 启动流程与向量表重映射
TCU上电后,CPU从0x08000000开始执行。这里放的是Bootloader的中断向量表。Bootloader做完初始化、校验App合法性后,就要跳转到App区了。
但有个关键问题:App区的中断向量表在0x08010000。如果CPU发生中断,它还是去0x08000000找向量表,那就乱套了。怎么办?
答案就是——向量表重映射。
以STM32为例,跳转前需要做两件事:
- 设置SCB->VTOR寄存器,告诉CPU新的向量表地址
- 把栈指针切换到App的栈顶
代码长这样:
/* Bootloader跳转到App的代码 */
typedef void (*pFunction)(void);
pFunction JumpToApp;
/* 1. 关闭全局中断 */
__disable_irq();
/* 2. 设置向量表偏移 */
SCB->VTOR = APP_A_ADDRESS;
/* 3. 获取App的栈顶指针和入口地址 */
uint32_t AppStack = *(uint32_t*)APP_A_ADDRESS;
uint32_t AppEntry = *(uint32_t*)(APP_A_ADDRESS + 4);
/* 4. 设置主栈指针 */
__set_MSP(AppStack);
/* 5. 跳转到App的Reset_Handler */
JumpToApp = (pFunction)AppEntry;
JumpToApp();
这里有个坑,我踩过。跳转前一定要把外设都复位干净。比如CAN控制器、定时器、DMA,这些外设如果还挂着中断,跳过去之后App根本跑不起来。我曾经有一次,Bootloader里开了CAN接收中断,跳转前忘了关,结果App一启动就被中断淹没了。排查了整整两天才找到原因。
4.3 双区备份机制(A/B分区)
这是TCU远程升级的核心保障。说白了,就是准备两个App区,一个跑,一个备。升级的时候往备区写,写完了校验通过再切换。
为什么需要双区?因为OTA升级过程中可能断网、断电、刷错固件。如果没有备份区,升级失败TCU就废了。有了A/B分区,大不了回滚到旧版本继续跑。
我设计的A/B切换逻辑是这样的:
| 场景 | 当前运行区 | 升级目标区 | 切换条件 |
|---|---|---|---|
| 正常启动 | A区 | — | 校验A区CRC通过 |
| OTA升级 | A区 | B区 | 下载完成,校验B区CRC通过 |
| 升级成功 | B区 | — | 下次启动从B区引导 |
| 升级失败 | A区 | B区 | B区校验失败,回滚到A区 |
具体实现时,我会在参数存储区放一个boot_flag变量。Bootloader启动时先读这个标志:
/* Bootloader启动流程伪代码 */
void Bootloader_Main(void)
{
uint8_t boot_flag = ReadBootFlag();
if (boot_flag == BOOT_FROM_A)
{
if (CheckAppCRC(APP_A_ADDRESS) == PASS)
{
JumpToApp(APP_A_ADDRESS);
}
else
{
/* A区坏了,尝试B区 */
if (CheckAppCRC(APP_B_ADDRESS) == PASS)
{
SetBootFlag(BOOT_FROM_B);
JumpToApp(APP_B_ADDRESS);
}
else
{
/* 两个区都坏了,进入恢复模式 */
EnterRecoveryMode();
}
}
}
else /* BOOT_FROM_B */
{
/* 类似逻辑,优先启动B区 */
...
}
}
这里有个细节:升级完成后不要立即切换标志。我建议先让App在新分区跑一段时间,确认没问题了再更新boot_flag。如果一写完就切,万一新固件有隐藏bug,一启动就死机,那就回不去了。
嗯,说到回滚,我再补充一句。我曾经设计过一个系统,升级失败后Bootloader自动回滚了,但App里保存的标定数据还是新版本的格式,跟旧固件不兼容。结果车跑起来各种异常。后来我加了一个版本号校验,App启动时检查标定数据版本,不匹配就恢复出厂设置。这个坑,你们设计的时候一定要考虑进去。
好了,这一章的内容就这些。Bootloader的设计其实还有很多细节,比如安全启动、加密校验、看门狗喂狗时机等等。咱们下一章接着聊。