☰
STM32G431 Bootloader实战:Ymodem串口IAP升级与Flash分区跳转详解
2026/11/5 2:22:05 网站建设 项目流程

简介:面向STM32G431平台开发者的一份IAP在线升级Bootloader工程源码,聚焦基于Ymodem协议的固件传输与Flash跳转实现,适合已具备HAL库基础、需要为产品加入串口升级能力的中级嵌入式工程师参考。压缩包共约200个文件、10.62MB,以26个c与69个h源文件构成HAL驱动与用户逻辑主体,配合Keil工程文件(uvprojx、uvoptx)、编译中间产物(o、d、crf、lst、map)以及axf、hex、sct等输出文件,另含ioc配置与少量说明文本,完整保留了从工程配置到可烧录固件的链路。已有207人学习下载。读者可从中理清Ymodem握手、分包接收、校验与Flash擦写流程,借鉴Bootloader与App分区划分、跳转前外设复位等关键处理,并借助现成工程快速搭建验证环境,减少从零移植的试错成本。

1. 一块板子刷错固件之后,我才认真看这个 Bootloader

去年调一块 STM32G431 的板子,串口烧录时手滑选错了 bin 文件,板子直接变砖,只能拆壳飞线接 SWD 救回来。那次之后我开始认真看 IAP 升级这套东西——这个项目就是一份跑在 STM32G431 上的 Bootloader 实现,走的是 Ymode 协议(YMODEM 的常见拼写变体,下文统一按 Ymodem 讲),通过串口把新固件传进单片机,由 Bootloader 负责擦写 Flash 并跳转到应用区。它解决的是产品出厂后现场升级的问题:设备装在机柜里、封在外壳中,不可能每次都拆开接调试器。适合正在做 STM32 IAP 方案、需要一份能直接改的 Bootloader 参考实现的开发者,尤其是用 G4 系列、想搞清楚 Flash 分区和跳转细节的人。

2. 项目定位与技术选型:为什么是 G431 + Ymodem

2.1 这个 Bootloader 到底承担哪几件事

把 Bootloader 拆开看,它其实只干四件事,多一件都不该干:上电后判断要不要进入升级模式、在升级模式里通过串口接收固件数据、把数据写进应用区 Flash、校验通过后跳转到应用区执行。这四件事对应四个模块:启动决策、通信协议解析、Flash 读写、跳转与向量表重定位。

启动决策这块,常见做法是上电后先读一个标志位(放在备份寄存器或者 Flash 末尾的专用扇区),如果标志位是「请求升级」就停在 Bootloader 里等串口;否则直接跳应用区。另一种做法是给一个很短的等待窗口,比如上电后 1 到 2 秒内串口收到特定握手字符就进升级,超时就跳应用。这个项目走的是后者偏多的思路,好处是不依赖应用区主动写标志,坏处是每次上电都多等一两秒。实际产品里我一般会把两种结合:正常上电快速跳转,只有应用区主动置位或者串口收到升级命令才停留。

通信协议选 Ymodem 而不是自定义协议,理由很实在:Ymodem 带文件名、文件大小和 CRC16 校验,一帧 1024 字节,传 100KB 左右的固件也就百来帧,串口 115200 下几十秒能传完;而且 PC 端现成工具多,不用自己写上位机。代价是 Bootloader 里要实现 Ymodem 的收发状态机,包括 'C' 字符握手、SOH/STX 帧头、包序号取反校验、EOT 结束、最后空帧确认这一整套。这套状态机是这个项目里代码量最大、也最容易翻车的部分。

Flash 读写这块,G431 属于 STM32G4 系列,Flash 按 2KB 一页(Page)组织,擦除必须以页为单位,写入支持双字(64 位)编程。这意味着 Bootloader 收到 1024 字节数据后不能直接写,得先攒够一页或者按双字对齐写入,而且写之前目标页必须是擦除状态。G4 的 Flash 控制器比 F1 复杂,有 Page Erase 和 Mass Erase,还有 option byte 相关的保护位,这些在移植时都要注意。

