Bootloader 设计原理:从 OTA 到安全启动
Bootloader 设计原理:从 OTA 到安全启动
在嵌入式开发里,Bootloader 是那种”平时想不起它,出问题时第一个怀疑它”的组件。它横跨硬件上电和用户业务之间,既是系统能否启动的第一道关卡,也是设备量产后能否远程升级的关键基础设施。这篇文章我结合自己写过的几个量产项目,把 Bootloader 从启动流程、OTA 升级到安全启动的设计要点串一遍。
一、Bootloader 到底在做什么
简单说,Bootloader 是芯片上电后、跳转到应用程序之前运行的一段代码。它的职责可以归纳成几件事:
- 初始化最小硬件环境(时钟、Flash、串口)
- 决定是进入升级模式还是直接运行 App
- 校验 App 的完整性(可选的 CRC / 签名)
- 跳转到 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();
}
有几个容易踩的坑值得单独说:
- 中断没关干净。如果 SysTick 还开着,跳转后一旦 App 里还没配好 SysTick 处理函数,就可能触发 HardFault。
- 时钟残留。Bootloader 里如果用了 PLL 把主频拉高,App 又假设是默认 HSI,就可能跑飞。
HAL_RCC_DeInit()能解决大部分问题。 - 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 表示通过 */
}
一个完整的信任链通常分三级:
- ROT(Root of Trust,信任根):芯片内置的不可篡改的密钥或哈希,比如 STM32H7 的 Secure Boot 复用 OTP 存储公钥
- Bootloader 验签 App:确保被加载的 App 是可信的
- App 可选的二次验签:加载子程序/插件时继续校验
另外安全启动往往还叠加 防回滚(Anti-rollback) 机制:记录当前已烧录的最高版本号,拒绝运行低于该版本的固件,防止攻击者把有漏洞的旧版本刷回去利用。
五、一些实战经验
写了这么多项目,我总结几条反复验证过的经验:
- 一定要留”安全兜底”通道。哪怕升级逻辑写得多完美,也要保证最坏情况下设备还能通过串口/USB 重新烧录。很多设备变砖,不是 Bootloader 逻辑差,而是没给自己留后路。
- 升级区要保持”幂等”。每次升级流程开始前,先把升级区整块擦掉,避免残留脏数据被误判成新固件。
- 日志和状态机要清晰。Bootloader 里建议维护一个
boot_state状态机(IDLE → DOWNLOADING → VERIFYING → WRITING → DONE),每步写进参数区,复位后能判断上次卡在哪一步。 - 跳转前关中断,这个坑我踩过不止一次,值得反复强调。
- 量产前做断电耐操测试。在搬运过程中随机断电几千次,观察设备能否自恢复,这是检验 Bootloader 健壮性的金标准。
结语
Bootloader 本身代码量不大,但它是嵌入式系统里”牵一发动全身”的部分。从最小启动到 OTA,再到安全启动,本质都是围绕一个问题:如何在保证系统可靠上电的前提下,让设备在全生命周期内可维护、可升级、可信赖。
如果你正准备上手,我建议的顺序是:先把最小启动和跳转跑通,再上单区 OTA,稳定之后再引入双区和验签。循序渐进,每一步都能验证,比一开始就堆满功能要靠谱得多。