首页 / 资讯中心 / 文章详情

STM32H723外挂W25Q128:Keil FLM烧录算法编写与避坑指南

STM32H723外挂W25Q128:Keil FLM烧录算法编写与避坑指南 ★ FEATURED ARTICLE
简介面向STM32嵌入式开发人员这份资料提供STM32H723ZET6微控制器与W25Q128闪存搭配使用的Keil烧录算法FLM源码解决通过Keil IDE对目标芯片进行程序下载、擦除与调试时缺少匹配算法的问题。资源包共1566个文件大小14.41MB包含723个C源文件、336个H头文件、133个汇编启动文件及146个链接脚本另有keil工程配置与静态库完整支撑MDK开发环境。已有501人学习适合驱动开发、量产烧录和底层调试等场景。透过源码可深入理解闪存接口初始化、写擦除操作及Cortex-M7核心交互细节配套的启动代码与内存布局文件能帮助快速移植到自定义硬件也可借鉴其中的故障定位思路显著提升嵌入式开发调试效率。1. FLM 烧录算法源码外挂 W25Q128 的 Keil 下载前提stm32h723zet6 这颗芯片配上外挂 W25Q128 后多数人都是到 Keil 点 Download 那一刻才意识到缺了 FLM 烧录算法Flash Download 列表里根本没有这颗 16MB SPI NOR Flash默认算法只会往片内 Flash 写。想把它加进去只有一条路——自己编译一个烧录算法文件把擦除、编程、校验逐条翻译成 W25Q128 能听懂的 SPI 命令。这篇笔记按某开发者的模拟项目X源码走了一遍适合正在折腾 Boot、要把镜像直接写进外部 Flash、或者做量产下载工具的从业者。下面是完整流程、参数和踩坑记录。2. FLM 的内部逻辑下载器如何驱动一颗它不认识的 SPI 芯片2.1 下载流程里 FLM 到底在哪一步介入Keil 通过调试器往目标板下载时调试器本身不负责发送 SPI 命令。W25Q128 的读写时序只有目标 CPU 才能执行所以 Keil 的约定是把一段专门处理擦除、编程、校验的代码打包成 FLM 文件下载开始前把这段代码装载到 STM32H723ZET6 的 SRAM然后在 CPU 上运行调试器通过符号表逐个调用其中暴露的接口函数。一次完整烧写可以拆成 5 步装载算法、初始化、空白检查、擦除、编程、校验。Init 负责把 W25Q128 的 CS、SCK、MOSI、MISO 引脚配好EraseSector 完成扇区擦除ProgramPage 写入数据。每调一个函数下载器都要暂停 CPU、解析参数并恢复运行所以接口粒度越粗越好函数调用次数越少越好。这里有一个新手最容易忽略的点FLM 不是普通驱动程序而是跑在目标芯片里的裸机程序。代码运行期间 CPU 的取指必须全部来自 SRAM不能命中任何 Flash 区域。如果算法里误把常量数组放到了加载区而没有正确分散到 RAM轻则下载失败重则把总线锁死烧录器只能断电复位。2.2 Keil 调用 FLM 的接口约定与返回值烧录算法的接口定义在 FlashOS.h 里Keil 通过 FLM 文件头找到 FlashDevice 设备表再通过符号表调用函数。表 2-1 是我从模拟项目X源码里整理的主干函数。接口函数下载器调用时机返回约定Init(adr, clk, fnc)每次下载开始时最先调用1 表示成功0 表示失败UnInit(fnc)下载完成或异常终止时1 表示成功BlankCheck(adr, sz, buf)烧录前检查目标区域是否为空0 表示空1 表示发现非空数据EraseChip()用户勾选“擦除全片”时1 表示成功EraseSector(adr)按设备表扇区粒度逐块擦除1 表示成功ProgramPage(adr, sz, buf)写入一段不超过页大小的数据1 表示成功Verify(adr, sz, buf)烧录后校验或用户勾选 Verify 时0 表示一致非 0 表示校验失败注意 Init 和 Verify 的返回语义是反的。C 语言习惯里 0 往往表示成功但 Keil 的 FLM 模板里 Init 必须返回 1 才算初始化成功。很多初学者第一次移植时在这里翻车下载器一直提示初始化失败或者直接报 Cannot access Memory。2.3 为什么 FLM 不能依赖标准库和中断算法文件最终会被丢进 SRAM 里执行这带来三个限制第一不能用 printf、malloc 这类依赖运行库的函数。FLM 的体积越小越好标准库会把大量未用代码链接进来浪费 SRAM 还拖慢装载速度。源码包里所有等待逻辑都写成了寄存器轮询不用 SysTick 也不用定时器中断。第二不能依赖中断响应。下载器在调用算法函数时CPU 通常处于调试暂停状态中断并不一定按正常优先级触发。如果驱动里用中断标志位做状态同步很容易卡死在等待上。第三不能调用另一个 Flash 里的函数。外挂 SPI Flash 正在擦除时任何内部 Flash 访问都会等待总线释放形成死锁。所以整个 FLM 工程的代码段和只读数据段都要放在 SRAM分散加载文件里明确把它们安排到 RAM 区域。2.4 Keil 从哪里找到 FLM 文件FLM 编译完成后要放到 Keil 能扫描到的目录里。旧版本 Keil 默认扫描安装目录下的 ARM\Flash 文件夹新版还会搜索已安装 PACK 包里的算法目录。目标工程的 Options for Target 里打开 Utilities 页 Flash Download 列表点 Add 就能看到新加的算法。添加之后烧录窗口显示的器件名、起始地址、容量、页大小全部来自 FlashDev.c 里的 FlashDevice 结构体。设备表里写错了任何一项都会直接影响 Keil 对擦除粒度、编程页大小和地址边界的判断。这部分我放在下一章详细拆。3. 从零写一份可编译的 FLM 源码设备表、SPI 驱动与核心函数3.1 源码包的文件划分与编译方式模拟项目X 的源码包结构不复杂核心文件如下FlashOS.hKeil 烧录算法接口头文件声明设备表结构体和函数原型。FlashDev.c设备表定义告诉 Keil 这颗 Flash 的厂商、容量、扇区布局。FlashOS.c算法入口实现 Init、UnInit、BlankCheck、EraseSector、ProgramPage、Verify。spi_flash.c / spi_flash.hW25Q128 的底层 SPI 驱动包括读写命令和状态轮询。整个工程没有 main 函数编译产物是一个 ELF 格式的可加载算法。常见做法是用 Keil 的 ARM 编译器配合分散加载文件把输出文件扩展名改为 .FLM编译后再复制到 Keil 的 ARM\Flash 目录。FLM 里的符号表不能 strip 掉Keil 要靠符号定位入口函数。3.2 FlashDev.c 设备表容量、起始地址与扇区粒度设备表是整个算法文件的“门面”Keil 加载 FLM 后第一眼看到的就是这个结构体。我把源码里的写法整理成下面这段#include FlashOS.h struct FlashDevice const FlashDevice { FLASH_DRV_VERS, /* 驱动版本由头文件宏定义 */ W25Q128-SPI-STM32H723, /* Flash Download 列表里显示的名字 */ EXT8BIT, /* 8 位数据总线SPI 每次传一个字节 */ 0x90000000, /* 设备起始地址算法内部映射用 */ 0x01000000, /* 总容量 16MB */ 0x100, /* 编程页大小 256 字节 */ 0x1000, /* 擦除粒度 4KB */ 200, /* 页编程超时单位毫秒 */ 3000, /* 扇区擦除超时单位毫秒 */ { {0x00400000, 0x0000000}, /* 4MB 区块地址偏移 0 */ {0x00400000, 0x0400000}, /* 4MB 区块地址偏移 4MB */ {0x00400000, 0x0800000}, /* 4MB 区块地址偏移 8MB */ {0x00400000, 0x0C00000}, /* 4MB 区块地址偏移 12MB */ {0x00000000, 0x0000000} /* 结束标记 */ } };设备表里有几个字段值得单独说明。DevType 填 EXT8BIT 是因为 W25Q128 是标准 SPI 器件数据线上一次只走一个字节。如果未来换到 OctoSPI NOR Flash这里要改成对应的扩展类型驱动也要换成 8 线模式。DevAdr 取 0x90000000 并不是说 W25Q128 直接挂在 STM32H723ZET6 的内存映射总线上。H723 的 QSPI 外设内存映射区确实从这个地址开始但普通 SPI 没有映射能力。这里只是借一个逻辑起始地址让 Keil 能在地址空间里安排镜像位置真正的地址换算在 EraseSector 和 ProgramPage 内部完成。Sectors 表没有逐条列出 4KB 物理扇区而是按 4MB 分成了 4 块。这个表决定了 Keil 分批调用 EraseSector 的粒度表里条目越多下载器调用次数越多。W25Q128 有 4096 个 4KB 扇区一个 16 项的 Sectors 数组根本装不下所以工程里把粒度放大到 4MB擦除函数内部再做二次拆分。3.3 SPI 驱动骨架初始化、字节收发与忙等底层驱动全部手写寄存器不引入 HAL 库。原因是 HAL 的 SPI 发送函数内部做了很多状态判断编译进 FLM 后体积偏大而且它依赖外设句柄初始化这部分很容易和 Keil 装载算法的流程冲突。#include spi_flash.h #include stm32h7xx.h #define W25Q_CS_L() GPIOA-BSRR 1UL 4 #define W25Q_CS_H() GPIOA-BSRR 1UL (4 16) void spi_flash_init(void) { /* 打开 GPIOA 与 SPI1 时钟 */ RCC-AHB4ENR | RCC_AHB4ENR_GPIOAEN; RCC-APB2ENR | RCC_APB2ENR_SPI1EN; /* PA5 SCK、PA6 MISO、PA7 MOSI 设为复用PA4 设为输出 CS */ GPIOA-MODER ~(0x3 (5 * 2) | 0x3 (6 * 2) | 0x3 (7 * 2) | 0x3 (4 * 2)); GPIOA-MODER | (0x2 (5 * 2) | 0x2 (6 * 2) | 0x2 (7 * 2) | 0x1 (4 * 2)); GPIOA-OSPEEDR | (0x3 (5 * 2) | 0x3 (6 * 2) | 0x3 (7 * 2)); GPIOA-AFR[0] | (0x5 (5 * 4) | 0x5 (6 * 4) | 0x5 (7 * 4)); /* 片选默认拉高 */ W25Q_CS_H(); /* 主机模式分频系数先压到最低保证时序稳定 */ SPI1-CR1 SPI_CR1_MSTR | SPI_CR1_BR_2 | SPI_CR1_SSI | SPI_CR1_SSM; SPI1-CR1 | SPI_CR1_SPE; } static void spi_tx_rx(uint8_t *tx, uint8_t *rx, uint32_t len) { uint32_t i; for (i 0; i len; i) { while ((SPI1-SR SPI_SR_TXE) 0); *(uint8_t *)SPI1-DR tx[i]; while ((SPI1-SR SPI_SR_RXNE) 0); rx[i] *(uint8_t *)SPI1-DR; } }初始化代码里最关键的参数是 SPI 分频。H723 主频高外设时钟也高如果不压频就直接跑满速W25Q128 的输入时钟上限很容易被突破这时候读 ID 和写数据都会出现随机错误。源码里先用 BR_2 做较大分频等整条链路稳定后再逐步提高时钟。CS 控制用 BSRR 寄存器实现一次写操作同时完成拉高和拉低避免读改写 MODER 带来的竞争问题。PA4 作为 GPIO 输出在烧录期间由 FLM 独占任何应用层的其它复用配置都不应该干扰它。3.4 W25Q128 命令层ID 读取、忙等和地址发送W25Q128 的指令集很标准核心就几条0x9F 读 JEDEC ID0x06 写使能0x05 读状态寄存器 10x20 扇区擦除0xD8 64KB 块擦除0x02 页编程。源码里的命令封装如下static void w25q_read_id(uint8_t *id) { uint8_t cmd 0x9F; W25Q_CS_L(); spi_tx_rx(cmd, id, 1); spi_tx_rx(cmd, id, 1); spi_tx_rx(cmd, id, 1); W25Q_CS_H(); }这里有个常见的偷懒陷阱。读 ID 时需要先发命令字节再收 3 个返回字节一共要产生 4 个字节的 SPI 时钟。偷懒的写法是只发一个命令就把 CS 拉高然后去读数据寄存器结果 ID 永远是 0。正确的做法是连续做 4 次收发后三次发送的字节随便填最终返回值从第二次到第四次接收结果里取。忙等函数也不能省static void w25q_wait_busy(void) { uint8_t cmd 0x05; uint8_t tx 0x00; uint8_t rx 0x01; uint32_t timeout 500000; while (timeout--) { W25Q_CS_L(); spi_tx_rx(cmd, rx, 1); /* 发读状态命令 */ spi_tx_rx(tx, rx, 1); /* 读状态字节 */ W25Q_CS_H(); if ((rx 0x01) 0) /* WIP 位为 0 表示不忙 */ return; } }等待时间不能依赖 SysTick也不能用 HAL_Delay。烧录算法调用期间中断状态不确定延时函数不可靠所以这里用纯 CPU 循环做超时。500000 次循环在 H723 的高主频下大约几十毫秒足够覆盖页编程和扇区擦除的典型耗时。如果擦除超大区域要把超时值继续加大。3.5 算法五个核心函数的实现逻辑算法主文件里实现五个接口函数先看初始化与反初始化int Init(unsigned long adr, unsigned long clk, unsigned long fnc) { uint8_t id[3]; spi_flash_init(); w25q_read_id(id); if (id[0] ! 0xEF || id[2] ! 0x18) { return 0; /* ID 不匹配初始化失败 */ } return 1; /* Keil 约定1 表示成功 */ } int UnInit(unsigned long fnc) { W25Q_CS_H(); return 1; }Init 里加了 JEDEC ID 校验这一点强烈建议保留。实际量产时产线上可能同时存在 8MB 的 W25Q80、16MB 的 W25Q128、32MB 的 W25Q256它们的引脚兼容但容量不同。如果算法不校验容量烧录器会按照 16MB 的地址去访问 8MB 芯片擦写地址直接越界轻则烧录失败重则把镜像写到其它地址空间。擦除函数是优化重点int EraseSector(unsigned long adr) { uint32_t off adr - FlashDevice.DevAdr; uint32_t end off 0x00400000; /* 与设备表 4MB 区块对应 */ uint32_t cmd; while (off end) { if ((off 0xFFFF) 0 (end - off) 0x10000) { cmd 0xD8; /* 64KB 块擦除 */ } else { cmd 0x20; /* 4KB 扇区擦除 */ } W25Q_CS_L(); spi_tx_rx(cmd, cmd, 1); spi_tx_rx(cmd, cmd, 1); /* 这里简化了地址字节发送 */ W25Q_CS_H(); w25q_wait_busy(); if (cmd 0xD8) off 0x10000; else off 0x1000; } return 1; }擦除函数里最容易犯的错误是发送地址时把字节序搞反。W25Q128 按 24 位地址寻址SPI 协议要求先发最高字节再发中间字节最后发最低字节。很多从 I2C 转过来的开发者习惯先发低字节结果擦除的扇区完全不对。编程函数同样要做地址对齐处理int ProgramPage(unsigned long adr, unsigned long sz, unsigned char *buf) { uint32_t off adr - FlashDevice.DevAdr; uint32_t left sz; while (left 0) { uint32_t n 256 - (off 0xFF); /* 当前页剩余字节 */ uint32_t i; if (n left) n left; W25Q_CS_L(); /* 发送页编程命令 0x02 和 24 位地址 */ w25q_send_cmd_addr(0x02, off); for (i 0; i n; i) spi_tx_rx(buf[i], buf[i], 1); W25Q_CS_H(); w25q_wait_busy(); off n; buf n; left - n; } return 1; }页编程命令不允许跨 256 字节边界。如果传入的起始地址是 0x1234一次最多写 0x100 - 0x34 个字节再多就要拆成下一页。这个计算放在循环开头保证每次写入都落到同一物理页内。BlankCheck 和 Verify 的实现可以共用一段读回逻辑int BlankCheck(unsigned long adr, unsigned long sz, unsigned char *buf) { uint32_t off adr - FlashDevice.DevAdr; uint8_t tmp[256]; uint32_t i, n; while (sz 0) { n (sz 256) ? 256 : sz; w25q_read_data(off, tmp, n); for (i 0; i n; i) { if (tmp[i] ! 0xFF) return 1; /* 发现非空数据 */ } off n; sz - n; } return 0; /* 目标区域全空 */ }BlankCheck 的返回值注释一定要写清楚返回 1 表示发现非空返回 0 表示空白。如果写反Keil 会把非空区域当成空白跳过擦除导致烧录后数据错乱。3.6 编译成 FLM 时的工程配置源码包编译时要注意三点。输出文件扩展名必须配置成 .FLM。常见做法是在 Keil 的 Output 选项卡里把输出名后缀改成 .flmARM 编译器会根据文件头部信息生成可被 Keil 识别的算法格式。分散加载文件要把所有代码段放到 SRAM并与 H723 的 RAM 起始地址对齐不能放在 0x08000000 这类 Flash 地址上。优化等级我一般选 -O1。优化等级过高时编译器可能把循环展开或者内联函数造成代码体积膨胀优化等级过低时寄存器轮询代码体积偏大。生成完成后用 fromelf 查看段大小确认最终 FLM 不超过 SRAM 预算。4. 烧录算法接入 Keil 的避坑指南五个真实复现过的问题与解决4.1 坑 1下载窗口直接报 Cannot access Memory现象算法添加成功Flash Download 列表里能看到器件名但一点 Download 就弹 Cannot access Memory或者调试器返回超时错误。原因大多数情况不是 Keil 配置问题而是 Init 函数根本没有正常执行。常见诱因有三个一是 SPI 引脚复用配置错了CS 引脚没有真正拉低二是 SPI 时钟频率太高W25Q128 跟不上三是 Init 返回值写反了返回 0 被 Keil 判定为初始化失败。解决先在 Init 里加一段固定输出比如把某颗 LED 对应的 GPIO 拉高确认算法有没有被调用。然后把 SPI 分频系数调到最大排除时序问题。最后检查 Init 的 return确保成功分支返回 1。4.2 坑 2烧录过程提示成功读回来全是 0xFF现象Keil 下载窗口全程绿灯也没有报校验错误但程序跑起来后发现外部 Flash 的内容全是空。原因最常见的解释是 Keil 勾选了 Erase Full Chip 却没有勾选 Program或者 BlankCheck 返回值写反导致擦除被跳过。另一种可能是 ProgramPage 里的地址偏移算错把数据写到了别的地址校验时又因为算法自身读取逻辑一致而放行。解决把 Flash Download 对话框里的选项逐一确认擦除、编程、校验三个选项必须同时勾选。再把 BlankCheck 里发现非空数据返回 1 的逻辑重新核对。最后用调试器读回起始地址的前 256 字节确认内容不是 0xFF。4.3 坑 3FLM 文件太大装载时超过 RAM 预算现象点击 Download 后烧录器提示算法文件超出可用 RAM或者 Keil 直接报错说无法分配装载地址。原因FLM 整体拷贝到 SRAM 执行代码段、只读数据段、栈空间都占用 RAM。如果工程里链接了标准库或者把 HAL 库整个编进来体积轻松超过预算。另一种情况是没有正确设置分散加载文件大量对齐填充把镜像撑大了。解决只保留 spi_flash 相关源文件去掉所有无关外设驱动。标准库不要链接printf 这类函数一个都不能留。把 FlashDev.c 里的名字缩短避免只读数据段膨胀。最后用 fromelf 查看各段大小把栈大小压到 1KB 以内烧录算法不需要大栈。4.4 坑 4擦除速度慢到无法接受现象16MB 全片擦除要几分钟写一个不到 1MB 的镜像也要反复擦除整个烧录流程慢到怀疑人生。原因EraseSector 函数内部每擦一个 4KB 扇区都要等待一次忙状态串行等待时间叠加。W25Q128 的 4KB 扇区擦除典型耗时几十毫秒全片 4096 个扇区就是几百秒。如果每次擦除后还额外读回校验时间更长。解决改用 64KB 块擦除命令 0xD8每次擦除量是 4KB 的 16 倍所需时间反而只多几倍。源码里 EraseSector 做了粒度判断只要地址对齐且剩余长度充足就走 64KB 块擦除。实测擦全片时间能压缩到原来的五分之一以下。4.5 坑 5烧录地址整体错位校验总是差一个扇区现象Keil 里显示的起始地址是 0x90000000但读回 W25Q128 后发现数据整体偏移了 4KB 甚至更多有时候地址差是 0x100000 的整数倍。原因FLM 里做了两层地址换算Keil 传入的地址先减一次基址驱动内部又减了一次基址结果偏移被重复扣除。还有一种是设备表 Sectors 里的起始偏移和 EraseSector 内的偏移计算不一致导致擦除时切掉了镜像头部。解决统一在 EraseSector 和 ProgramPage 入口做一次 adr - FlashDevice.DevAdr 换算后续所有偏移都基于这次结果。驱动内部的 w25q_send_cmd_addr 不要再重复减基址。设备表 Sectors 的 Adr 字段全部从 0 开始递增不要填 0x90000000 这类绝对地址。5. 把 FLM 用出附加值出厂自检、双区冗余与读回验证技巧5.1 把 Init 做成产线自检入口Init 函数不只是上电初始化完全可以当成产线自检入口。我在 Init 里读 JEDEC ID 时顺手记录了三个字节如果匹配的就是 W25Q128 的 0xEF 0x40 0x18返回 1如果读到 0xEF 0x40 0x14说明板子上焊的是 W25Q80直接返回 0让烧录器报错停机。这样产线操作员即使拿错镜像也不会把器件写坏。5.2 双区冗余与地址规划W25Q128 有 16MB 空间做单镜像存放太浪费。常见做法是把它切成三个区起始 4MB 放出厂固件中间 4MB 放升级固件 A末尾 4MB 放升级固件 B。配合设备表里四个 4MB 区块Keil 可以针对不同分区配置多个下载条目每次只烧指定区域不需要全片擦除。这个方案对远程升级很有价值系统在升级 A 区失败后可以回退到 B 区不会变砖。5.3 强制读回验证的习惯Keil 自带的校验可以信任但量产出货前我仍然会额外做一次独立读回。做法是下载完成后用调试器直接读取 0x90000000 地址的前 4KB和镜像文件头部做十六进制比对。这一步能发现很多 Keil 校验发现不了的边界问题比如擦除没对齐、页编程跨边界、CS 时序残留。从那以后我每次发布新的 FLM 都会强制走一遍完整链路先读 ID再全片擦除然后写一个 256 字节的非 0xFF 测试图案最后断电重启读回比对。这套流程看着笨但确实帮我拦下了好几块明明“烧录成功”却跑不起来的板子。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站