Bootloader 设计原理:从 OTA 到安全启动

2026年8月16日 0 By admin

Bootloader 设计原理:从 OTA 到安全启动

在嵌入式开发里,Bootloader 是那种”平时想不起它,出问题时第一个怀疑它”的组件。它横跨硬件上电和用户业务之间,既是系统能否启动的第一道关卡,也是设备量产后能否远程升级的关键基础设施。这篇文章我结合自己写过的几个量产项目,把 Bootloader 从启动流程、OTA 升级到安全启动的设计要点串一遍。

一、Bootloader 到底在做什么

简单说,Bootloader 是芯片上电后、跳转到应用程序之前运行的一段代码。它的职责可以归纳成几件事:

  1. 初始化最小硬件环境(时钟、Flash、串口)
  2. 决定是进入升级模式还是直接运行 App
  3. 校验 App 的完整性(可选的 CRC / 签名)
  4. 跳转到 App 入口

在普通的 STM32 / GD32 这类 Cortex-M MCU 上,整个 Flash 通常被划分成三段:

+------------------+  0x08000000
|   Bootloader     |  32KB ~ 64KB
+------------------+
|   App 区         |  剩余空间的大部分
+------------------+
|   参数区/数据区   |  少量,存升级标志、版本号
+------------------+

Bootloader 本身占据低位地址,这是芯片复位后固定从 0x08000000(映射到 0x00000000)开始执行决定的。App 则被链接到 Bootloader 之后的某个固定地址,比如 0x08008000。

二、启动流程与跳转细节

复位之后,首先要判断”我这次是要正常跑 App,还是留在 Bootloader 里等待升级”。这里通常用两种信号:

  • 硬件信号:某个 GPIO 电平(比如按住按键上电进入升级模式)
  • 软件信号:参数区里的一个升级标志位,由 App 在收到升级指令后写入

判断完成后,如果是正常启动,核心就是”跳转到 App”。这段代码看起来简单,坑却不少。关键在于跳转前要把 MCU 的状态清理干净:

typedef void (*pFunction)(void);

void jump_to_app(uint32_t app_addr)
{
    /* 关闭全局中断,避免跳转前被打断 */
    __disable_irq();

    /* 关闭所有外设中断,SysTick 也要关 */
    SysTick->CTRL = 0;
    SysTick->LOAD = 0;
    SysTick->VAL  = 0;

    /* 复位 RCC 到默认状态,避免时钟配置残留 */
    HAL_RCC_DeInit();

    /* 关闭已经初始化的外设 */
    HAL_DeInit();

    /* 设置主堆栈指针为 App 向量表的第一个字 */
    uint32_t jump_addr = *((volatile uint32_t *)(app_addr + 4));
    pFunction jump = (pFunction)jump_addr;

    /* 设置向量表偏移,虽然 App 里通常会自己再设一次 */
    SCB->VTOR = app_addr;

    /* 设置 MSP,跳转 */
    __set_MSP(*(volatile uint32_t *)app_addr);
    jump();
}

有几个容易踩的坑值得单独说:

  1. 中断没关干净。如果 SysTick 还开着,跳转后一旦 App 里还没配好 SysTick 处理函数,就可能触发 HardFault。
  2. 时钟残留。Bootloader 里如果用了 PLL 把主频拉高,App 又假设是默认 HSI,就可能跑飞。HAL_RCC_DeInit() 能解决大部分问题。
  3. JTAG/SWD 复用。如果引脚被复用,跳转前要恢复默认状态。

App 侧其实也有一层配合,通常会在 main 最开头重新设置一次向量表偏移,形成双重保险:

int main(void)
{
    SCB->VTOR = FLASH_BASE | APP_OFFSET;
    /* ... */
}

三、OTA 升级:从框架到流程

OTA(Over-The-Air)是 Bootloader 最被看重的功能。一个完整的 OTA 升级链路通常是:

云端下发新固件 → 设备端下载 → 校验 → 写入 Flash → 置升级标志 → 复位 → Bootloader 搬运/切换

这里有一个架构选择:双区(A/B)还是单区+备份

3.1 双区方案(A/B 分区)

Flash 被分成 A、B 两个 App 区。当前跑在 A 区,新固件就下载到 B 区,校验通过后切换启动区。优点是随时可以回滚,缺点是 Flash 占用翻倍。

3.2 单区方案(下载到备份区,再搬运)

很多小容量 MCU Flash 吃紧,常见的做法是:把新固件先下载到一个临时的暂存区,校验通过后由 Bootloader 把暂存区内容搬回 App 区。缺点是搬运期间断电可能变砖,所以一定要配合”搬运开始前写标志、搬完校验后清除标志”的流程。

不管哪种方式,固件的完整性与版本校验都不能省。一个典型的固件包结构可能是:

typedef struct {
    uint32_t magic;        /* 魔数,固定 0x4D495544 */
    uint32_t version;      /* 版本号 */
    uint32_t size;         /* 固件大小 */
    uint32_t crc32;        /* 整包 CRC */
    uint8_t  reserved[16];
} firmware_header_t;

