GD32F103 IAP升级全解析:Bootloader跳转与Flash读写避坑指南
2026/9/9 20:47:24 网站建设 项目流程

简介:面向嵌入式开发者,提供GD32F103芯片的在线升级引导程序完整工程。该芯片与STM32采用相同内核,升级流程和关键机制基本一致,因此使用STM32的开发者同样可以借鉴。在线升级也称IAP,常用于产品出厂后的固件更新与维护,本工程正是围绕这一需求设计。工程基于GD32F103C8T6,片内只读存储器共64K,扇区大小为1K。存储空间划分清晰:引导程序从存储器起始位置占用前30K,用户程序紧随其后占用余下34K,两个区域地址连续且不重叠。资源共164个文件,以75个头文件和61个C源文件为主体,还包含启动汇编、工程配置文件、链接脚本、生成二进制程序的批处理脚本,以及编译好的hex与bin文件,压缩包约624K,可直接用配套工具打开编译并烧录验证。目前已有5169人学习下载。读者可以从中理解引导跳转、中断向量重定向、闪存擦写、地址分区等核心实现,快速搭建属于自己的固件升级方案,适合需要动手实践的初中级嵌入式工程师。

1. 为什么自己写IAP升级:一个避不开的坑

做嵌入式开发的朋友应该都遇到过这种场景:产品发出去几百台,突然发现固件有个小bug,要么派工程师带着J-Link到现场拆壳烧录,要么让客户把设备寄回来,一来一回成本和时间直接失控。GD32F103作为国产Cortex-M3里用量很大的片子,IAP升级基本是量产项目的必修课。我这边把调通的一套GD32F103 IAP升级源代码拆开讲清楚,Bootloader、App跳转、Flash读写,还有网上问得最多的“跳转后卡死”“HAL_Delay失效”这类问题,一次性说明白。

1.1 IAP到底解决了什么问题

IAP全称In-Application Programming,中文叫“在应用编程”,核心思路是芯片里同时放两段程序:一段是Bootloader,上电先跑;一段是用户App。Bootloader负责通过串口、CAN、USB或者以太网接收新的固件包,写到Flash里,然后跳转到App执行。这样以后升级固件就不需要打开设备、不需要连调试器,只要把新固件发给设备就行。

GD32F103的内部Flash支持按页擦除、按半字(16位)编程,这给IAP提供了硬件基础。我们只需要在Bootloader里控制FMC(Flash Memory Controller),把接收到的固件写入指定地址,再通过函数指针跳到App的入口。整个过程涉及“Flash分区规划”“固件传输协议”“中断向量表重映射”“跳转时对CPU状态的合理处置”四个关键点,任何一个环节没处理好,轻则升级失败,重则设备变砖。

1.2 这套源代码的设计目标

我最初做这套GD32F103 IAP升级源代码,目标很直接:单串口能升级、断电不损坏老固件、上位机不用装特殊软件。所以在设计上做了几个取舍:

  • 使用Bootloader + App双分区,Bootloader固定放在Flash起始地址,App偏移到后面。
  • 新固件先写入下载暂存区,全部接收完成并校验通过后,再擦除App区并拷贝过去,避免中途断电导致App区损坏。
  • 通信协议用简化版XMODEM,配合SecureCRT的YMODEM发送功能就能传固件,不用为上位机单独花时间。
  • 每次固件包附带CRC32校验,能识别传输过程中丢字节、改字节的情况。

技术方案不追求花哨,但每一步都要可靠。毕竟IAP是给已经出货的产品用的,出了问题没法挨个拆机。

2. 整体设计与代码框架

2.1 Flash分区表

以GD32F103C8T6为例,它内部Flash总共64KB,页大小是1KB。分区方式影响后面所有代码,建议按下面这张表规划:

分区名起始地址大小用途
Bootloader0x0800000016KB(0x4000)启动、固件接收、Flash搬运
App区0x08004000不固定用户应用程序
参数区Flash末尾留1KB1KB保存升级标志、版本号、CRC等

Bootloader分16KB看起来奢侈,但编译出来通常只有6~8KB,留余量方便以后加协议。App从0x08004000开始,意味着App工程里的IROM1起始地址也要改成0x08004000。这里最容易出错:如果App工程还是从0x08000000编译,即使Bootloader跳转成功,很多功能也可能异常。

参数区我习惯放在Flash最后1KB,专门存“是否需要升级”的标志字。这个标志字可以简单设成两个连续32位值,例如0xA5A5A5A50x5A5A5A5A,擦写时保证写入的完整性,读取时只有两个值同时匹配才认为有效。

