GD32F103 IAP远程升级:Bootloader双区设计与Flash跳转
简介面向GD32F103系列单片机开发者与嵌入式学习者的IAP在线升级源代码包重点解决Bootloader与用户程序分区、固件跳转及升级流程的工程落地问题。由于GD32与STM32内核架构一致IAP升级路径主要依赖内核机制因此相关实现也可作为STM32平台移植与对照的参考。压缩包共164个文件约624KB以C源文件与头文件为主体辅以启动汇编、Keil工程配置、分散加载脚本、HEX/BIN固件及批处理辅助脚本便于直接编译、烧录和验证。代码针对GD32F103C8T6的64KB Flash给出明确规划Bootloader占用30KB用户程序占用34KB扇区按1KB组织分区边界与升级入口在工程中均有对应配置。已有5183人学习下载适合需要理解IAP分区设计、Bootloader编写、固件跳转和工程配置细节的中级嵌入式开发者参考也可用于课程设计、项目升级方案预研与代码移植。1. 现场返修跑一趟三百公里GD32F103 的 IAP 到底省掉了什么设备装在客户的控制柜里固件里有个小数位处理的 bug维护的人得开三百公里车过去拆壳、插上仿真器、烧录、再装回去一天就没了。这种场景在 ARM 嵌入式 项目里极其常见而 GD32F103 这类单片机偏偏又是最容易被装进“人够不着”的地方的那一批。IAPIn-Application Programming要解决的就是这件事让设备自己接收新固件把 Flash 里属于应用的那一段擦掉重写然后重新跳过去跑人不用到现场。这份源码包给的就是这么一套东西——一个 Bootloader、一份改好的 App 工程模板、一套串口升级协议拿着它能直接在自己板子上把“远程升级”这条链路跑通。适合两类人一类是手上已经有 GD32F103 的产品、被返修折磨过的另一类是刚学完 Flash 编程、想找个完整工程看看工业界到底怎么组织双区程序的。它不是教程是能编译、能改、能往自己协议上套的工程骨架。2. 方案全景Bootloader 和 App 两个程序怎么分 Flash先把最容易含糊的地方说清楚IAP 不是“在程序里改程序”而是两个独立的程序住在同一片 Flash 的不同地址段靠一次跳转交接控制权。Bootloader 是那个永远不升级、也永远不能被擦掉的小程序App 是业务代码随时可以被覆盖。理解这一点后面所有的地址计算、向量表偏移、链接脚本改动才有落脚点。2.1 为什么是「双区 状态标志」而不是自己覆盖自己新手最容易想到的做法是App 收到升级命令直接擦掉自己所在区域的 Flash然后往里写新数据。这个思路在某些芯片上能勉强跑在 GD32F103 上是必然出错的原因有三个而且都很具体。第一擦除和编程操作本身是由 Flash 控制器执行的Cortex-M3 在 Flash 被擦写的这段时间里如果代码也从同一片 Flash 取指取到的就是不确定值程序会直接跑飞。有些型号支持 RWW读写同时GD32F103 的 Flash 在擦除期间取指是被硬件挡住的。第二擦除是按页来的。你要覆盖的这段 Flash 里正装着“正在执行擦除的那段代码”一擦就没了后续的写入逻辑无从执行。第三也是最容易忽略的一点升级过程中一旦掉电App 区就是半截旧代码加半截新代码没有任何机制能把它救回来。这就成了彻底的变砖除非返厂。所以这套源码用的是标准做法Bootloader 常驻低地址负责所有 Flash 操作App 只负责接收数据和把数据交给 Bootloader自己一次都不碰 Flash 的擦写。为了应对掉电再在单独的参数页里放一个状态标志让 Bootloader 上电时能判断“上一次升级是不是没走完”。这就是典型的“后悔药”设计——不指望不出事指望出了事能自己爬回来。提示这份源码的 Bootloader 里没有任何业务逻辑也不依赖任何外设初始化顺序就是为了让它足够小、足够难被改坏。改动 Bootloader 之前先把它的 hex 单独存档一份。2.2 地址分配表从 0x08000000 开始把 128KB 切干净GD32F103 的片内 Flash 从 0x08000000 起以 128KB 的型号为例一共 0x20000 的空间。这份工程切成了四段每段的用途、大小和页数都对得上不是随手写的区域起始地址结束地址大小页数2KB/页说明Bootloader0x080000000x08003FFF16KB8常驻不参与升级App0x080040000x0801BFFF96KB48业务固件可整体覆盖备份/日志区0x0801C0000x0801F7FF14KB7存旧版本关键参数、升级日志参数页0x0801F8000x0801FFFF2KB1升级状态标志、固件长度、CRC几个数字不能随便改。Bootloader 给 16KB 是因为它要装串口驱动、CRC32、Flash 操作和状态机实测编出来大概 9 到 11KB留 5KB 余量比较舒服。App 从 0x08004000 起这个 0x4000 的偏移量后面会出现在三个地方链接脚本的 ORIGIN、启动文件里的 VECT_TAB_OFFSET、Bootloader 里的 APP_BASE 宏——这三个值必须完全一致改一个就得改三个这是实际项目里最常见的翻车点。参数页单独放在最后一页是因为它需要被频繁擦写每次升级至少写两次开始一次、结束一次离 App 越远越好避免 App 写满的时候越界把它一起擦了。还有一个细节页大小不是固定 2KB 的。中容量型号是 1KB/页大容量是 2KB/页。工程里用FMC_PAGE_SIZE宏包了一层换型号的时候改宏别去改业务代码里的擦除循环。判断自己手上片子是哪个档最稳的办法是查数据手册里的 Flash 组织表别靠型号猜。2.3 向量表重映射与时钟外设的接管边界App 从 0x08004000 开始但 Cortex-M3 复位后是从 0x08000000 取向量表的。如果不做处理App 里任何一个中断触发CPU 都会跑到 Bootloader 的向量表里去找入口找到的地址是 Bootloader 的中断服务函数而那个函数根本不认识 App 的 UART 句柄和 DMA 缓冲结果是随机 HardFault 或者数据错乱。解决方式是在 App 的启动代码里把 SCB-VTOR 指向自己的向量表基址。这份工程把这段放在system_gd32f10x.c的SystemInit()末尾优先级高于任何外设初始化/* App 工程system_gd32f10x.c */ #define VECT_TAB_OFFSET 0x4000U /* 必须和链接脚本的 ORIGIN 偏移一致 */ void SystemInit(void) { /* ... 时钟、Flash 等待周期等原有配置 ... */ #ifdef VECT_TAB_SRAM SCB-VTOR SRAM_BASE | VECT_TAB_OFFSET; #else SCB-VTOR FLASH_BASE | VECT_TAB_OFFSET; /* 0x08004000 */ #endif }VECT_TAB_OFFSET填的是相对 Flash 基址的字节偏移不是绝对地址。写过SCB-VTOR 0x08004000也能跑但换芯片型号时容易忘改用宏拼出来更稳。时钟这块的边界要说清Bootloader 为了串口接收会把系统时钟配到 108MHz、开 USART0、开 SysTick 做超时。跳转前这些东西必须全部还原——SysTick 关掉、USART 反初始化、NVIC 里挂起的中断清干净。不还原的后果是 App 里一开中断就被 Bootloader 遗留的挂起标志立刻打断一次进到 App 还没初始化的中断函数里。血泪经验是跳转函数里做外设复位比在 App 里做防御更靠谱因为 App 作者可能换人。3. Bootloader 核心代码从上电判定到跳转 App 的完整链路Bootloader 的逻辑其实很短上电 → 判断该不该升级 → 不该升级就跳 App。真正占篇幅的是判断逻辑和跳转前的清理。这一章按执行顺序把三段核心代码拆开每段都能直接抄进自己的工程。3.1 上电决策与跳转 App 的完整实现main()里只做三件事的判断顺序不能调换。先查“上次升级有没有被打断”再查“这次有没有人要求升级”最后才是跳 App。如果把跳 App 放最前面掉电恢复的逻辑永远走不到。/* boot/main.c */ #include gd32f10x.h #define APP_BASE 0x08004000U #define APP_SIZE 0x00018000U /* 96KB */ #define PARAM_PAGE 0x0801F800U #define PARAM_MAGIC 0x5A5AA5A5U typedef struct { uint32_t magic; /* 魔数判断参数页是否被初始化过 */ uint32_t fw_len; /* 新固件字节数 */ uint32_t fw_crc; /* 整包 CRC32 */ uint32_t fw_ver; /* 版本号回滚时用 */ uint32_t state; /* 0空闲 1升级中 2升级完成待校验 */ } param_t; int main(void) { param_t p; /* Flash 读取不需要解锁直接按地址取即可 */ memcpy(p, (const void *)PARAM_PAGE, sizeof(p)); /* 1. 上次升级被掉电打断App 区内容不可信强制留在 Bootloader */ if (p.magic PARAM_MAGIC p.state 1U) { boot_enter_upgrade(REASON_RESUME); } /* 2. 串口握手 / 按键长按 / 特定 RAM 标志任一命中都进升级 */ if (boot_check_upgrade_request() 0) { boot_enter_upgrade(REASON_HOST); } /* 3. 都正常跳 App跳失败说明 App 区是空的或被擦过兜底留下 */ if (jump_to_app(APP_BASE) ! 0) { boot_enter_upgrade(REASON_NO_APP); } for (;;) { } /* 理论上到不了这里 */ }跳转函数本身只有十几行但每一行都在防一种具体的错误typedef void (*app_entry_t)(void); int jump_to_app(uint32_t base) { uint32_t sp *(volatile uint32_t *)(base); /* 向量表第 0 项栈顶 */ uint32_t pc *(volatile uint32_t *)(base 4U); /* 向量表第 1 项复位入口 */ /* 栈顶必须落在 20KB SRAM 范围内否则说明这块 Flash 是空的(0xFFFFFFFF) */ if (sp 0x20000000U || sp 0x20005000U) return -1; /* 复位入口必须落在 App 区且最低位为 1Thumb 状态 */ if (pc APP_BASE || pc APP_BASE APP_SIZE) return -2; if ((pc 1U) 0U) return -3; __disable_irq(); /* 关总中断跳转过程中不能被打断 */ SysTick-CTRL 0; SysTick-LOAD 0; SysTick-VAL 0; /* 把 Bootloader 用过的外设全部复位避免状态泄漏到 App */ rcu_periph_reset_enable(RCU_USART0RST); rcu_periph_reset_disable(RCU_USART0RST); NVIC-ICER[0] 0xFFFFFFFFU; /* 关掉所有外部中断 */ NVIC-ICPR[0] 0xFFFFFFFFU; /* 清掉所有挂起标志 */ __set_MSP(sp); /* 先换栈再跳顺序反了必崩 */ ((app_entry_t)pc)(); return 0; }参数说明sp的合法区间 0x20000000 到 0x20005000 对应 20KB SRAM如果你的型号 SRAM 更大比如 48KB上界要改成 0x2000C000否则 App 用了高地址 RAM 会被误判成无效固件。pc的最低位检查是 Cortex-M 的硬性要求编译器生成的函数指针天然带这一位但如果你从 Flash 里手工读出来的值没有这一位跳过去就是 HardFault。__set_MSP()必须在关中断之后、跳转之前调用而且中间不能有任何函数调用否则会往旧栈里压返回地址。实际写的时候给这个函数加__attribute__((noreturn))更保险防止编译器在末尾插一段栈恢复代码。3.2 Flash 解锁、页擦除和半字写入的寄存器级写法GD32F103 的 Flash 控制器有个保护机制上电后处于锁定状态任何擦写操作都会被忽略并且置位错误标志。必须先连续写入两个固定密钥解锁。这两个密钥写在手册里但顺序错了、中间隔了别的寄存器操作都会失败。/* boot/fmc.c */ #define FMC_KEY1 0x45670123U #define FMC_KEY2 0xCDEF89ABU void fmc_unlock(void) { if (FMC_CTL FMC_CTL_LK) { /* 已解锁就别重复写密钥 */ FMC_KEY FMC_KEY1; FMC_KEY FMC_KEY2; } } void fmc_lock(void) { FMC_CTL | FMC_CTL_LK; } /* 擦除一页addr 可以是页内任意地址 */ int fmc_erase_page(uint32_t addr) { if (FMC_STAT FMC_STAT_BUSY) return -1; FMC_CTL | FMC_CTL_PER; /* 进入页擦除模式 */ FMC_ADDR addr; FMC_CTL | FMC_CTL_START; /* 触发硬件开始干活 */ while (FMC_STAT FMC_STAT_BUSY); /* 2KB 页大约 20~40ms别加超时太短的看门狗 */ FMC_CTL ~FMC_CTL_PER; if (FMC_STAT (FMC_STAT_PGERR | FMC_STAT_WPERR)) { FMC_STAT FMC_STAT_PGERR | FMC_STAT_WPERR; /* 写 1 清标志 */ return -2; } return 0; } /* 按半字写入len 为字节数addr 必须 2 字节对齐 */ int fmc_write_half(uint32_t addr, const uint8_t *buf, uint32_t len) { uint32_t i; if (addr 0x1U) return -1; FMC_CTL | FMC_CTL_PG; for (i 0; i len; i 2U) { uint16_t hw (uint16_t)buf[i]; /* 奇数长度时补 0xFF和擦除后的空状态一致 */ hw | (uint16_t)((i 1U len) ? buf[i 1U] : 0xFFU) 8; *(volatile uint16_t *)(addr i) hw; /* 写入即触发编程 */ while (FMC_STAT FMC_STAT_BUSY); if (FMC_STAT FMC_STAT_PGERR) { FMC_CTL ~FMC_CTL_PG; FMC_STAT FMC_STAT_PGERR; return -2; } } FMC_CTL ~FMC_CTL_PG; return 0; }几个参数必须盯住。FMC_STAT_BUSY的轮询不能省页擦除是几十毫秒级的操作程序跑得比硬件快不等 BUSY 就发下一条命令控制器直接忽略。错误标志是“写 1 清零”而不是“写 0 清零”写成FMC_STAT ~FMC_STAT_PGERR会出现标志永远清不掉、后续每次判断都失败的现象。还有一个隐蔽的点未擦除的地址不能直接编程。Flash 的编程只能把 1 变 0如果那块地方之前写过数据直接写会得到按位与之后的结果看起来“写成功”了读回来却是错的。所以写入流程永远是“先擦整页再顺序写”中间不能跳着写。3.3 固件接收状态机与 CRC32 完整性校验串口上来的数据是流式的没有边界。这份工程用一个五状态的接收机处理分包状态跳转全部由帧头、长度字段和 CRC 决定不依赖任何延时。状态触发条件动作异常出口WAIT_HEAD收到 0xA5记录进 WAIT_HEAD2—WAIT_HEAD2收到 0x5A进 RECV_LEN回 WAIT_HEADRECV_LEN收满 2 字节得到 payload 长度进 RECV_BODY长度 1024 则丢弃RECV_BODY收满 len 字节进 RECV_CRC超时 500ms 则复位RECV_CRC收满 2 字节校验 CRC16通过则交给业务层失败回 WAIT_HEAD整包校验用的是 CRC32算法放在 Bootloader 里逐包累加最后一次比对/* 逐位实现的 CRC32省掉 1KB 查表空间128KB 全量校验约 12ms 108MHz */ uint32_t crc32_update(uint32_t crc, const uint8_t *buf, uint32_t len) { uint32_t i, b; crc ~crc; for (i 0; i len; i) { crc ^ buf[i]; for (b 0; b 8U; b) { crc (crc 1) ^ (0xEDB88320U (uint32_t)(-(int32_t)(crc 1U))); } } return ~crc; }调用方式是crc crc32_update(crc, chunk, n)一路累加初值传 0。这个写法比查表慢 8 倍左右但 Bootloader 只有 16KB 空间省下来的 1KB 查表值这个时间。校验策略分两层每个数据包做一次 CRC16防止串口误码整包做完再算一次 CRC32和参数页里记录的fw_crc比对。只有 CRC32 对上了才把state从 1 改成 2。这一步是整个升级流程里唯一一次“宣布成功”在它之前任何时刻掉电下次上电都会重新进升级模式。注意CRC32 比对通过后不要立刻跳 App。先把参数页写完整、再回读一遍确认最后才跳。这一步多花 5ms能避开参数页写入时掉电导致状态不确定的情况。4. App 侧的改造链接脚本、向量表和回跳拿到一份普通工程直接烧到 0x08004000一定会出问题——编译器默认从 0x08000000 开始布局烧进去的固件第一个字节就在 Bootloader 的向量表上两块代码直接重叠。App 侧要改的地方一共三处改完才算真正接上。4.1 链接脚本与中断向量表偏移GCC 工具链改的是.ld文件Keil/IAR 改的是 scatter 文件或 ICF本质一样把 Flash 的起始地址从 0x08000000 挪到 0x08004000长度从 128K 改成 96K。/* App 工程GD32F103C8_FLASH.ld 片段 */ MEMORY { FLASH (rx) : ORIGIN 0x08004000, LENGTH 96K RAM (rwx) : ORIGIN 0x20000000, LENGTH 20K } /* 中断向量表必须放在段首且带 KEEP 防止被链接器优化掉 */ SECTIONS { .isr_vector : { . ALIGN(4); KEEP(*(.isr_vector)) . ALIGN(4); } FLASH .text : { *(.text*) *(.rodata*) } FLASH }KEEP这个关键字很多人会漏。如果 App 里没有任何地方显式引用向量表符号链接器在开-ffunction-sections -gc-sections的时候会把它当垃圾回收掉生成出来的 bin 文件开头是别的代码烧进去必然跑飞。对应地App 的启动文件里那个VECT_TAB_OFFSET也要改成 0x4000。有些工程模板里这个宏藏得很深在system_gd32f10x.c顶部还有个开关VECT_TAB_SRAM别手滑打开——那是把向量表搬到 RAM 的调试选项量产固件开了它上电就没向量表了。改完之后验证方法很直接编译出 hex用烧录工具打开看一眼起始地址是不是 0x08004000再看 bin 文件的第 0 到 3 字节应该是00 50 00 20栈顶 0x20005000 的小端表示第 4 到 7 字节是复位入口值应该在 0x08004000 到 0x0801BFFF 之间。这两个值对不上后面怎么调都是白费。4.2 升级命令的帧格式与串口收包协议本身不长一共四段帧头、长度、命令、载荷、校验。字段字节数说明帧头2固定 0xA5 0x5A用于快速同步长度2载荷字节数小端上限 1024命令10x01 握手 / 0x02 数据 / 0x03 结束 / 0x04 查询版本 / 0x05 回跳载荷N数据包时为固件片段其余为空CRC162覆盖长度、命令、载荷CCITT 初值 0xFFFFApp 侧用 DMA 空闲中断收包比逐字节中断稳得多尤其是 115200 以上波特率、每包 1KB 的时候/* App 工程usart0.c */ #define PKT_BUF_SIZE 1088U static uint8_t s_dma_buf[PKT_BUF_SIZE]; static uint16_t s_pkt_len; static volatile uint8_t s_pkt_ready; void USART0_IRQHandler(void) { if (usart_interrupt_flag_get(USART0, USART_INT_FLAG_IDLE) ! RESET) { /* 清空闲标志的固定动作先读 STAT 再读 DATA */ usart_data_receive(USART0); dma_channel_disable(DMA_CH2); s_pkt_len PKT_BUF_SIZE - dma_transfer_number_get(DMA_CH2); s_pkt_ready 1; dma_transfer_number_config(DMA_CH2, PKT_BUF_SIZE); /* 重装计数 */ dma_channel_enable(DMA_CH2); } }参数说明s_pkt_len是这一帧实际收到的字节数用 DMA 剩余计数反推不需要自己累加。空闲中断的清除顺序不能颠倒先读状态寄存器再读数据寄存器是硬件要求顺序反了标志清不掉中断会一直重入。PKT_BUF_SIZE给 1088 而不是 1024是为了容纳帧头、长度、命令、CRC 这些额外字节。缓冲区一定要比最大载荷大否则最大包会溢出——这种溢出不会立刻报错会在几包之后表现为随机数据错误非常难查。收完一包之后交给解析函数CRC16 对上才处理命令字对不上直接丢包并回一个 NAK。数据包处理完立刻把数据转交给 Bootloader 暂存区不在这里做 Flash 操作——App 不碰 Flash 这条原则要贯彻到底。4.3 反初始化外设后安全回跳App 里收到 0x05 命令或者按键长按、收到特定串口字符串要做的是写升级标志、关外设、跳回 Bootloader。这三步顺序不能乱先写标志再跳跳完 Bootloader 上电判定才认。/* App 工程boot_req.c */ void request_bootloader(void) { param_t p; memcpy(p, (const void *)PARAM_PAGE, sizeof(p)); p.magic PARAM_MAGIC; p.state 1U; /* 标记“即将升级”掉电也能恢复 */ p.fw_len 0; p.fw_crc 0; fmc_unlock(); fmc_erase_page(PARAM_PAGE); fmc_write_half(PARAM_PAGE, (const uint8_t *)p, sizeof(p)); fmc_lock(); /* 回读一遍确认写进去了别指望写完就一定成功 */ { param_t chk; memcpy(chk, (const void *)PARAM_PAGE, sizeof(chk)); if (chk.magic ! PARAM_MAGIC || chk.state ! 1U) { return; /* 标志没写成功宁可留在 App 也别跳 */ } } usart_deinit(USART0); dma_deinit(DMA_CH2); nvic_irq_disable(USART0_IRQn); jump_to_app(0x08000000U); /* Bootloader 当成一个 App 跳过去 */ }jump_to_app复用第 3 章那个函数只是基址换成 0x08000000所以里面的地址范围检查也要跟着放宽Bootloader 区是 0x08000000 到 0x08004000。实际工程里通常把这个函数拆成带range_lo和range_hi两个参数的版本两个方向共用一份。注意回读校验那一段。Flash 写入失败不一定会报错——页擦除没完成就写、写入地址跨页、电压跌落都可能表现为“函数返回成功、内容却是错的”。多花一次读的时间把后悔药提前吃掉。5. 常见问题排查IAP 最容易翻车的五个地方5.1 跳转与外设相关的三个坑坑一升级完跳过去App 跑了几毫秒进 HardFault。现象是串口能打印出 App 的第一句日志然后就没声了调试器停下时 PC 落在一个不认识的地址上。多数情况下原因是向量表没搬App 里第一个定时器中断触发后跳到了 Bootloader 的中断入口。解决分两步先确认SCB-VTOR的值在跳转后第一行打断点读一下应该是 0x08004000再确认 App 的链接脚本起点。两个值有一个不对就会出这个现象。顺带检查 Bootloader 里有没有残留的 SysTick 挂起跳转函数里的SysTick-CTRL 0不能删。坑二Bootloader 跳转成功但 App 的串口收不到数据。现象是 App 能跑、能点灯就是串口是哑的。原因是 Bootloader 里开的 USART0 没有反初始化App 重新初始化时外设还带着旧状态波特率寄存器被写坏或者 DMA 通道还挂着没释放。解决办法是在跳转函数里做rcu_periph_reset_enable/disable一对操作把 Bootloader 用过的外设全部复位。这个操作代价很小比在 App 里加一堆防御性代码划算。坑三改完链接脚本烧进去后校验能过但一上电就进 Bootloader 的升级模式。现象是设备永远停在升级等待串口一直发握手包。原因是 App 的向量表第 0 项栈顶值不合法跳转函数里的sp检查把它挡回来了。常见来源是链接脚本里 RAM 长度写错——写了 48K 但实际芯片只有 20K编译器把栈顶放到 0x2000C000超出了 SRAM 物理范围读出来是 0xFFFFFFFF。改回正确的 RAM 长度即可同时把跳转函数里的上界改成和链接脚本一致别写死。5.2 通信与工程配置相关的两个坑坑四升级到 70% 左右固定失败重试几次又在不同位置失败。现象很典型小固件能升大固件升到一半断了。原因是接收缓冲区按最大包长算但没算上帧头和长度字段最大包正好卡在边界上溢出覆盖了后面的状态变量。另一种情况是 Bootloader 里的暂存 buffer 开在栈上1KB 的局部数组直接把栈打穿。解决办法是把接收缓冲区改成静态全局尺寸按“最大载荷 16 字节”留暂存区用两块 1KB 乒乓缓冲写 Flash 的时候另一块继续收。0x08004000 之后的 App 区是 96KB一次擦一页写一页不需要把整个固件缓存在 RAM 里。坑五Keil 下编译出来的 App 单独烧能跑通过 IAP 烧就不行。现象是同一个工程仿真器烧进去一切正常Bootloader 升级进去就异常。原因通常是编译器生成的是 hex 或带地址信息的 axf而升级工具把整个文件当成纯二进制发过去了前面的地址记录被当成固件内容写进了 Flash。另一个原因是 Keil 的 “Create HEX File” 和 “Create Binary File” 同时勾选分发的时候拿错了文件。规范做法是分发前统一转成 bin转换工具从 axf 里提取起始地址填 0x08004000转完比对文件大小和 map 文件里的 Code RO Data 总量对得上才算对。提示升级失败时最有效的排查手段不是加打印而是把参数页整页 dump 出来看。magic、state、fw_len、fw_crc四个值一摆问题定位到哪一步了基本就清楚了。6. 二次开发把串口协议换掉加签名和双备份这份工程的骨架是通用的真正要按项目改的是三处传输通道、校验强度、回滚能力。传输通道最好换——串口升级在现场要走线能换成 CAN、USB 或者 4G 模组透传的优先换掉。工程里把收发抽象成了boot_transport_t结构体两个函数指针recv和send换通道只需要实现这两个函数状态机和 Flash 逻辑一行不用动。/* boot/transport.h —— 换通道只需要实现这两个函数 */ typedef struct { int (*recv)(uint8_t *buf, uint32_t len, uint32_t timeout_ms); int (*send)(const uint8_t *buf, uint32_t len); void (*deinit)(void); /* 跳转前调用把通道彻底关掉 */ } boot_transport_t;校验强度这块CRC32 只能防误码防不了人为篡改。要做防篡改在固件头的固定偏移放一段签名Bootloader 里用公钥验验不过直接拒绝升级并保留旧版本。GD32F103 的 Flash 够放一个 2048 位的 RSA 公钥和一段 SHA-256 实现代价是 Bootloader 从 11KB 涨到 15KB 左右16KB 的分区刚好卡得住再往上就得把 Bootloader 区扩到 24KB。回滚能力受限于 Flash 容量。128KB 的型号里 App 占了 96KB放不下第二份完整固件硬要做 A/B 只能换 256KB 的大容量型号把布局改成 Bootloader 16KB App A 96KB App B 96KB 参数 2KB正好 210KB。这个改动不只是改地址宏参数页结构里要加“当前有效分区”字段跳转前根据它算基址项128KB 型号256KB 型号A/BBootloader16KB16KBApp96KB96KB × 2备份/日志14KB不单独占用参数页2KB2KB掉电后可回滚否是Bootloader 体积上限16KB16KBA/B 的升级流程是新固件写到非活动分区 → 校验通过 → 改参数页里的活动标志 → 重启。回滚就是把标志改回去不涉及任何 Flash 搬移。这套逻辑在参数页上要多一个 crc 字段用来存两个分区的校验值上电时如果活动分区 CRC 不对自动切到另一个。还有一个我踩过的坑值得说改完分区布局之后必须把 Bootloader 单独存档一份并打上版本号。Bootloader 一旦刷进去后续所有升级都靠它它自己出问题就只能拆机。从那以后我每次动 Bootloader 的代码都强制走一遍“编译 → 存档 hex → 在空片上全擦 → 先烧 Bootloader 再 IAP 升 App → 断电中途拔一次验证恢复”这套流程一次不落。升级链路这种东西平时不出事出事就是现场。这份源码包把上面这些环节都整理成了可直接编译的工程包含 Bootloader、App 模板、上位机升级工具和布局说明拿到手先按默认的 128KB 布局跑通一次完整升级再动地址宏。希望帮到你。本文还有配套的精品资源点击获取