一、引言
如果你的设备已经在千里之外的客户现场,发现了一个致命的 bug,你会怎么做?派人飞过去用 J-Link 烧录?还是------通过网络远程升级固件?
这就是 OTA(Over-The-Air)固件升级的价值。而 OTA 的核心,就是 Bootloader。
本文从零开始,带你设计一个工业级的 Bootloader 系统:
-
Bootloader 的三种架构设计
-
Flash 分区策略与链接脚本
-
固件校验(CRC32/SHA256)
-
升级流程的状态机设计
-
安全启动与固件签名
-
差分升级(Delta OTA)原理
平台:STM32F407(1MB Flash)+ ESP8266(WiFi) 适用:所有 Cortex-M 系列 MCU
二、Bootloader 架构设计
2.1 三种核心架构
┌────────────────────────────────────────────────────────┐
│ 架构 A:双区模式(A/B Partition) │
│ │
│ ┌──────────┬─────────────┬─────────────┬────────────┐ │
│ │Bootloader│ APP_A │ APP_B │ Download │ │
│ │ (48KB) │ (448KB) │ (448KB) │ Area │ │
│ │0x08000000│ 0x0800C000 │ 0x0807C000 │ │ │
│ └──────────┴─────────────┴─────────────┴────────────┘ │
│ │
│ 升级流程: │
│ APP_A 运行时下载新固件到 APP_B │
│ → 校验通过 → Bootloader 切换启动到 APP_B │
│ → 下次升级从 APP_B 写回 APP_A │
│ │
│ 优点:永远有一个可运行的版本(回滚安全) │
│ 缺点:Flash 利用率仅 50% │
├────────────────────────────────────────────────────────┤
│ 架构 B:单区 + 下载缓存模式 │
│ │
│ ┌──────────┬──────────────────┬──────────────┐ │
│ │Bootloader│ APP │ Download │ │
│ │ (48KB) │ (512KB) │ Buffer │ │
│ │0x08000000│ 0x0800C000 │ (448KB) │ │
│ └──────────┴──────────────────┴──────────────┘ │
│ │
│ 升级流程: │
│ 先下载新固件到 Download Buffer │
│ → 校验通过 → 重启到 Bootloader │
│ → Bootloader 将 Download Buffer 覆盖 APP │
│ │
│ 优点:APP 空间更大 │
│ 缺点:覆盖期间断电 → 变砖!需要外置看门狗恢复机制 │
├────────────────────────────────────────────────────────┤
│ 架构 C:外部 Flash 模式 │
│ │
│ ┌──────────┬──────────────────────────┐ │
│ │Bootloader│ APP (整个内部 Flash) │ │
│ │ (48KB) │ (976KB) │ │
│ │0x08000000│ 0x0800C000 │ │
│ └──────────┴──────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ 外部 SPI Flash (W25Q64, 8MB) │ │
│ │ → 存储新固件镜像 │ │
│ └──────────────────────────────────────┘ │
│ │
│ 优点:APP 可用全部内部 Flash,冗余在外置 Flash │
│ 缺点:需要额外芯片,BOM 成本增加 │
└────────────────────────────────────────────────────────┘
推荐: 对于 STM32F407(1MB Flash),推荐架构 A(双区模式)。512KB 的 APP 空间对大多数应用足够,双区带来的回滚安全性是最重要的工程保障。
2.2 Flash 分区的链接脚本实现
/* flash_app_a.ld —— APP_A 的链接脚本 */
MEMORY
{
FLASH (rx) : ORIGIN = 0x0800C000, LENGTH = 448K /* APP_A 起始 */
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
_estack = ORIGIN(RAM) + LENGTH(RAM); /* 栈顶 */
SECTIONS
{
.isr_vector :
{
KEEP(*(.isr_vector))
} > FLASH
.text : { *(.text*) } > FLASH
.rodata : { *(.rodata*) } > FLASH
.data : { /* ... */ } > RAM AT > FLASH
.bss : { /* ... */ } > RAM
}
/* flash_app_b.ld —— APP_B 的链接脚本(关键差异:起始地址不同!)*/
MEMORY
{
FLASH (rx) : ORIGIN = 0x0807C000, LENGTH = 448K /* ★ APP_B 起始地址 */
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
2.3 为什么 APP 要偏移向量表?
// 在 APP 的 system_stm32f4xx.c 中
// ★ 关键:修改 VTOR(向量表偏移寄存器)
#define VECT_TAB_OFFSET 0x0000C000 // APP_A 的偏移
void SystemInit(void)
{
// ...
// 对于 APP_A:VTOR = 0x08000000 + 0x0000C000 = 0x0800C000
// 对于 APP_B:VTOR = 0x08000000 + 0x0007C000 = 0x0807C000
SCB->VTOR = FLASH_BASE | VECT_TAB_OFFSET;
}
三、Bootloader 的核心逻辑
3.1 Bootloader 主流程
// bootloader_main.c
#define APP_A_ADDR 0x0800C000
#define APP_B_ADDR 0x0807C000
#define APP_FLAG_ADDR 0x0800FFE0 // 最后一个扇区的末尾(存启动标志)
typedef enum {
APP_SLOT_A = 0,
APP_SLOT_B = 1,
} app_slot_t;
typedef struct {
uint32_t magic; // 魔数:0xAA55A5A5
uint32_t active_slot; // 当前活动槽位
uint32_t app_size; // APP 大小
uint32_t app_crc; // APP 的 CRC32
} boot_info_t;
void Bootloader_Main(void)
{
boot_info_t boot_info;
// 1. 读取启动信息
ReadBootInfo(&boot_info);
// 2. 校验启动信息
if (boot_info.magic != 0xAA55A5A5) {
// 首次启动,默认从 APP_A 启动
boot_info.active_slot = APP_SLOT_A;
boot_info.magic = 0xAA55A5A5;
WriteBootInfo(&boot_info);
}
// 3. 确定要启动的 APP 地址
uint32_t app_addr = (boot_info.active_slot == APP_SLOT_A)
? APP_A_ADDR : APP_B_ADDR;
// 4. 校验 APP 栈指针的合法性
uint32_t app_sp = *(volatile uint32_t *)app_addr;
if ((app_sp < 0x20000000) || (app_sp > 0x20020000)) {
// 栈地址不合法 → 尝试另一个槽位
app_addr = (boot_info.active_slot == APP_SLOT_A)
? APP_B_ADDR : APP_A_ADDR;
app_sp = *(volatile uint32_t *)app_addr;
if ((app_sp < 0x20000000) || (app_sp > 0x20020000)) {
// 两个槽位都无效 → 进入下载模式
EnterDownloadMode();
return;
}
}
// 5. CRC 校验
uint32_t app_reset_handler = *(volatile uint32_t *)(app_addr + 4);
uint32_t app_size = GetAppSize(app_addr); // 从固件头读取
uint32_t calc_crc = CRC32_Calc((uint8_t *)app_addr, app_size);
if (calc_crc != boot_info.app_crc) {
// CRC 不匹配 → 尝试另一个槽位
// ...
}
// 6. ★ 关键的跳转操作
JumpToApp(app_addr);
}
3.2 跳转到 APP 的汇编实现
// 跳转到 APP 的关键:恢复默认环境 + 设置正确的栈和 PC
void JumpToApp(uint32_t app_addr)
{
typedef void (*app_entry_t)(void);
app_entry_t app_entry;
uint32_t app_stack;
// 1. 关闭所有中断(NVIC 清空)
for (int i = 0; i < 8; i++) {
NVIC->ICER[i] = 0xFFFFFFFF; // 清除使能
NVIC->ICPR[i] = 0xFFFFFFFF; // 清除挂起
}
// 2. 关闭 SysTick
SysTick->CTRL = 0;
// 3. 设置 MSP(主栈指针)
app_stack = *(volatile uint32_t *)app_addr; // 向量表第一项 = 初始 SP
__set_MSP(app_stack); // 直接用内核寄存器操作
// 4. 设置 VTOR
SCB->VTOR = app_addr;
// 5. 读复位向量(向量表第二项 = 复位处理函数地址)
app_entry = (app_entry_t)(*(volatile uint32_t *)(app_addr + 4));
// 6. 跳转!(不再返回)
app_entry();
}
; 等价的汇编实现(更底层、更安全)
JumpToApp:
LDR SP, [R0] ; R0 = app_addr,第一项赋值给 SP
LDR R1, [R0, #4] ; 第二项 = 复位处理函数
BX R1 ; 跳转,永不再返回
四、固件格式设计与校验
4.1 自定义固件头
// firmware_header.h
#define FIRMWARE_MAGIC 0x46574D53 // "FWMS"
typedef struct __attribute__((packed)) {
uint32_t magic; // 魔数
uint32_t version; // 版本号(如 0x00010002 = v1.0.2)
uint32_t size; // 固件体大小(不含头部)
uint32_t crc32; // 固件体的 CRC32
uint32_t timestamp; // 构建时间戳
uint32_t hw_version; // 硬件版本兼容性
uint8_t signature[64]; // ECDSA 签名(可选)
uint8_t reserved[36]; // 保留,补齐 128 字节
} firmware_header_t;
// 完整的固件镜像 = firmware_header_t + 固件体(.bin)
4.2 CRC32 vs SHA256
// CRC32:快速但仅检测随机错误
uint32_t CRC32_Calc(const uint8_t *data, uint32_t len)
{
uint32_t crc = 0xFFFFFFFF;
for (uint32_t i = 0; i < len; i++) {
crc ^= data[i];
for (int j = 0; j < 8; j++) {
if (crc & 1)
crc = (crc >> 1) ^ 0xEDB88320; // 反射多项式
else
crc >>= 1;
}
}
return ~crc;
}
// 对比:CRC32 vs SHA256
// ┌──────────┬──────────┬────────────────────┬──────────┐
// │ 算法 │ 输出长度 │ 防篡改 │ 计算速度 │
// ├──────────┼──────────┼────────────────────┼──────────┤
// │ CRC32 │ 4 字节 │ ❌ 可伪造(线性运算)│ ★★★★★ │
// │ SHA256 │ 32 字节 │ ✅ 抗碰撞 │ ★★☆☆☆ │
// └──────────┴──────────┴────────────────────┴──────────┘
// 工程实践:传输校验用 CRC32,完整性验证用 SHA256
// CRC32 放置在固件头中做快速完整性检查
// SHA256 用于固件签名验证(安全启动)
4.3 固件打包工具
# pack_firmware.py —— 固件打包脚本
import struct
import zlib
import time
import sys
MAGIC = 0x46574D53
def pack_firmware(bin_path, version_str, output_path):
with open(bin_path, 'rb') as f:
fw_body = f.read()
# 解析版本号
major, minor, patch = map(int, version_str.split('.'))
version = (major << 16) | (minor << 8) | patch
# 计算 CRC32
crc = zlib.crc32(fw_body) & 0xFFFFFFFF
# 构建头部
header = struct.pack('<IIIII',
MAGIC, # magic
version, # version
len(fw_body), # size
crc, # crc32
int(time.time()) # timestamp
)
# 补齐到 128 字节
header += b'\x00' * (128 - len(header))
# 写入
with open(output_path, 'wb') as f:
f.write(header + fw_body)
print(f"Packed: {output_path}")
print(f" Version: v{major}.{minor}.{patch}")
print(f" Size: {len(fw_body)} bytes")
print(f" CRC32: 0x{crc:08X}")
if __name__ == '__main__':
pack_firmware('firmware.bin', '1.0.2', 'firmware.fw')
五、OTA 升级状态机
5.1 完整的升级流程
┌───────────────┐
│ 正常运行 │
│ (APP 模式) │
└───┬───────┬───┘
│ │
收到升级指令│ │ 下载失败 / 校验失败
▼ │
┌──────────────────┐│
│ 下载新固件 ││
│ (分段下载+校验) ││
└───────┬──────────┘│
│ │
下载完成 │
CRC 通过 │
▼ │
┌──────────────┐ │
│ 写入升级标志 │ │
│ 重启系统 │ │
└──────┬───────┘ │
│ │
▼ │
┌──────────────┐ │
│ Bootloader │ │
│ 阶段1: 校验 │ │
│ 新固件 CRC │───┘ CRC 失败 → 回退旧固件
└──────┬───────┘
│ CRC 通过
▼
┌──────────────┐
│ Bootloader │
│ 阶段2: 切换 │
│ 活动槽位 │
└──────┬───────┘
│
▼
┌──────────────┐
│ 启动新固件 │
└──────────────┘
5.2 代码实现
// ota_manager.c
typedef enum {
OTA_STATE_IDLE,
OTA_STATE_DOWNLOADING,
OTA_STATE_VERIFYING,
OTA_STATE_READY_TO_SWITCH,
OTA_STATE_FAILED,
} ota_state_t;
static ota_state_t ota_state = OTA_STATE_IDLE;
static uint32_t ota_bytes_received = 0;
static uint32_t ota_total_bytes = 0;
static FIL ota_file;
// 收到 OTA 开始命令
int OTA_Begin(uint32_t total_size, uint32_t crc_expected)
{
// 1. 打开下载文件(写入到非活动槽位对应的 Flash 区域)
app_slot_t target_slot = GetInactiveSlot();
// 2. 如果目标槽位在外部 Flash,挂载文件系统
if (f_open(&ota_file, "firmware.fw", FA_WRITE | FA_CREATE_ALWAYS) != FR_OK) {
return -1;
}
ota_total_bytes = total_size;
ota_bytes_received = 0;
ota_state = OTA_STATE_DOWNLOADING;
return 0;
}
// 接收固件数据块
int OTA_ReceiveChunk(const uint8_t *data, uint16_t len)
{
if (ota_state != OTA_STATE_DOWNLOADING) return -1;
UINT bw;
f_write(&ota_file, data, len, &bw);
ota_bytes_received += len;
if (ota_bytes_received >= ota_total_bytes) {
f_close(&ota_file);
ota_state = OTA_STATE_VERIFYING;
}
// 返回进度百分比
return (ota_bytes_received * 100) / ota_total_bytes;
}
// 完成校验
int OTA_VerifyAndCommit(void)
{
if (ota_state != OTA_STATE_VERIFYING) return -1;
// 1. 读取并校验固件头
firmware_header_t header;
// ... 读取文件头部 ...
if (header.magic != FIRMWARE_MAGIC) {
ota_state = OTA_STATE_FAILED;
return -2; // 非法固件
}
// 2. CRC 校验固件体
uint32_t calc_crc = CRC32_File(&ota_file, sizeof(header), header.size);
if (calc_crc != header.crc32) {
ota_state = OTA_STATE_FAILED;
return -3; // CRC 校验失败
}
// 3. 写入启动标志(下次启动时由 Bootloader 识别)
boot_info_t info;
info.magic = 0xAA55A5A5;
info.active_slot = GetInactiveSlot(); // ★ 切换到新槽位
info.app_size = header.size;
info.app_crc = header.crc32;
WriteBootInfo(&info);
ota_state = OTA_STATE_READY_TO_SWITCH;
// 4. 软件复位
NVIC_SystemReset();
// 不会执行到这里
return 0;
}
六、差分升级(Delta OTA)
6.1 原理
差分升级不传输完整固件,只传输新旧版本的差异:
旧固件 (v1.0) ──┐
├──► bsdiff/hdiffpatch ──► 差异包 (patch)
新固件 (v1.1) ──┘ (通常只有固件的 5%~30%)
MCU 端:
旧固件 + patch ──► apply_patch() ──► 新固件
节省的流量 = 1 - (patch_size / full_firmware_size)
典型节省:70%~95%
6.2 在 MCU 上的实现方案
// 简易差分升级——基于块的比较(适合资源有限的 MCU)
#define BLOCK_SIZE 1024 // 1KB 块
typedef enum {
DELTA_CMD_COPY_FROM_OLD = 0, // 从旧固件拷贝
DELTA_CMD_COPY_FROM_NEW = 1, // 从差异包中新数据拷贝
} delta_cmd_t;
typedef struct {
delta_cmd_t cmd;
uint32_t src_offset; // COPY_FROM_OLD 时的源偏移
uint16_t length; // 拷贝长度
// 如果是 COPY_FROM_NEW,数据跟在命令后面
} delta_block_t;
// 应用差异包(最简实现)
int Delta_Apply(const uint8_t *old_fw, const uint8_t *patch,
uint8_t *new_fw, uint32_t new_size)
{
const uint8_t *p = patch;
uint32_t new_offset = 0;
while (new_offset < new_size) {
delta_block_t *block = (delta_block_t *)p;
p += sizeof(delta_block_t);
if (block->cmd == DELTA_CMD_COPY_FROM_OLD) {
memcpy(new_fw + new_offset, old_fw + block->src_offset, block->length);
} else {
memcpy(new_fw + new_offset, p, block->length);
p += block->length;
}
new_offset += block->length;
}
return 0;
}
七、安全启动(Secure Boot)
7.1 信任链
安全启动的信任链:
┌───────────────────────────────────────┐
│ ROM Bootloader(芯片出厂固化) │
│ 验证 Bootloader 签名 → 启动 Bootloader│
├───────────────────────────────────────┤
│ Bootloader(你写的) │
│ 验证 APP 签名 → 启动 APP │ ← 本文的范围
├───────────────────────────────────────┤
│ APP │
│ 运行用户业务逻辑 │
└───────────────────────────────────────┘
签名验证算法:ECDSA(椭圆曲线数字签名算法,ECC-P256)
7.2 固件签名验证实现
// 使用 micro-ecc 库(适合 MCU 的轻量 ECC 库)
#include "uECC.h"
// 编译时嵌入公钥(私钥绝不在固件中!)
static const uint8_t pub_key[64] = {
0x12, 0x34, /* ... 64 字节 ECC-P256 公钥 ... */
};
// 验证固件签名
int SecureBoot_Verify(const uint8_t *firmware, uint32_t fw_size,
const uint8_t *signature, uint32_t sig_size)
{
// 1. 计算固件的 SHA256 哈希
uint8_t hash[32];
SHA256_Calc(firmware, fw_size, hash);
// 2. 用公钥验证 ECDSA 签名
int ret = uECC_verify(pub_key, hash, sizeof(hash), signature, uECC_secp256r1());
return ret; // 1 = 验证通过,0 = 验证失败(固件被篡改或未签名)
}
// 签名工具(在 PC 上运行,绝不在 MCU 上!)
// $ openssl ecparam -genkey -name prime256v1 -out private_key.pem
// $ openssl ec -in private_key.pem -pubout -out public_key.pem
// $ ./sign_firmware.py firmware.bin private_key.pem firmware.signed.fw
八、工业级可靠性增强
8.1 看门狗保护
// Bootloader 中启用独立看门狗
void Bootloader_Init(void)
{
// IWDG: LSI ~40kHz, 预分频 64 → 640Hz
// 重装载值 3200 → 超时约 5 秒
IWDG->KR = 0x5555; // 解除写保护
IWDG->PR = 0x04; // 预分频 64
IWDG->RLR = 3200; // 5 秒超时
IWDG->KR = 0xCCCC; // 启动看门狗
// 在 Flash 写入操作的关键步骤喂狗
// 如果写入超时(Flash 损坏)→ 看门狗复位 → 重试
}
8.2 下载断点续传
// 断点续传支持
typedef struct {
uint32_t total_size;
uint32_t received_size;
uint32_t chunk_size;
uint8_t resumed; // 是否续传
} ota_resume_info_t;
int OTA_Resume(ota_resume_info_t *info)
{
// 从 Flash 中读取上次中断时的进度
ReadResumeInfo(info);
if (info->resumed) {
printf("Resuming from %lu / %lu bytes\r\n",
info->received_size, info->total_size);
// 向服务器请求从 received_size 偏移处继续传输
// HTTP Range: bytes=received_size-
}
return 0;
}
九、总结
| 设计要点 | 推荐方案 |
|---|---|
| 分区策略 | 双区模式(A/B)------回滚最安全 |
| 固件校验 | CRC32(快速)+ SHA256(防篡改) |
| 掉电保护 | 先写完整再切换标志,双区确保至少一个可用 |
| 安全启动 | ECDSA 签名验证(micro-ecc 库) |
| 带宽优化 | 差分升级(bsdiff),典型节省 70%~95% |
| 可靠性 | IWDG + 断点续传 + 下载超时重试 |
| 最小 Bootloader 大小 | ~12KB(优化后可做到 8KB) |
核心原则: Bootloader 的第一要务是确保设备永远不会变砖。宁可升级失败回退旧版本,也不能因为升级导致设备不可用。双区模式 + 看门狗 + 校验机制 = 工业级可靠性。
下一篇预告:嵌入式低功耗设计深度解析