第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救回来,那叫一个狼狈。

注意:Bootloader区一旦损坏,TCU就彻底变砖了。量产前一定要确认Bootloader的写保护机制已经生效。

4.2 启动流程与向量表重映射

TCU上电后,CPU从0x08000000开始执行。这里放的是Bootloader的中断向量表。Bootloader做完初始化、校验App合法性后,就要跳转到App区了。

但有个关键问题:App区的中断向量表在0x08010000。如果CPU发生中断,它还是去0x08000000找向量表,那就乱套了。怎么办?

答案就是——向量表重映射

以STM32为例,跳转前需要做两件事:

  1. 设置SCB->VTOR寄存器,告诉CPU新的向量表地址
  2. 把栈指针切换到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一启动就被中断淹没了。排查了整整两天才找到原因。

我的习惯:跳转前调用DeInit函数把所有外设复位到默认状态,然后关闭全局中断。跳转完成后,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能自动回滚到好的那个分区。

嗯,说到回滚,我再补充一句。我曾经设计过一个系统,升级失败后Bootloader自动回滚了,但App里保存的标定数据还是新版本的格式,跟旧固件不兼容。结果车跑起来各种异常。后来我加了一个版本号校验,App启动时检查标定数据版本,不匹配就恢复出厂设置。这个坑,你们设计的时候一定要考虑进去。

好了,这一章的内容就这些。Bootloader的设计其实还有很多细节,比如安全启动、加密校验、看门狗喂狗时机等等。咱们下一章接着聊。