做这个项目之前,我其实已经用标准库写过好几版SPI Flash驱动了,但每次换芯片、换工程都要重新翻寄存器手册,过程相当折腾。后来彻底切到STM32CubeMX + HAL库,才发现用图形化配置SPI外设、生成初始化代码、再补业务逻辑,整个开发节奏快得不是一点半点。这篇文章就基于STM32F407,完整走一遍用STM32CubeMX配置SPI、驱动NOR Flash(以W25Q系列为例)实现数据读写的全过程,包括CubeMX里的关键设置、底层驱动封装、读写擦除的完整代码,以及几个我在调试时反复踩过的坑。
如果你之前只用过I2C的EEPROM,或者刚接触SPI协议,希望往NOR Flash里存点参数、日志甚至音频数据,这篇内容应该能帮你少走不少弯路。无论是准备做Bootloader、数据记录仪,还是简单的离线存储,SPI Flash这套玩法都是非常通用的基础技能。
1. 项目概述:为什么存储方案选了NOR Flash
选存储芯片这件事,很多新手会纠结:EEPROM、NOR Flash、NAND Flash、SD卡到底用哪个。实际项目里,NOR Flash最常见的应用场景就是存固件、配置参数、运行日志这类对随机读取要求高、写入量不大的数据。在STM32F407平台上,通过SPI接口挂一颗W25Q128这类NOR Flash,读取速度能轻松跑到几十兆比特每秒,而且引脚占用极少,4根信号线加电源就搞定。
1.1 和EEPROM、NAND Flash对比,NOR Flash的优势在哪
先说说共性认识。I2C EEPROM(比如AT24C256)容量普遍在几KB到几百KB,写入按字节操作很方便,但容量一旦上到MB级别,价格就很离谱。NAND Flash容量大、成本低,但坏块管理、ECC校验这些逻辑复杂度直接劝退很多人,更适合做文件系统级别的大容量存储。NOR Flash属于中间位置:容量从1MB到64MB常见,支持按字节随机读取,写入时需要先擦除再写,擦除粒度通常为4KB扇区。
在F407这类MCU平台上,SPI NOR Flash还有一个特殊价值:支持内存映射模式(Memory-Mapped Mode),也就是XIP(Execute in Place)。这意味着你可以把固件放在外部Flash里,上电后MCU直接像访问内部Flash一样读指令执行,很多Bootloader方案就是这么设计的。虽然我们这篇文章主要讲普通读写,但理解这层特性,对你以后做OTA升级会有帮助。
1.2 硬件接线与芯片选型参考
我的测试平台用的是STM32F407VET6核心板,外接一颗W25Q128(16MB),这是非常经典的国产兼容型号,市面上常见的有华邦、兆易创新GD25Q系列、博通集成等,指令集基本通用。接线建议优先使用SPI1,因为STM32CubeMX对SPI1的默认引脚映射最顺手,而且SPI1挂在APB2总线上,时钟频率更高。
| 信号 | STM32F407引脚 | 说明 |
|---|---|---|
| SCK | PA5 | 时钟,由主机输出 |
| MOSI | PA7 | 主机输出,从机输入 |
| MISO | PA6 | 主机输入,从机输出 |
| CS | PG12(可任意GPIO) | 片选,低电平有效 |
| VCC | 3.3V | W25Q系列支持2.7~3.6V |
| GND | GND | 共地必须接 |
片选引脚我特意强调一下,CubeMX里可以选硬件NSS(PA4),但我建议用普通GPIO做软件片选。原因放在后面章节详细说,简单讲就是软件片选在控制时序上更灵活,尤其在做多从机或者需要严格时序的场景时不容易踩坑。
2. STM32CubeMX配置:时钟树与SPI参数这张表最务实
很多初学者打开STM32CubeMX就蒙了,界面里那么多选项不知道点哪里。其实配置SPI外设,核心就三件事:时钟树、SPI参数、GPIO模式。按照下面这套流程操作,基本不会出错。
2.1 新建工程与时钟树设置要点
打开STM32CubeMX,选芯片型号时直接搜STM32F407VET6。选好后先不要急着配置引脚,优先把RCC时钟树搞定。
在System Core -> RCC里,把High Speed Clock(HSE)设为Crystal/Ceramic Resonator。然后进入Clock Configuration页面,把HCLK那栏填到168MHz,CubeMX会自动计算各总线分频。这里要特别注意:SPI1挂在APB2上,APB2的最高频率是84MHz,而SPI外设的时钟源就是APB2时钟。所以SPI1的输入时钟是84MHz,后面算波特率要用这个数。
很多人会忽略的细节是,如果没正确配置HSE,直接用内部HSI时钟,系统频率只能跑到16MHz左右,SPI速率上不去,之后读Flash ID时序不稳,表现就是偶尔读到0xFF或者数据错乱。所以时钟树一定要先搞定,这一步是为后面所有外设打基础的。
2.2 SPI参数设置里的几个关键选项
在Pinout & Configuration页面,搜索SPI1并点击,然后选择Full-Duplex Master模式。此时左侧引脚图会自动把PA5、PA6、PA7分配为SPI1的SCK、MISO、MOSI,这个映射对F407来说是默认的,基本不会变。
接着看Parameter Settings,这几项是核心:
- Mode:Full-Duplex Master,全双工主机模式
- Hardware NSS Signal:Disable,这里选了Disable就表示我们用软件管理片选,NSS引脚不会被SPI外设自动控制
- Data Size:8 bits,W25Q系列的命令和数据长度都是8位,这个不用犹豫
- First Bit:MSB First,SPI Flash协议规定高位先传,别改
- Prescaler:根据你要的波特率来定,这个要算
- CPOL / CPHA:设为Low / 1 Edge,也就是SPI Mode 0,这是W25Q系列上电默认支持的时序模式
波特率这块,我实际测试下来发现,飞线连接时跑到10MHz以上会开始不稳定。APB2总时钟84MHz,Prescaler选8的话,SPI时钟就是84/8=10.5MHz,这个速度对W25Q128来说完全在规格书范围内,但实际布线环境要留余量。我建议调试阶段先用Prescaler=64,即1.3125MHz,确保通信逻辑先跑通,再逐步提高频率。
注意:SPI Mode 0(CPOL=0, CPHA=0)是绝大多数SPI Flash默认支持的时序模式,但遇到其他外设(比如某些传感器)时,一定要去查数据手册确认CPOL和CPHA,不能想当然。
3. NOR Flash工作原理:为什么写之前必须先擦除
第一次用SPI Flash的人,最容易困惑的问题就是:为什么明明写了一个字节,读出来却是0xFF?为什么往某个地址写数据,却影响到了整个扇区?这些问题背后就是NOR Flash的物理结构决定的。
3.1 存储单元结构与擦除粒度
NOR Flash的存储单元逻辑上是一个大数组,里面分了很多扇区(Sector),W25Q128的扇区大小是4KB,还有一个Block的概念,一个Block包含16个扇区,即64KB。写入数据时,只能把1变成0,不能把0变成1。要把0变回1,只能执行擦除操作,擦除的最小单位是扇区(4KB)。
这个特性和EEPROM差别很大。EEPROM可以按字节改,NOR Flash不能。所以你的写入逻辑在设计时就要提前规划:写数据前,如果目标地址区域已经写过数据,需要先擦除整个扇区,否则写进去的数据会和旧数据做“与”运算,结果完全不是你想要的。
我用一个生活化的比喻来理解:NOR Flash就像一张只能涂黑的答题卡,你每次填答案只能涂黑选项,想改答案就必须把整块卡片用橡皮擦干净重来,而橡皮擦的最小单位是一小块区域,不能只擦一个点。
3.2 状态寄存器与写操作流程
W25Q系列芯片执行擦除或写入命令后,内部会自动处理,这个过程需要时间。怎么知道它处理完了?读状态寄存器(命令码05h),看Bit0也就是WIP(Write In Progress)位,为1说明还在忙,为0说明可以继续发下一条命令。这个轮询机制是SPI Flash驱动里最基本的流程控制。
整个写操作流程总结如下:
- 发送写使能命令(06h),把状态寄存器的WEL位置1
- 发送页编程命令(02h),带上24位地址和最多256字节数据
- 轮询状态寄存器,等待WIP位变为0
- 如果需要验证,读回数据比对
写使能这一步很多人会漏掉。W25Q系列进入页编程或擦除前,必须先把WEL位置1,否则命令会被忽略。如果发现读写不生效,先检查是不是忘了发写使能。
3.3 W25Q128常用命令整理
我把项目里会用到的核心命令整理成一张表,方便你后续查:
| 命令名称 | 命令码 | 功能说明 |
|---|---|---|
| Write Enable | 0x06 | 置位WEL,擦写前必须执行 |
| Read Status Register | 0x05 | 读状态寄存器,查询WIP和WEL |
| Read Data | 0x03 | 按字节读,支持任意地址连续读 |
| Page Program | 0x02 | 页编程,一次最多256字节 |
| Sector Erase | 0x20 | 扇区擦除,4KB对齐 |
| Block Erase | 0xD8 | 64KB块擦除,批量操作时比较快 |
| Chip Erase | 0xC7 | 整片擦除,耗时较长且不可逆 |
| Read JEDEC ID | 0x9F | 读芯片厂商ID和型号ID,调试神器 |
4. 代码实现:从CubeMX生成到完整读写流程
配置完CubeMX之后,点击GENERATE CODE,STM32CubeIDE会自动生成初始化代码。接下来要做的就是在生成的工程基础上,封装SPI Flash的底层驱动,然后写业务测试逻辑。
4.1 CubeMX生成的SPI初始化代码解读
打开工程里的main.c,看到SPI1的初始化函数,内容大概是这样的:
static void MX_SPI1_Init(void) { hspi1.Instance = SPI1; hspi1.Init.Mode = SPI_MODE_MASTER; hspi1.Init.Direction = SPI_DIRECTION_2LINES; hspi1.Init.DataSize = SPI_DATASIZE_8BIT; hspi1.Init.CLKPolarity = SPI_POLARITY_LOW; hspi1.Init.CLKPhase = SPI_PHASE_1EDGE; hspi1.Init.NSS = SPI_NSS_SOFT; hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_64; hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB; hspi1.Init.TIMode = SPI_TIMODE_DISABLE; hspi1.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE; hspi1.Init.CRCPolynomial = 10; if (HAL_SPI_Init(&hspi1) != HAL_OK) { Error_Handler(); } }这里有个细节:__HAL_RCC_SPI1_CLK_ENABLE()和 GPIO复用的初始化代码已经由CubeMX写好了,也就是说SPI1的时钟和引脚复用模式(AF5)都已经处理完,不用你手动配置。GPIO初始化部分会生成在MX_GPIO_Init()里,我们把CS引脚设为推挽输出、默认拉高,这个操作在CubeMX里直接配置Pin OUT就好。
HAL库的SPI收发最常用的是这两个函数:
HAL_StatusTypeDef HAL_SPI_Transmit(SPI_HandleTypeDef *hspi, uint8_t *pData, uint16_t Size, uint32_t Timeout); HAL_StatusTypeDef HAL_SPI_Receive(SPI_HandleTypeDef *hspi, uint8_t *pData, uint16_t Size, uint32_t Timeout);注意:HAL_SPI_Transmit和HAL_SPI_Receive是分开的,但SPI是全双工协议,主机发送数据的同时从机也在发送。所以有些场景我们需要用HAL_SPI_TransmitReceive()同时收发。对NOR Flash这种协议,读数据时就是发送命令字节的同时接收从机返回的数据,因此读操作要特别处理。
4.2 底层驱动封装:CS控制、读ID、读数据
首先封装片选控制。软件片选的精髓就是CS引脚完全由你手动拉低拉高,什么时候拉低,什么时候释放,全在掌控中。这里我习惯用宏定义:
#define FLASH_CS_LOW() HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_RESET) #define FLASH_CS_HIGH() HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_SET)读JEDEC ID是验证通信最简单的办法。W25Q128的ID是0xEF 0x40 0x18(华邦),GD25Q128CS则是0xC8 0x40 0x18。读ID的时序很标准:拉低CS,发送0x9F,然后连续读3个字节,最后拉高CS。
void flash_read_id(uint8_t *id) { uint8_t cmd = 0x9F; FLASH_CS_LOW(); HAL_SPI_Transmit(&hspi1, &cmd, 1, 100); HAL_SPI_Receive(&hspi1, id, 3, 100); FLASH_CS_HIGH(); }这里有个细节,读数据时,主机必须产生时钟,从机才会在MISO上输出数据。而HAL_SPI_Receive在接收的同时,主机SCK会持续翻转,MOSI上发送的内容由硬件决定(通常是0xFF或0x00)。所以读数据的本质是“主机发空字节、收有效字节”。
再看读数据函数。读任意地址数据不需要擦除,直接发0x03命令加24位地址,然后连续读,地址会自动递增。比如从地址addr读len个字节到buf:
void flash_read(uint32_t addr, uint8_t *buf, uint32_t len) { uint8_t cmd[4]; cmd[0] = 0x03; cmd[1] = (addr >> 16) & 0xFF; cmd[2] = (addr >> 8) & 0xFF; cmd[3] = addr & 0xFF; FLASH_CS_LOW(); HAL_SPI_Transmit(&hspi1, cmd, 4, 100); // 连续读,一边发0xFF一边收数据 uint8_t dummy = 0xFF; for (uint32_t i = 0; i < len; i++) { HAL_SPI_TransmitReceive(&hspi1, &dummy, &buf[i], 1, 100); } FLASH_CS_HIGH(); }这段代码在数据量大的时候效率不高,因为循环里每次调用HAL函数都有开销。如果追求读速度,可以改成一次HAL_SPI_TransmitReceive(&hspi1, dummy_buf, buf, len, timeout),用一块全0xFF的缓冲区同时发送和接收。下面的写操作函数里我也会提到类似思路。
4.3 写使能、页编程与扇区擦除实现
写使能是擦写前必须执行的第一步。实现很简单:
void flash_write_enable(void) { uint8_t cmd = 0x06; FLASH_CS_LOW(); HAL_SPI_Transmit(&hspi1, &cmd, 1, 100); FLASH_CS_HIGH(); }注意,写使能命令必须在CS拉低之后马上发送,发送完立刻释放CS,这是固定时序。发完06h之后,芯片内部就把WEL位置1了,这个状态会一直保持到下一次上电、执行擦写命令、或者发送写禁用命令(04h)。
写完使能就能擦除了。扇区擦除命令是0x20,后面跟24位地址,且地址必须是4KB对齐。如果地址不是4KB的整数倍,芯片会把地址向下对齐到所在扇区,直接擦掉整个扇区,这个坑我踩过一次,必须提醒你。
void flash_sector_erase(uint32_t addr) { uint8_t cmd[4]; cmd[0] = 0x20; cmd[1] = (addr >> 16) & 0xFF; cmd[2] = (addr >> 8) & 0xFF; cmd[3] = addr & 0xFF; flash_write_enable(); FLASH_CS_LOW(); HAL_SPI_Transmit(&hspi1, cmd, 4, 100); FLASH_CS_HIGH(); flash_wait_busy(); }等待忙状态函数flash_wait_busy()是驱动里非常重要的一个环节。它做的事就是轮询状态寄存器,直到WIP位清零:
void flash_wait_busy(void) { uint8_t cmd = 0x05; uint8_t status = 0x01; while (status & 0x01) { FLASH_CS_LOW(); HAL_SPI_Transmit(&hspi1, &cmd, 1, 100); HAL_SPI_Receive(&hspi1, &status, 1, 100); FLASH_CS_HIGH(); } }页编程命令是0x02,一次能写1到256字节。这里有个最关键的坑:页编程不能跨页。芯片内部是按页(Page)管理的,一页256字节,如果地址落在页边界附近,写入长度超过了当前页剩余空间,数据会回卷到页开头,把页开头的旧数据覆盖掉。所以写驱动时必须分段处理,保证每一段的写入范围都在当前页内。
void flash_write(uint32_t addr, const uint8_t *buf, uint32_t len) { // 按页切分,每页最多写(256 - (addr % 256))字节 while (len > 0) { uint32_t page_remain = 256 - (addr % 256); uint32_t write_len = (len < page_remain) ? len : page_remain; flash_write_enable(); FLASH_CS_LOW(); uint8_t cmd[4]; cmd[0] = 0x02; cmd[1] = (addr >> 16) & 0xFF; cmd[2] = (addr >> 8) & 0xFF; cmd[3] = addr & 0xFF; HAL_SPI_Transmit(&hspi1, cmd, 4, 100); // 连续写入write_len个字节,HAL_SPI_Transmit可以一次传完 HAL_SPI_Transmit(&hspi1, (uint8_t *)buf, write_len, 100); FLASH_CS_HIGH(); flash_wait_busy(); addr += write_len; buf += write_len; len -= write_len; } }这段代码里面有个强制类型转换(uint8_t *)buf,如果传入的数组是const的,这样做没问题;如果入口参数定义成了uint8_t *,可以直接省略。HAL库的Transmit函数不修改数据内容,但形参类型是uint8_t *,所以有时需要做个cast。
4.4 主函数里的业务验证流程
写完驱动之后,我在main函数里先跑一段验证逻辑,确认SPI通信正常、读写数据一致:
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_SPI1_Init(); uint8_t id[3] = {0}; flash_read_id(id); // 应该是 0xEF 0x40 0x18 uint8_t tx_buf[512]; uint8_t rx_buf[512]; memset(tx_buf, 0x00, sizeof(tx_buf)); memcpy(tx_buf, "SPI NOR Flash Test Data", 23); // 擦除首扇区 flash_sector_erase(0x000000); // 写入到偏移100的位置,测试跨页处理 flash_write(0x000064, tx_buf, 300); // 读回验证 memset(rx_buf, 0x00, sizeof(rx_buf)); flash_read(0x000064, rx_buf, 300); // 比对tx_buf和rx_buf,一致则说明读写成功 if (memcmp(tx_buf, rx_buf, 300) == 0) { // 通信和读写都正确 } else { // 走到这里就按后续章节的排查思路来 } while (1) { } }这里我把写入长度故意设为300字节,就是为了验证跨页分支逻辑是否正确。从地址0x64开始写,第一页剩余256-100=156字节,写完后剩下的144字节会写到下一页,正好测试代码里page_remain的处理。
5. 常见问题与排查技巧实录
这段内容是我实际调试过程中最值钱的部分。很多问题书上都查不到,只有亲手踩过,才能体会其中的微妙。
5.1 读ID一直返回0xFF或者0x00
这是SPI Flash调试最常见的问题。排查思路按顺序来:
- 先确认供电,W25Q128的工作电压是2.7~3.6V,很多开发板用3.3V供电没问题,但电源纹波大的话,在擦除瞬间电流拉高,会出现偶发失败
- 再查接线,MISO和MOSI是最容易接反的,主机MOSI接从机MOSI,主机MISO接从机MISO,别把MOSI和MISO交叉对接
- 检查CS引脚释放状态,CS必须默认高电平,如果悬空可能在外部噪声干扰下随机选中芯片
- 最后用示波器看SCK波形,如果SCK上没有时钟输出,大概率是CubeMX里SPI模式没配成Master
还有一个容易被忽略的问题:如果你用的是STM32F407ZGT6这种带有多个SPI的芯片,引脚复用要看清,PA5/PA6/PA7在F407上确实是SPI1,但如果你不小心在CubeMX里改动了某个引脚的复用功能,生成的代码就可能把SPI1信号引到了别的引脚上。建议每次生成工程后,先看一眼HAL_SPI_MspInit()里的GPIO初始化部分。
5.2 通信偶尔正常,偶尔读写数据错乱
这种问题多半是时序和速率的问题。SPI时钟频率过高、线缆过长、或没有进行阻抗匹配时,边沿采样会不稳定。我实测F407核心板用杜邦线连接W25Q模块,1.3125MHz非常稳,10.5MHz开始偶尔出错,21MHz基本没法用。
所以我的建议是:功能调试阶段,Prescaler放到64或128,让通信稳定运行,先把业务逻辑跑通,然后在示波器配合下逐步提高频率。工业产品定型时,再考虑PCB走线优化和更高速率的可能性。另外,如果你的SPI设备有某个引脚需要接上拉电阻(比如WP写保护引脚、HOLD引脚),一定要确认它们处于无效状态(通常接高电平)。这两个引脚悬空时,芯片内部逻辑可能莫名其妙进保护状态,表现就是擦写偶尔失败。
5.3 写数据后读回全是0xFF
这个问题十有八九是忘了擦除。NOR Flash写入前,目标扇区必须是擦除态(全0xFF),然后通过写数据把某些位从1变成0。如果你没擦除就往里面写,读出来的结果就是旧数据和新数据做了一次“逻辑与”,你看到的数据就很奇怪。
另一个可能是指针越界或者页回卷。如果写入长度跨越了页边界,而驱动没有做分页处理,数据会回卷到页首,页首原本擦除掉的数据区域就会被新数据覆盖,而页尾你想要的数据根本没写进去。读回来自然对不上。
5.4 擦除时间很长,程序像卡死了
扇区擦除时间通常在30ms到400ms之间,W25Q128的规格书上标的最大值是3秒(我实测一般几十毫秒)。如果等待超时设置得太短,或者中断频繁打断轮询循环,程序可能误判超时。建议把flash_wait_busy()里的轮询超时设长一点,比如一个循环计数50000次,不要直接依赖HAL函数的Timeout参数,因为超时参数只是HAL库内部判断用的,不是硬实时。
另外,如果擦除整个芯片(Chip Erase),时间会比较长,期间不能掉电。工业场景下如果你在擦除过程中突然断电,坏块风险会增加。所以批量擦除时,一定要确保供电稳定,或者加掉电检测电路,在电压跌落前停止写操作。
5.5 提高速度的几个方向
基础驱动跑通之后,如果你想进一步榨干SPI Flash的性能,可以考虑这些方向:
- 用DMA方式传输数据,解放CPU,尤其在大批量写日志的场景下
- 使用双倍速率模式?不,STM32F407的SPI不支持DDR模式,这条路走不通
- 页编程尽量写满256字节,避免小包多次传输
- 如果数据量特别大,优先用块擦除(0xD8)代替扇区擦除,时间能省很多
- 对于连续的大数据块,考虑直接内存映射模式读取,省去命令开销
这些内容展开讲又是一篇文章了。建议你先跑通基础读写,再根据应用场景优化。
6. 最后的个人经验
根据我做过的几个量产项目,这里分享三点体会。
第一,驱动层的通用性很重要。别看现在是W25Q128,将来可能换GD25Q64或者别的兼容型号。所以写驱动时,把命令码、页大小、扇区大小这些参数提成宏定义,换芯片时只需要改宏,不要为了省事写死在代码里。
第二,数据可靠性设计要做在前头。NOR Flash的擦写寿命一般标称10万次,看起来很多,但如果你的产品频繁记录日志,某个扇区很快就会逼近寿命上限。建议做磨损均衡,或者至少定期迁移数据到备用扇区。一个简单做法是固定一个扇区做计数统计,写满后整体搬移到下一扇区。
第三,调试时保留一组“裸命令”接口非常有帮助。比如预留一个测试函数,可以直接通过串口命令read、write、erase,这样你在现场排查问题时,不用重新烧录程序就能验证Flash状态。我在项目里就是这么做的,省了大量来回改代码重新编译的时间。
这套SPI NOR Flash驱动的方法,不只适用于F407,换到F103、G0系列,CubeMX配置步骤类似,驱动代码稍微改改引脚和句柄就能复用。希望这篇文章能帮你快速上手,少踩几个坑。