ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

软件SPI读写W25Q64:时序、指令与避坑指南

软件SPI读写W25Q64:时序、指令与避坑指南

简介:本资源面向嵌入式开发初学者与单片机工程师,聚焦STM32平台下软件SPI通信与W25Q64 Flash存储器的读写实践。W25Q64可存储8M字节数据且掉电不丢失,案例通过GPIO模拟SPI时序,配合PA4至PA7分别连接从机CS、CLK、DO、DI引脚,并预留SPI1硬件外设接线方式,便于后续切换硬件SPI对照学习。压缩包共83个文件,约1004KB,包含36个h头文件、33个c源文件、8个s启动文件,以及uvprojx工程、uvoptx配置、jpg接线图与bat脚本等,涵盖MySPI、W25Q64、OLED、Key等驱动模块及标准外设库,工程结构完整可直接编译。目前已有2052人学习下载,适合希望掌握SPI时序、Flash读写指令与嵌入式工程组织方式的读者参考实践。

1. 软件SPI读写W25Q64:为什么时序对了,数据还是错的

很多人第一次用 STM32 驱动 W25Q64 时都会遇到一个诡异现象:代码逻辑看起来没问题,引脚配置也检查了三遍,读回来的 ID 却是 0xFF 或者 0x00。更让人抓狂的是,换一块板子、换一片 Flash,有时候又能正常工作。这种"玄学"问题的根源,往往不在代码本身,而在于对 SPI 时序本质的理解偏差。

W25Q64 是一颗 8MB 的 NOR Flash,支持标准 SPI、Dual SPI 和 Quad SPI 三种模式。在嵌入式开发中,用 GPIO 模拟软件 SPI 来读写它,是理解 SPI 协议底层机制最好的切入点。因为当你用硬件 SPI 外设时,很多时序细节被硬件自动处理了,你不需要关心时钟极性、相位、建立保持时间;但一旦改成软件模拟,每一个电平翻转的时机都暴露在你面前,任何一处偏差都会直接导致通信失败。

这篇文章要讲清楚的核心问题是:软件 SPI 到底是怎么通过 GPIO 翻转来模拟出一个完整的 SPI 时序的?W25Q64 的指令集和状态寄存器在其中扮演什么角色?以及为什么有些看似"多余"的延时和操作,恰恰是保证可靠通信的关键。

2. SPI 时序的本质:时钟极性与相位到底在控制什么

2.1 从一根时钟线说起

SPI 是全双工同步串行总线,核心只有四根线:SCK(时钟)、MOSI(主出从入)、MISO(主入从出)、CS(片选)。所谓"同步",意思是数据的采样和输出都跟随时钟边沿进行。但问题来了——时钟空闲时是高电平还是低电平?数据在上升沿采样还是下降沿采样?这两个问题的答案,就构成了 SPI 的四种模式组合。

CPOL(Clock Polarity)决定时钟空闲状态:CPOL=0 时 SCK 空闲为低电平,CPOL=1 时空闲为高电平。CPHA(Clock Phase)决定采样边沿:CPHA=0 时在第一个边沿采样,CPHA=1 时在第二个边沿采样。W25Q64 支持 Mode 0(CPOL=0, CPHA=0)和 Mode 3(CPOL=1, CPHA=1),这两种模式在实际使用中最常见。

软件 SPI 的本质,就是用代码精确控制 SCK 的翻转和 MOSI/MISO 的读写时机。下面是一段典型的软件 SPI 字节收发实现:

// 软件SPI:Mode 0 (CPOL=0, CPHA=0) // 空闲时SCK为低,数据在SCK上升沿被采样 uint8_t SoftSPI_TransferByte(uint8_t txData) { uint8_t rxData = 0; for (int i = 7; i >= 0; i--) // MSB先行 { // 1. 主机在SCK低电平时准备好MOSI数据 if (txData & (1 << i)) MOSI_HIGH(); else MOSI_LOW(); // 2. 拉高SCK,产生上升沿,从机在此刻采样MOSI SCK_HIGH(); // 3. 主机在上升沿后读取MISO,从机已在此前准备好数据 if (MISO_READ()) rxData |= (1 << i); // 4. 拉低SCK,完成一个时钟周期 SCK_LOW(); } return rxData; }

