简介:本资源面向STM32单片机与嵌入式开发初学者及进阶学习者,聚焦软件SPI通信方式读写W25Q64 Flash存储器这一典型外设实验。W25Q64内部可存储8M字节数据且掉电不丢失,案例完整演示了主机STM32与从机W25Q64模块之间的通信实现,接线沿用SPI1外设引脚布局,PA4至PA7分别对应NSS、SCK、MISO、MOSI,便于后续过渡到硬件SPI章节。压缩包共83个文件,约1004KB,包含36个h头文件、33个c源文件、8个s启动文件,以及uvprojx工程文件、uvoptx配置、jpg接线图与bat脚本等,涵盖MySPI、W25Q64、OLED、Key等驱动模块与标准外设库,工程结构清晰,可直接编译调试。目前已有2052人学习下载,适合希望掌握软件模拟SPI时序、Flash读写指令与嵌入式工程组织方式的读者参考实践。
1. 软件SPI驱动W25Q64:为什么我宁愿用GPIO模拟也不碰硬件外设
去年调一块STM32板子上的W25Q64,硬件SPI的MOSI和MISO死活对不上波形,示波器抓了半天发现是引脚复用配置被另一段初始化代码覆盖了。当时赶进度,索性把SPI外设关掉,直接用GPIO模拟时序,二十分钟跑通。从那以后,凡是W25Q64这类标准SPI Flash的读写,我优先考虑软件SPI——不是因为硬件SPI不好,而是软件SPI的引脚随便换、时序看得见、出问题能单步调试,对嵌入式开发和单片机初学者来说,这种可控性比省那几个CPU周期重要得多。
这份资源围绕STM32平台,用软件模拟SPI的方式完成对W25Q64 Flash存储器的读写操作,涵盖扇区擦除、页写入、数据读取等核心功能。适合正在学STM32 SPI通信、需要给项目加外部存储、或者被硬件SPI时序坑过的嵌入式开发者。下面从项目定位、代码结构、关键实现到二次开发,完整拆一遍。
2. 项目定位与技术选型:为什么是软件SPI加W25Q64
2.1 这个项目到底解决什么问题
W25Q64是一颗8MB容量的SPI NOR Flash,嵌入式项目里常用来存字库、图片、配置参数、日志数据。它支持标准SPI模式,指令集简单,但时序要求严格——时钟极性、相位、建立保持时间都有明确规定。STM32的硬件SPI外设当然能驱动它,但硬件SPI有几个现实问题:引脚固定,PCB画错了不好改;配置项多,CPOL/CPHA、波特率预分频、数据帧格式,任何一个配错都出不来数据;出问题时波形在芯片内部,只能靠猜。
软件SPI用普通GPIO口模拟时钟和数据线,引脚随便指定,时序完全由代码控制。代价是速度慢一些,STM32F103在72MHz主频下,软件SPI大概能跑到几MHz的等效时钟,对W25Q64的读写需求来说够用。这个项目的价值就在于:把SPI通信的底层时序摊开给你看,每一行代码对应波形上的一个电平变化,调试的时候逻辑分析仪一挂,问题一目了然。
2.2 技术栈与硬件清单
项目基于STM32标准外设库或HAL库开发,不依赖特定型号,F1、F4系列都能移植。核心用到的东西不多:
| 类别 | 具体内容 | 说明 |
|---|---|---|
| 主控 | STM32系列单片机 | 任意带GPIO的型号均可 |
| 存储 | W25Q64 SPI Flash | 8MB容量,标准SPI接口 |
| 引脚 | 4根GPIO | CS、CLK、MOSI、MISO |
| 开发环境 | Keil MDK / STM32CubeIDE | 标准C工程 |
| 调试工具 | 逻辑分析仪(可选) | 抓时序用,没有也能跑 |
软件SPI的引脚分配很灵活,我一般选同一组GPIO端口,比如PA4到PA7,这样操作寄存器的时候一条语句就能搞定,不用跨端口读改写。CS片选单独一根,CLK时钟一根,MOSI主机输出从机输入,MISO主机输入从机输出,四根线各司其职。
2.3 软件SPI的时序本质
SPI通信的本质就是:主机拉低CS选中从机,然后在CLK的每个边沿上,MOSI和MISO同时交换一位数据。W25Q64支持SPI模式0(CPOL=0,CPHA=0)和模式3(CPOL=1,CPHA=1),模式0最常用——时钟空闲为低电平,数据在上升沿采样。
软件SPI要做的就是手动翻转GPIO来模拟这个时序。发送一个字节,就是循环8次,每次先把MOSI设成当前位,然后拉高CLK,再拉低CLK,从机在上升沿读到数据。接收一个字节同理,只不过是在CLK拉高之后去读MISO的电平。听起来简单,但建立时间和保持时间要留够,不然高速下会丢位。我一般会在CLK翻转之间加几个NOP空指令,保证电平稳定。
3. 核心模块拆解:GPIO模拟时序与W25Q64指令交互
3.1 软件SPI底层驱动:四个GPIO的翻转逻辑
先看最底层的引脚操作。这部分代码直接决定时序能不能跑通,我习惯把宏定义写清楚,后面改引脚只动这一处。
// 软件SPI引脚宏定义,根据实际接线修改 #define SPI_CS_PORT GPIOA #define SPI_CS_PIN GPIO_Pin_4 #define SPI_CLK_PORT GPIOA #define SPI_CLK_PIN GPIO_Pin_5 #define SPI_MOSI_PORT GPIOA #define SPI_MOSI_PIN GPIO_Pin_7 #define SPI_MISO_PORT GPIOA #define SPI_MISO_PIN GPIO_Pin_6 // 引脚电平操作宏,用位带或BSRR寄存器实现 #define SPI_CS_HIGH() GPIO_SetBits(SPI_CS_PORT, SPI_CS_PIN) #define SPI_CS_LOW() GPIO_ResetBits(SPI_CS_PORT, SPI_CS_PIN) #define SPI_CLK_HIGH() GPIO_SetBits(SPI_CLK_PORT, SPI_CLK_PIN) #define SPI_CLK_LOW() GPIO_ResetBits(SPI_CLK_PORT, SPI_CLK_PIN) #define SPI_MOSI_HIGH() GPIO_SetBits(SPI_MOSI_PORT, SPI_MOSI_PIN) #define SPI_MOSI_LOW() GPIO_ResetBits(SPI_MOSI_PORT, SPI_MOSI_PIN) #define SPI_MISO_READ() GPIO_ReadInputDataBit(SPI_MISO_PORT, SPI_MISO_PIN)这段代码把每个引脚的操作封装成宏,好处是后面写时序逻辑的时候不用关心底层寄存器。CS、CLK、MOSI配置为推挽输出,MISO配置为浮空输入或上拉输入。初始化的时候记得开GPIO时钟,否则写寄存器没反应——这个坑我踩过不止一次,现象是波形死活出不来,查半天发现RCC没使能。
3.2 SPI模式0的字节收发函数
底层宏有了,接下来是收发一个字节。W25Q64用模式0,数据在上升沿采样,所以发送时先设数据再拉高时钟。
// 软件SPI收发一个字节,模式0:空闲低电平,上升沿采样 uint8_t SoftSPI_ReadWriteByte(uint8_t txData) { uint8_t rxData = 0; uint8_t i; for (i = 0; i < 8; i++) { // 准备发送位:MSB先行,取最高位 if (txData & 0x80) SPI_MOSI_HIGH(); else SPI_MOSI_LOW(); txData <<= 1; // 左移,准备下一位 __NOP(); // 短暂延时,保证建立时间 SPI_CLK_HIGH(); // 上升沿,从机采样MOSI __NOP(); rxData <<= 1; // 接收位左移 if (SPI_MISO_READ()) // 读取MISO电平 rxData |= 0x01; SPI_CLK_LOW(); // 下降沿,准备下一个位 __NOP(); } return rxData; }这个函数是全双工的,发一个字节的同时收一个字节。参数txData是要发送的数据,返回值是接收到的数据。MSB先行是SPI的标准做法,W25Q64也要求高位在前。__NOP()是空指令,用来做微秒级以下的延时,保证CLK翻转后电平稳定。如果主频很高,比如STM32F4跑168MHz,可能需要多加几个NOP,或者用示波器确认一下时钟频率别超过W25Q64的极限——W25Q64标准SPI最高支持104MHz,但软件模拟一般也就几MHz,不用担心超速。
3.3 W25Q64指令层:擦除、写入、读取
有了字节收发函数,就可以按W25Q64的指令集来操作了。W25Q64的指令都是先发命令码,再发地址或数据。几个核心指令:
| 指令名称 | 命令码 | 功能 |
|---|---|---|
| Write Enable | 0x06 | 写使能,擦除和写入前必须发 |
| Read Status Register | 0x05 | 读状态寄存器,判断忙闲 |
| Page Program | 0x02 | 页写入,最多256字节 |
| Sector Erase | 0x20 | 扇区擦除,4KB |
| Read Data | 0x03 | 读取数据 |
写使能是每次擦除或写入前都要发的,W25Q64内部有个写使能锁存位,不发这个指令,后面的擦写操作直接被忽略。状态寄存器的BUSY位用来判断芯片是否在忙,擦除一个扇区大概需要几十毫秒,写入一页大概零点几毫秒,必须等BUSY清零才能发下一条指令。
// 读取W25Q64状态寄存器,判断BUSY位 uint8_t W25Q64_ReadStatus(void) { uint8_t status; SPI_CS_LOW(); SoftSPI_ReadWriteByte(0x05); // 发送读状态寄存器指令 status = SoftSPI_ReadWriteByte(0xFF); // 读回状态值 SPI_CS_HIGH(); return status; } // 等待芯片空闲,超时返回1表示失败 uint8_t W25Q64_WaitBusy(void) { uint32_t timeout = 0; while ((W25Q64_ReadStatus() & 0x01) == 0x01) { timeout++; if (timeout > 1000000) // 超时保护,防止死循环 return 1; } return 0; }W25Q64_ReadStatus函数拉低CS,发0x05指令,再发一个哑字节0xFF把状态值读回来。W25Q64_WaitBusy循环读状态寄存器,检查最低位BUSY,直到它变0。超时保护是必须加的,万一硬件有问题,没有超时就会卡死在while里,整个系统假死。这个超时值根据主频调整,一般给个几十万到上百万次循环足够覆盖最大擦除时间。
3.4 扇区擦除与页写入的完整流程
擦除和写入是W25Q64操作里最容易翻车的地方。NOR Flash的特性是:写入只能把1变成0,不能把0变成1,所以写入之前必须先擦除,擦除会把整个扇区变成0xFF。而且擦除的最小单位是扇区(4KB),写入的最小单位是页(256字节),跨页写入需要拆分成多次。
// 扇区擦除,addr为扇区起始地址,需4KB对齐 void W25Q64_SectorErase(uint32_t addr) { SPI_CS_LOW(); SoftSPI_ReadWriteByte(0x06); // 写使能 SPI_CS_HIGH(); SPI_CS_LOW(); SoftSPI_ReadWriteByte(0x20); // 扇区擦除指令 SoftSPI_ReadWriteByte((addr >> 16) & 0xFF); // 地址高8位 SoftSPI_ReadWriteByte((addr >> 8) & 0xFF); // 地址中8位 SoftSPI_ReadWriteByte(addr & 0xFF); // 地址低8位 SPI_CS_HIGH(); W25Q64_WaitBusy(); // 等待擦除完成 } // 页写入,addr需256字节对齐,len不超过256 void W25Q64_PageProgram(uint32_t addr, uint8_t *buf, uint16_t len) { uint16_t i; SPI_CS_LOW(); SoftSPI_ReadWriteByte(0x06); // 写使能 SPI_CS_HIGH(); SPI_CS_LOW(); SoftSPI_ReadWriteByte(0x02); // 页写入指令 SoftSPI_ReadWriteByte((addr >> 16) & 0xFF); SoftSPI_ReadWriteByte((addr >> 8) & 0xFF); SoftSPI_ReadWriteByte(addr & 0xFF); for (i = 0; i < len; i++) { SoftSPI_ReadWriteByte(buf[i]); // 逐字节写入 } SPI_CS_HIGH(); W25Q64_WaitBusy(); // 等待写入完成 }扇区擦除的地址必须4KB对齐,比如0x000000、0x001000、0x002000,如果传了个0x000800进去,擦除的其实是0x000000开始的扇区,数据就乱了。页写入的地址必须256字节对齐,len不能超过256,超了会在页内回卷,覆盖掉页开头的数据。这两个对齐要求是W25Q64的硬件特性,不是软件能绕过去的,写代码的时候一定要在注释里标清楚。
读取数据就简单多了,发0x03指令加地址,然后连续读就行,没有对齐限制,想读多少读多少。
// 读取数据,addr任意,len任意 void W25Q64_ReadData(uint32_t addr, uint8_t *buf, uint32_t len) { uint32_t i; SPI_CS_LOW(); SoftSPI_ReadWriteByte(0x03); // 读数据指令 SoftSPI_ReadWriteByte((addr >> 16) & 0xFF); SoftSPI_ReadWriteByte((addr >> 8) & 0xFF); SoftSPI_ReadWriteByte(addr & 0xFF); for (i = 0; i < len; i++) { buf[i] = SoftSPI_ReadWriteByte(0xFF); // 发哑字节读数据 } SPI_CS_HIGH(); }4. 避坑与排查:软件SPI调W25Q64最容易翻车的五个地方
4.1 读出来全是0xFF或0x00
现象:发完读指令,读回来的数据全是0xFF或者全是0x00,换地址也一样。
原因:0xFF通常意味着MISO一直高电平,可能是MISO引脚没配置成输入,或者从机根本没响应;0x00则可能是MISO被拉死。先查GPIO初始化,MISO必须是输入模式,而且如果外部没有上拉电阻,建议配成上拉输入。再查CS片选,CS没拉低的话,W25Q64根本不理你。
解决:用万用表量MISO引脚电压,发读指令的时候应该有高低变化。如果没有,检查引脚配置和焊接。另外确认W25Q64的供电和地都接好了,VCC和GND虚焊也会导致无响应。
4.2 擦除后写入不成功
现象:擦除指令发了,也等了BUSY,但写入后再读,数据不对。
原因:最常见的是写使能没发,或者写使能和实际擦写操作之间CS被拉高了。W25Q64的写使能位在CS上升沿之后会被清除,所以写使能指令和擦写指令必须在同一次CS拉低期间连续发送,中间不能断开。
解决:把写使能和擦除/写入放在同一个CS低电平周期里,中间不要插其他操作。我一般写成:CS_LOW → 0x06 → CS_HIGH → CS_LOW → 0x20 → 地址 → CS_HIGH,这样写使能位在第二次CS拉低时仍然有效。
4.3 跨页写入数据错乱
现象:一次写入超过256字节,读回来发现后面的数据覆盖了前面的。
原因:W25Q64页写入时,如果写入长度超过256字节,地址会在页内回卷。比如从0x0000F0开始写300字节,写到0x0000FF之后会回到0x000000继续写,把开头的数据覆盖掉。
解决:写入前先算好页边界,跨页的拆成多次页写入。我一般封装一个W25Q64_Write函数,内部自动处理跨页拆分,调用者不用关心。
4.4 时序太快导致数据丢失
现象:低速能跑,提高主频后偶尔读出错数据。
原因:软件SPI的翻转速度受GPIO驱动能力和代码执行速度影响,CLK边沿太快,W25Q64的建立保持时间不够,数据采样出错。
解决:在CLK翻转之间加NOP延时,或者降低GPIO的翻转速度。用逻辑分析仪抓一下CLK频率,W25Q64标准SPI最高104MHz,但软件模拟一般控制在1到5MHz比较稳。如果主频是72MHz,一个NOP大概14纳秒,加两三个NOP就能把边沿放缓。
4.5 擦除时间不够导致后续操作失败
现象:擦除后立刻写入,写入失败或者数据不对。
原因:扇区擦除需要几十毫秒,虽然调用了W25Q64_WaitBusy,但如果超时值设得太小,可能在BUSY还没清零的时候就返回了。
解决:加大超时值,或者直接用状态寄存器轮询,不要用固定延时。W25Q64数据手册里擦除典型时间45ms,最大400ms,超时值按最大时间留余量。
5. 二次开发与移植:从能跑到好用
5.1 把软件SPI抽象成可替换的接口
现在代码里SoftSPI_ReadWriteByte是直接操作GPIO的,如果以后想换成硬件SPI,或者换到另一组引脚,改起来要动很多地方。我的做法是加一层函数指针或者条件编译,把底层收发抽象出来。
// 定义SPI收发接口类型 typedef uint8_t (*SPI_ReadWriteFunc)(uint8_t); // 全局接口指针,默认指向软件SPI static SPI_ReadWriteFunc spi_rw = SoftSPI_ReadWriteByte; // 切换为硬件SPI时,只需替换这个指针 void W25Q64_SetSPIInterface(SPI_ReadWriteFunc func) { spi_rw = func; }这样W25Q64的指令层代码完全不用改,只换底层实现就行。移植到新平台的时候,写一个新的GPIO操作函数,把指针指过去,上层逻辑原封不动。
5.2 加一个简单的文件系统或参数管理
裸读写W25Q64适合存少量数据,但如果要存多个参数、日志、字库,直接按地址管理很容易冲突。我一般会在上面加一层简单的参数管理:把Flash分成几个区域,每个区域存一类数据,用一个结构体记录地址和长度。
// Flash分区定义 typedef struct { uint32_t start_addr; // 起始地址 uint32_t size; // 区域大小 const char *name; // 区域名称 } FlashPartition; // 分区表,按实际需求划分 const FlashPartition partitions[] = { {0x000000, 0x001000, "config"}, // 配置参数区,4KB {0x001000, 0x010000, "font"}, // 字库区,64KB {0x011000, 0x100000, "log"}, // 日志区,1MB };这样每次读写先查分区表,找到对应区域的起始地址,再偏移。好处是地址不会写乱,加新数据的时候也清楚往哪放。分区表可以根据项目需求调整,W25Q64总共8MB,合理规划一下够用很久。
5.3 验证方法:写一个自测函数
移植完或者改完代码,别急着往项目里集成,先跑一个自测。我习惯写一个W25Q64_SelfTest函数,擦除一个扇区,写入一组递增数据,再读回来比对,全对就返回0,有错就返回错误位置。
// 自测函数,返回0表示通过,非0表示出错位置 uint32_t W25Q64_SelfTest(void) { uint8_t write_buf[256]; uint8_t read_buf[256]; uint16_t i; // 准备测试数据 for (i = 0; i < 256; i++) write_buf[i] = i; // 擦除测试扇区 W25Q64_SectorErase(0x000000); // 写入测试数据 W25Q64_PageProgram(0x000000, write_buf, 256); // 读回数据 W25Q64_ReadData(0x000000, read_buf, 256); // 比对 for (i = 0; i < 256; i++) { if (read_buf[i] != write_buf[i]) return i + 1; // 返回出错位置,从1开始 } return 0; }这个自测函数跑一遍,基本能覆盖CS、CLK、MOSI、MISO四根线的连通性和时序正确性。如果返回0,说明底层驱动没问题,可以放心往上层用。如果返回非0,返回值就是第一个出错的位置,对着逻辑分析仪抓波形,很快能定位。
5.4 速度优化:什么时候该换硬件SPI
软件SPI的优点是灵活,缺点是占CPU。每次收发一个字节要循环8次,每次好几个GPIO操作,如果频繁读写大量数据,CPU基本被占满。我一般这样判断:如果单次读写不超过几百字节,或者读写频率很低,软件SPI完全够用;如果要连续读写几KB甚至几十KB,比如刷字库、存图片,那就该换硬件SPI了。
换硬件SPI的时候,底层接口不用重写,把spi_rw指针指向硬件SPI的收发函数就行。硬件SPI的配置注意几点:波特率预分频别设太高,W25Q64虽然支持高速,但PCB走线不好的话高速会出错;CPOL和CPHA设成模式0;数据帧设成8位;MSB先行。配好之后,同样的W25Q64指令层代码直接跑,速度能提升几十倍。
从那以后我每次调SPI Flash,都先把自测函数跑一遍,确认底层通了再写上层逻辑。这个习惯帮我省了很多来回折腾的时间,也避免了不少低级错误。希望帮到你。
本文还有配套的精品资源,点击获取