1. 从"一锤子烧录"到"在线升级":为什么我必须搞懂IAP
做嵌入式开发最难受的时刻之一,是设备已经交付、外壳打了胶、现场都跑起来了,结果发现固件里藏着一条隐蔽的 bug,或者客户临时想要加一个功能。以我自己的一段经历来说,有一年做现场设备,几十台已经部署完毕,程序里一个和低功耗唤醒相关的时序问题,排查了很久才确认根因。那会儿项目里还没有任何升级通道,最后只能派人出差、拆壳、接线、连调试器、重新烧录,来回搞了两周。就是从那个项目开始,我把 STM32 的 IAP 在线升级方案完整地梳理了一遍,把 Bootloader、App 跳转、串口分帧传输、Flash 擦写校验整个链路全部打通。
所谓 IAP,全称是 In-Application Programming,中文一般叫应用内编程。通俗点说,就是芯片里先烧一个很小的引导程序(Bootloader),它负责跟外部通信、接收新固件、把固件写入 Flash,然后跳转去运行真正的业务程序(App)。以后想升级,不需要再用烧录器,一条串口线、一个 CAN 口,甚至通过网线和无线模块就能完成固件更新。这篇文章包含了我从原理理解到代码落地的全过程,也会把跳转失败、中断失灵、擦写异常这类我实际踩过的坑单独拿出来讲,希望能帮到正在做或者准备做 IAP 的同行。
1.1 一次设备返修让我下决心研究在线升级
那次返修的具体背景不多说,总之问题确认后,摆在面前的选择只有两个:要么生产拆机重刷,要么给设备加升级能力。拆机重刷在开发阶段不算事,但在量产和现场维护阶段,成本会放大得特别快。设备装在高处、井下或者户外,光是拆装的人工成本就不止烧录那几分钟。
当时我查了不少资料,发现 IAP 并不是一个新概念,很多正式项目都在用,区别只在于做得粗糙还是做得完善。粗糙的做法是 Bootloader 很简单,能收数据、能擦写、能跳转就行;完善的做法会考虑掉电保护、固件校验、版本兼容、升级失败回滚。我的做法是先跑通最基本的链路,再逐步把保护机制补上去,后面你会看到这两者差距有多大。
1.2 ICP、ISP、IAP:三种烧录方式的边界
很多刚开始接触 IAP 的同事会把 ICP、ISP、IAP 混在一起。这里直接用一张表格把差异和适用场景说清楚。
| 方式 | 全称 | 实现手段 | 是否需要外部设备 | 能否远程 | 典型场景 |
|---|---|---|---|---|---|
| ICP | In-Circuit Programming | JTAG/SWD 调试器直接访问芯片 | 需要调试器 | 否 | 开发调试、产线烧录 |
| ISP | In-System Programming | 通过片上 Bootloader,用串口等接口烧录 | 需要上位机/串口线,利用 BOOT 引脚 | 否,必须在板上操作 | 出厂前批量烧录 |
| IAP | In-Application Programming | 芯片内用户程序擦写 Flash,执行跳转 | 只需通信接口 | 可以,配合网络/无线 | 现场升级、OTA、远程运维 |
核心差异在于,IAP 的触发和执行者是芯片内部的程序本身,不需要动 BOOT 引脚,也不需要调试器。只要 Bootloader 放在 Flash 最靠前的区域,App 放在后面,程序就能自己更新自己。这一点让远程升级变成可能,也是 IAP 对设备运维最大的价值。
1.3 IAP 到底能解决什么问题
除了省掉拆壳烧录,IAP 在工程上解决得最多的是这三类问题:第一,固件发布了新功能或者修复了问题,需要快速批量推送到存量设备;第二,不同客户有不同的功能需求,不可能为每个客户单独出一版产线固件,交货后通过升级接口做定制化配置;第三,产品需要远程维护,Bootloader 放在本地只负责接收和写入,通信协议栈放在 App 里,这样升级通道可以根据产品形态自由选择,串口、CAN、以太网、Wi-Fi、蓝牙都行。
有一点要说清楚,IAP 不能替代调试器在开发阶段的作用。在线升级定位不了问题,该接 SWD 单步调试还是得接。它是给产品和运维用的,不是给调试用的,想清楚这一点,做方案的时候就不会过度设计。
2. IAP 的底层逻辑:Flash 分区、启动流程与跳转指针
做 IAP 之前,我建议先搞清楚芯片从上电到跑 App 之间到底发生了什么。很多人代码能跑通,但一问起原因就含糊,遇到跳转失败就只会翻网上帖子。其实原理并不复杂,就是两个地址和一个函数指针。
2.1 先给 Flash 做个功能分区
STM32 内部 Flash 的起始地址是 0x08000000,复位后芯片从这里取代码执行。为了让 Bootloader 和 App 互不干扰,第一件事就是把 Flash 切成几块固定用途的区域。我习惯用一个 512KB Flash 的型号来举例,大致如下:
| 起始地址 | 大小 | 用途 |
|---|---|---|
| 0x08000000 | 32KB | Bootloader |
| 0x08008000 | 约 480KB | App 区 |
| 0x0807F000 | 4KB | 参数区 |
Bootloader 一定要放在最前面,因为芯片复位后默认从 0x08000000 开始取向量表。App 区紧跟着 Bootloader,具体从哪个地址开始取决于 Bootloader 大小。参数区用来存放升级标志、App 版本号、固件 CRC 备份之类的信息,掉电不丢失,而且必须放在 Bootloader 和 App 都不使用的固定地址上。
分区大小没有绝对标准,但有一条硬性原则:Bootloader 分区要给未来升级留余量。我早期把 Bootloader 只设计成 8KB,后来加了一个串口命令行、一组外部 Flash 驱动、一套升级校验协议,空间就紧巴巴了。在 512KB 这种容量下,建议 Bootloader 直接留 32KB 起步,写代码的时候可以稍微从容一点。
2.2 芯片上电后到底执行了什么
STM32 复位以后,CPU 会做两件固定的事:把 0x08000000 地址里存放的值加载到主堆栈指针 MSP,把 0x08000004 地址里存放的值加载到程序计数器 PC。这两个地址就是中断向量表的前两个成员。Bootloader 被编译并烧录在 0x08000000,所以上电后自然先跑 Bootloader。
Bootloader 完成自己的工作后,要转去执行 App,本质上做的事情和复位一样:把 App 区起始地址处的值重新赋给 MSP,把 App 区起始地址 +4 处的值赋给 PC。这个动作在代码里就是"用函数指针去调用一个地址",看起来平平无奇,但有几个隐藏条件必须先处理好。
2.3 一个足够健壮的跳转函数
我工程里的跳转函数长这样:
typedef void (*app_func)(void); int 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); app_func app_reset; /* 检查栈顶指针是否落在 RAM 范围内,防止跳进非法地址 */ if ((msp_value & 0xFFF00000) != 0x20000000) { return -1; } /* 跳转前关掉全局中断 */ __disable_irq(); /* 复位外设时钟和 SysTick,避免把状态带进 App */ RCC_DeInit(); SysTick->CTRL = 0; SysTick->LOAD = 0; SysTick->VAL = 0; /* 把所有中断强制关闭 */ for (int i = 0; i < 8; i++) { NVIC->ICER[i] = 0xFFFFFFFF; } /* 设置新的栈指针 */ __set_MSP(msp_value); /* 取出复位向量并跳转 */ app_reset = (app_func)reset_value; app_reset(); /* 正常不会走到这里 */ return 0; }每个步骤都不是多余的。检查栈指针能防止跳转到一个擦除过或者写入错误的地址;关闭中断是为了避免跳转瞬间有外设中断触发,此时向量表还没切换,程序会跑飞到旧表;复位 SysTick 和时钟,是因为 App 往往在 main 里会重新初始化这些资源,Bootloader 遗留的状态反而会干扰初始化判断。最后一步,把地址 +4 处的值当成函数地址来调用,其实就是让 PC 跳进 App 的 Reset_Handler,让它像冷启动一样跑起来。
这里有一个新手最容易困惑的点:跳转进去的 App Reset_Handler 并不是 App 的 main,而是一段启动代码,它还会负责初始化数据段、清除 BSS 段、调用 SystemInit,最后才进入 main。所以跳转函数只管把 PC 指过去,剩下的事由 App 自身的启动流程接管,不要在跳转函数里画蛇添足去调用 App 的 main。
3. Bootloader 端实现:分帧接收、擦写 Flash、异常兜底
跳转只是最后一步,真正的核心在 Bootloader 怎么把固件安全地写进 Flash。这一点如果偷懒,后面升级稳定性会很难看。Bootloader 我建议保持克制:能做一件事,就绝不做两件事。
3.1 Bootloader 的功能边界
Bootloader 的职责只有四个:检查是否需要升级、接收固件数据、擦写 Flash、跳转 App。复杂的通信协议栈、文件系统、业务逻辑都不要往这里放。原因有三层:一是 Bootloader 本身就是最脆弱的部分,它一旦出问题,设备就失去了升级修复能力,变成砖;二是它的代码量越大,被意外擦写或破坏的概率越高;三是升级场景下通信环境未必好,协议越简单越容易做超时和重传。
那 App 已经具备的驱动能力能不能复用?比如产品主通信是 Wi-Fi,Bootloader 里要不要也带 Wi-Fi 驱动?我的建议是不要。更合理的做法我在第 4 章会细说,先让 App 去完成复杂通信,再通知 Bootloader 执行写入。这样 Bootloader 只需要认一个最基础的串口协议就够了。
3.2 一版够用的串口升级协议
如果只是内部自用,协议可以设计得很朴素,但要能应对下面几个问题:数据帧怎么切分、接收端怎么知道一帧开始和结束、数据错了怎么办、丢帧了怎么办。我平时用的是帧头 + 命令 + 数据长度 + CRC + 帧尾的结构:
typedef struct { uint8_t head[2]; /* 0xAA 0x55 */ uint8_t cmd; /* 0x01 握手, 0x02 擦除, 0x03 写数据, 0x04 校验, 0x05 跳转 */ uint16_t len; /* 数据区长度 */ uint8_t data[256]; /* 数据内容 */ uint32_t crc32; /* 对 head/cmd/len/data 计算的 CRC32 */ uint8_t tail[2]; /* 0x0D 0x0A */ } upgrade_frame_t;握手流程建议这样设计:Bootloader 启动后先给上位机发一帧"就绪"并携带自身版本号;上位机回一帧"握手确认";之后上位机发擦除命令,Bootloader 把 App 区整片擦掉;随后上位机一帧一帧地发固件数据,每帧带帧序号,Bootloader 写完一帧回一帧应答,上位机收到应答才发下一帧;全部发完,上位机发校验命令,Bootloader 对 App 区读回数据做 CRC 比对;校验通过再发跳转命令。
这个流程看起来多了一轮握手和最后校验,但它解决了两个致命问题:擦除不成功时不会写数据;写了一半断线时,上位机通过重发缺少的帧续传,而不是从头再来。传输速率不需要太高,串口 115200 波特率跑几百 KB 的固件,体感也就几十秒,现场升级完全够用。
3.3 Flash 擦写要守住的纪律
STM32 的 Flash 必须按页擦除,不同型号页大小不一样,常见有 1KB、2KB,写之前务必查对应参考手册。擦除之后 Flash 内容是 0xFF,然后按半字或字写入。很多问题都出在擦写细节上,我整理了三条纪律记在代码注释里:
第一,操作 Flash 之前必须解锁。STM32 Flash 编程寄存器默认是锁定的,直接写会没反应,解锁时要按顺序写入两个固定密钥。第二,擦除后一定要读回确认整页都是 0xFF,写入一页后也要把刚写的数据读出来比对,任何一次不一致就立刻停止并向上位机报错。第三,Flash 擦写期间尽量关中断,尤其不要让 SysTick 或者串口中断去打断 Flash 操作,否则轻则写失败,重则程序跑飞。
/* 解锁 Flash 后,先擦除一个页,再写入数据 */ FLASH_Unlock(); FLASH_ErasePage(app_addr + FLASH_PAGE_SIZE * page_index); FLASH_ProgramWord(app_addr + offset, *(uint32_t *)(data + offset)); FLASH_Lock();上面是配合官方标准库的典型调用。实际项目里还要封装一层"先擦后写再校验",不要直接裸调。检查 Flash 忙标志位,等当前操作完成再启动下一次,不然会踩到硬件上的忙等待问题。
3.4 升级标志与断电保护
升级过程中最怕断电。如果 Bootloader 不问缘由地每次启动都直接跳转 App,那么在 App 被擦掉、新固件还没写完时断电,下次上电就会跳进一个空白区域,设备彻底变砖。解决办法就是引入升级标志,并让它先于擦除动作写入参数区。
我的做法是:上位机发出升级请求时,Bootloader 先在参数区固定地址写入一个 magic 值,比如 0xA5A5A5A5,表示"当前处于升级流程"。然后才做擦除、接收、写入、校验。全部成功之后,把这个 magic 值清除,再跳转 App。如果中途断电,参数区仍然是这个 magic 值,下次上电 Bootloader 检查到它,就不会跳 App,而是直接停留在升级模式等上位机重新连接。这相当于给设备留了一扇永远能打开的救生门。
4. App 端配合改造:中断向量表、链接脚本、二次更新
Bootloader 写好了,App 如果还是按 0x08000000 来编译,跳转过去也跑不了。很多第一次做 IAP 的人卡在这一步:Bootloader 明明跳了,程序就是不执行。这一章讲 App 侧必须做的三件事。
4.1 把 App 编译到想要的地址
App 的代码肯定不能再从 0x08000000 开始了。在 MDK 里,打开 Options for Target,找到 Target 页,把 IROM1 的起始地址改成 0x08008000,大小改成剩余 Flash 容量。在 IAR 里,则是修改链接配置文件中的 ROM 起始地址。如果你用 GCC 工具链,改链接脚本里 FLASH 区域的 ORIGIN 即可。
这里特别容易踩的坑是:改了 IROM1 起始地址,但代码里还有一个宏 VECT_TAB_OFFSET 或者类似名字的配置没改。很多芯片厂商的库会提供一个宏来决定向量表偏移量,只在编译参数里把偏移值加上去,对应的启动文件才知道要去修改 VTOR 寄存器。改完以后打开编译生成的 map 文件,确认 App 的__initial_sp和 Reset_Handler 地址确实落在 0x08008000 之后,这一步是最直接的验证方法。
4.2 为什么跳转后中断会"失灵"
App 程序跑起来了,但一触发中断就 HardFault,或者干脆没有任何中断响应。这个现象我见过不止一次,九成以上是向量表偏移没做。向量表就像一个"中断编号到处理函数地址"的电话本,CPU 触发中断时要靠它找人。App 编译地址变了,向量表也搬到了新地址,但 CPU 默认还盯着 0x08000000 的旧表,找电话本找错了地方。
解决办法是在 App main 的最开始,越早越好,设置向量表偏移寄存器:
SCB->VTOR = 0x08008000; __DSB();Cortex-M4/M7 内核都有这个寄存器,直接赋值即可。对于 STM32F1 系列,同样属于 Cortex-M3 内核,也可以操作 VTOR,老版本固件库会提供 NVIC_SetVectorTable 之类的封装,本质上还是写这个寄存器。赋值后加一条__DSB()数据同步指令,确保新地址被 CPU 真正看到,这一步不要省略。
从根本上说,这要求 App 不能假设自己在 0x08000000 运行。凡是跟绝对地址相关的启动配置,比如某些库默认的向量表地址、Boot0 引脚决定的启动模式、Flash 写保护配置,全部要过一遍。
4.3 更推荐的架构:App 下载、Bootloader 写入
上一章我提到 Bootloader 不该背负复杂通信协议,那产品里的以太网、Wi-Fi 这些升级通道怎么接入?成熟项目里更常见的是"App 下载,Bootloader 写入"的分工模式。具体来说:App 运行期间通过网络模块把新固件整个下载下来,临时存放在外部 Flash 或者内存里,下载完成后在参数区写升级标志,然后软复位。Bootloader 上电看到升级标志,不直接跳 App,而是从临时存放区读取固件、写入 App 区,校验成功后清标志再跳转。
这样做的优点非常明显:Bootloader 只需要实现最简单的串口读取,不需要认识 TCP/IP 协议,代码量小、稳定性高。App 是业务程序,本来就承担着所有复杂通信任务,下载固件的逻辑也很好加。升级包下载失败时也容易处理,直接删掉临时文件、清标志、正常重启即可,完全不会影响旧固件运行。如果你正在设计一套新的 IAP 方案,我个人强烈建议采用这种架构。
5. 实测中绕过的坑:跳转失败、中断失灵、擦写异常
IAP 真正痛苦的不是写功能,而是调试各种"看起来不应该发生"的问题。下面这些坑我基本都踩过一遍,每条都给一个排查思路。
5.1 跳转就 HardFault 的排查链路
现象是 Bootloader 打印"开始跳转",然后程序就进 HardFault 了。首先要怀疑的不是跳转函数,而是 App 地址本身。用调试器看内存窗口,检查 0x08008000 处的前四个字节是不是一个合法的栈顶指针,0x08008004 处是不是一个可执行的地址。这两个值如果全是 0xFF 或者乱码,说明 App 根本没有烧录到预期地址,回去查链接配置。
第二个要查的是跳转前中断是否清干净了。Bootloader 这段已经使能过的外设中断,特别是串口、定时器中断,如果跳转前没有关闭,跳转瞬间中断触发,App 的向量表还没生效,CPU 会去旧向量表找中断处理函数,旧表对应位置的代码可能已经被擦写掉了,自然 HardFault。所以跳转函数里那一段关中断和清 NVIC 的代码,不要因为看着繁琐就删掉。
第三个隐蔽点是时钟树。如果 Bootloader 把系统时钟切到了外部高速晶振,而 App 的 SystemInit 又假定自己从默认时钟开始初始化,中间状态可能不对。稳妥做法是跳转前把 RCC 复位回默认状态,后面 App 自己重新配时钟。除了个别对启动时间极其敏感的场景,这个操作几乎没有副作用。
5.2 程序能跑但外设中断全部失灵
代码逻辑看起来正常,串口能打印,但外部中断、定时器中断都进不去。用调试器手动挂断点观察,发现程序运行位置对,中断标志也置了,就是不跳处理函数。十有八九是向量表偏移量不对。
排查时先确认 App 的SCB->VTOR赋值是否真的执行了。有的库在 SystemInit 里会重置 VTOR,而你自己代码里写在它之后的地方,看起来写了但被覆盖;有的项目把 VTOR 放在一个条件编译里,宏没开就根本没执行。解决办法是在启动文件里就完成偏移,或者至少放在SystemInit()之后第一行,并加上断点确认寄存器值变成了 0x08008000。
还有一种情况是偏移值虽然写了,但地址没按向量表对齐。不同 STM32 系列对向量表地址的对齐要求不同,一般是按中断向量总数的字节对齐,常见是 256 字节对齐。偏移地址写 0x08008000 满足要求,但如果 App 地址选得比较随意,比如 0x08008100,就可能存在对齐风险,同一系列的不同型号也会有差异,务必看参考手册确认。
5.3 Flash 写入后读回校验不一致
这个问题多半出在"写入前没擦除"或"写入方式不合格格"。Flash 只能把 1 写成 0,不能把 0 写成 1,如果不先擦除,页里的旧数据和新数据叠加,读回来当然对不上。另外,STM32 Flash 编程接口要求按半字或字写入,不允许一次性写一个字节后还指望相邻单元不受影响。使用标准库时要注意是调 ProgramHalfWord 还是 ProgramWord,不同型号支持的最小粒度不一样。
我的习惯是每写完一个扇区,立刻回读整个扇区,和内存里的源数据逐字节比对。一旦不一致,立即向上位机回错误帧,同时保留错误现场方便定位。写入期间如果有 DMA 或者中断在操作同一片 Flash,也会导致数据异常,需要把这类访问停掉。
5.4 升级到一半断电,如何保证设备还能救
前面提到的升级标志在这里起决定作用。没有这层保护的设备,升级过程中断电,上电后 Bootloader 如果仍然盲目跳转,有极大概率变砖。有了标志保护,Bootloader 就懂得"上一次升级没完成,我不跳,继续等上位机"。
但也别完全依赖标志,因为标志本身也可能被破坏。更稳的做法是,Bootloader 跳转前除了检查标志,还顺带检查 App 区起始地址的前几个字节是不是有效的栈顶指针和复位向量。无效就留在升级模式,有效才跳。这套"双重检查"逻辑很简单,却能避免相当比例的返修。
5.5 完整排查链路示例
我曾调试过一台设备,现象是升级完成后串口无任何输出,LED 不闪,整机像死了一样。刚开始以为是升级写入失败,后来重烧整个镜像也没有帮助。顺着链路排查,发现 Bootloader 在跳转前把 SysTick 配置清掉,但 App 的延时函数依赖 SysTick 的初始 Tick 值,而且 App 里调用延时时才去初始化相关寄存器,初始化参数又读取了启动文件里预置的时钟值,时钟值因为 Bootloader 复位时钟而变了,最后初始化错乱。
这一类问题不显山露水,但它提醒我:跳转前的"清场"动作要克制,只清理中断和外设,把时钟复位放在系统初始化正常路径里完成,不要在 Bootloader 里过度干预。每次遇到这种难缠问题,我都建议在 App main 的最开始用 GPIO 翻转或者串口打印一个"入口标记",先确认跳转成功,再做外围定位,能节省大量时间。
6. 把 IAP 做得更实用:CRC 校验、版本管理和 A/B 双区
基础链路跑通之后,IAP 还有一个更重要的目标:让升级这件事变得可靠、可控、可回滚。缺了这些,升级一次像开一次盲盒,体验很糟糕。
6.1 给固件包加上 CRC32 完整性校验
串口传输受环境干扰,Wi-Fi、蓝牙传输更不用多说,不校验就写入 Flash 等于拿设备开玩笑。帧协议里我已经加了 CRC32,这里再强调一点:CRC 的多项式和起始值,上位机和下位机必须完全一致。STM32 内部自带硬件 CRC 外设,但它生成的 CRC 结果和常见的软件 C RC32 算法默认配置不一定一样,我就吃过这个亏,上位机算出来和芯片硬件算出来总对不上,最后干脆自己写了一个查表法的软件 CRC32,两边统一,反而省心。
校验应该分两层做:单帧一层,保证传输过程中每个数据帧没被篡改;整包一层,所有数据写完以后,对整个 App 区算一次 CRC,和上位机发的包尾 CRC 比对。只做单帧校验无法发现帧顺序错乱或者整个文件不完整的问题,两个层面都做才能堵住漏洞。
6.2 版本号与兼容性检查
升级包里要带 App 版本号、编译时间、最小 Bootloader 版本。这个信息既写在升级包头里,也保存在参数区。Bootloader 在接收固件前先检查包头,如果版本低于当前版本,直接拒绝;如果固件要求的 Bootloader 版本高于当前版本,说明芯片 Bootloader 太老,先要完成 Bootloader 自身的升级。
这看起来增加了很多规则,但长期维护一批设备时特别有用。设备分布在不同地区,固件版本千差万别,没有版本检查乱升级,很容易把新功能推到不支持它的老硬件上。版本信息在参数区里要预留足够空间,后续加硬件型号、生产批次这些信息就不用重新分区了。
6.3 A/B 双区带来的回滚能力
对可靠性要求高的设备,A/B 双区方案是这个领域的标准答案。它的思路是在 Flash 里放两个 App 区:A 区跑当前版本,B 区作为备用,升级时把新固件写入非当前运行区,写完校验通过后切换启动指向。切换之后如果新版本运行异常,设备可以自动回滚到旧版本。
代价是 Flash 利用率直接减半,比如一个 512KB 的芯片,Bootloader 之外的可用空间被拆成两个 200KB 左右的区,App 体量必须受约束。不过现在很多芯片 Flash 容量都很大,封装合适的话,双区的安全感是分区减半无法衡量的。实现上就是在参数区多放一个"当前启动区"标记,Bootloader 跳转时读这个标记决定进入 A 区还是 B 区。回滚策略可以做成新版本启动后看门狗超时自动切回,也可以由上位机远程下发命令强制切回,具体取决于产品形态。
最后再说一点我的习惯:IAP 不要等项目快交付了才开始设计。Bootloader 分区、Flash 容量预留、升级参数区,这些在设计原理图阶段就该定了。我在后来的项目里,硬件第一版就把 Bootloader 接口、预留升级按键和参数区地址全部规划好,后面加功能、修 bug 都变得很从容。第一次花了一周时间把整套链路跑通,之后的每个项目直接复用,收益远超当初投入。