3. Bootloader最小系统实现:编写最简单的Bootloader、跳转到App、验证跳转是否成功、LED指示状态
好,咱们进入实战环节。前面两章把IAP的概念和存储规划讲清楚了,这一章我们直接动手,写一个能跑起来的Bootloader最小系统。
什么叫最小系统?说白了就是:上电后检查一下,然后跳转到App,没了。没有通信协议,没有校验,没有超时处理。但就是这个最简单的框架,能帮你把Bootloader的核心逻辑彻底搞明白。
我个人习惯,做复杂系统前先搭一个最小原型。跑通了,再往上加功能。不然一上来就搞串口协议+CRC校验+超时重传,出了问题你根本不知道是跳转没成功,还是协议解析有bug。
3.1 Bootloader到底要做什么?
先想清楚一件事:Bootloader本质上就是一个引导程序。它的生命周期只有几毫秒到几秒,干完活就“死”了——把控制权交给App。
最小系统的Bootloader只需要做三件事:
- 初始化:关中断、设堆栈、配时钟(尽量少做)
- 决策:判断要不要升级?还是直接跳App?
- 跳转:修改向量表,跳转到App入口
嗯,这里要注意:初始化越少越好。为什么?因为App启动时也会做初始化,你如果在Bootloader里把外设配好了,跳过去App再配一遍,可能冲突,也可能把Bootloader配好的状态搞乱。
3.2 存储布局与链接脚本
写Bootloader之前,得先定好Flash怎么分。我以STM32F103为例,它有个特点:Flash从0x08000000开始,每页1KB或2KB(看型号)。
| 区域 | 起始地址 | 大小 | 内容 |
|---|---|---|---|
| Bootloader | 0x08000000 | 16KB | 引导程序 + 中断向量表 |
| App | 0x08004000 | 剩余Flash | 应用程序 + 中断向量表 |
16KB够写一个功能完整的Bootloader了。App从0x08004000开始,也就是偏移了16KB。
链接脚本怎么改?以STM32的GCC工具链为例,Bootloader工程的链接脚本里,Flash起始地址保持0x08000000不变。App工程的链接脚本要改成:
/* App工程的链接脚本片段 */
MEMORY
{
FLASH (rx) : ORIGIN = 0x08004000, LENGTH = 240K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 64K
}
同时,App代码里要告诉编译器:我的中断向量表不在0x08000000了,在0x08004000。这个通过修改VECT_TAB_OFFSET宏实现:
/* App的main函数开头 */
SCB->VTOR = 0x08004000; /* 设置向量表偏移 */
3.3 最简单的Bootloader代码
好,直接上代码。这个Bootloader不做任何通信,上电后延时500ms,然后直接跳转到App。
/* bootloader.c - 最小系统Bootloader */
#include "stm32f1xx.h"
#include <string.h>
/* App起始地址:0x08004000 */
#define APP_ADDRESS 0x08004000
/* 函数指针类型:指向App的main函数 */
typedef void (*pFunction)(void);
void Delay_ms(uint32_t ms)
{
/* 简单延时,实际项目用SysTick */
for(uint32_t i = 0; i < ms * 4000; i++)
{
__NOP();
}
}
void JumpToApp(void)
{
uint32_t app_stack;
pFunction app_main;
/* 1. 关闭全局中断 */
__disable_irq();
/* 2. 关闭所有外设中断(可选,但推荐) */
for(uint32_t i = 0; i < 8; i++)
{
NVIC->ICER[i] = 0xFFFFFFFF;
NVIC->ICPR[i] = 0xFFFFFFFF;
}
/* 3. 读取App的栈顶指针(向量表第一个字) */
app_stack = *(volatile uint32_t*)APP_ADDRESS;
/* 4. 读取App的复位向量(向量表第二个字) */
app_main = (pFunction)(*(volatile uint32_t*)(APP_ADDRESS + 4));
/* 5. 检查栈顶指针是否合法(通常在RAM范围内) */
if((app_stack < 0x20000000) || (app_stack > 0x20010000))
{
/* 栈指针不合法,不跳转 */
while(1);
}
/* 6. 设置主栈指针 */
__set_MSP(app_stack);
/* 7. 跳转到App的复位向量 */
app_main();
}
int main(void)
{
/* 初始化LED引脚(假设PA5接LED) */
RCC->APB2ENR |= RCC_APB2ENR_IOPAEN;
GPIOA->CRL &= ~(0xF << 20); /* PA5清零 */
GPIOA->CRL |= (0x02 << 20); /* PA5推挽输出 */
/* LED闪烁3次,表示Bootloader正在运行 */
for(int i = 0; i < 3; i++)
{
GPIOA->BSRR = GPIO_BSRR_BS5; /* 亮 */
Delay_ms(200);
GPIOA->BSRR = GPIO_BSRR_BR5; /* 灭 */
Delay_ms(200);
}
/* 延时500ms后跳转 */
Delay_ms(500);
JumpToApp();
/* 正常情况下不会执行到这里 */
while(1);
}
这段代码的核心在JumpToApp()函数里。我解释一下每一步在干什么:
- 关中断:跳转过程中不能有任何中断打扰,否则栈指针一换,中断处理就崩了。
- 读栈顶指针:App的向量表第一个字存的是栈顶地址,我们要把这个值写到MSP里。
- 读复位向量:向量表第二个字是复位中断的入口地址,也就是App的
main()函数入口。 - 合法性检查:我习惯加一个范围判断,防止App区是空的或者数据被破坏,跳到一个非法地址。
- 设置MSP并跳转:先设栈指针,再跳转。顺序不能反,否则跳过去栈就乱了。
3.4 验证跳转是否成功
代码写完了,怎么知道跳转成功了?我一般用两种方法验证:
方法一:LED状态指示
Bootloader里让LED闪烁3次,App里让LED常亮或闪烁不同次数。这样一看就知道:
- 如果LED先闪3次,然后变成常亮 → 跳转成功
- 如果LED一直闪3次循环 → 跳转失败,复位了
- 如果LED不亮 → 程序根本没跑起来
App代码里加一行LED控制:
/* App的main函数开头 */
int main(void)
{
/* 设置向量表偏移 */
SCB->VTOR = 0x08004000;
/* 初始化LED */
RCC->APB2ENR |= RCC_APB2ENR_IOPAEN;
GPIOA->CRL &= ~(0xF << 20);
GPIOA->CRL |= (0x02 << 20);
/* LED常亮,表示App已运行 */
GPIOA->BSRR = GPIO_BSRR_BS5;
while(1);
}
方法二:调试器观察
用J-Link或ST-Link连上,在JumpToApp()的app_main()调用处设断点。单步执行后,看PC指针是否跳到了0x08004000+4的位置。如果PC指向了App区域,说明跳转成功。
3.5 跳转失败的原因排查
如果你发现跳转没成功,别慌。我列几个最常见的原因:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 跳转后跑飞(HardFault) | App的向量表偏移没设 | 检查App代码中SCB->VTOR的设置 |
| 跳转后无反应 | App区没有有效代码 | 检查Flash是否烧录了App |
| LED一直闪3次 | 跳转后复位了 | 检查看门狗是否在Bootloader里开了 |
| 跳转后外设工作异常 | Bootloader初始化了外设没关 | 跳转前复位所有外设寄存器 |
我曾经遇到过一个特别隐蔽的问题:Bootloader里开了某个外设的时钟,跳转后App重新初始化这个外设时,没有先复位外设寄存器,结果配置没生效。从那以后,我在JumpToApp()里加了一段代码,把所有外设时钟都关了再跳:
/* 关闭所有外设时钟(RCC寄存器) */
RCC->APB1ENR = 0;
RCC->APB2ENR = 0;
RCC->AHBENR = 0;
3.6 本章核心逻辑:Bootloader跳转流程图
下面这张图把整个流程串起来了。你可以把它贴在工位旁边,写代码时对照着看。
3.7 避坑指南与经验总结
写Bootloader最小系统,有几个坑我替你们踩过了:
- 中断向量表对齐:App的起始地址必须是Flash页大小的整数倍。STM32F103的页是1KB或2KB,所以0x08004000(16KB)没问题。如果你用0x08004100,那就等着HardFault吧。
- 栈指针检查不能省:如果App区是空的(全0xFF),读出来的栈指针就是0xFFFFFFFF,设到MSP里直接崩。加个范围检查,几行代码的事。
- 跳转前关所有中断:不只是全局中断,外设的中断标志位也要清。我习惯把NVIC的所有中断挂起位都清了再跳。
- App的链接脚本要单独维护:Bootloader和App用不同的链接脚本,别混了。我见过有人把App烧到Bootloader的地址,然后说“怎么跳不过去”——当然跳不过去,因为App把自己覆盖了。