搞嵌入式开发的人迟早会碰到“固件升级”这个问题。产品已经量产出货了现场发现一个bug或者需要加个新功能总不能把设备拆下来重新烧录吧。这时候就得靠IAPIn-Application Programming应用内编程了。这篇文章就围绕STM32在线IAP升级这件事掰开揉碎讲清楚Bootloader怎么设计、Flash怎么分区、跳转代码怎么写、通信协议怎么定以及那些文档里不会告诉你的坑。适合正在做产品、或者准备把升级功能加进自己项目的开发者参考。1. 先把IAP这件事想明白它到底解决什么问题很多人第一次接触IAP是在做Bootloader的时候被“跳转”这个概念卡住的。跳转本身不复杂无非就是修改程序计数器但真正难的是怎么让系统在“Bootloader”和“App”两个程序之间安全、稳定地切换同时保证升级过程断电了也不至于变砖。1.1 IAP与ISP的本质区别做个简单对比。ISPIn-System Programming在系统编程是通过芯片自带的串行下载接口串口、SWD、USB DFU等把固件写进Flash由芯片厂商固化在ROM里的引导代码来完成。它需要外部工具介入比如调试器或者一个串口下载软件而且大多数时候设备不能作为独立产品自己发起升级。IAP则完全不一样。它是在应用运行的过程中由用户自己的程序去擦除、写入Flash完成固件更新。也就是说升级逻辑完全掌握在你自己手里。你可以让设备通过网络、串口、SD卡、无线模块甚至另一个处理器来获取固件数据然后调用Flash操作接口自己完成升级。这意味着产品可以在现场、在用户手里完成更新不需要返厂不需要拆机不需要接上调试器。所以IAP解决的不是“怎么把Flash写进去”而是“怎么让设备自己有能力替换自己的程序”。这也是为什么我把IAP看作一套体系而不只是一个烧写函数。1.2 什么场景必须用IAP用不用IAP要看你的产品形态。如果你的项目是开发板、学习用的小Demo那无所谓调试器直接刷就行。但如果这是一个量产产品用户分布在各个地方升级需求会持续存在那IAP基本都是刚需。举个实际例子我做过一个电池管理模块前期已经铺了上千套到现场后来发现某型号电池的曲线参数有偏差需要更新内部查表逻辑。如果没有IAP就得挨个寄回或者派人上门成本极高。有了IAP通过网络下发一个固件包几秒就搞定。另外还有一些特殊场景比如Bootloader本身要支持多应用切换或者设备需要远程升级或者你有意愿做一个支持用户自行更新固件的平台——这些都离不开IAP。就算只是给产品预留一个调试模式把IAP串口做成隐藏入口也比直接留JTAG口强得多。2. Flash内存布局与Bootloader设计这一步错了后面全白搭IAP系统设计的第一件事不是写代码而是规划Flash。内存布局没规划好后面所有代码写得再漂亮都是空中楼阁轻则升级失败重则设备直接变砖。2.1 内存分区方案怎么定以大多数STM32系列为例Flash一般是按页Sector/Page管理的不同系列页大小不一样比如有些是1KB一页有些是2KB或者更大。你要做的第一件事就是查你所用芯片的Flash总容量和页大小然后规划出至少两段区域一段放Bootloader一段放App。我常用的分区套路是这样Bootloader区从0x08000000开始分配16KB或32KBApp区从Bootloader之后开始占剩余的大部分空间预留参数区放升级标志、版本号、配置信息一般单独划分几个页举个例子某芯片有256KB Flash页大小为2KBBootloader区我分配32KB16页App区从0x08008000开始App区内再分一个区域存升级包有些实现会先把升级包写到另一个区域校验完再拷贝到App区相当于双缓冲参数区放在靠后的独立页。Bootloader区大小怎么定别拍脑袋。先大概评估你需要的通信协议栈、Flash驱动、握手逻辑代码量。串口Bootloader加最精简协议一般16KB够用如果还要加加密、压缩、支持多种传输通道那就给32KB甚至64KB都不过分。宁多勿少因为Bootloader一旦固化后期想改就很难了。2.2 Bootloader的职责边界别什么都塞进去Bootloader代码要极端克制。它的职责只有三个检测升级请求标志位、按键、特定数据帧接收固件数据并写入Flash跳转到App执行仅此而已。什么RTOS、文件系统、协议栈统统不往Bootloader里放。你塞进去的每一样东西都会增加Bootloader的体积、复杂度和出错概率。Bootloader越简单越不容易翻车因为它是你系统里最后一个救命稻草。我在实际项目里Bootloader里甚至没有用中断全程轮询。一个串口接收超时判断、一个状态机、一套Flash写入流程就足够了。你可能会觉得这有点原始但在Bootloader这个场景“原始”往往等于“可靠”。2.3 中断向量重映射最容易犯的致命错误App跳转之后第一个碰到的问题是中断向量表错位。以Cortex-M内核为例程序启动后读向量表的第一项初始栈指针、第二项复位中断入口执行。如果App还在Bootloader的向量表基础上运行那任何中断都会跳错位置系统直接HardFault。解决办法是在App的启动代码或者main函数最开头设置向量表偏移寄存器。Cortex-M3/M4内核里这个寄存器叫VTOR很多STM32的库函数里也有对应的系统函数。常见的写法是在main函数最开始加上这样一段SCB-VTOR FLASH_BASE | APP_ADDR;这里的APP_ADDR就是App区的偏移地址。要注意这个偏移地址必须满足对齐要求大多数Cortex-M要求向量表按地址对齐比如128字节或芯片Flash页大小对齐。我在项目里设置App起始地址都是按页对齐的这样就不会有对齐问题。另外还要注意一个细节如果App里使用了操作系统或者你自己实现了某些外设中断处理那你需要在链接脚本里把中断向量表的存放地址也改掉让向量表数据实际放在App区开头。不然编译出来的App向量表默认还是在0x08000000即便你设置了VTOR数据本身不对也没意义。3. 核心代码实现擦写、跳转、接收每个环节都有讲究写IAP代码没有太玄妙的东西但每一步如果不注意细节调试起来会非常折磨人。这里把三个核心模块掰开讲Flash擦写流程、程序跳转、串口接收协议。3.1 Flash擦写流程与注意事项STM32的Flash操作基本步骤都一样解锁、等待忙、擦除页、编程、上锁、校验。但实际操作里有两个非常容易踩的坑。第一个坑是中断问题。Flash操作期间芯片的Flash控制器比较忙如果你在擦除或编程过程中响应了中断且中断服务函数里访问了Flash恭喜你卡死或者数据损坏都是家常便饭。所以一个稳妥的做法是在擦写Flash之前关闭所有可屏蔽中断等操作完成后再恢复。有的芯片还要求关闭指令预取缓冲和Cache甚至要求Flash操作在RAM里执行因为如果代码本身放在Flash里擦写Flash时可能导致取指失败。第二个坑是写保护。某些芯片出厂会有Flash读保护或写保护设置如果你用调试器设置过读保护等级或者在某次操作里不小心开启了写保护那擦写Flash时会返回错误标志。遇到这种情况检查一下选项字节区域把保护等级降下来再说。一个典型的页擦除函数大概长这样void flash_erase_page(uint32_t page_addr) { HAL_StatusTypeDef status; __disable_irq(); // 清除各种标志位 CLEAR_BIT(FLASH-SR, FLASH_FLAG_ALL); // 解锁Flash HAL_FLASH_Unlock(); // 执行页擦除 status HAL_FLASHEx_Erase(erase_config, page_error); // 上锁Flash HAL_FLASH_Lock(); __enable_irq(); // 根据status做错误处理 }代码虽短但关闭中断这一步千万别省。在实时性要求高的系统里擦写Flash的短暂阻塞是可以接受的前提是你测算过最长的关闭中断时间别让关键任务卡死。3.2 跳转函数的正确写法死机现场最多的就是跳转之后跑飞。跳转代码本身很简单typedef void (*pFunction)(void); pFunction jump_to_app; uint32_t app_stack_addr *(volatile uint32_t *)APP_ADDR; jump_to_app (pFunction)(*(volatile uint32_t *)(APP_ADDR 4)); __disable_irq(); // 把SysTick、外设中断等全部复位 HAL_RCC_DeInit(); HAL_DeInit(); __enable_irq(); // 设置MSP指向App的栈顶 __set_MSP(app_stack_addr); // 跳转到App复位函数 jump_to_app();看着不多但有几个点必须做对。第一跳转前一定要检查目标地址合法性。比如地址是否在Flash有效范围、栈指针地址是否落在RAM区间。我见过一个案例升级包数据损坏导致App区第一个字被写成了随机值跳转后栈指针飞到天上整个系统直接崩了。在跳转前加一个简单校验能避免大量痛不欲生的调试时间。第二跳转前要把当前用到的外设全部复位。Bootloader里可能初始化了串口、看门狗、定时器如果不关干净直接跳过去App初始化时可能产生冲突。比如看门狗没有关闭而App启动流程又比较慢那App还没跑起来就被看门狗复位了这是非常常见的跳转后无限重启的根源。第三MSP和PSP要分清。如果你的Bootloader用了RTOS那么跳转前不仅要设置MSP还要确保当前使用的是Thread Mode而不是Handler Mode。一般情况下Bootloader不用RTOS就省了这些麻烦这也是我倾向把Bootloader做到最简的原因之一。3.3 App侧程序编写要点App侧其实不需要为IAP做太多额外工作但有三件事很关键。第一件事编译时的地址设置。用GCC的话链接脚本里FLASH的起始地址要改成APP_ADDR用Keil的话在Options - Target里把IROM1的起始地址改成APP_ADDR大小改成App区的大小。这一步如果不做App编译出来根本不能独立运行于App区。第二件事向量表偏移设置。前面已经提过在main函数入口就设置SCB-VTOR。要特别提醒的是如果App里用了操作系统比如FreeRTOS且这个偏移设置被放在某个初始化库函数里执行得太晚可能在设置之前系统就已经响应了某个中断结果还是错位。所以我建议把这个设置放在最前面越早越好。第三件事App里要留一个升级入口。比如一个特定的串口命令、一个按键组合、一个网络指令触发后App写入一个“需要升级”的标志位到参数区然后软件复位。复位后Bootloader检测到这个标志就进入刷写流程。这种“先写标志再软复位”的模式比直接在App里调用Bootloader跳转函数要干净得多因为它保证了App退出时的状态是确定的外设状态是干净的。4. 升级通信协议设计稳定传输靠的不是加大波特率Bootloader的好与坏很大程度看它的升级协议设计得怎么样。如果你只在一个安静的桌面上测试随意写写都行但放到真实场景里会有干扰、会有丢包、会有用户中途断电协议设计得松一点都不行。4.1 帧格式与握手交互不要只有一个“发数据”流程我在正式项目里用的协议至少包含这么几个环节握手、版本确认、擦除通知、分包传输、完整性校验、跳转确认。握手阶段不是简单发一个“我来了”。我设计的帧一般长这样帧头固定字节序列比如0xAA 0x55用于同步长度域一帧数据的总长度命令字标识帧类型握手请求、握手应答、数据帧、结束帧等序号域帧计数器用来处理乱序和重复数据域长度可变保存固件数据或者附加信息校验域CRC32或至少CRC16每次传输接收方必须回一个ACK发送方在超时时间内没收到ACK就要重发。这个ACK机制是基础中的基础。有的同学图省事发完就拉倒结果在无线场景下丢一帧就升级失败最后只能一遍遍地重新整包传。握手流程我习惯这么走上位机发握手帧里面带上固件版本、固件大小、CRC校验值Bootloader应答如果版本比对通过、空间够用就返回“准备就绪”上位机发擦除指令Bootloader执行擦除并返回结果上位机开始分包发数据每包等ACK全部发完后上位机发结束帧Bootloader做整体CRC校验通过则置位标志位并跳转这个流程里最关键的是“擦除”这个环节单独拎出来做命令。因为擦除一个较大的Flash区需要时间如果擦除和数据放在同一个命令里上位机容易超时而且擦除期间的任何返帧都可能被误判为数据帧。4.2 分包大小怎么选不是越大越好很多人在设计升级协议时会本能地想把每个包做大觉得这样效率高。但实际不是那么回事。串口场景下包太大意味着单帧耗时太长。如果在无线环境下一个包出错就导致整个大包重发效率反而更低。我常用的经验值是512字节到1024字节的数据载荷。这个大小兼顾了两个点一是Flash页对齐很多芯片一页就是1KB或2KB一次擦一次写正好对应一页二是单帧出错时重发代价可控。还有一个细节每个数据包的序号处理要足够健壮。比如收到重复序号的包接收方应该回复“这个包已经收到过了”的ACK而不是直接报错收到跳包的序号接收方应该能提示“上包缺失”让上位机重发。这样即使通信质量不佳也能通过丢几包就补几包的方式完成升级而不是整包推倒重来。4.3 断点续传与超时处理产品化的关键一步把升级做到“能跑”不难但做到“产品级”就需要考虑断点续传和超时兜底。断点续传的意思是升级过程中突然断电或者通信中断等系统重新上电后Bootloader能不能判断出当前有没有完整的App、有没有升级包的缓存然后决定是继续升级还是放弃升级、直接跑旧App。要做好这一点参数区至少要存这么几个状态升级进行中标志、接收到的字节数或包序号、固件CRC、目标版本号。每次收到一包数据并且成功写入Flash后都更新这个进度信息。二次上电后Bootloader看到“升级进行中”这个标志且进度信息有效就可以请求上位机从断点继续发数据。当然这要求上位机的固件包格式是支持任意偏移读取的不然还得从头传。超时处理也不能马虎。Bootloader进入刷写模式后如果长时间没有收到有效数据帧应该有一个超时退出机制自动复位回到正常运行旧App的状态。这样即使上位机崩了、线松了设备也不会一直卡死在升级模式里。我在项目里一般设置5到10秒无有效数据就自动复位。5. 实际调试中遇到的坑四个高频问题排查实录写代码是一回事调试是另一回事。IAP相关的问题现象都很相似——跑飞、卡死、升级失败、反复重启但根因可能各不相同。这里整理几个我实际排查过程中遇到的典型问题。5.1 跳转后死机优先级最高的排查项跳转后死机我一般按三步排查先确认App区首地址数据是否有效。你可以用调试器读一下App区第一个地址存放的初始栈指针是否为合法的RAM地址。如果这个值是0xFFFFFFFF那基本可以确定升级包有问题或者App根本没写进去。再确认向量表偏移设置是否在App启动最早期执行。Cortex-M在复位后、执行第一条指令之前是从向量表取栈指针和复位向量的。如果你App代码里在main函数才设置VTOR那么在从复位到进入main之间会有段窗口期期间任何中断比如系统滴答、看门狗、外部IO中断都可能造成取向量错误。我用的一个保险方法是在启动文件里、进入main之前就设置VTOR有些编译器的启动汇编文件里甚至预留了SystemInit的位置把VTOR设置放那里最稳。最后看外设状态的清理。有些外设在Bootloader里被初始化后状态寄存器里的某些标志位还残留着App初始化时如果按“上电默认值”来配置就可能出现状态错乱。跳转前把DMA、定时器、串口、ADC、看门狗全部DeInit这个是基础操作。5.2 Flash擦写失败查看寄存器就明白了擦写失败一般分为两类。一类是操作时序问题比如没有解锁、没有等待BSY标志清除、操作了保留位区域等。另一类是保护问题这类最隐蔽尤其是用调试器连过以后芯片的Flash读保护等级可能被改过或者某些页被写保护。擦写失败时查看Flash状态寄存器的错误标志位是最快的定位办法。不同系列的错误标志位定义不同但思路一致先清标志、再重试、如果连续报同样的错误基本可以确定是硬件保护或非法操作。我遇到过一次特别诡异的情况Bootloader擦写前512KB没问题但擦写最后两页总是报错。查了大量资料后发现最后两页其实是信息块区域根本不归用户Flash区管。这种问题就是没查清芯片的Flash映射表造成的写代码之前把对应型号的技术手册翻透能省很多时间。5.3 升级失败后设备变砖的挽救方案设备“变砖”通常是指升级过程中断电导致App区数据不完整Bootloader跳转时发现App区校验失败又没有回退机制于是一开机就进入升级模式用户拿不到可运行的程序看起来就像死机了。要避免这种情况我推荐从设计上做两个保险。第一个是AB分区方案。也就是在Flash里放两份App一份当前运行的“A”区一份用来接收新固件的“B”区。升级的时候把新固件写到B区校验通过后再切换启动入口。这样即使B区写坏了A区还能正常运行。这个方案空间成本高但对可靠性要求高的产品很值得。第二个是在参数区维护一个“上上次可用版本回退机制”。升级前把当前App的关键信息备份到参数区升级失败时Bootloader可以回滚到旧版本。实现成本比AB分区低但已经能覆盖大部分“断电导致变砖”的问题。如果你的项目受成本限制只能放一份App也没关系但至少要保证Bootloader能识别“App无效”这个状态进入升级模式后不会死循环而是等待上位机重新下发固件。这样设备至少不会彻底变砖。5.4 低波特率串口升级慢优化思路很多产品的升级接口是现成的调试串口波特率只有9600甚至更低。传一个100KB的固件算下来要一百多秒用户等得很痛苦。优化思路有几个方向压缩固件数据用LZ4或者MiniLZO这类轻量压缩算法把通信通道换成更快的接口比如USB-CDC或者SPI如果只能走串口就尽量把波特率提到平台上同时把校验算法换成轻量的CRC16减少每帧的协议开销。这里提醒一句不要为了压缩处理时间在Bootloader里引入过于复杂的解压算法。Bootloader代码体积和复杂度永远是第一要控制的东西。如果一定要压缩尽量做成编译时压缩、运行时解压而且解压算法要用RAM友好型的避免在Bootloader阶段使用大量动态内存。6. 从项目落地角度看IAP设计的几条个人建议最后聊几个我从多个产品项目里总结出来的经验算不上什么高深理论但确实能帮你减少返工。第一个建议是Bootloader的编译选项里把优化等级调到最高也没关系但一定要关闭所有会自动添加额外特性的功能比如断言、堆检查、断言失败反馈等。这些功能在开发期很有用但在Bootloader里会让程序体积和运行路径不可控。第二个建议是给升级功能做一个硬件上的“强制升级引脚”。比如在Bootloader里判断一个特定的GPIO状态如果这个引脚在复位时被拉低就强制进入升级模式。这样即使App彻底跑不起来只要引导用户按住按键再上电就能重新进入升级流程。成本几乎没有利润很大。第三个建议是升级过程中的每一步日志都要有输出。Bootloader里的串口日志先不要想着省打印几个关键状态字模块初始化完成、Flash擦除开始、擦除完成、每一包接收结果能帮你远程定位大量现场问题。如果担心日志占资源可以用一个整形变量做状态码升级失败时上位机把状态码读出来即可。第四个建议是不要以为App只会被升级一次。你要考虑的是这个设备在生命周期内可能要升级几十次。所以Bootloader的跳转逻辑、升级校验、参数区状态管理都要经得起反复擦写的考验。Flash是有擦写寿命的尽量采用磨损均衡策略别把每次升级都固定写在同一页。尤其是有大量参数写入的场景要设计好页切换逻辑。最后再分享一个小技巧升级时把支持新的固件版本号一并写入参数区。以后做现场统计时通过网络或串口读取每台设备的版本号就能快速掌握固件部署情况。IAP这套东西技术原理不算难难的是把边界情况想全、把后续维护路径设计好。希望这篇文章能帮你在规划固件升级时少走些弯路。如果你正在做Bootloader建议从一开始就把内存分区、协议交互、异常回退这些基础设计做扎实后面会省很多事。