☰
GD32 IAP Bootloader串口Ymodem固件升级方案详解
2026/9/27 1:01:37 网站建设 项目流程

手头有个项目要在GD32上做远程固件升级,需求一句话就能说清楚:产品已经装在现场,不能拆壳、不能连仿真器,只能靠一个串口把新固件灌进去。我最后选的方案是“GD32 IAP bootloader + 串口 + Ymodem协议”,这段时间把整个流程从头到尾跑通了,也踩了不少坑。这篇文章把我自己的实现过程、代码结构、协议细节和调试心得都整理出来,给做GD32 bootloader开发的同行一个可以直接参考的范本。

IAP(In-Application Programming)不稀奇,但真正落地时涉及的问题很多:Flash分区怎么划、bootloader和APP各自怎么改链接脚本、Ymodem协议的状态机怎么写、跳转APP时中断向量表怎么处理、烧写过程中哪些中断要关、哪些坑会导致程序直接HardFault……这些全是实践经验堆出来的东西。下面按我实际开发的顺序,把整个工程拆开讲。

1. 为什么要做IAP:从一次现场拆壳说起

1.1 现场升级的痛点:拆壳、仿真器与物理接触

我最早做单片机固件升级,用的都是最原始的方式:拿着J-Link或者ST-Link到现场,打开设备外壳,找到板子上的SWD接口,然后连上电脑烧录。听起来没毛病,但真正到了现场就发现处处难受。

工业设备往往安装在机柜深处、户外箱体里甚至高空支架上,拆壳本身可能就是个大工程。而且有些设备出厂时会打胶密封,一拆就破坏防护等级,后续防水防尘全没了。更麻烦的是,现场工程师未必有仿真器,也不一定用得明白Keil或调试软件。哪怕我远程把烧录文件发过去,对方也可能因为接触不良、接线顺序问题折腾半天。

所以我当时定了一个硬性需求:升级方式必须简单到现场工程师只会用一根USB转串口线和电脑就能完成,不需要拆壳,不需要J-Link,最好在设备上留一个外接调试串口即可。这就是IAP方案最典型的应用场景——通过MCU自身运行的bootloader程序,接收并写入新固件。固件升级从“物理接触”变成了“逻辑操作”,设备本身变成了烧录器。

1.2 ICP、ISP与IAP:三个概念别再搞混

在做GD32升级方案前,一定要先分清三个概念:ICP、ISP和IAP。我见过不少同事把这三个词混着用,结果方案设计从一开始就歪了。

ICP(In-Circuit Programming)就是通过仿真器,比如J-Link、ST-Link、CMSIS-DAP,直接连接SWD/JTAG口操作Flash。开发调试阶段最常用,但需要物理连接仿真器,不适合现场升级。

ISP(In-System Programming)一般指利用芯片出厂时固化的系统bootloader,通过串口、USB、CAN等通信接口下载程序。GD32出厂时也有一段系统bootloader,可以用官方工具通过串口烧录。但它的问题是:这段固化程序是芯片出厂时的固定版本,更新流程不可定制,比如无法做固件加密、版本校验、A/B备份等逻辑,而且每次升级都要重启进系统bootloader,流程死板。

IAP(In-Application Programming)则是用户自己在Flash里写一段bootloader程序,上电先跑bootloader,根据需要接收数据并写入APP区,写完再跳转启动APP。整个流程完全由自己控制,灵活性最高。表格对比如下:

方式物理接口是否需要仿真器升级流程可控性适用场景
ICPSWD/JTAG需要不可控,直接用工具擦写开发调试、产线烧录
ISP串口/USB等不需要使用芯片出厂固化bootloader,流程固定出厂烧录、简单现场升级
IAP串口/USB/网口/无线等不需要完全自主可控现场升级、OTA升级、批量维护

我需要的是“完全可控”,所以IAP是唯一选择。GD32内部Flash可以自写自擦,这是IAP能成立的前提,硬件上不需要做任何额外改动。

1.3 方案选型:为什么锁定串口+Ymodem

确定IAP大方向后,传输通道和协议也需要定下来。传输通道可选串口、USB、以太网、CAN、无线等。产品本身没有网络和USB需求,使用串口是最简单且最通用的选择。另一个重要原因是串口对外接线简单,很多设备本身就预留了调试串口,硬件成本几乎为零。USB转TTL模块遍地都是,现场工程师手里基本都有。

协议上,我在Xmodem和Ymodem之间犹豫了一下。Xmodem是最经典的串口文件传输协议,按128字节或1024字节分块传输,带CRC校验,简单可靠。但Xmodem的块里只有纯数据,没有文件名、文件大小这些元信息。接收端无法事先知道固件到底多大,只能盲收直到发完,这在实际工程里很被动。Ymodem在Xmodem基础上增加了块0的握手包,发送端会先发一个包含文件名和文件大小的128字节帧,接收端解析后就知道整个固件长度,能提前判断固件是否过大、空间是否不足。