2.2 源码结构与通信协议

这一版源码保持了精简的层次,主要文件就三个:

  • bootloader.c:主流程、升级标志判断、跳转逻辑。
  • flash_if.c:FMC解锁、擦除、编程、整页拷贝。
  • xmodem.c:XMODEM接收、CRC校验、分包处理。

通信协议层面,XMODEM本身已经解决了分包、应答、超时重传的问题,一包128字节,每包都有CRC校验,对串口传输来说足够可靠。需要注意的坑是GD32F103的串口接收要用中断或者DMA,不能在阻塞等待中丢字符。我实际用的方案是串口中断接收,把数据放进环形缓冲区,XMODEM状态机在主循环里调度。

很多资料喜欢自己定义“帧头+长度+CRC32”的私有协议,不是不行,但工作量会多不少。XMODEM是1980年代的老协议,正因为简单可靠,至今还在嵌入式Bootloader里广泛使用。只要你稍微改一下,让每一包能携带“偏移地址”或者“固件总长度”信息,就能变成很实用的升级通道。

2.3 Bootloader主流程

Bootloader的main函数逻辑很直接,先判断有没有升级请求,有就进入固件接收流程,没有就校验App是否有效,有效则跳转。

int main(void) { system_clock_config(); uart_init(115200); led_init(); if (check_upgrade_flag()) { update_firmware(); // 接收固件,写入临时区,校验后搬运到App区 clear_upgrade_flag(); } if (app_valid()) { jump_to_app(APP_START_ADDR); } /* App无效就在这里死循环,方便继续接收固件 */ while (1) { update_firmware(); } }

实际产品里,app_valid()不只要判断0x08004000地址的前4字节是不是合法栈顶,最好再判断入口地址是否落在App区间。比如reset_vector必须在0x080040000x0800FC00之间,否则可能跳到未知地址,直接HardFault。

3. 核心代码实现

3.1 跳转函数完整实现

跳转是整个IAP里最微妙的地方,网上关于“跳转后卡死”的提问有一大半都出在这里。一个能稳定工作的跳转函数至少要做三件事:关闭全局中断、停掉SysTick、设置主堆栈指针。下面是我在GD32F103上调通的版本:

typedef void (*app_entry_t)(void); #define APP_START_ADDR 0x08004000u void jump_to_app(uint32_t app_addr) { uint32_t msp_value = *(volatile uint32_t *)app_addr; uint32_t reset_value = *(volatile uint32_t *)(app_addr + 4u); app_entry_t app_entry; /* 栈顶地址必须在RAM范围,防止跳到非法地址 */ if ((msp_value & 0xFFF00000u) != 0x20000000u) { return; } __disable_irq(); /* 彻底停掉SysTick,避免跳转后SysTick_Handler错乱 */ SysTick->CTRL = 0u; SysTick->LOAD = 0u; SysTick->VAL = 0u; /* 设置MSP后再取出复位向量 */ __set_MSP(msp_value); app_entry = (app_entry_t)reset_value; app_entry(); }

为什么非要用msp_value & 0xFFF00000做合法性判断?因为Cortex-M3的RAM地址一般在0x20000000开头,而栈顶指针必然落在RAM里。如果读出来是0xFFFFFFFF或者0x00000000,说明这里没有固件,直接返回比硬跳安全得多。

关中断的顺序也很有讲究,必须在读取复位向量之后、调用函数指针之前。因为一旦跳过去,App可能立刻会开中断,我们得保证从“关闭中断”到“新向量表生效”之间没有中断钻进来。

3.2 App端中断向量表重映射

跳转只是第一步,App本身必须知道自己不在0x08000000,而在0x08004000。Cortex-M3的中断控制器有一个VTOR寄存器,用来告诉CPU中断向量表放在哪里。GD32F103和STM32F103在这一点上寄存器布局一致,配置方法有两种。

如果用GD32标准外设库,可以直接调用:

nvic_vector_table_set(NVIC_VECTTAB_FLASH, 0x4000u);

如果在App里用的是STM32 HAL库,或者类似结构,可以在main最开始设置:

SCB->VTOR = 0x08004000u;

关键点是这条语句必须在任何中断使能之前执行,最好放在main的第一行。如果你用的是STM32 HAL库,还要记得在system_stm32f1xx.c里把VECT_TAB_OFFSET改成0x4000u,否则SystemInit()会在你写上那句赋值之前又把VTOR清成0。

这里有个容易忽略的细节:跳转完成后,PC一下就执行到App的Reset_Handler,但这时候外设寄存器还是Bootloader里的状态。所以App的启动文件里那段“拷贝数据段、清BSS段、调用SystemInit”的流程必须完整跑一遍,外设时钟配置也必须从头初始化,不能指望Bootloader帮你做过。

3.3 Flash擦写与固件接收

GD32F103的Flash编程不能像RAM一样直接用指针写,必须先解锁FMC,按页擦除,再按半字写入。一个典型写入函数长这样:

void flash_write_page(uint32_t addr, uint8_t *buf, uint32_t len) { fmc_unlock(); fmc_flag_clear(FMC_FLAG_END | FMC_FLAG_WPERR | FMC_FLAG_PGERR); /* 先擦除整页 */ fmc_page_erase(addr); fmc_flag_clear(FMC_FLAG_END | FMC_FLAG_WPERR | FMC_FLAG_PGERR); /* 按半字(16bit)写入 */ for (uint32_t i = 0u; i < len; i += 2u) { uint16_t data = buf[i] | ((uint16_t)buf[i + 1] << 8u); fmc_halfword_program(addr + i, data); while (fmc_flag_get(FMC_FLAG_BUSY) != RESET) { } fmc_flag_clear(FMC_FLAG_END | FMC_FLAG_WPERR | FMC_FLAG_PGERR); } fmc_lock(); }

这里有几个坑值得展开说。

第一,擦除函数和写数据函数的地址参数都是“绝对地址”,不是偏移。很多人把App偏移量0x4000直接传进去,导致擦到Bootloader区域,当场变砖。

第二,写入前一定要确保这一页已经被擦除。Flash编程只能把1写成0,不能把0写成1,如果不擦除,写入后数据会错乱。

第三,写完一页后强烈建议回读校验,对比缓冲区内容和Flash实际内容。虽然这会让升级时间变长几十毫秒,但能提前发现Flash老化、电压不稳、地址不对等问题。

固件接收部分用的是XMODEM,XMODEM的发送方一包一包发,接收方每收到一包就回一个ACK。Bootloader收到第一包时就能从包头解析出总长度,然后按页暂存到下载暂存区。等所有包都收完,CRC校验通过,再执行“擦除App区+拷贝”的动作。这样安排的好处是,App区在拷贝完成之前始终保持上一版固件,即使中途断电,重新上电后Bootloader发现没有有效新固件,依旧会把旧的App跑起来。

4. 常见问题:跳转后卡死与HAL_Delay失效

4.1 跳转后卡死的原因

“从Bootloader跳到App,程序就死掉”是最常见的现象,原因无非下面几类。

一是中断向量表没偏移。App里没有设置VTOR或者设置的位置不对,中断一旦发生,CPU会跑去Bootloader的向量表找入口,但Bootloader的中断服务函数里处理的是Bootloader的业务逻辑,自然没法正常工作。表现就是跳转过去后一打开中断就死。

二是跳转前外设中断没处理干净。比如串口接收中断正在挂起,跳过去一瞬间App的向量表还没准备好,这个中断先到了,CPU直接进HardFault。解决办法是在跳转前关闭全局中断,必要时清掉挂起位。

三是App的编译地址和实际存放地址不一致。App编译时IROM1起始地址还是0x08000000,但你把它放在0x08004000运行,中断向量表和字符串常量都会有问题,程序跑飞是常事。

四是堆栈指针检查没做,读了错误的Flash内容,跳转到非法地址。

4.2 HAL_Delay失效的根因

网上很多人问“跳转后HAL_Delay卡死”,这个问题非常典型。HAL_Delay的原理是不断查询uwTick这个全局变量,而uwTick是在SysTick中断里加的。如果你的App里调用了HAL_Delay,但SysTick中断没有正确执行,uwTick永远不变,HAL_Delay会一直卡在while循环里。

SysTick中断不执行,通常是两个原因叠加造成的:App工程没有正确设置中断向量表偏移,导致SysTick中断进来后找不到App的SysTick_Handler;或者Bootloader跳转前没有把SysTick停掉,跳转后SysTick还残留着上一次的加载值,时序完全对不上。

我调过的一个项目就卡在这上面。Bootloader跳转前没有执行SysTick->CTRL = 0,App的VTOR也忘了设置,结果跳过去后HAL_Delay直接卡死。代码里去掉HAL_Delay就正常,加上就死,排查了一个多小时才定位到是SysTick的问题。

修复需要两头做。Bootloader跳转前把SysTick停掉并清空计数,代码章节里的SysTick->CTRL = 0那三行就是干这个的。App侧在main最前面设置SCB->VTOR = 0x08004000,然后再调HAL_Init()SystemClock_Config(),顺序不能反。

4.3 问题速查表

现象可能原因解决办法
跳转后立即HardFault栈顶地址非法、复位向量异常、App地址不对检查App起始地址处的前8字节,加栈顶合法性判断
HAL_Delay卡死VTOR未偏移、SysTick没有重新初始化跳转前停SysTick,App里先设VTOR再HAL_Init
跳转后串口乱码时钟配置不正确,或App和Bootloader用了不同波特率检查HSE_VALUE、PLL参数、串口分频
Flash写入失败FMC未解锁、地址越界、页未擦除先调用fmc_unlock,正确擦除后再写
升级中断电变砖没有暂存区/备份区,直接覆盖App用下载暂存区,校验通过后再搬运
升级完成后旧App还在升级标志判断逻辑反了,或者固件传输没完成在固件接收完成并校验成功后才清标志位

这张表我每次给同事做培训都会贴出来,大多数IAP问题都能在上面找到影子。

5. 踩坑记录与实战建议

5.1 升级失败后的恢复策略

IAP最怕的不是升级失败,而是失败后设备变成砖。做一个相对安全的恢复机制,需要从设计上保证Bootloader永远可用。

一个有效的做法是“三段式”:Bootloader区、App区、下载暂存区。新固件先全部收进下载暂存区,这里可以放在Flash尾部,也可以放在外部SPI Flash或者W25QXX里。收完后对数据进行CRC校验,校验通过才允许擦除App区。如果擦除App区之后、拷贝还没完成时掉电,那确实会变砖,所以更稳妥的方案是拷贝时先写“搬运中”标志,重新上电时发现这个标志,就继续完成搬运,而不是直接跳转。

另外一个很实用的习惯是给App加一个“升级失败自动回滚”机制。比如App启动时先记录当前版本号到参数区,运行5秒后如果没有收到升级指令,就认为本次启动正常;如果5秒内被异常复位多次,Bootloader就认为App有问题,自动回到接收固件模式。

这种兜底逻辑不需要多复杂,但能省掉大量售后问题。产品在外面,你不可能每次都用J-Link去救,能靠串口重新引导就是最大的幸运。

5.2 调试期最有效的三板斧

IAP调试第一件事,不是直接烧Bootloader,而是先把App单独烧到0x08004000,用调试器确认它在偏移地址能独立运行。做法是改App工程的IROM1起始地址,然后烧录到0x08004000,跑一下基本功能。如果这一步就出问题,说明App本身没有按偏移地址编译好,跟Bootloader无关。

第二件事,把Bootloader里的升级功能先屏蔽,只保留“检查标志位+跳转”。烧进去后确认能从Bootloader跳到App,再把升级功能加回来。这样排查问题时能快速缩小范围,不会一死机就一头雾水。

第三件事,串口打印一定要留。Bootloader里每个关键节点都打一条日志,比如收到第几包、CRC是否正确、App校验是否通过。产品量大之后你会发现,IAP问题最难的往往不是代码,而是“现场到底发生了什么”。有了日志,客户发一条串口信息回来,你马上就能定位。

5.3 源码扩展方向

这套GD32F103 IAP升级源代码目前是串口接收+Flash搬运的裸机方案,改造成CAN升级或者网络升级也不难,只需要替换传输层。比如用CAN,把XMODEM的串口收发函数换成CAN收发,协议本身不用动。用网络时记住一个原则:底层传输可以换,上层“暂存区+校验+断电恢复”的健壮性逻辑不要动。

如果App将来升级到FreeRTOS,要注意跳转前必须把RTOS的全局中断屏蔽掉,并且确保跳转时处理器处于线程模式,使用MSP而不是PSP。裸机代码里这个问题不突出,一旦上RTOS,很多人直接踩“跳转后不进main”的坑。

再往后,如果GD32F103的Flash空间不够,可以考虑把下载暂存区放到外部SPI Flash,固件先通过串口传到外部Flash,再慢慢搬到内部Flash。虽然速度慢一点,但内部Flash空间压力会小很多。

我在实际项目里还有一个小习惯:Bootloader做完后,会专门写一个2KB的小固件当作“急救固件”,平时不启用,只有出厂烧录或者售后返修时烧进去。这样即使Bootloader的升级逻辑出了意外,也能有个最小系统把设备救回来。这个习惯成本很低,但带来的安全感很高。IAP这种东西,平时看着不起眼,出一次问题就足够记住一辈子。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询