简介:以STM32H723ZET6与W25Q128为核心的Keil烧录算法FLM源码,面向需要为Cortex-M7项目定制片外Flash编程方案的嵌入式工程师。该资源解决MDK环境中缺少匹配Flash Loader算法、导致片外闪存无法正常下载调试的问题,可直接用于生成、加载和修改W25Q128对应的FLM文件。压缩包共1566个文件、约14.41MB,以C源文件、头文件、链接描述文件(icf)、启动汇编(s)及MDK工程配置为主,同时包含flm、axf等编译产物,目录结构完整。资源已有492人学习/下载,属于底层烧录算法中较具参考价值的实现。通过阅读源码可了解FLM算法中初始化、读取、写入、擦除、校验等函数的组织方式,并借助配套工程快速修改Flash型号、引脚与扇区参数;还可从库文件与链接脚本中排查编译或下载链路问题,减少开发调试时间,为量产下载、现场升级提供可靠支撑。 如果你手头有块STM32H723ZET6开发板,外挂了一片W25Q128,想通过Keil把程序直接烧进这颗16MB的SPI NOR Flash里,大概率会在Flash Download页面卡住——列表里根本没有W25Q128这个选项,默认的ST-Link算法只认片内Flash,点多少次Load都没用。
我第一次做这个需求,是为了在板子上同时放一套图形界面固件和一段音频字库,片内1MB Flash实在不够用。网上能搜到的FLM资料很零散,要么只有编译好的成品文件,要么是几行说不清道不明的源码片段。折腾了几天后终于把整套东西调通,顺手整理成了可复用的工程模板。这篇博文就围绕这套STM32H723ZET6 + W25Q128的Keil烧录算法FLM源码展开,把FLM的原理、源码实现、Keil工程配置、踩坑排查一次讲清楚。
内容以实操为主,适合已经有STM32基础、想自己写外部Flash烧录算法的开发者。如果只是想把算法跑起来,直接把源码和配置步骤抄过去也能用。
1. FLM是什么:Keil烧录外部Flash时调试器到底在干什么
1.1 FLM本质是一个跑在SRAM里的临时驱动程序
使用ST-Link或J-Link时,Keil的Flash Download流程其实分三步。第一步,调试器把FLM算法的代码段下载到目标MCU的SRAM中,放在约定好的地址范围。第二步,调试器把CPU拉到FLM的Init函数,让目标芯片自己完成外设初始化。第三步,调试器反复调用EraseSector、ProgramPage、Verify这些回调函数,传入地址和数据,逐段完成擦除、写入、校验。整个流程结束后,调试器把PC恢复到用户程序的复位向量,加载应用程序。
注意,FLM本身不带main函数,不跑操作系统,连C库启动代码都是裁剪过的。它就是一个袖珍的、被调试器远程调用的函数库。这个设计的好处是,调试器不需要理解具体Flash芯片的指令时序,只要MCU自己会读写Flash就行。你可以把FLM理解成装修现场的一个临时工人,监理不亲自砌墙,只负责指挥工人“这面墙怎么砌、用哪批砖”。
1.2 为什么H723+W25Q128组合没有现成的烧录算法
STM32H723ZET6的片内Flash是1MB,在图形界面、字库、OTA升级、日志存储这些场景下,这个容量确实紧张。W25Q128是华邦的128Mbit(16MB)SPI NOR Flash,价格便宜、封装常见、读写时序简单,几乎成了中高端板子的标配存储扩展方案。
问题在于,Keil的Pack包为STM32H723提供的是片内Flash算法,操作的是内部Flash控制器地址空间。而W25Q128走的是SPI接口,指令集完全不同。虽然H7系列有OSPI接口可以把外部Flash映射到0x90000000地址空间,但Keil并没有为W25Q128这种通用SPI Flash提供现成的FLM算法,所以自己写是绕不开的步骤。
1.3 FLM文件里的函数表与FlashDevice描述
FLM文件本质是ELF格式,用Keil自带的fromelf工具可以查看导出符号。核心导出符号就这么几个:Init、UnInit、EraseChip、EraseSector、ProgramPage、Verify,外加一个FlashDevice结构体。FlashDevice里描述了设备名、起始地址、容量、页大小、扇区大小、擦除后默认值、编程超时、擦除超时这些参数。
调试器在加载FLM时,会从ELF符号表里找到这些函数的地址,生成一张函数指针表。所以函数名必须精确匹配,大小写都不能错,否则Keil会报“Cannot load flash programming algorithm”。我第一次就是有个函数名少写了一个字母,找了大半天才发现。
2. W25Q128 FLM源码实现:每个回调函数背后的逻辑
2.1 从W25Q128数据手册里必须抄对的参数
写驱动前,先翻芯片手册。W25Q128的关键参数整理成一张表,写代码的时候对照着来:
| 参数 | 值 | 说明 |
|---|---|---|
| 容量 | 128Mbit / 16MB | |
| JEDEC ID | EF 40 18 | 用0x9F指令读回 |
| 页大小 | 256字节 | Page Program单次上限 |
| 扇区大小 | 4KB | Sector Erase使用0x20指令 |
| 块大小 | 32KB / 64KB | Block Erase使用0x52 / 0xD8 |
| 整片擦除 | 0xC7 | Chip Erase |
| 读状态寄存器 | 0x05 | bit0是BUSY,bit1是WEL |
| 写使能 | 0x06 | 每次擦除/编程前必须发送 |
| 扇区擦除时间 | 典型45ms,最大400ms | |
| 页编程时间 | 典型0.7ms,最大3ms |
这里有个容易被忽略的点:W25Q128上电后状态寄存器1的BP位默认全0,写保护是关闭的。但如果板子在之前测试时配置过写保护位,或者芯片是二手拆机件,BP位可能非0。FLM的Init函数里最好加一段保险代码,把状态寄存器1写成0x00,解除写保护。另一种情况是硬件上把WP引脚拉低了,这种即使软件清了BP位也写不进去,后面排查章节会再提到。
2.2 通讯方式选型:为什么我选GPIO模拟SPI
FLM运行环境很特殊:没有用户main函数,没有标准外设初始化流程。调试器调用FLM时,MCU处于复位后的状态,时钟树默认是HSI。在这个环境里初始化硬件SPI,你需要同时处理GPIO复用、外设时钟、SPI配置参数,任何一步遗漏都可能出问题。
所以我强烈建议FLM里用GPIO模拟SPI。原因很简单:只需要操作四个引脚,流程完全可控,时序直观,出问题用示波器一抓就知道是哪一步不对。速度上比硬件SPI慢一些,但FLM烧写本身不是高频操作,完全可接受。如果追求极致速度,可以在Init里初始化硬件SPI,但新手不建议一上来就这么干。
2.3 核心源码:Init、EraseSector、ProgramPage、Verify
下面以SPI1的常见引脚接法为例:PA5=CLK、PA6=MISO、PA7=MOSI、PA4=CS。首先定义引脚控制宏:
// 引脚定义(以SPI1为例) #define FLASH_CS_PIN GPIO_PIN_4 #define FLASH_CLK_PIN GPIO_PIN_5 #define FLASH_MOSI_PIN GPIO_PIN_7 #define FLASH_MISO_PIN GPIO_PIN_6 #define CS_LOW() (GPIOA->BSRR = (uint32_t)FLASH_CS_PIN << 16) #define CS_HIGH() (GPIOA->BSRR = (uint32_t)FLASH_CS_PIN) #define CLK_LOW() (GPIOA->BSRR = (uint32_t)FLASH_CLK_PIN << 16) #define CLK_HIGH() (GPIOA->BSRR = (uint32_t)FLASH_CLK_PIN) #define MOSI_LOW() (GPIOA->BSRR = (uint32_t)FLASH_MOSI_PIN << 16) #define MOSI_HIGH() (GPIOA->BSRR = (uint32_t)FLASH_MOSI_PIN) #define MISO_READ() ((GPIOA->IDR & FLASH_MISO_PIN) != 0)注意BSRR寄存器的用法:BSRR的低16位是置位,高16位是复位。所以CS_LOW用<< 16把对应引脚复位,CS_HIGH直接操作低16位置位。
然后是SPI字节收发函数,标准的Mode 0时序:CLK低电平时改变MOSI,CLK高电平时采样MISO:
static uint8_t SPI_ExchangeByte(uint8_t dat) { uint8_t i; for (i = 0; i < 8; i++) { if (dat & 0x80) MOSI_HIGH(); else MOSI_LOW(); dat <<= 1; CLK_HIGH(); __NOP(); __NOP(); if (MISO_READ()) dat |= 0x01; CLK_LOW(); __NOP(); __NOP(); } return dat; }GPIO初始化。H7系列GPIOA时钟挂在AHB4总线上,直接用寄存器配置:
static void FLASH_GPIO_Init(void) { RCC->AHB4ENR |= RCC_AHB4ENR_GPIOAEN; GPIOA->MODER &= ~(GPIO_MODER_MODER4 | GPIO_MODER_MODER5 | GPIO_MODER_MODER6 | GPIO_MODER_MODER7); // PA4/PA5/PA7设为输出,PA6保持输入 GPIOA->MODER |= (GPIO_MODER_MODER4_0 | GPIO_MODER_MODER5_0 | GPIO_MODER_MODER7_0); GPIOA->OTYPER &= ~(GPIO_OTYPER_OT4 | GPIO_OTYPER_OT5 | GPIO_OTYPER_OT6 | GPIO_OTYPER_OT7); GPIOA->OSPEEDR |= (GPIO_OSPEEDR_OSPEED4 | GPIO_OSPEEDR_OSPEED5 | GPIO_OSPEEDR_OSPEED6 | GPIO_OSPEEDR_OSPEED7); CS_HIGH(); CLK_LOW(); }初始化后必须把CS拉高、CLK拉低。如果上电后CS保持低电平,Flash会误以为外部在发送命令,时序就全乱了。
下面是Flash基础操作函数:
static void FLASH_WaitBusy(void) { uint8_t status; CS_LOW(); SPI_ExchangeByte(CMD_READ_STATUS); do { status = SPI_ExchangeByte(0xFF); } while (status & 0x01); CS_HIGH(); } static void FLASH_WriteEnable(void) { CS_LOW(); SPI_ExchangeByte(CMD_WRITE_ENABLE); CS_HIGH(); }Init函数:
int Init(unsigned long adr, unsigned long clk, unsigned long fnc) { FLASH_GPIO_Init(); // 解除写保护(如果之前被配置过) FLASH_WriteEnable(); CS_LOW(); SPI_ExchangeByte(CMD_WRITE_STATUS); SPI_ExchangeByte(0x00); CS_HIGH(); FLASH_WaitBusy(); return 0; }EraseSector函数,按4KB扇区擦除:
int EraseSector(unsigned long adr) { FLASH_WriteEnable(); CS_LOW(); SPI_ExchangeByte(CMD_SECTOR_ERASE); SPI_ExchangeByte((adr >> 16) & 0xFF); SPI_ExchangeByte((adr >> 8) & 0xFF); SPI_ExchangeByte(adr & 0xFF); CS_HIGH(); FLASH_WaitBusy(); return 0; }ProgramPage函数,这是最核心的回调,后面会专门讲跨页问题:
int ProgramPage(unsigned long adr, unsigned long sz, unsigned char *buf) { unsigned long i, page_remain; // 限制单次写入长度,避免跨页 page_remain = 256 - (adr & 0xFF); if (sz > page_remain) sz = page_remain; FLASH_WriteEnable(); CS_LOW(); SPI_ExchangeByte(CMD_PAGE_PROGRAM); SPI_ExchangeByte((adr >> 16) & 0xFF); SPI_ExchangeByte((adr >> 8) & 0xFF); SPI_ExchangeByte(adr & 0xFF); for (i = 0; i < sz; i++) { SPI_ExchangeByte(buf[i]); } CS_HIGH(); FLASH_WaitBusy(); return 0; }最后是FlashDevice结构体,这个要放在模板提供的FlashDev.c文件里:
struct FlashDevice const FlashDevice = { FLASH_DRV_VERS, "W25Q128 @ 0x90000000", EXTSPI, 0x90000000, 0x01000000, 256, // PageSize 0xFF, // Erased Value 500, // Program Page Timeout (ms) 3000, // Erase Sector Timeout (ms) 256 // Buffer Size };FLASH_DRV_VERS和EXTSPI这些宏定义在Keil模板自带的FlashOS.h头文件里,直接用模板里的就行。
2.4 ProgramPage跨页问题与稳定写法
W25Q128的Page Program一次最多写256字节,而且不能跨页边界。如果当前地址距离页末尾只剩10字节,你却传入了256字节,Flash会把超出的数据写到本页开头的地址去,后面的数据全部错位,Verify必挂。
解决方式就是代码里的page_remain计算:用256减去当前地址在页内的偏移量,得到本页剩余空间,如果调用方传入的数据长度超过剩余空间,就截断到剩余空间。剩下的数据Keil会紧接着调用下一次ProgramPage处理,不会丢数据。
这个处理不是可选优化,是必须写的。Keil默认按最大缓冲长度调用ProgramPage,如果你不截断,烧几百KB固件时大概率会在某个页边界处校验失败。我最初写的版本没有做跨页处理,烧到一半就报Verify Failed,排查了很久才发现是这里的问题。
3. Keil工程搭建与烧录验证:从模板工程到实际Load
3.1 最省事的路径:复用Keil自带的FLM模板
Keil安装目录下自带Flash算法模板工程,路径是Keil_v5\ARM\Flash\_Template。里面已经有FlashOS.h、FlashPrg.c、FlashDev.c这几个文件,工程配置也基本完善。把这个目录复制出来改名,然后把上一节的源码填进去,编译就能生成.FLM文件。
模板工程需要注意两点。第一,如果你的Keil版本较新(比如5.36以上),默认编译器是AC6,而老模板可能默认AC5,需要在Options for Target -> Target里切换ARM Compiler版本。第二,如果模板工程芯片型号和你的H723不对应,没关系,FLM算法本质上是一个在RAM里运行的小程序,芯片型号选择主要影响编译器的内核指令集。直接选一个ARMCM7内核的模板工程,或者新建工程时选STM32H723ZET6都行,关键是Output里可执行文件名后缀要写成.FLM。
如果从零建工程,关键配置是:Target页把IROM1起始地址设为0x20000000,大小0x10000,IRAM1不要和IROM1重叠;Output页把可执行文件名设为W25Q128;Linker页勾选Use Memory Layout from Target Dialog。编译后生成的W25Q128文件,改后缀为.FLM即可使用。
3.2 用户工程里加载自定义FLM的配置细节
打开你的实际应用工程,进入Options for Target -> Debug -> Settings -> Flash Download页面。默认列表里是STM32H7xx内部Flash算法,点Add按钮,在文件选择框里切到你的FLM文件所在目录,选中W25Q128.FLM,列表里就会出现两个算法:一个负责片内Flash,一个负责外部W25Q128。
然后看下面的RAM for Algorithm配置。这是给FLM算法在SRAM里预留的运行空间,必须和你用户工程的RAM布局错开。H723有多个RAM块,比如AXI SRAM在0x24000000,如果用户程序默认使用0x20000000的DTCM,那算法RAM可以分配在0x24000000起始的一块区域。具体大小建议至少4KB,我习惯给8KB,足够FLM代码和缓冲区使用。
配置完成后,用户程序如果要把代码或数据放到外部Flash,就把对应的地址段设在0x90000000起始。Keil会检查地址落在哪个算法的管辖范围内,然后自动调用对应的Flash算法。两部分互不干扰。
3.3 首次烧录验证:从Output窗口到示波器抓波形
配置完成后,先别急着烧大固件。写一个8KB左右的测试数组,里面放一段有规律的数据,编译后点Load。观察Output窗口的日志,正常情况会显示:
Erase Done. Programming Done. Verify OK.如果报错,日志里会有具体地址。这时候最有效的调试手段是:在Keil里进入Debug模式,在ProgramPage函数里设置断点,然后执行Download。因为调试器下载时会先把FLM算法加载进SRAM,断点是能命中的。单步走一遍,看传入的地址、数据长度、缓冲区内容是否正确,比对着日志猜问题快得多。
如果条件允许,用逻辑分析仪抓一下CS、CLK、MOSI三根线的波形。CS低电平期间,CLK应该有连续的脉冲,MOSI上能看到命令字节开头的0x06(写使能)。抓一次就知道Flash是否真的收到了指令,这是最直接的验证手段。
4. 烧录失败排查链路:我实际踩过的五个问题
4.1 读ID全是0xFF:先量引脚,再查GPIO配置
如果Init阶段读JEDEC ID返回0xFFFFFF,说明Flash根本没响应。我踩过最典型的坑是W25Q128模块上的WP和HOLD引脚悬空。这两个引脚是低电平有效,WP拉低会进入写保护状态,HOLD拉低会忽略SPI时钟信号。很多黑色的W25Q128模块,板上没有做上拉,直接引出引脚,导致芯片状态不稳定。
排查顺序应该是:先用万用表量Flash的3.3V供电,然后是WP和HOLD引脚的电压。这两个引脚应该都在高电平,如果不是,接一个10K电阻上拉到3.3V。再看MOSI和MISO有没有接反,尤其是通过杜邦线或者转接板连接的时候,这两个引脚接反非常常见。最后才是看GPIO配置。H723某些引脚默认有JTAG复用功能,如果误用了PA15、PB3、PB4这些引脚,输出电平可能不受控制。建议使用PA5/PA6/PA7/PA4这种常规GPIO,并且在Init里把SPI相关引脚全部显式配置为普通输入输出模式。
4.2 Verify失败:跨页和写使能是两大元凶
能擦除、能写入,但校验不对,十有八九是跨页问题。这个前面已经详细说过,page_remain截断是必须的。另一个高频问题是漏发写使能。W25Q128的每个擦除和编程命令前都必须发0x06,写完状态寄存器的WEL位会自动清0。如果ProgramPage函数里没有在每次写入前发写使能,Flash会直接忽略命令,写入操作实际上没有发生。
还有一种隐蔽情况是状态寄存器里的BP位被置位。如果芯片初始化时状态寄存器不是默认值,写操作会被硬件拒绝。可以在Init函数里加一段强制写状态寄存器清零的代码,就是我前面写的那段:发0x06写使能,再发0x01写状态寄存器命令,填入0x00,等待空闲。
另外检查一下SPI模式。W25Q128支持Mode 0和Mode 3,我用的是Mode 0:CLK低电平时改MOSI,CLK高电平时采样MISO。如果你用的是硬件SPI,CPOL和CPHA参数要和板子实际接法匹配,配置错了一样会校验失败。
4.3 RAM check failed:算法RAM区域与用户工程冲突
这个报错说明Keil在把FLM算法加载到SRAM时,检测到目标RAM区域有问题。最常见的原因是RAM for Algorithm配置的区域和用户程序使用的RAM重叠了。比如用户程序默认把栈放在0x20000000起始的DTCM区域,你也在RAM for Algorithm里填了0x20000000,Keil加载算法时就会把用户程序的数据覆盖掉,或者检测到地址冲突。
解决办法很直接:给算法分配一个独立的RAM块。H723有多个RAM,比如把RAM for Algorithm设为0x24000000起始的一块AXI SRAM区域,同时确认这块区域没有在用户程序的分散加载文件里被占用。如果用的是STM32CubeMX生成的工程,还要检查它默认的RAM分配,避免新配置和生成的代码冲突。
4.4 擦除超时:FlashDevice里的timeout参数设太短
FLM的FlashDevice结构体里有两个超时参数:ProgTout(编程超时)和EraseTout(擦除超时),单位都是毫秒。如果设得太小,调试器会在Flash还在擦除时就判定超时,导致后续操作直接失败。
W25Q128的扇区擦除时间典型值45ms,最大值400ms,但这不是唯一的影响因素。调试器调用FLM时,芯片时钟可能还处于复位后的HSI状态,擦除时间会比典型值更长。我习惯把ProgTout设成500ms,EraseTout设成3000ms,留足余量。超时参数大一些不会拖慢烧录速度,因为调试器是同步等待Flash完成操作的,容错率更高。
4.5 烧进去跑不起来:启动方式和VTOR问题
如果烧录正常完成,但程序从外部Flash启动后跑不起来,这不一定是FLM算法的问题。要看你的使用场景:
如果是从内部Flash的bootloader搬运代码到外部Flash运行,检查搬运逻辑和目标地址映射。如果希望直接从外部Flash执行代码(XIP),需要先配置OSPI/QSPI接口的memory-mapped模式,让外部Flash映射到0x90000000地址空间,然后把中断向量表VTOR重定向到0x90000000。这个配置本质上是bootloader的职责,不是FLM算法能解决的。
如果只是把数据存外部Flash,那用户程序通过SPI读写即可,根本不涉及启动问题。我把FLM烧进去跑不起来这个现象单独列出来,是因为很多人在做外部Flash启动时,烧录算法已经成功了,却误以为是FLM的问题,其实卡在了启动引导阶段。
5. 从FLM到量产:源码复用与个人心得
5.1 把FLM源码抽成通用SPI NOR驱动
FLM源码里的Init、EraseSector、ProgramPage、SPI_ExchangeByte、FLASH_WaitBusy这些函数,本质就是一套完整的SPI NOR Flash驱动,只是运行环境特殊。把它们抽出来后,在用户应用里改成正常的GPIO初始化或硬件SPI初始化,其余逻辑完全复用。
尤其值得注意的是ProgramPage里的跨页处理。这个逻辑放到任何SPI NOR Flash驱动里都适用,我后来在RTOS环境下也直接沿用这套代码,只把GPIO初始化部分换成了SPI外设初始化,运行得很稳定。所以写FLM不是一次性的工作,做完这一套,等于给后续项目攒了一个通用的Flash驱动模块。
5.2 产线烧录时的参数设置和注意事项
量产烧录时,一般会把FLM算法文件提供给产线工具或者烧录脚本。有几点建议:
算法文件路径不要包含中文和空格,产线工具对路径解析比较敏感。RAM for Algorithm的配置要固定下来,并写进项目文档,防止后续工程师调整用户工程RAM布局时无意中改了这块区域导致冲突。FlashDevice里的设备名写清楚,比如"W25Q128 @ 0x90000000",多型号兼容时方便区分。产线上如果遇到批量写入失败,优先怀疑硬件接触不良和WP/HOLD上拉电阻虚焊,而不是算法逻辑问题,因为算法在样机调试阶段已经验证过了。
5.3 我自己的调试验收习惯
第一次调FLM花的时间比我预期长得多,网上没有一个完整的从源码到配置到排查的链路可参考。复盘之后发现,所有问题都集中在几个点上:引脚上拉、跨页截断、写使能、RAM地址冲突、超时参数。只要建工程时把这些点全部检查一遍,FLM其实很好写。
后来我养成了一个习惯:每次写完FLM,不要急着烧大固件,先烧一个8KB的测试段,烧完读回来比对。测试段能快速暴露驱动和配置层面的问题,避免在几MB的固件烧录过程中反复失败浪费时间。另外,在ProgramPage里临时加一段翻转GPIO的代码,用示波器观察烧录进程,这个方法比看日志直观得多,尤其适合在现场排查问题。FLM这个技术点,说穿了就是一小段SPI Flash驱动加一套Keil约定好的壳,理解它的结构之后,以后换Flash型号、换MCU平台,都能很快移植。
本文还有配套的精品资源,点击获取