尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

STM32 IAP Bootloader详解:跳转卡死根因分析与实战源码

STM32 IAP Bootloader详解:跳转卡死根因分析与实战源码 简介面向需要实现串口或 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要么寄板子回来要么安排人上门拆机。这种时候 IAPIn-Application Programming应用内编程就不再是“锦上添花”的加分项而是刚需。我用 STM32 做 Bootloader IAP 升级已经有三四个项目了从最早的串口 IAP 到后来的 OTA跳转卡死、固件变砖、Flash 擦写失败这些坑都踩过一遍。这篇博文就把 STM32 Boot / IAP 的原理、源码结构和实用经验整理出来重点聊聊“跳转后卡死”这个经典问题以及我从源码和参考资料里提炼出的设计思路。1. 为什么需要 IAPBoot 和 App 的整体设计思路1.1 IAP 要解决的真实痛点先说说我最早遇到的场景一个设备发出去 500 台客户分布在三个省结果发现某些条件下通讯模块会死锁。用 JTAG/SWD 直接烧录固件在产线上没问题但人已经在外地了总不能每台设备都拆壳子飞线。这种时候 IAP 的价值就体现出来了设备本身通过串口、CAN 或者网络接收新固件写到片内 Flash 的 App 区域然后跳转执行。整个过程不需要额外的烧录器也不需要拆机。IAP 的另一个应用场景是产线烧录。很多量大的产品不在产线用 J-Link 全量烧写而是出厂时只烧 BootloaderApp 在最终装配后通过串口引导写入。这样做的原因是 Bootloader 基本不变只需要烧一次而 App 可能对应不同批次、不同配置通过 IAP 灵活切换会快很多。1.2 Boot App 双区架构与 Flash 布局IAP 的核心思路是把 Flash 分成两个区域Boot 区和 App 区。芯片上电先跑 Boot 区代码Boot 负责判断是否需要升级。如果需要升级就通过外部接口接收固件包写入 App 区如果不需要升级就直接跳转到 App 区执行。以最常见的 STM32F103C8T6 为例它的 Flash 标称 64KB实际不少是 128KB这是 ST 的一个彩蛋后面聊RAM 20KB。我在项目里常用的分区方式是这样的区域起始地址大小用途Boot 区0x080000008KB / 16KBBootloader 固件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 自己的。如果不提前设置 MSPApp 启动时用的还是 Boot 的栈空间一旦 App 的栈指针被自身代码更新很可能覆盖掉 Boot 区的某些变量区域表现就是莫名其妙跑飞。第二为什么要关全局中断Boot 运行期间可能开启了串口中断、定时器中断等跳转瞬间这些中断源还在但中断向量表已经切到 App 区了。如果 App 还没来得及重映射自己的中断服务函数ISR一个未处理的中断进来就直接 HardFault。更麻烦的是 SysTickHAL 库的HAL_Delay依赖 SysTick 中断累加 tickBoot 里如果有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就会一直死等——看起来就是“跳转后卡死”。具体来说有两种常见情况一是跳转前没有关闭 SysTickBoot 里的 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_addr4))();这种精简写法在只有裸机、没开任何中断、时钟也不重新配置的场景下能用但放在实际产品里就是定时炸弹。我的跳转标准动作是先关掉全局中断停掉 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_addr4))();这样也能跑但代码可读性差万一写错一个括号就跳进错误地址了。后来我写跳转代码一定分行宁可多写几行也不在指针转换上省事。4. 完整的 IAP 升级流程与固件校验策略4.1 Flash 分区规划与扇区边界计算Flash 分区这件事做不好后面全是坑。有些工程师图省事Boot 放在 0x08000000App 直接放 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 在 0x08004000App 却按 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 里这样生成 binOptions 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 }注意不要只改ORIGINLENGTH也要跟着 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 foundST-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 中断被关闭或优先级配置冲突跳转前停 SysTickApp 里重新HAL_Init跳转后频繁 HardFault未关闭 Boot 开启的中断跳转前清 NVIC 使能和挂起位升级失败写入 Flash 报错地址没对齐页边界或写前未擦除按页擦除地址按扇区大小对齐Boot 检查 App 始终无效标志字位置或值不对统一用宏管理地址校验魔数CRC升级几秒后设备死机固件被截断或 CRC 漏检增加长度和 CRC16/32 校验完善分包协议6.2 源码从哪里找、怎么看关于“STM32 _Boot_IAP 源码 参考资料”我建议按下面这个路径去搜集别一上来就抱着某个付费资料死磕。我自己当初是先看官方资料再看开源代码最后才动手写自己的 Boot。第一份参考资料是 ST 官方的应用笔记AN2557IAP 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 和加密一步步来比一开始就追求大而全要稳得多。本文还有配套的精品资源点击获取
返回列表