GD32F103 IAP固件升级:Bootloader跳转与断点续传
简介:面向GD32F103C8T6等ARM Cortex-M单片机的IAP在线升级源代码包,适合已具备一定嵌入式基础、需要为量产设备加入Bootloader与应用程序跳转能力的开发者参考。资源围绕64KB Flash的地址划分展开,明确30KB Bootloader(0x8000000—0x80077FF)与34KB用户程序(0x8007800—0x800FFFF)的典型分区,并给出1024Byte扇区下的空间规划思路,附可烧录的bin、hex产物,便于对照理解升级路径及与内核相关的实现要点,对学习IAP升级机制、Flash读写与地址映射具有参考价值。压缩包共164个文件,以75个头文件、61个C源文件为主,辅以启动汇编、Keil工程、链接脚本、批处理文件和少量配置调试文件,覆盖外设驱动、工程组织与烧录产物等层面,整体约624KB,工程结构贴近实际项目,便于按模块查阅、复现与裁剪。已有5183人学习下载,可用于GD32与STM32同内核IAP移植、Flash扇区划分、Bootloader与用户程序分离及跳转排错等实践参考,也适合从STM32平台迁移至GD32时对照验证升级流程。
1. 从一次整批返修说起:GD32F103 IAP 要解决的三个真实问题
某开发者做的第一款带壳产品用的是 GD32F103,出厂三个月后客户要改一条按键长按逻辑,产线只能把机器一台台拆开、夹上烧录夹、重新下载。那次返修之后,他把 IAP 升级补进了固件:Flash 里常驻一段 Bootloader,App 通过串口把新固件收进来写进 Flash,写完自己跳过去。这类需求在 arm 嵌入式硬件和单片机项目里反复出现,IAP 本身不难,难的是升级中断电、校验失败、跳转跑飞之后,设备还能不能救回来。下面这套方案围绕 GD32F103 展开,覆盖分区、跳转、协议、校验和验收,适合正在做相关毕设或课设、需要一套能跑通又能讲清楚的人。
2. 总体方案设计:Bootloader 与 App 的分工边界
动手写代码之前,最该先定下来的不是协议,而是 Flash 怎么切。分区一旦定死,后面 Bootloader 的擦除范围、App 的链接地址、上位机的固件长度校验全都要跟着改,改起来是连锁反应。我一般会先在纸上把存储布局画出来,确认每个区域的起始地址都是页对齐的,再开始写第一行代码。
2.1 三段式 Flash 布局与容量分配
GD32F103 的内部 Flash 从 0x08000000 开始线性映射,CPU 可以直接按地址取指,这也是内部 Flash 做 IAP 最省事的地方——不需要额外的读写驱动,擦写接口就在固件库里。以 64KB Flash 的中容量器件为例,我通常切成三段:Bootloader 占前 12KB,第 13 页单独留给升级标志,剩下的全部给 App。
| 区域 | 起始地址 | 大小 | 职责 |
|---|---|---|---|
| Bootloader | 0x08000000 | 12KB(12 页) | 串口收发、Flash 擦写、CRC 校验、跳转 |
| 升级标志页 | 0x08003000 | 1KB(1 页) | 升级请求标志、固件长度、CRC32、版本号 |
| App 区 | 0x08003400 | 51KB | 用户应用、业务逻辑、通信协议 |
把标志单独放一页而不是塞进 Bootloader 内部,理由是擦除粒度。标志需要频繁改写,如果和 Bootloader 代码共处一页,每次写标志都要先擦掉整页,等于把自己的代码擦没了。独立一页之后,写标志就是「擦一页、写两个半字」,风险被隔离在很小的范围里。地址之所以选 0x08003400,是因为 0x3400 正好是 1024 的整数倍,页对齐,不会出现「擦除范围跨到 App 第一页」这种低级但致命的错误。
提示:页大小必须查具体型号的手册,中容量器件常见 1KB 一页,大容量器件常见 2KB 一页,代码里一定用宏而不是硬编码,否则换型号时整个擦除逻辑都要重算。
App 区的上限也不是随便写的。51KB 是 64KB 减去 13KB 的结果,实际项目中我会再留 1 到 2KB 的余量,防止后期加功能时固件悄悄顶到边界。上位机在发送前读一次固件文件大小,超过 App 区容量就直接拒绝,比写到一半发现越界要从容得多。
2.2 升级触发方式与状态机
分区定完,第二个要定的是「什么时候进 Bootloader」。项目里常见三种触发方式,各自的适用场景差别很大。
第一种是上电窗口。设备复位后 Bootloader 先跑,串口等 500ms 到 2s,收到握手帧就进升级流程,收不到就跳 App。这种方式最可靠,因为不依赖 App 是否还能正常运行,缺点是每次上电都要多等这一小段。第二种是通信命令,App 运行中收到上位机的升级指令,往标志页写一个魔数,然后软复位,Bootloader 读到魔数就停下来等升级。这种方式用户体验最好,但前提是 App 还活着。第三种是硬件条件,比如某个引脚被拉低、某个按键长按,产线工装常用。
实际项目里我一般把第一种和第二种都做上:上电窗口保底,通信命令做日常。标志页里存的就不只是一个布尔值,而是一个结构:
/* 升级标志页的内容,写入前先擦除整页 */ typedef struct { uint32_t magic; /* 0x49415055,用来判断这一页是否被写过 */ uint32_t fw_len; /* 待升级固件的字节数 */ uint32_t fw_crc32; /* 整包 CRC32,跳转前再核一次 */ uint32_t fw_version; /* 版本号,方便回滚时比对 */ uint32_t state; /* 0 空闲 / 1 升级中 / 2 升级完成待跳转 */ } iap_flag_t;magic的作用是区分「这一页是空的」和「这一页存着有效标志」。Flash 擦除后是全 0xFF,如果只判断state字段,0xFF 会被误读成一个合法状态。加上魔数之后,读出来不等于 0x49415055 就一律当作无标志,这种防御在掉电场景下能救命。
状态机我写成显式枚举,而不是靠一堆 if 判断当前的进度:
typedef enum { IAP_ST_IDLE = 0, /* 上电初始,等待窗口期 */ IAP_ST_HANDSHAKE, /* 收到握手帧,协商单包长度 */ IAP_ST_ERASE, /* App 区擦除中 */ IAP_ST_TRANSFER, /* 数据帧接收中,seq 递增 */ IAP_ST_VERIFY, /* 整包 CRC 校验 */ IAP_ST_DONE, /* 写标志、跳转 */ IAP_ST_ERROR /* 任意环节失败,回到等待重连 */ } iap_state_t;状态机的价值在于「任何一步出错都有明确的落脚点」。比如 ERASE 阶段收到数据帧,直接丢弃并回一个错误码,而不是硬着头皮往下写;TRANSFER 阶段超时,退到 ERROR 而不是卡死。串口升级最怕的不是出错,是出错了以后再也进不去升级模式,只能拆机。
2.3 技术选型:内部 Flash、外挂 SPI Flash 还是 SD 卡
固件存哪里,是方案阶段最容易吵起来的地方。三种做法我都用过,结论是:小容量 Cortex-M3 器件做 IAP,优先内部 Flash。
内部 Flash 的优势是零成本、零引脚、零额外驱动。擦写接口在固件库里就有,Bootloader 体积能控制在 12KB 以内,剩下的空间全给应用。代价有两个:一是擦写寿命有限,通常按一万次量级估算,做产品固件升级完全够用,但绝不能拿它当数据日志区天天写;二是擦除时 CPU 从同一块 Flash 取指会被总线暂停,虽然硬件会自动插入等待,但擦除一页动辄几十毫秒,如果开了看门狗且喂狗周期设得比较紧,就会在擦除过程中被复位。
外挂 SPI Flash 的好处是能存两份固件做回滚,容量也宽裕,代价是多四根线、Bootloader 里要多一套 SPI 驱动和块管理逻辑。SD 卡容量最大,但文件系统和卡本身的一致性在工业现场是玄学,插拔、掉电、卡质量都能让升级流程翻车,我一般只在带屏的消费类设备上考虑。
| 方案 | 成本 | 占用引脚 | 是否支持回滚 | 主要风险 |
|---|---|---|---|---|
| 内部 Flash | 无 | 0 | 需双区划分 | 擦写寿命、擦除期间喂狗 |
| 外挂 SPI Flash | 器件成本 | 4 | 天然支持 | 多一套驱动与块管理 |
| SD 卡 | 卡座成本 | 4 到 6 | 支持 | 卡一致性、文件系统损坏 |
选内部 Flash 意味着 App 区只有一份,升级失败就没有旧固件可退。这个短板要靠流程补:擦除之前先把固件完整收完并校验通过,再动 App 区;或者把 App 区切成 A/B 两半轮流写,代价是每个区只有一半空间。毕设和课设场景下,单区加严格预检已经够用,答辩时能把「为什么不双区」讲清楚,反而是加分项。
3. 跳转与向量表:IAP 里最容易写错的两段代码
跳转这件事,代码只有十几行,但十个人写能有六个人第一次跑不通。原因不在跳转本身,而在跳过去之前有没有把「现场」清理干净。Cortex-M3 的复位行为是硬件从向量表取前两个字分别装进 MSP 和 PC,Bootloader 手工跳转等于要手工模拟这一过程,少做一步就会以 HardFault 收场。
3.1 Bootloader 跳到 App 的完整流程
先看代码,再解释每一步为什么不能省:
#define APP_BASE_ADDR 0x08003400u #define APP_MAX_SIZE (51u * 1024u) #define SRAM_BASE 0x20000000u #define SRAM_MASK 0x2FFE0000u typedef void (*app_entry_t)(void); /* 返回值:0 表示跳转成功(正常不返回),非 0 为失败原因 */ uint8_t iap_jump_to_app(uint32_t app_addr) { uint32_t app_sp = *(volatile uint32_t *)(app_addr); /* 向量表[0]:初始栈顶 */ uint32_t app_pc = *(volatile uint32_t *)(app_addr + 4u); /* 向量表[1]:复位入口 */ /* 栈顶必须落在 SRAM 区间,否则说明这段区域没有有效固件 */ if ((app_sp & SRAM_MASK) != SRAM_BASE) { return 1; } /* 复位入口必须落在 App 区内,防止误跳到 Bootloader 自己 */ if (app_pc < app_addr || app_pc >= (app_addr + APP_MAX_SIZE)) { return 2; } __disable_irq(); /* 关掉 SysTick,否则它的中断会打到还没初始化的向量表上 */ SysTick->CTRL = 0; SysTick->LOAD = 0; SysTick->VAL = 0; /* 清所有外部中断的使能位与挂起位 */ for (uint8_t i = 0; i < 8u; i++) { NVIC->ICER[i] = 0xFFFFFFFFu; NVIC->ICPR[i] = 0xFFFFFFFFu; } /* 把 Bootloader 打开过的外设恢复默认,避免 App 拿到半初始化的硬件 */ iap_periph_deinit(); __set_MSP(app_sp); /* 切换主栈指针 */ ((app_entry_t)app_pc)(); /* 跳到复位入口,正常情况不会返回 */ return 3; /* 能走到这里说明 App 入口立即返回了 */ }栈顶检查用的是常见的0x2FFE0000掩码,判断高 16 位是不是0x2000。这个写法对 SRAM 起始于 0x20000000 的器件都成立,但如果你的型号 SRAM 起始地址不同,掩码就要跟着改,更稳妥的做法是拿 SRAM 起止地址做区间比较。复位入口检查是很多人忽略的一步:如果 App 区是空的,向量表全是 0xFFFFFFFF,PC 会变成一个非法地址,跳过去立刻 HardFault,而这种复位在现象上和「上电不启动」几乎一样,排查起来非常痛苦。
iap_periph_deinit()是这套代码里最容易被省略、也最容易在后期咬人的一步。Bootloader 里通常开了串口、定时器、可能还有 DMA,如果跳转前不关,App 启动时这些外设还带着旧的配置和挂起标志,表现是「串口第一帧数据丢了」「定时器进了一次中断就再也不进」。这类问题不会立刻暴露,往往在连续跑几天之后才发作。
3.2 App 侧向量表重映射与启动顺序
Bootloader 跳过来的时候,CPU 的 VTOR 还指向 0x08000000,也就是 Bootloader 的向量表。这时候 App 里任何一个中断触发,都会跳到 Bootloader 的处理函数上去,结果就是复位或者跑飞。所以 App 必须在使能任何中断之前,先把向量表偏移改掉:
/* 放在 App 的 main() 最前面,SystemInit 之后、任何外设初始化之前 */ void app_relocate_vector(void) { /* Cortex-M3 的 VTOR 要求按 0x200 对齐,0x08003400 满足 */ SCB->VTOR = APP_BASE_ADDR; __DSB(); /* 保证写操作完成 */ __ISB(); /* 清流水线,确保后面的取指用新向量表 */ }顺序错一步就白干。很多人把这段写在main()中间、外设初始化之后,表面上也能跑,因为那段时间里没有中断触发;一旦打开串口接收中断,第一帧数据到来时 VTOR 可能还没生效,直接进 HardFault。我一般要求 App 的main()第一行就是这句,甚至在启动文件的SystemInit末尾加,宁可早不可晚。
用固件库的写法是nvic_vector_table_set(NVIC_VECTTAB_FLASH, 0x3400),第二个参数是相对 Flash 起始的偏移量,不是绝对地址,这个细节坑过不少人——写 0x08003400 进去,实际偏移就成了一个天文数字。
3.3 工程配置:IROM 起始地址、链接脚本与合并烧录
代码改完,工程的链接配置必须同步改,否则编译器会把 App 链接到 0x08000000,烧进去直接覆盖 Bootloader。
Keil 下的做法是打开 Target 选项,把 IROM1 的 Start 从 0x08000000 改成 0x08003400,Size 改成 0xCC00(51KB)。GCC 工具链改的是链接脚本:
/* 链接脚本片段:FLASH 起始地址与 Bootloader 错开 */ MEMORY { FLASH (rx) : ORIGIN = 0x08003400, LENGTH = 51K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 20K } SECTIONS { /* 向量表必须放在最前面,否则 VTOR 指向的位置读不到正确内容 */ .isr_vector : { KEEP(*(.isr_vector)) } > FLASH .text : { *(.text*) *(.rodata*) } > FLASH }RAM 的长度也要按实际型号改,GD32F103 不同容量器件的 SRAM 从 20KB 到 48KB 不等,链接脚本里写大了,运行到一半踩到不存在的内存,现象是随机的数据错乱,用调试器都不好抓。
量产时还有一步:Bootloader 的 hex 和 App 的 hex 要合并成一个文件烧录,否则产线要烧两次。合并工具按地址区间拼就行,注意 App 的 hex 起始地址必须严格等于分区表的 App 基址,中间不能有空洞。合并完成后我习惯用调试器读一次 0x08000000 到 0x08003400 的内容,确认 Bootloader 没被覆盖,这个动作三十秒,能省掉整批返工。
4. 固件传输与校验:帧协议、Flash 写入与断点续传
协议这一层,最省事的做法是直接上 Ymodem,串口工具自带,上位机不用自己写。但 Ymodem 的帧头是固定格式,想加版本号、断点续传字段就得自己扩,扩完还不如自定义一套简单帧。我的选择是自定义,帧结构控制在一屏代码能看完的复杂度。
4.1 帧结构与命令字设计
帧格式定成:两字节帧头、命令字、序号、长度、载荷、CRC16。固定开销 7 字节,单包载荷 512 字节或 1024 字节,串口波特率 115200 下一包大约几十毫秒,重传成本可以接受。
| 命令 | 值 | 方向 | 载荷 | 说明 |
|---|---|---|---|---|
| CMD_HANDSHAKE | 0x01 | 双向 | 4 字节协议版本 | 协商版本与单包最大长度 |
| CMD_ERASE | 0x02 | 上位机发起 | 4 字节固件长度 | Bootloader 据此算擦除页数 |
| CMD_DATA | 0x03 | 上位机发起 | 固件数据 | seq 递增,用于判重与续传 |
| CMD_VERIFY | 0x04 | 双向 | 4 字节 CRC32 | 整包校验,失败要求重传 |
| CMD_END | 0x05 | 上位机发起 | 无 | 写完成标志并跳转 |
| CMD_NAK | 0x7F | Bootloader 发起 | 1 字节错误码 | 通知上位机重发或终止 |
CRC16 用查表或者 MODBUS 多项式都可以,关键是上位机和固件必须用同一套实现。上位机侧的打包逻辑大概是这样:
import struct def crc16(data: bytes) -> int: """CRC-16/MODBUS,多项式 0xA001,和固件里的实现必须完全一致""" crc = 0xFFFF for b in data: crc ^= b for _ in range(8): crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1 return crc def build_frame(cmd: int, seq: int, payload: bytes = b"") -> bytes: """按 5A A5 | cmd | seq | len(2B, 大端) | payload | crc16(小端) 组装""" body = struct.pack(">BBH", cmd, seq, len(payload)) + payload return b"\x5A\xA5" + body + struct.pack("<H", crc16(body))长度字段用大端、CRC 用小端,这种不一致看着别扭,但串口调试助手一般是按大端显示多字节数据,而 CRC 校验值习惯低字节在前,两边统一约定好写进文档就行。真正要避免的是同一份代码里两种端序混用,查起来能耗掉一整天。
序号seq是断点续传的基础。Bootloader 收到一个数据帧,先比对 seq 是不是当前期望值:等于就写入并回 ACK,小于就说明是重发的旧包,直接回 ACK 不重复写,大于就回 NAK 让上位机退回去重发。这个三段判断能把「ACK 丢了导致上位机重发」的情况处理干净,不然同一段数据被写两次,Flash 里就多了一份垃圾。
4.2 Flash 擦除与写入:页对齐、半字编程与回读比对
GD32F103 的 Flash 编程最小单位是半字,也就是 16 位。写入前必须先擦除,擦除的最小单位是页。这两个约束决定了所有写入函数都必须做对齐检查。
#define FLASH_PAGE_SIZE 1024u /* 按型号手册改,大容量器件可能是 2KB */ /* 按页擦除,addr 会自动向下对齐到页首,len 向上取整 */ uint8_t iap_erase_region(uint32_t addr, uint32_t len) { uint32_t page = addr & ~(FLASH_PAGE_SIZE - 1u); uint32_t end = addr + len; fmc_unlock(); while (page < end) { if (fmc_page_erase(page) != FMC_READY) { /* 内部会等 BUSY 标志 */ fmc_lock(); return 1; } page += FLASH_PAGE_SIZE; } fmc_lock(); return 0; } /* 半字写入,addr 必须 2 字节对齐,len 必须为偶数 */ uint8_t iap_write_halfwords(uint32_t addr, const uint8_t *data, uint32_t len) { if ((addr & 0x1u) || (len & 0x1u)) { return 1; /* 对齐检查不能省 */ } fmc_unlock(); for (uint32_t i = 0; i < len; i += 2u) { uint16_t hw = (uint16_t)data[i] | ((uint16_t)data[i + 1u] << 8); if (fmc_halfword_program(addr + i, hw) != FMC_READY) { fmc_lock(); return 2; } /* 回读比对:电压波动和擦写寿命会让写失败变得很隐蔽 */ if (*(volatile uint16_t *)(addr + i) != hw) { fmc_lock(); return 3; } } fmc_lock(); return 0; }fmc_page_erase内部会等 BUSY 标志清零,但有些型号的库函数在超时后直接返回,所以外面还要判返回值。回读比对这步很多人觉得多余,实际上它是唯一能发现「写入看似成功、实际位没翻转」的手段。工业现场电源不稳的时候,这种半成功状态真的会出现,而它导致的后果是 App 校验通过、运行到某个函数才跑飞,排查成本极高。
还有一个隐含前提:擦除和写入期间不能从同一块 Flash 取指。Cortex-M3 的硬件会在总线冲突时插入等待,功能上没问题,但如果看门狗喂狗周期设得比一次页擦除时间还短,就会在擦除循环里被复位。通常的做法是在擦除前先喂一次狗,并且把看门狗周期放到 2 秒以上。
4.3 断点续传、双区备份与回滚策略
断点续传的核心不是协议,是「进度存在哪」。存在 RAM 里没用,掉电就没了;存在标志页里又太频繁,每包擦一次 Flash,寿命直接见底。我的做法是分两级:正常传输过程中,进度只在 RAM 里维护,靠 seq 判重;只有在检测到链路中断、准备退出升级模式之前,才把「已写入的固件长度」和「最后一次成功包的 seq」写进标志页。这样一次升级最多写两次标志页,寿命完全可控。
上位机的配合逻辑是:连接后先读一次标志页,如果发现有未完成的升级记录,从记录的偏移继续发,而不是从头来。这个设计在实测中把一次 40KB 固件的中断恢复时间从十几秒压到了一秒以内。
双区备份是回滚的基础。把 App 区平分成 A、B 两块,Bootloader 里维护一个「当前有效区」标志:新固件永远写非活动区,校验通过后把标志切过去,再跳转。如果新固件启动后连续几次没喂狗、被看门狗复位,Bootloader 就判定新固件异常,把标志切回旧区。代价是每个区只有一半空间,64KB 的器件上每个区只剩 25KB 左右,功能稍多就装不下。所以双区适合 128KB 以上的型号,64KB 的器件还是老老实实做单区加预检。
注意:回滚判据不能只看「启动失败一次」,要连续 N 次加一个启动计数器,且计数器本身要存在不会因软件复位被清掉的地方,否则新固件正常启动一次就把计数清了,回滚机制形同虚设。
5. 避坑与排查:五类最容易翻车的问题
这套代码我在不同项目上重写过三四遍,每次出问题的位置都差不多。下面五条是按踩坑频率排的,每条都写成现象、原因、解决三段,方便直接对照现场。
5.1 现象:跳过去直接 HardFault,连第一个断点都进不去
原因通常有三个。一是 VTOR 没设或设晚了,App 里第一个中断触发时还在用 Bootloader 的向量表;二是链接地址改了但代码里的APP_BASE_ADDR没改,两边对不上;三是向量表读出来的栈顶不合法,跳过去瞬间就崩。
解决顺序是先用调试器读 0x08003400 的前两个字,确认第一个字落在 0x2000xxxx 区间、第二个字落在 App 区内。这两个值不对,说明固件根本没烧到正确地址,问题在链接脚本或合并工具,不在跳转代码。两个值都对还崩,就单步跟iap_jump_to_app,看是不是在__set_MSP之后、跳转之前被中断打断——这种情况一定是关中断没做干净。
5.2 现象:擦完 Flash 设备彻底不启动,串口也连不上
最典型的场景是「先擦除、后收固件」,中途掉电或断线,App 区一片空白,标志页也没写,Bootloader 上电等窗口期结束发现没固件可跳,就卡在等待里,而如果窗口期实现得不好,连等待都没有,直接跳一个空地址。
解决的根本办法是把顺序倒过来:先把整个固件收进 RAM 或者先算好 CRC 收到临时缓冲,全部校验通过再擦 App 区。51KB 的固件塞不进 20KB 的 SRAM,那就退一步,用分块接收加流式 CRC,最后收完 CMD_VERIFY 再统一擦写,传输阶段只做暂存和校验,不动 App 区。这样最坏情况是升级失败但旧固件完好。
再补一道保险:Bootloader 的等待窗口做成「只要收到任何合法帧就重置超时」,而不是固定等 2 秒就跳。这样即使 App 区是空的,只要上位机连着,永远能重新发起升级。
5.3 现象:写入偶发失败,或者写进去的数据读出来是乱的
三个原因按概率排:地址没做半字对齐、擦除范围没覆盖到写入地址、写入和擦除之间没有等 BUSY。第一种最常见,上位机按字节发,固件按半字拼,如果包长是奇数,最后一包就会多写或少写一个字节。第二种发生在长度不是页大小整数倍的时候,最后一页没擦,写进去就是旧的位与新值做与运算,读出来永远是错的。
解决是把所有检查前置。iap_write_halfwords里那两个对齐判断必须保留,返回错误码而不是默默截断;擦除函数里end的计算要从addr + len向上取整到页,不能只擦len / PAGE_SIZE个整页。再加上 4.2 节里的回读比对,任何一次写失败都能立刻定位到具体地址,不用等到 App 跑起来才发现。
5.4 现象:升级到一半断线,重连后怎么发都没有响应
这种情况八成是状态机卡在 TRANSFER 里出不来。串口接收中断里收到半帧数据,或者超时机制没做,状态一直挂在「等下一包」,上位机重连发的握手帧被当成非法数据丢弃。
解决是把超时做进状态机。每个状态都挂一个超时计数,在定时器中断里递减,减到 0 就无条件退回 IDLE 并清空接收缓冲。IDLE 状态下只认握手帧,其他一律丢弃,这样重连时一定是干净的起点。另外接收缓冲要按帧头重新同步,收到 0x5A 之后再等 0xA5,对不上就把 0x5A 当普通字节跳过,否则一次错位会导致后面所有帧都错位。
5.5 现象:升级成功,跑几分钟或者进一次低功耗就死机
跳转前外设没复位、中断没清干净的典型症状。Bootloader 里开过串口和定时器,跳转后 App 又初始化一遍,看起来没问题,但 NVIC 里的挂起位还在,App 一开总中断就立刻进了一次「幽灵中断」,处理函数里访问的还是 App 没初始化的对象。
解决是跳转前那段清理必须做全:关 SysTick、清 NVIC 的 ICER 和 ICPR、把用过的外设逐个 DeInit、必要时对 RCC 里对应的外设时钟做一次复位。清理函数不要图省事只写一半,每个 Bootloader 里开过的外设都要对应一行。做完这些还不能放心的,可以在跳转前把 SRAM 的前几 KB 手动清一遍,代价很小,能消掉一类「上次运行残留」的玄学问题。
6. 验收与答辩:怎么证明这套 IAP 真的能用
功能跑通只是及格线,能拿出测试记录才算交付。我一般会按下面这张表逐条过,每条都留串口日志或者示波器截图,答辩的时候直接放出来,比嘴上说「测试过」有说服力得多。
| 用例 | 操作 | 预期结果 |
|---|---|---|
| 正常升级 | 完整下发 40KB 固件 | 校验通过,复位后版本号更新 |
| 断点续传 | 发到 60% 时拔线,10 秒后重连 | 从断点包继续,不整体重来 |
| 掉电保护 | 擦除阶段直接断电 | 上电停在 Bootloader,可重新发起升级 |
| 校验失败 | 故意翻转固件中 1 个字节 | 返回 CRC 错误,旧固件仍可启动 |
| 反复升级 | 连续升级 20 次 | 每次都成功,标志页无残留 |
| 空片启动 | 擦空 App 区后上电 | 不跑飞,串口有等待提示 |
测掉电保护这条要真断电,不能靠软复位模拟,因为软复位不会让 Flash 处于擦除中途的半写状态,测不出真实问题。做法是拿一个可调电源,在擦除开始后随机时间拉掉供电,重复二三十次,看每次上电是不是都能进 Bootloader。这个测试最枯燥,但它是唯一能证明「设备不会变砖」的手段。
答辩环节,我建议按「分区、跳转、协议、异常」四段讲,每段两分钟。分区讲清楚为什么留 12KB、为什么标志单独一页;跳转讲栈顶和 PC 的取值、VTOR 的时机;协议讲帧结构和 seq 的判重逻辑;异常讲掉电和校验失败怎么兜底。评委最常追问的三个问题是:App 区装不下怎么办、升级过程中掉电怎么办、新旧固件不兼容怎么办。前两个在方案里都有答案,第三个要提前想好——通常是靠标志页里的版本号,Bootloader 在跳转前做一次兼容性判断,不兼容就停在升级模式要求重新下发。
从那以后我每次做带升级功能的板子,都会在第一次烧录前强制走一遍「擦空 App 区再上电」的流程,确认 Bootloader 自己站得住,再往下写业务代码。这个习惯救过我好几次,因为最容易翻车的地方从来不是升级成功那次,而是升级失败之后设备还能不能救回来。希望帮到你。
本文还有配套的精品资源,点击获取