1. 为什么需要自己动手做FLM下载算法
1.1 一个让很多人卡住的真实场景
你手头有一块STM32F407的板子,外挂了一颗W25Q128 SPI Flash,容量16MB。产品固件越来越大,内部2MB的Flash已经装不下了,于是你把一部分资源文件、字库、甚至整个APP镜像放到了外部SPI Flash里。调试阶段你用Keil MDK烧录内部Flash一切正常,但每次要更新外部Flash里的数据,就得单独打开一个下载工具、手动加载bin文件、选地址、点下载——来回切换,效率极低。
更麻烦的是量产阶段。产线上不可能让工人手动操作两个工具,你希望Keil MDK点一下"Download"按钮,内部Flash和外部SPI Flash一起搞定。这时候你就需要FLM下载算法文件。
FLM是Flash Loader Module的缩写,本质是一个.FLM后缀的动态链接库(实际是ELF格式),Keil MDK在烧录时把它加载进RAM执行,由它负责擦除、编程、校验外部Flash。Keil自带的算法库只覆盖常见厂商的并行NOR Flash和部分SPI Flash,W25Q128虽然常见,但你的硬件连接方式(哪个SPI接口、哪个GPIO做片选、时钟多少)千差万别,所以官方算法大概率不匹配,必须自己生成。
1.2 这篇内容适合谁看
如果你满足以下任意一条,这篇内容就是写给你的:
- 用STM32F407+W25Q128做项目,需要在Keil MDK里直接烧录外部SPI Flash
- 想理解FLM文件的生成原理,而不只是照抄一个现成文件
- 做IAP升级、OTA方案,需要把外部Flash的读写逻辑固化到下载算法里
- 单纯好奇Keil的下载算法是怎么跑起来的
我默认你有基本的Keil MDK使用经验,知道怎么新建工程、配置时钟、写GPIO初始化代码。如果你连STM32CubeMX都没打开过,建议先补一下基础再来看。
1.3 整体思路先讲清楚
Keil MDK的下载算法本质上是一个运行在STM32内部RAM里的独立程序。它不依赖你的主工程,有自己的入口函数、自己的时钟配置、自己的SPI驱动。Keil在烧录时会做这几件事:
- 把FLM文件加载到STM32的RAM中(地址由算法工程的分散加载文件决定)
- 跳转到算法的入口,传入操作类型(擦除/编程/校验)和地址、数据
- 算法执行对应的Flash操作,返回结果
- Keil根据返回值判断成功与否
所以我们要做的,就是写一个符合Keil算法接口规范的工程,编译出FLM文件,放到Keil的算法目录里,然后在目标工程的Flash Download配置里选中它。
整个流程分四步:建算法工程 → 写驱动代码 → 配置分散加载 → 编译部署。下面逐步拆开讲。
2. 算法工程搭建与关键配置
2.1 工程目录结构怎么规划
我习惯把算法工程和主工程分开存放,目录结构大概是这样:
FlashAlgo_W25Q128/ ├── MDK-ARM/ │ ├── FlashAlgo.uvprojx │ └── FlashAlgo.uvoptx ├── Drivers/ │ ├── CMSIS/ │ └── STM32F4xx_HAL_Driver/ ├── Inc/ │ ├── flash_algo.h │ └── w25q128.h ├── Src/ │ ├── flash_algo.c │ ├── w25q128.c │ └── system_init.c └── FlashAlgo.sct关键点:算法工程不要用HAL库的完整初始化流程。HAL库的HAL_Init()会配置SysTick中断、NVIC优先级分组,这些在下载算法环境里可能没有意义,甚至会导致异常。我一般只保留最底层的寄存器操作,或者用HAL的__HAL_RCC_xxx_CLK_ENABLE()宏来开时钟,SPI部分直接操作寄存器。
2.2 Keil工程的目标配置
新建工程时选STM32F407对应的器件(比如STM32F407ZGTx),然后重点改这几个地方:
Target选项卡:
- 勾选"Use MicroLIB",减小代码体积
- 不勾选"Use Cross-Module Optimization"(算法工程不需要)
Output选项卡:
- 输出文件名设为
W25Q128_F407(最终会生成W25Q128_F407.FLM) - 勾选"Create Executable"(不是Library)
Linker选项卡:
- 取消勾选"Use Memory Layout from Target Dialog"
- 在Scatter File里指定我们自己的
.sct文件
C/C++选项卡:
- Define里加上
STM32F407xx, USE_HAL_DRIVER - Optimization选
-O2或-Os,算法对体积敏感
注意:算法工程的RAM地址必须和主工程实际可用的RAM区域匹配。STM32F407有128KB SRAM(112KB+16KB),算法一般放在0x20000000起始的区域,大小给8KB~16KB足够。
2.3 分散加载文件怎么写
这是最容易出错的地方。FLM文件被Keil加载后,代码和数据都放在RAM里执行,所以分散加载文件要这样写:
LR_IROM1 0x20000000 0x00004000 { ER_IROM1 0x20000000 0x00004000 { *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) } RW_IRAM1 0x20004000 0x00004000 { .ANY (+RW +ZI) } }解释一下:LR_IROM1定义加载区域从0x20000000开始,大小16KB;ER_IROM1是执行区域,同样地址;RW_IRAM1放读写数据和零初始化数据,从0x20004000开始,再给16KB。
为什么这么分?因为Keil加载FLM时,会把整个镜像搬到0x20000000,然后跳转执行。如果代码段和数据段地址重叠,或者超出了实际RAM范围,就会HardFault。
我实测下来,STM32F407的CCM RAM(0x10000000起始的64KB)不能用于DMA,但算法里如果不用DMA,放CCM也没问题。不过为了通用性,还是放普通SRAM最稳。
2.4 算法接口函数长什么样
Keil的FLM需要实现一组固定签名的函数,定义在flash_algo.h里。核心结构体是FlashDevice,长这样:
typedef struct { unsigned short Vers; unsigned short DevType; unsigned long DevAdr; unsigned long szDev; unsigned long szPage; unsigned long Res; unsigned char valEmpty; unsigned long toProg; unsigned long toErase; unsigned char fRet; unsigned long toCheck; unsigned long szPageCheck; } FlashDevice;对应的函数有:
int Init(unsigned long adr, unsigned long clk, unsigned long fnc); int UnInit(unsigned long fnc); int EraseChip(void); int EraseSector(unsigned long adr); int ProgramPage(unsigned long adr, unsigned long sz, unsigned char *buf); unsigned long Verify(unsigned long adr, unsigned long sz, unsigned char *buf);这些函数名不能改,参数类型也不能改,否则Keil加载时会找不到符号。Init里做SPI和GPIO初始化,EraseSector里发擦除命令,ProgramPage里发页编程命令,Verify里读回来比对。
3. W25Q128驱动代码的实操细节
3.1 SPI接口和GPIO的初始化
W25Q128支持标准SPI、Dual SPI、Quad SPI。为了简单可靠,我们用标准SPI模式。STM32F407的SPI1挂在APB2上,最高时钟84MHz,但W25Q128的标准SPI最高只支持104MHz(部分型号),实际用的时候分频到21MHz或42MHz比较稳。
初始化代码大概这样:
void W25Q128_Init(void) { GPIO_InitTypeDef gpio; SPI_InitTypeDef spi; __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_GPIOB_CLK_ENABLE(); __HAL_RCC_SPI1_CLK_ENABLE(); // PA5=SCK, PA6=MISO, PA7=MOSI gpio.Pin = GPIO_PIN_5 | GPIO_PIN_6 | GPIO_PIN_7; gpio.Mode = GPIO_MODE_AF_PP; gpio.Pull = GPIO_NOPULL; gpio.Speed = GPIO_SPEED_FREQ_VERY_HIGH; gpio.Alternate = GPIO_AF5_SPI1; HAL_GPIO_Init(GPIOA, &gpio); // PB6=CS, 普通推挽输出 gpio.Pin = GPIO_PIN_6; gpio.Mode = GPIO_MODE_OUTPUT_PP; gpio.Pull = GPIO_PULLUP; gpio.Speed = GPIO_SPEED_FREQ_VERY_HIGH; HAL_GPIO_Init(GPIOB, &gpio); W25Q128_CS_HIGH(); spi.Mode = SPI_MODE_MASTER; spi.Direction = SPI_DIRECTION_2LINES; spi.DataSize = SPI_DATASIZE_8BIT; spi.CLKPolarity = SPI_POLARITY_LOW; spi.CLKPhase = SPI_PHASE_1EDGE; spi.NSS = SPI_NSS_SOFT; spi.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_4; // 84/4=21MHz spi.FirstBit = SPI_FIRSTBIT_MSB; spi.TIMode = SPI_TIMODE_DISABLE; spi.CRCCalculation = SPI_CRCCALCULATION_DISABLE; HAL_SPI_Init(&SPI1, &spi); __HAL_SPI_ENABLE(&SPI1); }这里有个坑:算法环境里HAL_Delay可能不可用,因为SysTick没初始化。W25Q128的擦除和编程需要等待忙状态,不能用HAL_Delay,得自己写循环延时或者读状态寄存器轮询。
3.2 读状态寄存器与忙等待
W25Q128的状态寄存器1的bit0是BUSY位,bit1是WEL(写使能锁存)。擦除和编程前要先发Write Enable(0x06),然后轮询BUSY位直到为0。
static uint8_t W25Q128_ReadSR1(void) { uint8_t cmd = 0x05; uint8_t sr; W25Q128_CS_LOW(); HAL_SPI_Transmit(&SPI1, &cmd, 1, 100); HAL_SPI_Receive(&SPI1, &sr, 1, 100); W25Q128_CS_HIGH(); return sr; } static void W25Q128_WaitBusy(void) { uint32_t timeout = 0xFFFFFF; while ((W25Q128_ReadSR1() & 0x01) && timeout--) { __NOP(); } }超时值给0xFFFFFF是经验值。W25Q128的扇区擦除典型时间45ms,最大400ms;整片擦除典型20s,最大100s。如果超时设太小,大扇区擦除会误判失败。但也不能无限等,否则算法卡死Keil会报超时。
3.3 扇区擦除和页编程的实现
W25Q128的擦除粒度是4KB(扇区)、32KB(块)、64KB(块)、整片。Keil的EraseSector每次传一个地址进来,我们按4KB对齐擦除。
int EraseSector(unsigned long adr) { uint8_t cmd[4]; W25Q128_WriteEnable(); cmd[0] = 0x20; // Sector Erase cmd[1] = (adr >> 16) & 0xFF; cmd[2] = (adr >> 8) & 0xFF; cmd[3] = adr & 0xFF; W25Q128_CS_LOW(); HAL_SPI_Transmit(&SPI1, cmd, 4, 100); W25Q128_CS_HIGH(); W25Q128_WaitBusy(); return 0; }页编程的页大小是256字节。Keil的ProgramPage可能一次传256字节,也可能传更少。要注意不能跨页写,如果起始地址不是页对齐,或者长度超过页边界,需要拆分成多次。
int ProgramPage(unsigned long adr, unsigned long sz, unsigned char *buf) { uint8_t cmd[4]; W25Q128_WriteEnable(); cmd[0] = 0x02; // Page Program cmd[1] = (adr >> 16) & 0xFF; cmd[2] = (adr >> 8) & 0xFF; cmd[3] = adr & 0xFF; W25Q128_CS_LOW(); HAL_SPI_Transmit(&SPI1, cmd, 4, 100); HAL_SPI_Transmit(&SPI1, buf, sz, 1000); W25Q128_CS_HIGH(); W25Q128_WaitBusy(); return 0; }实操心得:W25Q128的页编程在跨页时会回卷到页首覆盖数据,这是硬件行为。Keil一般会保证按页对齐调用,但如果你自己写测试代码,务必注意这一点。我踩过一次坑,连续写300字节,结果后44字节覆盖了页首,数据全乱。
3.4 FlashDevice结构体的填充
这个结构体告诉Keil外部Flash的参数:
struct FlashDevice const FlashDevice = { FLASH_DRV_VERS, // 版本号,固定0x0100 EXTSPI, // 设备类型,外部SPI Flash 0x00000000, // 设备起始地址(相对地址) 0x01000000, // 设备大小16MB 4096, // 编程页大小,W25Q128是256,但Keil按扇区管理 0, // 保留 0xFF, // 擦除后的值 100, // 编程超时ms 3000, // 擦除超时ms 0x00, // 返回值 100, // 校验超时ms 256 // 校验页大小 };这里szPage填4096还是256有讲究。Keil用这个值决定擦除粒度,如果填256,Keil会按256字节擦除,但W25Q128最小擦除是4KB,会出错。所以填4096,让Keil按扇区擦除。szPageCheck填256,校验时按页读。
4. 编译、部署与调试验证
4.1 编译出FLM文件
工程配置好后点编译,Keil会在输出目录生成.FLM文件。如果报错"L6218E: Undefined symbol",检查函数名和签名是否和标准一致。如果报错"L6406E: No space in execution regions",说明分散加载的RAM区域太小,把0x4000改大一点。
编译成功后,把.FLM文件复制到Keil的算法目录:
Keil安装目录\ARM\Flash\或者放在工程目录下,在Flash Download配置里手动指定路径。
4.2 在主工程里配置下载算法
打开你的STM32F407主工程,进入Options → Debug → Settings → Flash Download:
- 点击"Add",选中你生成的
W25Q128_F407.FLM - 起始地址填
0x00000000(如果外部Flash映射到0x00000000)或实际映射地址 - 大小填
0x01000000(16MB) - 勾选"Erase Sectors"和"Program"、"Verify"
如果外部Flash没有映射到CPU地址空间(比如通过SPI读写,不是FSMC映射),那Keil的下载地址需要和你的算法里地址对应。通常做法是把外部Flash的0地址映射到某个CPU地址,比如0x00000000,算法里直接用这个地址。
4.3 用Keil直接烧录验证
配置好后,点Download按钮。Keil会先擦除内部Flash,再调用你的算法擦除外部Flash,然后编程。如果一切正常,Output窗口会显示:
Erase Done. Programming Done. Verify OK.如果卡在"Erase Done"之后不动,大概率是WaitBusy死循环了。用调试器连上,在W25Q128_WaitBusy里打断点,看状态寄存器返回值。常见原因:SPI没初始化成功、CS引脚没拉低、时钟没开。
4.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| Keil报"Flash Download failed" | FLM文件路径不对或未选中 | 检查Flash Download配置里的算法列表 |
| 擦除超时 | WaitBusy超时值太小 | 增大timeout,或检查SPI通信是否正常 |
| 编程后校验失败 | 页编程跨页或未发Write Enable | 检查ProgramPage的地址对齐和WEL位 |
| HardFault | 分散加载RAM地址冲突 | 检查.sct文件,确保代码和数据不重叠 |
| 算法加载后不执行 | 入口函数签名不对 | 对照Keil官方模板检查函数名和参数 |
| SPI读回全0xFF | MISO引脚配置错误 | 检查GPIO复用和SPI模式 |
独家避坑:如果你的板子上W25Q128的CS引脚和主工程里其他外设复用,算法里初始化时一定要先把CS拉高,再配置为输出。我遇到过CS悬空导致Flash误进入某种状态,折腾了半天。
5. 进阶优化与扩展思路
5.1 提高擦除和编程速度
标准SPI在21MHz下,页编程256字节大约需要0.7ms(不含等待),扇区擦除45ms。如果换成Quad SPI,理论速度能翻4倍。但Quad SPI需要额外的GPIO(IO2、IO3),而且算法里要发0x38进入QPI模式,复杂度高不少。
另一个优化点是减少Verify次数。Keil默认编程后校验,如果对速度要求高,可以在Flash Download配置里取消"Verify",产线烧录能省不少时间。
5.2 支持多片Flash或不同容量
如果板子上挂了多片W25Q128,或者用了W25Q64、W25Q256,只需要改FlashDevice结构体里的szDev和算法里的地址计算。W25Q256是32MB,地址需要4字节模式,要发0xB7进入4字节地址模式,这个在Init里处理。
5.3 和IAP/OTA方案的配合
做4G OTA或者以太网OTA时,外部Flash通常存的是待升级的固件包。下载算法负责把固件包烧到外部Flash,Bootloader再从外部Flash读到内部Flash。这时候要注意:算法里的地址映射要和Bootloader里的读写地址一致。我一般把外部Flash的0x00000000映射到CPU的0x00000000,Bootloader里用SPI读的时候也按这个偏移算。
5.4 调试算法本身的技巧
算法在Keil里不能直接单步调试,因为它是被Keil加载执行的。我的做法是:先把算法代码放到一个普通工程里,用主循环调用EraseSector、ProgramPage,通过串口打印结果,确认逻辑没问题后,再改成FLM工程编译。这样能省很多调试时间。
另外,Keil的Output窗口会打印算法返回的错误码。如果fRet非0,可以在ProgramPage里根据返回值设置不同的错误码,方便定位。
6. 我个人在实际操作中的几点体会
第一次做FLM的时候,我卡在分散加载文件上整整一个下午。代码编译没问题,但Keil加载后直接HardFault。后来用调试器看反汇编,发现代码段被链接到了0x08000000(内部Flash地址),而Keil把它加载到0x20000000执行,地址对不上。改.sct文件后一次通过。
还有一次,W25Q128的扇区擦除总是超时。查了半天发现是SPI的时钟极性配错了。W25Q128支持Mode 0和Mode 3,我配成了Mode 2,读状态寄存器返回全0xFF,BUSY位一直是1。改成Mode 0就好了。这种问题看数据手册的时序图能快速定位。
最后一个建议:算法工程里不要用printf。MicroLIB的printf会占用不少空间,而且算法环境没有串口初始化,printf会卡死。要调试就用GPIO翻转或者把结果写到某个RAM地址,用调试器看。
这个FLM文件做好之后,我把它用在了三个不同的STM32F407项目上,只要SPI引脚和时钟配置一致,直接复用,省了大量重复劳动。如果你也在做类似的事情,希望这篇内容能帮你少走点弯路。