这段代码看起来简单,但每一行的顺序都不能随意调换。MOSI 必须在 SCK 拉高之前就稳定,因为从机会在上升沿采样;MISO 必须在 SCK 拉高之后读取,因为从机也是在上升沿之后才把数据推到位。如果先拉高 SCK 再设置 MOSI,从机采到的就是上一个bit的值,数据整体偏移一位——这就是很多人读回数据"差一位"的根本原因。

2.2 为什么软件 SPI 需要额外延时

硬件 SPI 外设的时钟频率可以跑到几十 MHz,但软件 SPI 用 GPIO 翻转模拟时,频率通常只能做到 1-5 MHz,甚至更低。这不是因为 GPIO 翻转速度不够,而是因为 W25Q64 对时序有最低要求。

翻看 W25Q64 的数据手册,会发现几个关键时间参数:CS 拉低到第一个 SCK 上升沿之间需要至少 5ns 的建立时间;SCK 上升沿到数据有效需要一定的时间;最后一个 SCK 下降沿到 CS 拉高之间也需要保持时间。在硬件 SPI 中,这些时间由外设自动保证;但在软件 SPI 中,如果 GPIO 翻转太快,从机可能还没来得及响应,数据就已经被采样了。

常见的做法是在关键操作之间插入几个 NOP 或者短延时:

// 在CS拉低后插入短延时,满足tSLCH(片选到第一个时钟的建立时间) CS_LOW(); Delay_ns(10); // 根据主频调整,通常几个NOP即可 // 发送指令...

这里的延时不是"越慢越好",而是"刚好满足手册要求"。延时过长会降低通信速度,延时过短则可能导致偶发失败。我一般会先用较长的延时确保功能正常,再逐步缩短到手册规定的最小值附近,留出 20% 左右的余量。

2.3 CS 片选的控制逻辑

CS 信号在 SPI 通信中扮演着"帧界定"的角色。W25Q64 的很多指令是"指令码 + 地址 + 数据"的多字节序列,CS 必须在整个序列期间保持低电平,中间不能拉高,否则 Flash 会认为一次传输结束,后续字节被当作新指令解析。

一个容易踩的坑是:在发送指令和读取数据之间,有些开发者习惯性地拉高 CS 再拉低,想"刷新"一下状态。这在 W25Q64 上是致命的——比如读状态寄存器指令 0x05,如果 CS 在指令码之后拉高,Flash 会直接结束这次操作,后续读到的就是无效数据。

正确的做法是:从 CS 拉低开始,到整个指令序列完成,CS 始终保持低电平。只有在需要发起新的独立操作时,才拉高 CS 再重新拉低。

3. W25Q64 的指令集:状态寄存器为什么是绕不开的一环

3.1 指令码与地址的发送顺序

W25Q64 的指令格式通常是:1 字节指令码 + 3 字节地址 + N 字节数据。地址是 24 位的,因为 8MB 容量需要 23 位地址线(2^23 = 8M),加上一位保留位凑成 3 字节。发送时高位在前,比如要访问地址 0x000123,发送顺序是 0x00、0x01、0x23。

以读数据指令 0x03 为例,完整的时序是:CS 拉低 → 发送 0x03 → 发送 3 字节地址 → 读取 N 字节数据 → CS 拉高。这里有一个细节:在发送完地址后,Flash 需要一点时间准备数据,但 W25Q64 的读操作是"流式"的,不需要额外的等待周期,直接连续读取即可。

// 读取W25Q64数据:指令0x03 + 24位地址 + 数据 void W25Q64_ReadData(uint32_t addr, uint8_t *buf, uint32_t len) { CS_LOW(); SoftSPI_TransferByte(0x03); // 读数据指令 SoftSPI_TransferByte((addr >> 16) & 0xFF); // 地址高8位 SoftSPI_TransferByte((addr >> 8) & 0xFF); // 地址中8位 SoftSPI_TransferByte(addr & 0xFF); // 地址低8位 for (uint32_t i = 0; i < len; i++) { buf[i] = SoftSPI_TransferByte(0xFF); // 发送哑元,读取数据 } CS_HIGH(); }

注意这里发送地址时用的是SoftSPI_TransferByte,它的返回值被丢弃了——因为在发送阶段,MISO 上的数据是无意义的。而在读取阶段,发送的是 0xFF(哑元),目的是产生时钟脉冲,让从机把数据推出来。这是 SPI 全双工特性的典型体现:你发送什么不重要,重要的是时钟在翻转。

