资讯动态

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

发布时间:2026/10/17 14:22:39 来源:尧图企业网站定制
简介本资源面向嵌入式开发初学者与单片机工程师聚焦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 的四种模式组合。CPOLClock Polarity决定时钟空闲状态CPOL0 时 SCK 空闲为低电平CPOL1 时空闲为高电平。CPHAClock Phase决定采样边沿CPHA0 时在第一个边沿采样CPHA1 时在第二个边沿采样。W25Q64 支持 Mode 0CPOL0, CPHA0和 Mode 3CPOL1, CPHA1这两种模式在实际使用中最常见。软件 SPI 的本质就是用代码精确控制 SCK 的翻转和 MOSI/MISO 的读写时机。下面是一段典型的软件 SPI 字节收发实现// 软件SPIMode 0 (CPOL0, CPHA0) // 空闲时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 写使能为什么每次写操作前都要发 0x06W25Q64 有一个写保护机制上电后写使能锁存器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 表示通用推挽输出、最大 50MHz0x8 表示上拉/下拉输入。不同 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 是厂商 ID0x4017 是设备 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 通信本身出了问题读到的状态寄存器值始终是 0xFFMISO 被拉高。还有一种可能是写使能没有成功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。这个习惯帮我省下了大量猜测到底是硬件问题还是代码问题的时间。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