而且Ymodem本身的差错控制机制很完善,收发双方通过ACK、NAK、CAN、EOT等控制字符确认每一帧的状态,发现CRC错误或帧序号异常就要求重发。这对串口这种容易受干扰的物理链路来说非常重要。选Ymodem还有一层原因:大多数串口调试工具和终端软件都原生支持,比如SecureCRT、XCOM等,现场工程师上手几乎没有学习成本。

2. 内存与启动流程:bootloader的地基

2.1 Flash分区:给bootloader和APP各划一块地

很多第一次写IAP的人最容易犯的错误,就是不理解为什么APP不能从0x08000000直接运行。这要从Cortex-M3的启动机制说起,但做工程前重要的问题是先规划Flash分区。

我的示例以GD32F103CBT6为例,它有128KB的Flash。如果使用更常见的GD32F103C8T6(64KB Flash),只需把分区按比例缩小。分区原则是:bootloader区必须足够跑完接收、校验、擦写、跳转这套升级逻辑,同时APP区要尽量大。我自己的推荐分区如下:

区域起始地址大小说明
Bootloader0x0800000032KB中断向量表、串口驱动、Ymodem协议、Flash驱动、跳转函数
APP0x0800800096KB应用程序区
参数区(可选)0x0801F8002KB存放升级标志、版本号、CRC校验值等

为什么Bootloader要32KB这么大?如果只是“串口+Ymodem+Flash擦写”,实际上16KB甚至8KB都够用。但我要在bootloader里做一些升级标志判断、版本回显、APP有效性校验,而且调试过程可能再加日志输出,多预留点空间不至于后期挤到优化代码。APP区起始地址0x08008000,注意这个地址必须向中断向量表对齐。GD32F103是Cortex-M3内核,其向量表要求按0x100或者更大的边界对齐,无论怎样,起始地址至少要保持128字节对齐,32KB对齐自然没问题。

对于C8T6这种64KB Flash的芯片,建议将Bootloader压缩到16KB(0x08000000~0x08003FFF),APP从0x08004000开始,APP区48KB。如果产品功能不多,48KB完全足够。

2.2 上电后CPU做了什么:SP、PC、VTOR

Cortex-M3的启动流程,对IAP跳转理解极其关键。芯片上电复位后,CPU并不是直接执行main函数,而是先做两件事:

  • 从Flash地址0x08000000读取初始栈顶指针(MSP的值);
  • 从Flash地址0x08000004读取复位向量(Reset_Handler的地址),然后跳转执行。

这两项数据就存放在固件的最前面,也就是中断向量表的头两个位置。工程编译出的bin文件,前8个字节固定是这两项。

所以bootloader本质上是贴着地址0x08000000摆放的一段独立固件,它自己也有一套完整的中断向量表,有自己的启动文件startup_gd32f10x_hd.s,需要编译成独立工程。

APP则不同。APP烧录地址被偏移到0x08008000,但CPU本来不知道这个事。bootloader升级完APP后,如果想要跳转执行APP,必须手动完成“读APP向量表的SP和Reset地址,然后设置新的栈顶指针和PC指针”这个过程。同时还有一个很关键的动作:要把中断向量表偏移量寄存器(VTOR)指到APP的起始地址,否则APP跑起来后,一旦发生串口中断、定时器中断,CPU会去按0x08000000处的中断向量表找中断服务函数,也就是说会跑到bootloader的向量表里,大概率直接死机或者误入莫名其妙的函数。

GD32F103的VTOR寄存器位于SCB基地址偏移0x30处,可以直接用SCB->VTOR = APP_BASE_ADDR来设置。部分GD32官方固件库提供nvic_vector_table_set这类封装函数,本质也一样,只是入口参数不同。我在实际项目中明确不依赖封装,直接操作寄存器更清楚。

2.3 GD32 Flash控制器:擦写到底要过几道关

GD32的Flash控制器叫FMC,不同型号页大小有差异。GD32F103系列的Flash页大小是1KB,也就是说擦除的最小单位是1KB。这个尺寸和Ymodem协议的大块1024字节刚好对应,一块擦一页,设计起来很顺手。GD32F30x等系列可能是2KB页,需要按2KB处理分区。

写Flash的正确流程是固定的:

  1. 解锁Flash(fmc_unlock);
  2. 清除错误标志(fmc_flag_clear);
  3. 擦除目标页(fmc_erase_page);
  4. 等待Busy位清零;
  5. 按32位字写入数据(fmc_word_program);
  6. 写完再次等待Busy位清零;
  7. 上锁Flash(fmc_lock)。

