第四章 Bootloader原理:启动流程、双区启动与固件校验

大家好,我是你们的老朋友。今天我们来聊聊Bootloader。

说实话,Bootloader这玩意儿,看着简单,但坑特别多。我最早做嵌入式时,觉得Bootloader不就是跳转到主程序嘛,有啥难的?结果第一次做OTA升级,设备变砖了……嗯,从那以后,我再也不敢小看它了。

这一章,我会把Bootloader的核心原理掰开揉碎讲清楚。包括启动流程、双区启动(A/B分区),还有固件校验与签名。这些都是实战中绕不开的硬骨头。

4.1 Bootloader到底在干什么?

简单说,Bootloader就是上电后第一个跑的程序。它的任务很明确:初始化硬件,然后加载并跳转到真正的应用程序。

你想想看,MCU上电后,PC指针指向复位向量。那个地址放的就是Bootloader的第一条指令。所以,Bootloader是老大,它说了算。

我个人习惯把Bootloader分成两个阶段:

  • 第一阶段:汇编写的,极简。关看门狗、初始化时钟、设置堆栈。然后跳转到C环境。
  • 第二阶段:C语言写的。做更复杂的初始化,比如外设、内存、文件系统。最后加载固件。

为什么分两阶段?因为汇编里做复杂逻辑太痛苦了。我早期一个项目,用汇编写了300行,后来发现一个bug,改得我头皮发麻。从那以后,我坚持第一阶段只做最必要的事,剩下的交给C。

4.2 启动流程:从复位到main函数

咱们一步步看,MCU上电后到底发生了什么。

  1. 复位:硬件复位,PC指向复位向量地址(通常是0x00000000或0x08000000)。
  2. 加载栈指针:从复位向量表取出栈顶地址,初始化SP。
  3. 跳转复位处理函数:执行Reset_Handler,通常是汇编写的。
  4. 系统初始化:调用SystemInit,配置时钟、PLL、Flash等待周期。
  5. 跳转main:调用C库的__main,完成数据段、BSS段初始化,最后进入main函数。

这里有个关键点:向量表重映射。Bootloader和应用程序都有自己的向量表。Bootloader启动时,向量表在Flash起始地址。当它跳转到应用程序前,必须把向量表地址改成应用程序的地址。

重要:如果不做向量表重映射,中断来了会跑回Bootloader的向量表,直接死机。我见过好几个新手在这里翻车。

代码示例,跳转前的关键操作:

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

/* 重置系统时钟,避免外设冲突 */
DeInitPeripherals();

/* 设置主栈指针 */
__set_MSP(*(uint32_t*)APP_ADDR);

/* 获取应用程序复位向量 */
pFunction app_entry = (pFunction)(*(uint32_t*)(APP_ADDR + 4));

/* 跳转 */
app_entry();

注意,跳转前要关中断、清理外设。否则应用程序初始化时可能被残留的中断打断。我曾经因为没关一个定时器中断,跳转后程序跑飞,查了两天才找到原因。

4.3 双区启动(A/B分区)

双区启动,说白了就是准备两份固件。一份运行,一份备用。升级时,把新固件写入备用区,校验通过后切换启动。

为什么需要双区?因为OTA升级有风险。如果升级过程中断电、通信中断,固件写一半,设备就变砖了。双区方案可以保证:总有一个能用的固件

分区布局一般是这样:

区域 起始地址 大小 内容
Bootloader 0x08000000 64KB 启动代码
分区A 0x08010000 256KB 应用程序A
分区B 0x08050000 256KB 应用程序B
配置区 0x08090000 4KB 启动标志、版本号

启动流程是这样的:

  1. Bootloader读取配置区,看哪个分区是“有效”的。
  2. 校验该分区的固件完整性(CRC或签名)。
  3. 如果校验通过,跳转执行。
  4. 如果校验失败,尝试另一个分区。
  5. 两个分区都坏了?进入恢复模式,等待重新烧录。

个人经验:我习惯在配置区存一个“启动计数器”。每次启动成功,应用程序会清零这个计数器。如果Bootloader发现计数器超过3次,说明应用程序启动失败,自动回滚到旧版本。这个机制救过我很多次。

