做嵌入式开发这几年,每次听到“IAP升级”这四个字,我脑子里都会先闪过一次变砖的经历。HC32F460这颗国产Cortex-M4芯片,性能不错,但IAP升级做不好,轻则产品要返厂,重则现场彻底罢工。这篇文章不会去念手册,而是把我在HC32F460 IAP升级过程中反复踩过、也反复验证过的那些注意事项整理出来,包括Flash分区怎么规划、Bootloader里哪些决策容易后悔、App工程必须改哪几处、以及升级卡死和掉电变砖的抢救思路。无论你是刚接触IAP的新手,还是已经写了几个Bootloader的老手,只要正在用HC32F460做升级功能,这篇文章都值得存下来对照着看。
1. HC32F460的Flash分区与启动链,决定了IAP能不能安全落地
1.1 为什么最终会选IAP,而不是ICP或ISP
先明确一下三种方式的差别。ICP是通过SWD调试器直接烧Flash,适合研发调试和产线首件,但产品已经发到客户手里之后,你不可能要求对方拿调试器去升级。ISP是利用芯片出厂固化的一段引导程序,通过串口这类接口把固件写进Flash,不需要外接调试器,但很多产品在整机状态下串口并不方便引出,而且原厂ISP协议对帧格式和时序有要求,扩展起来不够灵活。IAP是在你自己的程序里放一个升级引导程序,通过任意可用的通信接口收到固件后,直接操作Flash控制器完成自我写入。
HC32F460的IAP灵活就灵活在通信接口和协议都由你说了算。串口、CAN、SPI,甚至网口都能做升级通道;自定义协议还能加版本校验、断点续传、回滚保护。所以量产产品基本都是这套方案:Bootloader负责启动和升级,App负责业务,两者通过Flash分区隔离开。
1.2 一个不容易后悔的分区方案
分区是IAP的第一步,分区错了后面全错。HC32F460不同型号Flash容量有差异,下面以512KB Flash、按8KB扇区划分的情况举例,具体扇区大小你还是要以对应型号的参考手册为准。
| 分区 | 起始地址 | 大小 | 用途 |
|---|---|---|---|
| Bootloader区 | 0x00000000 | 32KB | 启动引导、升级主逻辑 |
| App区 | 0x00008000 | 448KB | 用户应用代码 |
| 参数区 | 0x00078000 | 32KB | 升级标志、版本信息、断点记录 |
为什么App起始地址选0x8000而不是紧贴Bootloader后面?因为Flash擦除是按扇区进行的,App区只有落在整扇区边界上,Bootloader区才能独立擦写,不会被App区的擦除操作误伤。其次,Cortex-M4向量表本身需要对齐,0x8000是0x400的倍数,后面设置SCB->VTOR时不会额外产生对齐冲突。
我特别建议保留一块独立参数区。升级标志写在参数区,不要把标志和App代码混在同一个扇区里。掉电恢复时,Bootloader只需要读固定地址的标志就能判断当前处于什么状态,逻辑会简单很多。
1.3 启动链和向量表偏移
Cortex-M4上电后从0x00000000取初始MSP,从0x00000004取Reset_Handler地址。Bootloader正好放在0x00000000,所以芯片一启动,第一段代码必然是Bootloader。Bootloader完成升级判断后,再跳转到App。
App的向量表不在0x00000000,而在0x00008000。这就意味着App初始化时,必须把向量表偏移寄存器SCB->VTOR改成0x00008000。如果漏了这一步,CPU触发中断时仍然去0x00000000处查向量表,拿到的全是Bootloader的中断入口,App里的串口中断、定时器中断、DMA中断全会跑飞。
跳转还有一个容易被忽视的地方:跳转前尽量把外设复位到默认状态,关闭已使能的中断,清掉挂起的中断标志。很多App启动后莫名其妙跑飞,就是因为在Bootloader阶段外设状态没收拾干净,App的初始化代码又假设外设是干净的。
2. Bootloader里几个“事后才后悔”的设计决策
2.1 升级协议别一上来就写复杂
我第一版IAP协议犯过典型的过度设计错误:每一帧都带好多字段,状态机写到后面把自己绕晕。后来我总结,Bootloader里的协议只需要稳定、够用,不要把加密、压缩、断点续传全部塞进第一版。
一个简单的自定义协议可以这样设计:
| 字段 | 长度 | 说明 |
|---|---|---|
| 帧头 | 2字节 | 0xAA 0x55 |
| 命令 | 1字节 | 0x10开始升级,0x11数据包,0x12结束 |
| 长度 | 2字节 | 数据字段长度 |
| 数据 | N字节 | 固件数据或参数 |
| CRC32 | 4字节 | 从命令到数据的校验 |
| 帧尾 | 2字节 | 0x0D 0x0A |
数据包大小建议控制在1KB到2KB之间。太小则交互次数多,效率低;太大会把串口缓冲区撑爆,丢一个字节就得重传一整包。
2.2 校验要做两遍,不能只看写成功
很多初版只对每一包做CRC,或者只对整包做一次校验,这都不够。我的做法是接收时每包校验一次,错了直接丢弃并要求主机重传;全部写完后,再从整个App区重新读出来算一次CRC,和升级包头部里的CRC比较。不要盲目相信Flash编程器返回的成功标志——虽然大部分时候它写进去了,但“读回来再算一遍”永远是最稳的验证方式。
头部信息至少要包含魔数、目标设备类型、固件版本、总长度、整包CRC。魔数用来识别这是不是合法固件包,设备类型防止把A型号的固件刷进B型号,版本号则是回滚和防降级的基础。
2.3 单Bank直写,还是双Bank切换
HC32F460的Flash空间够大,可以设计成双Bank:A区跑当前App,B区用来存新固件。全部校验通过后,再一次性切换启动区。双Bank的容错能力很强,但代价是可用的App可用空间直接少一半。
如果产品是消费类小批量,单Bank直写加上有效标志就够了;如果是工业设备、网关这类不允许长时间停机的产品,我建议优先双Bank,或者至少给固件留一个完整的备份区。现场升级最怕的不是慢,而是升级失败之后设备起不来。多花一点Flash,换来“随时能回退”的底气,很划算。
2.4 强制升级入口必须独立于App
Bootloader的启动逻辑通常是:检查App有效就跳转,App无效就进升级模式。这里有个隐患:如果App跑飞了,但它的有效标志还没被破坏,Bootloader依然会跳进一个坏掉的App,升级入口就彻底消失了。所以必须有一个不受App控制的强制升级入口。
我常用的做法是:Bootloader启动后延时500ms,检测一个按键引脚,或者检测串口是否收到升级命令。如果检测到,就留在Bootloader里等升级;否则再跳转App。这个延时不要省,它换来的是现场可救性。没有这个入口,等设备已经变砖时再想升级,只能拆机开盖找调试接口。
3. App工程要改的链接地址、向量表和中断环境
3.1 链接脚本里的ROM起始地址
App工程如果还用默认的0x00000000,编译器生成的代码位置会和Bootloader重叠,下载时直接覆盖Bootloader,重则SWD连接都受影响。所以App工程第一个要改的就是ROM起始地址和长度。
Keil里在Options -> Target页面设置IROM1的Start为0x8000,Size为0x70000。IAR在链接配置文件里把ROM起始地址改成0x8000。GCC工程则改链接脚本中FLASH的ORIGIN为0x8000,LENGTH为0x70000。0x70000是448KB,正好对应前面分区表里App区的长度。
这里还有个小提醒:编译生成Hex后,用十六进制编辑器看一眼第一条记录,起始地址应该是0x8000,而不是0x0000。如果还是0x0000,说明链接脚本没改干净,刷进去就是灾难。
3.2 SCB->VTOR 必须在main一开始执行
App的main函数第一件事就应该是设置向量表偏移,不要等RTOS启动,也不要在第一个外设中断使能之后才设置。
#include "hc32f460.h" #define APP_START_ADDR 0x00008000UL void app_vector_table_init(void) { SCB->VTOR = APP_START_ADDR; __DSB(); __ISB(); } int main(void) { app_vector_table_init(); /* 其他初始化 */ }为什么加__DSB和__ISB?因为VTOR是系统控制寄存器,写完之后需要内存屏障让CPU重新取指和中断向量读取立刻生效。不加有时候也能跑,但会在极端时序下翻车,属于典型的“不写后悔”的代码。
3.3 跳转前的“环境卫生”
Bootloader跳转App之前,要把现场收拾干净。简单跳转代码如下:
void jump_to_app(uint32_t app_addr) { uint32_t msp = *(volatile uint32_t *)app_addr; uint32_t pc = *(volatile uint32_t *)(app_addr + 4); if ((pc & 0xFF000000) == 0 || msp == 0xFFFFFFFF) { /* 入口无效,不要跳 */ return; } __disable_irq(); /* 切换到App向量表指定的栈指针 */ __set_MSP(msp); /* 跳到App的Reset_Handler */ ((void (*)(void))pc)(); }简化写法里只切了MSP和PC,但实际产品还要关外设、清中断、恢复默认时钟。App的启动文件会重新初始化时钟和外设,但如果Bootloader没把外设时钟关干净,App初始化时可能遇到未知状态导致启动异常。
4. 现场踩过的三个坑:地址、Flash擦写和看门狗
4.1 扇区擦多了,Bootloader直接没了
第一次做HC32F460 IAP时,我把App放在0x8000,升级擦写逻辑是“从起始扇区开始连续擦N个扇区”。问题出在固件长度不是扇区整数倍,我计算N时又多加了1个扇区,结果把App区末尾的参数区给擦了。更危险的情况是有人把App起始地址设在类似0x7000这种非整扇区位置,擦除时会连带擦掉Bootloader尾部,整个设备直接失去引导能力。
解决办法是擦除前必须做范围检查,伪代码如下:
bool check_sector_range(uint32_t sector_start, uint32_t len) { if (sector_start < APP_FLASH_START) return false; if (sector_start + len > APP_FLASH_END) return false; return true; }这类检查看起来很简单,但很容易在代码审查时被忽略,最好单独抽成一个函数,并且把App区边界写成宏。任何调用擦除、写入的入口先过这一关。
4.2 擦写期间关闭全局中断,UART数据照样丢
Flash擦写需要几十毫秒,期间如果CPU还在从Flash取指,很可能被Flash控制器拖住,所以很多工程师会选择在这段时间关闭全局中断。但串口中断关掉了,数据就没人收,包一大就丢。
我的做法是不要把整包固件一口气收完再开始擦写。把固件数据拆成多个扇区块,每收到一块,写完一个扇区后立即回一个ACK;主机收到ACK再发下一块。这样中断关闭时间只覆盖单个扇区的擦写窗口,串口缓冲区只要扛住这个窗口的少量数据就够了。
如果你在App里实现了软升级功能,而不是完全依赖Bootloader接收,Flash编程函数最好放到RAM里执行。否则你正在擦除的扇区恰好包含当前执行的代码,CPU可能直接从全0xFF的Flash地址取指,进HardFault。HC32F460的RAM空间足够放这段代码,别省这个事。
4.3 看门狗在升级时添乱
升级到一半,看门狗超时复位,Flash正写着,复位之后App区处于半更新状态,Bootloader一看有效标志没了,老老实实停在升级模式,但主机已经断连,设备就卡死了。这个场景我真实遇到过。
建议做法是:如果芯片看门狗允许在升级期间关闭,就关闭它,升级完成后由App重新配置;如果不允许关闭,就把Flash擦写流程切成小片,每片之间喂狗。同时,擦写Flash前把参数区里的升级标志置为“升级中”。不要先擦App区再写标志,那样掉电时Bootloader无法判断当前是初始状态还是半更新状态。
掉电保护的关键是升级标志的写入时机。我的习惯是:升级前先写“升级中”标志,固件全部写完且校验通过后,再写“App有效”标志。Bootloader上电时如果读到“升级中”又不是“App有效”,就稳在Bootloader等待重新升级,不会误跳坏App。
5. 一次升级卡在95%的排查过程:从现象到根因
5.1 现象:最后一帧就是写不进去
某个项目里,Bootloader从串口接收固件升级App,前面进度都很正常,到95%附近,也就是升级包最后一个数据帧写完时,Bootloader不再回ACK,主机端显示超时。设备重启后,Bootloader检测到App无效,又进入升级模式。反复重试,每次都卡在同一个位置。
5.2 排查逻辑:先看写地址,再看Flash保护
打开调试日志,把每次写入的地址和Flash控制器的状态打印出来。日志显示,前面所有写入都落在0x8000到0x77FFF之间,但最后一帧的写入地址却变成了0x78000。
0x78000是参数区的起始地址,不是App区。为什么Bootloader会往那里写?查打包脚本,发现生成升级包时把头部结构体长度也算进了固件总长度。总长度比实际App二进制多了64字节,Bootloader从0x8000开始按总长度逐包写入,前面的包都在App区,只有最后64字节越界到参数区。
参数区特意做了写保护,所以Flash控制器返回忙状态,Bootloader在等待擦写完成的循环里一直等不到结束标志,表现为“卡死”。实际上代码还在跑,只是没有超时退出机制。
5.3 修法和预防
第一,修正打包脚本,头部结构体只用来描述固件,不参与固件数据长度计算。第二,Bootloader里增加写地址边界检查,发现目标地址超出App区直接返回失败,不进入等待循环。第三,所有Flash写操作都增加超时退出,哪怕是等待状态位,也不能无限等。
这次排查让我意识到,IAP升级的很多故障并不是Flash驱动写错了,而是上位机和下位机对“固件长度”的理解不一致。边界检查是最后一道防线,比依赖打包脚本正确更可靠。
6. 量产环境下的升级可靠性与恢复兜底
6.1 固件包头部设计
量产升级不能只发一个裸二进制文件,至少要在前面加一个固定格式的头部。结构体可以这样定义:
typedef struct { uint32_t magic; /* 固定魔数,用于识别 */ uint32_t device_id; /* 目标设备类型 */ uint32_t version; /* 固件版本,单调递增 */ uint32_t total_len; /* 固件总长度 */ uint32_t crc32; /* 整包CRC */ } upgrade_header_t;版本号建议用32位整数,不要用字符串比较。字符串版本在防降级判断时会比较麻烦,整数版本号直接比较大小就行。
6.2 断点续传和掉电恢复
断点续传的原理很简单:在参数区记录当前已成功写入的块号,掉电重启后Bootloader读这块记录,让主机从该块号继续传输。要注意的是,块号记录必须在一个完整数据块写成功之后再更新,不能提前。
频繁擦写参数区会磨损Flash。我在实际项目里通常把参数区做成日志式写入:每次更新写在一个新位置,写满一块后再轮转,避免每次都擦同一段扇区。这样参数区的寿命会大幅提升,不会因为升级几十次就坏一个扇区。
6.3 给Bootloader留一个永不OTA的底线
Bootloader一旦写坏,现场只能拆机用调试器救。我的原则是:Bootloader可以做版本修正,但绝不让App直接升级Bootloader。产品出厂前把Bootloader烧好,后期只通过它升级App。这能避免“升级Bootloader失败导致整机变砖”的最坏情况。
同时,无论IAP做得多顺,PCB上一定要留SWD调试接口。现场真变砖了,这是最后的生命线。硬件设计阶段省这几个引脚,后期维护会付出十倍代价。
最后分享一个我自己的习惯:每次改完IAP,我会专门做一轮断电测试矩阵——分别在0%、25%、50%、95%、100%写入这几个点断电,再上电看能不能正常恢复。这个方法土,但非常有效。HC32F460的IAP升级说到底不是代码多难,难的是把所有意外状态都想到。希望这篇笔记能帮你少踩几个坑。