有一个很重要的问题:Flash擦除和编程操作期间,如果CPU还从Flash里取指执行,会怎样?实际上控制器内部有处理,但如果你在擦写Flash期间触发了中断,而中断服务函数也在Flash里,那中断入口的取指就会和Flash操作冲突,轻则等待总线导致中断响应延迟,严重时直接导致Flash操作失败或者程序跑飞。

所以我在bootloader的擦写函数里,做了这个处理:进入擦写前关闭全局中断,擦写完成后再打开。实际调试证明,这能明显降低偶发死机概率。

另外,每次操作前必须调用fmc_flag_clear清楚上次可能残留的错误标志,尤其是写保护错误和编程错误。如果不清理,下次操作可能直接失败。很多人升级偶尔失败,重启一下又能用,就是这个标志位没有清干净。

3. Ymodem协议:串口升级的核心基础

3.1 帧格式:SOH/STX、块号、CRC

Ymodem协议本质上是在Xmodem协议基础上扩展出来的,把传输过程分成两阶段。第一阶段用于传输文件信息(块0),第二阶段用于传输文件正文。所有数据都按“帧”组织,帧有两种大小:128字节帧和1024字节帧。

帧结构如下:

字段字节数取值/说明
帧头1SOH(0x01)表示128字节帧,STX(0x02)表示1024字节帧
块号1从0x00开始递增,到0xFF后回绕,慢速传递
块号反码10xFF减去块号,用于校验块号是否正确
数据128或1024实际有效数据,最后一块用0x1A或0x00填充
CRC高字节1CRC16校验值的高字节
CRC低字节1CRC16校验值的低字节

块0是128字节帧,数据内容格式一般为:

  • 文件名:字符串,以0x00结尾;
  • 文件大小:十进制ASCII字符串,以0x00结尾;
  • 时间戳等信息:以0x00结尾。

但不同Ymodem发送工具对块0的处理并不完全一致,有些工具只在文件名后补一个0x00,不发送文件大小。所以bootloader里解析块0时,要按“能取到文件大小就取,取不到就盲收”的方式处理,不能因为块0格式不完美就直接报错退出。收到块0后,接收端一般直接ACK,不需要对文件名做过多判断。

3.2 一次完整的Ymodem传输流程

完整流程可以分成握手、文件信息、文件数据、结束四步。

第一步,接收端(bootloader)先发送字符C(0x43),表示“我准备好了,用CRC校验方式收发,请发送文件”。

第二步,发送端收到C后,发送块0,128字节,里面带着文件名和大小。接收端校验CRC无误后,回复ACK。

第三步,接收端再次发送C,开始正文传输。发送端按1024字节分块发送数据帧。接收端每收到一帧,校验帧头、块号、块号反码、CRC,全部正确就ACK,有问题就回复NAK,发送端会重发当前帧。这个ACK机制是Ymodem可靠性的核心,串口丢字节不怕,重传能补回来。

第四步,正文发完后,发送端发EOT(0x04),接收端回复ACK。然后接收端再发C,发送端有两种可能的收尾方式:一是再发一个EOT,二是直接发送一个空块(块号0,数据全0)。我的状态机两种都处理,既兼容SecureCRT,也兼容其他工具。

我用表格把整个交互过程整理如下:

步骤接收端(Bootloader)发送端(PC端软件)
1发送C等待
2等待块0发送文件信息块0
3校验后发送ACK收到ACK
4发送C开始发送数据帧
5每帧校验并ACK发送下一帧
6等待EOT发送EOT
7发送ACK,再发送C发送EOT或空包
8结束等待结束

要特别注意,接收端在整个传输过程中要严格控制状态,不能把下一个文件的数据误认为当前文件的数据。如果实现的是多文件连续传输,空包之后还要支持继续发C等待下一个文件;我只做单固件升级,所以收到空包就直接判定传输结束。

3.3 CRC16计算:别在这里翻车

Ymodem的CRC算法是标准的CRC-16/CCITT,生成多项式是0x1021,初始值0x0000。它的计算方式和普通CRC32不太一样,是逐字节、逐位处理的,具体说就是每个字节先左移8位进入CRC寄存器,然后按位移位异或。

协议里的CRC是16位值,发送时高字节在前,低字节在后。很多初次写Ymodem的人,代码逻辑没问题,但发送接收双方CRC字节序搞反,结果每一帧都报CRC错误,然后就怀疑通信有问题。这里先记死一条:CRC字节序是高字节在前。

下面是适合放在bootloader里的标准CRC16实现:

uint16_t ymodem_crc16(const uint8_t *data, uint32_t length) { uint16_t crc = 0x0000; uint8_t i; while (length--) { crc ^= (uint16_t)(*data++) << 8; for (i = 0; i < 8; i++) { if (crc & 0x8000) crc = (crc << 1) ^ 0x1021; else crc <<= 1; } } return crc; }

