简介:面向需要实现串口或 USB 在线升级功能的嵌入式开发者,这份 STM32 Boot/IAP 源码参考资料聚焦 Bootloader 与用户程序的协作机制。包内提供 STM32F 系列完整工程示例,包含 boot 程序与用户程序源码,覆盖串口 bootloader 协议解析、Flash 分区规划、跳转逻辑等关键实现,并附有利用 ST 官方 USB 例程实现 USB IAP 的说明文本,引导读者理解设计思想而不是直接套用。整个压缩包共 489 个文件、约 5.92MB,以 .c/.h 源文件和 Keil 工程文件(.uvproj/.uvopt)为主,同时包含 .hex/.bin 固件、.map 映射文件、.pdf/.rar 参考文档和编译中间文件,资源结构较完整,便于按模块对比源码与配置进行调试。目前已有 230 人学习/下载。考虑到示例的开发环境与目标芯片存在差异,直接编译可能无法通过,需要读者自行移植修改,这正是一个加深理解启动流程和 IAP 原理的实践过程,适合有一定 STM32 基础、希望掌握固件升级技术的开发者。 搞嵌入式这些年,我接手过的不少项目都死在同一个问题上:产品已经量产,客户分布在不同城市,固件突然发现一个隐蔽 bug,要么寄板子回来,要么安排人上门拆机。这种时候 IAP(In-Application Programming,应用内编程)就不再是“锦上添花”的加分项,而是刚需。我用 STM32 做 Bootloader + IAP 升级已经有三四个项目了,从最早的串口 IAP 到后来的 OTA,跳转卡死、固件变砖、Flash 擦写失败这些坑都踩过一遍。这篇博文就把 STM32 Boot / IAP 的原理、源码结构和实用经验整理出来,重点聊聊“跳转后卡死”这个经典问题,以及我从源码和参考资料里提炼出的设计思路。
1. 为什么需要 IAP?Boot 和 App 的整体设计思路
1.1 IAP 要解决的真实痛点
先说说我最早遇到的场景:一个设备发出去 500 台,客户分布在三个省,结果发现某些条件下通讯模块会死锁。用 JTAG/SWD 直接烧录固件在产线上没问题,但人已经在外地了,总不能每台设备都拆壳子飞线。这种时候 IAP 的价值就体现出来了:设备本身通过串口、CAN 或者网络接收新固件,写到片内 Flash 的 App 区域,然后跳转执行。整个过程不需要额外的烧录器,也不需要拆机。
IAP 的另一个应用场景是产线烧录。很多量大的产品不在产线用 J-Link 全量烧写,而是出厂时只烧 Bootloader,App 在最终装配后通过串口引导写入。这样做的原因是 Bootloader 基本不变,只需要烧一次,而 App 可能对应不同批次、不同配置,通过 IAP 灵活切换会快很多。
1.2 Boot + App 双区架构与 Flash 布局
IAP 的核心思路是把 Flash 分成两个区域:Boot 区和 App 区。芯片上电先跑 Boot 区代码,Boot 负责判断是否需要升级。如果需要升级,就通过外部接口接收固件包写入 App 区;如果不需要升级,就直接跳转到 App 区执行。
以最常见的 STM32F103C8T6 为例,它的 Flash 标称 64KB(实际不少是 128KB,这是 ST 的一个彩蛋,后面聊),RAM 20KB。我在项目里常用的分区方式是这样的:
| 区域 | 起始地址 | 大小 | 用途 |
|---|---|---|---|
| Boot 区 | 0x08000000 | 8KB / 16KB | Bootloader 固件 |
| App 区 | 0x08002000 或 0x08004000 | 剩余全部 | 应用程序固件 |
| 标志区 | Flash 最后一页 | 1KB / 2KB | 升级标志、固件状态 |
Boot 区大小怎么定?这不是拍脑袋的。Bootloader 如果用标准外设库或者寄存器操作,代码量很小,8KB 足够;如果上了 HAL 库加一个屏幕驱动,哪怕只是做显示进度条,8KB 会非常紧张,建议直接给 16KB。App 的起始地址必须按扇区边界对齐,因为 Flash 擦除的最小单位是扇区(F103 是 1KB 一页,但很多系列是 2KB/4KB/8KB 不等)。你定分区时先查芯片手册上的 Flash 扇区表,别随便给一个地址。
1.3 为什么选 Boot + App 而不是其他方案
市面上常见的固件更新方式有 ICP(在电路编程)、ISP(在系统编程)和 IAP 三种。ICP 就是产线上用 SWD/JTAG 烧录,速度快但需要外部工具;ISP 利用出厂固化的 Bootloader 通过串口下载,不需要额外工具但流程死板,而且没办法和业务逻辑联动;IAP 的好处是 Boot 区代码完全由你自己控制,可以在接收固件前做鉴权校验,可以在升级失败时回滚,还能在开机时做自检。
我见过不少团队为了省事直接依赖 ST 出厂 ISP 引导,结果遇到一个需求就抓瞎:客户要求设备开机后先做运行自检、再决定要不要进入升级模式,类似这种逻辑只能靠自研 Bootloader 实现。所以只要产品不是一次性烧完就完事,Boot + App 的 IAP 结构基本是嵌入式设备维护的底限方案。
2. Bootloader 源码结构与启动流程拆解
2.1 Boot 区代码整体流程
一个能稳定工作的 Bootloader 并不复杂,启动流程可以归纳成下面几步:上电初始化时钟和用到的外设,检查是否有升级请求,检查 App 区固件是否有效,有效则跳转,无效则进入升级模式等待接收固件。
我用寄存器操作写过一个精简版 Boot,整个工程就三个文件:main.c、flash.c、uart.c。主流程大概这样:
int main(void) { SystemInit(); // 时钟复位与 PLL 配置 uart_init(115200); if (check_upgrade_cmd() || check_app_invalid()) { uart_send_string("enter upgrade mode...\r\n"); upgrade_process(); // 接收固件、擦写 Flash、置升级完成标志 } if (check_app_valid()) { jump_to_app(APP_ADDR); } // 跳转失败就死等升级 while (1) { upgrade_process(); } }很多初学者看到这里会有疑问:Boot 和 App 都是独立工程,怎么知道 App 有效无效?我在实践里用的是一个“魔数 + 长度 + CRC”的标志字。比如在 Flash 的 App 区首地址写一个固定值0xA5A5A5A5,每次升级完成后写入这个魔数;Boot 启动时先读这个地址,匹配才认为 App 有效。更严谨一点,再把 App 的固件长度和 CRC 都存进去,防止固件半截被截断。
2.2 跳转函数的经典写法与参数校验
跳转函数是整个 Boot 里最敏感的部分,直接决定了会不会跳过去就死在门口。先看一个我实际项目里稳定跑了两年的跳转函数:
typedef void (*pFunction)(void); void jump_to_app(uint32_t app_addr) { uint32_t msp_value = *(volatile uint32_t *)app_addr; uint32_t reset_value = *(volatile uint32_t *)(app_addr + 4); // 校验栈顶指针:通常 RAM 地址在 0x20000000 开头 if ((msp_value & 0xFFF00000) != 0x20000000) { return; // 栈顶不合法,不跳转 } __disable_irq(); // 关闭全局中断 SysTick->CTRL = 0; // 停掉 SysTick SysTick->LOAD = 0; SysTick->VAL = 0; HAL_RCC_DeInit(); // 复位时钟为默认状态 __set_MSP(msp_value); // 设置主栈指针 SCB->VTOR = app_addr; // 重映射向量表 pFunction jump = (pFunction)reset_value; jump(); // 跳转到 App 复位向量 }这里有几个为什么,值得展开说说。
第一,为什么要先把栈顶地址读出来再跳转?因为 App 工程编译时用的是它自己的链接脚本,它的栈顶和复位向量都在 App 区开头。CPU 上电默认从 0x08000000 取 MSP 和 PC,但跳转到 App 后这两个值必须换成 App 自己的。如果不提前设置 MSP,App 启动时用的还是 Boot 的栈空间,一旦 App 的栈指针被自身代码更新,很可能覆盖掉 Boot 区的某些变量区域,表现就是莫名其妙跑飞。
第二,为什么要关全局中断?Boot 运行期间可能开启了串口中断、定时器中断等,跳转瞬间这些中断源还在,但中断向量表已经切到 App 区了。如果 App 还没来得及重映射自己的中断服务函数(ISR),一个未处理的中断进来就直接 HardFault。更麻烦的是 SysTick:HAL 库的HAL_Delay依赖 SysTick 中断累加 tick,Boot 里如果有HAL_Delay残留,跳转后 SysTick 中断还可能不停触发。
2.3 App 工程的起始地址与向量表配置
Boot 跳转过去只是第一步,App 工程本身必须配合 Boot 做出修改。改两处:第一处是链接脚本或者 Keil 的 IROM 配置,把代码起始地址改成 App 区地址;第二处是启动阶段要把向量表偏移地址设到 App 区。
Keil 工程里的设置很简单:Options for Target -> Target 页,IROM1 起始地址改成0x08004000(如果你把 App 放在这里),大小改成0x8000之类的剩余 Flash 容量。然后代码里初始化向量表:
SCB->VTOR = 0x08004000;如果你用 STM32CubeMX 生成工程,在system_stm32f1xx.c里可以找到VECT_TAB_OFFSET这个宏,默认是 0,改成你 App 的偏移量即可:
#define VECT_TAB_OFFSET 0x4000需要特别提醒的是,买来的开发板例程、网上很多源码,App 工程要么没改向量表偏移,要么 IROM 地址还是默认的 0x08000000。这种工程烧进去后,Boot 一跳转,中断一来就乱套,表现就是串口能打印却不工作、按键间歇失灵、延时忽快忽慢。排查这类问题,第一步永远先确认向量表和链接地址是否匹配。
3. 实战中最容易踩的坑:IAP 跳转后卡死的根因分析
3.1 HAL_Delay 卡死与 SysTick 中断的纠缠
“IAP 跳转后卡死 HAL_Delay”我不敢说百分百涵盖所有卡死场景,但至少有一半的人卡在这里。为什么?
HAL_Delay的实现很简单,就是死循环等uwTick达到目标值,而uwTick是 SysTick 中断里累加的:
void HAL_Delay(uint32_t Delay) { uint32_t tickstart = HAL_GetTick(); uint32_t wait = Delay; while ((HAL_GetTick() - tickstart) < wait) { } }问题就出在这个机制上。如果跳转到 App 后,App 没有重新正确初始化 SysTick 中断,或者中断被 Boot 关闭了,HAL_Delay就会一直死等——看起来就是“跳转后卡死”。
具体来说有两种常见情况:
一是跳转前没有关闭 SysTick,Boot 里的 SysTick 中断还在运行,但 App 启动时重新配置了 NVIC 优先级分组。Cortex-M3 里 SysTick 中断默认优先级由SCB->AIRCR决定,Boot 和 App 用不同优先级分组时,SysTick 的抢占优先级可能变成极高,导致主循环代码被 SysTick 中断淹没,而 HAL 库的HAL_InitTick又没能正常接管,最终表现就是主逻辑跑不动,看起来像卡死。
二是跳转时把全局中断关了,但 App 启动早期调用HAL_Init时虽然会重新配置 SysTick,可如果之前的 SysTick 设置了 PendSV/中断挂起位,某些内核状态会残留。最稳妥的做法就是像上面代码那样,跳转前先停掉 SysTick 并把它复位到默认值。
3.2 跳转前必须做的事:关中断、复位时钟、清标志
网上能找到很多版本的跳转代码,有些就两行:
__set_MSP(*(uint32_t*)app_addr); ((void(*)())*(uint32_t*)(app_addr+4))();这种精简写法在只有裸机、没开任何中断、时钟也不重新配置的场景下能用,但放在实际产品里就是定时炸弹。
我的跳转标准动作是:先关掉全局中断,停掉 SysTick,复位时钟到默认,设置新的 MSP,设置 VTOR,最后跳转。这里再补一个细节:跳转前把 NVIC 里所有中断都清掉,可以循环写NVIC->ICER或者直接用HAL_NVIC_DisableIRQ逐个关。有些老工程师会写一段汇编:
__disable_irq(); for (int i = 0; i < 8; i++) { NVIC->ICER[i] = 0xFFFFFFFF; // 清所有挂起和使能位 }这样做的目的是彻底切断 Boot 遗留下来的所有中断隐患,让 App 从一个“干净”的状态开始跑。你可以把中断比作一群随时会敲门的外卖员:你搬家了,但没有告诉他们新地址,他们还会去旧地址敲门。跳转前把这个“通知系统”整个停掉,到了新家再重新雇佣一批外卖员,才是最保险的。
3.3 实战跳转代码清单
为了让你直接能抄,我把这一整段逻辑整理成一个可以放到 Boot 工程里的函数。这段代码我在 F103 和 F407 上都跑过,针对 HAL 库和标准外设库做了适配:
void jump_to_app(uint32_t app_addr) { // 1. 校验 App 有效 uint32_t stack_addr = *(volatile uint32_t *)app_addr; if ((stack_addr & 0xFFF00000) != 0x20000000) { return; } // 2. 关闭中断、停外设、清中断标志 __disable_irq(); SysTick->CTRL = 0; SysTick->LOAD = 0; SysTick->VAL = 0; HAL_RCC_DeInit(); for (int i = 0; i < 8; i++) { NVIC->ICER[i] = 0xFFFFFFFF; NVIC->ICPR[i] = 0xFFFFFFFF; } // 3. 设置新栈顶和向量表 __set_MSP(stack_addr); SCB->VTOR = app_addr; // 4. 跳转 void (*app_reset_handler)(void) = (void (*)(void))(*(volatile uint32_t *)(app_addr + 4)); app_reset_handler(); while (1); }注意第 4 步,我用的是把复位向量先保存到函数指针再调用。有人喜欢直接((void(*)())*(volatile uint32_t*) (app_addr+4))();,这样也能跑,但代码可读性差,万一写错一个括号就跳进错误地址了。后来我写跳转代码一定分行,宁可多写几行也不在指针转换上省事。
4. 完整的 IAP 升级流程与固件校验策略
4.1 Flash 分区规划与扇区边界计算
Flash 分区这件事,做不好后面全是坑。有些工程师图省事,Boot 放在 0x08000000,App 直接放 0x08001000,结果 Boot 代码一旦超过 4KB,直接覆盖 App 头部,整个固件就废了。
我的习惯是先把整个 Flash 扇区表打开,按扇区来分配。拿 STM32F103 举例,它的主 Flash 按 1KB 一页组织,Boot 给 16KB 就是 16 页;但到了 F407,扇区大小就变成了前 4 个 16KB、中间 1 个 64KB、后面 7 个 128KB,布局完全不同,不能照搬 F103 的分区方式。查手册或者调用FLASH_Sector宏时,一定要按具体芯片来。
分区信息建议固化成一个宏定义头文件,方便 Boot 和 App 共用以保证一致性:
#define FLASH_BASE_ADDR 0x08000000 #define BOOT_SIZE (16 * 1024) #define APP_ADDR (FLASH_BASE_ADDR + BOOT_SIZE) #define FLAG_ADDR (FLASH_BASE_ADDR + FLASH_TOTAL_SIZE - FLASH_PAGE_SIZE)用宏而不是散落的魔法数字,是为了防止 Boot 和 App 分别维护一套地址时对不上。我见过不止一次 Boot 认为 App 在 0x08004000,App 却按 0x08008000 编译,跳转过去全是乱码。
4.2 固件传输协议与 CRC 校验
IAP 升级本质上就是一个带文件传输协议的小系统。我常用的最简单的分包协议是:上位机先发一帧“头包”,包含固件长度、CRC、目标地址;然后按 256 字节为一包发送正文;最后一包发“结束包”,Boot 收到后做整体校验并写入标志。
CRC 校验非常重要。串口在噪音环境下传大文件,出错是常态而不是例外。网上有人用累加和做校验,这种在数据量小的时候勉强够用,但固件动辄几十上百 KB,累加和的碰撞概率不小。我在项目里用 CRC16/CRC32 都试过,简单场景用 CRC16 查表法就够,资源紧张可以直接用查表实现。
uint16_t crc16_update(uint16_t crc, uint8_t byte) { crc ^= (uint16_t)byte << 8; for (int i = 0; i < 8; i++) { crc = (crc & 0x8000) ? ((crc << 1) ^ 0x1021) : (crc << 1); } return crc; }0x1021 是 CRC-CCITT 的常用多项式,做固件校验够了。如果不想自己造轮子,很多参考资料和开源工程里都有现成的 CRC 模块,把人家的源码读一遍、验证一下,比自己写省心得多。
4.3 防止变砖的“双标志”策略
整个 IAP 流程里,我最担心的是升级过程中断电。固件写到一半,Flash 里的 App 区已经残了,重新上电 Boot 一检查发现固件无效,直接进入升级模式——这不算变砖,因为 Boot 还在。真正可怕的是升级完成标志写晚了一步,Boot 误判 App 有效,跳转进一个残缺固件,然后死循环或 HardFault,这时候 Boot 进不去,板子才叫真砖了。
我现在的做法是维护两个标志:一个是“升级请求标志”,一个是“升级完成标志”。流程是这样的:上位机下发升级指令后,Boot 在标志区写入“升级请求”并重启。重启后 Boot 发现升级请求,进入升级模式,接收完整固件,写完后置“升级完成”标志,并清除“升级请求”。App 启动后如果发现“升级完成”标志,把标志清除再运行业务代码。
万一升级过程断电,最坏的情况是“升级请求”还在但固件不完整,Boot 重启后检查到请求,重进升级模式等待下一轮数据,始终不会去碰一个不完整的固件。这套策略只需要额外消耗一个 Flash 页,却能最大程度避免变砖。
5. 开发环境配置与调试工具链
5.1 Keil MDK 工程配置与 bin 文件生成
做 IAP 项目,最舒服的 IDE 还是 Keil MDK,但有两个配置很多人不知道或者容易忽略。
第一,工程编译后默认只生成 hex 文件,但是 IAP 升级通常用的是 bin 文件,因为 bin 是纯数据,文件头没有长度信息,方便自定义格式。在 Keil 里这样生成 bin:Options for Target -> User -> After Build/Rebuild,勾选 Run User Programs After Build,填:
fromelf --bin --output=./Output/App.bin ./Output/App.axf第二,App 工程和 Boot 工程最好分两个 Keil 工程目录,各用各的输出文件,别混在一起。我见过有人把一个工程里同时放 Boot 源码和 App 源码,用宏区分编译,结果版本混乱不说,跳转地址也经常一不小心就改错了。
5.2 VSCode 嵌入式开发与 GCC 链接脚本要点
这几年 VSCode 做 STM32 开发的人也多了,EIDE、PlatformIO 这些插件都能用。用 GCC 工具链时,改 App 起始地址就要改链接脚本了。以 STM32F103 为例,链接脚本里的MEMORY段要这样改:
MEMORY { FLASH (rx) : ORIGIN = 0x08004000, LENGTH = 48K RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 20K }注意不要只改ORIGIN,LENGTH也要跟着 Flash 剩余空间走。如果还是写 64K,链接器可能会把数据放到 Boot 区去,烧进去就覆盖 Boot 了。这种情况编译通常不报错,运行却出问题,非常坑。
5.3 常用下载调试工具与常见连接报错排查
调试 Boot 和 App 时,ST-LINK 和 J-Link 都有人用。从软件上来讲,STM32 ST-LINK Utility 或者 STM32CubeProgrammer 都可以直接查看 Flash 内容和选项字节。我经常用它们做一件事:读整颗 Flash,确认 Boot 和 App 各自烧到了自己该在的位置,省得肉眼猜。
很多人调试时会遇到一个经典报错:error: no stm32 target found! if your product embeds debug authentication, please deal with it。结合我自己的排查顺序,列一张速查表:
| 症状 | 常见原因 | 处理办法 |
|---|---|---|
| 连接不上,报 no target found | ST-LINK 驱动没装好 | 重装 ST-Link 驱动,检查设备管理器是否识别 |
| 连接不上,Target 电压显示 0V | 板子没供电或 SWD 接口虚焊 | 先给板子单独供电,量 VCC 和 GND |
| 连接不上,但电压正常 | SWDIO/SWCLK 接线反了或松了 | 调换两根杜邦线,缩短接线长度 |
| 之前能连,现在连不上 | 目标芯片可能进入了低功耗或死锁 | 把 BOOT0 拉高复位,让芯片停留在系统存储器 |
| 连接不稳定,时好时坏 | 复位电路异常或 SWD 线太长 | 检查复位电容,SWD 频率调低,线束缩短 |
上面这个表里的问题,一半以上我都在实际项目里遇过。尤其是 BOOT0 拉高这个技巧,芯片进休眠或者代码跑飞时,SWD 连不上时拉高 BOOT0 再上电复位,等芯片停在 ISP 引导区后就能连上了。这个技巧在 STM32 开发里非常实用,值得记牢。
6. 常见问题速查表与源码学习建议
6.1 常见问题速查表
把我在不同项目里遇到的 IAP 相关问题整理一下,按故障现象归类:
| 现象 | 根本原因 | 解决思路 |
|---|---|---|
| 跳转后串口无输出、卡死 | 向量表没重映射或跳转前外设时钟未复位 | 跳转前复位时钟,App 里设置SCB->VTOR |
跳转后HAL_Delay不延时 | SysTick 中断被关闭或优先级配置冲突 | 跳转前停 SysTick,App 里重新HAL_Init |
| 跳转后频繁 HardFault | 未关闭 Boot 开启的中断 | 跳转前清 NVIC 使能和挂起位 |
| 升级失败,写入 Flash 报错 | 地址没对齐页边界或写前未擦除 | 按页擦除,地址按扇区大小对齐 |
| Boot 检查 App 始终无效 | 标志字位置或值不对 | 统一用宏管理地址,校验魔数+CRC |
| 升级几秒后设备死机 | 固件被截断或 CRC 漏检 | 增加长度和 CRC16/32 校验,完善分包协议 |
6.2 源码从哪里找、怎么看
关于“STM32 _Boot_IAP 源码 参考资料”,我建议按下面这个路径去搜集,别一上来就抱着某个付费资料死磕。我自己当初是先看官方资料,再看开源代码,最后才动手写自己的 Boot。
第一份参考资料是 ST 官方的应用笔记,AN2557(IAP using USART),这是 F1 系列的经典参考,里面对 IAP 原理、跳转示例、Flash 操作讲得很详细。F4 系列可以看 AN3969。STM32Cube 固件包里的Examples/IAP目录也有现成工程,可以直接编译下载验证。这些是地基,必须看。
第二份参考是 GitHub 上的开源工程。搜索关键词可以用stm32-iap、stm32-bootloader、stm32-ota,能找到很多高质量仓库。看开源工程时一定要带着问题去看,比如“它的跳转函数跟我写的有什么不同”“它的双区备份是怎么设计的”“它的升级协议怎么处理超时和重传”。带着问题去看源码,比自己零散地搜网友帖子效率高得多。
6.3 我踩过最深的坑:Boot 和 App 共用库函数的版本不一致
这个坑我在做 F407 项目时踩过:Boot 用的是老版 HAL 库,App 用的是新版 HAL 库。跳转后没过多久就死机。最后查出来是两边库的初始化逻辑有差异,Boot 里配置过一个外设,跳转后新库重新初始化时发现外设状态不对,直接断言失败。
从那以后我的做法是:Boot 里能用寄存器写的绝不用库函数,哪怕多写几行也能接受。Boot 的功能有限,初始化时钟、初始化串口、擦写 Flash,这些用寄存器操作完全可以搞定。把库依赖降到最低,跳转时带过去的状态就最少,App 侧容错空间就越大。这个习惯后来帮我避免了很多诡异问题,强烈建议你也试试。
现在写新项目的 Boot 分区表时,我一定会先把“跳转前要做什么”写成固定函数贴在代码最上面,每次拿到新平台先验证最小跳转,再加功能。从最简单的串口 IAP 跑通,再谈 OTA 和加密,一步步来,比一开始就追求大而全要稳得多。
本文还有配套的精品资源,点击获取