简介:面向 GD32 与 STM32 等 ARM Cortex-M 系列单片机的远程升级(FOTA)需求,这套源码工程完整拆分了 IAP 引导程序与 App 应用程序两个独立工程,开发者可基于它理解固件下载、完整性校验、闪存擦写、跳转和异常回滚等关键链路,适合正在做 IoT 设备固件在线更新的中高级嵌入式工程师参考。资源包为 rar 压缩包,共 771 个文件,除核心的 C 源码与 H 头文件外,还包含 uvprojx/uvoptx 工程文件、编译链接生成的 axf/hex/bin 映像文件,以及 map/lst 等辅助分析文件,在 17.35MB 包体内即可获得可直接打开、编译与烧录的完整工程。已有 4114 人学习下载,IAP 与 App 两个工程分目录组织,结构清晰,便于对照阅读;从固件存放地址规划、中断向量重映射、启动文件配置到升级失败后的恢复策略均有代码级呈现,稍作适配即可迁移到 STM32 平台,是一份实践性很强的 MCU 远程升级参考实现。 做嵌入式产品最怕的一件事,就是设备已经发货到现场,结果发现固件有bug,或者客户要加新功能。这时候如果产品没有预留升级通道,就只能派人带着烧录器跑现场,拆壳、接线、烧录,一来一回成本高得吓人。我手里这个GD32远程升级项目,就是专门解决这个问题的。整个工程包含独立的IAP引导程序和应用程序两个工程,底层逻辑和STM32完全同源,玩过STM32的人可以直接参考移植。
先说下这套方案的适用范围。如果你正在做GD32、STM32系列的MCU产品,手头有串口、CAN、以太网或者无线模块,想给设备加上远程固件升级能力,那这套工程就是给你准备的。没接触过IAP的新手也能看,我会把跳转原理、Flash分区、中断向量偏移这些关键点都拆开讲明白,同时也会把那些坑——比如跳转后HAL_Delay卡死、找不到芯片目标这类问题——一次说透。
1. 方案设计:为什么一上来就要做Boot+App双工程
很多初学者问到远程升级,第一反应是“直接在应用程序里接收固件,然后写进Flash不就完了?”这个思路成立,但有个致命问题:如果应用程序在升级过程中断电、跑飞,或者写入的固件本身就是坏的,那设备就彻底变砖了,连补救的机会都没有。所以工业级、产品级的远程升级必须拆成两个独立工程,也就是Boot和App分离。
1.1 IAP到底解决了什么问题
IAP的全称是In-Application Programming,在应用编程,说白了就是MCU在运行过程中自己给自己写Flash。这套机制的核心价值有两个:一是让设备在不拆机的情况下更新固件,二是让升级过程具备“容错”能力。
Boot工程(也就是IAP引导程序)承担三个任务:上电后检查是否有升级请求,有就接收固件并写入App区;没有升级请求就直接跳转到App运行;同时负责在App异常时做兜底,防止设备变砖。App工程就是正常的业务逻辑代码,但它内部要预留一个触发条件,比如收到串口命令、按键按下、云端指令等,然后软复位进入Boot,进入升级模式。
这种设计放在产品上的真实场景是这样的:设备在现场跑着,服务器推送了一个版本更新包,App收到后先校验包完整性,然后写一个升级标志位到Flash参数区,接着执行软复位。MCU重新上电后Boot先起来,一查标志位发现有升级请求,就进入接收模式,通过串口或网络把新固件收下来、写完、校验通过、清标志位,最后跳进新App。如果中途断电也没关系,标志位还在,下次上电Boot还会尝试继续升级。
1.2 双工程的划分与Flash布局
GD32和STM32一样,内部Flash都是从0x08000000开始编址。双工程方案要把Flash切成几个区,我的划分方式是所有同类产品通用的做法:
| Flash分区 | 地址范围 | 存放内容 |
|---|---|---|
| Boot区 | 0x08000000 - 0x0800FFFF | IAP引导程序,64KB |
| App区 | 0x08010000 - 0x0807FFFF | 应用程序,448KB |
| 参数区 | 0x08080000 - 0x08080FFF | 升级标志、版本号等参数,4KB |
这种分法兼顾了两个工程的空间余量。GD32F103系列的主频、Flash容量和STM32F103是兼容的,512KB Flash的芯片这么划分很宽裕。如果用的是256KB或者128KB的芯片,可以把Boot区压缩到32KB,App区相应缩小,核心原则是Boot区要足够稳定、尽量少更新,App区要容纳得下正常业务代码。
1.3 为什么GD32工程可以给STM32参考
GD32是国内厂商兆易创新的产品,内核同样是Cortex-M3/M4,外设寄存器层面和STM32高度兼容,标准外设库的函数命名几乎一模一样。所以这套工程的架构、协议和跳转逻辑完全可以用在STM32上,差别主要体现在三个地方:一是Flash扇区大小不同,GD32F103的扇区是4KB一页,STM32F103是1KB一页,擦写函数的地址对齐逻辑要改;二是时钟树配置有差异,GD32的主频可以跑到108MHz以上,部分外设时钟源选择不同;三是部分外设中断向量和寄存器偏移略有区别,但IAP相关的Flash操作和中断控制部分几乎可以平级替换。
2. 核心原理:跳转背后不能踩的坑
IAP最核心的环节就是Boot跳转到App的过程。很多人在这一步翻车,根本原因是没理解Cortex-M3/M4的启动机制。我先把原理讲透,后面再给代码。
2.1 跳转流程:向量表、MSP、Reset_Handler
Cortex-M3内核上电后,硬件会自动从地址0x08000000处取出初始栈指针(MSP)赋给SP寄存器,从0x08000004处取出复位向量赋给PC寄存器,然后跳过去执行。这意味着MCU的启动流程严格依赖向量表放在0x08000000处。
所以Boot跳转App的时候,至少要做三件事:
- 判断App的栈指针和复位向量是否合法(这两个值必须在Flash地址范围内,否则说明App区是空的或者固件损坏)。
- 把SP寄存器设成App的初始栈顶地址。
- 把PC指向App的复位向量地址(Reset_Handler)。
核心跳转代码大概长这样:
typedef void (*pFunction)(void); void jump_to_app(uint32_t app_addr) { uint32_t app_msp = *(volatile uint32_t *)app_addr; uint32_t app_reset = *(volatile uint32_t *)(app_addr + 4); pFunction jump_func; // 合法性检查 if ((app_msp & 0xFFF00000) == 0x20000000) { __disable_irq(); // 关闭全局中断 jump_func = (pFunction)app_reset; __set_MSP(app_msp); // 设置主栈指针 jump_func(); // 跳转,不再返回 } }这里有个容易被忽略的点:__disable_irq()只关了全局中断,但外设本身的中断使能位可能还是开着的。如果Boot里用了串口、定时器,这些中断源仍然处于使能状态,跳到App后一旦某个中断触发,而App的中断处理函数还没初始化好,就会直接进HardFault。所以正规做法是跳转前把用过的外设全部DeInit,把中断挂起标志清干净。
2.2 为什么跳转后HAL_Delay会卡死
这个话题在搜索热词里出现了很多次,原因非常典型。HAL_Delay函数依赖SysTick中断,而SysTick是Boot代码里初始化并启动的。跳转到App后,App的SystemInit和HAL_Init的确会重新配置SysTick,但问题是跳转前Boot可能把SysTick关掉了,或者App里的HAL_Init没有正确重新使能SysTick,导致HAL_GetTick()永远返回0,HAL_Delay就死循环了。
这个问题的根源还有另一层:跳转前__disable_irq()把PRIMASK置1,如果跳转到App后没有重新使能全局中断,整个系统就处于中断屏蔽状态,SysTick永远无法触发,HAL_Delay自然卡死。所以跳转函数执行完之后,App的第一条代码路径上要尽早执行__enable_irq(),或者在使用HAL_Delay之前确认中断已经打开。
我在工程里的处理方式是:跳转前不做__disable_irq(),而是用更精细的中断控制,先把Boot用到的串口中断、定时器中断单独关闭,再调用__set_PRIMASK(0)确保中断打开状态可控,同时在App的main函数最前面加上:
int main(void) { HAL_Init(); // 这里会配置SysTick __enable_irq(); // 确认全局中断打开 SystemClock_Config(); // 其他初始化... }这套组合拳打下来,HAL_Delay卡死的问题基本不会再出现。
2.3 中断向量表的偏移设置
App的Flash地址从0x08010000开始,但Cortex-M3内核的硬逻辑规定向量表默认在0x08000000。这就导致App运行起来后,一旦有中断发生,CPU还是从0x08000000处取中断向量,取到的却是Boot的向量,App的中断服务函数根本不会被执行。
解决办法是通过VTOR寄存器(向量表偏移寄存器)把向量表重定位到App的起始地址。标准外设库和HAL库各有各的写法:
// 标准外设库 NVIC_SetVectorTable(NVIC_VectTab_FLASH, 0x10000); // HAL库 SCB->VTOR = 0x08010000;注意一点:向量表要天然对齐到Flash页大小,我的App起始地址是0x08010000,偏移量0x10000是64KB,满足Cortex-M3要求的2的幂次对齐。如果你把App放在0x08008000这种位置,要确认对齐属性,否则中断跳转依然会出问题。
3. Bootloader工程实现细节
Boot工程是整个升级系统的地基。它不负责具体业务,只做三件事:检查升级标志、接收固件、写入Flash。下面我按工程实际结构拆开讲。
3.1 工程配置与Boot主流程
Boot工程的构建和普通MCU工程一样,没太多特殊之处,但有几个配置必须注意:
- 启动文件用标准启动文件即可,不需要修改启动文件内容。
- 链接脚本把Flash起始地址设为0x08000000,长度设成0x10000(64KB)。
- 优化等级建议选-O2或者-Os,Boot区要尽量精简,给App区留空间。
- 编译生成的烧录文件是.hex,用烧录器直接烧到0x08000000就行。
Boot的主流程就是一条路走到底的循环:
int main(void) { // 1. 初始化时钟、串口、Flash、看门狗(如有) // 2. 读取参数区的升级标志 if (check_update_flag() == UPDATE_REQUEST) { // 3. 进入升级模式:串口/USB/CAN接收固件 receive_firmware_and_write_flash(); // 4. 校验App区固件完整性 if (verify_app_checksum() == PASS) { clear_update_flag(); // 清升级标志 jump_to_app(APP_ADDR); } else { // 校验失败,可能请求重传 } } else { // 5. 无升级请求,直接跳转 jump_to_app(APP_ADDR); } }看门狗这里必须多说一句。如果Boot升级过程中喂狗策略设计不好,会出现两种尴尬:一种是不喂狗导致接收固件超时被复位,进入升级和复位的死循环;另一种是升级等待时一直喂狗,导致代码卡死在Boot里无人发现。我的做法是只在串口每收到一包数据时喂狗,如果接收超时超过5秒,直接软复位回正常模式,避免设备卡死在升级状态。这个设计在远程升级场景里极其重要,因为我见过太多因为升级中断而一直停在Boot的现场设备。
3.2 升级协议设计:分帧传输与CRC校验
远程升级的固件包不是一次性塞进串口的,MCU的RAM装不下,串口缓冲也容不下。所以固件要分包发送,Boot一包一包地收,一包一包地写入Flash。我用的协议帧格式如下:
| 帧头 | 命令字 | 包序号 | 数据长度 | 数据 | CRC32 |
|---|---|---|---|---|---|
| 0xAA 0x55 | 1字节 | 2字节 | 2字节 | N字节 | 4字节 |
- 命令字定义:0x01表示开始升级(附带固件总长度和CRC),0x02表示固件数据包,0x03表示升级完成,0x04表示取消升级。
- 包序号从0开始递增,Boot收到后回ACK,发送端没有收到ACK会重发当前包。
- CRC32覆盖从命令字到数据末尾的所有字节,保证数据传输不出错。
开始升级帧的数据结构是成员固件总长度(4字节)加固件整体CRC32(4字节),Boot收到后先擦除App区。注意擦除Flash要花时间,期间不能接收串口数据,所以我推荐的做法是:收到开始升级命令后,先发一个ACK给上位机,然后才执行Flash整片擦除。上位机收到ACK再开始发数据帧,避免擦除期间数据丢失。
3.3 Flash擦写实现与写保护处理
GD32和STM32的Flash操作流程类似,但有一个坑:芯片出厂时Flash读保护(RDP)可能是使能的,或者调试模式下Flash被锁定,写入时会报错。我踩过这个坑后,在Boot里加了一个解锁判断:
void flash_unlock_check(void) { if (FLASH->CR & FLASH_CR_LOCK) { FLASH_Unlock(); // 解锁Flash控制寄存器 } }Flash写入的常见流程是这样的:先判断目标地址是不是对齐到字(4字节),非对齐写入会触发硬件错误;然后检查该地址所在的扇区是不是需要擦除,Flash只能从1写0,如果之前写过非0xFF的数据,必须整页擦除后再写;写完一个页后,可以选择性地读回来校验。一个小技巧是Boot里每次写完一个扇区后,对该扇区数据做CRC累积计算,全部写完后和上位机发送的整体CRC做比对,这样比逐个字节比对快很多,代码也简单。GD32的每个扇区是4KB,IAP数据包一般是512字节或1KB,所以8包左右就写完一个扇区,每次写满一个扇区就校验一次,能尽早发现写入异常。
4. APP工程改造:把业务代码挪到新的起始地址
Boot工程写好后,App工程并不是简单编译一下就行,它必须“知道”自己运行在0x08010000,而不是默认的0x08000000。这部分一共改三个地方。
4.1 修改链接脚本与工程选项
在Keil或IAR工程里,Options for Target -> Target页面,把IROM1的起始地址改成0x08010000,大小改成0x70000(448KB)。如果用的是GCC工具链,要修改链接脚本中的FLASH段起始地址:
MEMORY { FLASH (rx) : ORIGIN = 0x08010000, LENGTH = 448K RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 64K }这里有一个常见的理解偏差:很多人以为改了链接脚本就行了,但实际上编译出的Hex文件起始地址仍然可能是0x08000000,因为Keil会把Hex的启动地址和链路信息写入文件头部。烧录的时候一定要确认烧录算法里的基地址是0x08010000,或者直接烧录bin文件而不是hex文件,然后把bin文件的烧录起始地址设为0x08010000。这也是本工程里同时提供两个工程文件的另一个原因:避免开发者弄混烧录地址,把App烧到Boot区去。
4.2 中断向量表重映射和系统初始化
App启动流程里,系统时钟初始化和向量表重映射的顺序是有讲究的。有的教程把SCB->VTOR放到main函数第一个执行,有的放在SystemInit里,这两种做法在GD32上都能跑,但在STM32上有细微差别。HAL库的SystemInit函数会调用SystemCoreClockUpdate,这个过程如果执行得太早,而Flash等待周期还没设置好,可能读取出错。我在App工程里是这样处理的:
void SystemInit(void) { // 使能FPU(如果内核带FPU) // 设置向量表偏移 SCB->VTOR = APP_BASE_ADDR; // 0x08010000 }这个SystemInit是启动文件里在调用main之前就执行的,所以向量表在App的main函数跑起来之前已经生效,之后的中断就能正常路由到App的中断处理函数了。
那么App里原来的中断函数需要改动吗?不需要。中断处理函数名还是USART1_IRQHandler、TIM2_IRQHandler那些,编译链接后这些函数被放在App区,向量表里的入口地址也是App区的地址,跳转过去后中断自然按新向量表执行。
4.3 App如何触发进入升级模式
远程升级的“远程”两个字,核心就在这个触发机制上。无非两种常见形式:
一种是本地触发。App收到串口命令“upgrade”或者按键长按3秒,先把升级标志写入参数区,然后执行软复位NVIC_SystemReset()。参数区我放在Flash的最后一页,这样即使用户反复升级也不会把参数区和代码区互相覆盖。写入标志位的具体实现要注意:写入前必须先读一下该地址的数据,如果是非0xFF,要整页擦除再写,否则写入不生效。
另一种是网络触发。服务器下发升级指令,App通过TCP/UDP/MQTT收到指令,校验通过后,把固件先缓存到外部Flash或者SD卡(这也解释了为什么会有人搜GD32 FATFS,就是干这个用的),然后写标志位,软复位。Boot上电后发现标志位为升级请求,先从外部存储读固件写内部Flash。这种方式的优势是升级包可以在线续传,网络断了等恢复后重传就行,不会因为Boot区空间不足而写不下整个固件。
5. 远程升级实测与问题排查
工程写完不能烧进去跑一下就完事,远程升级场景里各种现场问题层出不穷。我挑几个典型的排查过程分享一下。
5.1 本地模拟远程升级完整流程
我拿到这套工程后是这样验证的:用串口把电脑和开发板连起来,模拟远程服务器发送固件包。流程如下:
- 先把Boot固件通过烧录器烧进0x08000000。
- 把App工程编译生成的bin文件通过上位机工具分包发送,每包512字节,帧格式按上面协议走。
- Boot收到开始升级命令后擦除App区,发ACK。
- 上位机逐包发送固件数据,Boot边收边写Flash,每写完一扇区回一个进度ACK。
- 全部数据发完后,上位机发升级完成命令,Boot对App区做整体CRC校验。
- 校验通过,清升级标志,跳转到App,App的串口打印“App started”即说明升级成功。
我这套流程实测下来,一个128KB的App固件,在115200波特率下大概2分钟左右传完,包括擦除和写入时间。如果换成CAN或者网口,速度还能快一个量级。
5.2 常见问题速查表
我在调试过程中遇到过的问题,整理成一张表,基本覆盖了新手会踩的大部分坑:
| 故障现象 | 可能原因 | 排查方法 |
|---|---|---|
| 跳转后HAL_Delay卡死 | 全局中断未打开/SysTick未启动 | 检查跳转函数中__enable_irq()是否调用,确认App的HAL_Init先执行 |
| 跳转后进入HardFault | 外设中断未关闭、向量表偏移未设置 | 跳转前DeInit所有外设,检查SCB->VTOR是否在SystemInit里设置 |
| 跳转后App串口打印乱码 | 波特率时钟配置不一致 | 检查App和Boot的时钟树配置是否相同,APB分频是否一致 |
| Flash写入失败 | Flash未解锁、地址未对齐、扇区未擦除 | 加入flash_unlock_check(),检查写入地址4字节对齐,先擦后写 |
| 升级中途断电后无法再升级 | 升级标志未清除或未重新置位 | 确认标志位写入时机,建议标志位放在独立扇区,先擦后写保证原子性 |
| 连接仿真器找不到目标 | Flash进入读保护、芯片进入低功耗 | 检查RDP等级,用STM32CubeProgrammer或J-Flash解除读保护 |
| Boot和App都正常但跳转后不执行App | 跳转地址错误或App区固件为空 | 检查app_addr参数是否和链接脚本一致,读0x08010000处的值确认栈指针合法 |
这里单说“连接仿真器找不到目标”这个问题,热搜词里也出现了error: no stm32 target found。很多情况下不是芯片坏了,而是IAP程序一旦跑起来,Flash被加上读保护,调试接口就被锁定,仿真器连接不上。解决办法是用STM32CubeProgrammer的Connect Under Reset模式连接,电平拉低复位脚再点连接,进去后再把读保护等级设置为0。GD32的ISP串口烧录模式也是同理,BOOT0拉高再上电,走ISP协议整片擦除。
5.3 我从这套工程里总结的几条经验
最后说几个实际项目里的心得。第一,串口升级协议一定要做应答超时和重传机制,但重传次数不能设成无限次,否则网络模块异常时设备会一直卡在升级状态。我设的是单包重传3次,3次失败就中止升级,等下一次升级请求。
第二,App里建议加入软件版本号,升级完成后Boot读App区里的版本号然后上报给服务器,这样远程升级系统能自动确认是升级成功还是失败,不用靠现场人员反馈。这个信息放到App的固定偏移位置就行,比如0x08010000+0x200处,放4字节版本号。
第三,如果你用的是GD32和STM32同为Cortex-M3的F103系列,这套工程基本是平级替换的;但如果是M4内核的GD32F407、STM32F407,注意硬件中断向量表地址和FPU初始化代码略有差异,跳转函数本身逻辑不变,但系统初始化部分要按M4的启动文件来改。
我一直觉得,远程升级是嵌入式产品走向成熟的一道分水岭。没有升级能力的产品,出厂就是最终形态;有了这套IAP机制,产品才真正具备了持续迭代、远程修复的能力。这套GD32双工程源码的核心设计思路不受具体芯片型号限制,你拿着它去套STM32或者任何Cortex-M内核的单片机,把Flash分区、链接脚本、向量表偏移这三样改对,剩下的就是通信协议和业务逻辑的事情了。
本文还有配套的精品资源,点击获取