这个函数对128字节和1024字节帧都适用,直接传入帧数据区和数据长度即可。CRC校验范围是从块号到数据区结束,不包含SOH/STX帧头,也不包含CRC本身。

4. Bootloader代码实现:从串口到Flash

4.1 串口接收:中断+环形缓冲区

Ymodem传输的数据量按100KB固件算,拆成大约100个1024字节块。如果使用轮询方式在接收循环里while(USART_GetFlagStatus(...) == RESET)等待每个字节,理论上也能工作,但效率很低,而且如果擦写Flash耗时较长,容易丢字节。所以我采用中断接收+环形缓冲区方案。

串口每收到一个字节就进中断,把数据压进环形缓冲区;主循环的Ymodem状态机从缓冲区里取数据解析。这样发送端连续快速发数据时,MCU即使在处理其他逻辑,也能通过中断把字节存住,不会丢。

初始化串口的代码如下,我以USART0为例,PA9是TX,PA10是RX,波特率115200,8位数据位、1位停止位、无校验:

#include "gd32f10x.h" #define UART_RING_SIZE 2048 static volatile uint8_t uart_ring[UART_RING_SIZE]; static volatile uint16_t uart_head = 0; static volatile uint16_t uart_tail = 0; static void uart_ring_push(uint8_t byte) { uint16_t next = (uart_head + 1) % UART_RING_SIZE; if (next != uart_tail) { uart_ring[uart_head] = byte; uart_head = next; } } static int uart_ring_pop(uint8_t *out) { if (uart_head == uart_tail) return 0; *out = uart_ring[uart_tail]; uart_tail = (uart_tail + 1) % UART_RING_SIZE; return 1; } void uart_init(void) { rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_USART0); gpio_init(GPIOA, GPIO_MODE_AF_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_9); gpio_init(GPIOA, GPIO_MODE_IN_FLOATING, GPIO_OSPEED_50MHZ, GPIO_PIN_10); usart_deinit(USART0); usart_baudrate_set(USART0, 115200U); usart_word_length_set(USART0, USART_WL_8BIT); usart_stop_bit_set(USART0, USART_STB_1BIT); usart_parity_config(USART0, USART_PM_NONE); usart_receive_config(USART0, USART_RECEIVE_ENABLE); usart_transmit_config(USART0, USART_TRANSMIT_ENABLE); usart_interrupt_enable(USART0, USART_INT_RBNE); nvic_irq_enable(USART0_IRQn, 2, 0); usart_enable(USART0); } void USART0_IRQHandler(void) { if (usart_interrupt_flag_get(USART0, USART_INT_FLAG_RBNE) != RESET) { uart_ring_push((uint8_t)usart_data_receive(USART0)); } }

注意GD32各系列固件库的中断标志宏命名可能略有差异,比如有的库用USART_INT_FLAG_RBNE,有的用USART_FLAG_RBNE。写的时候以你当前工程使用的固件库头文件为准,但如果你的IDE已经自动生成中断服务框架,多半不会出错。环形缓冲区大小我设置为2048字节,足够缓冲两帧1024字节数据,实际测试中即使Flash擦写过程中来数据也不会溢出。

4.2 Flash擦写封装

Flash擦写封装是bootloader里最容易踩坑的模块。我提供一个抽象层,把解锁、擦除、编程、上锁封装成两个函数:一个是整页擦除,一个是按任意长度写入。这里为方便理解,我假设数据写入按4字节对齐,写之前调用擦除函数。

#define APP_BASE_ADDR 0x08008000UL #define APP_MAX_SIZE 0x00018000UL // 96KB #define FLASH_PAGE_SIZE 1024UL void flash_erase_page(uint32_t page_addr) { fmc_unlock(); fmc_flag_clear(FMC_FLAG_END | FMC_FLAG_WPERR | FMC_FLAG_PGERR); fmc_erase_page(page_addr); while (fmc_flag_get(FMC_FLAG_BUSY) != RESET) { } fmc_lock(); } void flash_write_words(uint32_t addr, uint8_t *buf, uint32_t len) { uint32_t i = 0; uint32_t word; fmc_unlock(); while (i + 4 <= len) { word = (uint32_t)buf[i] | ((uint32_t)buf[i + 1] << 8) | ((uint32_t)buf[i + 2] << 16) | ((uint32_t)buf[i + 3] << 24); fmc_flag_clear(FMC_FLAG_END | FMC_FLAG_WPERR | FMC_FLAG_PGERR); fmc_word_program(addr + i, word); while (fmc_flag_get(FMC_FLAG_BUSY) != RESET) { } i += 4; } fmc_lock(); }

