简介本资源面向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只支持模式0CPOL0CPHA0和模式3CPOL1CPHA1。我一般用模式0SCK空闲时为低电平数据在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、擦除、写、读回校验的完整流程确认时序和指令集全对了再往上叠业务逻辑。希望帮到你。本文还有配套的精品资源点击获取