跳转这块是最容易被忽略的:Cortex-M4 的向量表默认在 0x08000000,应用区如果从 0x08008000 开始,跳转前必须把 SCB->VTOR 改成应用区的起始地址,否则中断一来就飞。跳转代码本身很短,但顺序错了就是 HardFault。

2.2 技术栈与工程组织

项目基于 STM32G431,用 STM32CubeMX 生成初始化代码、HAL 库做外设驱动,IDE 一般是 Keil MDK 或者 STM32CubeIDE,编译器 ARMCC/ARMCLANG 或 GCC 都行。HAL 库的好处是串口和 Flash 的底层寄存器操作被封装了,坏处是 Flash 编程函数在 G4 上要调HAL_FLASH_Program配合FLASH_Program_DoubleWord,擦除要调FLASH_PageErase,这些和 F1 的FLASH_Erase不一样,照抄 F1 代码会编译不过。

工程一般拆成两个独立工程:Bootloader 工程和 App 工程。Bootloader 编译出来烧到 0x08000000,App 编译时把链接脚本里的起始地址改成 0x08008000(或者别的偏移),中断向量表偏移也要在 App 的system_stm32g4xx.c里改VECT_TAB_OFFSET。两个工程共用同一套 Ymodem 解析代码和 Flash 操作代码,通常抽成一个iap目录,两边都 include。

目录结构大致是这样:

project/ ├── bootloader/ │ ├── Core/ │ │ ├── Src/main.c # 启动决策、跳转 │ │ ├── Src/stm32g4xx_it.c # 中断 │ │ └── Src/stm32g4xx_hal_msp.c │ ├── IAP/ │ │ ├── iap.c # 升级流程主控 │ │ ├── ymodem.c # Ymodem 收发状态机 │ │ └── flash_if.c # Flash 擦写封装 │ └── MDK-ARM/ 或 STM32CubeIDE/ ├── app/ │ ├── Core/ ... │ ├── IAP/ # 与 bootloader 共用 │ └── STM32G431CBTx_FLASH.ld # 链接脚本,起始地址改 0x08008000 └── tools/ └── 上位机发送脚本或说明

这个结构的关键点是IAP目录被两个工程共享,改一处两边同步。实际做的时候我建议用 git submodule 或者直接复制加注释标明同步关系,别搞成两份各改各的,后面版本对不上就是血泪经验。

2.3 Flash 分区规划

分区是这个项目的地基,定错了后面全要返工。G431CB 有 128KB Flash,典型分法是:

区域起始地址大小用途
Bootloader0x0800000032KB启动、Ymodem、Flash 操作
应用区0x0800800088KB用户 App
参数/标志区0x0801E0008KB升级标志、版本号、CRC

Bootloader 给 32KB 是因为 Ymodem 状态机加 HAL 库编译出来通常 20KB 上下,留点余量。应用区 88KB 对大多数控制类应用够用,如果 App 更大就把 Bootloader 压到 16KB 或者换更大 Flash 的型号。参数区放最后一页,专门存升级请求标志和固件 CRC,这样应用区可以整片擦除而不影响标志。

提示:分区边界必须按页对齐,G431 一页 2KB,0x08008000 是 16KB 偏移,正好 8 页,对齐没问题。如果自己改成 0x08007000 这种非页对齐地址,擦除时会连带擦掉前一页。

3. 核心模块拆解:从串口收包到跳转执行

3.1 启动决策与跳转:VTOR 和 MSP 两件事不能错

先看main.c里的启动逻辑,这是整个 Bootloader 的入口:

/* bootloader/Core/Src/main.c */ int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); /* 读参数区标志,判断是否需要升级 */ uint32_t upgrade_flag = *(volatile uint32_t *)FLAG_ADDR; if (upgrade_flag == UPGRADE_MAGIC) { /* 清除标志,防止反复进升级 */ flash_if_erase_page(FLAG_PAGE); iap_run(); /* 进入 Ymodem 接收循环,不返回 */ } /* 正常启动:检查应用区是否有效再跳 */ if (app_is_valid()) { jump_to_app(APP_ADDR); } /* 应用区无效,兜底进升级 */ iap_run(); while (1) {} }

FLAG_ADDR是参数区地址,UPGRADE_MAGIC是个约定值比如0x5A5A5A5A。应用区在收到升级命令后往这个地址写 magic,然后软复位,Bootloader 上电读到就进升级。app_is_valid()一般检查应用区起始的栈顶值是否落在 RAM 范围内(0x20000000 到 0x20008000 之间),不合法说明应用区是空的或者被擦过,直接进升级。

跳转函数是重点:

/* bootloader/IAP/iap.c */ typedef void (*pFunction)(void); void jump_to_app(uint32_t app_addr) { uint32_t stack_top = *(volatile uint32_t *)app_addr; uint32_t reset_vec = *(volatile uint32_t *)(app_addr + 4); pFunction app_entry = (pFunction)reset_vec; /* 关中断,避免跳转过程中有中断进来 */ __disable_irq(); /* 重定位向量表到应用区 */ SCB->VTOR = app_addr; /* 设置主栈指针 */ __set_MSP(stack_top); /* 跳转 */ app_entry(); }

逻辑说明:app_addr处前两个字分别是初始栈顶和复位向量,这是 Cortex-M 的约定。SCB->VTOR必须在跳转前改,否则应用区里的中断服务函数地址还是按 Bootloader 的向量表找,一进中断就跑到 Bootloader 的 handler 里。__set_MSP把主栈指针切到应用区的栈顶,这一步很多人漏掉,结果应用区一用栈就踩到 Bootloader 的栈空间。__disable_irq()是保险,跳转前把中断关了,应用区启动后会自己重新开。

参数说明:app_addr就是分区表里的应用区起始地址,改分区时这里和链接脚本要同步改。stack_top和reset_vec用volatile读,防止编译器优化成缓存值。

3.2 Ymodem 接收状态机:'C' 握手和包序号校验

Ymodem 接收的核心是一个按字节驱动的状态机,串口每收到一个字节就喂进去。简化后的主循环长这样:

/* bootloader/IAP/ymodem.c */ int ymodem_receive(uint8_t *buf, uint32_t max_len, uint32_t *out_len) { uint8_t packet[PACKET_SIZE]; uint32_t file_size = 0; uint32_t received = 0; uint8_t seq = 0; /* 阶段一:发 'C' 请求发送方开始 */ uart_send_byte('C'); while (1) { int len = uart_receive_packet(packet, TIMEOUT_MS); if (len < 0) { uart_send_byte(NAK); /* 超时或校验错,请求重发 */ continue; } uint8_t pkt_seq = packet[1]; uint8_t pkt_seq_inv = packet[2]; /* 包序号和反码必须互补 */ if ((uint8_t)(pkt_seq + pkt_seq_inv) != 0xFF) { uart_send_byte(NAK); continue; } if (pkt_seq == 0) { /* 第 0 帧是文件信息帧,解析文件名和大小 */ file_size = parse_file_size(&packet[3]); uart_send_byte(ACK); uart_send_byte('C'); /* 请求第一帧数据 */ seq = 1; continue; } if (pkt_seq == seq) { uint32_t copy_len = (len > (int)(max_len - received)) ? (max_len - received) : len; memcpy(buf + received, &packet[3], copy_len); received += copy_len; uart_send_byte(ACK); seq++; } else if (pkt_seq == (uint8_t)(seq - 1)) { /* 重复帧,说明上一个 ACK 丢了,重发 ACK 不重复写 */ uart_send_byte(ACK); } else { uart_send_byte(NAK); } if (received >= file_size) { break; } } /* 等 EOT,回 ACK,再等空帧确认 */ uart_send_byte(ACK); *out_len = received; return 0; }

