简介:这套源码是一份面向GD32与STM32 MCU开发者的远程升级(FOTA)参考实现,包含独立IAP引导工程与App应用工程,适合需要快速为物联网设备增加固件无线更新能力的嵌入式开发人员参考。压缩包共771个文件,大小17.35MB,主要包含216个h头文件、182个C源文件、100个目标文件和99个交叉引用文件,以及hex/bin固件、axf调试文件、uvprojx工程配置等类型,可覆盖源码阅读、编译构建与烧录验证的完整链路。源码中IAP部分涵盖新固件接收、完整性校验、Flash擦写与跳转逻辑,App部分即正常运行的应用工程,二者配合可实现远程升级的整体闭环。目前已有4130人学习,对于正在规划Bootloader方案或调试升级失败问题的开发者,可直接参考双工程目录划分、启动代码和升级流程设计,节省前期预研时间。
1. GD32远程升级到底在做什么:IAP不是把代码复制过去就完事
设备部署在无人值守的机房里,程序出了bug要更新,跑现场刷一根串口线,来回交通加人工成本可能抵得上设备本身的价钱。GD32远程升级要解决的就是这件事:让固件通过通信链路直接写入Flash,不拆机、不断电、不留人。源码里包含IAP和APP两个工程,IAP负责接收固件、擦写Flash、跳转启动,APP才是真正的业务程序。STM32开发者看这份工程时,只要把GD32的Flash操作接口换成自己芯片的库函数,升级框架可以直接平移。适合做物联网网关、工业控制器、车载设备、电力采集终端的嵌入式开发者,尤其是那些设备已经部署出去、售后成本压不住的场景。
2. 拆解两个工程:IAP与APP的分工和Flash分区规划
拿到工程先别急着编译,第一件事是理解两个工程为什么必须分开。IAP工程编译出来是一个小程序,放在Flash的最起始地址,上电先跑它;APP工程编译出来是真正的业务固件,放在Flash的偏移地址,由IAP在合适的时机跳转过去。两个工程在同一个MCU里共存,靠的是各自独立的编译地址和中断向量表。
2.1 编译地址怎么定:IROM1偏移是双工程的命根子
Cortex-M3内核上电时,从地址0x08000000读取前4个字节作为主栈指针,再读后4个字节作为复位向量,然后开始执行。IAP工程必须躺在0x08000000,这样上电才能进入IAP。APP工程必须偏移,比如放在0x08008000,IAP跳转时就跳到这个地址。在Keil里,IAP和APP两个工程的Options对话框,Target标签页的IROM1起始地址分别配置成0x08000000和0x08008000,大小按Flash容量分配。IAP的RAM起点建议不动,APP的RAM同样不动,避免栈指针初始化错乱。如果在IAR里,改的是链接脚本里ROM区域的origin和length,原理一样。
编译地址配错的表现很隐蔽:单步调试IAP看不出问题,但只要跳转执行就HardFault。因为APP编译地址如果仍然是0x08000000,IAP跳过去执行的其实是IAP自己,逻辑全乱。我一般会在两个工程的链接器输出文件里检查一条关键项:APP的__initial_sp和Reset_Handler两个符号的地址,必须落在APP分区范围内,否则编译地址一定没改对。
2.2 Flash分区:四段划分与大小估算
远程升级不只是把Flash劈成IAP和APP两半,至少要四个分区。以256KB Flash的GD32中等容量型号为例:
| 分区 | 起始地址 | 大小 | 用途 |
|---|---|---|---|
| IAP区 | 0x08000000 | 32KB | 升级引导程序,包含串口驱动、Flash驱动、CRC校验、状态机 |
| APP区 | 0x08008000 | 96KB | 业务应用程序,正常运行入口 |
| 升级缓存区 | 0x08020000 | 96KB | 远程固件先整体传到此处,校验通过后再搬运到APP区 |
| 参数区 | 0x08038000 | 4KB | 保存升级标志、固件长度、CRC值、版本号、升级状态 |
缓存区是关键设计。直接往APP区写固件,写了一半掉电,设备就变砖了;先写缓存区,APP区永远是上一次完整的固件,断电、断连、校验失败都不会破坏当前可运行的程序。参数区可以利用Flash最后的一个扇区,写入次数受Flash擦写寿命限制,但存储升级状态这种低频小数据完全够用。大小估算上,IAP编译出来一般10~20KB,给32KB冗余充足;APP区根据业务代码规模定,功耗表和算法库多的给128KB也不夸张;缓存区最小不得小于APP区大小,否则大固件装不下。
2.3 为什么STM32可以直接参考这份GD32工程
GD32和STM32在Cortex-M3这个内核层面几乎一致,向量表机制、中断控制器、Flash写入流程、启动方式全部相同。GD32的固件库和STM32标准外设库的API命名风格接近,比如Flash解锁、擦除、编程、读回校验,函数名不同但参数和调用顺序大同小异。把GD32的flash_program_half_word换成STM32的FLASH_ProgramHalfWord,把gd32_flash_erase换成FLASH_ErasePage,整个工程就能编译过。跳转函数、中断向量表偏移、升级状态机这些逻辑代码一行都不用改。真正的差异在Flash的页大小和擦除方式,GD32有的型号按页擦除,有的按扇区擦除,STM32则是按页擦除,页大小从1KB到2KB不等,移植时把擦除粒度对齐自己的芯片就行。
3. 远程升级的传输与协议层:分包、重传和CRC一个都不能少
远程升级链路不神秘,说白了就是IAP通过串口、以太网或者无线模块接收固件字节流。真正要设计的是通信协议:固件可能几十KB,环境可能丢包,设备可能随时断电,协议层必须保证数据完整到达并且能卡住错误包。
3.1 通信通道选型:UART透传最稳,以太网和CAN按场景来
最常见的做法是MCU通过UART接一个4G模组或WiFi透传模块,上位机在云端下发固件,模组把字节流原封不动地吐给MCU的串口。这种方案IAP只跟UART打交道,传输可靠性由链路模组负责,代码最简单。工业现场如果走CAN总线,IAP就换成CAN接收中断,帧格式从串口字节流换成CAN报文,逻辑不变。以太网场景更直接,IAP里集成一个轻量级TCP客户端,主动拉取固件。通道选择的基本原则是:IAP里的通信协议要和业务通信完全独立,升级不依赖APP里的任何协议栈。因为APP可能已经跑飞了,或者新固件本身有问题,IAP必须自带一套可用的通信能力。
3.2 帧格式设计:帧头、命令字、包序号、CRC一个都不能少
远程升级的帧格式并不需要花哨,但字段必须齐全。常见设计是一个变长帧,数据域最长256字节,帧头和帧尾用固定字节对齐:
typedef struct { uint8_t head[2]; // 0xAA 0x55,用于帧同步 uint8_t cmd; // 0x01握手 0x02数据 0x03结束 0x04取消 uint16_t seq; // 包序号,从1递增 uint16_t len; // data有效长度 uint16_t crc; // CRC16,覆盖cmd到data末尾 uint8_t data[256]; // 固件数据或握手参数 } upgrade_frame_t;帧头固定两个字节配合状态机做同步,收到0xAA且下一个字节是0x55才认为帧起始。包序号用来检测重复包和乱序,重传机制靠它对齐。CRC16覆盖整个帧的有效区,数据在传输中哪怕变一个bit也能被发现。上位机和设备之间的交互逻辑是:设备收到合法包后立即回一个ACK帧,ACK里带收到的seq和CRC校验结果;上位机发完一包后等待ACK,超时重发同一包,连续重发三次仍无回应就终止升级并上报。
3.3 解析状态机与重传策略:先传到缓存区,校验通过再写APP
IAP里接收端应该是一个逐字节驱动的状态机,不是一次性接收完整帧。串口中断每次来一个字节,状态机根据当前状态决定是找帧头、收数据还是校验CRC。这样可以保证帧长度任意、串口缓冲区再小也不会漏数据。
typedef enum { FRAME_WAIT_AA, FRAME_WAIT_55, FRAME_WAIT_CMD, FRAME_WAIT_SEQ_L, FRAME_WAIT_SEQ_H, FRAME_WAIT_LEN_L, FRAME_WAIT_LEN_H, FRAME_WAIT_CRC_L, FRAME_WAIT_CRC_H, FRAME_RX_DATA } frame_state_t; uint8_t rx_buf[263]; uint16_t rx_index; uint16_t rx_crc; frame_state_t fs = FRAME_WAIT_AA; void uart_isr_rx(uint8_t byte) { switch (fs) { case FRAME_WAIT_AA: if (byte == 0xAA) { fs = FRAME_WAIT_55; rx_crc = 0; } break; case FRAME_WAIT_55: if (byte == 0x55) { fs = FRAME_WAIT_CMD; } else { fs = FRAME_WAIT_AA; } break; case FRAME_WAIT_CMD: rx_buf[rx_index++] = byte; fs = FRAME_WAIT_SEQ_L; break; // 其余字段依次接收,累计到rx_buf并同步计算CRC case FRAME_RX_DATA: rx_buf[rx_index++] = byte; if (rx_index >= 2 + 1 + 2 + 2 + 2 + rx_len) { if (calc_crc16(rx_buf + 2, rx_index - 2 - 2) == rx_crc) { process_frame(rx_buf + 2, rx_index - 2 - 2); } fs = FRAME_WAIT_AA; rx_index = 0; } break; } }状态机一旦把CRC算错或者超时没有新字节到来,就重置回FRAME_WAIT_AA,丢弃半个包。收完整个固件后,IAP对缓存区的完整镜像做一次总体CRC校验,确认无误后才开始搬运到APP区。这样每次升级都是一个原子操作:要么APP区保持旧版本,要么替换成新版本,不存在中间态。
4. IAP工程核心代码:跳转函数、Flash擦写与合法性过滤
IAP工程的核心逻辑可以拆成三块:上电后判断要不要进升级模式,不进就跳转APP;进升级模式就收数据写缓存;写完后校验、搬运、重启。跳转Flash的细节都在代码注释里,照着调工程就能跑通。
4.1 跳转函数与中断向量表重映射:关中断、设VTOR、改栈顶、跳转
跳转APP不是简单地用函数指针调用,必须完成四件事:关中断、重设向量表偏移、设置主栈指针、跳转到复位向量。如果不关中断,跳转瞬间进来的中断会去APP的向量表查询入口,但APP的外设和中断初始化还没执行,状态完全不确定,最常见的现象就是HardFault。
#define APP_BASE_ADDR 0x08008000 #define APP_RAM_BASE 0x20000000 typedef void (*p_function)(void); void jump_to_app(void) { uint32_t app_sp = *(volatile uint32_t *)APP_BASE_ADDR; uint32_t app_pc = *(volatile uint32_t *)(APP_BASE_ADDR + 4); p_function app_entry = (p_function)app_pc; // 跳转前必须把全局中断关掉,并且把挂起的中断全部清掉 __disable_irq(); for (int i = 0; i < 8; i++) { NVIC->ICER[i] = 0xFFFFFFFF; // 关闭所有外设中断 NVIC->ICPR[i] = 0xFFFFFFFF; // 清除挂起中断 } // 设置向量表偏移,APP里所有中断从这里查入口 SCB->VTOR = APP_BASE_ADDR; // 重设主栈指针,对应APP工程编译出来的初始SP __set_MSP(app_sp); // 跳转APP app_entry(); while (1); }跳转前要确认app_sp落在RAM地址范围0x20000000到0x20000000 + RAM大小之间,app_pc落在APP区Flash范围之内,否则不要执行跳转。如果app_sp异常,直接进入升级模式等待重新下发固件,而不是盲目跳转。__disable_irq和__set_MSP在Cortex-M3内核对GD32和STM32通用,编译器直接内联。
4.2 Flash擦除与写入:按页擦除、半字写入、擦写后读回校验
Flash写入前必须先擦除,擦除以页为单位,写入按半字(16位)为单位。GD32和STM32的Flash控制器都不允许字节写入,16位是底线。写之前要先解锁Flash控制寄存器,写完重新上锁防止误操作。
uint8_t flash_write_buffer(uint32_t addr, uint8_t *buf, uint32_t len) { uint32_t addr_end; if ((addr & 0x01) || (len & 0x01)) { return 1; // 地址或长度未对齐半字,直接失败 } addr_end = addr + len; // 按页擦除,页大小根据芯片型号查手册,比如1KB while (addr < addr_end) { flash_unlock(); flash_erase_page(addr); // 不同GD32型号的擦除API名略有差异 flash_lock(); addr += FLASH_PAGE_SIZE; } addr -= len; flash_unlock(); while (addr < addr_end) { flash_program_half_word(addr, *(uint16_t *)buf); // 立即读回比对,防止Flash写入无效或电压不足 if (*(volatile uint16_t *)addr != *(uint16_t *)buf) { flash_lock(); return 2; } addr += 2; buf += 2; } flash_lock(); return 0; }擦除动作比较耗时,擦除过程中如果代码本身执行在Flash里,要确保擦除的区域不是当前正在执行的代码扇区。IAP工程把自己放在0x08000000起始的32KB内,擦除APP区和缓存区都不会碰到IAP的代码扇区,所以在IAP里直接调擦除函数是安全的。如果擦写函数放在APP里调用,就要特别小心,最好是搬进RAM执行。
4.3 跳转前的合法性过滤:魔术字、状态标志、CRC缺一不可
IAP每次上电先读取参数区,根据参数区的内容决定下一步动作。参数区在Flash末尾,用结构体管理,读取时直接按地址强转,写入时整段擦除再编程:
typedef struct { uint32_t magic; // 固定魔术字0xA5A5A5A5,用于判断参数区是否有效 uint32_t upgrade_flag; // 0表示空闲,1表示缓存区有完整固件待搬运 uint32_t fw_size; // 固件总长度 uint32_t fw_crc; // 固件总体CRC32 uint32_t fw_version; // 固件版本号 uint32_t try_count; // 连续启动失败计数 } upgrade_param_t; #define PARAM_BASE_ADDR 0x08038000 #define PARAM_MAGIC 0xA5A5A5A5上电流程在main函数里是这样:读参数区,如果magic不对则把参数区初始化为全默认值;如果upgrade_flag为1,说明缓存区有完整固件,跳到升级搬运流程;否则检查APP区首地址的栈顶指针是否合法,合法直接跳转APP。这一套过滤条件看着简单,但能挡住绝大多数变砖场景:缓存区没有完整固件时,永远执行旧APP;旧APP被破坏时,try_count连续增长,超过阈值就自动待在IAP等待重新升级。
5. 远程升级避坑清单:跳转死机、擦除失败、升级中断全记录
远程升级的坑几乎都集中在跳转瞬间和Flash操作两个环节。下面几条是实际调试中最常踩的问题,按现象、原因、解决的顺序写清楚,排查时可以一条条对照。
5.1 跳转到APP后第一个中断就HardFault:向量表偏移没生效
现象:IAP能跑,跳转代码执行完就进HardFault,但断电重启后APP又能正常运行。原因:芯片复位后从Flash起始地址读取向量表,APP复位启动时向量表地址天然正确。IAP跳转时如果不重设向量表偏移,中断一来CPU仍然去0x08000000找向量表,但此时向量表是IAP的,中断入口指向IAP函数,跳过去检查到地址非法就HardFault。解决:跳转前必须设置SCB->VTOR = APP_BASE_ADDR。有些GD32型号还需要额外置位一个标志寄存器使VTOR生效,务必查芯片手册确认。
5.2 跳转后UART中断风暴:外设中断没关干净
现象:跳转瞬间一切正常,几微秒后程序陷入死循环,调试器停住发现PC在中断处理函数里反复进出。原因:跳转前UART、DMA、定时器中断没有关闭,跳转到APP后,APP的main还没执行到外设初始化,此时串口来了一个字节,触发中断。中断向量表已经指向APP,但APP的中断处理函数访问了尚未初始化的外设寄存器,状态未知。解决:跳转前遍历NVIC把所有中断关闭并清除挂起位,__disable_irq()只是关全局中断,不够,NVIC里的使能位还在。APP的main函数里第一步就重新配置所有用到的中断,和IAP彻底解耦。
5.3 Flash擦除时程序跑飞:擦除区域和代码执行区重叠
现象:在升级流程里执行擦除,擦到某个地址时程序直接跑飞,看门狗复位,反复重启。原因:擦除的是当前正在执行的Flash扇区,CPU取指时发现该扇区已被擦空,读到全FF非法指令,立即HardFault。升级流程如果放在APP里,APP代码所在的扇区恰好和要擦除的扇区重叠,就会发生。解决:擦写期间执行的代码必须放在不被擦除的区域,或者干脆把擦写函数放到RAM里执行。IAP工程天然满足这个条件,因为IAP的代码扇区和升级数据区完全隔离,这也是为什么远程升级框架必须把IAP独立出来的原因之一。
5.4 升级中断一次就变砖:没有缓存区和状态回退
现象:固件传到60%断电,重新上电设备完全没反应,只能跑现场用烧录器救砖。原因:升级流程直接在APP区擦写,擦除后还没写完就断电,APP区既不是旧固件也不是新固件。解决:所有固件先写入升级缓存区,只有整体CRC校验通过后才把缓存区内容搬运到APP区。搬运前在参数区写upgrade_flag=1,搬运完成后写0。上电时IAP看到flag=1就重新执行搬运或直接等待新固件下发,而不是跳转一个残缺的APP。这套机制哪怕搬运过程中断电,旧APP也在,只是需要重新下发一遍固件。
5.5 固件能写进去但运行异常:Flash写入宽度和等待周期
现象:升级完成后启动,APP能跑但部分常量错误,有些函数行为怪异,断电重启后症状稳定复现。原因:Flash写入时地址没按半字对齐,或者用了字节写入,部分数据没有真正编程成功。另一个常见原因是主频提高后Flash等待周期没配置够,Flash读出数据错误。解决:写入前检查地址和长度都按2字节对齐,用半字编程指令,写完立即读回比对。时钟初始化里FLASH_ACR等待周期必须和主频匹配,比如主频72MHz时等待周期设2,主频108MHz时设3或4,具体看芯片手册。这一条最容易在移植到不同型号时不注意到。
6. 进阶玩法:双Bank启动与断点续传,把升级失败变成可回退的事故
前面讲的缓存区方案能防止升级中断变砖,但还有一种更狠的场景:新固件本身有bug,跑起来就死机或者疯狂复位。此时APP区已经是新固件,IAP每次跳转都失败,设备等于废了。进阶做法是双Bank启动,把Flash里APP区域拆成Bank A和Bank B,当前启动Bank A,升级时往Bank B写,写完校验通过后翻转启动标志,下次跳转Bank B。如果Bank B启动失败,IAP检测到连续复位计数超标,自动翻回Bank A,旧固件随时能顶上。可回退的升级才能真正谈远程无人值守。
断点续传是另一个提升体验的点。大固件传到一半网络中断,传统做法是全部重传。如果参数区记录着最后成功写入缓存区的包序号,重新连接后从断点继续传,几十KB的固件能省大量流量和时间。实现上接收ACK时同步更新参数区序号,缓存放Flash不用怕掉电丢失,只是Flash擦写寿命要吃紧一些。签名校验也建议补上,CRC只防随机错误,防不了恶意篡改,预算允许时对固件头部做摘要校验,升级前验证来源合法性。
远程升级框架一旦稳定,收益非常直观:不用跑现场、不用带烧录器、不用约用户时间窗口,固件迭代周期从按周变成按小时。我自己的习惯是每次升级前先备份当前固件版本号到日志区,升级后连读三次新固件的版本和CRC,确认无误才把状态置为完成。这套习惯救过我一次——有一次现场环境温度异常导致Flash写入时序不稳,正是靠双Bank和日志回退,设备没报废,用户甚至没察觉到升级失败过。希望帮到你。
本文还有配套的精品资源,点击获取