简介:本资源是一套基于STM32H750的串口IAP(在应用编程)固件升级完整实现方案,面向嵌入式开发工程师及STM32 H7系列进阶学习者,解决高性能MCU远程/现场无编程器升级固件的核心需求。方案采用ST官方HAL库构建,代码高度可移植,兼容STM32H7全系列芯片,涵盖UART通信、Flash擦写编程、校验回滚、中断安全写入等关键IAP模块,适用于工业设备OTA维护、产品售后升级等实际场景。压缩包共1401个文件,主体为620个C源文件与720个头文件(.c/.h),支撑HAL驱动、底层Flash操作、Bootloader逻辑及FatFS等外设功能;另含5个Keil工程文件(.uvprojx/.uvoptx)、6个HEX固件示例、16张界面/流程图(.png)及调试脚本,总大小11.38MB。已有315人下载学习,提供开箱即用的编译环境与结构清晰的目录组织,开发者无需从零配置即可快速验证IAP全流程,显著降低HAL库下Flash在线升级的开发门槛与调试风险。 拿到这个项目标题,我的第一反应是“又有人在H750上栽Flash的跟头了”。STM32H750这颗芯片的坑,用过的都知道,内部Flash只有128KB,想跑稍微大一点的固件就得External Memory,但串口IAP这个需求在所有H7系列上又几乎绕不开。这包资料解决的不是“能不能升”的问题,而是“怎么升得稳、升不坏”的问题。下面把我实测过的完整方案、代码思路、踩过的坑一次性写清楚。
1. 项目概述与整体方案拆解
1.1 为什么选串口IAP,而不是其他升级方式
我们做产品后期维护,最怕的就是拆壳子、接JTAG、重新烧录。批量设备一旦交付到用户手里,现场升级只能靠通信接口。可选方案无非就是串口IAP、USB DFU、CAN升级、网络远程升级,但串口IAP依然是性价比最高、最通用的一个。
原因很直白:几乎每个MCU都留了串口,硬件上不需要额外设计,线接三根(TX、RX、GND)就能用。USB DFU在H7上虽然也有,但驱动安装、Mode配置、BootRom固件更新流程在产线上折腾起来非常费事;CAN升级可靠但需要再搭一条总线,不适合单机设备;网络升级就更不用说了,不是所有现场都有网口和路由器。
串口IAP还有一个隐藏优点——它足够底层。Bootloader一旦写好,不管App用什么RTOS、用什么GUI库、跑多大的固件,只要把新的bin文件通过串口发下去就能更新。这对于MCU方案迭代频繁的团队来说,等于给产品装上了一个可以“打补丁”的后门,而且是完全自主可控的那种。
1.2 H750的存储结构特点与分区规划
做IAP之前必须先把H750的存储结构吃透,这一步直接决定你Bootloader和App怎么分区。
STM32H750VBT6的原生Flash只有128KB,这是它最大的限制。整个内部Flash按扇区管理,注意是扇区,不是页。前几个扇区是16KB,后面的扇区是64KB或128KB,具体扇区划分记得去参考手册(RM0433)里核对,不同封装型号有细微差别。擦除操作的最小单位是扇区,这对分区设计影响很大——你没法只擦除几百字节,一擦就是一整块。
我在实际项目里推荐这样的分区方案:
| 分区 | 起始地址 | 大小 | 说明 |
|---|---|---|---|
| Bootloader | 0x08000000 | 32KB | 存放引导程序和升级逻辑 |
| App区 | 0x08008000 | 64KB | 存放当前运行的应用程序 |
| 标志区 | 0x08018000 | 8KB | 存放升级标志、固件信息 |
为什么Bootloader只占32KB?因为Bootloader本身做好串口驱动、Flash驱动、协议解析之后,代码量非常小,留足后面的调试空间,32KB完全够用。App区64KB是居中的选择——由于H750内部Flash只有128KB,如果App后期超过了这个大小,就得考虑把App放在外部QSPI Flash上运行(XIP模式),那套方案更复杂,我后面会单独提一下。对大多数中小型固件来说,64KB的App空间足以容纳GUI、传感器算法、通信协议栈。
1.3 整体升级流程设计
流程不复杂,但每个环节都必须设计得严谨。先说整体链路:
设备上电后先进入Bootloader,Bootloader检查是不是有升级请求。没有请求就直接跳转到App;有请求就等上位机发送固件数据包,逐包校验、逐扇区擦写,全部写完后设置合法标志,然后软复位进入App。
关键点在于“升级请求”的判定方式。我在项目中同时支持了两种触发方式,效果很好:
- 按键触发:上电时按住一个GPIO脚,Bootloader检测到电平就进入升级模式。这个方式适合产线或者维修场景。
- 串口命令触发:App运行时收到特定命令帧,先把标志写入Flash的专用扇区,再执行软复位,Bootloader上电后检查标志决定是否进入升级模式。这个方式适合用户远程下发指令。
还有第三种方式是“无App可跳转就自动进入升级模式”,比如出厂首次烧录时Bootloader发现App区全是0xFFFF,就自动停在升级等待状态。这套逻辑做进去之后,产线烧录流程会顺畅很多。
2. 开发环境与Bootloader工程搭建
2.1 为什么用HAL库,和标准库比好在哪
STM32H7系列刚推出时,官方其实只提供了HAL库和LL库,没有传统意义上的标准外设库。所以网上看到“HAL库和标准库的区别”这种问题,在H7上根本不存在选择,直接用HAL。
从开发效率角度讲,HAL库的抽象层次确实比标准库高很多。标准库你得自己管理外设时钟、中断标志位、寄存器状态,HAL库里大部分只需要调用HAL_UART_Transmit、HAL_Flash_Program这种封装好的接口就行。H7的Flash控制器和低功耗模式本来就比F1/F4复杂,拿标准库那套思维写H750,光是把时钟树配明白就得浪费半天时间。
但HAL库也有让人头疼的地方——函数内部封装层级太深,一旦出问题,排查路径很长。比如中断回调函数HAL_UART_RxCpltCallback触发条件你搞不清楚,串口数据可能就莫名其妙丢了。所以我的建议是:HAL库负责外设初始化,但核心的协议解析、状态机、Flash操作逻辑自己写,别过度依赖HAL的回调机制。
此外,H7系列的CubeMX集成了很好的时钟配置工具。H750主频可以跑到480MHz,但Flash等待周期设错了会黑屏死机,CubeMX可以帮你自动计算等待状态,比手写寄存器靠谱得多。
2.2 CubeMX关键配置与工程初始化
Bootloader工程我推荐直接用STM32CubeMX生成,配置项不算多,但有几个地方必须注意。
时钟树方面,H750最高主频480MHz,我实际项目里跑在400MHz,留一点余量。关键是两个地方:一个是供电电压范围要选Scale 1,否则主频上不去;另一个是Flash Latency要跟主频匹配,CubeMX会根据你选的时钟源自动计算等待周期,这点不要手动乱改。
串口配置方面,我一般用USART1作为IAP通信口,原因很简单——USART1挂在APB2总线上,时钟频率比APB1上的USART2/3高,高波特率下更稳定。波特率115200是稳妥选择,工业现场9600到115200都没有问题,但我试过921600,在短距离调试器直连时也可以,就是抗干扰能力下降明显。产品化建议还是115200,够用且皮实。
中断配置方面,串口接收必须开空闲中断(IDLE Interrupt),配合DMA接收。这样做的效果是:数据到达后DMA自动搬进缓冲区,总线空闲时触发中断,一次性告诉你“一帧数据收完了”,不用每个字节都进一次中断。HAL库的UART接收中断是逐字节触发的,CPU开销大不说,连续高速传输时容易被别的中断打断导致丢字节。所以项目里用空闲中断+DMA是标配,你需要自己写UART空闲中断的扩展代码,HAL库本身没有封装这个功能。
还有一个很容易踩的坑:Bootloader阶段的看门狗策略。很多人在Bootloader里也开了独立看门狗(IWDG),结果升级大固件时Flash擦写时间太长,忘了喂狗,擦到一半系统复位了,固件直接变砖块。我的做法是Bootloader阶段关闭IWDG,擦写完成后启动App前再重新初始化看门狗。这种方式在正式产品里要配合主机超时重发机制,才能保证掉电等异常场景下能重新升级,不至于永久变砖。
2.3 链接脚本与启动文件调整
Bootloader本身的链接脚本不需要大改,Flash起始地址就是0x08000000,大小设为0x8000(32KB)。真正要改的是App工程的链接脚本。
如果你用的是Keil MDK,在Options for Target -> Target页面,把IROM1的起始地址改成0x08008000,Size改成0x10000(64KB)。RAM一般不用动,H750有512KB RAM,App随便用。如果你用STM32CubeIDE,则需要修改链接脚本文件(.ld),把FLASH的ORIGIN改成0x08008000,LENGTH改成64K。
还有启动文件里的一个细节:如果App工程是从模板复制过来的,启动文件开头有一段“Initial Stack Pointer”的赋值,那部分不要手动改。编译器会通过__initial_sp符号自动设置,你只需要保证链接脚本里RAM区域配置正确就行。
必须强调的是,App工程改完地址后,还要在SystemInit函数或者main函数最开始设置向量表地址,否则中断一触发就跑去执行Bootloader的向量表,直接死机。设置方法非常简单:
SCB->VTOR = 0x08008000;H7系列的向量表地址要求对齐到0x200的整数倍,0x08008000刚好满足条件。但更稳妥的做法是不要硬编码,在链接脚本里定义一个宏来自动获取:
extern uint32_t _isr_vectors; SCB->VTOR = (uint32_t)&_isr_vectors;这样无论你以后怎么调整App起始地址,都不用手动同步VTOR了。
3. 核心实现:串口接收、Flash写入与跳转
3.1 串口空闲中断+DMA接收方案
这一段是整个IAP能不能跑稳的关键。先说结论:不用DMA+空闲中断的方案,串口IAP就是个痛苦面具。
传统HAL库最原始的接收方式是这样:先调用HAL_UART_Receive_IT(),每收到一个字节就触发一次中断,在中断回调里把字节存入数组。这种方式有两个致命问题:第一,数据量大时频繁进中断,占用CPU时间;第二,如果上位机连续发送,两个字节间隔太短,中断嵌套容易丢数据。
DMA+空闲中断的思路完全不同。你设置一个环形缓冲区,DMA一直在后台往缓冲区里搬运数据,完全不占用CPU。当串口总线上超过一个字节时间没有新数据时,硬件会产生IDLE中断,你在中断里计算“这帧数据从哪到哪,长度是多少”,然后交给协议解析函数去处理。一次中断处理一整帧数据,效率高一个数量级。
空闲中断在HAL库里的处理方法是自己扩展,大致代码逻辑是这样:
extern UART_HandleTypeDef huart1; #define RX_BUF_SIZE 1024 uint8_t rx_buf[RX_BUF_SIZE]; volatile uint16_t rx_index = 0; volatile uint8_t frame_complete = 0; void UART1_IDLE_IRQHandler(void) { uint16_t tmp; if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); tmp = __HAL_DMA_GET_COUNTER(huart1.hdmatx); // 实际应为hdmarx rx_index = RX_BUF_SIZE - tmp; frame_complete = 1; HAL_UART_DMAStop(&huart1); } }注意上面是示意代码,实际上DMA计数器获取通过__HAL_DMA_GET_COUNTER(huart1.hdmarx),拿的是剩余未传输的字节数,用缓冲区总长度减去它就能算出已接收字节数。拿到一帧数据后立即停止DMA,处理完再重新启动接收。
这里有个初学必踩的坑:DMA接收缓冲区和实际数据的分界。如果上位机发送了200字节,但缓冲区是1024字节,DMA会在收到200字节后继续等,直到总线空闲产生IDLE中断。所以不论一帧多长,只要不超过缓冲区大小,处理逻辑都是一样的——IDLE中断触发时计算接收长度。如果超过缓冲区大小,DMA会有溢出错误,必须做好分包处理,我一般要求上位机每包不超过512字节,留足安全余量。
3.2 Flash扇区擦除与写入实操
H7系列Flash写入和F1/F4完全不是一个套路,这点必须单独说。
F4的Flash编程粒度是16位(半字),H7变成256位(32字节),一次操作必须写入32字节对齐的数据块。H7的Flash控制器还把闪存分成了两个Bank,擦除时可以通过配置使用单Bank模式或双Bank模式,细节参考参考手册。Bootloader阶段我们用默认的单Bank模式就行。
擦除操作使用HAL库接口,需要先解锁Flash:
HAL_FLASH_Unlock();然后配置擦除参数:
FLASH_EraseInitTypeDef erase_init; uint32_t page_error = 0; uint32_t address = APP_START_ADDR; erase_init.TypeErase = FLASH_TYPEERASE_SECTORS; erase_init.Sector = FLASH_SECTOR_1; // 按实际扇区编号来 erase_init.NbSectors = 1; erase_init.VoltageRange = FLASH_VOLTAGE_RANGE_3; erase_init.Banks = FLASH_BANK_1; HAL_FLASHEx_Erase(&erase_init, &page_error);擦除完成后再写入。写入时按32字节一组来写:
uint8_t buffer[32] = {0}; // 必须是32字节对齐的数据块,最好用uint64_t指针 uint32_t write_addr = APP_START_ADDR; for (int i = 0; i < (file_length / 32); i++) { memcpy(buffer, file_data + i * 32, 32); HAL_FLASH_Program(FLASH_TYPEPROGRAM_FLASHWORD, write_addr, (uint64_t *)buffer); write_addr += 32; }还要处理不是32倍数的尾部数据:把剩余字节拷贝到临时数组里,后面补0xFF,再按32字节写一次。因为Flash擦除后是全0xFF状态,补0xFF不会改变数据的实际值。
有个非常容易忽略的细节:H7擦写Flash期间,CPU不能从同一块Flash取指令。如果你的Bootloader代码本身就在内部Flash上运行,擦除期间一旦触发中断,CPU跳去执行中断服务程序,而中断服务程序也在Flash里,直接触发总线错误。解决办法是擦写Flash前关闭所有中断(尤其串口中断),擦写完再重新开启,或者把Flash操作相关函数放在RAM中执行。我实测下来“擦写前关中断”最省事,但有个副作用是擦写期间串口数据会丢。所以协议层要在整个固件包全部接收完、校验通过后,再统一执行擦写流程,保证擦写过程中不需要再接收串口数据。
3.3 App跳转与向量表重定向
固件全部写入Flash后,最后一步是跳转到App。这一步看似简单,但至少有五个细节没处理好就前功尽弃。
跳转函数如下:
typedef void (*pFunction)(void); void JumpToApp(uint32_t app_addr) { uint32_t msp_value; pFunction app_entry; // 1. 检查栈顶值是否合法,避免跳到空Flash导致HardFault msp_value = *(volatile uint32_t *)app_addr; if ((msp_value & 0xFFF00000) != 0x20000000) { return; } // 2. 关闭全局中断 __disable_irq(); // 3. 恢复外设到默认状态 HAL_UART_DeInit(&huart1); HAL_DeInit(); // 4. 关闭SysTick,防止跳转后SysTick中断干扰 SysTick->CTRL = 0; SysTick->LOAD = 0; SysTick->VAL = 0; // 5. 设置MSP并跳转 app_entry = (pFunction)(*(volatile uint32_t *)(app_addr + 4)); __set_MSP(msp_value); app_entry(); }为什么要检查栈顶值?因为如果App区是空的或者擦除一半断电了,那块Flash内容是0xFFFFFFFF,把它赋值给MSP后,CPU一执行第一条指令就会HardFault。判断方法是检查栈顶值是否落在0x20000000开头的RAM区域内。这个校验能在升级失败时把系统留在Bootloader里,而不是直接死机。
跳转前为什么必须__disable_irq()并DeInit外设?因为Bootloader里可能正在跑某个外设中断,如果带着中断状态跳到App,App在初始化这些外设之前,中断可能就已经来了。比如串口接收中断还在挂着,跳转后App还没初始化串口,中断来了却没有对应的处理函数,直接卡死。
向量表设置这个操作必须在App的main函数最开始执行,越早越好。原因前面说过,CPU响应任何中断前都要去向量表查入口地址,不重定向,中断就去Bootloader里找处理函数了。另外还有一个涉及RTOS的坑:如果你App用了FreeRTOS,SysTick在跳转后被Bootloader关闭了,RTOS启动时会自动重新初始化,所以不用特别担心。但如果跳转前没有清PendSV和Systick的pending位,App启动RTOS后第一个时间片就可能异常,稳妥做法是跳转前把NVIC挂起的中断都清掉。
4. 常见问题与排查技巧实录
4.1 串口数据错乱与接收死机
先说数据错乱的场景。现象是上位机发了完整的一包数据,Bootloader接收后校验CRC失败,或者收到的字节数对不上。
排查路径第一条:检查波特率和时钟配置。H750主频跑到480MHz时,如果APB分频器配错,USART波特率会偏得很离谱,比如115200实际跑出来114500,发几百字节就乱一位。我建议在Bootloader启动时回显一个固定字符串,比如“BOOT_V1.0”,然后用串口助手看是否能正常显示。乱码就是波特率问题,不乱码就往下查。
第二条:检查DMA配置的缓冲区长度和变量类型。很多人DMA接收缓冲区定义成8位数组,但DMA计数器寄存器是16位的,处理不好高字节就丢了,导致计算出的接收长度是错的。注意__HAL_DMA_GET_COUNTER的返回值类型。
还有“接收死机”的情况,最常见的是DMA接收溢出。如果上位机发送速率过快,数据量超过DMA缓冲区,DMA会产生溢出中断,后续数据全部丢失,但HAL库不会自动处理这个状态。我的做法是中断里检测HAL_UART_ErrorCallback,一旦出现HAL_UART_ERROR_DMA或HAL_UART_ERROR_ORE,立即复位DMA接收状态机,同时向主机请求重传当前包。
4.2 Flash擦写失败
H7的Flash编程问题比较多,我列一下自己项目里遇到过的所有失败原因。
现象之一是调用HAL_FLASHEx_Erase后返回错误,检查page_error时发现指向了某个扇区。这个原因通常是Flash没有正确解锁,或者扇区编号超出了当前Bank的范围。H750单Bank模式下有8个扇区,如果你传了扇区编号8以上的值,就会失败。
现象之二是写入时报HAL_FLASH_ERROR_PGS(编程序列错误),这个绝大多数情况都是因为写入的缓冲区地址不是32字节对齐。假设你定义了一个uint8_t数组,取首地址时C语言会帮你对齐,但如果这数组中间某个偏移不对齐,就会出现错误。我的建议是统一用64位指针操作,把HAL_FLASH_Program的第三个参数强制转换成uint64_t数组指针。
现象之三是写入速度很慢。H7的Flash编程在480MHz下理论速度不错,但如果你按单字节去调用HAL_FLASH_Program,一次调用一个32字节包,总时间会非常难看。1MB固件光写入就要几分钟。正确做法是把数据缓存到RAM中,凑满32字节再一次写入。Bootloader开个1KB的RAM缓冲,用DMA接收完一包数据入RAM,然后一次性写入Flash,效率能提升好几倍。
4.3 跳转后跑飞或死机
跳转后死机是整个IAP项目里最让人崩溃的问题,因为现象通常很一致——程序死在HardFault_Handler里,而Bootloader的代码看起来一点问题都没有。
第一个检查点是向量的合法性。我之前做过的排查实验证明:App工程生成bin文件后,最开始的4字节必须是初始栈指针值,值域落在0x20000000开头的RAM区间;紧接着的4字节是复位处理函数入口。当符号地址在0x08008000附近时,说明链接脚本改对了;如果这个值是0xFFFFFFFF或者0x08000000,那就是链接脚本没生效。
第二个检查点是中断向量表重定向是否在中断使能之前执行。很多同学把SCB->VTOR设置放在外设初始化之后,一旦外设初始化时触发了中断,CPU第一时间进入死机状态。设置VTOR的代码必须在main函数第一行。
第三个检查点是跳转时外设状态是否彻底清理干净。尤其是DMA和中断控制器,跳转前最好把用过的DMA流全部停止,把NVIC里所有使能的中断全部清除:
for (int i = 0; i < 8; i++) { NVIC->ICER[i] = 0xFFFFFFFF; NVIC->ICPDR[i] = 0xFFFFFFFF; }还有,H7的外设有个特点:跳转进入App后,如果App没有重新初始化复用功能(AFIO),GPIO状态会保留Bootloader阶段的配置。如果Bootloader把某个串口引脚拉低了,App启动时那段引脚配置为外设模式前的极短时间内,设备可能会异常。这个现象比较隐蔽,但遇到串口外部通信设备电平不对时,可以往这个方向排查。
4.4 升级稳定性注意事项汇总
做了多年IAP开发,我把关键的稳定性经验全部总结成下面这张速查表,每条都踩过坑:
| 环节 | 注意事项 | 原因 |
|---|---|---|
| 协议层 | 每包必须带CRC32校验 | 串口传输误码率虽低,但一旦出错会导致写入错数据 |
| 协议层 | 主机和Bootloader必须有应答握手机制 | 无应答升级出现断线无法恢复 |
| 协议层 | 固件包总大小必须在包头声明 | Bootloader据此判断App区是否放得下 |
| Flash层 | 擦写前关闭全局中断 | 防止中断从Flash取指令导致总线错误 |
| Flash层 | 擦写流程必须在整包数据校验通过后执行 | 避免边收边写半路出错 |
| Flash层 | Bootloader保留一段可重写的引导区 | 方便之后通过Bootloader升级Bootloader自身 |
| 跳转层 | 跳转前必须检查栈顶合法性 | 防止空白Flash导致不可恢复的HardFault |
| 跳转层 | App的VTOR设置必须在初始化中断前 | 防止中断向量查表指向Bootloader |
| 调试层 | 用串口助手时勾选“发送新行”可能引入多余字符 | 协议解析必须精确按帧头帧尾匹配 |
再强调一个项目层面的大坑:不要把App区最后一个扇区完全写满。如果你把Flash用到了最后一个字节,下次升级时Bootloader需要先擦除整个App区——但此时App区还存着正在运行的老固件,如果升级到一半断电,机器就变砖了。我的做法是App区末尾预留一个4KB的“备份区”,升级时使用双Bank方案:新固件写入另一个Bank,校验通过后再切换启动地址。这个做法在H7上实现起来比F4简单,因为H7原生支持双Bank切换,代码成本不高但可靠性提升巨大。
最后补一个关于上位机的实操建议。虽然网上有现成的“IAP助手”,但在工程化项目中,我建议自己用Python写一个简单的升级脚本,几百行就够。核心逻辑就是打开串口、发握手命令、分包发送bin文件、等待每包确认、发跳转命令。这样你能完全控制协议,遇到问题也好定位。附一个精简版发送端思路:
import serial, struct, time, zlib ser = serial.Serial('COM3', 115200, timeout=1) with open('app.bin', 'rb') as f: data = f.read() crc = zlib.crc32(data) & 0xFFFFFFFF header = struct.pack('<BBII', 0xAA, 0x55, len(data), crc) ser.write(header) # 先发包头 SEQ = 0 for offset in range(0, len(data), 256): chunk = data[offset:offset+256] frame = struct.pack('<BBH', 0x5A, 0xA5, SEQ) + chunk ser.write(frame) ack = ser.read(2) if ack != b'OK': print('retry seq', SEQ) # 重发当前包 SEQ += 1这个脚本可以跑得通,但只做演示。实际产品上位机还要加超时重传、进度显示、断线续传,这些根据你自己的协议去实现就行。
我个人在实际操作中的体会是:串口IAP做得再花哨,最核心的永远是“失败后能不能恢复”。所以Bootloader千万别做得太复杂,代码越短越好,稳定性越强越好。我第一次把Bootloader塞了各种显示、日志、按键菜单进去,结果升级时擦写Flash触发中断直接崩了,排查了两天才发现是Bootloader自身代码量太大、中断服务程序存在被擦除的扇区里。这件事给我的教训很深,也让我最终把Bootloader精简到只剩串口接收、Flash写入、跳转三件事。
这个方案最大可扩展的方向就是接入外部QSPI Flash。H750内部Flash空间捉襟见肘时,把App固件放在外部QSPI Flash,Bootloader通过内存映射模式直接XIP执行,串口IAP的写入目标也改成外部Flash。整体思路和本文一致,只是Flash驱动从内部Flash换成QSPI,跳转前多一个QSPI初始化的步骤,有兴趣的可以从这里往下深挖。
本文还有配套的精品资源,点击获取