使用这个封装时要注意几个点:

  • 擦除地址必须按页对齐。如果是1KB页,地址必须能被1024整除。
  • 写入地址要落在已擦除的页范围内。Ymodem的1024字节块正好和1KB页对应,所以我在主流程中收到1024字节数据后,先擦除目标地址所在页,再写入整块数据。
  • 写Flash期间我虽然已经关闭了全局中断,但fmc_word_program执行期间,程序仍从Flash取指,这在Cortex-M3上是允许的。不过如果你执行更高级的Flash操作想从RAM里执行代码,那是另一套玩法,IAP用不到。

如果遇到GD32库函数名称或FMC标志宏与实际不完全一致,只要按同样的流程调整函数名即可。核心操作顺序不能乱:解锁、清标志、擦除、等待、编程、等待、上锁。

4.3 Ymodem状态机主循环

Ymodem接收状态的实现,是整个bootloader最核心的代码。我写了一个比较精简但功能完整的版本,主要分为几个函数:从环形缓冲区读取一个字节,带超时;接收完整一帧;解析帧头和校验;主循环里根据状态推进。

我先写一个带超时的读字节函数:

static int uart_read_byte_timeout(uint8_t *byte, uint32_t timeout_ms) { uint32_t start = systick_get(); while (systick_get() - start < timeout_ms) { if (uart_ring_pop(byte)) return 1; } return 0; }

这里使用了systick提供毫秒计数,在bootloader早期初始化时必须开启SysTick定时器。如果没有现成的systick接口,用延迟函数配合状态轮询方式也可以,只是超时判断要自己换算。

接收一帧的函数如下。它先从串口读帧头,根据帧头类型决定后续数据长度,然后依次读块号、反码、数据、CRC,最后校验。

#define PKT_INVALID (-1) #define PKT_TIMEOUT (-2) #define PKT_CANCEL (-3) static int ymodem_recv_frame(uint8_t *data_buf, uint32_t timeout_ms) { uint8_t header; uint8_t blk, blk_neg; uint8_t crc_hi, crc_lo; uint16_t crc_calc; uint32_t len, i; if (!uart_read_byte_timeout(&header, timeout_ms)) return PKT_TIMEOUT; if (header == 0x04) /* EOT */ return 0x04; if (header == 0x18) /* CAN */ return 0x18; if (header == 0x01) len = 128; else if (header == 0x02) len = 1024; else return PKT_INVALID; if (!uart_read_byte_timeout(&blk, timeout_ms)) return PKT_TIMEOUT; if (!uart_read_byte_timeout(&blk_neg, timeout_ms)) return PKT_TIMEOUT; if ((uint8_t)(blk + blk_neg) != 0xFF) return PKT_INVALID; for (i = 0; i < len; i++) { if (!uart_read_byte_timeout(&data_buf[i], timeout_ms)) return PKT_TIMEOUT; } if (!uart_read_byte_timeout(&crc_hi, timeout_ms)) return PKT_TIMEOUT; if (!uart_read_byte_timeout(&crc_lo, timeout_ms)) return PKT_TIMEOUT; crc_calc = ymodem_crc16(data_buf, len); if (((uint8_t)(crc_calc >> 8) != crc_hi) || ((uint8_t)(crc_calc & 0xFF) != crc_lo)) return PKT_INVALID; return (int)len; }

注意这个函数里我假设块0也是128字节帧,发送端会按标准发。有些工具发送1024字节帧时,最后一个不足1024字节的块会用128字节帧SOH发送,这样更省流量。所以接收端不能假设所有数据帧都是1024字节,每次要根据frame header判断。

主状态机循环代码如下。它做的事情是:先处理块0文件信息,然后循环收正文数据帧,写Flash,收EOT收尾。

int ymodem_iap_run(void) { uint8_t frame_buf[1024]; uint32_t app_size = 0; uint32_t current_addr = APP_BASE_ADDR; int ret; int is_first = 1; uart_write_byte(0x43); /* C */ ret = ymodem_recv_frame(frame_buf, 5000); if (ret <= 0) return -1; /* 块0,文件名/大小,只校验,不写Flash */ uart_write_byte(0x06); /* ACK */ uart_write_byte(0x43); /* C */ while (1) { ret = ymodem_recv_frame(frame_buf, 5000); if (ret == 0x18) /* CAN */ return -2; if (ret == 0x04) /* EOT */ { uart_write_byte(0x06); /* ACK */ /* 等待第二个EOT或空包 */ ret = ymodem_recv_frame(frame_buf, 5000); if (ret == 0x04 || ret > 0) { uart_write_byte(0x06); } return (int)app_size; } if (ret <= 0) return -3; if (is_first) { is_first = 0; } if (current_addr + ret > APP_BASE_ADDR + APP_MAX_SIZE) return -4; /* 擦除本页,写入数据 */ flash_erase_page(current_addr & ~(FLASH_PAGE_SIZE - 1)); flash_write_words(current_addr, frame_buf, (uint32_t)ret); current_addr += (uint32_t)ret; app_size += (uint32_t)ret; uart_write_byte(0x06); /* ACK */ } }

