简介:本资源是一份面向嵌入式初学者与STM32开发者的完整软件SPI驱动实践项目,聚焦W25Q64 Flash存储器的底层读写功能实现,解决单片机在无硬件SPI外设或需灵活引脚复用场景下的可靠Flash通信问题。压缩包共83个文件,涵盖36个头文件(.h)定义寄存器、接口及驱动结构,33个C源文件(.c)实现系统初始化、软件SPI时序模拟、W25Q64指令集封装、OLED显示与按键交互等核心逻辑,另有启动文件(.s)、Keil工程配置(.uvprojx/.uvoptx)、调试配置(.dbgconf)及接线示意图(.jpg),结构清晰、模块解耦,便于理解驱动分层设计思想。资源包大小为1004KB,轻量易导入,已获2052人学习下载。读者可直接编译运行,获得从GPIO模拟时序、Flash擦写校验到数据持久化存储的全流程代码参考,并通过MySPI.c、W25Q64.c等关键文件深入掌握软件SPI时序精度控制与Flash指令协议细节。
1. 用普通GPIO模拟SPI时序,把W25Q64当“掉电不丢的内存”来用:适合没硬件SPI引脚、又得存配置/日志/固件片段的嵌入式现场
你手头那块STM32F103C8T6最小系统板,PA0~PA3全被LED、按键、串口占满了,唯独没空闲的SPI外设引脚——但项目硬要求记录设备运行时的温度曲线(每秒1次,持续24小时),断电后还得能读出来。这时候,软件SPI不是“退而求其次”的妥协方案,而是唯一能落地的解法。本项目就是一套零依赖、可裁剪、带完整擦写校验逻辑的W25Q64驱动,它不调用HAL库SPI模块,所有SCK/MOSI/MISO/CS信号全靠GPIO翻转+精确延时生成,实测在72MHz主频下稳定跑出1MHz等效时钟(满足W25Q64 DC参数要求),支持标准指令集(0x03读、0x02写、0x20/0xD8扇区/块擦除)、写保护解除、状态寄存器轮询,甚至内置了页对齐检查和写前校验。如果你正在做工业传感器节点、便携医疗设备或教育实验平台,需要在资源极度受限的MCU上实现可靠非易失存储,这份代码就是你拆开就能焊进自己工程里的“存储胶水”。
2. 工程结构与核心文件职责:从裸机启动到W25Q64操作的完整链路
2.1 目录树与关键文件定位(基于标准Keil MDK工程)
项目采用极简分层:无RTOS、无中间件、无抽象层,所有代码直面寄存器。目录结构如下(路径以/为根,对应Keil工程中Group组织):
/Drivers /W25Q64 w25q64.h // 头文件:宏定义、函数声明、结构体 w25q64.c // 主驱动:初始化、读写擦除、状态轮询 w25q64_spi.c // 软件SPI底层:SCK/MOSI/MISO/CS GPIO控制 + 延时 /Startup startup_stm32f103xb.s // 标准启动文件 /Core main.c // 主循环:演示写入1KB数据并验证 stm32f103xb.h // 标准外设库头文件(使用标准外设库V3.5.0) /Inc stm32f10x_conf.h // 外设使能配置提示:
w25q64_spi.c是本项目最核心的“玄学”所在——它不依赖SysTick或定时器,纯靠__nop()和循环计数实现纳秒级精度延时。在72MHz下,一个__nop()耗时约13.9ns,通过预计算循环次数(如for(volatile uint8_t i=0; i<5; i++);)精准控制SCK高/低电平时间,这是软件SPI稳定性的命脉。
2.2 w25q64.h:接口契约与关键常量定义
该头文件定义了所有对外暴露的API和底层约束,是二次开发的第一入口:
#ifndef __W25Q64_H #define __W25Q64_H #include "stm32f10x.h" // === 硬件连接定义(必须按实际接线修改!)=== #define W25Q64_CS_GPIO_PORT GPIOA #define W25Q64_CS_GPIO_PIN GPIO_Pin_4 #define W25Q64_SCK_GPIO_PORT GPIOA #define W25Q64_SCK_GPIO_PIN GPIO_Pin_5 #define W25Q64_MOSI_GPIO_PORT GPIOA #define W25Q64_MOSI_GPIO_PIN GPIO_Pin_7 #define W25Q64_MISO_GPIO_PORT GPIOA #define W25Q64_MISO_GPIO_PIN GPIO_Pin_6 // === W25Q64容量与地址映射 === #define W25Q64_FLASH_SIZE (8*1024*1024) // 8MB = 64Mbit #define W25Q64_SECTOR_SIZE (4*1024) // 4KB/sector #define W25Q64_PAGE_SIZE (256) // 256B/page #define W25Q64_BLOCK_SIZE (64*1024) // 64KB/block // === 指令集(直接映射W25Q64 datasheet Table 1)=== #define W25X_WriteEnable 0x06 #define W25X_WriteDisable 0x04 #define W25X_ReadStatusReg 0x05 #define W25X_WriteStatusReg 0x01 #define W25X_ReadData 0x03 #define W25X_FastRead 0x0B #define W25X_PageProgram 0x02 #define W25X_SectorErase 0x20 #define W25X_BlockErase 0xD8 #define W25X_ChipErase 0xC7 #define W25X_PowerDown 0xB9 #define W25X_ReleasePowerDown 0xAB // === 函数声明 === uint8_t W25Q64_Init(void); uint8_t W25Q64_ReadStatusRegister(void); void W25Q64_WriteEnable(void); void W25Q64_WaitForWriteEnd(void); uint8_t W25Q64_ReadByte(uint32_t ReadAddr); void W25Q64_Read(uint32_t ReadAddr, uint8_t *pBuffer, uint16_t NumByteToRead); void W25Q64_WritePage(uint32_t WriteAddr, uint8_t *pBuffer, uint16_t NumByteToWrite); void W25Q64_Write(uint32_t WriteAddr, uint8_t *pBuffer, uint16_t NumByteToWrite); void W25Q64_EraseSector(uint32_t SectorAddr); void W25Q64_EraseBlock(uint32_t BlockAddr); void W25Q64_EraseChip(void); #endif参数说明:
W25Q64_CS_GPIO_*等宏必须与你的PCB走线严格一致,CS引脚必须配置为推挽输出(默认高电平),其余三线配置为推挽输出(MOSI/SCK)和浮空输入(MISO);W25Q64_FLASH_SIZE等尺寸宏决定了地址合法性检查范围,若误填为W25Q32(4MB)会导致越界访问;- 指令宏名与W25Q64官方手册完全对应,避免自行缩写(如
0x02不能写成WRITE_CMD),方便查手册排错。
2.3 w25q64_spi.c:软件SPI时序生成器的血泪细节
这是整个项目最易翻车的模块。它不使用任何中断或DMA,全靠CPU暴力翻转IO+空循环延时。关键在于SCK边沿对齐和建立/保持时间保障。以下是W25Q64_SPI_WriteReadByte函数的核心实现(带逐行注释):
// 向W25Q64发送1字节,并同时接收1字节(全双工) // 输入:byte - 要发送的字节 // 返回:从MISO读取的字节 uint8_t W25Q64_SPI_WriteReadByte(uint8_t byte) { uint8_t i, temp = 0; // SCK初始为低电平(模式0:CPOL=0, CPHA=0) GPIO_ResetBits(W25Q64_SCK_GPIO_PORT, W25Q64_SCK_GPIO_PIN); for(i = 0; i < 8; i++) { // === 步骤1:设置MOSI(MSB先发) === if(byte & 0x80) { GPIO_SetBits(W25Q64_MOSI_GPIO_PORT, W25Q64_MOSI_GPIO_PIN); } else { GPIO_ResetBits(W25Q64_MOSI_GPIO_PORT, W25Q64_MOSI_GPIO_PIN); } // MOSI建立时间:tSU,IO ≥ 10ns(W25Q64手册P15),此处预留200ns余量 // 72MHz下,1个__nop()≈13.9ns → 15个__nop()≈208ns __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); // === 步骤2:SCK上升沿采样MOSI,同时MISO开始输出 === GPIO_SetBits(W25Q64_SCK_GPIO_PORT, W25Q64_SCK_GPIO_PIN); // SCK高电平宽度:tCH ≥ 50ns,此处留300ns(22个__nop) __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); // === 步骤3:读取MISO(SCK高电平时采样) === if(GPIO_ReadInputDataBit(W25Q64_MISO_GPIO_PORT, W25Q64_MISO_GPIO_PIN)) { temp |= (0x80 >> i); } // === 步骤4:SCK下降沿,为下一位准备 === GPIO_ResetBits(W25Q64_SCK_GPIO_PORT, W25Q64_SCK_GPIO_PIN); // SCK低电平宽度:tCL ≥ 50ns,同样留300ns __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); __nop(); byte <<= 1; // 准备下一位 } return temp; }逻辑说明:
- 该函数实现了SPI Mode 0(CPOL=0, CPHA=0):SCK空闲低电平,数据在SCK上升沿采样;
- 每位传输包含4个关键阶段:MOSI建立→SCK上升→MISO采样→SCK下降,每个阶段都插入精确
__nop()保证时序; temp |= (0x80 >> i)实现了高位在前(MSB First)的字节重组,符合W25Q64指令格式;- 为什么不用SysTick?因为SysTick中断可能打断SPI时序,导致SCK波形畸变;纯NOP延时虽占CPU,但绝对可控。
3. 初始化与状态机设计:如何让W25Q64从“砖头”变成可用存储
3.1 W25Q64_Init():四步握手建立信任
初始化不是简单拉高CS,而是一套完整的“设备唤醒+身份确认+写保护解除”流程:
uint8_t W25Q64_Init(void) { uint16_t retry = 0; uint8_t id[3]; // 步骤1:GPIO初始化(必须在调用此函数前完成时钟使能!) RCC->APB2ENR |= RCC_APB2ENR_IOPAEN; // 使能GPIOA时钟 // 配置CS为推挽输出,初始高电平(禁用器件) GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = W25Q64_CS_GPIO_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(W25Q64_CS_GPIO_PORT, &GPIO_InitStructure); GPIO_SetBits(W25Q64_CS_GPIO_PORT, W25Q64_CS_GPIO_PIN); // 配置SCK/MOSI为推挽输出,MISO为浮空输入 GPIO_InitStructure.GPIO_Pin = W25Q64_SCK_GPIO_PIN | W25Q64_MOSI_GPIO_PIN; GPIO_Init(W25Q64_SCK_GPIO_PORT, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = W25Q64_MISO_GPIO_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(W25Q64_MISO_GPIO_PORT, &GPIO_InitStructure); // 步骤2:发送Release Power-Down指令(0xAB),唤醒休眠芯片 W25Q64_CS_L(); // 拉低CS W25Q64_SPI_WriteReadByte(W25X_ReleasePowerDown); W25Q64_CS_H(); // 步骤3:等待芯片就绪(最长100us,但加点余量) for(retry = 0; retry < 1000; retry++) { if((W25Q64_ReadStatusRegister() & 0x01) == 0) break; // BUSY位清零 Delay_us(1); // 自定义微秒延时函数 } if(retry == 1000) return 1; // 超时失败 // 步骤4:读取JEDEC ID验证芯片真实性(0x9F指令) W25Q64_CS_L(); W25Q64_SPI_WriteReadByte(0x9F); id[0] = W25Q64_SPI_WriteReadByte(0xFF); // Manufacturer ID: 0xEF id[1] = W25Q64_SPI_WriteReadByte(0xFF); // Memory Type: 0x40 id[2] = W25Q64_SPI_WriteReadByte(0xFF); // Capacity: 0x17 (64Mbit) W25Q64_CS_H(); if(id[0] == 0xEF && id[1] == 0x40 && id[2] == 0x17) { return 0; // 初始化成功 } else { return 2; // ID不匹配,可能是假货或接线错误 } }参数说明:
W25Q64_CS_L()和W25Q64_CS_H()是宏定义,分别展开为GPIO_ResetBits(...)和GPIO_SetBits(...),确保CS操作原子性;Delay_us(1)是一个独立的微秒级延时函数(通常用SysTick或DWT实现),用于短等待,不可用for()空循环替代,否则会受编译器优化影响;- JEDEC ID校验是防呆关键:
0xEF4017是Winbond原厂W25Q64BV的ID,若读到0xEF4016(W25Q32)或全0xFF,说明芯片未响应或MISO断线。
3.2 状态寄存器轮询:写操作的“后悔药”机制
W25Q64所有写/擦除操作都是异步的,必须轮询状态寄存器(SR)的BUSY位(bit0)才能知道是否完成。W25Q64_WaitForWriteEnd()是安全屏障:
void W25Q64_WaitForWriteEnd(void) { uint8_t status; do { W25Q64_CS_L(); W25Q64_SPI_WriteReadByte(W25X_ReadStatusReg); status = W25Q64_SPI_WriteReadByte(0xFF); W25Q64_CS_H(); // 加入1ms延时避免高频读取(手册要求tSHSL≥100ns,但实际可放宽) Delay_ms(1); } while(status & 0x01); // BUSY位为1表示忙 }为什么不能省略?
若在擦除未完成时发起新操作,W25Q64会返回0x00作为无效状态,后续读写全部失效,且无法通过复位恢复——必须断电重启。这个函数就是你的“写操作保险丝”。
4. 写入与擦除的底层逻辑:页对齐、扇区擦除、写前校验的硬核实现
4.1 W25Q64_WritePage():256字节的原子写单元
W25Q64的写操作有严格限制:只能将1变为0,不能0变1;且每次写入必须在单一页内(256B),跨页需分多次调用。W25Q64_WritePage()封装了这一约束:
void W25Q64_WritePage(uint32_t WriteAddr, uint8_t *pBuffer, uint16_t NumByteToWrite) { uint16_t i; // 地址合法性检查(防止越界) if(WriteAddr >= W25Q64_FLASH_SIZE) return; // 强制页对齐:计算起始页内偏移 uint16_t PageOffset = WriteAddr % W25Q64_PAGE_SIZE; uint16_t BytesLeftInPage = W25Q64_PAGE_SIZE - PageOffset; // 若写入长度超过当前页剩余空间,则只写到页尾 if(NumByteToWrite > BytesLeftInPage) { NumByteToWrite = BytesLeftInPage; } // 步骤1:使能写入 W25Q64_WriteEnable(); // 步骤2:发送Page Program指令(0x02)+ 3字节地址 W25Q64_CS_L(); W25Q64_SPI_WriteReadByte(W25X_PageProgram); W25Q64_SPI_WriteReadByte((WriteAddr >> 16) & 0xFF); W25Q64_SPI_WriteReadByte((WriteAddr >> 8) & 0xFF); W25Q64_SPI_WriteReadByte(WriteAddr & 0xFF); // 步骤3:发送数据(最多256字节) for(i = 0; i < NumByteToWrite; i++) { W25Q64_SPI_WriteReadByte(pBuffer[i]); } W25Q64_CS_H(); // 步骤4:等待写入完成 W25Q64_WaitForWriteEnd(); }关键逻辑:
PageOffset = WriteAddr % 256计算出地址在页内的偏移,决定本次最多能写多少字节;W25Q64_WriteEnable()必须在每次写操作前调用,否则W25Q64会忽略0x02指令;- 地址发送顺序为MSB→LSB(A23~A0),对应
>>16、>>8、&0xFF三步位运算; - 写前无需擦除:但若目标地址已有数据(非0xFF),则只能将1→0,不能恢复0→1——所以实际应用中,写入前通常先擦除整个扇区。
4.2 W25Q64_Write():自动分页的用户友好接口
为简化应用层调用,W25Q64_Write()将长数据流自动切分为多个页写入:
void W25Q64_Write(uint32_t WriteAddr, uint8_t *pBuffer, uint16_t NumByteToWrite) { uint32_t Addr = WriteAddr; uint16_t Len = NumByteToWrite; uint16_t PageLen; while(Len > 0) { // 计算当前页还能写多少 PageLen = W25Q64_PAGE_SIZE - (Addr % W25Q64_PAGE_SIZE); if(Len < PageLen) PageLen = Len; // 执行单页写入 W25Q64_WritePage(Addr, pBuffer, PageLen); // 更新指针 Addr += PageLen; pBuffer += PageLen; Len -= PageLen; } }使用示例(main.c中):
uint8_t testData[1024] = {0}; for(int i=0; i<1024; i++) testData[i] = i % 256; // 写入地址0x000000处 W25Q64_Write(0x000000, testData, 1024); // 读回验证 uint8_t readBuf[1024]; W25Q64_Read(0x000000, readBuf, 1024); // 比较 if(memcmp(testData, readBuf, 1024) == 0) { // OK } else { // ERROR }注意:
W25Q64_Write()不处理擦除,调用前必须确保目标区域已擦除(全0xFF)。生产环境建议封装为W25Q64_WriteWithErase(),先擦除再写入。
5. 避坑指南:那些让你调试到凌晨三点的软件SPI经典翻车现场
5.1 现象:W25Q64_ReadStatusRegister()始终返回0x00
原因:MISO引脚配置错误。常见误配为GPIO_Mode_Out_PP(推挽输出),导致MCU强行拉低MISO,芯片无法驱动总线。
解决:严格按w25q64.h中定义,MISO必须配置为GPIO_Mode_IN_FLOATING(浮空输入),且PCB上无下拉电阻。
5.2 现象:写入后读取数据全为0x00,或部分字节错乱
原因:页写入时未做地址对齐检查,跨页写入触发W25Q64内部地址回绕(如向0x0000FF写2字节,第二字节实际写入0x000000)。
解决:在W25Q64_WritePage()中强制截断NumByteToWrite,确保不跨页;或改用W25Q64_Write()自动分页。
5.3 现象:调用W25Q64_EraseSector()后,W25Q64_ReadStatusRegister()返回0xFF
原因:擦除指令(0x20)后未调用W25Q64_WaitForWriteEnd(),立即读取状态寄存器,此时BUSY位仍为1,但W25Q64_ReadStatusRegister()函数内部又执行了一次W25Q64_CS_L(),导致时序紊乱。
解决:所有擦除/写入操作后,必须显式调用W25Q64_WaitForWriteEnd(),禁止依赖函数内部隐式等待。
5.4 现象:在Keil中开启-O2优化后,软件SPI时序完全错乱
原因:编译器将__nop()优化掉,或重排循环代码,破坏纳秒级延时精度。
解决:在w25q64_spi.c顶部添加编译指示:
#pragma push #pragma O0 // 对本文件禁用优化 #include "w25q64.h" // ... 其余代码 #pragma pop并在Keil中为该文件单独设置优化等级为-O0。
5.5 现象:同一份代码在STM32F103C8T6上正常,在F103CBT6上读ID失败
原因:不同封装的GPIO端口映射不同。C8T6的PA6/PA7在CBT6上可能被复用为JTAG功能(PA6=JTMS, PA7=JTCK),导致MISO/MOSI失效。
解决:在main()开头添加JTAG禁用代码:
// 禁用JTAG,释放PA13/PA14/PA15(若用到),此处针对PA6/PA7需禁用SWD AFIO->MAPR &= ~AFIO_MAPR_SWJ_CFG; // 清除SWJ配置位 AFIO->MAPR |= 0x02; // 设置为"JTAG-DP Disabled and SW-DP Enabled" 或 0x04 "JTAG-DP Disabled and SW-DP Disabled"6. 二次开发实战:从“能用”到“可靠”,三个必改项与一个验证技巧
6.1 必改项1:将固定GPIO映射改为运行时可配
当前w25q64.h中W25Q64_CS_GPIO_PORT等宏是编译期常量,导致换板子就得改头文件。更工程化的做法是定义一个初始化结构体,在W25Q64_Init()中传入:
typedef struct { GPIO_TypeDef* cs_port; uint16_t cs_pin; GPIO_TypeDef* sck_port; uint16_t sck_pin; GPIO_TypeDef* mosi_port; uint16_t mosi_pin; GPIO_TypeDef* miso_port; uint16_t miso_pin; } W25Q64_GPIO_Config_t; uint8_t W25Q64_InitEx(const W25Q64_GPIO_Config_t* config);这样,同一份.c文件可复用于不同硬件平台,只需在main.c中构造不同的config实例。我一般会在main()开头定义:
W25Q64_GPIO_Config_t w25q64_cfg = { .cs_port = GPIOB, .cs_pin = GPIO_Pin_0, .sck_port = GPIOB, .sck_pin = GPIO_Pin_1, .mosi_port= GPIOB, .mosi_pin = GPIO_Pin_2, .miso_port= GPIOB, .miso_pin = GPIO_Pin_3 }; W25Q64_InitEx(&w25q64_cfg);6.2 必改项2:增加写保护(WP)和持有保护(HOLD)引脚支持
W25Q64的/WP(Write Protect)和/HOLD引脚常被忽略,但它们是工业场景的救命稻草:
/WP接地时,状态寄存器写保护位(SRWD+BP0/BP1)生效,防止意外擦除;/HOLD拉低可暂停SPI传输,用于多主竞争场景。
在w25q64.h中新增:
#define W25Q64_WP_GPIO_PORT GPIOB #define W25Q64_WP_GPIO_PIN GPIO_Pin_4 #define W25Q64_HOLD_GPIO_PORT GPIOB #define W25Q64_HOLD_GPIO_PIN GPIO_Pin_5并在W25Q64_Init()中初始化为推挽输出,默认高电平(释放保护)。这能让设备在强干扰环境下不死机。
6.3 必改项3:用CRC32校验替代简单memcmp验证
main.c中的memcmp(testData, readBuf, 1024)只能发现全0或全1错误,对随机位翻转无能为力。应升级为CRC32校验:
// 在w25q64.c中添加 #include <stdint.h> uint32_t crc32(const uint8_t *data, size_t len) { const uint32_t crc32_table[256] = { /* 标准IEEE 802.3表,此处省略 */ }; uint32_t crc = 0xFFFFFFFF; for(size_t i = 0; i < len; i++) { crc = (crc >> 8) ^ crc32_table[(crc ^ data[i]) & 0xFF]; } return crc ^ 0xFFFFFFFF; } // 使用方式 uint32_t expected_crc = crc32(testData, 1024); W25Q64_Write(0x000000, testData, 1024); W25Q64_Read(0x000000, readBuf, 1024); uint32_t actual_crc = crc32(readBuf, 1024); if(expected_crc == actual_crc) { /* OK */ }6.4 一个硬核验证技巧:用逻辑分析仪抓SPI波形反向验证
当你遇到“代码逻辑没错但就是不工作”的黑匣子问题,别猜,直接抓波形。我的血泪经验是:
- 抓3组信号:CS、SCK、MOSI(必抓),MISO(选抓);
- 触发条件设为CS下降沿,确保捕获完整事务;
- 重点看3个点:① CS拉低后,第一个SCK上升沿前是否有足够建立时间(≥100ns);② SCK周期是否稳定(72MHz MCU下1MHz SCK周期应为1us±5%);③ MOSI数据是否与发送字节一致(用十六进制解码)。
曾有一次,我发现W25Q64_WriteEnable()后,W25X_PageProgram指令的第三个地址字节总是错——最终定位到W25Q64_SPI_WriteReadByte()中byte <<= 1放在了循环末尾,导致最后一次左移多了一次。逻辑分析仪波形上,第三个字节的MSB永远是0,而其他字节正常。这种问题,单靠printf调试根本不可能发现。
从那以后我每次移植软件SPI驱动,都强制走一遍逻辑分析仪验证流程,哪怕只是抓10个字节。希望帮到你。
本文还有配套的精品资源,点击获取