3.2 状态寄存器的轮询机制

W25Q64 的写操作(页编程 0x02、扇区擦除 0x20、块擦除 0xD8、全片擦除 0xC7)都需要时间。页编程典型耗时 0.7ms,扇区擦除 45ms,全片擦除可能长达 20 秒。在这段时间内,Flash 处于"忙"状态,不接受新指令。

怎么知道 Flash 忙不忙?靠读状态寄存器。状态寄存器 1 的 bit0 是 BUSY 位,为 1 表示正在执行内部操作。标准做法是:发送写指令后,循环读取状态寄存器,直到 BUSY 位变为 0。

// 等待W25Q64空闲 void W25Q64_WaitBusy(void) { uint8_t status; do { CS_LOW(); SoftSPI_TransferByte(0x05); // 读状态寄存器1 status = SoftSPI_TransferByte(0xFF); CS_HIGH(); } while (status & 0x01); // bit0为BUSY位 }

这里有一个性能优化的点:每次读状态寄存器都要拉低再拉高 CS,如果轮询间隔太短,CS 频繁翻转会浪费时间。常见的做法是在两次轮询之间加一个短延时,比如 10-100 微秒,既能及时检测到 BUSY 清除,又不会过度占用 CPU。

3.3 写使能:为什么每次写操作前都要发 0x06

W25Q64 有一个写保护机制:上电后,写使能锁存器(WEL)默认为 0,任何写操作(页编程、擦除)都会被忽略。要执行写操作,必须先发送写使能指令 0x06,把 WEL 置 1。

关键在于:WEL 在执行完一次写操作后会自动清零。也就是说,每次页编程或擦除之前,都要重新发送 0x06。很多初学者写完一次数据后,第二次写就失败了,就是因为忘了在第二次写之前重新发写使能。

// 页编程:写使能 → 发送指令和地址 → 发送数据 → 等待完成 void W25Q64_PageProgram(uint32_t addr, uint8_t *data, uint16_t len) { W25Q64_WriteEnable(); // 发送0x06 CS_LOW(); SoftSPI_TransferByte(0x02); // 页编程指令 SoftSPI_TransferByte((addr >> 16) & 0xFF); SoftSPI_TransferByte((addr >> 8) & 0xFF); SoftSPI_TransferByte(addr & 0xFF); for (uint16_t i = 0; i < len; i++) { SoftSPI_TransferByte(data[i]); } CS_HIGH(); W25Q64_WaitBusy(); // 等待编程完成 }

还有一个隐藏的坑:页编程不能跨页。W25Q64 的页大小是 256 字节,如果从地址 0x0000F0 开始写 32 字节,会写到 0x000110,跨越了页边界。Flash 内部会自动回卷到页首,覆盖掉 0x000000 开始的数据。所以写数据前必须计算当前页剩余空间,分多次写入。

4. 从 GPIO 翻转到底层时序:软件 SPI 的完整实现链路

4.1 GPIO 模式配置的讲究

软件 SPI 的 GPIO 配置看似简单,但有一个细节容易被忽略:MOSI 和 SCK 配置为推挽输出,MISO 配置为上拉输入或浮空输入。如果 MISO 配置成推挽输出,会和从机的输出形成冲突,轻则通信失败,重则烧毁引脚。

推挽输出的驱动能力强,上升沿和下降沿陡峭,适合做时钟和数据输出。MISO 作为输入,如果配置成浮空输入,在从机没有驱动时会处于不确定状态,容易受干扰;配置成上拉输入,可以在空闲时保持高电平,减少误触发。

// GPIO初始化:SCK和MOSI推挽输出,MISO上拉输入 void SoftSPI_GPIO_Init(void) { // 使能GPIO时钟(以某系列为例) RCC->APB2ENR |= RCC_APB2ENR_IOPAEN; // PA5: SCK, 推挽输出, 50MHz GPIOA->CRL &= ~(0xF << 20); GPIOA->CRL |= (0x3 << 20); // 输出模式, 最大50MHz // PA6: MISO, 上拉输入 GPIOA->CRL &= ~(0xF << 24); GPIOA->CRL |= (0x8 << 24); // 输入模式, 上拉/下拉 // PA7: MOSI, 推挽输出, 50MHz GPIOA->CRL &= ~(0xF << 28); GPIOA->CRL |= (0x3 << 28); }