这段代码有几个细节需要说明:

  • 第一次uart_write_byte(0x43)之后,发送端可能不会立刻响应,所以ymodem_recv_frame的超时时间我给了5000ms,留足人工点击发送固件的时间。
  • 块0没有写入Flash,只是解析文件名和大小。如果愿意,这里还可以把文件名和大小提取出来,判断固件是否适配。
  • 每收到一帧正文,current_addr会直接累加。对于1024字节帧,正好提升1KB;对于最后一个不足1024的128字节帧,同样按实际长度累加。但要提醒的是,如果最后一帧用了128字节帧,且正好是某一页的最后部分,注意是否需要对页边界对齐处理。通常APP设计时将整个固件大小补齐到页边界编译,实际上不会有太大问题。
  • 如果收到CAN字符,表示发送端主动取消传输,我直接返回错误码。连续收到两个CAN在Ymodem里才是标准取消,但很多工具只发一个,我为了兼容性保留单个处理。

主循环里还应该对ACK和NAK做区分处理。当前代码在接收帧失败时会直接返回错误,更完善的实现可以在收到CRC校验失败帧时,回复NAK并等待重传同一块数据。实际Ymodem工具在发送端也会处理重传,所以你即使不实现NAK重传,脚本工具也可能重发。但既然做协议,我建议还是补上。补法很简单:把ret为PKT_INVALID的情况单独处理,发送0x15(NAK),然后继续循环等待同一帧。具体实现可参考下面这段逻辑:

if (ret == PKT_INVALID) { uart_write_byte(0x15); /* NAK,要求重发 */ continue; }

4.4 跳转到APP:那一步很多人写错

APP写完以后,bootloader的最后一个关键动作是跳转执行APP。跳转本身不难,真正容易写错的是两个点:一是没有校验栈顶指针有效性,跳到了环地址直接HardFault;二是没有先设置VTOR,导致APP中断全部错乱。

我使用下面的跳转函数:

typedef void (*app_reset_handler)(void); void jump_to_app(uint32_t app_addr) { uint32_t app_sp; uint32_t app_reset; app_reset_handler jump; app_sp = *(volatile uint32_t *)app_addr; app_reset = *(volatile uint32_t *)(app_addr + 4); /* 栈顶指针应指向RAM区域,GD32F103 RAM为0x20000000起始 */ if ((app_sp & 0x2FFE0000) != 0x20000000) { return; } /* 关闭全局中断,清理外设状态 */ __disable_irq(); SCB->VTOR = app_addr; __set_MSP(app_sp); jump = (app_reset_handler)app_reset; jump(); }

这里栈顶指针校验用的是粗粒度掩码。GD32F103CBT6的RAM是20KB,地址范围是0x20000000~0x20004FFF;如果换成更大RAM的芯片,这个掩码要调整。更通用的做法是直接在工程头文件里定义一个RAM起始和RAM大小宏,然后判断app_sp >= RAM_BASE && app_sp < RAM_BASE + RAM_SIZE。

跳转之前关全局中断,是为了避免跳转那一刻有中断请求插入,导致执行流被拉回旧中断向量表。APP启动后会在自己的SystemInit函数里重新配置时钟和中断。如果你的APP初始化特别快,没有及时重新设置VTOR,可能出现跳转成功后PC跑飞。所以我在APP工程的启动代码里也做了向量表偏移设置,下一节会说到。

4.5 APP工程的修改点

很多人写完bootloader,编译下载后却发现APP不运行,这一步往往是APP工程没有修改正确。APP工程必须做两处调整:

第一处是链接脚本/Keil工程设置。打开Keil的Options for Target -> Target页,把IROM1的起始地址改成APP在Flash中的实际地址,大小改成APP区大小。比如APP起始0x08008000,大小0x18000(96KB)。这一步决定了APP编译出的中断向量表位置、代码地址和所有绝对地址引用。

第二处是中断向量表偏移设置。GD32F103的启动文件会执行SystemInit,然后在跳转main前初始化向量表。但SystemInit默认把向量表指到0x08000000。APP工程里通常在SystemInit函数末尾,或者在main函数开始时执行:

SCB->VTOR = 0x08008000;

如果你的项目使用Keil,还有一种方式是在system_gd32f10x.c里找到类似#define VECT_TAB_OFFSET 0x0的定义,改成0x8000。这样SystemInit会在早期完成向量表重映射。我从实测经验讲,最好两处都设置,因为不同库版本的SystemInit对VTOR的处理时机不同。设置早了问题不大,设置晚了可能中断频繁的代码直接HardFault。

APP工程编译生成的bin文件,默认从0x08008000开始,前8字节就是自己的SP和Reset向量。bootloader跳转时读取地址0x08008000和0x08008004,拿到的是APP自己的向量表内容,这正好对得上。

5. 完整升级流程与实测记录

