搞嵌入式开发的人,迟早会碰到“固件升级”这个问题。产品已经量产出货了,现场发现一个bug,或者需要加个新功能,总不能把设备拆下来重新烧录吧。这时候就得靠IAP(In-Application Programming,应用内编程)了。这篇文章就围绕STM32在线IAP升级这件事,掰开揉碎讲清楚Bootloader怎么设计、Flash怎么分区、跳转代码怎么写、通信协议怎么定,以及那些文档里不会告诉你的坑。适合正在做产品、或者准备把升级功能加进自己项目的开发者参考。
1. 先把IAP这件事想明白:它到底解决什么问题
很多人第一次接触IAP,是在做Bootloader的时候被“跳转”这个概念卡住的。跳转本身不复杂,无非就是修改程序计数器,但真正难的是怎么让系统在“Bootloader”和“App”两个程序之间安全、稳定地切换,同时保证升级过程断电了也不至于变砖。
1.1 IAP与ISP的本质区别
做个简单对比。ISP(In-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或32KB
- App区:从Bootloader之后开始,占剩余的大部分空间
- 预留参数区:放升级标志、版本号、配置信息,一般单独划分几个页
举个例子,某芯片有256KB Flash,页大小为2KB,Bootloader区我分配32KB(16页),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,建议从一开始就把内存分区、协议交互、异常回退这些基础设计做扎实,后面会省很多事。