
做STM32H750的IAP升级十个里面有八个会被“片外App卡死”按在地上摩擦。这颗芯片本身没问题问题在于它的内置Flash只有128KB——对你没看错一颗跑480MHz的M7内核内置存储却抠门到这种程度。所以真正做产品的人几乎都会把App放在片外Flash上跑XIP或者上电后拷贝到RAM里跑。而一旦涉及“片外App”IAP的复杂度就直接翻倍Bootloader要管存储初始化、存储器映射、Cache一致性、中断向量表重映射还要处理跳转瞬间外设状态的残留问题。这篇我按自己做产品时的实际踩坑经历来写主要覆盖Bootloader设计、APP接收端怎么配合、片外Flash执行时为什么总是卡死、以及AB分区回滚和固件版本管理怎么落地。适合已经把H750点亮、但想给产品加远程升级或串口升级功能的工程师看。代码和思路可以直接抄但更重要的是看清楚每一步背后的“为什么”否则换个板子还是会翻车。1. 方案选型与整体设计思路1.1 H750的特殊性128KB内置Flash带来的连锁问题先明确一个事实STM32H750的内置Flash分区里实际可用的用户区大约是128KB代码稍微加点图形界面、算法库或者文件系统就顶满了。所以H750的常规玩法是“外挂一片QSPI Flash”典型型号是W25Q12816MB或者W25Q25632MB挂在QUADSPI接口上通过内存映射模式映射到0x90000000地址段。这一下就把IAP的问题拉高了一个维度。以前在F103上写BootloaderApp放在0x08008000附近跳转只需要改个向量表和栈顶指针就够了。H750要做片外升级Bootloader得先初始化QSPI、配置好映射关系确认外部Flash能读能写然后才能谈“跳转”这两个字。另一个容易被忽略的点是QSPI Flash的读速度远低于内置Flash如果不在Bootloader和App里慎重处理Cache和时序参数App跑着跑着突然卡死或者HardFault是家常便饭。很多人第一次调H750 IAPflash了Bootloader和App一按复位板子直接“死给你看”问题往往就出在这里。1.2 升级通道怎么选串口、USB、网络还是SD卡IAP只是“在应用编程”的统称真正的传输通道要根据产品形态来定。我的经验是先定通道再定协议最后才写代码。串口是最常见的简单可靠调试方便。Y-Modem协议在IAP里用得非常多因为它自带校验和重传PC端工具SecureCRT、Xshell都支持调试时非常省事。缺点是速度一般115200波特率下升级1MB固件要一分多钟适合小固件或者有线调试场景。CAN升级在车载、工控设备里很常见。如果Bootloader已经跑在CAN网络上升级软件可以通过CAN总线广播升级包设备端一包一包接收并写入Flash校验通过后复位运行新固件。注意CAN单帧数据只有8字节协议分包管理比串口复杂帧计数和丢失重传机制一定要设计清楚。USB升级有两条路。一条是DFU模式STM32原厂自带但H750的DFU只认内置Flash片外App还得自己扩展。另一条是把H750的USB Host接口做成U盘模式用户把固件拷贝到U盘插上去Bootloader检测到U盘就自动升级。这个体验最好但对Bootloader的USB协议栈要求较高代码量会明显上升。如果有以太网或WiFi模块OTA升级就是另一套逻辑了核心还是HTTP或私有协议下载固件到缓冲区然后走Flash写入流程。热词里提到的“ota升级”“esp32 ota升级”本质都绕不开同一个闭环下载、校验、写入、回滚。通道只是入口存储管理和跳转策略才是真正的难点。1.3 XIP直接执行还是拷贝到RAM两条路线怎么选片外App有两种跑法这是H750 IAP最容易糊涂的地方。第一种是XIPExecute in Place把QSPI Flash内存映射到0x90000000App编译地址直接指向这个段上电后Bootloader初始化QSPI然后直接跳过去执行。优点是实现简单App代码不用搬掉电也不丢。缺点是QSPI读速度比内部Flash慢如果Flash时序配置不对跑复杂逻辑时容易出问题。一般来说打开ICache之后性能基本够用但DCache相关的问题会伴随整个调试过程。第二种是“拷贝到RAM执行”。Bootloader上电后把外部Flash里的App搬到AXI SRAM0x24000000或者外部SDRAM然后从RAM启动。优点是执行速度快不受QSPI时序影响卡死几率低很多。缺点是需要保证RAM空间足够而且每次上电都要做一次拷贝App的主频和功耗也会略有增加。我的建议是产品MCU主频在400MHz以上、Flash读取速度跟得上的优先选XIP代码改动最少如果App对实时性要求很高、又经常出现随机卡死可以考虑RAM执行方案。我自己做产品时小规模固件用XIP稳定性和速度都能接受。但不管选哪条路Bootloader里QSPI的初始化和时序配置必须稳这是片外App能不能跑起来的基石。2. Bootloader设计从启动流程到跳转逻辑2.1 Bootloader该管哪些事Bootloader不是简单地在启动时问一句“你要不要升级”它的职责比你想象的多。从功能上划分至少要包含这几块通信模块串口/CAN/USB/网络、固件接收与校验、Flash擦写管理、启动项判断与跳转、异常恢复与回滚。如果是片外Flash方案还要加上QSPI初始化和内存映射配置。每一个模块都有各自的坑但最重要的其实是后两项判断该跳App还是进入固件升级流程以及跳转时能不能把现场收拾干净。Bootloader本身要尽量小而稳不要依赖复杂外设不要开太多中断更不要在Bootloader里做业务逻辑。它的生命周期很短要么升级固件要么跳转App做完事就赶紧交棒。用H750做Bootloader如果不加复杂通信协议一般10KB20KB就够用没必要往里面塞一堆用不上的HAL驱动。2.2 启动流程设计上电后先干哪一步一个典型的H750 IAP启动流程可以拆成这样上电系统时钟初始化。使用内部HSE或者外部晶振配好锁相环此时QSPI相关时钟必须保证稳定。初始化串口打印Bootloader版本号。检测升级触发条件按键电平、某个标志位例如备份寄存器里的Magic Number、或者通信接口是否有升级指令。只要有一个成立就进入升级模式。如果不需要升级检查App区有效性读取App首地址的栈顶指针MSP、复位向量校验CRC或版本号。校验通过跳转App校验失败或者App区为空停留在Bootloader等待升级指令。整个过程听起来简单但每一步都可能出问题。比如时钟配置如果App里用CubeMX重新配置了时钟把PLL又改写一遍而QSPI的时钟源也被重新配置了外部Flash映射可能当场失效这在XIP模式下是致命级别的错误。所以Bootloader和App必须约定好时钟怎么配、哪些外设Bootloader初始化后要保留、哪些必须清干净这些都需要在接口文档里写清楚。2.3 跳转App的代码与隐藏的坑跳转代码网上很多但80%的写法都少了一些必要的保护。我一般是这样写的typedef void (*pFunction)(void); void JumpToApp(uint32_t ulAppAddr) { uint32_t ulMspVal; uint32_t ulResetVal; pFunction jumpFunc; /* 检查App首字是否为合法的栈顶地址 */ ulMspVal *(volatile uint32_t *)ulAppAddr; if ((ulMspVal 0xFFF00000) 0x20000000 || (ulMspVal 0xFFF00000) 0x24000000 || (ulMspVal 0xFFF00000) 0x30000000) { /* 栈顶地址确认在RAM范围 */ } else { Error_Handler(); } /* 取第二条向量Reset_Handler */ ulResetVal *(volatile uint32_t *)(ulAppAddr 4); /* 跳转前关闭全局中断避免外设中断残留 */ __disable_irq(); /* 关闭RTT、串口等用到的外设避免中断干扰App */ HAL_UART_DeInit(huart1); __HAL_FMC_EXTENDEDMEMORY_DISABLE(); /* 如果用了外部存储器按需处理 */ /* 设置主栈指针然后跳转 */ __set_MSP(ulMspVal); jumpFunc (pFunction)ulResetVal; jumpFunc(); }这段代码有几个要点必须说清楚。第一MSP的校验不是摆设。如果你跳转到的地址根本不是一个有效的App栈顶指针大概率是个非法值你强行跳过去马上HardFault。所以跳转之前的防御性检查很关键。第二__disable_irq()只关闭了中断使能不代表外设状态就干净了。比如你在Bootloader里开了串口DMADMA还在搬运数据跳转过去之后DMA中断触发App的向量表根本不知道这个中断是什么结果是灾难性的。所以跳转前要主动去初始化Bootloader用过的外设尤其是UART、DMA、定时器和USB。第三如果你用的是XIP方案QSPI外设绝对不能DeInit更不能把时钟关了。你一旦把QSPI停了外部Flash映射直接断开App的PC指针在0x90000000地址去取指立刻HardFault。很多人在Bootloader里习惯性调用HAL_QSPI_DeInit清理外设结果刚跳转就死在入口找半天找不到原因。正确的做法是QSPI保持初始化状态只是把相关中断关闭。第四Cortex-M7和Cortex-M3/M4不太一样它默认可能使用PSP如果之前跑过RTOS。跳转App之前要确保当前使用的是MSP也就是主栈指针。如果你从FreeRTOS环境里跳转当前SP是PSP直接调用__set_MSP后跳转App的启动代码可能被绕过去。稳妥做法是在跳转前强制使用MSP再把控制寄存器里CONTROL.SPSEL清零。3. APP接收端设计串口升级协议与Flash写入3.1 自定义升级协议帧格式、校验、分包与重传如果你不想依赖Y-Modem自己写一个简单的升级协议是很有必要的尤其是走CAN或自定义网络通道时没有现成协议可用。我习惯用自定义帧格式结构大致如下帧头(2B) 帧类型(1B) 包序号(2B) 数据长度(1B) 数据(NB) CRC32(4B) 0xAA55 0x01 0x0001 0x00 ... ...帧头固定0xAA55用来做字节对齐和帧同步。帧类型区分三类固件数据帧0x01、结束帧0x02、应答帧0x03。包序号是当前包在整个固件中的序号从1开始计数。数据长度一包最好不要超过1KB因为QSPI按页编程通常256B一页接收缓冲区太大反而占用RAM。每一包发送后Bootloader必须返回ACK或NAK。上位机收到ACK再发下一包收到NAK重发当前包如果超时没收到应答连续重试三次后中断升级。千万不要把发送速率拉满不管对端QSPI擦写需要时间你不做流控数据就丢在缓冲区里。在固件传输前最好先发一个固件头信息固件总长度、CRC校验、目标地址、设备型号、固件版本号。Bootloader收到头信息后先擦除目标分区再开始接收数据。这样能避免“传了半天最后一校验发现版本不匹配”的尴尬情况。3.2 QSPI Flash写入流程擦除、编程、校验、掉电保护很多人一上来就在App里直接调用HAL_QSPI_Transmit往W25Q128写数据写完发现数据是乱的或者复位的瞬间板子直接变砖。原因很简单QSPI Flash写入之前必须先擦除而且擦除的单位通常是4KB扇区或64KB块不能只擦一个字节。所以完整的写入流程是这样的退出内存映射模式。QSPI在内存映射模式下CPU可以直接读取0x90000000地址但这时候芯片不能对Flash发出编程或擦除指令。所以写之前要把QSPI切成间接模式。按扇区擦除目标区域。如果升级的是完整固件直接把整个App分区擦掉如果做AB分区只擦目标B分区。按页编程。W25Q系列一页256字节编程前要确保写地址偏移和页边界对齐跨页的数据要拆成两笔写。读回校验。写完一页读一页CRC或逐字节比较有错误立即报告不要等到最后才发现。写完成后再切回内存映射模式这样Bootloader和App都能重新从0x90000000读取固件。掉电保护的关键在于写入顺序。我的实践是先把固件写到“暂存区”或“备份区”全部写完后验证CRC验证通过后把状态字一个Magci Number写入Flash的状态区告诉Bootloader“这个分区可用”。如果写入过程中掉电状态字没有被更新Bootloader就知道这个分区不完整不会尝试跳转而是重新等待升级或者从另一个分区启动。3.3 APP端必须配合的三件事App不是只要被Bootloader跳转过去就万事大吉。如果不做下面三件事片外App卡死是必然的。第一向量表重映射。Cortex-M7的向量表地址可以通过SCB-VTOR设置。XIP方案下最简单的方式是在App启动代码最前面加SCB-VTOR 0x90000000;如果App在RAM里执行就要写RAM段的首地址。注意向量表地址必须按中断向量表大小对齐H750如果用全量中断表一般按0x400对齐就够了。第二编译地址要改。App工程里的Link Script必须把Flash段起始地址改成0x90000000XIP或0x08020000内置Flash偏移。很多人Bootloader写好了App编译还是从0x08000000开始跳过去之后PC取到的第一条指令根本不是App板上所有的表现都令人崩溃。第三时钟和外设初始化千万别乱来。App如果使用CubeMX生成代码启动后SystemInit和main里的时钟配置可能重新初始化PLL这会把Bootloader配好的QSPI时序打乱。如果Bootloader确实配置了外部Flash并处于内存映射模式App启动初期应避免重新配置与QSPI相关的时钟树。如果必须配置一定要先确认QSPI重新初始化能正常工作否则就等着看随机卡死。4. 片外App卡死排查从现象定位到根因4.1 卡死现象的四种典型表现片外App卡死不是一种原因形形色色的现象对应完全不同的根因必须分类讨论。第一种跳转后直接HardFault。这种最常见原因通常是栈顶指针校验不过、向量表没重映射、或者外部Flash映射没建立。你可以在HardFault_Handler里读SCB-HFSR和SCB-CFSR寄存器看是哪类错误如果是总线错误多半是PC访问了非法地址如果是指令访问错误很可能是PC取址到了0x90000000但QSPI根本没有映射。第二种上电能跑几秒到几分钟后不定时卡死。这种随机卡死优先怀疑Cache一致性和QSPI时序。H750开了DCache之后如果读取外部Flash的数据区时发生了Cache未命中或写回冲突就会出现不可预期的卡死。最简单粗暴的验证办法把DCache关了测试如果卡死消失了基本就是Cache一致性问题。第三种复位后反复卡在Bootloader或者App跳转死循环。这种大多是升级标志位没清干净Bootloader每次上电都认为App无效停在等待升级状态或者App里立即请求重启。第四种App正常运行但跑着跑着看门狗复位。这个原因常常出在App初始化里外设配置耗时太长喂狗线程还没跑起来看门狗已经超时。或者是升级完成后没有及时更新有效标志Bootloader把新固件判成无效又切回旧固件看起来就像每隔几秒重启一次。4.2 高效排查三段式方法我排查片外App卡死时一般按这个顺序来第一步看跳转前状态。用调试器连接H750在跳转点断点执行到跳转前读取QSPI映射的首地址确认0x90000000处确实有App的数据且首字是合法的栈顶值0x20000000或0x24000000段。这一步能排除90%的“什么都还没跑就死”问题。第二步看走没走进App。在App的Reset_Handler第一行设断点在main第一行设断点。如果Reset_Handler能进但main进不了问题大概率在启动代码里如果main进了又死掉问题在外设初始化或Cache配置。第三步看卡死在哪个外设或哪条指令。HardFault发生时通过调试器读取LR和PC定位到故障指令。如果是LDR指令访问某地址触发总线错误检查地址是否落在外部Flash的映射范围内如果是DMA访问冲突回到外设配置检查。这个思路适用于绝大多数现场比盲猜省太多时间。4.3 一个实测案例QSPI采样边沿导致的随机卡死说个我自己调过的案例。板子用的是W25Q256Bootloader和App都正常上电能跑但是只要环境温度稍微升高或者操作界面切换得频繁电脑上测试时隔几分钟就卡死一次。一开始怀疑代码逻辑问题加了大量打印也看不出规律。后来怀疑Cache把DCache关掉卡死频率明显下降但偶尔还是复现。最后用逻辑分析仪抓QSPI时序发现Flash读指令的采样边沿设置不对在时序余量不足的临界状态下偶尔会读回错误数据。解决办法是把QSPI的采样边沿改成“下降沿采样”同时把Dummy Cycles从4调到6降低一点Flash读取时钟频率让时序余量更充足。这个案例说明很多“随机卡死”最终是时序余量问题不是软件逻辑问题。如果你也遇到那种“换一块板子就好了”的玄学问题建议先用示波器或逻辑分析仪看一遍QSPI时序再回头怀疑代码。5. 升级安全与回滚AB双分区策略落地5.1 为什么要做AB分区而不是原地刷写最基础的IAP是“原地升级”Bootloader接收固件直接擦写当前App区写完后跳转。这种做法最省Flash空间但有个致命弱点如果升级过程中掉电、擦写失败、或者固件本身有bug设备就彻底变砖只能拆机用烧录器救回来。产品量产之后“变砖”是不可接受的。所以更稳妥的做法是AB双分区把Flash分成两个App区Slot A和Slot B一个放当前运行的固件另一个放新固件。升级时把新固件写入不活跃的那个分区全部写完后校验通过再切换启动标志。下次复位后Bootloader从新分区启动如果新固件启动失败Bootloader再自动回滚到旧分区。H750外挂一片16MB的W25Q128完全有条件做两个4MB甚至更大的App分区。每个分区头部放一个状态字VALID表示分区可用INVALID表示分区不完整或已被放弃。5.2 AB分区升级与回滚的状态机升级流程可以概括成一张状态机Bootloader检查Slot A和Slot B的状态字选择有效的一个启动。上位机发送新固件头Bootloader把固件写入“非当前启动”的那个分区。写入完成CRC校验通过把新分区状态字置为VALID同时把旧分区状态字保留为VALID作为备份。复位。Bootloader再次启动发现新分区VALID跳转到新分区。App启动后开始喂独立看门狗。如果30秒内没有喂狗成功例如App崩溃看门狗复位。Bootloader检测到“启动尝试次数”超过设定阈值比如3次自动把新分区状态字置为INVALID回滚到旧分区启动。这个流程的关键在于“尝试次数”标志。如果只用一个状态字App启动失败后状态字已经是VALIDBootloader每次都会尝试启动新App又每次失败陷入死循环。所以要在备份寄存器或者Flash里存一个“启动失败计数”每次跳转前递增App正常启动后清零。关于版本管理我再加一条每个固件头部不要只放版本号还要放“设备型号ID”。否则一个产品系列里有多个硬件版本固件内核一致但外设配置不同刷错型号的固件会导致外设初始化异常。我在实际项目里吃过这个亏一条产线上的两种板子共用一套IAP流程有人拿A板的固件刷了B板结果屏幕不亮、CAN不通查了半天才发现是版本型号没匹配上。所以即便做的是小批量产品设备型号ID的检查和版本号一样重要。5.3 掉电保护与升级过程的“原子性”AB分区解决的是“升级后启动失败”的回滚问题但升级过程中掉电是另一回事。我见过不少方案升级写到一半断电结果Bootloader被迫重刷客户体验很差。要提升升级过程的抗掉电能力核心原则是“状态先置无效数据写完再置有效”。具体做法升级开始前把目标分区状态字清为INVALID。接收固件数据逐包写入目标分区。全部写完CRC校验通过再把状态字置为VALID。如果中间掉电目标分区状态字保持INVALIDBootloader会直接忽略这个分区继续从另一个分区启动。这里要注意的是“状态字写入”本身也要防止掉电导致写一半。W25Q系列的状态字一般放在单独的扇区写入前先擦除再写一页。如果担心异常可以把状态字连续写两遍甚至三遍读取时以多数为准。这个“冗余写”的技巧虽然原始但对防止Flash半写状态非常有效。6. 调试工具与个人经验分享6.1 串口日志和J-Link RTT怎么配合调试IAP最痛苦的阶段是“不知道卡在哪一步”。我强烈建议Bootloader从一开始就带串口日志而且日志前缀要和App区分开。比如Bootloader打印[B] Bootloader v1.0跳转前打印[B] Jump to App 0x90000000App启动后打印[A] App v2.1.3 started。这样用串口助手一拉日志就能判断是Bootloader的问题还是App的问题。J-Link RTT也很有用。RTT不占用串口引脚而且速度快可以在RTOS环境里直接输出日志。缺点是RTT需要调试器连着不适合产线测试但开发阶段排查随机卡死时RTT加SEGGER SystemView的组合能看到实时任务调度情况和异常发生时的调用栈比串口日志更直观。我自己常用的调试套路是开发阶段串口日志和RTT一起开串口看Bootloader阶段RTT看App阶段量产时关闭RTT只留串口错误日志日志量压缩到只记录关键事件。6.2 我会用的上位机与命令行小工具做IAP升级上位机可以简单也可以复杂。如果只是调试我用Python写一个几十行的小脚本就够核心功能就是读bin文件、分包、发送、等待ACK、计算CRC。类似这样import serial, struct, binascii ser serial.Serial(COM10, 115200, timeout1) firmware open(app.bin, rb).read() pack_size 1024 seq 1 for offset in range(0, len(firmware), pack_size): data firmware[offset:offset pack_size] frame struct.pack(HBBH, 0xAA55, 0x01, seq, len(data)) data crc binascii.crc32(frame) 0xFFFFFFFF frame struct.pack(I, crc) ser.write(frame) ack ser.read(1) if ack ! b\x06: print(NAK at packet, seq) break seq 1如果不想写代码Y-Modem工具也可以配合SecureCRT就能手动传固件。我在实验室调试时经常直接用它省时省力。Linux下用sb命令也可以发Y-Modem。bin转hex、hex转bin这类格式转换多用arm-none-eabi-objcopy一次搞定不用另装工具。6.3 我的个人踩坑清单和习惯做H750 IAP久了我总结出几条铁律新打样的板子先不要跑任何业务代码先把Bootloader跑通确认QSPI读写没问题、跳转没问题再开始写App。这个顺序不能反否则你调试时根本分不清是硬件问题还是软件问题。每次跳转前干三件事检查App栈顶地址合法性、关闭全局中断、清理非必要外设。这三步少一步后面都会加倍还回来。全片擦除只在新板才用。正常升级时严格按目标分区擦除千万别把整个Flash擦了否则另一分区的备份固件也没了一旦新固件失败就没有回滚的机会。最后关于QSPI时序参数如果对Flash型号没有十足把握就用保守配置时钟频率先降到50MHz左右Dummy Cycles设大一点等跑稳定了再慢慢提升。时序余量这个东西不是每个板子都一样的批量生产时PCB走线差异会让临界参数直接变成事故现场。升级功能本身不难难的是把升级做到“升级失败也不怕”。我自己的体会是把最坏情况都想到并处理掉产品才敢真正发到客户手里。如果你也正在调H750的IAP建议先把AB回滚和掉电保护做完再去优化升级速度和体验。稳定压倒一切这句话在固件升级这件事上永远不会过时。