逻辑说明:Ymodem 的帧结构是SOH(0x01)/STX(0x02) + 包序号 + 序号反码 + 数据 + CRC16,SOH 表示 128 字节数据,STX 表示 1024 字节。第 0 帧特殊,数据区是「文件名\0文件大小\0」的字符串,解析出文件大小后才知道什么时候收完。包序号从 1 开始递增,接收方校验seq + seq_inv == 0xFF,不满足就 NAK 让发送方重发。

参数说明:PACKET_SIZE按 STX 取 1027(1 字节头 + 2 字节序号 + 1024 数据),TIMEOUT_MS一般 1000 到 3000,太短容易在慢速串口上误判超时。max_len是应用区能容纳的最大字节数,防止发送方传超大文件把 Flash 写穿。

这里有个容易翻车的点:重复帧处理。如果接收方发了 ACK 但发送方没收到,发送方会重发同一帧,接收方如果直接再写一遍,数据就错位了。正确做法是识别出pkt_seq == seq - 1时只重发 ACK,不写数据。这个逻辑不写,传大文件时偶发数据错乱,而且很难复现。

3.3 Flash 擦写封装:按页擦、按双字写

G431 的 Flash 操作和 F1 差别不小,封装一层很有必要:

/* bootloader/IAP/flash_if.c */ int flash_if_write(uint32_t addr, const uint8_t *data, uint32_t len) { HAL_FLASH_Unlock(); /* 先擦除覆盖到的所有页 */ uint32_t page_start = addr & ~(FLASH_PAGE_SIZE - 1); uint32_t page_end = (addr + len - 1) & ~(FLASH_PAGE_SIZE - 1); for (uint32_t p = page_start; p <= page_end; p += FLASH_PAGE_SIZE) { FLASH_EraseInitTypeDef erase = {0}; uint32_t page_error = 0; erase.TypeErase = FLASH_TYPEERASE_PAGES; erase.Page = (p - FLASH_BASE) / FLASH_PAGE_SIZE; erase.NbPages = 1; if (HAL_FLASHEx_Erase(&erase, &page_error) != HAL_OK) { HAL_FLASH_Lock(); return -1; } } /* 按双字写入,不足 8 字节补 0xFF */ uint64_t dword; for (uint32_t i = 0; i < len; i += 8) { uint32_t chunk = (len - i >= 8) ? 8 : (len - i); dword = 0xFFFFFFFFFFFFFFFFULL; memcpy(&dword, data + i, chunk); if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_DOUBLEWORD, addr + i, dword) != HAL_OK) { HAL_FLASH_Lock(); return -2; } } HAL_FLASH_Lock(); return 0; }

逻辑说明:擦除以页为单位,FLASH_PAGE_SIZE在 G431 上是 2048。erase.Page是页号不是地址,要减FLASH_BASE再除页大小。写入用双字编程,最后不足 8 字节的部分补 0xFF 凑齐,因为 G4 不支持单字节编程。每次写之前目标区域必须已擦除,否则HAL_FLASH_Program返回错误。

参数说明:addr必须是 8 字节对齐,len任意但内部按 8 字节向上取整处理。FLASH_BASE是 0x08000000。擦除失败常见原因是目标页被写保护(option byte 里的 WRP),或者地址越界。

注意:擦除整页会丢掉该页原有数据,所以升级流程里不能边收边擦同一页。常见做法是收到一页数据(2KB,即两帧 1024)就擦一页写一页,或者先全部收到 RAM 再统一写——但 G431 只有 32KB RAM,大固件必须边收边写。

4. 跑起来:从零编译到串口升级的完整步骤

4.1 环境准备与两个工程的编译