5.1 编译Bootloader并首次烧录

Bootloader本身是独立工程,需要单独编译、单独下载。首次下载我用J-Link通过SWD接口直接烧到GD32起始地址0x08000000。烧录后可以先给串口接上USB转TTL模块,上电应该能看到bootloader打印提示,比如“Bootloader v1.0, wait Ymodem…”这样的输出。我实际代码里在串口初始化后加了一行版本打印,方便确认bootloader已经活过来。

我建议在bootloader里加一个简单的升级触发方式:默认上电后,如果检测到升级标志(比如某个GPIO电平或者某个备份寄存器标志),就进入升级模式;否则如果APP区已经有有效固件,直接跳转APP。也可以做成“上电后延时500ms,如果收到0x43,也就是串口工具主动发送Ymodem的握手信号,就进入升级;否则超时后跳APP”。第二种方式现场工程师操作简单,只要在上电瞬间点一下“发送文件”,就不需要额外按按钮。

实测下来,两者结合最好用,保留一个升级触发引脚可以紧急恢复,同时也可以通过串口唤醒进入升级。

5.2 生成APP.bin:MDK中的fromelf

Keil MDK编译APP工程后,默认生成的是axf和hex文件。Ymodem传输工具通常需要bin文件,所以要在MDK中配置一个fromelf命令,自动从编译结果中生成bin文件。

在MDK的Options for Target -> User标签页,After Build/Rebuild里勾选Run #1,然后填写命令行:

fromelf --bin --output=.\Objects\APP.bin .\Objects\APP.axf

前提是fromelf命令在Keil的ARM编译器目录里可以被系统PATH找到。如果提示找不到命令,可以在前面加上完整路径,例如:

C:\Keil_v5\ARM\ARMCC\bin\fromelf.exe --bin --output=.\Objects\APP.bin .\Objects\APP.axf

如果使用AC6编译器,路径会变成C:\Keil_v5\ARM\ARMCLANG\bin\fromelf.exe。不同版本路径略有差异,找到实际路径即可。

编译完成后,会生成一个从0x08008000开始排布的APP.bin。这个bin文件就是通过Ymodem传输的固件文件。

5.3 用终端工具完成一次Ymodem升级

实测流程如下:

  1. 用USB转TTL模块连接GD32的USART0,TXD接板子的RXD,RXD接板子的TXD,GND必须共地。
  2. 打开串口工具,选择对应的COM口,波特率设置为115200,数据位8、停止位1、无校验、无流控。
  3. 给设备上电,bootloader启动后串口窗口能看到提示信息。
  4. 在工具的传输菜单里选择“发送Ymodem”或者“Ymodem”,选择APP.bin文件进行发送。
  5. 观察发送进度。传输过程中,bootloader每接收一帧,串口工具应能看到ACK信号或进度条滚动。
  6. 发送完毕后,APP自动启动。如果APP里有打印,串口窗口会出现APP的启动日志。

这里重点强调一项设置:串口工具必须关闭硬件流控。很多USB转TTL模块没有RTS/CTS引线,如果工具开启了流控,Ymodem握手会卡住,因为双方没有物理引脚进行流控信号交换。

我实测时用SecureCRT发送,大约96KB的固件,115200波特率下传输加擦写总共花了十几秒,中间没有出现错误重传。如果换成XCOM这类国产串口工具,需要在发送文件时选择Ymodem,大多数工具都支持。

5.4 如何判断升级是否真正成功

升级是否成功,不能光看串口工具上的“传输完成”。我做了三层验证:

第一层,bootloader在升级完成后,把接收到的固件总长度和预期值(从块0解析的文件大小)做比较。如果两者不等,说明传输过程数据不完整。

第二层,bootloader可对整个APP区做一次CRC校验,把计算结果和固件末尾预存的校验值比对。但这要求固件生成时把校验值放进bin里,这属于额外设计。如果不想做这么复杂,至少可以在升级完成后读取APP起始地址处的app_sp,判断是否落在合法RAM区域。

第三层,APP启动后打印自己的版本号。我在APP的main函数开头加了一句串口打印,比如APP Version 2.0 started。升级完成后看到这行输出,就可以确认跳转成功且APP运行正常。这个验证最直观,我在现场就靠这个判断是否升级成功。

每次升级前,bootloader还会把APP区前4个字节(即APP的初始栈顶指针)读出来检查,如果栈顶指针明显不合法,就认为APP区没有有效程序。这个检查能防止“空APP跳转”导致死机。

5.5 升级失败后的恢复

升级过程中如果网络抖动、串口线松动、或者突然断电,APP区可能写到一半,处于“半完整”状态。这时bootloader不能直接跳转到这个半成品APP,否则运行必然出问题。

