3. Bootloader 架构设计:内存规划、工作模式与状态机
好,咱们进入第三章。这一章很关键,说白了就是给 Bootloader 和 App 画地盘、定规矩。你想想看,一个系统里 Bootloader 和 App 要共存,谁住哪、谁先跑、怎么交接,这些不规划清楚,后面全是坑。
我个人习惯,在写第一行代码之前,先把内存分区图画出来。别急着敲键盘,画图花不了十分钟,但能省你后面几天调试时间。
3.1 Bootloader 与 App 的内存分区规划
STM32 的 Flash 是从 0x08000000 开始的。Bootloader 和 App 各占一段,互不干扰。我见过不少新手把 App 直接放在 0x08000000,结果 Bootloader 没地方放,或者一升级就把自己给擦了——嗯,这属于“自杀式设计”。
下面是一个典型的分区方案,以 STM32F103C8T6(64KB Flash)为例:
| 分区 | 起始地址 | 大小 | 内容 |
|---|---|---|---|
| Bootloader | 0x08000000 | 16 KB | 启动代码、升级逻辑、Flash 驱动 |
| App 向量表 | 0x08004000 | — | App 中断向量表起始 |
| App 代码区 | 0x08004000 | 40 KB | 用户应用程序 |
| 参数存储区 | 0x0800E000 | 4 KB | 升级标志、版本号、校验值 |
| 备份/暂存区 | 0x0800F000 | 4 KB | 升级过程中暂存数据 |
关键点:Bootloader 的大小必须预留足够。我建议至少留 16KB,哪怕你现在的 Bootloader 只有 8KB。为什么?因为后面要加安全校验、加密解密、双备份,代码量会翻倍。我曾经在一个项目里只留了 12KB,结果加了个 SHA256 校验就爆了,最后不得不重新分区,所有固件都得重刷——血的教训。
App 的起始地址不是随便定的。它必须是 0x200 的倍数,因为 STM32 的向量表对齐要求是 2^N,最小 256 字节对齐。实际项目中我一般按 1KB 对齐,省心。
还有一个容易被忽略的点:参数存储区。这个区域用来存升级标志、版本号、CRC 校验值。我习惯把它放在 Flash 的最后几页,这样即使 Bootloader 或 App 的大小变了,参数区地址也不受影响。
3.2 Bootloader 的两种工作模式
Bootloader 就干两件事:要么直接跳转到 App,要么等着接收新固件。这就是所谓的直通模式和升级模式。
3.2.1 直通模式(Pass-Through Mode)
直通模式最简单。上电后 Bootloader 检查升级标志,如果没有升级请求,直接跳转到 App 入口。整个过程几十毫秒,用户几乎感觉不到 Bootloader 的存在。
跳转代码长这样:
/* 跳转到 App 入口 */
void jump_to_app(uint32_t app_addr)
{
uint32_t stack_ptr = *(volatile uint32_t*)app_addr;
uint32_t entry = *(volatile uint32_t*)(app_addr + 4);
/* 关闭全局中断 */
__disable_irq();
/* 重置所有外设(可选,但推荐) */
deinit_all_peripherals();
/* 设置主堆栈指针 */
__set_MSP(stack_ptr);
/* 跳转 */
void (*app_entry)(void) = (void (*)(void))entry;
app_entry();
}
我的习惯:跳转前一定要关闭所有中断,并且把用过的外设反初始化。否则 App 启动时可能会触发意外中断,或者外设状态混乱。我踩过这个坑——跳转后串口一直在发乱码,查了两天才发现是 DMA 没关干净。
3.2.2 升级模式(Update Mode)
升级模式就复杂多了。Bootloader 需要初始化通信接口(UART、SPI、USB 或 CAN),接收固件数据,写入 Flash,最后校验。这个过程中 Bootloader 是主角,App 完全不参与。
升级模式的触发方式有几种:
- GPIO 检测:上电时检测某个引脚电平,拉低就进升级模式。我最常用的方式,简单可靠。
- 标志位检测:检查参数区的升级标志。App 收到升级指令后,在复位前写一个标志,Bootloader 看到标志就进升级模式。
- 超时检测:上电后等待几秒,如果收到特定命令就进升级模式,否则跳 App。适合没有额外引脚的场景。
- 强制模式:如果 App 校验失败或不存在,自动进入升级模式。这是保底方案。
注意:升级过程中绝对不能断电!我建议在硬件上做掉电检测,或者在软件里做断点续传。否则写到一半断电,Flash 里的数据就毁了,设备变砖。我在一个工业项目里吃过这个亏,后来加了双备份区才解决。
3.3 状态机设计
Bootloader 本质上是一个有限状态机。把状态理清楚,代码结构就清晰了。我习惯用枚举定义状态,用 switch-case 实现状态流转。
下面是一个典型的状态机定义:
typedef enum {
BOOT_STATE_INIT, // 初始化
BOOT_STATE_CHECK, // 检查升级标志
BOOT_STATE_JUMP_APP, // 跳转到 App
BOOT_STATE_WAIT_CMD, // 等待升级命令
BOOT_STATE_RECV_DATA, // 接收固件数据
BOOT_STATE_VERIFY, // 校验固件
BOOT_STATE_UPDATE_DONE, // 升级完成
BOOT_STATE_ERROR // 错误处理
} boot_state_t;
状态流转图如下:
状态机的核心逻辑在 BOOT_STATE_CHECK 里做决策:
void boot_state_machine(void)
{
boot_state_t state = BOOT_STATE_INIT;
while(1)
{
switch(state)
{
case BOOT_STATE_INIT:
system_init();
state = BOOT_STATE_CHECK;
break;
case BOOT_STATE_CHECK:
if(check_update_flag())
{
state = BOOT_STATE_WAIT_CMD;
}
else
{
state = BOOT_STATE_JUMP_APP;
}
break;
case BOOT_STATE_JUMP_APP:
jump_to_app(APP_ADDR);
// 不会返回
break;
case BOOT_STATE_WAIT_CMD:
if(receive_command() == CMD_START_UPDATE)
{
state = BOOT_STATE_RECV_DATA;
}
else if(check_timeout())
{
state = BOOT_STATE_JUMP_APP;
}
break;
case BOOT_STATE_RECV_DATA:
if(receive_firmware() == RECV_COMPLETE)
{
state = BOOT_STATE_VERIFY;
}
else if(receive_error())
{
state = BOOT_STATE_ERROR;
}
break;
case BOOT_STATE_VERIFY:
if(verify_firmware() == VERIFY_PASS)
{
state = BOOT_STATE_UPDATE_DONE;
}
else
{
state = BOOT_STATE_ERROR;
}
break;
case BOOT_STATE_UPDATE_DONE:
clear_update_flag();
system_reset();
break;
case BOOT_STATE_ERROR:
error_handler();
// 等待看门狗复位或手动复位
break;
}
}
}
设计建议:每个状态都要有超时处理。比如 BOOT_STATE_WAIT_CMD 如果等不到命令,超时后自动跳 App,而不是死等。我在一个产品里忘了加超时,结果产线测试时有一批板子 Bootloader 卡在等待状态,全部返工——丢人。
3.4 分区规划与状态机的联动
分区规划和状态机不是孤立的。举个例子,参数存储区里存的升级标志,就是状态机在 BOOT_STATE_CHECK 阶段读取的。而暂存区的大小,决定了 BOOT_STATE_RECV_DATA 阶段是一次性接收还是分块接收。
我常用的参数区结构体:
typedef struct {
uint32_t magic; // 魔数,0xA5A5A5A5 表示有效
uint32_t update_flag; // 升级标志
uint32_t app_version; // App 版本号
uint32_t boot_version; // Bootloader 版本号
uint32_t crc32; // 整个参数区的 CRC 校验
} boot_param_t;
魔数的作用是判断参数区是否被初始化过。如果读出来不是 0xA5A5A5A5,说明这是第一次上电,或者 Flash 被擦除了,Bootloader 应该走默认流程。