先装 STM32CubeIDE 或者 Keil MDK 加 G4 的器件包,再装一个串口工具,SecureCRT、Xshell、或者开源的 Ymodem 发送工具都行,关键是支持 Ymodem 协议发送文件。硬件上需要一块 G431 的开发板或者目标板,USB 转串口接到 USART1(PA9/PA10 或者按自己板子的引脚)。

Bootloader 工程的编译步骤:用 CubeMX 打开工程配置,确认 USART1 波特率设成 115200、8N1,Flash 起始地址保持 0x08000000,生成代码后编译,应该能出 bin 和 hex。App 工程要改两处:链接脚本里FLASH (rx) : ORIGIN = 0x08008000, LENGTH = 88K,以及system_stm32g4xx.c里VECT_TAB_OFFSET改成0x8000。这两处不改,App 烧进去也跑不起来,因为中断向量还指向 0x08000000。

编译产物处理:Bootloader 的 bin 直接烧到 0x08000000,App 的 bin 留着给串口升级用。烧 Bootloader 用 ST-Link 或者 CubeProgrammer,一次烧好后面就不用再接调试器了。

4.2 串口升级实操

上电前先把串口工具连好,波特率 115200。给板子上电或者按复位,如果参数区有升级标志,Bootloader 会发 'C' 字符出来——串口工具里能看到一个 'C',说明进了升级模式。这时候在串口工具里选「发送文件」,协议选 Ymodem,选中 App 的 bin 文件,开始发送。

传输过程中能看到包序号递增,传完后 Bootloader 会校验并跳转。如果串口工具不支持 Ymodem,可以用命令行工具,Linux 下sb命令(lrzsz 包里的)就是发 Ymodem:

# 串口设备按实际改,/dev/ttyUSB0 或 COM3 stty -F /dev/ttyUSB0 115200 cs8 -cstopb -parenb sb --ymodem app.bin < /dev/ttyUSB0 > /dev/ttyUSB0

Windows 下用 SecureCRT 的 Transfer -> Send Ymodem 更省事。发送前确认 App 的 bin 大小没超过应用区容量,超了 Bootloader 会在收满max_len后停止,后面的数据丢掉,跳转后应用区不完整,表现为 HardFault 或者跑飞。

升级完成后板子会自动复位跳转,如果没跳,检查app_is_valid()是不是把合法应用判成了非法——常见原因是 App 的栈顶值不在 RAM 范围,比如链接脚本里 RAM 起始写错了。

4.3 验证升级是否真的成功

光看串口传完不算数,要验证三件事:应用区起始地址的前两个字是不是合法的栈顶和复位向量、应用能不能正常跑、复位后还能不能正常启动。第一件事可以用 CubeProgrammer 连上看 0x08008000 处的内存,正常应该是0x2000xxxx开头。第二件事看应用的功能是否正常,比如点个灯、串口打印版本号。第三件事按复位键,看是不是直接进应用而不是又进升级——如果又进升级,说明升级标志没清干净,检查flash_if_erase_page(FLAG_PAGE)有没有执行到。

我一般还会在应用区里加一个「请求升级」的命令,比如串口收到特定字符串就往参数区写 magic 然后软复位,这样现场升级不用拆机也不用按复位键,整套流程闭环。

5. 避坑与排查:五个真实踩过的坑

5.1 跳转后一进中断就 HardFault

现象:应用区能跑主循环,但串口中断或者定时器中断一来就死机。原因:SCB->VTOR没改,或者改了但应用区的向量表没按偏移链接。解决:确认跳转前SCB->VTOR = APP_ADDR,同时检查 App 工程的VECT_TAB_OFFSET和链接脚本起始地址一致。两个地方差一个字节都会出问题。

5.2 Ymodem 传大文件到一半卡死