这里的寄存器操作是直接操作 CRL(端口配置低寄存器),每个引脚占 4 个 bit。0x3 表示通用推挽输出、最大 50MHz;0x8 表示上拉/下拉输入。不同 STM32 系列的寄存器名称可能不同,但配置逻辑是一致的。

4.2 位操作的性能优化

软件 SPI 的性能瓶颈在于 GPIO 翻转速度。如果每次翻转都调用库函数,函数调用开销会显著降低通信速率。常见的优化是直接用位带操作或寄存器宏:

// 直接操作寄存器,避免函数调用开销 #define SCK_HIGH() (GPIOA->BSRR = GPIO_BSRR_BS5) #define SCK_LOW() (GPIOA->BSRR = GPIO_BSRR_BR5) #define MOSI_HIGH() (GPIOA->BSRR = GPIO_BSRR_BS7) #define MOSI_LOW() (GPIOA->BSRR = GPIO_BSRR_BR7) #define MISO_READ() ((GPIOA->IDR & GPIO_IDR_IDR6) != 0)

BSRR 是位设置/清除寄存器,写 1 到低 16 位设置对应引脚为高,写 1 到高 16 位设置对应引脚为低。这种写法比GPIO_SetBits()快得多,因为省去了函数调用和参数检查。在 72MHz 的 STM32 上,这种优化可以把软件 SPI 速率从几百 kHz 提升到 2-3 MHz。

4.3 完整读写流程的时序验证

把前面的模块串起来,一个完整的 W25Q64 读写流程是这样的:

第一步,初始化 GPIO 和 Flash。上电后先读 JEDEC ID(指令 0x9F),确认通信正常。W25Q64 的 JEDEC ID 是 0xEF4017,其中 0xEF 是厂商 ID,0x4017 是设备 ID。

第二步,擦除目标扇区。W25Q64 的最小擦除单位是扇区(4KB),擦除后数据全为 0xFF。注意:Flash 只能把 1 写成 0,不能把 0 写成 1,所以写入前必须先擦除。

第三步,页编程写入数据。每次最多写 256 字节,且不能跨页。

第四步,读取数据验证。用读数据指令 0x03 把刚写入的内容读回来,逐字节比较。

// 完整的读写测试流程 void W25Q64_Test(void) { uint8_t writeBuf[16] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10}; uint8_t readBuf[16] = {0}; uint32_t jedecID = W25Q64_ReadJEDECID(); if (jedecID != 0xEF4017) { // 通信异常,检查接线和GPIO配置 return; } W25Q64_SectorErase(0x000000); // 擦除扇区0 W25Q64_WaitBusy(); W25Q64_PageProgram(0x000000, writeBuf, 16); // 写入16字节 W25Q64_WaitBusy(); W25Q64_ReadData(0x000000, readBuf, 16); // 读回验证 for (int i = 0; i < 16; i++) { if (readBuf[i] != writeBuf[i]) { // 数据不匹配,排查时序或擦除问题 break; } } }

这个流程覆盖了软件 SPI 读写 W25Q64 的所有关键环节。如果 JEDEC ID 读不对,问题在通信层;如果 ID 对了但数据读回不对,问题在擦除或编程时序。

5. 避坑指南:软件 SPI 驱动 W25Q64 的五个典型翻车现场

5.1 读回数据全为 0xFF 或 0x00

现象:发送读 ID 指令后,MISO 上读回的数据始终是 0xFF 或 0x00,不随指令变化。

原因:MISO 引脚配置错误是最常见的原因。如果 MISO 被配置成推挽输出,主机会一直驱动这根线,从机的输出被短路,读回的就是主机输出的值。另一个可能是 CS 没有正确拉低,Flash 根本没被选中。

解决:检查 MISO 是否配置为输入模式;用示波器或逻辑分析仪观察 CS、SCK、MOSI、MISO 四根线的波形,确认 CS 在通信期间为低,SCK 有翻转,MISO 有数据变化。

5.2 写入成功但读回数据错位

