GD32F103 IAP串口升级:Bootloader分区与Flash跳转实战 简介这份 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 的地盘上。区域起始地址大小占用页是否参与升级Bootloader0x0800000016KB0 ~ 15否永远只烧一次App0x0800400044KB16 ~ 59是升级目标参数区0x0800F0004KB60 ~ 63否存标志和版本Keil 里的改法在 Target 选项的 IROM1Start 填0x08004000Size 填0xB00044KB。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还指着 0x08000000App 里任何一个外设中断都会跳进 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 区内且最低位为 1Thumb 状态 */ 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 / 0x4000App 的 IROM1 改成0x08004000 / 0xB000同时在 App 工程的 C/C 选项里预定义VECT_TAB_OFFSET0x4000如果选择用系统文件里的宏。GCC 环境用 Makefile 更清楚两套工程共用一份编译规则只换链接脚本和输出目录# 以 App 为例工具链前缀按实际安装调整 CROSS arm-none-eabi- CFLAGS -mcpucortex-m3 -mthumb -Os -Wall -ffunction-sections -fdata-sections LDFLAGS -T app/linker/app.ld -Wl,--gc-sections -Wl,-Mapbuild/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 $-mcpucortex-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_SIZE44KB 即 45056 字节链接会报 region overflow别硬改分区大小先看是不是把调试字符串、浮点 printf 一起编进去了。4.2 生成 bin 与烧录顺序先 Boot 后 App中间别断电编译产物是 elfIAP 上位机要的是裸 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, payloadb): 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, timeout2) 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 两半各 20KBBoot 记录当前活动区新固件永远写非活动区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 的项目都强制走一遍断电三次、错包一次的验证流程不管工期多紧。这类代码平时看不出问题出事的时候没有后悔药可吃只能靠事前把自己折腾一遍。希望帮到你。本文还有配套的精品资源点击获取