现象:小文件能传完,超过几十 KB 就停在某个包不动。原因:接收方处理慢,发送方超时重发,而接收方的重复帧处理逻辑有 bug,把重发的帧当新帧写,导致序号错乱。解决:按 3.2 里的逻辑,识别pkt_seq == seq - 1时只回 ACK 不写数据;同时把接收超时从 1 秒放宽到 3 秒,给 Flash 擦除留时间——擦一页 2KB 在 G431 上要几毫秒到十几毫秒,期间串口数据会丢,最好用 DMA 接收加环形缓冲。

5.3 擦除时把 Bootloader 自己擦了

现象:升级完复位,板子彻底不启动,连串口都没反应。原因:应用区起始地址算错,或者flash_if_write里页号计算把 Bootloader 所在的页也纳入了擦除范围。解决:在flash_if_write入口加地址范围断言,addr >= APP_ADDR && addr + len <= FLAG_ADDR,不满足直接返回错误。这个断言我后来每个项目都加,属于后悔药级别的防护。

5.4 应用区校验通过但跳转后跑飞

现象:app_is_valid()返回真,跳转也执行了,但应用不工作。原因:App 编译时用了-O2优化,链接脚本里没保留向量表段,或者启动文件里的Reset_Handler地址被优化偏移。解决:链接脚本里加KEEP(*(.isr_vector)),确保向量表固定在应用区起始;编译选项里别把向量表段优化掉。

5.5 串口工具发完文件但 Bootloader 不跳转

现象:串口显示传输完成,但板子停在 Bootloader 不动。原因:Ymodem 结束阶段没处理完,发送方发完 EOT 后等接收方 ACK,接收方如果没回或者回了 NAK,发送方会重发 EOT,循环卡住。解决:检查ymodem_receive里 EOT 处理,收到 EOT 回 ACK 后还要等一个空帧(第 0 帧,文件名为空)再回 ACK,两步都完成才算结束。有些上位机不发最后空帧,那就得在超时后主动结束,别死等。

6. 二次开发:把这份 Bootloader 改成自己的升级方案

拿到这份代码之后,最值得改的三个方向是升级触发方式、传输通道和固件校验。触发方式上,我一般会把「上电等 1 秒」改成「应用区主动置标志 + 上电快速跳转」,产品正常启动零等待,只有应用区发命令才进升级。具体做法是在参数区留一个 32 位 magic,应用区收到升级命令后写 magic 再调NVIC_SystemReset(),Bootloader 上电读到就进升级,读完立刻擦掉,避免下次上电又进。

传输通道上,Ymodem 走串口适合调试和现场,但如果产品有 USB 或者 CAN,可以保留 Ymodem 的帧解析逻辑,只把底层收发换成 USB CDC 或者 CAN 帧拼包。Ymodem 的状态机是字节流驱动的,换通道只要实现uart_receive_packet和uart_send_byte两个函数,上层不用动。这个解耦设计是这个项目里最值得保留的部分。

固件校验上,现在只靠 Ymodem 的 CRC16 逐帧校验,整包没有校验。我建议在参数区存一个固件 CRC32,升级完成后对整个应用区算一遍 CRC32 和存储值比对,不一致就标记应用区无效、下次上电进升级。CRC32 用查表法实现,几十行代码,算 88KB 也就几毫秒。验证方法很简单:故意改 bin 里一个字节再传,看 Bootloader 能不能拒绝。

还有一个技巧是版本回滚。参数区留两个槽位存「当前版本」和「上一版本」的 CRC 和长度,升级前把当前版本信息备份到上一版本槽,新固件校验失败就回滚到上一版本——前提是应用区能放下两份固件,放不下就只能保留上一版本的 CRC 用于判断,回滚靠重新传旧 bin。这个取舍看产品 Flash 余量。

从那以后我每次改 Flash 分区或者跳转逻辑,都强制先用 CubeProgrammer 读一遍 0x08008000 处的内存确认向量表,再上串口升级,最后按三次复位验证稳定性。这套流程走下来,变砖的概率基本为零。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询