下载完成后做三步校验:magic 对、size 对、crc32 对。任何一步不对就丢弃重来。这里提一句经验之谈:CRC 校验要在搬运/切换之前做,而不是之后,否则坏包直接覆盖掉好固件,设备就真的变砖了。

下面是 Bootloader 里一个简化的升级校验与搬运逻辑:

uint32_t check_and_upgrade(void)
{
    firmware_header_t hdr;
    flash_read(UPGRADE_AREA, &hdr, sizeof(hdr));

    if (hdr.magic != FIRMWARE_MAGIC) {
        return BOOT_NORMAL;   /* 无升级任务 */
    }

    if (hdr.size > APP_MAX_SIZE) {
        goto invalid;
    }

    uint32_t calc = crc32((uint8_t*)UPGRADE_AREA + sizeof(hdr), hdr.size);
    if (calc != hdr.crc32) {
        goto invalid;
    }

    /* 校验通过,开始搬运 */
    flash_erase(APP_AREA, APP_MAX_SIZE);
    flash_write(APP_AREA, UPGRADE_AREA, hdr.size);

    /* 搬运完再校验一次,防止中途断电 */
    if (crc32((uint8_t*)APP_AREA, hdr.size) != hdr.crc32) {
        goto invalid;
    }

    /* 清除升级区,写新版本号,标记启动 */
    save_version(hdr.version);
    return BOOT_JUMP_APP;

invalid:
    /* 升级失败,清区并尝试正常启动(可能还是旧固件) */
    flash_erase(UPGRADE_AREA, UPGRADE_SIZE);
    return BOOT_NORMAL;
}

四、安全启动:校验的不只是完整性

CRC 只能证明”数据没传错”,不能证明”这就是我们发的固件”。一旦设备联网,固件被篡改或注入的风险就是真实存在的,尤其涉及支付、安防、车联网的场合。这时候就需要安全启动(Secure Boot)

安全启动的核心思路是引入非对称加密:出厂时把公钥烧进芯片(或存进 OTP 一次性可编程区),升级时固件包里带上用私钥做的数字签名。Bootloader 在启动或升级时用公钥验签,验签通过才允许运行。

常见算法是 ECDSA(椭圆曲线数字签名),比如用 mbedTLS 或 wolfSSL 实现:

#include "mbedtls/ecdsa.h"

int verify_firmware_signature(const uint8_t *fw, uint32_t fw_len,
                              const uint8_t *sig, const uint8_t *pubkey)
{
    mbedtls_ecdsa_context ecdsa;
    mbedtls_ecdsa_init(&ecdsa);

    /* 先算固件的 SHA-256 摘要 */
    uint8_t hash[32];
    mbedtls_sha256(fw, fw_len, hash, 0);

    /* 解析公钥并验签 */
    mbedtls_ecp_group_load(&ecdsa.grp, MBEDTLS_ECP_DP_SECP256R1);
    mbedtls_ecp_point_read_binary(&ecdsa.grp, &ecdsa.Q, pubkey, 65);

    int ret = mbedtls_ecdsa_read_signature(
        &ecdsa, hash, sizeof(hash), sig, 64);

    mbedtls_ecdsa_free(&ecdsa);
    return ret;   /* 0 表示通过 */
}

一个完整的信任链通常分三级:

  1. ROT(Root of Trust,信任根):芯片内置的不可篡改的密钥或哈希,比如 STM32H7 的 Secure Boot 复用 OTP 存储公钥
  2. Bootloader 验签 App:确保被加载的 App 是可信的
  3. App 可选的二次验签:加载子程序/插件时继续校验

另外安全启动往往还叠加 防回滚(Anti-rollback) 机制:记录当前已烧录的最高版本号,拒绝运行低于该版本的固件,防止攻击者把有漏洞的旧版本刷回去利用。

五、一些实战经验

写了这么多项目,我总结几条反复验证过的经验:

  • 一定要留”安全兜底”通道。哪怕升级逻辑写得多完美,也要保证最坏情况下设备还能通过串口/USB 重新烧录。很多设备变砖,不是 Bootloader 逻辑差,而是没给自己留后路。
  • 升级区要保持”幂等”。每次升级流程开始前,先把升级区整块擦掉,避免残留脏数据被误判成新固件。
  • 日志和状态机要清晰。Bootloader 里建议维护一个 boot_state 状态机(IDLE → DOWNLOADING → VERIFYING → WRITING → DONE),每步写进参数区,复位后能判断上次卡在哪一步。
  • 跳转前关中断,这个坑我踩过不止一次,值得反复强调。
  • 量产前做断电耐操测试。在搬运过程中随机断电几千次,观察设备能否自恢复,这是检验 Bootloader 健壮性的金标准。

结语

Bootloader 本身代码量不大,但它是嵌入式系统里”牵一发动全身”的部分。从最小启动到 OTA,再到安全启动,本质都是围绕一个问题:如何在保证系统可靠上电的前提下,让设备在全生命周期内可维护、可升级、可信赖

如果你正准备上手,我建议的顺序是:先把最小启动和跳转跑通,再上单区 OTA,稳定之后再引入双区和验签。循序渐进,每一步都能验证,比一开始就堆满功能要靠谱得多。