GD32F103 IAP串口固件升级与Bootloader分区设计
简介:面向GD32F103系列单片机的IAP在线升级工程源代码,适合具备ARM嵌入式基础的开发者参考,重点解决Bootloader与用户程序分区、程序跳转及固件升级路径设计等问题。压缩包共164个文件,约624KB,以C源文件与H头文件为主体,配合启动汇编、Keil工程、分散加载脚本、批处理及HEX/BIN固件,覆盖底层驱动、工程配置与可烧录产物。代码按64KB Flash规划,Bootloader占30KB(0x8000000–0x80077FF),用户程序占34KB(0x8007800–0x800FFFF),扇区大小1024字节,分区边界与链接配置在工程中均有对应体现。已有5183人学习下载。其价值在于提供较完整的IAP工程骨架、分区参数、跳转与固件生成相关配置,便于对照理解GD32F103升级方案的组织方式并用于二次开发验证。
1. 从产线返修说起:为什么 GD32F103 的 IAP 值得单独做一套方案
去年帮一个做工业采集终端的小团队收拾过一次烂摊子:设备装在配电柜里,固件升级要拆壳、接 SWD 排线,一趟下来半小时,二十台设备整整耗掉一个下午。更麻烦的是,现场工程师没有开发环境,只有一根串口线。后来把 GD32F103 的 IAP(In-Application Programming,在应用编程)升级链路补齐,同样的活变成插上串口、点一下上位机、等三十秒。这套方案要解决的其实就是这件事:让设备在不动用仿真器、不拆外壳的前提下,靠自身已有的通信口把新固件写进 Flash。它适合正在做单片机课程设计、嵌入式毕设,或者刚接手一款量产板、需要补 Bootloader 的工程师。下面按需求、选型、分区设计、通信实现、避坑、验收的顺序,把整套 IAP 升级源代码讲透,看完基本能照着改一版落地。
2. 方案总体设计:Flash 分区、跳转流程与工程结构
IAP 的本质是在芯片里同时住两套程序:一套负责搬砖(Bootloader),一套干正经活(App)。Bootloader 在上电后先跑,判断是"该升级"还是"该跳转"。这套代码在 GD32F103 上其实不复杂,麻烦的是分区、向量表、跳转条件这三处细节,出一处偏差就是上电后一片死寂,靠调试器也看不出所以然。
2.1 Flash 分区规划与链接脚本对齐
GD32F103 常见型号的 Flash 容量从 64KB 到 128KB 不等,本项目按 128KB 规划,具体数值按实际型号改。分区的核心原则是 Bootloader 区不能被 App 的写操作波及,App 区要留出两倍空间做"先写临时区、校验通过再覆盖"的双区方案,或者退一步只做单区覆盖。考虑到单片机项目的实际体量,这里走的是单区方案:Bootloader 预留在最前面,App 紧随其后,参数区单独切一小块存升级标志和固件信息。
| 区域 | 起止地址 | 大小 | 用途 |
|---|---|---|---|
| Bootloader | 0x08000000 – 0x08003FFF | 16KB | 启动判断、串口收发、Flash 擦写 |
| App | 0x08004000 – 0x0801FFFF | 112KB | 业务固件 |
| 参数区 | 0x0801F800 – 0x0801FFFF | 2KB | 升级标志、版本号、固件长度、CRC |
分区定下来之后,App 工程的链接配置必须跟着改,否则编译器还是把代码从 0x08000000 开始摆,一烧进去就把 Bootloader 顶掉。以常见的 GCC 工具链为例,链接脚本改动集中在 MEMORY 段的起始地址和中断向量表偏移两处:
/* App 工程链接脚本片段:Flash 起点后移到 0x08004000 */ MEMORY { FLASH (rx) : ORIGIN = 0x08004000, LENGTH = 112K RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 32K } /* 向量表偏移,单位字节,必须与分区起始地址一致 */ _estack = ORIGIN(RAM) + LENGTH(RAM);向量表偏移是这套方案里最容易漏的一环。App 被 Bootloader 跳转执行时,硬件仍然按 0x08000000 去取复位向量,映射过去的是 Bootloader 的向量表,中断一进就跑飞。所以 App 的启动代码里必须把 SCB->VTOR 改成自己的起始地址,或者在系统初始化早期调用nvic_vector_table_set(NVIC_VECTTAB_FLASH, 0x4000)。我一般把这一步放在SystemInit之后、main之前,早于任何中断使能,顺序错了就是玄学现场:主循环跑得好好的,串口一收数据就进 HardFault。
参数区放最后 2KB,用一页整块的 Flash 存一个小结构体,比用备份寄存器稳,掉电不丢。结构体里至少要有升级请求标志、期望固件长度、CRC32 校验值和一个版本号字段,Bootloader 和 App 共用同一份定义,头文件里统一定义好,两边工程都 include 它,避免各写一遍导致字段错位。
2.2 Bootloader 与 App 的跳转与握手协议
跳转本身只有几行代码,但要跳得稳,得先收拾现场:关总中断、关外设、清挂起位,再取 App 的栈顶和复位向量。GD32F103 用的是 Cortex-M3 内核,这套流程和 STM32 一致,代码可以直接照这个骨架写:
/* 从 Bootloader 跳转到 App,addr 为 App 起始地址 0x08004000 */ typedef void (*app_entry_t)(void); void 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 + 4); /* 向量表第 1 项:复位入口 */ /* 简单的合法性判断,防止空片跳转 */ if ((stack_top & 0x2FFE0000) != 0x20000000) { return; /* 栈顶不在 SRAM 范围,说明 App 不存在,留在 Bootloader */ } __disable_irq(); /* 关总中断,避免跳转过程中被中断打断 */ SysTick->CTRL = 0; /* 关掉 SysTick,否则 App 里计数全乱 */ RCC_DeInit(); /* 复位时钟,让 App 从干净状态起步 */ SCB->VTOR = app_addr; /* 关键:中断向量表重定位到 App */ __set_MSP(stack_top); /* 设置主栈指针 */ ((app_entry_t)reset_vec)(); /* 跳过去,不再返回 */ }这段代码里有两个地方值得单独说。第一是栈顶合法性的判断:空片擦除后 Flash 全为 0xFF,读出来的 stack_top 是 0xFFFFFFFF,按位掩码一算不在 0x20000000 段,直接返回留在 Bootloader 等升级,比贸然跳进去跑飞强得多。第二是RCC_DeInit(),很多人省掉这一步,结果 App 里重新配时钟时发现外设行为诡异,本质是 Bootloader 用过串口和定时器,寄存器还留着旧状态。
握手协议走的是"上位机主动问、Bootloader 应答"的问答式。上电后 Bootloader 先看参数区有没有升级标志,有就直接进接收状态;没有就先开一个短超时窗口(通常 500ms 到 1s),在这段时间里如果串口收到上位机发来的握手帧,就进升级流程,超时没收到就跳 App。这个窗口是留给"设备已经装在现场、需要强制升级"的场景,靠上位机发特定命令触发,不用再拆机短接。
通信帧建议定成固定头 + 长度 + 命令 + 数据 + CRC 的结构,长度字段参与 CRC 计算,避免粘包时算错边界。命令至少要有:握手、擦除、写数据、校验、重启五条。擦除命令里带上固件总长度,Bootloader 按页循环擦,GD32F103 的页大小按型号分 1KB 和 2KB 两种,擦之前必须查手册确认,擦错页边界会把参数区一起抹掉,升级标志也没了,设备就卡在 Bootloader 里出不来——这个坑后面还会细说。
2.3 工程目录划分与编译产物约定
两套工程分开建,不要塞进同一个工程里靠宏切换,维护起来会疯。目录结构我一般这么摆:Bootloader 工程单独一个文件夹,只依赖最小外设库(串口、Flash、GPIO),不放任何业务代码;App 工程是正常业务工程,只多一个"升级请求"的接口函数,把标志写进参数区后软复位即可。两者共享一个iap_common目录,放参数区结构体定义、CRC32 实现、帧格式定义,通过相对路径 include,避免复制两份后改了一边忘另一边。
编译产物约定成两个固定名字,Bootloader 输出boot.bin,App 输出app.bin,上位机工具按名字去找,省得每次手选文件。打包时把 App 的版本号、长度、CRC 一起写进升级包的头部,Bootloader 收到后先校验包头再动 Flash,能挡掉不少传错文件的情况。这套约定看着琐碎,但产线批量升级时,减少一次人为选错文件就少一次返工。
3. 核心模块实现:串口收发、Flash 擦写与校验
方案定完,真正花时间的在三个模块:串口要收得稳、Flash 要擦得准、校验要算得对。这三块单独拎出来都不难,凑在一起出问题就特别隐蔽,比如串口丢字节导致 CRC 对不上,现象看起来像 Flash 写坏了,实际是接收缓冲长度算错。
3.1 串口不定长接收与帧解析
IAP 升级时数据是一包一包来的,每包长度不固定,用查询方式一个字节一个字节收会拖慢整体速度,也容易在擦 Flash 的间隙漏数据。常规做法是开串口接收中断配合空闲中断(IDLE),空闲中断触发时说明一帧收完了,把 DMA 或环形缓冲里的数据整体交给解析函数。GD32F103 的 USART 支持 IDLE 检测,配起来不复杂:
/* 串口空闲中断回调:一帧收完,置标志交给主循环处理 */ void USART0_IRQHandler(void) { if (usart_interrupt_flag_get(USART0, USART_INT_FLAG_IDLE) != RESET) { usart_interrupt_flag_clear(USART0, USART_INT_FLAG_IDLE); /* 清 IDLE 需要先读 STAT 再读 DATA,这是硬件要求,漏了清不掉 */ (void)USART_STAT0(USART0); (void)USART_DATA(USART0); rx_frame.len = RX_BUF_SIZE - dma_transfer_number_get(DMA0, DMA_CH3); rx_frame.ready = 1; /* 通知主循环有新帧 */ dma_channel_disable(DMA0, DMA_CH3); /* 处理完再重新装填,防止覆盖 */ } }用 DMA 搬运的好处是 CPU 不用守着每个字节,擦 Flash 那段时间(单页擦除通常几毫秒到几十毫秒)不会因为关中断错过数据。但要注意 DMA 缓冲不能太小,一包数据按 1KB 算,缓冲给 2KB 留余量,太小会在高速传输时丢尾包。解析函数里先找帧头,再读长度,再按长度取数据和 CRC,三者任意一项对不上就整帧丢弃并回 NAK,让上位机重发。重发次数由上微机控制,Bootloader 端只管单帧校验,不维护重传状态机,逻辑简单,出问题也好定位。
还有个细节:擦除和写入 Flash 期间,Flash 控制器处于忙状态,此时如果代码还在 Flash 里执行,CPU 取指会被挂起,串口中断响应会延迟。所以擦写函数执行前后要把中断优先级理清楚,或者干脆在擦除期间只靠 DMA 收数据、中断里只置标志不做重活,把解析放到擦除完成之后。这类"血泪经验"听着像小题大做,但真在 115200 波特率下跑过一次就知道,丢一包就要从头再来。
3.2 Flash 页擦除与字写入的时序控制
GD32F103 的 Flash 操作有固定套路:解锁、擦除或编程、等忙标志、上锁。少一步就失败,顺序错了还可能误擦。擦除按页进行,写入按字(32 位)或半字进行,写之前必须确认目标地址是擦除态,否则写进去的值是"与"运算结果,不是你想要的数据。
/* 按页擦除 App 区,addr 必须页对齐,len 为总字节数 */ int flash_erase_app(uint32_t addr, uint32_t len) { uint32_t page_size = 1024; /* 按实际型号确认,有的型号是 2KB */ uint32_t end = addr + len; fmc_unlock(); /* 解锁 Flash 控制器 */ for (uint32_t p = addr; p < end; p += page_size) { fmc_page_erase(p); /* 发起页擦除 */ while (fmc_flag_get(FMC_FLAG_BUSY) != RESET) { } /* 等忙标志清零 */ fmc_flag_clear(FMC_FLAG_END | FMC_FLAG_WERR | FMC_FLAG_PGERR); } fmc_lock(); /* 上锁,防止误操作 */ return 0; } /* 按字写入,addr 需 4 字节对齐,data 为待写数据 */ int flash_write_word(uint32_t addr, uint32_t data) { fmc_unlock(); fmc_word_program(addr, data); while (fmc_flag_get(FMC_FLAG_BUSY) != RESET) { } fmc_flag_clear(FMC_FLAG_END | FMC_FLAG_WERR | FMC_FLAG_PGERR); fmc_lock(); return 0; }参数说明上要盯住三点。第一,page_size不能想当然按 1KB 写,同一系列不同容量型号的页大小不同,擦除地址不对齐会返回错误标志,代码里最好加上地址对齐断言。第二,忙标志等待别用死循环不带超时,Flash 出故障时程序会卡死在这里,加个计数器超时返回错误码更稳妥。第三,错误标志必须每次清掉,WERR(写保护错误)和PGERR(编程错误)不清除的话,下一次操作会直接失败,现象是"第一包写进去了,第二包开始全错",很容易误判成通信问题。
写入顺序也有讲究:先擦完整片 App 区再逐包写,不要边擦边写。边擦边写听着省时间,实则一旦中途断电,App 区变成半新半旧,跳转过去必挂,而且没有备份可回滚。稳妥的做法是先把固件完整收进 RAM 缓冲或者外部存储,校验通过后再一次性擦写;RAM 不够就分块收、分块写,但每块写之前保证目标页已擦除。
3.3 CRC32 校验与升级标志的落盘策略
校验是升级流程的"后悔药"。没有校验,传输出错、Flash 写坏都发现不了,设备带着半截固件跑,故障现象千奇百怪,排查成本远高于加一段 CRC 代码。CRC32 用查表法实现,表可以预先算好放常量区,也可以运行时生成,单片机项目里直接放表更省事:
/* CRC32 查表法,多项式 0xEDB88320,与上位机保持一致 */ static const uint32_t crc32_table[16] = { /* 表内容略,按标准多项式生成 */ }; uint32_t crc32_calc(const uint8_t *buf, uint32_t len) { uint32_t crc = 0xFFFFFFFF; for (uint32_t i = 0; i < len; i++) { crc = (crc >> 4) ^ crc32_table[(crc ^ buf[i]) & 0x0F]; /* 半字节查表,表小省空间;整字节查表更快,按 Flash 余量取舍 */ } return crc ^ 0xFFFFFFFF; }上位机和 Bootloader 必须用同一个多项式、同样的初值和结果异或,否则永远对不上。我一般让上位机在打包时把 CRC 写进包头,Bootloader 收完后对整片 App 区重新算一遍,两者一致才写升级成功标志。这里有个容易忽略的点:CRC 计算的范围要明确是"固件数据的长度",不含包头,也不含末尾可能存在的填充字节,范围定错一字节都过不了。
升级标志的落盘策略是整套方案的收尾动作。流程上分三步:收到完整固件且校验通过,先把参数区里的"升级中"标志置位并写入固件长度和 CRC;然后擦写 App 区;写完后再次计算校验,一致则把标志改成"升级成功",不一致则回退为"升级失败"并保持停留在 Bootloader。这样即使写 Flash 中途掉电,下次上电看到"升级中",Bootloader 知道 App 不可信,直接进升级模式等上位机重传,而不是傻乎乎跳过去跑半截程序。参数区那一页在写入前也要先擦,写完立刻读回比对,确认落盘成功再继续,别嫌麻烦,这一步是整个流程唯一能兜住掉电的地方。
4. 避坑与常见问题排查:六类最容易翻车的现场
这套代码在实验室跑通和装到现场稳定运行,中间隔着一堆细节。下面六条是按发生频率排的,每条都按现象、原因、解决办法写,遇到问题可以顺着对号入座。
4.1 跳转后进 HardFault 或直接死机
现象:Bootloader 里打印正常,跳转后没有任何输出,调试器一看停在了 HardFault 处理函数里,或者连 HardFault 都没进,程序停在某个未知地址。
原因基本锁定在向量表偏移上。App 编译时链接地址没改,代码还是从 0x08000000 开始;或者链接地址改了,但启动代码里没设置 SCB->VTOR,中断一触发就按 Bootloader 的向量表跳,跳到了错误的入口。还有一种情况是跳转前没关 SysTick,App 里重新配置时被旧计数干扰。
解决办法:确认三处一致——链接脚本的 Flash 起始地址、启动代码里的向量表偏移设置、Bootloader 跳转时写入的 SCB->VTOR 值,三者必须指向同一个地址。调试时可以在跳转前读一下*(uint32_t*)0x08004000和*(uint32_t*)0x08004004,前者应该是 0x2000xxxx 的栈顶,后者应该是奇数(Thumb 指令集标志位),不对就说明 App 没烧进去或者地址错了。
4.2 串口升级中途丢包,CRC 反复校验失败
现象:小固件能升成功,固件一大就失败,失败位置不固定;降低波特率后成功率上升但依然偶发。
原因多半在接收侧。一是 DMA 缓冲太小,一包没收完就被覆盖;二是擦除 Flash 期间中断被长时间屏蔽,串口数据溢出;三是上位机发得太快,Bootloader 还没处理完上一帧,下一帧已经覆盖了缓冲。
解决办法:把 DMA 缓冲放大到单包最大长度的两倍,采用"半满 + 全满"双中断,或者在空闲中断里立刻把数据拷到处理队列再重新装填;擦除操作放在主循环里做,中断里只置标志;上位机侧每发一包等应答再发下一包,用简单的停等协议换稳定性。115200 波特率下,1KB 一包的传输时间约 90ms,留 200ms 超时足够。
4.3 擦除参数区导致升级标志丢失
现象:升级开始后设备卡死在 Bootloader,重启多少次都不跳 App,重新烧录 Bootloader 才能恢复。
原因:App 区长度计算多了,擦除循环的结束地址越过了参数区起始地址,把升级标志、版本号一起擦成 0xFF。或者页大小判断错,以为 1KB 一页,实际是 2KB,擦除地址对齐到页首时把前一页参数区也带走了。
解决办法:擦除循环的结束地址严格用addr + len判断,循环条件写p < end而不是p <= end;页大小在代码里定义成宏,注释标明对应型号,换型号时只改这一处;擦之前加断言assert(end <= PARAM_AREA_ADDR),把错误挡在编译期和调试期,而不是等到了现场才炸。
4.4 升级成功但 App 跑起来外设不工作
现象:升级流程全绿,跳转也成功,App 里串口、定时器行为异常,时钟频率不对,或者外设初始化直接失败。
原因:Bootloader 用过的外设在跳转前没还原。串口还开着、DMA 通道还使能、时钟树被改过没恢复,App 按默认状态去初始化,读到的寄存器值和预期不一致。
解决办法:跳转前做一次彻底的外设复位,RCC_DeInit()恢复默认时钟,把用到的 USART、DMA、GPIO 逐个deinit,NVIC 里使能过的中断全部关掉并清挂起位。宁可多复位几行,也不要让 App 去猜上一任留下了什么。App 自己的初始化代码也按"从零开始"写,不假设任何外设状态。
4.5 上位机与 Bootloader 的 CRC 算法不一致
现象:数据长度和内容都确认无误,CRC 就是差一个固定值,或者每次都不一样。
原因:一边用 CRC16 一边用 CRC32;或者多项式相同但初值不同(0x0000 对 0xFFFF);或者结果异或那一步一边做了另一边没做;还有大小端问题,CRC 值按小端发按大端解。
解决办法:把 CRC 参数写成上位机和下位机共享的头文件,初值、多项式、结果异或值、字节序全部显式定义,两边引用同一份定义。联调时先发一段固定测试数据(比如 32 个 0x00),两边打印 CRC 结果对比,对上之后再测真实固件。这一步花十分钟,能省掉后面两小时的互相甩锅。
4.6 低概率上电卡死,复位重启又恢复
现象:绝大部分上电正常,偶尔几台设备上电后停在 Bootloader 不跳转,断电重来又好了。
原因通常是升级标志的读取时序或者 Flash 读保护配置。参数区那一页在刚上电时读取,如果之前写入过程中掉电,结构体处于半写状态,校验和不对,Bootloader 判定标志无效,走进升级等待,而等超时后跳转逻辑又因为其他地方出错没执行。还有一种可能是读保护选项字节没配,Flash 在某些批次上读出异常值。
解决办法:参数区结构体加一个魔数和一个覆盖整个结构体的校验和,读取时先校验魔数和校验和,两者任一不对就按"无升级请求"处理并强制跳 App(如果 App 合法),保证设备优先能跑起来而不是卡在 Bootloader;同时在产品测试阶段做一次"升级中途强制断电"的破坏性测试,把这种情况暴露在出厂前,而不是交给现场。
5. 验收与答辩:用一张测试矩阵把方案讲圆
毕设答辩或者方案评审时,最怕的不是被问"为什么这么做",而是被问"你怎么证明它行"。IAP 这类东西光说"我升级成功了"没有说服力,得拿出可复现的验证记录。我通常准备一张测试矩阵,把功能、边界、异常三种情况铺开,每一项都有明确的预期结果和实际结果,现场演示时挑两三项跑一遍,比放十页 PPT 管用。
| 测试项 | 操作方式 | 预期结果 |
|---|---|---|
| 正常升级 | 上位机发完整固件 | 校验通过,自动跳转 App,版本号更新 |
| 空片启动 | 擦除 App 区后上电 | 停在 Bootloader,等待握手,不跑飞 |
| 传输中断 | 传输到一半拔串口 | 升级标志保持"升级中",重启后仍在 Bootloader 等待重传 |
| 固件损坏 | 上位机故意发错 CRC 的包 | Bootloader 回 NAK,拒绝写入,App 区不变 |
| 跳转后外设 | 升级后操作串口与定时器 | 外设功能与直接烧录 App 时一致 |
| 掉电恢复 | 写 Flash 过程中断电 | 重新上电不跳转,可再次升级 |
答辩时如果被追问细节,有几个点提前想清楚:分区大小为什么这么定(App 区按业务固件实测体积乘 1.5 倍留余量)、为什么用单区而不是双区(Flash 只有 128KB,双区放不下完整业务固件,退而用"升级标志 + CRC + 掉电重传"兜底)、擦除期间为什么中断延迟可以接受(用 DMA 收数据,擦除时间几十毫秒,上位机停等协议本身就有秒级超时)。这几个问题回答得出来,说明方案是真跑过的,不是抄的。
现场演示的节奏建议这样排:先演示一次完整升级,让评审看到"设备自己就能升级"这个核心价值;再故意发一包错数据,演示 NAK 和固件不受影响;最后拔一次串口线,重新上电演示恢复流程。三步走完,方案的鲁棒性不用多解释,看的人自己就有判断。
从那以后我每次交这类嵌入式方案,都强制自己先写完测试矩阵再动手写代码,测试项想不出来的地方,往往就是设计还没想清楚的地方。这套 IAP 源码结构不复杂,但把分区、跳转、校验、断电恢复这几处收紧之后,它就不再是一个课堂作业,而是能直接装到设备里跑的东西。希望这套方案能帮到你,照着自己的板子改改参数,多半能少走几个我踩过的坑。
本文还有配套的精品资源,点击获取