简介:这份 GD32F103 IAP 升级源代码面向需要实现单片机在线升级的嵌入式开发者与学习者,围绕 GD32F103C8T6 的 Flash 分区、BootLoader 与用户程序跳转等关键环节展开,也可作为同内核 STM32 平台 IAP 方案的参考。压缩包共 164 个文件,以 75 个 h 头文件、61 个 c 源文件、8 个 s 启动文件为主,并配有 uvprojx 工程、sct 分散加载、bin/hex 输出及批处理脚本,整体约 624KB,便于直接编译对照。资源中给出 64KB ROM 的典型划分:BootLoader 占 30KB,用户 bin 占 34KB,能帮助读者理解升级路径、空间约束、应用程序偏移设置以及固件生成方式。工程还包含外设驱动与启动代码,读者可据此梳理 IAP 工程结构、验证跳转流程、生成可烧录文件,并积累常见问题的排查思路。已有 5183 人学习下载,适合有一定单片机基础、希望掌握在线升级实现细节的开发者用于课程设计、项目移植和方案验证。
1. 从一台焊死在柜子里的采集板说起:IAP 到底解决了什么
去年冬天,一块装在配电柜里的采集板被整机拉回实验室,只为改一个采样系数——板子焊死在柜体上,外壳只剩一个 UART 排针露在外面。主控是 GD32F103,程序当年用烧录器一次性灌进去,没留任何升级入口。这类场景在 ARM 嵌入式硬件里太常见了:设备出厂之后再想动固件,能用的通道往往只有一根串口、一个 CAN 口,或者一个无线模组,调试器根本够不着。
这份 GD32F103 IAP 升级源代码要解决的就是这一件事:让单片机自己擦写自己的 Flash,把新固件从通信口收进来,校验通过后跳到新程序。工程被拆成 Bootloader 和 App 两个独立子工程,配一份帧协议、一段 FMC 操作封装和跳转代码,单片机端跑完这套就能实现"应用内编程"。适合两类人:手里有 GD32F103 板子、准备做串口升级的固件工程师,以及刚接触单片机、想搞明白 Boot 和 App 到底怎么分工的新手。读下去,你会看到 Flash 怎么切、中断向量表为什么要挪、跳转前必须关掉哪些东西。
2. 工程结构与 Flash 分区:Boot、App、参数区各占哪一段
拿到这份代码先别急着编译,打开根目录把工程结构和地址规划看清楚,后面的所有坑几乎都和分区有关。IAP 的核心约束只有一条:Flash 里同时住着两段程序,它们各自的中断向量表、启动地址、栈顶指针必须互不干扰。
2.1 目录结构与两个子工程的职责边界
工程按职责拆成三块,Bootloader 和 App 各是一个可独立编译的 Keil/GCC 工程,公共的 FMC 操作、CRC 计算、参数存取抽成一个共享目录,两边都引用同一份源文件,避免改一处漏一处。
gd32f103_iap/ ├── bootloader/ # 引导工程,烧在 0x08000000,永不参与升级 │ ├── main.c # 上电判断标志位,决定跳 App 还是进升级模式 │ ├── iap_uart.c # 串口收发、帧状态机 │ ├── iap_flash.c # FMC 解锁/擦除/编程封装 │ ├── iap_crc.c # CRC16(帧) 与 CRC32(整包) 实现 │ └── iap_param.c # 参数区读写,双副本带校验 ├── app/ # 业务工程,烧在 0x08004000,升级的就是它 │ ├── main.c # 开头重设 VTOR,然后跑业务 │ └── ... ├── common/ # 两边共用的头文件与工具 │ ├── iap_cfg.h # 所有地址、大小、魔数集中在这里 │ └── iap_crc.c └── tools/ └── flash_tool.py # 上位机,负责发固件包我第一次拆这类代码时习惯先看iap_cfg.h,因为地址散落在各个.c里是最常见的翻车原因——改个分区大小要翻八个文件。这份工程把所有常量收到一个头文件,Boot 和 App 引用同一个定义,改一处两边同步,这一点值得保留。
/* common/iap_cfg.h —— 全工程唯一的地址真相来源 */ #ifndef __IAP_CFG_H #define __IAP_CFG_H #define FLASH_BASE_ADDR 0x08000000U /* 内部 Flash 起始 */ #define FLASH_TOTAL_SIZE (64U * 1024U) /* 以 GD32F103C8 的 64KB 为例 */ #define FLASH_PAGE_SIZE 1024U /* 中容量器件 1KB/页,大容量是 2KB */ #define BOOT_BASE_ADDR 0x08000000U #define BOOT_SIZE (16U * 1024U) /* Boot 占 16KB,页 0~15 */ #define APP_BASE_ADDR 0x08004000U /* Boot 之后的第一页 */ #define APP_SIZE (44U * 1024U) /* App 最多 44KB,页 16~59 */ #define PARAM_BASE_ADDR 0x0800F000U /* 参数区 4KB,页 60~63 */ #define PARAM_SIZE (4U * 1024U) #define APP_END_ADDR (APP_BASE_ADDR + APP_SIZE) #define PARAM_MAGIC 0x5A5AA5A5U /* 参数区有效标志 */ #endifFLASH_PAGE_SIZE必须按器件手册确认:GD32F103 的中容量(x8/xB)是 1KB 一页,大容量(xC/xD/xE)是 2KB 一页。擦除粒度搞错了,会直接擦掉上一页的内容,而且现象很玄学——第一次升级正常,第二次 App 前 1KB 变成 0xFF。APP_BASE_ADDR一定要对齐到页边界,这是硬约束,不是建议。
2.2 分区表与链接地址:App 的起始地址怎么落到编译产物里
分区定完,第二件事是让编译器知道 App 不在 0x08000000。地址改错了,App 编译出来照样能跑仿真,一烧进去就死在 HardFault,因为它的复位向量被烧到了 Boot 的地盘上。
| 区域 | 起始地址 | 大小 | 占用页 | 是否参与升级 |
|---|---|---|---|---|
| Bootloader | 0x08000000 | 16KB | 0 ~ 15 | 否,永远只烧一次 |
| App | 0x08004000 | 44KB | 16 ~ 59 | 是,升级目标 |
| 参数区 | 0x0800F000 | 4KB | 60 ~ 63 | 否,存标志和版本 |
Keil 里的改法在 Target 选项的 IROM1:Start 填0x08004000,Size 填0xB000(44KB)。GCC 工具链改链接脚本,ORIGIN和LENGTH与上表保持一致。
/* app/linker/app.ld —— 只列出与 IAP 相关的段 */ MEMORY { FLASH (rx) : ORIGIN = 0x08004000, LENGTH = 44K /* 与 iap_cfg.h 保持一致 */ RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 20K /* GD32F103C8 的 SRAM 20KB */ } SECTIONS { .isr_vector : { KEEP(*(.isr_vector)) } > FLASH /* 向量表必须在段首 */ .text : { *(.text*) *(.rodata*) } > FLASH .data : { *(.data*) } > RAM AT > FLASH .bss : { *(.bss*) *(COMMON) } > RAM }App 侧还有一处必须改:中断向量表偏移。Boot 跳过来之后,SCB->VTOR还指着 0x08000000,App 里任何一个外设中断都会跳进 Boot 的处理函数,表现是串口接收半途卡死、定时器不进中断。在 App 的main()开头第一句就重设:
/* app/main.c —— 必须在任何外设初始化之前执行 */ #include "gd32f10x.h" int main(void) { /* App 的向量表在 0x08004000,偏移 = 0x4000 */ nvic_vector_table_set(NVIC_VECTTAB_FLASH, APP_BASE_ADDR - FLASH_BASE_ADDR); /* 或者直接操作寄存器,效果等价: SCB->VTOR = APP_BASE_ADDR; __DSB(); __ISB(); */ system_clock_config(); /* ... 业务初始化 ... */ while (1) { } }nvic_vector_table_set是固件库里对SCB->VTOR的封装,第二个参数是相对 Flash 基址的偏移字节数,不是绝对地址。有人图省事在system_gd32f10x.c里改VECT_TAB_OFFSET宏,效果一样,但那个宏藏在系统文件里,下次换库版本容易丢,我更倾向写在main()里显式一行。
2.3 参数区设计:升级标志怎么写才不会把自己锁死
Boot 每次上电要做判断:App 完好就跳过去,正在升级就留在 Boot 里等数据。这个判断靠参数区,而参数区最容易出的事故是"写到一半掉电,标志位变成随机值"。解决办法是双副本加校验。
/* bootloader/iap_param.c */ typedef struct { uint32_t magic; /* PARAM_MAGIC,判断本副本是否已初始化 */ uint32_t fw_version; /* 固件版本号,升级后由上位机下发 */ uint32_t fw_size; /* 本次固件字节数 */ uint32_t fw_crc32; /* 整包 CRC32,跳转前复核 */ uint8_t update_flag; /* 0x5A = 需要升级,0x00 = 正常启动 */ uint8_t app_valid; /* 0xA5 = App 校验通过可跳转 */ uint16_t upgrade_count; /* 累计升级次数,排查现场问题用 */ uint32_t check; /* 本结构体前 20 字节的 CRC32 */ } iap_param_t; /* 参数区切成两个 1KB 副本,写一份、留一份 */ #define PARAM_SLOT_A PARAM_BASE_ADDR #define PARAM_SLOT_B (PARAM_BASE_ADDR + FLASH_PAGE_SIZE) static uint8_t param_read(iap_param_t *out) { const iap_param_t *a = (const iap_param_t *)PARAM_SLOT_A; const iap_param_t *b = (const iap_param_t *)PARAM_SLOT_B; if (a->magic == PARAM_MAGIC && calc_crc32((uint8_t *)a, 20) == a->check) { *out = *a; return 0; } if (b->magic == PARAM_MAGIC && calc_crc32((uint8_t *)b, 20) == b->check) { *out = *b; return 0; } return 1; /* 两份都坏,按出厂状态处理 */ }写的时候交替写 A/B,先擦后写,写完立即回读比对,成功才更新另一份。这样即使擦除中途断电,至少还有一份旧参数可用。check字段只覆盖前 20 字节,是因为结构体尾部的check自身参与校验会形成循环依赖,约定清楚覆盖范围比校验更完整更重要。
注意update_flag的置位时机:应该在收到完整固件且 CRC 通过之后再置位,不是一进升级模式就置位。否则中途断电重启,Boot 看到"需要升级"却没有有效固件,只能一直卡在升级模式等数据,现场没人给发固件,设备就变砖了。这份代码里"收到完整固件"会先写app_valid = 0xA5,跳转前再复核一次 CRC,等于给自己留了后悔药。
3. 核心链路拆解:一帧固件数据从串口到 FMC 的全过程
分区和标志位是静态的骨架,真正跑起来的是数据链路:串口收到字节 → 状态机拼包 → 校验 → 写 Flash → 全部完成后复核 → 跳转。这条链路里任何一环没判好,现象都是"升级到 98% 卡住"或者"跳过去就死",没有日志可看,只能靠代码本身的防御性。
3.1 帧协议与接收状态机:把字节流切成可验证的包
串口是字节流,粘包断包随时发生,所以协议里必须有帧头、长度和校验。这份代码用的是很朴素的一版:
/* 帧格式: * +--------+-------+--------+----------+--------+--------+ * | 0xAA55 | CMD | LEN(2) | DATA(n) | CRC16 | 结束标志| * +--------+-------+--------+----------+--------+--------+ * CRC16 覆盖 CMD + LEN + DATA,多项式 0x1021,初值 0xFFFF */ #define FRAME_HEAD 0xAA55U #define CMD_HANDSHAKE 0x01U /* 握手,返回 Boot 版本与芯片型号 */ #define CMD_ERASE 0x02U /* 擦除 App 区,带固件总长度 */ #define CMD_DATA 0x03U /* 下发数据,每包 1KB,停等发送 */ #define CMD_FINISH 0x04U /* 结束,下发总 CRC32 与版本号 */ #define CMD_JUMP 0x05U /* 主动跳转,调试时用 */ #define ACK_OK 0x79U #define ACK_FAIL 0x1FU typedef enum { RX_WAIT_HEAD1, RX_WAIT_HEAD2, RX_WAIT_CMD, RX_WAIT_LEN_L, RX_WAIT_LEN_H, RX_WAIT_DATA, RX_WAIT_CRC_L, RX_WAIT_CRC_H, RX_WAIT_TAIL } rx_state_t; /* 每收到一个字节调用一次,不阻塞 */ uint8_t iap_rx_byte(uint8_t byte) { static rx_state_t st = RX_WAIT_HEAD1; static uint16_t len = 0, idx = 0; static uint8_t buf[1024 + 8]; /* 数据 + CMD + LEN + CRC 的临时缓冲 */ switch (st) { case RX_WAIT_HEAD1: if (byte == 0xAA) st = RX_WAIT_HEAD2; break; case RX_WAIT_HEAD2: st = (byte == 0x55) ? RX_WAIT_CMD : RX_WAIT_HEAD1; /* 错位要退回起点 */ break; case RX_WAIT_CMD: buf[0] = byte; st = RX_WAIT_LEN_L; break; case RX_WAIT_LEN_L: len = byte; st = RX_WAIT_LEN_H; break; case RX_WAIT_LEN_H: len |= (uint16_t)byte << 8; if (len > 1024) { st = RX_WAIT_HEAD1; break; } /* 长度超限直接丢弃 */ idx = 0; st = RX_WAIT_DATA; break; case RX_WAIT_DATA: buf[2 + idx++] = byte; if (idx >= len) st = RX_WAIT_CRC_L; break; /* CRC 两个字节收完,比对通过才交给命令处理函数 */ } return 0; }状态机是逐字节喂的,串口中断里只做"存字节 + 推状态",真正的命令处理放到主循环里做。原因很直接:擦除一页 Flash 是毫秒级操作,如果在中断里执行,收到下一个字节时上一个还没擦完,串口 ORE 溢出错误就来了。分清"收"和"处理"两段职责,是这类代码能不能稳定跑一整天的分水岭。
协议采用停等机制:上位机发一包 1KB,等 Boot 回 ACK 再发下一包。牺牲了一点速度,换来的是不用做滑动窗口、不用处理乱序,Flash 擦写速度本来就慢,串口 115200 的吞吐远大于 Flash 写入速度,停等完全够用。实测升级 40KB 固件大概十几秒,现场可以接受。
3.2 FMC 解锁、擦除与半字编程:写 Flash 的固定套路
GD32F103 的 Flash 操作走 FMC 寄存器,固件库里封装好了fmc_unlock / fmc_page_erase / fmc_word_program / fmc_lock这几个函数。顺序不能错:解锁 → 擦除整页 → 按半字或字编程 → 上锁。
/* bootloader/iap_flash.c —— 擦除 App 区,按页循环 */ uint8_t iap_erase_app(uint32_t fw_size) { uint32_t addr; if (fw_size == 0 || fw_size > APP_SIZE) { return 1; /* 固件大小越界,拒绝擦除 */ } /* 擦除范围向上取整到整页,多擦不亏,少擦会残留旧数据 */ uint32_t erase_len = (fw_size + FLASH_PAGE_SIZE - 1) & ~(FLASH_PAGE_SIZE - 1); fmc_unlock(); for (addr = APP_BASE_ADDR; addr < APP_BASE_ADDR + erase_len; addr += FLASH_PAGE_SIZE) { if (fmc_page_erase(addr) != FMC_READY) { fmc_lock(); return 2; /* 擦除超时或失败,检查是否有写保护 */ } } fmc_lock(); return 0; } /* 按 4 字节对齐写一块数据,调用前保证 address 和 length 都是 4 的倍数 */ uint8_t iap_write_block(uint32_t addr, const uint8_t *data, uint32_t len) { uint32_t i, word; if (addr < APP_BASE_ADDR || (addr + len) > APP_END_ADDR) return 1; if ((addr & 0x3U) || (len & 0x3U)) return 2; /* 未对齐直接拒绝 */ fmc_unlock(); for (i = 0; i < len; i += 4) { word = (uint32_t)data[i] | ((uint32_t)data[i + 1] << 8) | ((uint32_t)data[i + 2] << 16) | ((uint32_t)data[i + 3] << 24); if (word == 0xFFFFFFFFU) continue; /* 全 FF 不必写,省时间 */ if (fmc_word_program(addr + i, word) != FMC_READY) { fmc_lock(); return 3; } } fmc_lock(); return 0; }几个参数说明一下。fmc_page_erase的入参是页内任意地址,硬件会自动对齐到页首;但保险起见传页首地址更稳妥。fmc_word_program在 GD32 库里本质是两个半字写,硬件要求目标地址必须处于已擦除状态,也就是只能把 1 写成 0,不能覆盖写——所以每条 DATA 命令必须先确认目标区间擦过了。代码里检查word == 0xFFFFFFFF跳过,是把"整包全是 FF 的空数据"这种情况省掉,减少不必要的写周期。
还有一点容易忽略:擦写 Flash 期间 CPU 从 Flash 取指会暂停。中断如果开着,串口接收会丢字节,所以要么在擦除时关掉全局中断,要么把擦写函数放到 RAM 里执行。这份代码选了前者——每次 DATA 命令处理前__disable_irq(),写完之后__enable_irq()再回 ACK,配合停等协议,一包之内丢不起字节的问题就绕过去了。
3.3 整包校验与跳转:最后一步走错就前功尽弃
数据全部写完之后不能直接跳,必须先做整包 CRC32 复核,再判断栈顶指针和复位向量是否合法。这两步是跳转前的最后一道闸。
/* bootloader/main.c —— 校验并跳转 */ typedef void (*app_entry_t)(void); uint8_t jump_to_app(uint32_t app_addr) { uint32_t stack_top = *(volatile uint32_t *)app_addr; /* 向量表第 0 项 */ uint32_t reset_vec = *(volatile uint32_t *)(app_addr + 4U); /* 向量表第 1 项 */ /* 1. 栈顶必须落在 SRAM 范围内(GD32F103C8 的 SRAM 是 0x20000000 起 20KB) */ if (stack_top < 0x20000000U || stack_top > 0x20005000U) return 1; /* 2. 复位向量必须落在 App 区内,且最低位为 1(Thumb 状态) */ if (reset_vec < app_addr || reset_vec >= APP_END_ADDR) return 2; if ((reset_vec & 0x1U) == 0U) return 3; /* 3. 关中断、停 SysTick、清掉 Boot 用过的所有中断使能 */ __disable_irq(); SysTick->CTRL = 0; SysTick->LOAD = 0; SysTick->VAL = 0; for (uint32_t i = 0; i < 8; i++) { NVIC->ICER[i] = 0xFFFFFFFFU; NVIC->ICPR[i] = 0xFFFFFFFFU; } /* 4. 向量表指向 App,重设主栈指针,跳到复位向量 */ SCB->VTOR = app_addr; __DSB(); __ISB(); __set_MSP(stack_top); ((app_entry_t)reset_vec)(); return 0; /* 正常不会走到这里 */ }stack_top和reset_vec是链接器在生成固件时写进向量表前 8 个字节的两个值,跳转的本质就是把它们读出来、塞进 MSP 和 PC。判断条件写死 SRAM 范围看起来不优雅,但很实用:如果 App 因为链接地址配错,向量表读出来是0xFFFFFFFF,这里会直接拒绝跳转,串口返回 FAIL,你能立刻知道是固件本身有问题,而不是对着一个 HardFault 干瞪眼。
跳转前清NVIC->ICER那几行,是我见过的代码里最容易被删掉的一段。删了它,Boot 里开过的串口中断在 App 里通常还挂着使能位,App 一初始化就把中断重新配一遍似乎也没事——直到某个中断在跳转瞬间正好挂起,App 的向量表还没生效,PC 直接飞掉。多写八行循环,省掉半天排查。
4. 编译、烧录与联调:把 Boot 和 App 分别跑起来的三步
代码看懂之后,真正卡住人的往往是工程怎么编、两个 bin 怎么烧、上位机怎么发。这一节按顺序走一遍,新手照着做能跑通,熟手可以对照检查自己工程的配置差异。
4.1 工具链与工程配置:两套工程各自的编译要点
Keil 环境下,Bootloader 和 App 是两个独立.uvprojx。Boot 的 IROM1 保持0x08000000 / 0x4000,App 的 IROM1 改成0x08004000 / 0xB000,同时在 App 工程的 C/C++ 选项里预定义VECT_TAB_OFFSET=0x4000(如果选择用系统文件里的宏)。
GCC 环境用 Makefile 更清楚,两套工程共用一份编译规则,只换链接脚本和输出目录:
# 以 App 为例,工具链前缀按实际安装调整 CROSS = arm-none-eabi- CFLAGS = -mcpu=cortex-m3 -mthumb -Os -Wall -ffunction-sections -fdata-sections LDFLAGS = -T app/linker/app.ld -Wl,--gc-sections -Wl,-Map=build/app.map SRCS = $(wildcard app/*.c common/*.c) OBJS = $(patsubst %.c,build/%.o,$(SRCS)) build/app.elf: $(OBJS) app/linker/app.ld $(CROSS)gcc $(CFLAGS) $(OBJS) $(LDFLAGS) -o $@ build/%.o: %.c @mkdir -p $(dir $@) $(CROSS)gcc $(CFLAGS) -c $< -o $@-mcpu=cortex-m3 -mthumb对应 GD32F103 的内核,漏掉-mthumb会编译出 ARM 指令集代码,芯片直接跑不起来。-Wl,--gc-sections配合-ffunction-sections能把没用到的函数裁掉,对 44KB 的 App 区来说这几十 KB 的余量很关键。
编完之后先看体积,别急着烧:
arm-none-eabi-size build/app.elf # text data bss dec hex filename # 21364 312 2844 24520 5fc8 build/app.elftext + data才是要占 Flash 的字节数,bss只占 RAM。这个数一旦超过APP_SIZE(44KB 即 45056 字节),链接会报 region overflow,别硬改分区大小,先看是不是把调试字符串、浮点 printf 一起编进去了。
4.2 生成 bin 与烧录顺序:先 Boot 后 App,中间别断电
编译产物是 elf,IAP 上位机要的是裸 bin。转换命令按工具链不同:
# GCC 工具链 arm-none-eabi-objcopy -O binary build/app.elf build/app.bin # Keil 的 fromelf(在 .axf 同级目录执行) fromelf --bin --output build/app.bin build/app.axf生成的 bin 里已经不含地址信息,第一个字节就是 App 向量表的起始。烧录顺序必须是先 Boot 后 App,用调试器分两次烧:Boot 烧完可以立刻跑起来,它会因为参数区是空的(magic 不匹配)而停在升级模式等串口;这时再用上位机把 App 发进去,或者干脆用调试器把 App 直接烧到 0x08004000。
量产阶段两个 bin 分开烧效率太低,可以在工具里合并成一个镜像:
# 用 srec_cat 把 Boot 和 App 拼成从 0x08000000 开始的整片镜像 srec_cat boot.bin -binary -offset 0x08000000 \ app.bin -binary -offset 0x08004000 \ -o full_image.bin -binary合并之前一定要确认 App 的 bin 是从0x08004000起算的产物,如果 App 编译时忘了改链接地址,合并出来的镜像前半段正常、后半段全错,烧进去的现象是 Boot 能跑、App 一跳就死。这个坑我踩过一次,血泪经验是:合并前先xxd看一眼 App bin 的前 8 个字节,正常应该是00 50 00 20开头(栈顶 0x20005000)加一个01 40 00 08左右的复位向量,对不上就别往下走。
4.3 上位机联调:一段脚本把升级流程跑通
串口升级最怕"看起来发了,实际没写进去"。写个最小上位机,把握手、擦除、逐包发送、结束校验四步串起来,每步都等应答,出错立刻停,比盲发可靠得多。
# tools/flash_tool.py —— 最小可用版本,依赖 pyserial import serial, struct, time, zlib, sys HEAD = b'\xAA\x55' CMD_ERASE, CMD_DATA, CMD_FINISH = 0x02, 0x03, 0x04 ACK, PKT = 0x79, 1024 def crc16(data: bytes) -> int: crc = 0xFFFF for b in data: crc ^= b << 8 for _ in range(8): crc = ((crc << 1) ^ 0x1021) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF return crc def send_frame(ser, cmd, payload=b''): body = bytes([cmd]) + struct.pack('<H', len(payload)) + payload frame = HEAD + body + struct.pack('<H', crc16(body)) + b'\x0D\x0A' ser.write(frame) resp = ser.read(1) # 等一个字节应答,超时由 timeout 控制 return resp == bytes([ACK]) def upgrade(port, bin_path): data = open(bin_path, 'rb').read() ser = serial.Serial(port, 115200, timeout=2) time.sleep(0.1) if not send_frame(ser, CMD_ERASE, struct.pack('<I', len(data))): sys.exit('擦除失败:检查 Boot 是否停在升级模式') for off in range(0, len(data), PKT): chunk = data[off:off + PKT] if len(chunk) < PKT: # 末尾补齐到 4 字节对齐 chunk += b'\xFF' * (4 - len(chunk) % 4 if len(chunk) % 4 else 0) if not send_frame(ser, CMD_DATA, struct.pack('<I', off) + chunk): sys.exit(f'第 {off // PKT} 包写入失败,地址 0x{off:06X}') print(f'\r已写入 {min(off + PKT, len(data))}/{len(data)} 字节', end='') crc = zlib.crc32(data) & 0xFFFFFFFF # 与固件端算法保持一致 if send_frame(ser, CMD_FINISH, struct.pack('<II', len(data), crc)): print('\n升级完成,CRC =', hex(crc)) else: print('\n整包校验失败') if __name__ == '__main__': upgrade(sys.argv[1], sys.argv[2]) # python flash_tool.py COM3 app.bin脚本里crc32用的是 Python 标准库,zlib.crc32的初值和异或处理属于 CRC32/ISO-HDLC 变种,单片机端必须用同样的变种(多项式 0xEDB88320、初值 0xFFFFFFFF、结果取反),两边对不上就会出现"数据全对但校验失败"。这不是玄学,是 CRC 参数没统一,第一次对接时我在这一项上耗了一个下午。
联调时的验证顺序建议这样:先只发握手命令,确认 Boot 应答;再发一次小于一页的固件,观察串口返回和 Flash 内容(用调试器读 0x08004000);最后跑完整包,断电重启看是否自动跳 App。分三档验证,出问题时能立刻定位是协议层、擦写层还是跳转层。
5. 避坑与排查:IAP 现场最常见的五个翻车点
这一节列的五条,都是我在真板子上遇到过、并且从现象反推原因花了不少时间的。每条按"看到什么 → 为什么 → 怎么改"写,遇到对应现象可以对着抄。
5.1 跳转后 HardFault,或者卡在原地不动
现象:Boot 打印跳转成功,App 的main()里断点根本进不去,用调试器一看停在 HardFault_Handler,或者干脆 PC 停在一个不认识的地址。
原因基本是两个。一是 App 的链接起始地址没改,编出来的向量表还是按 0x08000000 排的,跳过去读到的是 Boot 自己的向量。二是SCB->VTOR没在 App 里重设,向量表地址还指着 Boot 区,一旦发生中断就跑到 Boot 的处理函数里去。
解决:先确认 App 工程的 IROM 起始地址和iap_cfg.h里的APP_BASE_ADDR一致,再确认 App 的main()第一行调用了nvic_vector_table_set。用调试器读 0x08004000 处的 8 个字节,正常应该是xx xx 00 20加xx xx 00 08,对不上就是链接配置错了。
5.2 第一个包写进去了,后面全写失败
现象:升级日志显示第 0 包 ACK,第 1 包开始全是 FAIL,重启后 App 不完整。
原因在于 Flash 只能把 1 写成 0,目标地址没擦干净就写,硬件会返回错误标志。很多人只擦了第一页就以为万事大吉,实际上iap_erase_app里的循环边界写成了addr < APP_BASE_ADDR + fw_size而没向上取整到页,最后一页残留旧数据,最后一包必失败;也有的是 DATA 命令里偏移量算错,写到了未擦除的区域。
解决:擦除长度一定向上取整到FLASH_PAGE_SIZE的整数倍;写完一包立刻回读比对,不一致就把地址和期望值打到串口上,比事后猜省事得多。
5.3 升级过程偶尔串口丢字节,包校验失败
现象:十几包里有那么一两包 CRC 错误,重发又能过,概率不高但很烦人。
原因是擦除或写 Flash 的时候 CPU 从 Flash 取指被挂起,如果这时串口接收中断开着、波特率又比较高,接收寄存器 ORE 溢出,字节就丢了。另一种可能是擦除时看门狗没喂,直接复位导致半包数据。
解决:处理 DATA 命令期间关全局中断,写完再打开;同时把接收缓冲从 1 字节扩到至少两包长度,让中断能先把数据存下来;看门狗的话,擦除一页大约几毫秒,页间循环里喂一次狗就够。停等协议在这里帮了大忙——每包都等应答,丢包能被发现,不会静默写坏。
5.4 升级成功但重启后还是跑老程序
现象:上位机显示全部 ACK、CRC 也通过了,断电重启,设备行为跟升级前一模一样。
原因是参数区的标志位没生效,或者 Boot 的判断逻辑写反了。常见的是update_flag写完没回读确认,实际写的时候整页被擦掉了但没写进去;也有的是 Boot 里读参数时先判断app_valid再判断update_flag,顺序一错,新固件永远被跳过。
解决:参数区双副本读写要带回读比对;Boot 的判断顺序固定成"参数有效 → App 校验通过 → 跳转",不通过就进升级模式并在串口打印原因(参数坏、CRC 错、向量非法各一个码),现场不给调试器也能从串口看出是哪一步。
5.5 半途断电,设备彻底不启动
现象:升级到一半拔了电源,再上电,串口无输出、也不进升级模式。
原因是没有断点续传或回滚机制。参数区已经被改成"正在升级",Boot 老老实实等数据,但 App 区只有半包固件,CRC 永远过不了,设备就卡死在升级模式——如果连 Boot 的串口都因为初始化失败没起来,那就是彻底变砖。
解决:置update_flag的时机推到"整包 CRC 通过之后";Boot 启动时加超时,比如等 10 秒没收到握手就检查现有 App 是否有效,有效就跳回去;想更稳就上 A/B 双区,后一章会讲。这块的教训很直接——任何写标志的操作,都得假设下一秒会断电。
6. 二次开发:从单区停等改成双区回滚与自定义通道
单区方案能跑,但它有个绕不过去的短板:升级过程中 App 区是被擦掉的旧固件,一旦新固件有问题,没东西可退。我后来在这份代码上做二次开发,第一件事就是把 App 区拆成 A/B 两半,各 20KB,Boot 记录当前活动区,新固件永远写非活动区,CRC 通过后切标志,重启切区。失败就把标志切回来,旧固件原封不动还在。
| 方案 | 空间占用 | 升级中断表现 | 回滚能力 | 适用场景 |
|---|---|---|---|---|
| 单区覆盖 | 44KB 全给 App | 设备进升级模式等待 | 无 | 有人现场守着、设备好拆 |
| A/B 双区 | 每区 20KB | 自动回退旧区 | 完整 | 无人值守、远程升级 |
| 单区 + 备份区 | App 36KB + 备份 8KB | 备份区可回写 | 部分 | 固件小、Flash 紧张 |
改造成双区,代码上动三处:iap_cfg.h里加APP_A_BASE / APP_B_BASE和active_area字段;jump_to_app的入参从常量改成按标志位取值;上层的擦除、写入函数把基址当参数传,别再用全局宏写死。通信通道也一样,把iap_uart.c里的收发抽成两层——底下是字节收发(串口、CAN、无线模组都能实现),上面是帧解析和命令处理,换通道时只改底下那几十行。我一般会在iap_cfg.h里留一个IAP_TRANSPORT宏,编译期选通道,比运行时判断省事,也避免把没用的驱动链进来。
验证方法没什么技巧,就是人为制造故障:固件发到 50% 直接拔电、故意发一个 CRC 错的包、把 App 的链接地址改错再发一遍,三种情况下设备都应该能回到旧固件正常跑。这三次过不了,双区就是白做的。
从那以后我每次做带 IAP 的项目,都强制走一遍"断电三次、错包一次"的验证流程,不管工期多紧。这类代码平时看不出问题,出事的时候没有后悔药可吃,只能靠事前把自己折腾一遍。希望帮到你。
本文还有配套的精品资源,点击获取