
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 FlashW25Q128虽然常见但你的硬件连接方式哪个SPI接口、哪个GPIO做片选、时钟多少千差万别所以官方算法大概率不匹配必须自己生成。1.2 这篇内容适合谁看如果你满足以下任意一条这篇内容就是写给你的用STM32F407W25Q128做项目需要在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不是LibraryLinker选项卡取消勾选Use Memory Layout from Target Dialog在Scatter File里指定我们自己的.sct文件C/C选项卡Define里加上STM32F407xx, USE_HAL_DRIVEROptimization选-O2或-Os算法对体积敏感注意算法工程的RAM地址必须和主工程实际可用的RAM区域匹配。STM32F407有128KB SRAM112KB16KB算法一般放在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开始大小16KBER_IROM1是执行区域同样地址RW_IRAM1放读写数据和零初始化数据从0x20004000开始再给16KB。为什么这么分因为Keil加载FLM时会把整个镜像搬到0x20000000然后跳转执行。如果代码段和数据段地址重叠或者超出了实际RAM范围就会HardFault。我实测下来STM32F407的CCM RAM0x10000000起始的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(); // PA5SCK, PA6MISO, PA7MOSI 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); // PB6CS, 普通推挽输出 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/421MHz 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 Enable0x06然后轮询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用这个值决定擦除粒度如果填256Keil会按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或实际映射地址大小填0x0100000016MB勾选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 failedFLM文件路径不对或未选中检查Flash Download配置里的算法列表擦除超时WaitBusy超时值太小增大timeout或检查SPI通信是否正常编程后校验失败页编程跨页或未发Write Enable检查ProgramPage的地址对齐和WEL位HardFault分散加载RAM地址冲突检查.sct文件确保代码和数据不重叠算法加载后不执行入口函数签名不对对照Keil官方模板检查函数名和参数SPI读回全0xFFMISO引脚配置错误检查GPIO复用和SPI模式独家避坑如果你的板子上W25Q128的CS引脚和主工程里其他外设复用算法里初始化时一定要先把CS拉高再配置为输出。我遇到过CS悬空导致Flash误进入某种状态折腾了半天。5. 进阶优化与扩展思路5.1 提高擦除和编程速度标准SPI在21MHz下页编程256字节大约需要0.7ms不含等待扇区擦除45ms。如果换成Quad SPI理论速度能翻4倍。但Quad SPI需要额外的GPIOIO2、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通常存的是待升级的固件包。下载算法负责把固件包烧到外部FlashBootloader再从外部Flash读到内部Flash。这时候要注意算法里的地址映射要和Bootloader里的读写地址一致。我一般把外部Flash的0x00000000映射到CPU的0x00000000Bootloader里用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读状态寄存器返回全0xFFBUSY位一直是1。改成Mode 0就好了。这种问题看数据手册的时序图能快速定位。最后一个建议算法工程里不要用printf。MicroLIB的printf会占用不少空间而且算法环境没有串口初始化printf会卡死。要调试就用GPIO翻转或者把结果写到某个RAM地址用调试器看。这个FLM文件做好之后我把它用在了三个不同的STM32F407项目上只要SPI引脚和时钟配置一致直接复用省了大量重复劳动。如果你也在做类似的事情希望这篇内容能帮你少走点弯路。