简介:本资源面向STM32单片机与嵌入式开发初学者及进阶学习者,聚焦软件SPI通信方式读写W25Q64 Flash存储器这一典型实战场景。W25Q64内部可存储8M字节数据且掉电不丢失,案例完整演示了主机STM32与从机W25Q64模块之间的通信实现,接线采用SPI1外设引脚方案,PA4至PA7分别对应NSS、SCK、MISO、MOSI,便于后续过渡到硬件SPI章节。压缩包共83个文件,约1004KB,以36个h头文件与33个c源文件为核心,涵盖MySPI、W25Q64、OLED、Key等驱动模块,另含8个s启动文件及uvprojx、uvoptx等Keil工程配置,目录按User、Hardware、Library、Start分层组织,结构清晰。目前已有2052人学习下载。读者可从中获得软件SPI时序模拟、Flash读写指令封装、OLED显示调试等完整代码与排错思路,适合作为嵌入式存储外设学习的参考范例。
1. 为什么我宁愿用软件SPI死磕W25Q64,也不直接开硬件外设
刚入行那会儿,我接手一个数据采集板子,主控是STM32F103,外挂一颗W25Q64做本地日志存储。硬件SPI引脚被板子布线锁死了,偏偏那两根线还跟JTAG复用,调试时一烧录就打架。当时第一反应是改板,但项目周期不允许,只能硬着头皮用软件SPI把W25Q64驱动起来。结果这一搞,反而让我摸清了SPI通信的底层时序——时钟极性、相位、片选建立时间,这些平时被硬件外设自动处理掉的细节,全得自己拿捏。
软件SPI读写W25Q64,说白了就是用普通GPIO口模拟SCK、MOSI、MISO三根线的电平翻转,配合CS片选,按W25Q64的指令集一帧一帧地收发数据。它解决的核心问题是:当硬件SPI引脚冲突、数量不够,或者你想完全掌控时序细节时,还能把Flash存储器跑起来。适合谁?手头有STM32开发板、想搞懂SPI协议底层、或者被硬件资源逼到墙角的嵌入式开发者。这篇笔记就把我踩过的坑和能直接抄的代码全倒出来,从GPIO配置到页编程、扇区擦除,一步步走通。
2. 软件SPI的时序底子:先把W25Q64的脾气摸透
2.1 SPI四种模式与W25Q64的固定档位
SPI通信有四个模式,由CPOL(时钟极性)和CPHA(时钟相位)组合而成。W25Q64只支持模式0(CPOL=0,CPHA=0)和模式3(CPOL=1,CPHA=1)。我一般用模式0:SCK空闲时为低电平,数据在SCK上升沿采样,下降沿输出。软件模拟时,这个时序必须用GPIO的置位和清零精确复现,差一个微秒都可能读到0xFF。
为什么强调这个?因为很多新手直接用硬件SPI的默认配置,CPOL和CPHA没对齐,读回来的ID全是0xFF,还以为芯片坏了。软件SPI的好处就在这——你可以用示波器或者逻辑分析仪直接抓SCK和MOSI的波形,一眼看出相位对不对。
2.2 软件SPI的GPIO配置要点
软件SPI需要三根输出线(SCK、MOSI、CS)和一根输入线(MISO)。SCK和MOSI配置为推挽输出,CS也配推挽输出但初始拉高。MISO必须配成上拉输入或浮空输入,因为W25Q64在CS拉高时MISO是高阻态,如果配成推挽输出会跟Flash的输出打架,烧芯片不至于,但读数据肯定乱。
我习惯把SCK和MOSI的GPIO速度设成50MHz,虽然软件翻转根本跑不到那么快,但边沿陡峭一点,时序更干净。CS线单独拎出来,不跟其他外设共用,避免片选误触发。
// 软件SPI GPIO初始化,以STM32F103为例 void SoftSPI_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // SCK - PA5 推挽输出 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // MOSI - PA7 推挽输出 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_7; GPIO_Init(GPIOA, &GPIO_InitStructure); // CS - PA4 推挽输出,初始拉高 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_SetBits(GPIOA, GPIO_Pin_4); // MISO - PA6 上拉输入 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; GPIO_Init(GPIOA, &GPIO_InitStructure); }这段代码里,MISO配成上拉输入是关键。上拉保证在Flash没驱动总线时,MCU读到的是确定的高电平,而不是悬空的玄学值。CS初始拉高,让Flash处于待命状态,等你要通信时再拉低。
2.3 软件SPI字节收发函数
字节收发是软件SPI的核心。模式0下,SCK空闲低电平,先发MOSI的最高位,然后SCK拉高,从机在上升沿采样,MCU在上升沿之后读MISO。一个bit一个bit循环8次,拼成一个字节。
// 软件SPI收发一个字节,模式0 uint8_t SoftSPI_SwapByte(uint8_t byte) { uint8_t i, recv = 0; for (i = 0; i < 8; i++) { // 准备MOSI数据,先发最高位 if (byte & 0x80) GPIO_SetBits(GPIOA, GPIO_Pin_7); else GPIO_ResetBits(GPIOA, GPIO_Pin_7); byte <<= 1; // SCK拉高,从机采样 GPIO_SetBits(GPIOA, GPIO_Pin_5); // 读MISO,主机采样 recv <<= 1; if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_6)) recv |= 0x01; // SCK拉低 GPIO_ResetBits(GPIOA, GPIO_Pin_5); } return recv; }逻辑说明:每次循环先摆好MOSI电平,再拉高SCK。从机在SCK上升沿把MOSI上的数据读走,同时把自己的数据推到MISO上。MCU在SCK高电平期间读MISO,然后拉低SCK,准备下一个bit。参数上,byte是要发送的字节,返回值是同时接收到的字节。注意这里没有加延时,因为GPIO翻转本身就有一定间隔,STM32F103跑72MHz时,这个循环的速率大概在几百kHz到1MHz之间,W25Q64最高支持80MHz,所以完全跟得上。
3. W25Q64指令集落地:从读ID到页编程的完整链路
3.1 读JEDEC ID验证通信是否打通
拿到一颗新Flash,第一件事不是急着写数据,而是读它的JEDEC ID。W25Q64的ID是0xEF4017,厂商ID 0xEF,设备ID 0x4017。读ID的指令是0x9F,拉低CS后发0x9F,然后连续读3个字节。
// 读取W25Q64的JEDEC ID uint32_t W25Q64_ReadID(void) { uint32_t id = 0; GPIO_ResetBits(GPIOA, GPIO_Pin_4); // CS拉低 SoftSPI_SwapByte(0x9F); // 发送读ID指令 id |= SoftSPI_SwapByte(0xFF) << 16; // 厂商ID id |= SoftSPI_SwapByte(0xFF) << 8; // 存储器类型 id |= SoftSPI_SwapByte(0xFF); // 容量 GPIO_SetBits(GPIOA, GPIO_Pin_4); // CS拉高 return id; }如果读回来是0xEF4017,说明硬件连线、GPIO配置、SPI时序全对了。如果读回0xFFFFFF或0x000000,先查CS有没有拉低,再查MISO是不是配成了推挽输出,最后用逻辑分析仪看SCK有没有波形。这一步过了,后面才有意义。
3.2 写使能、页编程与忙等待
W25Q64写入前必须先发写使能指令0x06,否则数据写不进去。页编程指令是0x02,后面跟24位地址,再跟最多256字节数据。注意,一页是256字节,跨页写入会回卷到页首覆盖数据,这是血泪教训。
// 写使能 void W25Q64_WriteEnable(void) { GPIO_ResetBits(GPIOA, GPIO_Pin_4); SoftSPI_SwapByte(0x06); GPIO_SetBits(GPIOA, GPIO_Pin_4); } // 等待忙状态结束 void W25Q64_WaitBusy(void) { uint8_t status; GPIO_ResetBits(GPIOA, GPIO_Pin_4); SoftSPI_SwapByte(0x05); // 读状态寄存器1 do { status = SoftSPI_SwapByte(0xFF); } while (status & 0x01); // bit0为BUSY位 GPIO_SetBits(GPIOA, GPIO_Pin_4); } // 页编程,地址需256字节对齐 void W25Q64_PageProgram(uint32_t addr, uint8_t *data, uint16_t len) { uint16_t i; W25Q64_WriteEnable(); GPIO_ResetBits(GPIOA, GPIO_Pin_4); SoftSPI_SwapByte(0x02); // 页编程指令 SoftSPI_SwapByte((addr >> 16) & 0xFF); SoftSPI_SwapByte((addr >> 8) & 0xFF); SoftSPI_SwapByte(addr & 0xFF); for (i = 0; i < len; i++) SoftSPI_SwapByte(data[i]); GPIO_SetBits(GPIOA, GPIO_Pin_4); W25Q64_WaitBusy(); // 等待写入完成 }参数说明:addr是24位地址,W25Q64容量8MB,地址范围0x000000到0x7FFFFF。len不能超过256,且addr的低8位加上len不能跨过256边界。忙等待是必须的,Flash内部擦写需要时间,页编程典型1.4ms,扇区擦除典型45ms,不等待就发下一条指令,数据必丢。
3.3 扇区擦除与数据读取
Flash的物理特性是只能把1写成0,不能把0写成1。所以写入前必须先擦除,擦除后全变0xFF。W25Q64最小擦除单位是扇区,4KB。扇区擦除指令0x20,读数据指令0x03。
// 扇区擦除,4KB对齐 void W25Q64_SectorErase(uint32_t addr) { W25Q64_WriteEnable(); GPIO_ResetBits(GPIOA, GPIO_Pin_4); SoftSPI_SwapByte(0x20); SoftSPI_SwapByte((addr >> 16) & 0xFF); SoftSPI_SwapByte((addr >> 8) & 0xFF); SoftSPI_SwapByte(addr & 0xFF); GPIO_SetBits(GPIOA, GPIO_Pin_4); W25Q64_WaitBusy(); } // 读取数据,任意地址任意长度 void W25Q64_ReadData(uint32_t addr, uint8_t *buf, uint32_t len) { uint32_t i; GPIO_ResetBits(GPIOA, GPIO_Pin_4); SoftSPI_SwapByte(0x03); SoftSPI_SwapByte((addr >> 16) & 0xFF); SoftSPI_SwapByte((addr >> 8) & 0xFF); SoftSPI_SwapByte(addr & 0xFF); for (i = 0; i < len; i++) buf[i] = SoftSPI_SwapByte(0xFF); GPIO_SetBits(GPIOA, GPIO_Pin_4); }读数据没有长度限制,可以跨页跨扇区连续读,地址会自动递增。但写数据必须按页对齐,擦除必须按扇区对齐。我一般会在应用层做一个简单的磨损均衡,把日志轮流写到不同扇区,避免同一个扇区反复擦写提前报废。
4. 避坑排查:软件SPI驱动W25Q64的五个翻车现场
4.1 读ID返回0xFF,波形却正常
现象:逻辑分析仪看SCK、MOSI、CS都正常,但MISO始终高电平,读回0xFFFFFF。 原因:MISO配置成了推挽输出,MCU和Flash同时驱动这根线,电平被拉死。 解决:把MISO改成上拉输入或浮空输入,确保MCU只读不驱。
4.2 页编程后读回数据错位
现象:写入0x01到0x0A,读回来发现0x0A跑到0x00去了。 原因:写入地址没有256字节对齐,跨页后Flash内部地址回卷到页首。 解决:应用层保证每次页编程的起始地址低8位加上长度不超过256,跨页就拆成两次写。
4.3 擦除后忘了写使能,数据写不进去
现象:扇区擦除成功,读回全0xFF,但页编程后数据没变。 原因:W25Q64每次写操作前都必须发0x06写使能,擦除和编程各需要一次。 解决:把写使能封装成独立函数,在每次擦除和编程前调用,别嫌麻烦。
4.4 忙等待没加,连续写丢数据
现象:连续写多个扇区,只有第一个成功,后面全失败。 原因:Flash内部擦写需要时间,上一条指令没完成就发下一条,状态机乱了。 解决:每次擦除和编程后调用忙等待函数,轮询状态寄存器bit0直到为0。
4.5 软件SPI速率过高导致读数据不稳定
现象:读ID正常,但连续读大量数据时偶尔出现位错误。 原因:GPIO翻转太快,W25Q64在3.3V下最高80MHz,但软件模拟加上循环开销,实际边沿可能不够干净,加上杜邦线长了有分布电容。 解决:在SCK翻转之间插入几个NOP空指令,或者降低GPIO速度配置,用示波器看SCK上升沿是否过冲。
5. 进阶技巧:用软件SPI做双Flash交替存储与数据校验
软件SPI跑通之后,我习惯在应用层加一层简单的数据校验和双区备份。W25Q64有8MB空间,划成两个4MB区域,交替写入。每次写新数据前,先擦除备用区,写入完成后更新一个标志扇区,记录当前有效区。这样即使写入过程中断电,重启后还能从旧区恢复。
具体做法:定义一个结构体存数据头,包含魔数、长度、CRC16校验值。写入时先算CRC,连同数据一起写入。读取时先校验魔数和CRC,不通过就切到备用区读。
typedef struct { uint32_t magic; // 0x5A5A5A5A uint32_t len; uint16_t crc; uint8_t data[250]; } LogRecord_t; // 简单CRC16校验 uint16_t CRC16_Calc(uint8_t *buf, uint32_t len) { uint16_t crc = 0xFFFF; uint32_t i; uint8_t j; for (i = 0; i < len; i++) { crc ^= buf[i]; for (j = 0; j < 8; j++) { if (crc & 0x01) crc = (crc >> 1) ^ 0xA001; else crc >>= 1; } } return crc; }写入流程:先读当前有效区标志,确定备用区地址,擦除备用区对应扇区,构造LogRecord_t,计算CRC,页编程写入,最后更新标志扇区。读取流程反过来,先读标志区,再读数据区,校验CRC,失败就切另一个区。
这套机制跑下来,最明显的收益是断电恢复能力。以前单区写入,写到一半掉电,整个日志区就废了。现在最坏情况是丢最后一条记录,前面的数据完好。代价是Flash寿命减半,但W25Q64擦写次数10万次,按每天写100次算,也能撑好几年。
从那以后我每次用软件SPI驱动Flash,都强制走一遍读ID、擦除、写、读回校验的完整流程,确认时序和指令集全对了再往上叠业务逻辑。希望帮到你。
本文还有配套的精品资源,点击获取