ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

软件SPI驱动W25Q64:从GPIO时序模拟到Flash读写实战

软件SPI驱动W25Q64:从GPIO时序模拟到Flash读写实战 简介这是一套基于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就是CPOL0、CPHA0空闲状态SCLK保持低电平数据在SCLK的上升沿被采样在下降沿切换。换句话说主机想让从机读取一个数据位就把MOSI上的电平准备好然后拉高SCLK从机在这个上升沿把这个位锁存进去主机想从从机读取一个数据位就把SCLK拉低从机在下降沿把数据放到MISO上主机再去读这个引脚的电平。理解了这条节奏软件SPI的核心代码其实就是一个循环里做两件事发一个位、收一个位。别把SPI想复杂了它就是一台带方向的移位寄存器每来一个时钟沿双方同时移出一位、移入一位。2.2 摸清W25Q64的指令集W25Q64是华邦Winbond生产的64Mbit串行Flash换算过来就是8MB存储空间按4KB一个扇区划分。它的所有操作都是靠主机发送指令字节来触发的芯片手册里有一张指令集表格读透它会发现很多规律。指令十六进制码功能时序说明Write Enable0x06写使能CS拉低、发1字节、CS拉高Read Status Register0x05读状态寄存器发指令后直接读1字节Read Data0x03读数据发指令3字节地址连续读Page Program0x02页编程写数据发指令3字节地址最多256字节数据Sector Erase0x20扇区擦除发指令3字节地址Read JEDEC ID0x9F读制造商和设备ID发指令后读3字节这几个指令就够用了实际上日常开发90%的时间都在跟它们打交道。还有一个细节务必记住读ID有两种方式0x9F是JEDEC标准返回的三字节依次是厂商ID0xEF、存储类型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全FFMISO引脚配置错误或接线松万用表量通断检查GPIO模式读ID全00MOSI发不出数据检查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加上DMACPU几乎零负担。这个切换并不伤筋动骨因为指令集逻辑完全一样你只需要把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去量、去对比、去找问题。这套底子打扎实了后面学任何带时序的外设都会顺利很多。本文还有配套的精品资源点击获取
返回列表