切换分区的关键代码:

/* 读取当前启动分区 */
uint8_t active_slot = ReadConfig(&config);
uint32_t app_addr = (active_slot == SLOT_A) ? SLOT_A_ADDR : SLOT_B_ADDR;

/* 校验固件 */
if (VerifyFirmware(app_addr) == PASS) {
    /* 更新启动计数器 */
    config.boot_count++;
    WriteConfig(&config);
    /* 跳转 */
    JumpToApp(app_addr);
} else {
    /* 切换分区 */
    active_slot = (active_slot == SLOT_A) ? SLOT_B : SLOT_A;
    WriteConfigSlot(active_slot);
    /* 复位重试 */
    NVIC_SystemReset();
}

4.4 固件校验与签名

固件校验,保证固件没被篡改、没损坏。签名,保证固件来自合法来源。

最简单的校验是CRC32。计算整个固件的CRC,存到固件头部。Bootloader加载前重新计算,对比一下。不一致就拒绝启动。

但CRC只能检测错误,不能防篡改。如果有人恶意修改固件,重新算个CRC贴上,你根本发现不了。所以,需要数字签名

签名流程:

  • 开发阶段:用私钥对固件哈希值加密,生成签名。把签名附加到固件尾部。
  • 启动阶段:Bootloader用公钥解密签名,得到哈希值。再自己算一遍固件哈希。两者一致,说明固件没被改过。

常用的算法是RSA或ECDSA。RSA签名长度大(256字节),但实现简单。ECDSA签名短(64字节),适合资源受限的MCU。

注意:公钥要固化在Bootloader里,不能存在可擦写的Flash中。否则攻击者可以替换公钥,绕过签名校验。我见过一个产品,公钥存在外部Flash里,结果被轻松破解。

代码示例,ECDSA签名校验的简化流程:

/* 计算固件哈希 */
uint8_t hash[32];
SHA256_Calculate(firmware_addr, firmware_size, hash);

/* 从固件尾部提取签名 */
uint8_t signature[64];
memcpy(signature, firmware_addr + firmware_size, 64);

/* 用公钥验证签名 */
if (ECDSA_Verify(public_key, hash, signature) == PASS) {
    /* 签名有效,启动 */
    JumpToApp(firmware_addr);
} else {
    /* 签名无效,拒绝启动 */
    EnterRecoveryMode();
}

这里有个细节:固件头部。我习惯在固件开头放一个结构体,包含魔数、版本号、固件大小、CRC、签名等信息。Bootloader先读头部,再决定怎么校验。

typedef struct {
    uint32_t magic;        // 魔数,比如0xDEADBEEF
    uint32_t version;      // 固件版本
    uint32_t size;         // 固件大小
    uint32_t crc32;        // CRC32校验值
    uint8_t  signature[64];// ECDSA签名
} firmware_header_t;

校验时,先检查魔数,再检查CRC,最后验签名。三级检查,层层递进。魔数可以快速判断是不是有效的固件,CRC可以快速检测传输错误,签名才是最终的安全保障。

4.5 避坑指南

最后,分享几个我踩过的坑:

  • 中断优先级:跳转前一定要把中断优先级重置为默认值。否则应用程序里设置的中断优先级可能和Bootloader冲突。
  • 外设状态:DMA、定时器这些外设,跳转前要彻底复位。我遇到过DMA还在传输,跳转后直接写飞内存。
  • 看门狗:Bootloader里要喂狗,但跳转前最好关掉。否则应用程序还没来得及初始化,看门狗就复位了。
  • Flash等待周期:如果Bootloader和应用程序跑在不同频率,跳转前要重新配置Flash等待周期。否则读Flash会出错。

嗯,这一章的内容就到这里。Bootloader看似简单,但每个细节都关系到系统的稳定性。下一章,我们会深入Flash驱动,讲讲擦写寿命、磨损均衡这些实战问题。

记住一句话:Bootloader是系统的守门员,守好了,系统才稳。