简介:这是基于GD32E230平台的单片机自动升级程序完整工程包,面向嵌入式开发工程师与单片机学习者,重点解决设备固件在线更新与启动引导程序设计问题,适用于设备维护、产线批量更新及物联网节点远程升级等场景。内容覆盖启动流程、固件校验与安全机制、串口通信协议实现、闪存分区管理、升级异常恢复以及链接脚本与内存布局规划,知识点完整且可直接对应工程代码。压缩包共283个文件,大小10.8MB,以C源码、头文件、IAR工程配置、编译目标文件和可烧录bin固件为主,附带下载调试脚本、板级配置文件及烧录辅助文件,便于导入工程后边读边调。目前已有2645人学习下载。借助这套资料可快速理清固件从版本检查、下载、擦写、写入到验证的完整链路,掌握工程级Bootloader的代码组织方式,有效缩短自动升级功能的开发与调试周期。
1. 单片机自动升级程序:从“拆机烧录”到“串口一键升级”,一次说清
做过单片机产品维护的人,大多经历过这样的场景:产品已经在客户现场跑着,固件发现一个 bug,按老办法只能把设备拆回来,用烧录器擦掉重烧。而基于 GD32E230 这类 Cortex-M23 内核单片机做一套 IAP(在应用编程)自动升级程序,本质上就是把 Flash 拆成 Bootloader 区和 App 区,Bootloader 负责串口收数据、擦 Flash、写 App,然后跳转过去。这套方案最直接的价值是:现场出问题时不用拆机,一条串口线甚至一个无线透传模块就能把新固件送进去。适合刚接触 IAP 的开发者拿来当起点,也适合想快速把 Bootloader 和 App 拆开的老手作对照。
2. GD32E230的Flash分区与Bootloader工程:地址怎么切、跳转怎么写才稳
2.1 为什么必须把Flash分区,以及一个能落地的地址布局
先讲原理。Cortex-M 单片机上电后从 0x08000000(Flash 起始地址)读取栈顶指针和复位向量,这是硬件行为,绕不开。如果整份固件只有一个 App,它自然应该从 0x08000000 开始;但一旦要“先跑 Bootloader、再跳进 App”,唯一诚实的做法就是把 App 的链接地址整体平移到 Flash 高地址,而 Bootloader 留在 0x08000000。两个程序各自编译、各自链接,互不覆盖,共享同一个 Flash。
GD32E230 的 Flash 容量按具体型号从 16KB 到 64KB 不等,这里以 64KB 型号为例,给出一份我实际验证过的分区表。
| 分区 | 起始地址 | 大小 | 用途 |
|---|---|---|---|
| Bootloader | 0x08000000 | 8KB | 串口升级主程序,含向量表与 Flash 驱动 |
| App | 0x08002000 | 48KB | 业务固件,中断向量表偏移到此区域 |
| 参数区 | 0x0800E000 | 4KB | 升级标志、当前版本号、帧序号计数 |
| 预留区 | 0x0800F000 | 4KB | 预留,暂未使用,保持全 0xFF |
有一个容易踩的坑:Bootloader 不建议只分 4KB。串口收帧、CRC 校验、Flash 驱动、擦写调度全塞进 4KB,编译后代码密度稍高就溢出。我一开始图省事给同事的工程分了 4KB,结果每次加功能都要精打细算。后来把 Bootloader 定到 8KB,代码写起来才不那么憋屈。另一个原则是 App 起始地址必须页对齐。GD32E230 的 Flash 页大小通常是 2KB,0x08002000 刚好落在页边界上,后面讲擦写时你会看到,页没对齐会造成额外的擦除时间浪费。
2.2 Bootloader 主循环:升级标志检查、接收固件、跳转App
建 Bootloader 工程时,芯片型号选 GD32E230 对应型号,Flash 起始地址保持 0x08000000,大小填 0x2000(即 8KB)。串口配置用 USART0,波特率 115200,8 数据位、无校验、1 停止位。这是升级协议里上位机和下位机唯一需要达成的默认共识,如果你后面要用无线模块透传,波特率同样先固定下来。
Bootloader 的 main 逻辑不复杂,核心就是“上电先读标志,有升级请求就进升级流程,没有就直接跳 App”。下面是一份可用的主循环骨架:
#include "gd32e230.h" #define APP_ADDR (0x08002000u) #define PARAM_ADDR (0x0800E000u) #define UPGRADE_FLAG (0xA5A50001u) /* 参数区第一个字写入该值,表示待升级 */ static uint32_t read_param_flag(void); static void enter_upgrade_mode(void); static void jump_to_app(uint32_t addr); int main(void) { uint32_t flag; system_clock_config(); /* 系统时钟,GD32E230 默认内部 RC 即可 */ uart0_init(115200u); /* 串口初始化,作为升级与调试通道 */ flag = read_param_flag(); if (flag == UPGRADE_FLAG) { enter_upgrade_mode(); /* 升级主流程,不返回 */ } jump_to_app(APP_ADDR); /* 无升级请求,直接跳 App */ while (1) { /* 正常到不了这里 */ } }逻辑说明:参数区默认出厂是 0xFF,第一次上电读到的不是 UPGRADE_FLAG,Bootloader 会直接跳进 App;只有 App 侧主动写入标志并复位,Bootloader 才会进入升级模式。这种“App 请求升级,Bootloader 执行升级”的协作方式,比每次上电都等串口数据要可靠——如果现场没有上位机发送数据,Bootloader 等几秒超时再跳转,白白拖慢了开机速度。
参数说明:
- APP_ADDR 必须和分区表严格一致。Bootloader 和 App 两个工程地址稍微错一个字节,跳转后大概率直接 HardFault。
- UPGRADE_FLAG 放在参数区第一个 32 位字。读之前要先确认该地址不为空,如果 Flash 是出厂状态(全 0xFF),读出来是 0xFFFFFFFF,自然不等于 UPGRADE_FLAG,逻辑不受影响。
- enter_upgrade_mode 设计为不返回函数,内部循环收帧、写 Flash,成功后清标志再跳转;如果升级失败或超时,它也不会贸然跳 App,而是停在原地重试。
2.3 跳转函数:取栈顶、取复位向量、关中断,一个都不能少
跳转是整个 IAP 方案里最玄学也最容易翻车的环节。Cortex-M 在复位后会固定从地址 0 取栈顶指针、从地址 4 取复位向量,而当我们用函数指针跳转时,要手动模拟这个过程。
typedef void (*app_entry_t)(void); void jump_to_app(uint32_t addr) { uint32_t app_sp; app_entry_t app_entry; /* 1. 检查 App 区首字作为栈顶指针是否合法 */ app_sp = *(volatile uint32_t *)addr; if ((app_sp & 0x2FFE0000u) != 0x20000000u) { while (1) { } /* 非法栈指针,原地等待 */ } /* 2. 取出 App 复位向量 */ app_entry = (app_entry_t)(*(volatile uint32_t *)(addr + 4u)); /* 3. 关闭全局中断,避免跳转瞬间被串口或定时器打断 */ __disable_irq(); /* 4. 复位所有外设,让 App 从干净环境开始 */ rcu_all_reset(); /* 5. 设置主栈指针 MSP */ __set_MSP(app_sp); /* 6. 跳转,永不返回 */ app_entry(); }参数说明:
- 栈顶指针合法性检查是防止 App 区其实是空白(全 0xFFFFFFFF)时误跳转。GD32E230 的 SRAM 最大 8KB,地址范围约 0x20000000~0x20001FFF,用掩码 0x2FFE0000 把低 17 位掩盖后,只比对高 16 位。不同 SRAM 容量要自己调整掩码,例如 SRAM 到 0x2000FFFF 时掩码要改成 0x2FFF0000,否则地址判断形同虚设。
- 复位向量取出后,如果想再稳一点,可以加一句
if ((uint32_t)app_entry < APP_ADDR) while(1);,我在量产固件里把这种双保险写成了一行断言,防止向量表被意外破坏。 - rcu_all_reset 是 GD32 固件库里的外设时钟复位函数。跳转前把所有外设时钟复位一遍,App 启动再去重新初始化,能避免 Bootloader 里没有关干净的定时器中断把 App 冲崩。这个细节救过我一次:某次串口发送最后一字节时刚好触发跳转,App 里串口中断标志莫名其妙被置位,加上这条复位后就再没犯过。
3. 升级协议设计:帧格式、CRC16与超时重传怎么落地
3.1 帧格式与命令码定义
Bootloader 的串口是一个哑通道,上位机和 Bootloader 需要约定一套最小但完整的协议。我不建议在这个场景引入复杂协议栈,帧头、长度、CRC、帧尾四件套就够了。设计目标是:任何一个字段错了都能被接收端发现,并且明确告诉上位机“重发这一帧”。
| 字节序号 | 内容 | 说明 |
|---|---|---|
| 0~1 | 0xAA 0x55 | 帧头,固定值 |
| 2 | 命令码 | 0x01 握手 / 0x02 开始 / 0x03 数据 / 0x04 结束 |
| 3~4 | 长度 | 小端,数据区长度,最大 256 |
| 5~5+N-1 | 数据区 | 内容随命令码变化 |
| 5+N~5+N+1 | CRC16 | 多项式 0xA001,覆盖命令码+长度+数据区 |
| 5+N+2~5+N+3 | 0x0D 0x0A | 帧尾 |
CRC16 采用 Modbus 多项式 0xA001,初始值 0xFFFF。GD32E230 是 M23 内核,没有硬件 CRC 外设可直接复用,用软件查表法算就行,一帧 256 字节在 115200 波特率下完全来得及。
握手帧的命令码是 0x01,数据区可以带 4 字节的 Bootloader 版本号;开始帧 0x02 的数据区带 App 总长度和固件 CRC32;数据帧 0x03 的数据区前两个字节是帧序号,后面是最多 256 字节的固件数据;结束帧 0x04 的数据区带累计接收字节数。每一帧都必须等待 Bootloader 回 ACK 或 NACK,上位机不主动多发一帧。
3.2 Bootloader端接收状态机:超时、重发、页擦写
Bootloader 端接收不能简单“收一帧处理一帧”,因为串口中断把一帧拆成多次中断到达是很正常的事,必须用状态机缓存。状态机分为 IDLE、GOT_HEAD、GOT_LENGTH、GOT_DATA、CHECK_CRC 这几个状态,每收完一帧完整数据,才进入校验和写 Flash 流程。
#define FRAME_BUF_SIZE 256u static uint8_t rx_buf[FRAME_BUF_SIZE + 8u]; static uint16_t rx_len; static uint16_t rx_index; static uint8_t rx_state; void uart0_irq_handler(void) { uint8_t ch; if (usart_interrupt_flag_get(USART0, USART_INT_FLAG_RBNE) != RESET) { ch = usart_data_receive(USART0); switch (rx_state) { case IDLE: if (ch == 0xAA) { rx_state = GOT_HEAD_A; rx_buf[0] = ch; rx_index = 1; } break; case GOT_HEAD_A: if (ch == 0x55) { rx_state = GOT_HEAD_B; rx_buf[1] = ch; rx_index = 2; } else if (ch != 0xAA) { rx_state = IDLE; } break; case GOT_HEAD_B: rx_buf[rx_index++] = ch; /* 命令码进缓存 */ rx_state = GOT_CMD; break; case GOT_CMD: rx_buf[rx_index++] = ch; /* 长度低字节 */ rx_state = GOT_LEN_L; break; case GOT_LEN_L: rx_buf[rx_index++] = ch; /* 长度高字节 */ rx_len = rx_buf[3] | ((uint16_t)rx_buf[4] << 8); rx_state = GOT_LEN_H; break; case GOT_LEN_H: rx_buf[rx_index++] = ch; /* 数据区第一个字节 */ rx_state = GOT_DATA; break; case GOT_DATA: rx_buf[rx_index++] = ch; if (rx_index >= (uint16_t)(rx_len + 7u)) { rx_state = CHECK_CRC; } break; default: rx_state = IDLE; break; } } }逻辑说明:这个状态机把帧头 0xAA 0x55 单独筛出来,命令码、长度、数据区都按顺序收进 rx_buf。判断是否收满用rx_len + 7u,因为除了数据区还有帧头、命令码、长度、CRC、帧尾这些固定开销。收满后进入 CHECK_CRC,主循环里再做校验和处理,不要在中断里做 Flash 擦写。
擦写策略上,我采用“攒够一页再擦写”的方式。GD32E230 的 Flash 页大小是 2KB,数据帧每帧最多 256 字节,8 帧凑够一页才调用一次擦写操作。最后一页不足 2KB 也要单独擦写,因为 Flash 擦除的最小单位是页,不能只擦一部分。
3.3 上位机发送脚本:Python串口发送与ACK等待逻辑
配合 Bootloader 的最小协议,上位机我用 Python 写了一个简洁的发送脚本。它的核心逻辑是:每一帧发出去后阻塞等待 ACK,收到 NACK 或 3 秒超时则重发当前帧,重发 5 次仍失败就终止升级。
import serial import struct import time def crc16_modbus(data: bytes) -> int: crc = 0xFFFF for b in data: crc ^= b for _ in range(8): if crc & 1: crc = (crc >> 1) ^ 0xA001 else: crc >>= 1 return crc def build_frame(cmd: int, payload: bytes) -> bytes: body = bytes([cmd]) + struct.pack('<H', len(payload)) + payload crc = crc16_modbus(body) return b'\xAA\x55' + body + struct.pack('<H', crc) + b'\x0D\x0A' def send_frame(ser: serial.Serial, cmd: int, payload: bytes, retries: int = 5) -> bool: frame = build_frame(cmd, payload) for _ in range(retries): ser.write(frame) ack = ser.read(4) if ack == b'\xAA\x55\x01\x00': # 简化 ACK 帧,实际建议用完整协议解析 return True time.sleep(0.1) return False逻辑说明:CRC16 函数按字节展开计算,不需要查表,速度对这个场景够用。build_frame 把命令码、长度、数据作为 CRC 计算范围,帧头和帧尾不计入 CRC,这样 CRC 失败时能区分是数据损坏还是帧同步错位。send_frame 每发一帧就等 4 字节 ACK,超时重发,这是保证“升级过程里不会把错帧写进 Flash”的最后一道防线。
4. App端配合改造:中断向量表偏移与链接脚本的四个设置
4.1 中断向量表偏移:SCB->VTOR 与 Keil 工程 IROM 设置
App 工程如果不做任何修改,编译出来默认放在 0x08000000,会把 Bootloader 覆盖。要让 App 知道“自己住在 0x08002000”,工程侧要同时改两处地方,少一处都会出问题。
第一处是链接地址。在 Keil 的 Options for Target -> Target 页里,把 IROM1 的 Start 改为 0x08002000,Size 改为 0xC000(48KB)。如果你用的不是 Keil,而是 IAR 或 GCC,对应的链接脚本里也要把 Flash 起始地址改成同样的值。注意 IROM1 的勾选千万别去掉,去掉后编译器会按默认地址链接,烧进去直接把 Bootloader 抹掉。
第二处是中断向量表偏移。Cortex-M23 内核默认从 Flash 起始地址取中断向量,也就是说硬件复位后看到的向量表是 Bootloader 的。App 启动后必须把向量表指针搬到 0x08002000,否则任何中断都会从 Bootloader 的向量表里取入口地址,App 一开定时器就死。GD32E230 的 SCB 寄存器操作固件库有封装,我习惯直接在 App 的 main 第一行写:
#define APP_BASE 0x08002000u int main(void) { SCB->VTOR = APP_BASE; /* 第一个动作:设置中断向量表偏移 */ /* 之后的初始化:时钟、串口、定时器等 */ }注意这条赋值必须在任何外设中断使能之前完成。如果在初始化外设之后才设 VTOR,期间来一个串口接收中断,处理器会去 Bootloader 向量表找入口地址,而那个地址对应的内存里根本没有 App 的中断处理函数,执行流直接失控。这个顺序问题让我排了一下午的错,最后用调试器看 PC 指针才明白。
4.2 生成 bin 固件与 App 端主动触发升级
Bootloader 接收的是裸 bin 文件,不是 hex。Keil 默认生成 axf 和 hex,需要加一条编译后命令来自动生成 bin。在 Options for Target -> User -> After Build 里加:
fromelf.exe --bin -o "$L@L.bin" "$L@L.axf"这条命令的含义是:把当前工程的 axf 文件转换成纯二进制 bin,输出到工程目录下同名的 bin 文件。$L@L是 Keil 的宏,展开后是工程名和 axf 路径。生成后,上位机升级脚本直接读取这个 bin,按 256 字节一帧拆分发送。注意 MDK 安装路径要正确,fromelf.exe 在 Keil 安装目录的 ARM\ARMCC\bin 或 ARM\ARMCLANG\bin 下,如果编译器找不到它,点击 Build 后会报错。
App 主动进入升级模式的方式,是在收到一条“进入升级”命令后写入升级标志并软复位:
#define PARAM_BASE 0x0800E000u #define UPGRADE_FLAG 0xA5A50001u void enter_upgrade_mode(void) { uint32_t *flag_addr = (uint32_t *)PARAM_BASE; flash_unlock(); /* 解锁 Flash 写操作 */ flash_word_program(flag_addr, UPGRADE_FLAG); flash_lock(); NVIC_SystemReset(); /* 软复位,Bootloader 上电后读到标志进入升级模式 */ }这里有两个点说明一下。第一个是写 Flash 之前必须解锁,GD32 的 Flash 默认是锁定状态,直接写寄存器会被忽略或者触发错误。固件库提供了 flash_unlock 和 flash_lock 函数,写完后要马上锁回去,防止误写。第二个是为什么不在 App 里直接跳 Bootloader,而是复位让 Bootloader 判断——因为直接跳转时外设状态、中断挂起位都不干净,很容易把 Bootloader 带崩;复位是最干净的交接方式,代价只是多 100ms 左右的启动时间。
4.3 App 里实现版本号查询与升级命令响应
升级流程里需要确认“现在跑的是哪个版本”,因此在 App 的串口协议里我会加一条查询版本命令。收到 0x10 命令后,App 返回 4 字节的固件版本号,例如 V1.3 表示为 0x0103。Bootloader 升级前也可以主动问 App 版本,但这里有个时序问题:升级期间 App 没在跑,所以版本号查询实际上是在业务运行时由上位机直接对 App 串口发的,Bootloader 全程不参与版本比较。
#define CMD_QUERY_VERSION 0x10u #define CMD_ENTER_UPDATE 0x20u void app_uart_command(uint8_t cmd) { uint8_t version[4] = {0x01, 0x03, 0x00, 0x00}; switch (cmd) { case CMD_QUERY_VERSION: uart_send(version, 4u); /* 返回 V1.3 */ break; case CMD_ENTER_UPDATE: enter_upgrade_mode(); /* 置标志 + 软复位 */ break; default: break; } }逻辑说明:查询版本命令让上位机在升级前先判断“是否需要升级”,固件版本一样就不必浪费一次擦写寿命。进入升级命令则会触发上述的“写标志+复位”流程,Bootloader 接管后开始收固件。这段代码放在 App 的串口解析循环里,不影响 Bootloader 的任何逻辑。
5. 避坑排查手册:跳转死机、中断失灵与Flash擦写异常的五个现场
5.1 现象:跳转到 App 就死机,串口无任何输出
原因:最常见的两个,一是跳转前没有关闭全局中断,跳转瞬间一个外设中断进来,PC 指针跳到 App 的向量表取了一个错误入口;二是跳转时设置的 MSP 不是 App 的栈顶,App 启动后压栈写飞。
解决:跳转前必须执行__disable_irq(),并且用 rcu_all_reset 把所有外设时钟复位。MSP 必须从 App 地址的第一个字取。我自己的排查顺序是:先查 App 地址第一个字是不是 0x2000xxxx,再用调试器单步进__set_MSP看寄存器是否更新,最后确认跳转前中断是否完全关闭。
5.2 现象:App 能跑起来,但所有中断都不响应
原因:中断向量表偏移没有设置,或者设置时机太晚。App 的定时器、串口中断使能后,CPU 从 Flash 起始地址取向量,取到的还是 Bootloader 的表。
解决:把SCB->VTOR = APP_BASE;放到 App main 函数的第一行,并且用调试器在中断使能前打断点,确认 VTOR 已经被写入。如果用的是 GCC 编译器,注意启动文件里有没有默认SCB->VTOR设置,有些 startup 文件会在 C 代码之前写一次,可能把偏移改回 0,需要把启动文件里的相关代码屏蔽。
5.3 现象:Flash 擦除极慢或写不进数据
原因:GD32 的 Flash 擦写和读访问之间有等待周期要求,芯片主频跑高后如果没有配置 Flash 等待周期,擦写操作会出现超时或写入校验错误。另外擦除前没有解锁 Flash,写操作会被硬件直接忽略。
解决:系统时钟调高后记得设置 Flash 等待周期。GD32E230 固件库里有相关函数,主频 72MHz 时等待周期至少配 1~2 个。擦除和写入前先调用 flash_unlock,写完调用 flash_lock。还有一个血泪经验:不要在 Flash 擦写过程中开中断,擦到一半来一个高优先级中断会严重影响时序,严重时 Flash 写入校验失败。擦写期间要么关中断,要么等擦写完成再开中断。
5.4 现象:升级过程中断电,设备变砖再也进不了升级模式
原因:Bootloader 设计缺陷——升级标志只存在参数区,App 区被写到一半就断电,参数区标志已被清除,重启后 Bootloader 认为没有升级请求,直接跳进残缺的 App,自然跑不起来。
解决:Bootloader 里增加一个“强制升级入口”,比如检测某个 GPIO 在启动时被拉低,或者长按按键进入升级模式。Bootloader 检查顺序改成:参数区标志有效,或者强升按键按下,两者任一成立就进入升级流程。这样即使 App 区写坏了,硬件上还能强制回到 Bootloader。另一个补救是数据帧带帧序号,Bootloader 收到新帧时先比较序号,失序帧直接丢弃并发 NACK,避免乱序写入。
5.5 现象:CRC 明明对的,Bootloader 却一直回 NACK
原因:常见的有三种——上位机的 CRC16 计算范围包含了帧头(下位机没算帧头),两边长度字段大小端搞反,或者串口配置里多/少了校验位。这个现象最隐蔽,因为串口工具看起来收到的字节一样,但第一位就错位了。
解决:先写一个串口回显调试函数,Bootloader 收到什么原样回什么,上位机打印对比。再看 CRC16 的两份实现,确认计算范围和初始值一致。我的习惯是把帧头外的所有字段都纳入 CRC,帧尾不进 CRC,这样 CRC 失败还能判断是数据坏了还是帧同步错了。最后检查波特率误差,115200 在内部 RC 下一般没问题,但如果用外部晶振,误差太大也会造成偶发乱码。
6. 验证技巧:用一台电脑把“升级→跳转→回滚”完整跑通
升级功能最怕的是理论和真机脱节。我的习惯是先不接真实业务固件,而是准备一个最小测试固件:LED 按版本号规律闪烁,比如 V1.0 闪 1 次间隔 1s,V1.1 闪 2 次。这个固件编译成 bin 只有几十字节到几百字节,升级过程快,验证跳转是否成功一眼就能看出来。
验证路径分五步。第一步,把 Bootloader 通过烧录器烧到 0x08000000,测试固件烧到 App 区或者不烧;第二步,用串口助手先发握手帧,确认 Bootloader 回 ACK;第三步,运行 Python 升级脚本,把 V1.0 bin 发进去,观察 Bootloader 日志里每页擦写是否按 2KB 对齐;第四步,复位后确认 LED 闪烁规律,再发 V1.1 bin,确认升级覆盖成功;第五步,人为在脚本里模拟断电——发送 20 帧后直接关串口,再上电按住强升键,确认能再次进入升级模式。整个流程跑通,才敢上真实业务固件。
最后一个关键检查是 App 区的影子校验。升级完成后,Bootloader 不要立刻跳转,先把 App 区内容和接收到的 bin 做一次全量 CRC 对比,一致才跳转。这一条是我在做量产产品时加的,它能兜住一种极端情况:每帧 CRC 都通过了,但最后一页 Flash 写入失败而 Flash 驱动没报错,导致 App 区实际内容不完整。从那以后我每次新项目的 IAP 流程都会强制走一遍“全量校验再跳转”,哪怕测试固件小到只有 1KB 也不跳过。希望这个习惯对你有帮助,踩过的坑能少走一个是一个。
本文还有配套的精品资源,点击获取