很多人刚接触单片机开发时,对 bootloader 总是有一种“高深莫测”的感觉。后来我自己做产品,才真正意识到它其实就是一段“先于主程序运行的小程序”,并没有想象中复杂。尤其是当你需要给已经出货的设备做固件升级时,IAP(In-Application Programming,应用中编程)几乎是绕不开的方案。这篇文章就把 bootloader 和 IAP 的来龙去脉、核心原理、关键代码盘一遍,争取让看完的朋友能直接上手搭一个最简单的升级方案。
这篇文章适合刚入门 ARM 单片机、正在做产品需要固件升级功能、或者对 STM32 启动流程一知半解的开发者。我会用 STM32F103C8 作为示例平台,从启动流程讲到 Flash 分区,再到串口 IAP 的完整实现,最后把容易踩的坑全部列出来。文章里的代码和思路都可以直接移植到其它 Cortex-M 芯片上,差别只在 Flash 大小和寄存器名。
1. 先搞清楚 bootloader 和 IAP 到底是什么关系
1.1 一句话讲清 bootloader、IAP、OTA 三个名词
很多教程把 bootloader、IAP、OTA 混在一起讲,确实容易把人绕晕。我习惯用一个比喻:bootloader 是小区门口的门卫,APP 是小区里的住户,IAP 是门卫手里那把可以换住户的门禁卡。也就是说,bootloader 是独立于 APP 的一段引导程序,每次上电先跑它;IAP 是利用 bootloader 实现“在应用现场烧写新程序”的机制;而 OTA(Over-The-Air)只是 IAP 的一种使用场景,把升级包的传输方式从串口换成了网络或无线。
三者的关系是这样的:
| 名词 | 本质 | 作用 | 典型场景 |
|---|---|---|---|
| bootloader | 一段引导代码 | 初始化硬件、选择是否进入升级流程、跳转到 APP | 上电启动、升级入口 |
| IAP | 一种编程机制 | 在用户程序中完成对 Flash 的擦除和写入 | 串口升级、SD 卡升级 |
| OTA | 一种升级场景 | 通过网络或无线信道完成固件下载与更新 | 物联网设备、智能硬件 |
从实现难度看,bootloader 最难,IAP 是 bootloader 核心功能的延伸,OTA 则是把传输层替换成无线模块。所以只要把 bootloader 的逻辑想清楚,后面两样都是水到渠成的事。
1.2 没经验的程序员常犯的一个误区
最常见的误区是:以为 bootloader 是一个“额外的功能”,必须在应用工程里“加一点代码”。实际上,bootloader 和 APP 是两个独立的工程、两份独立的代码、两段独立的烧录地址。你平时写的业务代码是 APP,烧录在 0x08000000 起始地址;而 bootloader 是另一个更小的程序,也烧录在 0x08000000,但它不跑业务逻辑,只负责“检查标志位、决定是原地等待升级还是跳转到 APP”。
我自己早年在做产品时,因为没想明白这个“两份工程”的关系,硬是把 bootloader 逻辑塞进了主工程里,结果每次编译都会覆盖地址,最后设备一升级就成砖。后来按正规思路拆分成两个独立工程,问题立刻消失。这一点必须从一开始就建立正确的认知。
1.3 为什么产品一定要有 IAP 功能
也许有人会问:我现在开发阶段用 ST-Link 烧录不是挺好的吗?为什么非要做 IAP?
实际做产品就明白了。设备已经发给客户,某天你发现一个 bug 要修,这时候不可能让客户拆机器寄回来重新烧固件。更重要的是,越是嵌入式产品,越是分布在不同现场,工程师背着烧录器到处跑根本不现实。IAP 的价值在于:让升级从“物理接触”变成“远程操作”,把烧录器变成一段预留的引导程序,产品交付后依然保留升级通道。
我见过很多创业团队在第一版产品里省掉了 bootloader,结果后期发现问题时只能返厂,每次返厂的人力、物流成本摊下来,比前期多做几天开发贵得多。所以只要产品有量产交付的可能,bootloader 这个“门卫”必须提前上岗。
2. 动手前必须搞懂的 ARM 启动流程与 Flash 分区设计
2.1 芯片复位后到底执行了什么
要写 bootloader,必须先理解 Cortex-M 芯片的上电启动流程。很多人觉得这一块太底层,其实用大白话拆开就三点:
- 芯片复位后,从地址 0x00000000 读取栈顶指针(MSP 初始值),加载到主堆栈指针寄存器;
- 从地址 0x00000004 读取复位向量,也就是复位后第一条要执行的指令地址;
- 跳转到该地址执行,通常进入汇编的 Reset_Handler,之后才会调用 SystemInit 和 main。
这里的关键是:在 F103 等芯片上,内存映射会把 Flash 的起始地址映射到 0x08000000,而 0x00000000 只是一个别名,实际读取的就是 Flash 开头的数据。所以向量表里第 0 项一定是栈顶地址,第 1 项一定是复位函数地址,这个结构从 BootROM 到 APP 都不会变。
bootloader 跳转 APP 本质就是在模拟这个启动过程:把栈顶指针设置成 APP 向量表第一项的值,再把 PC 指针跳转到 APP 向量表第二项的复位函数地址。说白了,一台机器换一套新门禁系统,逻辑是一样的。
2.2 Flash 分区怎么规划才合理
IAP 方案里,Flash 必须人为划分区域,不能把 bootloader 和 APP 混在一起。以 STM32F103C8T6 为例,它只有 64KB Flash,我一般这样划分:
| 区域 | 起始地址 | 大小 | 内容 |
|---|---|---|---|
| Bootloader 区 | 0x08000000 | 8KB | 引导程序 + 升级逻辑 |
| APP 区 | 0x08002000 | 56KB | 业务代码 |
| 标志区 | 0x0800FC00 | 1KB | 升级标志位、状态记录 |
bootloader 放 8KB 通常很宽裕,因为它的任务非常单一。APP 区从 0x08002000 也就是 8KB 偏移处开始,这样 APP 编译链接地址必须设为 0x08002000,同时要在代码里把中断向量表偏移到同一个位置。
如果你想更保守,可以做双备份区,把 APP 临时放在后面的空闲区域,校验成功后再搬过去,这样即使升级过程掉电也不会变砖。但双备份会占用双倍 Flash,对于 F103C8 这种小容量芯片基本不现实。我建议第一版先做单分区,逻辑简单、容易调通。
2.3 为什么说跳转 APP 前必须先关中断
这是非常容易踩坑的地方。bootloader 自身运行过程中,肯定初始化过定时器、串口、看门狗,这些外设会开启各种中断。如果不关闭中断就直接跳进 APP,APP 里的中断处理函数可能还没来得及配置,中断请求却已经来了,程序直接跑飞。
正确做法是:在跳转前关掉所有外设中断、关闭全局中断(或者直接调用__disable_irq()),并且把 SysTick 停止。另外,如果 bootloader 和 APP 共用了同一个外设,最好在跳转前把外设重新初始化一遍或干脆复位,避免 APP 启动后读到奇怪的状态。
这一块我会在后面跳转函数里详细说明,现在只需要记住一句话:跳转动作越“干净”,APP 启动越“安全”。
3. 手把手实现一个最简单的串口 IAP Bootloader
3.1 Bootloader 工程的基本配置
新建 Bootloader 工程时,首先要保证编译器生成的代码地址从 0x08000000 开始,并且不要把代码优化得太激进,否则调试时会很难定位问题。我在 Keil 里一般这样配置:
- Flash 起始地址:
0x08000000 - Flash 大小:根据 bootloader 分区设置
- 宏定义:
VECT_TAB_OFFSET=0x0(bootloader 本身不需要偏移向量表) - 优化等级:
-O1或-O0,方便调试 - 串口使能:用作升级通信通道,通常 115200-8-N-1
Bootloader 的最小工程只需要这几个模块:时钟初始化、串口初始化、Flash 擦写驱动、升级协议处理、跳转函数。我们不需要操作系统,也不需要复杂业务逻辑,整个工程尽量短小精悍。
// bootloader_main.c 核心骨架 int main(void) { // 硬件初始化 SystemInit(); uart1_init(115200); // 检查是否进入升级模式 if (check_upgrade_flag() == UPGRADE_REQUEST) { // 执行升级流程,通过串口接收固件并写入Flash firmware_upgrade(); } // 检查APP区是否有效,合法则跳转 if (check_app_valid()) { jump_to_app(APP_START_ADDR); } // 走到这里说明APP无效,停在bootloader里等待升级 while (1) { // 持续监听串口升级请求 } }这段代码基本就是整个 IAP 方案的“总导演”。上电后先看有没有升级需求,有就处理升级;没有升级需求就检查 APP 是否完好,完好的话直接跳转;如果 APP 区是空的或者校验失败,就原地等待升级命令。
3.2 升级协议应该简单到什么程度
很多做 IAP 的新人一上来就想用 Ymodem、Xmodem 协议,甚至想自己定义一套复杂的多帧交互协议。我的建议是:第一版先不要整复杂协议,用最简单的“帧头 + 命令 + 长度 + 数据 + 校验”就够了。
以我自己的串口升级协议为例:
#define PKG_HEAD1 0xAA #define PKG_HEAD2 0x55 #define CMD_ERASE_FLASH 0x01 #define CMD_WRITE_FLASH 0x02 #define CMD_END 0x03 typedef struct { uint8_t head1; uint8_t head2; uint8_t cmd; uint32_t addr; uint16_t len; uint8_t data[256]; uint8_t crc; } upgrade_packet_t;每个数据包长度很灵活,可以只发 16 字节,也可以发 256 字节。收到一帧后先校验帧头、再校验 CRC,通过后根据命令去擦除 Flash 或者写入数据。上位机就按这个格式发数据,下位机一帧一帧地回 ACK,整包发完后上位机发一个 CMD_END,bootloader 收到后校验整个 APP 区。
为什么第一版不推荐 Ymodem?因为 Ymodem 有文件名、块号、取消机制,逻辑更复杂,调试难度大。先把最简单的自定义协议调通,以后再换 Ymodem 也不迟。
3.3 Flash 擦写是 IAP 的核心难点
STM32 的 Flash 和普通 RAM 不一样,写入前必须先擦除,而且擦除以扇区为单位,F103 的小容量芯片一页是 1KB 或者 2KB,不能只擦一个字节。另外 Flash 写入只能把 1 写成 0,所以内存里的数据必须按半字(16 位)或全字(32 位)对齐写入。
下面这段是基于寄存器的 Flash 擦写函数,避免使用标准库的 HAL 封装,因为 bootloader 阶段代码越小越容易控制:
// 擦除App区一页 void flash_erase_page(uint32_t page_addr) { // 等待之前的操作完成 while (FLASH->SR & FLASH_SR_BSY); // 检查并解锁Flash if (FLASH->CR & FLASH_CR_LOCK) { FLASH->KEYR = 0x45670123; FLASH->KEYR = 0xCDEF89AB; } // 选择页擦除模式,指定页地址 FLASH->CR |= FLASH_CR_PER; FLASH->AR = page_addr; FLASH->CR |= FLASH_CR_STRT; // 等待擦除完成 while (FLASH->SR & FLASH_SR_BSY); // 关闭页擦除模式,重新上锁 FLASH->CR &= ~FLASH_CR_PER; FLASH->CR |= FLASH_CR_LOCK; }// 写入半字 void flash_program_halfword(uint32_t addr, uint16_t data) { while (FLASH->SR & FLASH_SR_BSY); if (FLASH->CR & FLASH_CR_LOCK) { FLASH->KEYR = 0x45670123; FLASH->KEYR = 0xCDEF89AB; } FLASH->CR |= FLASH_CR_PG; *(volatile uint16_t *)addr = data; while (FLASH->SR & FLASH_SR_BSY); FLASH->CR &= ~FLASH_CR_PG; FLASH->CR |= FLASH_CR_LOCK; }注意一点:擦除和写入都必须在 Flash 未被锁定的状态下进行,解锁序列是固定的两个密钥字。很多人在调试时忘了等BSY位,结果擦除还没完成就开始写,数据全部错乱。这只是时序问题,不是芯片问题。
3.4 核心跳转函数:最关键的 10 行代码
所有 bootloader 最终目标就一个:让 CPU 从 bootloader 区域跳转到 APP 区域运行。跳转函数不长,但每一行都不能错。
typedef void (*app_func_t)(void); void jump_to_app(uint32_t app_addr) { uint32_t app_msp = *(volatile uint32_t *)app_addr; uint32_t app_reset = *(volatile uint32_t *)(app_addr + 4); app_func_t app_main = (app_func_t)app_reset; // 关闭全局中断,清空中断标志 __disable_irq(); // 跳转前把系统时钟、外设停掉,恢复默认状态 SysTick->CTRL = 0; // 设置主栈指针 __set_MSP(app_msp); // 跳转到APP复位函数 app_main(); }第一行从app_addr处读取的是 APP 向量表的第一个字,也就是 APP 的初始栈顶地址;第二行读取的是第二个字,也就是 APP 的复位函数地址。app_main()其实不是普通函数调用,它不会返回,因为 APP 的 main 里是一个死循环。
这里常见的错误是:有人直接把app_addr转型成函数指针,然后跳过去,结果运行不起来。原因就是没有正确设置 MSP。没有正确的栈指针,C 语言的局部变量和函数调用全部都会崩溃。
3.5 串口收包时的中断处理细节
串口中断里收到的不一定是一帧完整数据,有可能拆成两次中断到达,也可能半包。所以不能一收到数据就立刻处理,必须借助状态机做“组帧”。
我的做法是用一个环形缓冲区或者一个固定数组,串口中断只负责把字节塞进缓存,主循环里再解析:
volatile uint8_t rx_buf[512]; volatile uint16_t rx_cnt = 0; void USART1_IRQHandler(void) { if (USART1->SR & USART_SR_RXNE) { uint8_t byte = USART1->DR; if (rx_cnt < sizeof(rx_buf)) { rx_buf[rx_cnt++] = byte; } } }主循环里解析帧头、帧尾和 CRC。如果发现 CRC 不对,直接丢弃整帧,让上位机重发。这里最忌讳的是在中断里做 Flash 擦写,因为 Flash 擦写耗时长,会阻塞其他中断响应,极容易丢下一个数据包。要养成“中断只收数据,主循环处理数据”的习惯。
4. APP 侧到底要配合改哪些东西
4.1 能跳过去还不够,还得让 APP 自己跑稳
很多人的 bootloader 写好了,跳转也成功执行了,但 APP 却卡在启动阶段。原因基本都出在中断向量表偏移上。
默认情况下,STM32 的中断向量表放在 Flash 起始地址 0x08000000,也就是 bootloader 的位置。如果 APP 直接运行在 0x08002000,但中断向量表还是指向 0x08000000,那么任何中断触发时,CPU 都会去 bootloader 区域找中断处理函数,结果自然跑飞。
解决办法有两种:
- 在启动代码里修改
VECT_TAB_OFFSET宏,等于 APP 起始地址相对于 Flash 基地址的偏移量; - 在运行时调用
SCB->VTOR = APP_START_ADDR;,手动把向量表搬到 APP 区。
对于 F103,我一般在system_stm32f10x.c文件里设置:
#define VECT_TAB_OFFSET 0x2000 // 8KB偏移,对应0x08002000同时在 App 主程序初始化时也可以加上SCB->VTOR = 0x08002000;作为双重保险,防止某些启动代码没有正确搬运向量表。
4.2 APP 的链接地址必须和分区一致,否则一切白搭
APP 工程的链接地址必须改为 APP 区起始地址。Keil 里的操作是:Options for Target -> Target 标签页,把 IROM1 Start 改成 0x08002000,把 Size 改成 0xE000 或者实际划分大小。
这一步非常非常重要。如果工程链接地址还是 0x08000000,编译器生成的代码可能会覆盖 bootloader 区,甚至覆盖自己的中断向量表。我第一次做 IAP 时就栽在这里,跳转总是失败,后来反汇编一看,函数地址全是 0x0800xxxx,显然没有按 APP 起始地址链接。
改完链接地址后,再用 J-Flash 或 STM32CubeProgrammer 把 APP 的 hex/bin 文件烧到 0x08002000 上,而不是从 0x08000000 开始。这是“跳转成功”和“彻底失败”的分水岭。
4.3 怎么生成 bin 文件,而不是 hex 文件
Keil 默认输出 hex 文件,但 IAP 的固件包往往更习惯用 bin 文件,因为 bin 是纯二进制数据,直接从地址 0 开始烧录,方便 bootloader 按地址写入 Flash。
在 Keil 里可以这样配置:Options for Target -> User -> After Build/Rebuild 中添加:
fromelf.exe --bin -o ./output/app.bin ./output/app.axf注意路径换成你自己的工程文件路径,编译完成后会自动生成一个app.bin。上位机读这个文件时,按协议分包发送即可,bootloader 收到后从 APP_START_ADDR 开始写入。
4.4 一个让你少走弯路的初步验证法
在正式写升级逻辑之前,我建议你们先做一个“固定跳转”的小实验:bootloader 里不检查任何标志,直接跳转到 0x08002000,然后 APP 随便点个灯;再把 APP 烧到 0x08002000,看看能不能跑起来。这样做的好处是把“跳转链路”和“升级链路”解耦,先确认跳转没问题,再去做串口收包和 Flash 写入,排查问题时至少能确定问题出在哪一层。
这个实验方法是我调试 IAP 时最常用的一招,强烈推荐大家先用它跑通一遍。
5. 实战中反复踩过的坑与排查技巧
5.1 跳转 APP 后直接 HardFault,如何快速定位
这是最常出现的问题。我的排查顺序一般是这样:
- 先用调试器读出
app_msp的值,确认它不是 0xFFFFFFFF,否则说明 APP 区根本没烧录,或者烧录地址不对; - 检查
app_reset是否落在 0x08002000 之后,如果落在 0x08000000 附近,说明 APP 工程链接地址没改; - 在跳转函数里临时加入软件延时,上电后先用调试器确认跳转前的中断确实已经关闭;
- 检查 APP 里是否有
SCB->VTOR偏移配置,没有的话一样会 HardFault。
经历几次 HardFault 之后你会发现,绝大多数跳转失败都是“向量表偏移没做”和“链接地址没改”这两件事。
5.2 升级过程中串口丢包、卡死,怎么处理
升级一半失败,通常不是协议设计问题,而是环境问题。串口线太长、电脑 USB 转串口芯片不稳定、波特率太高都会导致丢包。我建议第一版统一用 115200,并且在上位机加一个超时重发机制,bootloader 收到错误 CRC 就回 NAK,上位机收到 NAK 后重发当前帧。
还有一个我几乎每次都会踩的坑:Flash 擦写会消耗毫秒级时间,这段时间如果串口中断被禁,那么正在飞过来的字节就丢了。所以升级协议最好做成“上位机发一帧,等待应答后再发下一帧”的停等协议,而不是一股脑发几百帧过去。
5.3 升级过程中掉电了,如何处理才能保证不变成砖
单分区方案最大的弱点就在这里:升级过程中如果断电,Flash 里可能残存半包固件,APP 区校验不过,bootloader 无法跳转,设备就只能永远停在 bootloader 模式等待重新升级。
应对办法是 bootloader 里加一个“等待超时重新升级”的逻辑:如果 APP 无效,就停留在升级模式下一直监听串口,直到收到新的完整固件。这样虽然会丢失业务功能,但至少还能救回来,不至于变砖。等以后项目升级到双备份方案,才能真正做到即使升级失败也能自动回退到旧版本。
5.4 看门狗对 bootloader 的影响,很多人没提前想清楚
产品里有独立看门狗或窗口看门狗时,bootloader 和 APP 必须有一套喂狗策略。bootloader 阶段如果在等待串口升级,此时 APP 没有运行,业务层的喂狗任务不存在,看门狗可能会把系统复位,造成反复重启。
设计思路很简单:bootloader 里如果检测到需要升级,可以延长喂狗时间或者干脆等看门狗超时后不喂狗,但前提是升级流程能在看门狗周期内完成;如果升级时间可能很长,则必须在 bootloader 主循环里周期性喂狗。不然升级过程会伴随意外复位,效果等同于升级失败。
5.5 常见问题排查速查表
| 现象 | 优先检查项 | 原因参考 |
|---|---|---|
| 跳转后无任何现象 | 是否读取到正确 MSP/复位地址 | APP 区无固件或地址不对 |
| 跳转后 HardFault | 向量表偏移是否配置 | 中断向量表仍指向 bootloader |
| APP 能跑但不进中断 | SCB->VTOR 是否赋值 | 向量表未搬移到 APP 区 |
| 升级过程中卡死 | 串口中断中是否做耗时操作 | Flash 擦写在中断里执行 |
| 升级后校验失败 | 字节对齐、CRC 算法是否一致 | 上位机与下位机 CRC 不一致 |
| 设备变砖无法启动 | 是否停在 bootloader 等待升级 | 需要强制恢复升级流程 |
5.6 个人整理的三条实战经验
第一,bootloader 里的串口波特率不要定太高。别以为 921600 很快很爽,很多 USB 转串口芯片在极端环境下掉帧掉到你想骂人。生产环境用 115200,稳妥第一。
第二,升级固件前最好先发一个“开始升级”命令,让 bootloader 先把 APP 区整片擦除,再开始接收数据。不要边收边擦,因为擦除时间不确定,很容易导致接收阻塞丢包。
第三,调试阶段在 bootloader 里留一个调试串口输出,把关键状态打印出来。比如 “Jump to App”“Erase OK”“Write OK”“CRC OK”,每个状态一个编号,出问题后基本上一眼就能定位。
最后分享一个我自己的习惯:做 IAP 功能时,千万不要急于把代码写得“宏大”。先把最小链路跑通,再逐步加功能。固定的跳转实验通过后,再做串口收包;收包稳定后,再加 Flash 擦写;Flash 擦写正常后,再加校验和回滚逻辑。每一步都稳扎稳打,这个项目基本不会有太大问题。就算后面遇到新坑,排查范围也会小很多,因为你心里清楚每一层到底跑没跑通。希望这篇文章能帮大家把 bootloader 这块硬骨头啃下来,少走几天弯路。