现象:写入 0x01 0x02 0x03,读回的是 0x02 0x03 0x04 或者 0x00 0x01 0x02。

原因:SPI 模式不匹配。W25Q64 支持 Mode 0 和 Mode 3,如果主机用 Mode 0 而从机期望 Mode 3,采样边沿就会错位。另一种可能是软件 SPI 的采样时机写反了——在 SCK 上升沿之前读了 MISO,或者 MOSI 在 SCK 上升沿之后才稳定。

解决:确认 W25Q64 使用的模式(通常 Mode 0),检查SoftSPI_TransferByte中 MOSI 设置、SCK 拉高、MISO 读取、SCK 拉低的顺序是否正确。

5.3 擦除后写入失败

现象:扇区擦除后,页编程写入数据,但读回全是 0xFF。

原因:忘记发送写使能指令 0x06。W25Q64 的 WEL 位在每次写操作后自动清零,擦除和编程都需要独立的写使能。

解决:在每次W25Q64_SectorErase和W25Q64_PageProgram之前调用W25Q64_WriteEnable()。注意写使能本身也是一条 SPI 指令,需要 CS 拉低→发送 0x06→CS 拉高。

5.4 跨页写入导致数据覆盖

现象:从地址 0x0000F0 开始写 32 字节,读回时发现前 16 字节正确,后 16 字节跑到了 0x000000 的位置。

原因:W25Q64 页大小为 256 字节,页编程指令不能跨页。当地址到达页边界时,Flash 内部地址计数器回卷到页首,覆盖之前的数据。

解决:写数据前计算当前页剩余空间,如果剩余空间小于要写入的长度,分多次写入。每次写入不超过页边界。

5.5 轮询状态寄存器时死循环

现象:程序卡在W25Q64_WaitBusy中出不来,BUSY 位始终为 1。

原因:可能是擦除或编程操作确实需要较长时间(全片擦除可达 20 秒),也可能是 SPI 通信本身出了问题,读到的状态寄存器值始终是 0xFF(MISO 被拉高)。还有一种可能是写使能没有成功,Flash 忽略了写操作,BUSY 位自然也不会置起。

解决:在轮询循环中加超时计数,超过一定次数后退出并报错。同时检查写使能是否成功——可以在发送 0x06 后读状态寄存器,确认 WEL 位(bit1)为 1。

6. 软件 SPI 值不值得用:一个关于可控性与效率的取舍

软件 SPI 的最大价值在于"可控"。当你用 GPIO 模拟 SPI 时,每一个时钟边沿、每一次数据采样都在你的代码掌控之中。这种可控性在调试阶段是无价的——你可以随时在任意位置插入延时、翻转引脚、打印波形,而不需要去翻硬件手册查某个寄存器的某个位是什么意思。对于 W25Q64 这类时序要求严格但速度要求不高的场景,软件 SPI 完全够用,而且能帮你真正理解 SPI 协议的每一个细节。

但软件 SPI 的代价也很明显:占用 CPU 时间。硬件 SPI 发送一个字节只需要几个时钟周期,软件 SPI 需要几十甚至上百个指令周期。在 72MHz 的 STM32 上,软件 SPI 的速率通常只能做到 1-3 MHz,而硬件 SPI 可以轻松跑到 18 MHz 甚至更高。如果你需要频繁读写大量数据,软件 SPI 会成为瓶颈。

我的一般做法是:调试阶段用软件 SPI,把时序跑通、把 W25Q64 的指令集摸熟;功能验证完成后,如果速度不够,再切换到硬件 SPI。切换时只需要把SoftSPI_TransferByte替换成硬件 SPI 的收发函数,上层逻辑几乎不用改。这种"先软后硬"的路径,比一上来就啃硬件 SPI 的寄存器要踏实得多。

还有一个容易被忽略的点:软件 SPI 的引脚可以任意映射。当 PCB 布线紧张,硬件 SPI 的固定引脚走不通时,软件 SPI 可以把 SCK、MOSI、MISO 分配到任何空闲 GPIO 上。这种灵活性在一些空间受限的项目里是刚需。

从那以后我每次调 SPI 通信,都会先用软件 SPI 把波形打出来看一遍,确认时序没问题了再决定要不要换硬件 SPI。这个习惯帮我省下了大量猜测"到底是硬件问题还是代码问题"的时间。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进