搞嵌入式这些年,我一直觉得给MCU烧程序就该是“打开MDK,点一下Download,完事”。直到遇见STM32H750VBT6这颗芯片,它内置Flash只有128KB,跑复杂点的应用分分钟爆满。为了把程序和资源塞到外部W25Q32(4MB QSPI Flash)里,我以前只能先烧一个Bootloader,再用串口工具一帧一帧发固件。那段时间,每发一次固件都要经历一次“串口掉线重连、校验失败重发、版本号记错”的折磨。后来我花了两三天时间,把STM32H750的MDK下载算法(FLM)彻底搞明白了,终于能在MDK里像烧内置Flash一样直接Download外部QSPI Flash,还能用调试器在外部Flash上打断点。这篇就把完整过程和核心代码分享出来,给同样被串口升级折磨的人指条路。
1. 先搞清楚这四件事:Flash困境、芯片选型、下载算法和硬件连接
1.1 STM32H750VBT6的128KB困局
STM32H750VBT6这颗芯片主频可以跑到480MHz,内置DSP和双精度FPU,还带TFT-LCD控制器、LTDC、DCMI等丰富外设,性能在M7里算是很能打的。但唯独内置Flash只有128KB,这对于稍微上点规模的应用来说完全不够用——你随便放个LVGL界面、放点字库图片、加个轻量级文件系统,128KB就没了。
所以实际产品里,H750几乎都是外挂一片SPI/QSPI Flash来扩展存储。而扩展Flash最常见有两种玩法:一种是把资源数据(字库、图片、音频)放在外部Flash,程序本身仍从内置Flash运行;另一种是干脆把代码也放到外部Flash里运行(XIP),这样内置Flash就只放一个极小的Bootloader。第二种玩法对烧写工具的要求更高,因为你没法再用传统方式“把程序下载到内置Flash”了。
1.2 W25Q32是一块什么样的Flash
W25Q32是华邦(Winbond)出品的一颗32Mbit(4MB)SPI NOR Flash,工作电压2.7V-3.6V,支持标准SPI、双线SPI、四线SPI(QSPI)模式。4MB容量对于中小规模的MCU应用刚好合适,而且价格便宜、供货稳定、资料丰富,是我个人比较常用的扩展Flash型号。
这颗Flash的几个关键参数:
- 页大小:256字节
- 扇区大小:4KB
- 块大小:32KB/64KB可配
- 擦除后数据:0xFF
- 最大时钟频率:133MHz(四线快速读)
- JEDEC ID:0xEF4015
W25Q32的擦写寿命典型值是10万次,数据保持能力在常温下可以到20年,对于绝大多数嵌入式产品场景完全够用。
1.3 MDK下载算法是什么,为什么能解决串口升级的痛点
MDK下载算法在Keil里叫“Flash Programming Algorithm”,编译产物是.FLM文件。它的本质是一个极小的独立程序,会被MDK通过调试器(ST-Link/J-Link等)临时加载到目标芯片的RAM里运行,由这个RAM里的程序去操控外部Flash的擦除和写入。
为什么有了它就能告别串口?因为串口升级的链路是“APP/Bootloader用串口协议接收数据 → 写入Flash”,你需要先在芯片里驻留一个Bootloader,还要配合PC端的串口工具。而MDK下载算法走的是SWD/JTAG调试接口,经过调试器直接控制芯片RAM里的算法程序来烧写Flash,不需要Bootloader,不需要串口工具,也不占用任何通信外设。
更关键的是,下载算法的运行由MDK统一管理,你可以在MDK里直接对0x90000000地址空间(QSPI映射区)执行擦除、下载、校验,甚至调试。整个体验和烧内置Flash完全一致。
1.4 本文实验环境的硬件连接方案
在做下载算法之前,先得确定QSPI Flash接在STM32H750的哪些引脚上。我使用STM32H750的QSPI1外设,通过四线QSPI方式连接W25Q32,具体引脚分配如下:
| 功能 | QSPI1信号 | 引脚 | 复用功能(AF) |
|---|---|---|---|
| 时钟 | QSPI1_CLK | PB2 | AF9 |
| 片选 | QSPI1_BK1_CS | PG6 | AF10 |
| 数据0 | QSPI1_BK1_IO0 | PC1 | AF9 |
| 数据1 | QSPI1_BK1_IO1 | PC2 | AF9 |
| 数据2 | QSPI1_BK1_IO2 | PE2 | AF9 |
| 数据3 | QSPI1_BK1_IO3 | PD13 | AF9 |
这套引脚也是很多H750开发板的标准接法,如果你手里的板子是其他引脚方案,只需调整本文代码里的GPIO配置即可,不影响整体逻辑。
2. FLM算法在MDK中的角色:设备描述、函数接口和执行流程
2.1 FlashDevice结构体:算法的“身份证”
MDK加载FLM文件后,第一件事是读取里面FlashDevice结构体,获取这颗Flash的“身份信息”。这个结构体定义了算法支持的Flash芯片名称、基地址、容量、页大小、超时时间等关键参数。
#include "FlashOS.h" struct FlashDevice const FlashDevice = { FLASH_DRV_VERS, // 驱动版本,固定用这个宏 "W25Q32_QSPI_4MB", // 设备名称,会显示在MDK Flash Download列表中 EXTSPI, // 设备类型,外部SPI Flash用EXTSPI 0x90000000, // Flash起始地址(QSPI1映射地址) 0x00400000, // Flash大小:4MB 4096, // 页大小:编程时一次最多多少字节 0, // 预留字段 0xFF, // 擦除后填充值 100, // 编程超时:100ms 3000, // 擦除超时:3000ms { {0x1000, 0x000000}, // 4KB扇区擦除 {0x8000, 0x000000}, // 32KB块擦除(可选) {0x10000, 0x000000}, // 64KB块擦除(可选) {0x00400000, 0x000000}, // 整片擦除 {0x000000, 0x000000} // 结束标志 } };这里有几个字段值得解释一下:
- 设备地址0x90000000:这是STM32H750 QSPI1的存储映射区域起始地址。H750的QSPI1映射地址是0x90000000-0x9FFFFFFF,QSPI2映射到0x70000000-0x7FFFFFFF。
- 页大小设为4096:W25Q32实际页编程单位是256字节,但这里设成4096可以减小MDK调用ProgramPage函数的次数,从而提高编程速度。我们的ProgramPage函数内部会按256字节自动分块编程。
- 设备类型EXTSPI:在MDK的FlashOS.h头文件中,EXTSPI的值为5。如果这里写错,MDK会按内置Flash的算法逻辑来处理外部SPI Flash,导致报错。
最后那个扇区擦除表就是给MDK选择擦除策略用的。MDK在执行“擦除扇区”操作时,会根据下载地址范围和这个表来决定调用哪个擦除函数。比如下载地址落在4KB边界,就调用4KB扇区擦除;如果落在64KB边界,就调用64KB块擦除。这里我保留了32KB和64KB块擦除项,实际调试时更灵活。
2.2 六大核心回调函数
MDK下载算法需要实现以下六个函数,这些函数的原型定义在FlashOS.h中:
| 函数名 | 功能说明 |
|---|---|
| Init | 初始化Flash控制器和GPIO,返回0表示成功 |
| UnInit | 下载结束后做清理工作,比如关闭外设或保持状态 |
| EraseChip | 整片擦除Flash |
| EraseSector | 擦除指定地址所在的扇区 |
| ProgramPage | 往指定地址写入一页数据(大小由FlashDevice中的页大小决定) |
| Verify | 校验写入的数据,返回0表示正确,非0表示第一个错误地址 |
这六个函数就是MDK与下载算法之间的“协议”。MDK在需要擦除时调用EraseSector/EraseChip,在需要写数据时调用ProgramPage,在写完后调用Verify校验。Init和UnInit在每次下载操作开始/结束时被调用。
2.3 MDK调用FLM的完整流程
理解调用链对排查问题很有帮助。当你点击MDK的Download按钮后,会发生以下事情:
- 调试器(ST-Link/J-Link)通过SWD接口连接目标芯片,配置芯片的调试端口。
- MDK从FLM文件中解析出代码段和数据段,把整个算法镜像加载到RAM中(RAM地址由Flash Download配置页面里的“RAM for Algorithm”决定)。
- MDK设置程序计数器(PC)跳转到算法入口,调用Init函数,传入目标地址、时钟频率和功能码。
- Init函数完成QSPI外设和GPIO的初始化,返回0告诉MDK“一切OK”。
- MDK根据Flash Download配置的编程选项(比如“擦除扇区”或“整片擦除”)调用对应的擦除函数。
- MDK把要烧写的数据通过调试接口传输到RAM中的临时缓冲区,然后调用ProgramPage函数,将缓冲区里的数据编程到Flash的指定地址。
- 编程完成后,MDK调用Verify函数,把Flash里的数据和原数据做对比。
- 最后调用UnInit函数,如果配置了“Reset and Run”,还会复位并运行目标程序。
流程看着不复杂,但每一步都可能埋着坑。比如第2步中RAM for Algorithm设置不当,算法镜像就加载失败;第5步擦除函数实现不对,编程时就会报“verify failed at address xxx”。
3. 搭建FLM算法工程:目标配置、源文件与编译环境
3.1 新建工程和源文件
制作FLM算法不需要从零搭建HAL库环境,我们只需要一个精简工程,包含目标芯片启动文件、FlashDev.c和FlashPrg.c三个核心文件。
具体步骤:
打开Keil MDK,
Project -> New µVision Project,给工程起名,比如W25Q32_QSPI_FLM。选择设备,搜索并选中STM32H750VBTx。
在Manage Run-Time Environment弹窗里,不用勾选任何组件,直接OK。
在创建好的工程里,手动添加三个文件:
startup_stm32h750xx.s:这个启动文件可以从MDK的Pack安装目录下找到,路径一般是C:\Keil_v5\ARM\PACK\Keil\STM32H7xx_DFP\<版本号>\Device\Source\ARM\startup_stm32h750xx.sFlashDev.c:放置FlashDevice结构体FlashPrg.c:放置算法实现函数
把工程默认生成的
main.c删除或者不添加,FLM算法工程不需要main函数。
3.2 Target页面和分散加载文件的配置
FLM算法本身要在RAM里运行,所以Target页面的内存配置很关键。我使用的是AXI SRAM区域(0x24000000起始),原因后面会提到。
在Options for Target -> Target页面中:
- 勾选“Use Memory Layout from Target Dialog”
- IROM1:Start=0x24000000,Size=0x4000(16KB代码空间)
- IRAM1:Start=0x24004000,Size=0x4000(16KB数据空间)
- Xtal:保持默认即可,FLM算法不依赖这个值
这里的IROM1虽然名字叫ROM,但因为我们配置的是RAM起始地址,所以Keil编译器会把代码生成到RAM地址上。链接器会根据Target对话框自动生成分散加载文件,把只读代码段放进0x24000000起始的RAM空间。
为什么不把算法放在DTCM(0x20000000)?H750有128KB DTCM,速度最快,但也最容易和应用程序的RAM配置冲突。AXI SRAM有512KB,空间充足,而且即使App也用了AXI SRAM,只要RAM for Algorithm设置合理,在下载流程结束后App复位启动时会重新初始化这块区域,问题不大。
在Options for Target -> C/C++页面中,注意以下几点:
- Define里填
STM32H750xx,确保CMSIS头文件包含正确的外设定义。 - ARM Compiler建议选V6(或者V5.06),两个版本我都试过能编译通过。
- Optimization选择
-O2或-O3,别选-Os,避免链接时因为函数合并导致问题。 - 不勾选“One ELF Section per Function”,保持默认即可。
Output页面里,Name of Executable改成W25Q32_QSPI,这个名称就是最终生成的FLM文件名。勾选“Create HEX File”可以顺便生成HEX文件,方便调试。
3.3 编译生成FLM文件
直接按F7编译,如果没有报错,工程目录的.\Flash文件夹下会生成一个W25Q32_QSPI.FLM文件。MDK编译FLM工程时会自动输出FLM格式(这是MDK的特殊能力,普通工程输出的是AXF文件)。
如果编译报找不到FlashOS.h,确认一下MDK安装路径下的ARM\Flash\_Template目录是否在头文件包含路径里。一般安装MDK后,C:\Keil_v5\ARM\INC\FlashOS.h这个路径会自动加入,如果没有就手动加一下。
4. 核心函数实现:QSPI初始化、擦除、编程与校验
4.1 QSPI引脚与时钟初始化
在Init函数被MDK调用时,第一件事就是初始化QSPI外设和GPIO。这里我采用寄存器直接操作的方式,不依赖HAL库,好处是ELF文件体积小、加载快、和MDK的FLM加载器配合更稳定。
#include "FlashOS.h" #include "stm32h7xx.h" #define QSPI_FLASH_BASE 0x90000000UL #define W25Q32_JEDEC_ID 0xEF4015UL static void QSPI_InitHardware(void) { // 使能QSPI外设时钟 RCC->AHB1ENR |= RCC_AHB1ENR_QSPIEN; // 使能相关GPIO端口时钟 RCC->AHB4ENR |= RCC_AHB4ENR_GPIOBEN | RCC_AHB4ENR_GPIOCEN | RCC_AHB4ENR_GPIODEN | RCC_AHB4ENR_GPIOEEN | RCC_AHB4ENR_GPIOGEN; // PB2 -> QSPI_CLK (AF9) GPIOB->MODER &= ~GPIO_MODER_MODE2; GPIOB->MODER |= GPIO_MODER_MODE2_1; // Alternate Function GPIOB->OSPEEDR |= GPIO_OSPEEDR_OSPEED2; // High speed GPIOB->AFR[0] &= ~GPIO_AFRL_AFSEL2; GPIOB->AFR[0] |= (9U << GPIO_AFRL_AFSEL2_Pos); // PC1 -> QSPI_IO0 (AF9), PC2 -> QSPI_IO1 (AF9) GPIOC->MODER &= ~(GPIO_MODER_MODE1 | GPIO_MODER_MODE2); GPIOC->MODER |= (GPIO_MODER_MODE1_1 | GPIO_MODER_MODE2_1); GPIOC->OSPEEDR |= (GPIO_OSPEEDR_OSPEED1 | GPIO_OSPEEDR_OSPEED2); GPIOC->AFR[0] &= ~(GPIO_AFRL_AFSEL1 | GPIO_AFRL_AFSEL2); GPIOC->AFR[0] |= (9U << GPIO_AFRL_AFSEL1_Pos) | (9U << GPIO_AFRL_AFSEL2_Pos); // PD13 -> QSPI_IO3 (AF9) GPIOD->MODER &= ~GPIO_MODER_MODE13; GPIOD->MODER |= GPIO_MODER_MODE13_1; GPIOD->OSPEEDR |= GPIO_OSPEEDR_OSPEED13; GPIOD->AFR[1] &= ~GPIO_AFRH_AFSEL13; GPIOD->AFR[1] |= (9U << GPIO_AFRH_AFSEL13_Pos); // PE2 -> QSPI_IO2 (AF9) GPIOE->MODER &= ~GPIO_MODER_MODE2; GPIOE->MODER |= GPIO_MODER_MODE2_1; GPIOE->OSPEEDR |= GPIO_OSPEEDR_OSPEED2; GPIOE->AFR[0] &= ~GPIO_AFRL_AFSEL2; GPIOE->AFR[0] |= (9U << GPIO_AFRL_AFSEL2_Pos); // PG6 -> QSPI_CS (AF10) GPIOG->MODER &= ~GPIO_MODER_MODE6; GPIOG->MODER |= GPIO_MODER_MODE6_1; GPIOG->OSPEEDR |= GPIO_OSPEEDR_OSPEED6; GPIOG->AFR[0] &= ~GPIO_AFRL_AFSEL6; GPIOG->AFR[0] |= (10U << GPIO_AFRL_AFSEL6_Pos); // 配置QSPI外设 QUADSPI->CR = 0; // 先关闭QSPI // FSIZE=21: 表示Flash容量为2^(21+1)=4MB,CSHT=4个时钟周期 QUADSPI->DCR = (21U << 16) | (4U << 0); // 设置时钟分频,PRESCALER=2,QCLK = AHB时钟 / (2*2) QUADSPI->CR |= (2U << 24); }这里最关键的是DCR的FSIZE字段。FSIZE=21对应Flash容量为4MB,这是根据容量字节数 = 2^(FSIZE+1)反推出来的。如果这个值配错,QSPI的地址管理会出问题,轻则Memory Map区域无法正确映射,重则Flash操作直接卡死。
还有一个细节是片选高电平时间CSHT。W25Q32在每次命令结束到下一次命令开始之间需要一定的CS高电平时间(tSH),如果CSHT配置太小,Flash可能来不及“换状态”,导致下一个命令被忽略。配置成4个QSPI时钟周期,在100MHz以下的QSPI时钟频率下都是安全的。
4.2 Init函数:初始化并验证Flash ID
Init函数除了调用QSPI_InitHardware完成引脚和外设初始化,我习惯在这里顺手读一下W25Q32的JEDEC ID,确认芯片物理连接正确。这样MDK在下载前就能发现Flash接错或虚焊的问题,而不是等到擦除/编程时才报一堆莫名其妙错误。
int Init(unsigned long adr, unsigned long clk, unsigned long fnc) { uint32_t jedec_id = 0; QSPI_InitHardware(); // 间接读模式:读取JEDEC ID QUADSPI->CR &= ~QUADSPI_CR_EN; QUADSPI->DLR = 2; // 需要读3字节 QUADSPI->CCR = (0x9FU << 24) | (0x01U << 22) | // INSTRUCTION=0x9F, IMODE=单线 (0x01U << 8) | (0x01U << 0); // DMODE=单线, FMODE=间接读 QUADSPI->CR |= QUADSPI_CR_EN; // 读取3个字节的ID while((QUADSPI->SR & QUADSPI_SR_FTF) == 0); jedec_id = (QUADSPI->DR & 0xFFU); while((QUADSPI->SR & QUADSPI_SR_FTF) == 0); jedec_id |= ((QUADSPI->DR & 0xFFU) << 8); while((QUADSPI->SR & QUADSPI_SR_FTF) == 0); jedec_id |= ((QUADSPI->DR & 0xFFU) << 16); if(jedec_id != W25Q32_JEDEC_ID) { return 1; // Flash ID不匹配,返回错误,MDK会提示 } QUADSPI->CR &= ~QUADSPI_CR_EN; return 0; }这里的CCR配置逻辑要说明一下。0x9F是读JEDEC ID的标准命令,不需要地址,所以ADMODE为0;数据从QSPI总线上以单线方式返回,所以DMODE为1;FMODE为01表示间接读模式,数据会进入FIFO,通过读DR寄存器取出。
如果返回非0,MDK会在下载日志里显示“Error: Flash Download failed - Target DLL has been cancelled”,同时给出一个错误代码。这点在调试硬件连接时非常有用——如果Flash芯片没焊好,MDK会立刻报ID错误,而不是等到下载中段才失败。
4.3 写使能与忙等待的基础操作
在擦除和编程操作之前,QSPI Flash都需要先发写使能命令(0x06),让Flash的WEL位变为1。否则后续的擦除/编程命令会被Flash直接忽略。
static void QSPI_WriteEnable(void) { QUADSPI->CR &= ~QUADSPI_CR_EN; // 0x06写使能:仅指令,无地址,无数据,间接写模式 QUADSPI->CCR = (0x06U << 24) | (0x01U << 22) | (0x00U << 0); QUADSPI->CR |= QUADSPI_CR_EN; while(QUADSPI->SR & QUADSPI_SR_BUSY); }等待Flash内部操作完成也很关键。W25Q32执行扇区擦除(约几十毫秒)、页编程(约0.7毫秒)时,状态寄存器的WIP位会保持为1。我们需要不断读取状态寄存器(0x05),直到WIP位清零才能进行下一步操作。
static void QSPI_WaitBusy(void) { uint8_t sr; do { QUADSPI->CR &= ~QUADSPI_CR_EN; QUADSPI->DLR = 0; // 读1字节 // 0x05读状态寄存器,数据单线,间接读模式 QUADSPI->CCR = (0x05U << 24) | (0x01U << 22) | (0x01U << 8) | (0x01U << 0); QUADSPI->CR |= QUADSPI_CR_EN; while(QUADSPI->SR & QUADSPI_SR_BUSY); while((QUADSPI->SR & QUADSPI_SR_FTF) == 0); sr = (uint8_t)(QUADSPI->DR & 0xFFU); } while(sr & 0x01U); // WIP位为1就继续等 }这里有个容易踩的坑:在调用QSPI_WaitBusy之前,一定要先等上一个命令的BUSY标志清零,再来配置新命令。否则上一次的传输还没结束,你改了CCR/AR等寄存器,会导致当前命令状态错乱,Flash可能进入死锁状态。这也是我在每个辅助函数里都在操作寄存器前先执行QUADSPI->CR &= ~QUADSPI_CR_EN的原因。
4.4 擦除函数的实现
W25Q32支持整片擦除(0xC7)、64KB块擦除(0xD8)、32KB块擦除(0x52)和4KB扇区擦除(0x20)。在FLM算法里,我主要实现扇区擦除和整片擦除两种。
int EraseSector(unsigned long adr) { unsigned long offset = adr - QSPI_FLASH_BASE; QSPI_WriteEnable(); // 4KB扇区擦除命令 0x20 QUADSPI->CR &= ~QUADSPI_CR_EN; QUADSPI->CCR = (0x20U << 24) | (0x01U << 22) | // INSTRUCTION=0x20, IMODE=单线 (0x01U << 20) | (0x03U << 18) | // ADMODE=单线, ADSIZE=3字节 (0x00U << 8) | (0x00U << 0); // 无数据, 间接写模式 QUADSPI->AR = offset; QUADSPI->CR |= QUADSPI_CR_EN; while(QUADSPI->SR & QUADSPI_SR_BUSY); QSPI_WaitBusy(); // 等待擦除完成 return 0; } int EraseChip(void) { QSPI_WriteEnable(); // 整片擦除命令 0xC7 QUADSPI->CR &= ~QUADSPI_CR_EN; QUADSPI->CCR = (0xC7U << 24) | (0x01U << 22) | // INSTRUCTION=0xC7, IMODE=单线 (0x00U << 8) | (0x00U << 0); // 无地址无数据, 间接写模式 QUADSPI->CR |= QUADSPI_CR_EN; while(QUADSPI->SR & QUADSPI_SR_BUSY); QSPI_WaitBusy(); return 0; }注意EraseSector传入的adr参数是绝对地址(比如0x90001000),我们传给AR寄存器时需要减去0x90000000得到Flash内的相对偏移。W25Q32的4KB扇区擦除命令是一个地址周期的命令,AR寄存器里放着要擦除的扇区起始地址。
4.5 ProgramPage分页编程的实现
W25Q32的页编程支持一次最多写256字节,且不能跨256字节边界。因此ProgramPage函数里必须处理地址对齐问题——哪怕MDK传过来4096字节的数据,我也要按“当前地址在页内的偏移”来分块编程。
int ProgramPage(unsigned long adr, unsigned long sz, unsigned char *buf) { unsigned long offset = adr - QSPI_FLASH_BASE; unsigned long page_off, chunk, i; while(sz > 0) { page_off = offset & 0xFFUL; // 当前地址在页内的偏移 chunk = 256UL - page_off; // 当前页剩余空间 if(chunk > sz) chunk = sz; QSPI_WriteEnable(); // 页编程命令 0x02 QUADSPI->CR &= ~QUADSPI_CR_EN; QUADSPI->CCR = (0x02U << 24) | (0x01U << 22) | (0x01U << 20) | (0x03U << 18) | (0x01U << 8) | (0x00U << 0); QUADSPI->AR = offset; QUADSPI->DLR = chunk - 1; QUADSPI->CR |= QUADSPI_CR_EN; // 数据写入FIFO,硬件会自动通过IO发送给Flash for(i = 0; i < chunk; i++) { while((QUADSPI->SR & QUADSPI_SR_FTF) == 0); QUADSPI->DR = buf[i]; } // 等待数据发送完成 while(QUADSPI->SR & QUADSPI_SR_BUSY); // 等待Flash内部编程完成 QSPI_WaitBusy(); offset += chunk; buf += chunk; sz -= chunk; } return 0; }关键点是DLR寄存器的值为“要发送的字节数减1”,AR寄存器存的是当前页的起始地址。数据写入DR寄存器后,QSPI外设会按照CCR配置的数据模式把这些字节发给Flash。
为什么每次写DR前要等待FTF标志?因为H7的QSPI FIFO深度只有16字节,连续写入超过16字节的数据会导致FIFO溢出。FTF标志在FIFO还有空间时会置1(在写方向),所以用它来限流。
4.6 Verify校验函数的实现
MDK在编程完成后默认会调用Verify函数,把Flash里的数据和原始数据比对。这里我使用0x03单线读命令。
unsigned long Verify(unsigned long adr, unsigned long sz, unsigned char *buf) { unsigned long offset = adr - QSPI_FLASH_BASE; uint8_t rd; QUADSPI->CR &= ~QUADSPI_CR_EN; QUADSPI->DLR = sz - 1; // 读取sz字节 QUADSPI->CCR = (0x03U << 24) | (0x01U << 22) | (0x01U << 20) | (0x03U << 18) | (0x01U << 8) | (0x01U << 0); QUADSPI->AR = offset; QUADSPI->CR |= QUADSPI_CR_EN; for(unsigned long i = 0; i < sz; i++) { while((QUADSPI->SR & QUADSPI_SR_FTF) == 0); rd = (uint8_t)(QUADSPI->DR & 0xFFU); if(rd != buf[i]) { return adr + i; // 返回第一个失败地址 } } return 0; }Verify返回0表示全部一致;非0则返回第一个不一致的地址。MDK会根据返回值在日志里醒目标红“Verify Failed at xxxxxxxx”。
5. 接入MDK下载流程并实测烧写
5.1 Flash Download页面配置
编译生成FLM文件后,接下来把它注册到MDK的下载配置里。以ST-Link为例:
- 打开App工程(不是FLM工程),进入
Options for Target -> Debug,选择ST-Link,点击旁边的Settings。 - 切换到
Flash Download选项卡,点击Add按钮。 - 在弹出的文件选择框中找到我们编译生成的
W25Q32_QSPI.FLM,选中并确认。 - 在
Programming Algorithm列表中会出现“W25Q32_QSPI_4MB”,然后设置:- Start: 0x90000000
- Size: 0x400000
- RAM for Algorithm Start: 0x24000000
- RAM for Algorithm Size: 0x8000
- 编程选项建议勾选
Erase Sectors,这样MDK只会擦除目标数据所在的扇区,比整片擦除更快,也更安全。
这里的RAM for Algorithm就是MDK用来加载FLM算法镜像的RAM区域,必须和FLM工程编译时使用的IROM1起始地址一致。我之前在FLM工程里把代码放在0x24000000,所以这里填0x24000000;Size给0x8000(32KB),对于这个算法完全够用。
5.2 一个验证用的QSPI App工程
为了验证下载算法是否正常工作,我建了一个极简的App工程:
- 新建工程,选择STM32H750VBTx。
- 在
Options for Target -> Target页面,把IROM1改成:- Start: 0x90000000
- Size: 0x400000
- IRAM1保持默认:Start=0x20000000,Size=0x20000(使用DTCM)。
- 编写一个点亮板载LED的代码:
#include "stm32h7xx.h" void SystemInit(void) { // 实际工程中会调用SystemInit,这里简单置空 } int main(void) { RCC->AHB4ENR |= RCC_AHB4ENR_GPIOBEN; GPIOB->MODER &= ~GPIO_MODER_MODE0; GPIOB->MODER |= GPIO_MODER_MODE0_0; // PB0输出 while(1) { GPIOB->BSRR = GPIO_BSRR_BS0; for(volatile int i = 0; i < 1000000; i++); GPIOB->BSRR = GPIO_BSRR_BR0; for(volatile int i = 0; i < 1000000; i++); } }这个工程代码段很小,下载到0x90000000后会在QSPI Flash里占一个很小的区域。但要注意:这段代码是不能直接从复位后开始运行的,因为H750上电时不会自动初始化QSPI外设,所以后面我们必须搭配Bootloader来验证XIP执行。
5.3 下载实测:MDK里的烧写日志
在App工程里点击Download后,MDK的Build Output窗口会输出类似下面的日志:
Load "C:\\project\\app\\app.axf" Erase Done. Programming Done. Verify OK. Application running ...看到“Erase Done”和“Programming Done”只用了不到1秒,你就知道这算法成了。我实测烧写一个32KB的测试固件到QSPI Flash,整个过程只有几百毫秒,对比串口升级每分钟几KB的速度,完全是两个时代。
下载完成后,在MDK的Memory窗口里输入0x90000000,应该能看到App的机器码。如果能看到0x20000000或者其他RAM地址相关的变量初始化数据,说明数据确实写进去了。
5.4 常见失败原因与排查路径
我在实际操作中遇到了多次下载失败,最有价值的几个排查方向如下:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| Flash Download failed - Target DLL has been cancelled | QSPI引脚配置错误或Flash ID校验失败 | 单独编写一个测试程序,在RAM里跑QSPI初始化并读取JEDEC ID |
| Verify failed at address 0x90000xxx | QSPI时钟频率过高或Flash处于异常状态 | 调大PRESCALER分频值,比如把QCLK降到30MHz以下再试 |
| Erase Operation failed | 写使能命令没有正确发送 | 检查QSPI_WriteEnable中CCR配置,0x06命令的FMODE必须是间接写模式 |
| Programming failed | FIFO溢出导致数据丢失 | 检查ProgramPage里写DR前是否等待FTF标志 |
| 下载完成后程序无法运行 | 缺少Bootloader初始化QSPI并切换Memory-Mapped模式 | 参考第6.3节,编写一个内置Flash里的启动引导程序 |
排查时优先检查JEDEC ID是否能正常读取。只要ID读不出来,后面所有操作都白搭。而且JEDEC ID读取这个动作本身就能验证一半的问题:如果ID能读出来,说明GPIO配置、QSPI时序、Flash供电和焊接基本没问题。
6. 做算法期间踩过的坑和几条优化思路
6.1 编码、缓存、时钟这些容易绊脚的坑
第一个坑是MDK的工程编码问题。MDK默认用GB2312编码保存源文件,如果你在代码里写中文注释,然后在其他编辑器里打开时会乱码。这个问题在FLM算法工程里尤其恶心,因为算法跑在芯片RAM里,如果编译器把中文字符串常量生成到代码段里,FLM的体积会变大,甚至可能导致RAM空间不够。我的建议是FLM工程里所有注释和字符串都用英文。
第二个坑是Cortex-M7的缓存(ICache/DCache)对FLM算法的影响。在MDK加载算法到RAM后,芯片的缓存默认是关闭的,所以不会有问题。但如果在Bootloader或者App里已经打开了缓存,再回MDK里做二次下载,残留的缓存数据可能导致QSPI读取错误。遇到这种情况,最直接的办法是在下载前先复位芯片,让缓存回到关闭状态,再执行Flash操作。
第三个坑是QSPI时钟频率。H750在480MHz主频下,AHB时钟可能是240MHz。如果QSPI的PRESCALER配成0,QCLK就是240MHz,远超W25Q32的133MHz上限,必然通信失败。我在Init里设置PRESCALER=2,QCLK=AHB/(2×2)=60MHz,这是一个各种环境都安全的保守值。如果你想压榨速度,可以在你的硬件上逐步降低分频值做稳定性测试。
6.2 提升下载速度的思路
FLM算法现在的实现是单线模式(SPI Mode)下读写,速度直观感受是够用了。但如果你想追求更快的下载速度,可以从几个方向优化:
- 把ProgramPage里的写数据模式改为四线模式(DMODE=2),使用0x32四线页编程命令。这样数据通过4根线并行传输,理论速度提升4倍。
- 把Verify里的读命令从0x03改为0x6B(四线快速读),并设置合适的Dummy Cycles。
- 在FlashDevice结构体里把页大小从4096改成8192甚至更大,这样MDK一次调用ProgramPage传入的数据更多,减少函数调用次数。
不过要注意:四线模式下,地址、指令、数据的发送模式都要改,涉及的CCR配置和Dummy Cycles都要重新微调。建议先把单线版本跑通,再做提速优化。
6.3 下一步:QSPI XIP加载App
下载算法解决的是“烧写”问题,但要让H750从QSPI Flash里运行代码,还得处理“启动”问题。
由于H750不像某些芯片那样支持直接从QSPI Flash启动,我们需要在内置Flash里放一个很小的Bootloader,它的工作流程是:
- 初始化QSPI外设和GPIO。
- 把QSPI切换到Memory-Mapped模式(FMODE=11),让0x90000000地址空间可以直接访问Flash内容。
- 把中断向量表寄存器VTOR设置为0x90000000。
- 读取QSPI Flash前4字节(初始SP值)设置主栈指针,然后跳转到0x90000004处的Reset_Handler执行。
这个Bootloader只需要几十行C代码,配合我们的下载算法,整个开发流程就变成:内置Flash里放Bootloader,QSPI Flash里放App,所有代码下载都在MDK里一键完成。调试时还可以直接在QSPI地址上打断点,这在以前用串口升级方案时想都不敢想。
如果你的App工程里配置了中断向量表偏移(比如SCB->VTOR = 0x90000000),还需要注意调试器加载符号时的地址匹配问题。在Options for Target -> Debug -> Settings -> Flash Download里勾选“Reset and Run”,这样下载完会自动复位运行,Bootloader会完成QSPI初始化和跳转,IDE和硬件两边都不会打架。
做这个下载算法的过程中,我最深的体会是:FLM算法本质上就是把“操作Flash的驱动”打包成一个可加载执行的RAM程序,它和你在Bootloader里写的QSPI驱动在逻辑上没有本质区别,只是多了一层和MDK之间的接口协议。只要把这个协议摸清楚,后面给任何外部Flash做下载算法,都只是换几个命令码和参数的事。希望这篇文章能让更多人跳过那些我曾经踩过的坑,直接在MDK里享受一键烧写的快感。