STM32 Bootloader与串口IAP实现:从原理到代码,轻松搞定固件升级 很多人刚接触单片机开发时对 bootloader 总是有一种“高深莫测”的感觉。后来我自己做产品才真正意识到它其实就是一段“先于主程序运行的小程序”并没有想象中复杂。尤其是当你需要给已经出货的设备做固件升级时IAPIn-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 实现“在应用现场烧写新程序”的机制而 OTAOver-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 区0x080000008KB引导程序 升级逻辑APP 区0x0800200056KB业务代码标志区0x0800FC001KB升级标志位、状态记录bootloader 放 8KB 通常很宽裕因为它的任务非常单一。APP 区从 0x08002000 也就是 8KB 偏移处开始这样 APP 编译链接地址必须设为 0x08002000同时要在代码里把中断向量表偏移到同一个位置。如果你想更保守可以做双备份区把 APP 临时放在后面的空闲区域校验成功后再搬过去这样即使升级过程掉电也不会变砖。但双备份会占用双倍 Flash对于 F103C8 这种小容量芯片基本不现实。我建议第一版先做单分区逻辑简单、容易调通。2.3 为什么说跳转 APP 前必须先关中断这是非常容易踩坑的地方。bootloader 自身运行过程中肯定初始化过定时器、串口、看门狗这些外设会开启各种中断。如果不关闭中断就直接跳进 APPAPP 里的中断处理函数可能还没来得及配置中断请求却已经来了程序直接跑飞。正确做法是在跳转前关掉所有外设中断、关闭全局中断或者直接调用__disable_irq()并且把 SysTick 停止。另外如果 bootloader 和 APP 共用了同一个外设最好在跳转前把外设重新初始化一遍或干脆复位避免 APP 启动后读到奇怪的状态。这一块我会在后面跳转函数里详细说明现在只需要记住一句话跳转动作越“干净”APP 启动越“安全”。3. 手把手实现一个最简单的串口 IAP Bootloader3.1 Bootloader 工程的基本配置新建 Bootloader 工程时首先要保证编译器生成的代码地址从 0x08000000 开始并且不要把代码优化得太激进否则调试时会很难定位问题。我在 Keil 里一般这样配置Flash 起始地址0x08000000Flash 大小根据 bootloader 分区设置宏定义VECT_TAB_OFFSET0x0bootloader 本身不需要偏移向量表优化等级-O1或-O0方便调试串口使能用作升级通信通道通常 115200-8-N-1Bootloader 的最小工程只需要这几个模块时钟初始化、串口初始化、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_ENDbootloader 收到后校验整个 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向量表偏移是否配置中断向量表仍指向 bootloaderAPP 能跑但不进中断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 这块硬骨头啃下来少走几天弯路。