简介:一套适用于STM32G431系列微控制器的IAP在线升级Bootloader完整方案,借助Ymodem协议完成固件传输、校验与烧写,可帮助嵌入式开发者快速掌握基于串口的远程升级技术。源码由CubeMX初始化工程起步,通过Keil可直接编译,覆盖串口中断接收、Flash分区管理、Bootloader与App跳转等关键环节。压缩包共200个文件,以C源文件与头文件为主,并包含Keil工程配置、CubeMX引脚配置、分散加载文件、映射文件及Hex固件,完整覆盖从代码编译到最终下载的各个阶段,整体包体大小为10.62MB。目前已有202人学习下载,被较多开发初学者与工程师参考。借助这套工程,既能获取可直接烧录验证的引导程序,又能结合源码和编译产物理解Ymodem协议中的帧格式、超时重传、结束判定等重要细节,为后续开发上位机升级工具或扩展网络OTA功能提供扎实基础。 做过串口IAP升级的朋友都有体会,bootloader这活儿看着简单,真做起来全是细节。这次的项目是在STM32G431单片机上实现基于Ymodem协议的IAP代码升级bootloader,芯片资源不多但性能足够,M4内核跑170MHz,专门划出一段Flash给bootloader,剩下的空间跑应用固件。整个过程从协议设计、状态机编写、Flash驱动、跳转逻辑到上位机联调,前前后后折腾了几天,踩了不少坑。这篇就把整个实现思路和排查经验记下来,适合正在做STM32系列IAP、想了解Ymodem协议落地细节的开发者参考。
1. 项目背景与IAP整体架构
1.1 为什么选STM32G431配合Ymodem
STM32G431这颗芯片在电机控制、数字电源领域很常见,主频最高到170MHz,带FPU,Flash容量根据型号从64KB到128KB不等,SRAM最大32KB。做IAP升级完全够用,难点不在于性能,而在于把Bootloader和APP的分区、跳转、协议处理理清楚。
IAP升级方案里,串口+Ymodem是属于成熟可靠的那种。串口简单,不用额外芯片,一根线就能搞定;Ymodem协议又是Xmodem的升级版,多了文件名、文件大小、批量传输的能力,尤其适合有固定大小固件包的场景。实际用下来,Ymodem比纯Xmodem强在“带文件名和长度”,Bootloader拿到长度后可以直接算出Flash要擦多少页,省掉反复尝试的麻烦。这次选定方案就是:上位机用支持Ymodem的串口工具发送.bin固件,Bootloader接收并写入Flash,完成后软跳转到APP。
1.2 Flash地址分区与Bootloader/APP规划
以STM32G431RBT6为例,Flash一共128KB,按2KB一页划分。分区需要综合考虑Bootloader大小、APP大小、升级标志存储位置。我用的方案是:
- Bootloader区:0x08000000 ~ 0x08003FFF,共16KB,够放Ymodem协议处理、Flash驱动和串口驱动。
- APP区:0x08004000 ~ 0x0801FFFF,共112KB,留给应用固件。
- 升级标志区:0x0801F800 ~ 0x0801FFFF,最后2KB,专门存升级请求标志。已经有了一个大体的分区规划,实际操作用起来很清楚:Bootloader启动后先检查标志,没标志就直接跳APP,有标志就进入Ymodem接收流程。这样设计的好处是APP崩溃了也能强制进入Bootloader,不至于变砖。当然,如果项目简单,也可以直接用按键或者跳线帽触发。我用标志位是因为升级流程想要更自动。
2. Ymodem协议原理与关键帧格式
2.1 握手、数据包与结束流程
Ymodem协议初看有点绕,但拆开其实就是一套“握手-传数据-结束”的流程。接收端(Bootloader)上电后先发一个字节C(0x43,表示支持CRC校验),发送端收到这个C才开始发送第一个块。第一个块不是程序数据,而是文件名包:帧头SOH、块号为0、块号反码、文件名+文件大小,后面补齐0。接收端收到后回ACK,再发一个C,发送端才开始发真正的数据块。
数据块分两种帧:SOH(0x01)携带128字节数据,STX(0x02)携带1024字节数据。帧格式都是:帧头 + 块号 + 块号反码 + 数据 + CRC16高字节 + CRC16低字节。块号从1开始循环0~255。接收端收到一块后校验CRC,正确则回ACK,错误回NAK,要重传。数据全部传完后,发送端发EOT(0x04),接收端先回ACK,再发一个C,发送端发一个空的结束块(块号0,反码FF),接收端再回ACK,整个传输结束。这个看似多余的“第二个C”一定要处理,否则发送端不会结束,双方会干等。
2.2 CRC校验与超时处理机制
Ymodem用的是CRC16-XMODEM算法,多项式0x1021,初始值为0,逐字节计算。和Modbus CRC的初始值、反射模式都不一样,不能直接套用。我写Bootloader时专门提取了一个纯软件CRC16函数:
uint16_t ymodem_crc16(uint8_t *data, uint32_t len) { uint16_t crc = 0x0000; for (uint32_t i = 0; i < len; i++) { crc ^= (uint16_t)data[i] << 8; for (uint8_t j = 0; j < 8; j++) { if (crc & 0x8000) crc = (crc << 1) ^ 0x1021; else crc <<= 1; } } return crc; }单位类型的CRC算法实现,我每次都习惯先用一个固定数据算一遍,和Windows上的Ymodem工具比对结果一致后再往下走。CRC不对的帧一定要回NAK,连续多次错误就可以考虑终止。超时也很关键,发送端在上电后主动等C,如果等不到,它会一直重试。Bootloader这边也要有超时机制,比如串口接收超时120秒没数据,就自动跳转APP,避免升级失败后卡在Bootloader里。
3. Bootloader端实现要点
3.1 串口接收与状态机设计
Ymodem的接收端其实就是一个状态机,核心是“当前在等什么、下个字节怎么处理”。我用了一个枚举来表示当前状态:
typedef enum { STATE_WAIT_SOH, // 等待文件名包或数据包包头 STATE_WAIT_SEQ, // 等待块号 STATE_WAIT_COMP_SEQ, // 等待块号反码 STATE_WAIT_DATA, // 等待数据内容 STATE_WAIT_CRC_H, // 等待CRC高字节 STATE_WAIT_CRC_L, // 等待CRC低字节 STATE_WAIT_EOT, // 等待文件传输结束 STATE_FINISHED } ymodem_state_t;这种状态机的写法比一坨中断式判断要清晰太多。串口中断只做一件事:把收到的字节丢进环形缓冲区。主循环里从缓冲区取字节,推进状态机,同时做一轮数据处理。这样就不会出现“串口中断里做Flash擦写”这种自杀式操作。擦写Flash时间很长,绝对不能放在中断里,否则整个系统响应就瘫痪了。
每次收到完整一帧后,先验证块号反码,再算CRC,都通过才写入Flash。需要注意Ymodem的CRC是大端发送,CRC高字节在前,低字节在后,不要搞反。另外文件名包里包含文件名、文件大小这些字段,记得只关注大小,文件名可以直接丢弃或者存到日志里。
3.2 Flash擦除与写入操作
STM32G431的Flash按页擦除,一页2KB。擦除之前,先根据文件名包里解析出来的文件长度算好需要擦几页,然后一次把所有页擦完。也可以用边收边擦的方式,但为了逻辑简单,我先擦再写:
FLASH_EraseInitTypeDef erase; erase.TypeErase = FLASH_TYPEERASE_PAGES; erase.PageIndex = APP_FLASH_PAGE_START; // 例如第8页对应0x08004000 erase.NbPages = page_count; uint32_t error = 0; HAL_FLASH_Unlock(); HAL_FLASHEx_Erase(&erase, &error); HAL_FLASH_Lock();写入时有一个非常重要的问题:G431必须以双字(64位)为单位编程。也就是说,写入的数据缓冲区地址和写入地址都要8字节对齐。我从Ymodem数据包里拿到的是128字节的数组,不能直接拿这个数组去写Flash,要先拷贝到一个64位对齐的缓冲区,再转成uint64_t的数组依次写入:
__ALIGN_BEGIN uint64_t aligned_buffer[16] __ALIGN_END; memcpy(aligned_buffer, data_buffer, 128); HAL_FLASH_Unlock(); for (int i = 0; i < 16; i++) { HAL_FLASH_Program(FLASH_TYPEPROGRAM_DOUBLEWORD, APP_Flash_Addr + i * 8, aligned_buffer[i]); } HAL_FLASH_Lock();我一开始图省事直接拿uint8_t buffer[128]去调用HAL_FLASH_Program,结果就是HardFault,查了半天才发现是地址对齐问题。所以这块必须单独处理,别偷懒。
3.3 跳转APP的正确姿势与中断向量偏移
传输完成后,真正的考验到了:跳转。最核心的一件事是APP的栈顶指针和复位向量必须从APP区头部读取。跳转函数:
typedef void (*pFunction)(void); void jump_to_app(uint32_t app_addr) { uint32_t msp = *(volatile uint32_t *)app_addr; uint32_t reset = *(volatile uint32_t *)(app_addr + 4); if ((msp & 0xFFF00000) == 0x20000000) { // 简单校验栈指针范围 __disable_irq(); SysTick->CTRL = 0; HAL_RCC_DeInit(); __set_MSP(msp); pFunction jump = (pFunction)reset; jump(); } }跳转之前把SysTick关掉,把HAL的RCC复位到默认状态,非常重要。如果Bootloader里开了串口中断、定时器中断,跳转前要全部禁用,否则APP还没初始化完成,中断就进来了,直接卡死。我建议跳转前先__disable_irq(),让APP运行后在main函数里自己__enable_irq()。
还有一个坑就是APP工程的“中断向量表偏移”。STM32G431默认的向量表在0x08000000,如果APP编译出来不做任何修改,它仍然认为自己从0地址运行,所有外设中断都会跳到错误的地址。需要在APP工程里设置偏移。使用HAL库,可以直接在main函数最前面加一句:
SCB->VTOR = FLASH_BASE | 0x4000;或者修改system_stm32g4xx.c里的VECT_TAB_OFFSET为0x4000。如果漏了这一步,现象就是“APP能跑,但一进中断就飞”,具体到HAL_Delay,表现为卡死在SysTick_Handler里,因为SysTick中断进不去或者中断向量错乱。这也是热词里“iap跳转后卡死hal_delay”的常见原因,后面排查部分再细说。
4. 上位机工具与一键升级实操
4.1 常用Ymodem发送工具和参数配置
Ymodem是半双工的,用串口工具就能搞定。我试过几款,推荐以下两个:
- SecureCRT:老牌终端工具,自带Ymodem、Zmodem协议支持,稳定,缺点是收费。
- Tera Term:开源免费,也支持Ymodem,用起来顺手,win10/win11都能跑。
PC端参数设置:波特率建议115200或460800,数据位8,无校验,停止位1,禁流控。固件格式强烈建议用.bin。.hex虽然带了地址信息,但很多上位机会把Ymodem文件包里的路径名和hex地址一起处理,容易引发解析混乱;.bin干净利落,Bootloader只需要按顺序写地址就行。
4.2 完整升级流程演示
以下是我在项目中用的实际升级步骤:
- 编译APP工程,生成.bin文件(STM32CubeIDE里勾选“Create binary file”即可)。
- 让板子进入Bootloader。我用的是Flash标志位方式,也可以用按键检测方式,灵活选择。
- Bootloader完成预烧写检查后,串口打印提示,并在接收任务中发出
C。 - 在SecureCRT里打开对应串口,选择“传输”-“发送Ymodem”,选中编译生成的.bin。
- 等待进度条走完,程序自动跳转到APP。
实操中有个小细节:Bootloader上电后如果检测到有效APP,而升级标志又没设置,那就不要发C等太久,应该立刻跳转APP,否则现场设备上电后会一直卡等待状态,直接变成“砖”。我在Bootloader里设了一个短暂等待窗口,比如500ms内没有收到C馈或用户按升级键,就跳转到APP,这样可以保证正常设备上电秒起。
5. 常见问题与排查实录
5.1 跳转后卡死、HAL_Delay异常的根因排查
这是做IAP最容易踩的坑,没有之一。跳转后卡死一般分三种情况:
- 第一种:APP向量表偏移没设置。APP中断函数全部失效,SysTick触发后跳转到错误地址,
HAL_Delay死等。解决办法就是设置SCB->VTOR,或者设置VECT_TAB_OFFSET。 - 第二种:跳转前外设中断没清干净。Bootloader里用了串口中断,跳转到APP后,APP重新初始化外设之前,如果有多余的中断请求挂起,NVIC会立刻进中断,但向量表还没准备好,直接卡死。所以我跳转前会关总中断、停SysTick,必要时调用
NVIC_SystemReset()做一个彻底复位再跳。但这里要注意,用NVIC_SystemReset()复位后,Bootloader会重新跑一遍,需要再判断标志位是否允许跳APP,不然会死循环。 - 第三种:栈顶指针被清掉或者指向异常地址。跳转前一定要用
*(volatile uint32_t *)app_addr读取并校验栈指针,非法就直接停住,别盲目跳。
我把排查步骤归纳成一个表格,按照顺序检查即可:
| 现象 | 可能原因 | 排查方式 |
|---|---|---|
| 跳转后死机,无任何输出 | APP中的中断向量偏移未设置 | 检查SCB->VTOR或VECT_TAB_OFFSET |
跳转后停在HAL_Delay | SysTick中断异常或向量表偏移不对 | 设置偏移并检查HAL_Init调用位置 |
| 跳转后外设中断风暴 | Bootloader中断没关干净 | 跳转前__disable_irq()、关闭外设 |
| 跳转后偶发复跳回Bootloader | 栈顶指针错误或复位向量被优化 | 确认Reset_Handler地址是否为APP地址+4 |
| 传输完成但APP无反应 | 写入地址与编译链接地址不一致 | 核对.sct或link.ld中的FLASH起始地址 |
5.2 Ymodem传输失败与CRC错误
如果你的Ymodem老是返回NAK,或者传到一半挂起,优先检查Bootloader字节接收和状态机是否有bug。我的经验是:不要用HAL_UART_Receive阻塞接收,要用“中断接收+环形缓冲”的方式,配合超时判断。否则在大批量传输中,任意一个字节的延迟都会导致帧错位。
CRC错误还有一个常见原因:Ymodem工具发送的是Xmodem-CRC还是真正的Ymodem。部分工具对Ymodem的实现不太标准,文件名包发不完整,或者发完文件名包后没有继续发C。这种兼容性问题,最好的调试办法是把接收到的帧内容以十六进制打印出来,对照协议规范一步步看卡在哪一帧。
另外,串口波特率过高时,如果USB转串口芯片兼容性不好,容易出现丢字节。我实测460800在CP2102和CH340上表现稳定,但换某些劣质转接线就偶尔丢数据,升级到9600又会变慢。实际项目建议115200,稳定且调试方便。如果一定要高速,至少要换带隔离的转串口才能可靠。
5.3 踩坑心得与升级安全性建议
做IAP,进度完成只是第一步,后面还得考虑“升级失败不死机”。我个人建议至少要加三个保护:
- 第一,Bootloader里设置超时机制:等待Ymodem包超时后不要一直卡死,可以复位并跳转APP。这样即使中途断线,还能跑旧固件。
- 第二,接收固件时先擦除后写,但先写到APP区的备用地址(如果Flash够大),全部完成后再用一个临时函数跳转并做校验。空间不够时,也可以先写旧APP备份区,再擦除、再写新APP,但至少要有最后一包数据的校验。
- 第三,在APP里预留一个“升级后生效”的确认动作。比如APP启动后延迟几秒,如果没有异常就清掉升级标志;如果连续重启多次,说明新固件有问题,Bootloader就自动切回备份区。这个在复杂产品里很有用,简单项目可以根据实际资源取舍。
还有一点,整个Ymodem和Flash驱动建议在Bootloader里做成弱依赖HAL库、强依赖寄存器,因为Bootloader越精简越好。HAL库能吃资源,Flash编程和串口逻辑用寄存器写也不是很难。我这个项目后期已经把Ymodem状态机从HAL中断里剥出来,效果就是调试方便、逻辑更可控。
6. 按实际使用体会补充
最后分享一个我实操中深度受益的小技巧:Bootloader里的串口接收环形缓冲长度一定要够大,我用了512字节,而且Ymodem处理时不用一次性把一整帧都收完再写Flash,而是收到128字节后立即校验写入,写完再等下一块。这样做的好处是占用RAM小,逻辑也直白。另一个技巧是在调试阶段,把每个状态机的入口和出口用串口打印出来,比如WAIT_SOH -> WAIT_SEQ -> WAIT_DATA,一旦卡住就知道卡在哪,比瞎猜快得多。
我踩过最大的坑就是“跳转前没关SysTick”,导致HAL_Delay在APP里卡死,那时候没往向量表偏移上考虑,绕了好大一圈。这些经验写出来也是希望后来人少走弯路。如果只是做一个内部量产用的升级工具,这套方案不用做得太复杂,但如果产品面向用户,建议一定要把回滚和升级确认机制考虑进去。IAP这东西,功能实现只是开始,真正稳定可靠才算是完工。
本文还有配套的精品资源,点击获取