简介:这是一套基于STM32的软件SPI读写W25Q64 Flash存储器的完整工程资源,面向单片机初学者及嵌入式开发爱好者,帮助理解在无硬件SPI外设或需要灵活分配引脚时,如何用GPIO软件模拟时序与存储芯片进行可靠通信。资源包共83个文件,以C源文件和H头文件为主,包含STM32标准外设库、启动文件、Keil工程配置以及一张接线示意图,整体压缩后约1004KB,目录按Library、Hardware、User等模块划分,便于按需查阅和二次开发。案例以W25Q64为从机、STM32为主机,选用SPI1引脚定义软件模拟时序,包含MySPI、W25Q64、OLED等驱动模块,可直接看到主机从机的数据交互流程,覆盖读ID、擦除、写入、读取等典型存储操作。已有2050人学习过,说明其思路具有一定的普遍性和参考价值。通过学习,既能掌握软件SPI时序模拟的原理,也能为后续移植驱动到其他Flash或传感器提供清晰样板。
1. 放着硬件SPI不用,为什么总要折腾一遍软件SPI
先说个我自己的真实经历。有次做一块带W25Q64存储日志的板子,PCB布线阶段硬件同事把SPI1的三个引脚借走做了别的功能,等我调板时才发现STM32F103的硬件SPI引脚不能随便挪——要么重映射,要么只能干瞪眼。重映射之后又跟串口3冲突,最后还是老老实实写了软件SPI,用两个普通GPIO把W25Q64跑通了。从那以后我就明白,软件SPI不是硬件SPI的替代品,而是每个嵌入式工程师必须会的兜底方案。
软件SPI的本质,就是用GPIO按照SPI协议一根线一根线地模拟时序。它的价值不在性能,而在三个场景里极其好用:第一,硬件SPI引脚被占用或者不够用,你说换芯片?项目周期不答应。第二,芯片本身不带SPI外设,比如一些低成本的8位单片机,想挂Flash只能软件模拟。第三,调试阶段,软件SPI的时序完全可控,你可以让时钟慢到任意速度,配合逻辑分析仪直接把每一根线上的电平变化看得明明白白。
很多人会问,那直接上硬件SPI不香吗?香,但得分场合。硬件SPI的优势是速度快、有硬件FIFO、可以配合DMA做大数据搬运,CPU基本不用管。但它的劣势也同样明显:引脚固定、移植麻烦、出错之后排查难度大。软件SPI恰好相反,引脚自由度极高,想用哪个IO就用哪个IO,改板子时只需要改宏定义;缺点是速度上不去,还要占用CPU逐位操作。这里我习惯做一个对比,大家按需求选:
| 对比项 | 硬件SPI | 软件SPI |
|---|---|---|
| 速率 | 可达18Mbps以上 | 一般建议1Mbps以内 |
| CPU占用 | 低,可配合DMA | 高,每字节都要CPU参与 |
| 引脚 | 固定,只能重映射到指定引脚 | 任意GPIO,随便选 |
| 代码移植 | 依赖具体芯片外设 | 纯GPIO操作,几乎全平台通用 |
| 调试便利性 | 时序由硬件保证,出错难定位 | 可随意降速,逻辑分析仪可逐位分析 |
| 适合场景 | 大量数据、高速读写、需要并发 | 引脚受限、调试学习、低速小数据量 |
所以我的建议很直白:如果只是驱动W25Q64这种运行频率不高、单次读写几十个字节的存储芯片,软件SPI完全够用。如果是给LCD刷屏、做高速数据采集,那就老老实实上硬件SPI加DMA。本篇的教学价值在于,读懂软件SPI你就读懂了SPI协议本身,后面再切硬件SPI,不过是换几个寄存器操作而已。
2. 摸清W25Q64的“脾气”:SPI时序与指令手册速读
2.1 SPI协议的四根线和两种模式
SPI通信本质上就是四个角色:SCLK提供时钟节奏,MOSI负责主机往从机送数据,MISO负责从机往主机回数据,CS负责选人。它跟I2C最大的区别是不需要应答机制,主机给多少个时钟,就同步交换多少个位。所以写软件SPI的时候,最核心的就是把SCLK这条时钟线老老实实控制住,该出高电平出高电平,该拉低就拉低,节奏对了数据就对了。
SPI协议里有CPOL(时钟极性)和CPHA(时钟相位)两个参数,组合出四种模式。W25Q64这颗芯片支持模式0和模式3,绝大多数嵌入式项目里默认用模式0。所谓模式0,就是CPOL=0、CPHA=0:空闲状态SCLK保持低电平,数据在SCLK的上升沿被采样,在下降沿切换。换句话说,主机想让从机读取一个数据位,就把MOSI上的电平准备好,然后拉高SCLK,从机在这个上升沿把这个位锁存进去;主机想从从机读取一个数据位,就把SCLK拉低,从机在下降沿把数据放到MISO上,主机再去读这个引脚的电平。
理解了这条节奏,软件SPI的核心代码其实就是一个循环里做两件事:发一个位、收一个位。别把SPI想复杂了,它就是一台带方向的移位寄存器,每来一个时钟沿,双方同时移出一位、移入一位。
2.2 摸清W25Q64的指令集
W25Q64是华邦(Winbond)生产的64Mbit串行Flash,换算过来就是8MB存储空间,按4KB一个扇区划分。它的所有操作都是靠主机发送指令字节来触发的,芯片手册里有一张指令集表格,读透它会发现很多规律。
| 指令 | 十六进制码 | 功能 | 时序说明 |
|---|---|---|---|
| Write Enable | 0x06 | 写使能 | CS拉低、发1字节、CS拉高 |
| Read Status Register | 0x05 | 读状态寄存器 | 发指令后直接读1字节 |
| Read Data | 0x03 | 读数据 | 发指令+3字节地址,连续读 |
| Page Program | 0x02 | 页编程(写数据) | 发指令+3字节地址+最多256字节数据 |
| Sector Erase | 0x20 | 扇区擦除 | 发指令+3字节地址 |
| Read JEDEC ID | 0x9F | 读制造商和设备ID | 发指令后读3字节 |
这几个指令就够用了,实际上日常开发90%的时间都在跟它们打交道。还有一个细节务必记住,读ID有两种方式,0x9F是JEDEC标准,返回的三字节依次是厂商ID(0xEF)、存储类型(0x40)、容量代码(0x17);而0x90是老式Read Manufacturer/Device ID指令,时序是发0x90之后接三个哑元字节,再读两个字节,得到0xEF和0x17。两种读法我都用过,调试时要是发现返回值跟网上教程对不上,先检查是不是指令发错了。
2.3 Flash写入前必须先擦除的底层逻辑
这是新手最容易忽略的机制。Flash存储单元跟RAM不一样,它的物理特性决定了只能把1写成0,而不能直接把0写成1。所以要写入新数据之前,必须先执行擦除操作,把目标区域全部恢复成0xFF。W25Q64支持的擦除粒度有扇区擦除(4KB)、块擦除(64KB)、整片擦除,实际开发中扇区擦除是性价比最高的。
擦除和编程都受写保护锁控制,每次操作之前要先发0x06写使能指令,再把CS拉高,让这个使能指令生效。如果漏了这一步,你会发现写状态寄存器、页编程、扇区擦除全部没有反应,这就是芯片的自保护机制。状态寄存器的bit0是BUSY位,擦除和编程期间芯片内部忙,外部要不断轮询这个位,等它归零才表示操作完成。我的经验是:任何时候写完数据或擦完扇区,都要把等待BUSY这件事做进流程里,这是Flash驱动稳定性的基础。
3. 软件SPI的落地代码:从一根线到读写函数
3.1 GPIO初始化:四根线的引脚配置
我以STM32标准库为例,选PA5做SCLK、PA6做MISO、PA7做MOSI、PA4做CS。这里有个小讲究:MISO这根线是芯片往主机输入的信号,GPIO要配成浮空输入或上拉输入,千万别配成推挽输出,否则读回来的数据永远是定值,这是低级错误里高发的一个。
#define SPI_CS_PORT GPIOA #define SPI_CS_PIN GPIO_Pin_4 #define SPI_SCLK_PORT GPIOA #define SPI_SCLK_PIN GPIO_Pin_5 #define SPI_MISO_PORT GPIOA #define SPI_MISO_PIN GPIO_Pin_6 #define SPI_MOSI_PORT GPIOA #define SPI_MOSI_PIN GPIO_Pin_7 void Soft_SPI_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // MOSI、SCLK、CS 都是主机输出,推挽输出即可 GPIO_InitStruct.GPIO_Pin = SPI_CS_PIN | SPI_SCLK_PIN | SPI_MOSI_PIN; GPIO_InitStruct.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStruct); // MISO 是输入,浮空输入,外部无上下拉时也能工作 GPIO_InitStruct.GPIO_Pin = SPI_MISO_PIN; GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStruct); // 默认状态:CS拉高(不选中),SCLK拉低(模式0空闲态) GPIO_SetBits(GPIOA, SPI_CS_PIN); GPIO_ResetBits(GPIOA, SPI_SCLK_PIN); }如果你是HAL库用户,把操作函数换成HAL_GPIO_WritePin和HAL_GPIO_ReadPin就行,逻辑完全一致。注意初始化顺序:先开时钟,再配输出引脚,最后配输入引脚;CS默认一定要拉高,SCLK默认一定要拉低,否则第一次通信时序就是乱的。
3.2 核心字节交换函数:一发一收同时完成
软件SPI的心脏是下面这个函数。它做了一件看起来很朴素的事:循环8次,每一次把待发送字节的最高位移到MOSI上,拉高SCLK让从机采样,拉低SCLK让从机输出数据,最后把MISO上的电平读回来放进待接收字节。
uint8_t Soft_SPI_SwapByte(uint8_t byte) { uint8_t i; for (i = 0; i < 8; i++) { // 发送位:取最高位输出到 MOSI if (byte & 0x80) GPIO_SetBits(GPIOA, SPI_MOSI_PIN); else GPIO_ResetBits(GPIOA, SPI_MOSI_PIN); byte <<= 1; // SCLK 拉高,上升沿,从机采样 MOSI GPIO_SetBits(GPIOA, SPI_SCLK_PIN); SPI_Delay(); // SCLK 拉低,下降沿,从机把数据放到 MISO GPIO_ResetBits(GPIOA, SPI_SCLK_PIN); SPI_Delay(); // 在这里读 MISO,数据此时已经稳定 if (GPIO_ReadInputDataBit(GPIOA, SPI_MISO_PIN)) byte |= 0x01; } return byte; }SPI_Delay()是我习惯写的一个空循环延时,目的不是刻意拖慢速度,而是保证高低电平的持续时间能满足W25Q64的时序要求。主频72MHz的STM32F103上,一个简单的for循环延时几个微秒就足够。有些教程里甚至不延时也能跑,但我不建议去掉,因为PCBA的走线长度、引脚寄生电容都会影响时序,留一点余量能少很多莫名其妙的问题。
有个细节值得多说一句:读MISO的时机。我的写法是SCLK拉低之后再读,对应模式0里“从机在下降沿后输出数据”的语义。实际上很多Flash芯片在下降沿到来后需要极短的时间把数据放上MISO,所以SCLK拉低之后稍微延迟一下再读是最稳妥的,千万别在SCLK还在高电平的时候去读。当然,这种差异在低速软件SPI里几乎感知不到,但养成正确的时序观念,回头写硬件SPI配置CPOL/CPHA时就不会一脸茫然。
3.3 片选控制和指令封装
在写具体指令函数之前,先把片选操作提取成宏或内联函数。这段代码的价值在于,它把整个命令过程框起来:CS拉低表示选中芯片,之后的所有字节都属于同一条命令,直到CS拉高,命令才算结束。
#define SPI_CS_LOW() GPIO_ResetBits(GPIOA, SPI_CS_PIN) #define SPI_CS_HIGH() GPIO_SetBits(GPIOA, SPI_CS_PIN)以最简单的读状态寄存器为例,可以看到SPI指令的通用套路:拉低CS,发指令,读数据,拉高CS。
uint8_t W25Q64_ReadStatusReg(void) { uint8_t status; SPI_CS_LOW(); Soft_SPI_SwapByte(0x05); status = Soft_SPI_SwapByte(0xFF); SPI_CS_HIGH(); return status; }这里发0xFF是一个技巧,主机发出去的字节本身没有意义,目的是配合时钟把从机移位寄存器里的数据“顶”出来。SPI是全双工协议,只要你产生时钟,就同时完成了发送和接收,所以读数据的时候统一发0xFF,简单又不容易出错。
4. 读ID、擦除、写入、回读:让数据真正掉电不丢失
4.1 上电第一步:读ID验证通信链路
每次拿到一块新的W25Q64,我建议第一件事就是写一个读ID函数,串口把读到的三个字节打出来。如果读出来是EF 40 17,说明SPI时序正确,底层通信没问题;如果读出来全是FF,多半是MISO没接对,或者CS没拉低;如果读到00,多半是MOSI发不出数据,或者时钟没动。这一步排查比后面闷头调读写指令要快得多,强烈建议所有新手把这个步骤养成习惯。
void W25Q64_ReadJEDECID(uint8_t *manufacturer, uint8_t *type, uint8_t *capacity) { SPI_CS_LOW(); Soft_SPI_SwapByte(0x9F); *manufacturer = Soft_SPI_SwapByte(0xFF); *type = Soft_SPI_SwapByte(0xFF); *capacity = Soft_SPI_SwapByte(0xFF); SPI_CS_HIGH(); }主函数里调用一次,通过串口或者调试器看返回值。我遇到过很多次情况:代码逻辑写了一千行,最后发现是杜邦线松了,MISO那根线虚接,读回来全部是FF。先跑通读ID再往下走,能给自己省掉大量不必要的排查时间。
4.2 扇区擦除:写入新数据前的清理步骤
Flash写入前必须先擦除。我要往0x000000地址开始写数据,那么先擦除0x000000所在的这个4KB扇区,扇区起始地址就是0x000000。如果我要写0x001234这个地址,它所在的扇区起始地址是0x001000,也就是取地址的高20位作为扇区地址,低12位是该扇区内的偏移。
void W25Q64_SectorErase(uint32_t sector_addr) { // 写使能,在CS上升沿生效 SPI_CS_LOW(); Soft_SPI_SwapByte(0x06); SPI_CS_HIGH(); // 扇区擦除:指令+24位地址 SPI_CS_LOW(); Soft_SPI_SwapByte(0x20); Soft_SPI_SwapByte((sector_addr >> 16) & 0xFF); Soft_SPI_SwapByte((sector_addr >> 8) & 0xFF); Soft_SPI_SwapByte(sector_addr & 0xFF); SPI_CS_HIGH(); // 等待擦除完成,扇区擦除时间最长可达几百毫秒 while (W25Q64_ReadStatusReg() & 0x01); }注意写使能和擦除指令要分开两个CS周期,很多初学代码喜欢把所有字节一口气发完再拉高CS,这是错误的。0x06必须单独一个命令,在CS上升沿被锁存,然后才能执行后续的擦除或者编程指令。等待BUSY用while循环轮询,这个思路比sleep固定延时更可靠,因为擦除时间跟芯片状态、温度都有关系,固定延时写长了浪费时间,写短了直接翻车。
4.3 页编程:256字节的分页写入算法
W25Q64的页编程指令0x02最多可以一次写入256字节,超出则地址会回卷到当前页的开头,把之前写入的数据覆盖掉。所以如果你的数据跨页了,就必须拆成多次页编程。实际项目中,我会写一个通用的写数据函数,自动处理分页逻辑:
void W25Q64_WriteData(uint32_t addr, uint8_t *buf, uint32_t len) { uint32_t page_remain; while (len > 0) { // 先写使能 SPI_CS_LOW(); Soft_SPI_SwapByte(0x06); SPI_CS_HIGH(); // 当前地址到页尾还剩多少字节 page_remain = 256 - (addr % 256); if (page_remain > len) page_remain = len; // 发送页编程指令 SPI_CS_LOW(); Soft_SPI_SwapByte(0x02); Soft_SPI_SwapByte((addr >> 16) & 0xFF); Soft_SPI_SwapByte((addr >> 8) & 0xFF); Soft_SPI_SwapByte(addr & 0xFF); // 发送数据 while (page_remain--) { Soft_SPI_SwapByte(*buf); buf++; len--; addr++; } SPI_CS_HIGH(); // 等待编程完成 while (W25Q64_ReadStatusReg() & 0x01); } }这个函数的精华在page_remain = 256 - (addr % 256)这一行,每次循环先算出当前地址离页边界还有多少字节,取小值作为本次编程的长度,编程完地址往后推,直到数据全部写完。忘记处理跨页问题,是驱动W25Q64最经典的一类Bug,数据错乱还不是最可怕的,更麻烦的是它偶尔对、偶尔错,很难定位。先写清楚分页逻辑,后面就很省心。
4.4 读数据与掉电验证
读数据比写数据简单多了,不需要写使能,也不需要等BUSY,唯一的约束是CS必须保持拉低状态,从发完地址之后连续读。芯片内部会自动递增地址,所以读任意长度数据就是不断调用SwapByte取返回值。
void W25Q64_ReadData(uint32_t addr, uint8_t *buf, uint32_t len) { SPI_CS_LOW(); Soft_SPI_SwapByte(0x03); Soft_SPI_SwapByte((addr >> 16) & 0xFF); Soft_SPI_SwapByte((addr >> 8) & 0xFF); Soft_SPI_SwapByte(addr & 0xFF); while (len--) { *buf = Soft_SPI_SwapByte(0xFF); buf++; } SPI_CS_HIGH(); }到这一步,你就可以做一件很有成就感的事情——验证掉电不丢失。我在自己的板子上写了一个演示流程:上电后先往0x000000写入一串测试数据,再读出来通过串口打印;如果数据符合预期,再配合LED闪烁提示。然后断电,重新上电,这次跳过写入直接读,看看读出来的数据是不是还是那一串。当看到数据原封不动被读出来的时候,对Flash这块的底层逻辑就真的踏实了。W25Q64的标称擦写次数是10万次,数据保存时间可以达到20年以上,做设备参数存储、日志记录、字库存放都够用。
5. 实测中踩过的坑与软件SPI的提速思路
5.1 九成新手都会踩的几个坑
软件SPI项目不大,但坑不少。我把这几年带项目时常见的问题整理成一张排查表,按出现频率排序:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 读ID全FF | MISO引脚配置错误或接线松 | 万用表量通断,检查GPIO模式 |
| 读ID全00 | MOSI发不出数据 | 检查MOSI配置和SCLK是否有波形 |
| 写使能无效 | 0x06和后续指令放在同一个CS周期 | 把0x06单独成命令,CS拉高再拉低 |
| 写入后读回全FF | 没执行扇区擦除就写 | 先擦除再写,确认擦除后等BUSY完成 |
| 数据写到一半对一半错 | 页编程跨页了 | 用分页算法,确保单次不超过页边界 |
| 第n次写入后数据乱 | 前一次操作没等BUSY就发新指令 | 每次擦除、编程后都轮询状态寄存器 |
| 高低电平乱跳导致通信异常 | SCLK延时不够或主频改过 | 调整SPI_Delay的循环次数 |
其中“写使能无效”这个坑尤其容易在初学阶段埋下。我见过很多人的代码在W25Q64_SectorErase里把0x06和0x20连着发,CS一直拉低,结果擦除指令根本没被执行。正确的做法就是分开两个CS周期:第一个周期发0x06并拉高CS让芯片锁存写使能;第二个周期再拉低CS发擦除指令。想理解为什么,就去看数据手册里Write Enable那条指令的时序图,CS上升沿那个位置画了一个锁存标记,这是硬件设计决定的。
还有一个测试时容易被误导的点:如果你用的W25Q64是全新出厂的,读出来是全FF,这不能说明通信有问题。所以读ID测试通常放在最开始做,因为ID值固定,能直观反映通信链路是否正常。
5.2 软件SPI的提速手段
很多朋友用软件SPI跑起来之后,第一个不满就是速度太慢。软件SPI在每个字节上都要花大量CPU时间去翻转GPIO,所以它的瓶颈不在协议本身,而在GPIO操作的开销上。想提速,有几个思路。
第一步,把GPIO操作从库函数换成寄存器直接操作。标准库的GPIO_SetBits和GPIO_ResetBits虽然方便,但每次调用都有函数压栈和参数检查的开销。改成操作GPIOA->BSRR寄存器,一个赋值完成置位或清零:
#define MOSI_LOW() GPIOA->BSRR = (uint32_t)GPIO_Pin_7 << 16 #define MOSI_HIGH() GPIOA->BSRR = GPIO_Pin_7 #define SCLK_HIGH() GPIOA->BSRR = GPIO_Pin_5 #define SCLK_LOW() GPIOA->BSRR = (uint32_t)GPIO_Pin_5 << 16BSRR这个寄存器的设计很巧妙:低16位写1对应引脚置高,高16位写1对应引脚拉低,写0无影响。用寄存器操作之后,整个SwapByte函数体积小了很多,速度能提升好几倍。
第二步,缩短SPI_Delay的时间。在主频72MHz下,一条赋值语句几个周期就完成,SCLK周期只要不低于1微秒,W25Q64都能稳定工作。我实际测试过,把延时循环优化到只是简单空转几次,SCLK频率跑到接近1MHz依然稳定读写;再往上就要靠逻辑分析仪验证时序是否满足手册要求了。注意每个项目的走线寄生电容不同,提速要逐步试,不能一把梭。
第三步,如果数据量大到软件SPI实在顶不住,那就换硬件SPI。很多STM32的硬件SPI最高可以跑到18MHz,加上DMA,CPU几乎零负担。这个切换并不伤筋动骨,因为指令集逻辑完全一样,你只需要把SwapByte替换成SPI_I2S_SendData加等待标志位的操作,外围逻辑全部复用。这也是为什么我一直强调,先把软件SPI的时序逻辑搞懂再换硬件,这两者之间的鸿沟其实没有想象中那么大。
5.3 给软件SPI初学者的三条建议
第一,先接逻辑分析仪。哪怕是几十块钱的24MHz采样率逻辑分析仪,配合开源的PulseView软件,就能把SCLK、MOSI、MISO、CS四根线的时序全部抓下来,跟数据手册的时序图对照。调试SPI时的痛苦,80%都来源于“盲调”,看得见波形之后,问题定位速度会快一个数量级。
第二,不要嫌读ID这一步多余。无论是新板子还是新芯片,第一步永远是读ID确认通信正常。这个习惯我在公司里带新人时反复强调,它能快速把“代码问题”“接线问题”“芯片问题”分开,减少无意义的折腾时间。
第三,代码里把CS的高低电平宏定义独立出来,不要散落到处是GPIO操作。这样以后换引脚、换板子,改宏定义就可以直接准备编译,这也是软件SPI相对硬件SPI的最大优势。我有块测试板,前期用PA口做的软SPI,后来改板用了PB口,整个迁移过程不到五分钟,这种愉悦感硬件SPI给不了。
软件SPI这个东西,练的不是写代码的能力,而是对时序协议的理解。你把四根线的每一步都走了一遍,去读任何芯片的数据手册都有底气;哪天遇到硬件SPI解析不上来,也能用软件SPI去量、去对比、去找问题。这套底子打扎实了,后面学任何带时序的外设都会顺利很多。
本文还有配套的精品资源,点击获取