我的处理策略是:升级完成后,先读取APP起始地址的栈顶指针,判断它是否合法。同时记录一个“APP有效标志”,放在参数区。只有在升级完成后将标志置为有效,上电才跳转。如果升级中断、标志未置位,bootloader会继续等待下一次Ymodem升级,或者等待用户重新发送固件。

这样设计能保证一个关键体验:bootloader永远不会因为升级失败而变成砖。即使APP写坏了,bootloader依然能启动,可以通过串口再次升级修复。这也是我不断向客户强调的——绝对不要让bootloader自己毁掉自己。

6. 常见问题与避坑实录

6.1 典型问题速查

我把开发调试过程中遇到的高频问题整理成了一张表,方便排查:

现象可能原因解决办法
串口发送Ymodem后无响应波特率不匹配、TXD/RXD接反、没有共地检查接线,用串口助手自发自收测试确认线路正常
升级传输过程中反复报CRC错误波特率过高、串口干扰、发送工具开了流控降到9600或115200,关闭流控,更换短线或加磁环
传输结束,APP不运行跳转时栈顶指针无效,或者向量表偏移未设置检查APP工程IROM地址和VTOR设置,确认APP bin从正确地址生成
APP运行时串口中断触发HardFault跳转前没有设置VTOR,或者APP内中断向量表偏移没生效确保跳转前设置SCB->VTOR,APP启动系统初始化时再次设置
Flash擦写偶尔失败上次操作标志位未清除,或者Flash被读保护每次操作前fmc_flag_clear,检查选项字节读保护
升级到一半卡死擦写Flash时中断未关闭,中断服务函数在Flash里执行擦写期间关全局中断,或者把擦写函数放到RAM执行
发送端提示“文件名过长”或“文件头错误”块0格式不标准接收端不强行解析文件名,只跳过块0;或换标准Ymodem工具
串口工具进度条走完了,固件大小对不上最后一帧不足1024字节时填充和帧类型处理错误检查接收端是否支持128字节帧收尾,检查块号是否回绕

6.2 我的几条底线建议

做这个项目我踩过几个印象很深的坑,总结成建议。

第一,Flash擦写期间务必关中断。这个我在2.3节提过,但值得再强调一次。GD32的Flash控制器在编程擦除时,对Flash正常读操作会产生等待,而中断服务函数一般都存放在Flash里。也就是中断确实能进,但取指会被卡住。如果中断处理逻辑比较复杂,极容易超时或者状态错乱。我的bootloader里主动把SysTick也停了,擦写完成后重新启动。升级过程本来就不是实时系统,关那几十毫秒中断完全没影响。

第二,不要迷信“官方例程”。GD32官方例程一般只演示Flash怎么写、串口怎么收发,不一定直接给你完整可用的IAP。很多例程默认烧写在0x08000000,直接拿来做APP会跟bootloader冲突。一定要把APP工程、bootloader工程、链接地址全部分开核对。

第三,Ymodem工具的选择要固定。我开发时用SecureCRT,现场测试时用XCOM,两个工具的Ymodem实现细节有差异。比如某个工具在发送完正文后直接发空包,另一个会再发一个EOT。这两种我的代码都能兼容,但如果你只按一种工具调试,交付给客户用到另一种工具,就会遇到“传输完了不退出”的情况。验收阶段最好至少用两款不同的终端软件做兼容性验证。

第四,升级时要保证供电稳定。串口升级过程中Flash擦写是电流小高峰,如果设备用劣质USB转TTL模块供电,电压跌落可能导致MCU复位。我现场遇到过几次传输到一半设备重启,后来改成外部独立供电才稳定。

第五,给bootloader保留一个简单的串口命令交互,而不是一上来就只有Ymodem被动接收。比如我做了个“输入v回车返回版本号”的功能,这样现场排查时能快速确认设备当前bootloader版本和升级状态,再决定是否升级。

6.3 关于可靠性还想多说两句

IAP项目做到最后,你发现技术难度不是最大的问题,最大的问题是如何保证“永远有救”。所以我额外做了一个A/B分区备份方案。原理很简单:把Flash划分成两个APP区,正常运行区A和备用区B。当新固件升级到A区时,如果校验失败,bootloader自动回滚到B区运行;只有新固件完整且成功运行后,才把B区也更新。计划后续把这篇内容单独整理一下,这次先讲串口Ymodem版本,是因为A/B备份涉及的分区逻辑和启动策略更复杂,如果你是第一次接触IAP,先把这篇文章中的基础版跑通再说。

我自己的体会是,IAP bootloader不是那种“照着抄一个就能完事”的模块,它跟你的产品形态、升级频率、现场维护能力都强相关。串口+Ymodem这套方案胜在通用、务实、调试可见,在没有网络的产品上是性价比非常高的选择。写完bootloader后,你再回头看那些拆壳烧录的日子,会觉得整个开发流程终于像个正经产品了。

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

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

立即咨询