简介:面向华大单片机开发者的HC32L136远程在线IAP升级DEMO,以BOOT引导程序、APP应用程序和上位机软件三部分为主线,完整呈现远程固件在线升级的工程化实现;HC32F030、HC32L130、HC32L(F)17X、HC32L(F)19X等系列开发者同样可参考这套框架进行移植。压缩包共282个文件,整体大小3.29MB,核心代码由57个C头文件与39个C源文件组成,另有工程配置脚本、J-Link烧录文件、批处理工具和bin/hex固件,覆盖从编译、烧录到上位机联调的完整链路。整个资源包结构清晰,便于按模块自主学习。已有3525人浏览学习。对需要为华大MCU搭建IAP升级通道的工程师而言,这份资料能够帮其避开大量底层移植坑点,快速理解Boot与App的分区跳转、升级协议交互等关键设计,目录结构及工程模板可直接复用,尤其适合在项目早期评估远程升级方案的可行性与开发成本。
1. 远程升级变砖:为什么 HC32 单片机的在线 IAP 不能只靠「擦掉重写」
远程给 HC32L136 这类华大单片机做在线升级,最怕的不是协议写错,而是固件传到一半断电:Flash 里 App 区的旧程序被擦成半成品,新的又没写完,设备直接变砖。IAP(In-Application Programming)这套 bootloader 机制之所以值得深挖,正是因为它要回答三个底层问题:Flash 怎么分区才安全、上电后凭什么决定加载哪份固件、升级中途出故障凭什么自己把自己捞回来。搞懂这三点,你就不是只会调用烧录接口,而是能独立设计一套扛得住掉电、误码、跑飞的远程 IAP 方案。
2. Flash 分区与启动流程:Bootloader 凭什么知道该跑哪份固件
2.1 分区规划:为什么 Flash 至少要拆成三块
很多第一次做 IAP 的人会犯同一个错:把 bootloader 和 App 塞在同一段连续 Flash 里,升级时直接往 App 地址写。这在物理上行得通,但只要一次擦写被打断,整个芯片就变成一块不认任何下载器的砖头。所以分区规划是整个 IAP 机制的基石。
常见的分区思路是把 Flash 拆成三块:bootloader 区、App 区、参数区。以 HC32 系列 128KB Flash 的典型配置为例:
| 区域 | 起始地址 | 大小 | 作用 |
|---|---|---|---|
| Bootloader | 0x0000 | 16KB | 启动引导、升级接收、Flash 擦写 |
| App | 0x4000 | 108KB | 用户业务固件 |
| 参数区 | 0x1F000 | 4KB | 升级标志、版本号、CRC 校验结果、启动计数 |
Bootloader 区独立出来的意义不只是「隔离代码」,而是让「擦除」这个危险操作永远只发生在可控上下文里。上电后 CPU 永远先跑 bootloader,由它决定是跳进 App 还是进入升级模式,这样即使 App 区被擦得面目全非,bootloader 还活着,设备就还有救。
参数区单独划出来同样关键。升级状态标志如果放在 App 区,App 每次升级都会先被擦掉,那「前一版固件是否完整」就无从判断了。放在独立的参数区,bootloader 每次启动先读这里,就能准确知道上次升级的结果是成功、失败还是进行到一半。
提示:分区地址和大小必须和具体型号的 Flash 页大小对齐。HC32L136 这类 M0+ 内核芯片,页擦除单位在数据手册里都有明确标注,App 起始地址建议对齐到页边界,否则擦除时会把 bootloader 的尾巴一起抹掉。
2.2 上电启动链:从复位向量到 App 跳转的具体动作
分区的意义要落到一段真实存在的跳转代码上才能体现。Cortex-M0+ 内核复位后从 0x00000000 取栈顶指针、从 0x00000004 取复位向量,这是硬件行为。如果 bootloader 放在 0x0000,它天然就是第一个被执行的程序。bootloader 做完自检后,要跳进 App 区,标准做法是这样:
typedef void (*pFunction)(void); void jump_to_app(uint32_t app_addr) { uint32_t msp_value = *(volatile uint32_t *)app_addr; // App 初始栈顶指针 uint32_t reset_vector = *(volatile uint32_t *)(app_addr + 4); // App 复位向量 __disable_irq(); // 关全局中断,跳转期间不被任何中断打扰 SCB->VTOR = app_addr; // 把中断向量表重定位到 App 区 __set_MSP(msp_value); // 切换到 App 的栈空间 pFunction jump = (pFunction)reset_vector; jump(); // 从 App 的复位向量开始执行 }这段代码里每一步都有讲究。先读app_addr处的第一个 32 位字作为栈顶指针,是因为编译器链接 App 时会把栈顶放在首地址;读第二个字作为复位向量,是因为这对应中断向量表里的 Reset_Handler。跳转前关中断,是为了防止跳转过程中来了一个中断、而向量表还没切过去,直接跑飞到未知地址。
SCB->VTOR这行是跳转成功的灵魂。M0+ 内核默认从 Flash 起始地址取中断向量,App 跑起来之后第一次发生串口中断、定时器中断时,CPU 还是会去 0x0000 处找向量表——那正是 bootloader 的向量表。处理函数指错了位置,行为就是「App 一进中断就死机」。把 VTOR 指向 App 起始地址,整个中断向量表才算真正切到 App 这边。
2.3 中断向量重定向:VTOR 不设置或者没对齐会怎样
VTOR 的坑藏在细节里。M0+ 内核要求 VTOR 的地址对齐到中断向量表的大小,也就是(中断个数 + 16) * 4字节,通常取 64 字节或 128 字节对齐。很多开发者设置了SCB->VTOR = app_addr,但 App 的链接起始地址没有对齐,比如放在了0x4000 + 100这种位置,结果是中断照死。
对齐问题的根源在于 M0+ 的向量表查找逻辑:CPU 取第 N 号中断的处理函数时,实际访问的是VTOR + N * 4。如果 VTOR 本身有偏移,取出来的函数地址就错位了。更隐蔽的是,某些 HC32 型号在 bootloader 里设置过中断优先级分组,跳到 App 后这个配置会残留,导致 App 里中断优先级行为完全不符合预期。
所以规范的跳转函数里,__disable_irq()之后最好顺手把用到的外设全部复位到默认状态,比如把 UART、定时器、DMA 的中断标志清干净,外设时钟关闭。否则 bootloader 里残留的中断请求在 App 启动瞬间被响应,又会引发一次「没头没尾」的死机。这条在调试阶段极为常见,后文避坑章里会展开讲。
3. 升级协议与擦写时序:串口字节流怎么变成 Flash 里的新程序
3.1 帧格式设计:为什么必须有帧头、长度和校验
远程 IAP 的「远程」二字,落地到 HC32 这类没有网络协议栈的 MCU 上,通常就是 UART 外接一个数传模块,服务器推送的升级包经过模块透传变成一串串行字节流。问题是串口是字节流,没有天然边界——bootloader 必须自己从流水里「切」出一帧一帧的数据来。
一个能用于生产的升级帧,至少包含这些字段:
| 字段 | 长度 | 含义 |
|---|---|---|
| 帧头 | 2B | 固定值如 0x5A 0xA5,用于字节流同步 |
| 命令 | 1B | 0x01 开始升级 / 0x02 传数据 / 0x03 校验触发 / 0x04 重启 |
| 总数 | 2B | 本次固件总帧数 |
| 帧号 | 2B | 当前帧序号,从 0 递增 |
| 数据 | 256B | 一帧的有效固件内容 |
| CRC32 | 4B | 整帧校验值,含命令和帧号 |
帧头和长度存在的意义是「重新同步」。串口通信里偶发一个字节的丢失再正常不过,如果收到 0x5A 0xA5 后按长度字段去数数据,即使中间残缺也能知道这一帧废了,继续找下一帧头重新对齐。没有帧边界的协议,一旦错一个字节,后面所有数据全部错位,而且无从恢复。
数据字段定成 256B 不是拍脑袋。这个值要让「一帧数据」和「一轮 Flash 擦写」形成对应关系。如果 HC32 系列某型号的 Flash 页大小是 512B,那两帧正好凑一页;如果是 256B 页,一帧一页。对齐之后,bootloader 可以把「收帧—校验—擦页—写页」做成流水线,每一帧独立完成,而不是把所有数据囤在 RAM 里等收完再写。
提示:CRC32 的 4 个字节建议分散在帧尾,不要放在帧头后面。帧头丢了可以重新同步,CRC 丢了整帧作废重传即可。如果 CRC 放太前面,帧同步逻辑会变复杂,而且误判率升高。
3.2 先擦后写:一次升级的 Flash 操作时序
HC32 的 Flash 有个特性:写只能在擦过的区域上进行,而且擦除最小单位是页/扇区,写入最小单位通常是半字(16 位)。这意味着「升级一个字节」的真实开销是「擦掉整整一页」。所以 bootloader 的写 Flash 流程必须是严格的「先擦后写」:
int handle_rx_frame(uint8_t *buf, uint32_t len, uint32_t frame_no) { uint32_t crc = crc32_calc(buf, len - 4); if (crc != *(uint32_t *)(buf + len - 4)) { return FRAME_CRC_ERROR; // 校验不过,丢弃,等重传 } uint32_t page_off = frame_no * 256; // 假设 256B 对应一页 uint32_t app_buf_addr = APP_BASE_ADDR + page_off; flash_sector_erase(app_buf_addr); // 先擦除目标页 flash_program_halfword(app_buf_addr, (uint16_t *)buf, 128); // 再写入 128 个半字 return FRAME_OK; }这段代码的机制要点是「擦写分离」。先擦后写的顺序不能反:如果在没擦的页上直接写,写入操作要么失败、要么写进去的数据和旧数据做按位与,得到一堆乱码。很多人在调试时遇到「写进去了但读出来不对」,八成就是没先擦页。
擦除本身的耗时是个隐藏变量。数据手册给的页擦除时间通常是 20ms 上下,看起来不长,但升级一个 100KB 的固件,按 512B 一页算需要擦 200 次,光擦除时间就是 4 秒。这 4 秒里如果看门狗没喂,系统直接复位,擦到一半的 Flash 就停在残缺状态。所以 bootloader 的擦写循环里必须搭配喂狗策略,这个话题后面专门展开。
3.3 升级状态机:bootloader 怎么知道「这次升级到底成没成」
有了帧格式和擦写流程,还需要一个状态机把整个升级过程串起来。bootloader 上电后的逻辑不是「有没有数据就升级」,而是一套严格的状态转移:
typedef enum { ST_IDLE, // 空闲:校验 App 有效则直接跳转 ST_FIRST, // 已收到升级请求,等待第一帧 ST_RECEIVING, // 正在接收固件数据帧 ST_VERIFY, // 所有帧收完,做整包 CRC 校验 ST_READY // 校验通过,写完成标志,准备重启 } upgrade_state_t; upgrade_state_t state = ST_IDLE; while (1) { switch (state) { case ST_IDLE: if (check_app_valid() && !upgrade_requested()) { jump_to_app(APP_BASE_ADDR); // 老固件完好且没人要求升级,直接跑 } else { state = ST_FIRST; // 否则留在 bootloader 等升级 } break; case ST_RECEIVING: if (frame_timeout > 5000) { // 5 秒没收到下一帧,判定超时 state = ST_IDLE; // 清标志回空闲,下次上电还能跑老固件 } break; } }状态机的核心思想是「每一步都允许退出」。把超时、CRC 错误、帧号跳变都设计成「回到稳态」而不是「继续死等」,整个升级过程才是可中断的。升级到一半断电重启?没关系,bootloader 上电看到完成标志没写,直接回退到验证老固件的路径。
这里有个容易忽略的参数:帧号跳变。串口丢一帧会导致帧号不连续,如果 bootloader 允许乱序写入,那 Flash 里就会留下空洞,最后的整包校验必然失败。简单的做法是要求帧号严格递增,丢一帧就让上位机从这一帧开始重传;复杂一点的做法是 bitmap 记录已收帧,支持跳跃补传。后者对大固件友好,但 bootloader 的 RAM 占用会变大。HC32L136 的 RAM 通常只有 8KB 上下,做 bitmap 要精打细算。
4. 可靠性兜底:掉电、丢包、跑飞之后,系统凭什么还能活
4.1 双区备份:给升级失败准备的后悔药
到此为止的机制能保证「失败不致命」,但还谈不上「失败不影响业务」。因为单区方案下,升级失败后设备停留在 bootloader 里,等待重新升级,这在工厂产线没问题,在远程部署场景就是故障。A/B 双区方案是更彻底的兜底:Flash 里同时存在两个 App 区,升级时新固件写入备份区,写完后切换标志,而不是覆盖当前运行区。
双区方案的启动逻辑很清楚:bootloader 检查「下次启动区」标志,决定从 A 区还是 B 区启动。升级时把新固件写入当前没在运行的那个区,全部校验通过后才翻转标志。这样任意时刻 Flash 里都有一个「上一次验证过能跑」的固件,掉电最多损失一次升级尝试,不影响当前运行版本。
这种方式在资源受限的 MCU 上的代价是 Flash 容量直接减半。128KB 的 HC32 如果只跑一个 60KB 的固件,双区绰绰有余;但如果固件已经膨胀到 100KB,就得回到单区方案。这是典型的「空间换可靠性」取舍,没有绝对正确答案。
4.2 启动计数回滚:能兜住「能启动但跑飞」的固件
双区方案有一个盲区:固件烧进去了、CRC 也过了,但新固件一运行就死机。如果只是「死机后停在原地」,看门狗会定时复位,复位后 bootloader 又会跳进这个坏固件,形成不死不活的重启循环。启动计数机制就是专门治这个的。
bootloader 每次跳进新固件之前,先把参数区里的「连续启动次数」加 1。新固件正常运行时,会在初始化完成且自检通过后,把「固件有效」标志写进参数区。bootloader 启动时看到这个标志,就把启动计数清零。逻辑如下:
if (get_boot_count() >= MAX_BOOT_FAILURES) { rollback_to_previous(); // 连续 3 次都没活过验证期,回滚 } else if (!app_is_marked_valid()) { inc_boot_count(); // 新固件尚未自检通过,计数累加 jump_to_app(APP_BASE_ADDR); }这套机制能兜住的场景是「固件能启动但跑飞」:比如 App 初始化到一半死循环、堆栈配错、外设配置把头文件烧没了。它兜不住的是「压根起不来」,但那已经被看门狗和双区方案覆盖了。三层机制结合,才敢说远程升级有基本的容错能力。
4.3 看门狗与擦写耗时:为什么越简单的方案越容易翻车
把看门狗放进 IAP 设计里,是很多人的血泪教训。前面算过,擦完一个 100KB 固件需要 4 秒以上的连续擦除,如果擦写循环里没有喂狗指令,独立看门狗在 1 秒内就会把芯片复位。复位后 bootloader 发现升级没完成,又从头开始,然后又在同一个位置被看门狗复位——死循环。
喂狗策略不能是「每擦一页喂一次」,因为页擦除本身耗时 20ms,如果在擦除期间看门狗计数归零,照样复位。实际做法是升级前关闭看门狗或把窗口拉大,在每帧擦写完成后的「擦写间隙」喂狗。还有一种稳妥思路:把看门狗的超时窗口设成比页擦除时间大一个量级,比如 500ms,这样只要主循环没卡死,哪怕擦除慢一点也能兜住。
注意:升级过程中千万别在页擦除的临界点喂狗。擦除动作一旦被看门狗复位打断,Flash 控制器可能停在中间状态,比直接不擦还要难恢复。宁可把超时设大,也不要卡着窗口喂。
5. IAP 调试避坑:从黑匣子里捞出来的 5 条血泪记录
5.1 现象:App 一进中断就死机,复位后又能跑
原因:跳转 App 时没设置SCB->VTOR,或者设置时地址没按向量表大小对齐。M0+ 内核的中断向量查询仍然指向 bootloader 区,App 里的中断处理和 bootloader 的向量表对不上,一进中断就跑飞到非法地址。
解决:跳转函数里强制重设 VTOR,并且用#pragma或链接脚本确保 App 的起始地址对齐到 64 字节。调试阶段可以加一个断言:(app_addr % 64 == 0),不满足直接报错,别让它带病运行。
5.2 现象:升级完成后第一次上电能跑,第二次上电变砖
原因:升级时把参数区的「固件有效标志」放在了 App 区的末尾。App 升级时擦除了整个 App 区,标志随之消失;第一次运行是 bootloader 跳转前设置的临时标志,断电后丢失。第二次上电 bootloader 按「无有效固件」处理,不跳转了。
解决:标志位必须放在独立的参数区,和 App 区物理隔离。每次升级先写「升级中」状态,全部完成后再写「固件有效」。这样无论擦 App 擦到哪一步,参数区都保有能让 bootloader 做决策的有效信息。
5.3 现象:升级到一半看门狗复位,反复在原地重启
原因:擦写循环里没有喂狗,或者喂狗位置不对。页擦除期间喂狗指令不执行,看门狗累计超时。复位后升级重来,再次超时,死循环。
解决:把看门狗超时时间调到大于单页擦除时间的两倍以上,在每帧擦写完成后的循环底部喂狗。更彻底的做法是升级期间暂时关闭看门狗,等跳转前再重新初始化。
5.4 现象:串口工具发固件没反应,bootloader 毫无响应
原因:调试者直接选了 XMODEM 协议发送 .bin 文件,但 bootloader 端实现的是自定义帧协议,两边帧格式对不上。数据进来了,bootloader 等不到帧头,一直卡在同步状态。
解决:先确认协议匹配,再查帧头、CRC 字段的字节序。CRC32 常见的坑是上位机用小端发、单片机按大端解析,或者两边多项式基值差了一个0xFFFFFFFF的初始化量。对调 CRC 初始化值后再试,通常能立刻定位。
5.5 现象:新固件能跑,但串口打印乱码,功能时好时坏
原因:bootloader 跳转前没有把用过的外设恢复到默认状态。bootloader 初始化过 UART 和定时器,跳到 App 后这些外设的中断标志、配置寄存器还残留着,App 初始化时某些寄存器没重写,行为就不可预期。
解决:跳转函数里做「外设清理」:关掉 bootloader 用过的所有外设时钟,清中断挂起位,把 GPIO 恢复默认态。宁可多写几行复位代码,也不要留着上一个程序的「魂魄」在 App 里飘。
6. 单区与双区:IAP 设计里那道没法两全的选择题
走到这一步,你会发现 IAP 方案的所有复杂度最后都收拢到一个选择上:用单区加标志位,还是用双区加切换标志?这两个方案各有各的死角,没法两全。
| 维度 | 单区 + 回滚标志 | 双区 A/B |
|---|---|---|
| Flash 占用 | 低,App 区容量几乎全用 | 高,可用容量减半 |
| 升级失败影响 | 设备停在 bootloader,需重新升级 | 继续跑旧固件,业务无感知 |
| 实现复杂度 | 低,状态机短,RAM 占用小 | 高,需要额外处理启动计数和回滚 |
| 场景适配 | 产线烧录、现场可维护 | 远程部署、无人值守 |
如果让我给一个倾向:凡是「设备装上去之后人不方便再碰」的场景,比如光伏逆变器、农业物联网终端、野外数据采集器,直接上双区,别为了省那几十 KB Flash 给自己埋雷。反之如果设备在产线上,升级失败可以立刻用烧录器救回来,单区加标志位完全够用,代码还少一半。
验证一个 IAP 方案靠不靠谱,最有效的方法是故障注入:人为在随机位置断电、随机丢帧、随机在升级中敲复位,连续做几十次。每次故障后检查设备是否还能正常启动到旧固件或新固件,而不是卡死在 bootloader。这套测试跑通了,你的升级方案才算真正落地。
我早期做某跨平台系统的远程升级时,就是单区方案加一个标志位,测试时只验证了「正常升级路径」,没做掉电注入。结果第一批设备部署出去,第一次远程升级就碰上供电不稳,几十台设备全部停在 bootloader。后来我把状态机重写,参数区独立,加上启动计数回滚,再配合故障注入测试,才把这个问题治住。从那以后我每次设计 IAP,都强制把「升级中途断电」「新固件启动即崩溃」这两条路径走一遍验证,再谈功能完善。希望帮到你。
本文还有配套的精品资源,点击获取