江科大STM32入门:软件SPI读写W25Q64 Flash存储器详解(附完整代码+逐行解析)


本文基于江科大STM32入门教程,手把手带你用软件模拟SPI的方式驱动W25Q64 Flash芯片,从GPIO配置到读写时序,从页编程到扇区擦除,代码逐行解析,看完就能跑通!
一、前言
在STM32的学习过程中,存储器扩展是一个绕不开的课题。板载的Flash容量有限,当我们需要存储大量数据(比如字库、配置参数、日志记录)时,外接一颗SPI Flash就成了刚需。
W25Q64是华邦(Winbond)推出的一款64Mbit(8MB)串行Flash存储器,采用标准SPI接口通信,价格低廉、容量适中,在嵌入式领域应用非常广泛。
江科大的课程里,SPI部分分了两条线讲:
-
硬件SPI:用STM32自带的SPI外设
-
软件SPI:用GPIO手动模拟SPI时序
本文重点讲软件SPI这条线——虽然效率不如硬件SPI,但它最大的好处是引脚任意、时序可见、理解透彻。把软件SPI搞懂了,再看硬件SPI就是降维打击。
二、前置知识速览
2.1 W25Q64 关键参数
| 参数 | 数值 | 说明 |
|---|---|---|
| 容量 | 64Mbit = 8MB | 地址范围 0x000000 ~ 0x7FFFFF |
| 扇区(Sector) | 2048个 | 每个扇区 4KB |
| 块(Block) | 128个 | 每个块 64KB(16个扇区) |
| 页(Page) | 32768个 | 每页 256字节(16页 = 1扇区) |
| 擦除单位 | 扇区/块/整片 | 最小擦除单位是扇区(4KB) |
| 写入单位 | 页 | 一次最多写256字节,不能跨页 |
⚠️ 重要特性:Flash写入前必须先擦除!擦除后所有位变为1,写入时只能把1改成0。
2.2 SPI模式0 时序图
W25Q64支持SPI模式0(CPOL=0, CPHA=0),这也是我们软件模拟要遵循的时序:
-
空闲状态:SCK = 0,SS = 1
-
数据变化:SCK上升沿前,MOSI准备好数据
-
数据采样:SCK上升沿,从机读MOSI;同时MISO有效
-
SCK下降沿:主机读MISO,完成一位交换
┌───┐ ┌───┐ ┌───┐ ┌───┐
SCK ───┘ └───┘ └───┘ └───┘ └─── (CPOL=0)
↑ ↑ ↑ ↑
采样 采样 采样 采样
MOSI ───╪───────╪───────╪───────╪───────
D7 D6 D5 D4...
MISO ───╪───────╪───────╪───────╪───────
D7 D6 D5 D4...
2.3 硬件接线
| W25Q64引脚 | STM32引脚 | 功能 |
|---|---|---|
| CS(片选) | PA4 | SS,软件控制 |
| DO(数据输出) | PA6 | MISO,主机输入 |
| CLK(时钟) | PA5 | SCK,软件控制 |
| DI(数据输入) | PA7 | MOSI,主机输出 |
| VCC | 3.3V | 供电 |
| GND | GND | 地 |

W25Q64与STM32F103C8T6的SPI接线示意图
三、软件SPI底层实现(MySPI.c)
软件SPI的核心思想很简单:用GPIO口手动翻转电平,完全模拟SPI时序。这样做的好处是任何GPIO口都能当SPI用,而且时序完全由代码控制,调试起来一目了然。
3.1 引脚配置层
#include "stm32f10x.h" // 包含STM32F10x标准库头文件,提供GPIO、RCC等外设操作函数
/*============================ 引脚配置层 ============================*/
/**
* 函 数:SPI写SS引脚电平
* 参 数:BitValue 协议层传入的当前需要写入SS的电平,范围0~1
* 注意事项:此函数需要用户实现内容,当BitValue为0时,需要置SS为低电平,当BitValue为1时,需要置SS为高电平
*/
void MySPI_W_SS(uint8_t BitValue)
{
GPIO_WriteBit(GPIOA, GPIO_Pin_4, (BitAction)BitValue); // 调用库函数,根据BitValue设置PA4(SS)引脚电平
// BitAction是枚举类型,0=Bit_RESET(低电平),1=Bit_SET(高电平)
}
/**
* 函 数:SPI写SCK引脚电平
* 参 数:BitValue 协议层传入的当前需要写入SCK的电平,范围0~1
* 注意事项:此函数需要用户实现内容,当BitValue为0时,需要置SCK为低电平,当BitValue为1时,需要置SCK为高电平
*/
void MySPI_W_SCK(uint8_t BitValue)
{
GPIO_WriteBit(GPIOA, GPIO_Pin_5, (BitAction)BitValue); // 调用库函数,根据BitValue设置PA5(SCK)引脚电平
}
/**
* 函 数:SPI写MOSI引脚电平
* 参 数:BitValue 协议层传入的当前需要写入MOSI的电平,范围0~1
* 返 回 值:无
* 注意事项:此函数需要用户实现内容,当BitValue为0时,需要置MOSI为低电平,当BitValue为1时,需要置MOSI为高电平
*/
void MySPI_W_MOSI(uint8_t BitValue)
{
GPIO_WriteBit(GPIOA, GPIO_Pin_7, (BitAction)BitValue); // 调用库函数,根据BitValue设置PA7(MOSI)引脚电平
// BitValue要实现非0即1的特性,由上层调用保证
}
/**
* 函 数:SPI读MISO引脚电平
* 返 回 值:协议层需要得到的当前MISO的电平,范围0~1
* 注意事项:此函数需要用户实现内容,当前MISO为低电平时,返回0,当前MISO为高电平时,返回1
*/
uint8_t MySPI_R_MISO(void)
{
return GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_6); // 调用库函数,读取PA6(MISO)引脚电平并返回
// 返回值为0或1,直接对应引脚的高低电平
}
/**
* 函 数:SPI初始化
* 注意事项:此函数需要用户实现内容,实现SS、SCK、MOSI和MISO引脚的初始化
*/
void MySPI_Init(void)
{
/*开启时钟*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // 开启GPIOA的时钟,所有GPIO操作前必须先开时钟,否则配置无效
/*GPIO初始化*/
GPIO_InitTypeDef GPIO_InitStructure; // 定义GPIO初始化结构体变量
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; // 设置模式为推挽输出:SS、SCK、MOSI都是主机输出,用推挽输出驱动能力强
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4 | GPIO_Pin_5 | GPIO_Pin_7; // 选中PA4(SS)、PA5(SCK)、PA7(MOSI)三个引脚
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; // 设置输出速度为50MHz,SPI通信速度较快,引脚翻转要跟上
GPIO_Init(GPIOA, &GPIO_InitStructure); // 调用初始化函数,将以上配置写入寄存器,完成PA4/5/7的初始化
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; // 设置模式为上拉输入:MISO是主机输入,用上拉输入防止浮空
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6; // 选中PA6(MISO)引脚
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; // 输入模式下速度参数无实际意义,但结构体要求赋值
GPIO_Init(GPIOA, &GPIO_InitStructure); // 调用初始化函数,将PA6初始化为上拉输入
/*设置默认电平*/
MySPI_W_SS(1); // SS默认高电平:SPI未通信时片选要释放,高电平表示不选中从机
MySPI_W_SCK(0); // SCK默认低电平:模式0要求空闲时钟为低电平
}
3.2 协议层:SPI起始、终止和字节交换
/*============================ 协议层 ============================*/
/**
* 函 数:SPI起始
* 说 明:拉低SS片选信号,开始一次SPI通信
*/
void MySPI_Start(void)
{
MySPI_W_SS(0); // 拉低SS(PA4),选中W25Q64从机,SPI通信正式开始
}
/**
* 函 数:SPI终止
* 说 明:拉高SS片选信号,结束一次SPI通信
*/
void MySPI_Stop(void)
{
MySPI_W_SS(1); // 拉高SS(PA4),释放W25Q64从机,本次SPI通信结束
}
/**
* 函 数:SPI交换传输一个字节,使用SPI模式0
* 参 数:ByteSend 要发送的一个字节
* 返 回 值:接收的一个字节
* 原 理:SPI是全双工通信,发送和接收同时进行。主机发一位的同时,从机也回一位。
* 循环8次,完成一个字节的发送和接收。
*/
uint8_t MySPI_SwapByte(uint8_t ByteSend)
{
uint8_t i, ByteReceive = 0x00; // 定义循环变量i和接收变量ByteReceive
// ByteReceive必须赋初值0x00!因为后面用"或"操作置位,初值必须是0
for (i = 0; i < 8; i ++) // 循环8次,依次交换每一位数据,从最高位(MSB)开始
{
/*--- 步骤1:主机把要发送的数据位放到MOSI线上 ---*/
/* 两个!可以对数据进行两次逻辑取反,作用是把非0值统一转换为1 */
/* !!(0) = 0,!!(非0) = 1,这样无论BitValue原本是什么非零值,都变成1 */
MySPI_W_MOSI(!!(ByteSend & (0x80 >> i))); // 使用掩码方式取出ByteSend的第(7-i)位数据
// 0x80 >> i:第1次i=0,掩码是0x80(1000 0000),取最高位D7
// 第2次i=1,掩码是0x40(0100 0000),取D6...以此类推
// &运算后非0即1,再用!!规范化,写入MOSI
/*--- 步骤2:SCK上升沿,从机采样MOSI数据,同时MISO数据有效 ---*/
MySPI_W_SCK(1); // 拉高SCK(PA5),产生上升沿
// 上升沿时刻:从机读取MOSI上的数据位
// 同时从机把要发送的数据位放到MISO线上
/*--- 步骤3:主机读取MISO线上的数据位 ---*/
if (MySPI_R_MISO()) // 读取MISO(PA6)电平,如果为高电平(返回1)
{
ByteReceive |= (0x80 >> i); // 将ByteReceive的对应位置1
// 当MISO为1时,置变量指定位为1
} // 当MISO为0时,不做处理,指定位为默认的初值0
// 这里体现了ByteReceive初值必须为0x00的原因!
/*--- 步骤4:SCK下降沿,完成一位交换,准备下一位 ---*/
MySPI_W_SCK(0); // 拉低SCK(PA5),产生下降沿
// 下降沿时刻:数据移入完成,准备下一位数据
}
return ByteReceive; // 循环8次后,返回接收到的完整字节数据
}
💡 我的理解:
MySPI_SwapByte这个函数是软件SPI的灵魂。它的巧妙之处在于发送和接收是同时进行的——你每发一位,必然也收到一位。0x80 >> i这个掩码操作让代码非常优雅,从最高位到最低位依次处理。!!双取反是个小技巧,确保传入GPIO_WriteBit的只有0或1,避免其他非零值导致意外。
四、W25Q64驱动层实现(W25Q64.c)
底层SPI搞定了,接下来就是在SPI基础上封装W25Q64的专用指令。W25Q64的操作遵循固定的"指令+地址+数据"格式。
4.1 指令头文件(W25Q64_Ins.h)
#ifndef __W25Q64_INS_H
#define __W25Q64_INS_H
#define W25Q64_WRITE_ENABLE 0x06 // 写使能指令:执行写操作前必须先发送
#define W25Q64_WRITE_DISABLE 0x04 // 写禁止指令
#define W25Q64_READ_STATUS_REGISTER_1 0x05 // 读状态寄存器1:用于查询忙标志位
#define W25Q64_READ_STATUS_REGISTER_2 0x35 // 读状态寄存器2
#define W25Q64_READ_DATA 0x03 // 读取数据指令:按地址读取任意长度数据
#define W25Q64_PAGE_PROGRAM 0x02 // 页编程指令:向指定页写入最多256字节
#define W25Q64_SECTOR_ERASE_4KB 0x20 // 扇区擦除指令(4KB):最小擦除单位
#define W25Q64_BLOCK_ERASE_32KB 0x52 // 块擦除指令(32KB)
#define W25Q64_BLOCK_ERASE_64KB 0xD8 // 块擦除指令(64KB)
#define W25Q64_CHIP_ERASE 0xC7 // 整片擦除指令
#define W25Q64_JEDEC_ID 0x9F // 读取JEDEC ID指令:获取厂商ID和设备ID
#define W25Q64_DUMMY_BYTE 0xFF // 空字节:用于只读操作时的占位发送
#endif
4.2 W25Q64初始化与读取ID
#include "stm32f10x.h" // 包含STM32标准库头文件
#include "MySPI.h" // 包含软件SPI底层驱动头文件
#include "W25Q64_Ins.h" // 包含W25Q64指令定义头文件
/**
* 函 数:W25Q64初始化
* 说 明:初始化W25Q64前,必须先初始化底层的SPI通信协议
*/
void W25Q64_Init(void)
{
MySPI_Init(); // 先调用MySPI_Init()初始化底层的SPI引脚和协议层
// W25Q64本身不需要额外初始化,SPI通了就等于W25Q64就绪
}
/**
* 函 数:W25Q64读取ID号
* 参 数:MID 工厂ID(Manufacturer ID),使用输出参数的形式返回,W25Q64的MID固定为0xEF(Winbond)
* 参 数:DID 设备ID(Device ID),使用输出参数的形式返回,W25Q64的DID为0x4017
* 说 明:上电后发送0x9F指令,W25Q64会返回3字节:Manufacturer ID + Memory Type + Capacity
*/
void W25Q64_ReadID(uint8_t *MID, uint16_t *DID)
{
MySPI_Start(); // SPI起始:拉低SS,选中W25Q64
MySPI_SwapByte(W25Q64_JEDEC_ID); // 交换发送读取ID的指令0x9F
// 同时接收到第一个返回字节(Manufacturer ID),但这里忽略
*MID = MySPI_SwapByte(W25Q64_DUMMY_BYTE); // 发送空字节0xFF(占位),交换接收Manufacturer ID
// 通过输出参数*MID返回给调用者
*DID = MySPI_SwapByte(W25Q64_DUMMY_BYTE); // 发送空字节,交换接收Device ID的高8位(Memory Type)
*DID <<= 8; // 将高8位左移8位,放到16位变量的高字节位置
*DID |= MySPI_SwapByte(W25Q64_DUMMY_BYTE); // 发送空字节,交换接收Device ID的低8位(Capacity)
// 用"或"运算拼接到低8位,完整的16位DID通过输出参数返回
MySPI_Stop(); // SPI终止:拉高SS,释放W25Q64
}
💡 我的理解:读取ID是验证硬件连接是否正常的第一步。如果ID读出来不对(比如全是0xFF或0x00),说明接线有问题或者芯片没工作。
W25Q64_DUMMY_BYTE(0xFF)的使用很妙——SPI通信中你想读数据,就必须发点什么来"换"数据回来,发0xFF就是告诉从机"我没数据给你,你把你的数据给我"。
4.3 写使能与等待忙
/**
* 函 数:W25Q64写使能
* 说 明:W25Q64的所有写操作(页编程、擦除)之前,都必须先发送写使能指令
* 写使能后,W25Q64内部的写使能锁存器(WEL)置1,允许后续写操作
* 每次写操作完成后,WEL会自动清零
*/
void W25Q64_WriteEnable(void)
{
MySPI_Start(); // SPI起始
MySPI_SwapByte(W25Q64_WRITE_ENABLE); // 交换发送写使能指令0x06
// 该指令无返回数据,也不需要额外参数
MySPI_Stop(); // SPI终止
}
/**
* 函 数:W25Q64等待忙
* 说 明:W25Q64执行页编程或擦除操作后,需要一定时间完成内部操作
* 期间状态寄存器1的BUSY位(bit0)为1,表示芯片正忙
* 必须等待BUSY位清零后,才能进行下一次写操作
*/
void W25Q64_WaitBusy(void)
{
uint32_t Timeout; // 定义超时计数变量,防止死等
MySPI_Start(); // SPI起始
MySPI_SwapByte(W25Q64_READ_STATUS_REGISTER_1); // 发送读状态寄存器1指令0x05
Timeout = 100000; // 给定超时计数时间,约100000次循环
// 实际超时时间取决于主频,这里作为兜底保护
while ((MySPI_SwapByte(W25Q64_DUMMY_BYTE) & 0x01) == 0x01) // 循环读取状态寄存器1
// & 0x01:取出BUSY位(bit0)
// == 0x01:如果BUSY位为1,说明芯片还在忙
{
Timeout --; // 等待时,计数值自减
if (Timeout == 0) // 自减到0后,等待超时
{
break; // 强制退出循环,避免死等导致程序卡死
}
}
MySPI_Stop(); // SPI终止
}
💡 我的理解:
W25Q64_WaitBusy里的超时机制很重要。虽然正常情况W25Q64擦除最多几百毫秒,但万一芯片坏了或者接线松了,没有超时就会死循环。& 0x01就是取状态寄存器的最低位,这是查询忙标志的标准做法。另外注意,读状态寄存器的过程中SS要一直拉低,不能中途断开。
4.4 页编程(写入数据)
/**
* 函 数:W25Q64页编程
* 参 数:Address 页编程的起始地址,范围:0x000000~0x7FFFFF(23位地址)
* 参 数:DataArray 用于写入数据的数组指针
* 参 数:Count 要写入数据的数量,范围:0~256(一页最多256字节)
* 注意事项:写入的地址范围不能跨页!如果Address=0x0000FF,Count=2,则第二个字节会写到0x000100
* 跨页写入会导致数据回卷到页首覆盖,这是W25Q64的硬件特性
*/
void W25Q64_PageProgram(uint32_t Address, uint8_t *DataArray, uint16_t Count)
{
uint16_t i; // 定义循环变量
W25Q64_WriteEnable(); // 第一步:写使能!每次写操作前必须调用
MySPI_Start(); // SPI起始
MySPI_SwapByte(W25Q64_PAGE_PROGRAM); // 发送页编程指令0x02
MySPI_SwapByte(Address >> 16); // 交换发送地址23~16位(最高字节)
// Address是32位,右移16位后保留高8位
MySPI_SwapByte(Address >> 8); // 交换发送地址15~8位(中间字节)
// 右移8位后,低8位被移出,保留原来的15~8位
MySPI_SwapByte(Address); // 交换发送地址7~0位(最低字节)
// 直接发送低8位,不需要移位
for (i = 0; i < Count; i ++) // 循环Count次,依次发送数据
{
MySPI_SwapByte(DataArray[i]); // 依次在起始地址后写入数据
// 每发一个字节,W25Q64内部地址自动+1
}
MySPI_Stop(); // SPI终止
W25Q64_WaitBusy(); // 等待W25Q64内部完成编程操作
// 页编程典型时间约0.4ms~3ms,必须等忙标志清零
}
💡 我的理解:页编程的地址发送要特别注意——W25Q64是24位地址,但STM32的
uint32_t是32位,所以分三次右移发送高、中、低字节。这里有个经典坑:地址不能跨页。比如一页从0x000000到0x0000FF,如果你从0x0000FE开始写10个字节,后8个字节会回卷到0x000000覆盖!所以实际工程中,大数据量写入需要自己实现"跨页拆分"逻辑。
4.5 扇区擦除
/**
* 函 数:W25Q64扇区擦除(4KB)
* 参 数:Address 指定扇区的地址,范围:0x000000~0x7FFFFF
* 地址不需要对齐到扇区首地址,W25Q64会自动找到所在扇区并擦除整个4KB
* 返 回 值:无
* 注意事项:擦除后扇区内所有数据变为0xFF!擦除前务必确认数据已备份
*/
void W25Q64_SectorErase(uint32_t Address)
{
W25Q64_WriteEnable(); // 第一步:写使能!擦除也是写操作,必须先使能
MySPI_Start(); // SPI起始
MySPI_SwapByte(W25Q64_SECTOR_ERASE_4KB); // 发送扇区擦除指令0x20
MySPI_SwapByte(Address >> 16); // 交换发送地址23~16位
MySPI_SwapByte(Address >> 8); // 交换发送地址15~8位
MySPI_SwapByte(Address); // 交换发送地址7~0位
MySPI_Stop(); // SPI终止
W25Q64_WaitBusy(); // 等待擦除完成
// 扇区擦除典型时间约45ms~400ms,比页编程慢得多
}
💡 我的理解:擦除是Flash的痛点——最小擦除单位是4KB扇区,哪怕你只改1个字节,也要擦除整个扇区。所以Flash不适合频繁修改小数据。另外,擦除前一定要写使能,很多同学忘了这一步,结果擦除指令被W25Q64直接忽略,读出来数据没变,还以为是代码问题。
4.6 读取数据
/**
* 函 数:W25Q64读取数据
* 参 数:Address 读取数据的起始地址,范围:0x000000~0x7FFFFF
* 参 数:DataArray 用于接收读取数据的数组,通过输出参数返回
* 参 数:Count 要读取数据的数量,范围:0~0x800000(理论上可以读整个芯片)
* 说 明:读取操作不需要写使能,也不需要等待忙,随时可读
*/
void W25Q64_ReadData(uint32_t Address, uint8_t *DataArray, uint32_t Count)
{
uint32_t i; // 定义循环变量,注意Count是uint32_t,i也要用uint32_t
MySPI_Start(); // SPI起始
MySPI_SwapByte(W25Q64_READ_DATA); // 发送读取数据指令0x03
MySPI_SwapByte(Address >> 16); // 交换发送地址23~16位
MySPI_SwapByte(Address >> 8); // 交换发送地址15~8位
MySPI_SwapByte(Address); // 交换发送地址7~0位
for (i = 0; i < Count; i ++) // 循环Count次,依次读取数据
{
DataArray[i] = MySPI_SwapByte(W25Q64_DUMMY_BYTE); // 发送空字节0xFF,交换接收数据
// W25Q64收到读指令和地址后,自动从该地址开始输出数据
// 内部地址每读一次自动+1,可以连续读取任意长度
}
MySPI_Stop(); // SPI终止
}
💡 我的理解:读取是W25Q64最"友好"的操作——不需要写使能、不需要等待、可以跨页连续读。发送0x03和地址后,W25Q64就像一个流水一样不断吐数据,你发一个0xFF,它吐一个字节,内部地址自动加。这个特性让读取操作非常简单高效。
五、个人理解
学这一节的时候,我最大的感受就是:软件SPI虽然代码量大,但真的把SPI协议吃透了。
之前学硬件SPI,配置一下寄存器就完事了,SPI怎么工作的完全黑盒。但软件SPI不一样,每一根线的翻转你都看得见。
MySPI_SwapByte里那个0x80 >> i的位操作,我盯着看了好久才理解——原来是从最高位一位一位掰下来放到MOSI上。这种"亲手把数据位送出去"的感觉,比看任何时序图都直观。另外,W25Q64的"写前必擦"特性也让我印象深刻。以前用EEPROM(比如AT24C02),直接覆盖写就行,但Flash不行。这让我意识到不同存储器的底层物理机制差异:EEPROM是浮栅管局部擦除,Flash是整页/整扇区擦除。所以做项目选型时,如果数据需要频繁修改,EEPROM可能更合适;如果是大数据量存储,W25Q64的性价比完胜。
六、踩坑记录(用户自己写)
| 坑位 | 现象 | 原因 | 解决方案 |
|---|---|---|---|
| 坑1:忘了写使能 | 页编程或擦除后读数据,发现数据没变 | W25Q64默认写保护,所有写操作前必须发0x06写使能 | 每次写操作前调用W25Q64_WriteEnable() |
| 坑2:跨页写入覆盖 | 写入256字节,发现前面的数据被后面的覆盖了 | W25Q64页编程地址到页尾后会回卷到页首 | 软件层面做跨页拆分,确保每次写入不跨页边界 |
| 坑3:擦除后数据不是0xFF | 擦除后读出来还是旧数据 | 可能没等擦除完成就读取,或者根本没发擦除指令 | 擦除后必须调用W25Q64_WaitBusy()等待完成 |
| 坑4:SPI模式不对 | 数据全为0xFF或0x00 | 软件SPI时序与W25Q64不匹配 | 确认CPOL=0, CPHA=0,SCK空闲低电平,上升沿采样 |
| 坑5:SS引脚没拉高 | 偶尔通信正常,偶尔数据错乱 | SS片选信号不稳定,通信中途被意外拉低 | 确保每次MySPI_Start()和MySPI_Stop()成对出现 |
| 坑6:MISO配置上拉输入 | 读取数据始终为0 | MISO配置为浮空输入,电平不稳定 | 必须配置为上拉输入或下拉输入,不能浮空 |
我踩过最傻的一个坑是:MISO引脚配成了推挽输出。当时复制粘贴GPIO初始化代码,忘了把PA6的模式改成输入,结果MISO一直输出高电平,读出来的数据全是0xFF。调了一下午,最后用万用表量PA6电平才发现问题。所以GPIO模式一定要仔细核对:主机输出(SS/SCK/MOSI)是推挽输出,主机输入(MISO)是上拉输入!
还有一个坑是地址发送顺序。W25Q64要求先发高字节,但我的直觉是"先发低地址",结果数据写到了完全不对的位置。后来画了个图才明白:
Address >> 16是把23~16位移到最低8位,所以先发的是最高字节,这符合SPI"高位在前"的惯例。
七、总结表格
| 功能 | 指令码 | 是否需要写使能 | 是否需要等待忙 | 最大数据量 | 注意事项 |
|---|---|---|---|---|---|
| 读JEDEC ID | 0x9F | ❌ | ❌ | 3字节 | 上电即可读,用于验证芯片型号 |
| 写使能 | 0x06 | ❌ | ❌ | 无 | 每次写操作前必须调用 |
| 读状态寄存器1 | 0x05 | ❌ | ❌ | 1字节 | 查询BUSY位(bit0)判断芯片状态 |
| 页编程 | 0x02 | ✅ | ✅ | 256字节 | 不能跨页,写入前确保已擦除 |
| 扇区擦除(4KB) | 0x20 | ✅ | ✅ | 无 | 最小擦除单位,擦除后全为0xFF |
| 块擦除(64KB) | 0xD8 | ✅ | ✅ | 无 | 擦除速度比扇区擦除快(单位容量) |
| 读取数据 | 0x03 | ❌ | ❌ | 无限制 | 可跨页连续读取,地址自动递增 |
八、main.c 测试代码参考
#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "OLED.h"
#include "W25Q64.h"
uint8_t MID; //定义用于存放MID号的变量
uint16_t DID; //定义用于存放DID号的变量
uint8_t ArrayWrite[] = {0x01, 0x02, 0x02, 0x08}; //定义要写入数据的测试数组
uint8_t ArrayRead[4]; //定义要读取数据的测试数组
int main(void)
{
/*模块初始化*/
OLED_Init(); //OLED初始化
W25Q64_Init(); //W25Q64初始化
/*显示静态字符串*/
OLED_ShowString(1, 1, "MID: DID:");
OLED_ShowString(2, 1, "W:");
OLED_ShowString(3, 1, "R:");
/*显示ID号*/
W25Q64_ReadID(&MID, &DID); //获取W25Q64的ID号
OLED_ShowHexNum(1, 5, MID, 2); //显示MID
OLED_ShowHexNum(1, 12, DID, 4); //显示DID
/*W25Q64功能函数测试*/
W25Q64_SectorErase(0x000000); //扇区擦除
W25Q64_PageProgram(0x000000, ArrayWrite, 4); //将写入数据的测试数组写入到W25Q64中
W25Q64_ReadData(0x000000, ArrayRead, 4); //读取刚写入的测试数据到读取数据的测试数组中
/*显示数据*/
OLED_ShowHexNum(2, 3, ArrayWrite[0], 2); //显示写入数据的测试数组
OLED_ShowHexNum(2, 6, ArrayWrite[1], 2);
OLED_ShowHexNum(2, 9, ArrayWrite[2], 2);
OLED_ShowHexNum(2, 12, ArrayWrite[3], 2);
OLED_ShowHexNum(3, 3, ArrayRead[0], 2); //显示读取数据的测试数组
OLED_ShowHexNum(3, 6, ArrayRead[1], 2);
OLED_ShowHexNum(3, 9, ArrayRead[2], 2);
OLED_ShowHexNum(3, 12, ArrayRead[3], 2);
while (1)
{
}
}

现象图
更多代码参考:STM32_Project: 基于江科大的STM32的教程编写的程序
📌 开发环境:Keil5 + STM32F103C8T6最小系统板 + ST-Link
📌 参考资料:江科大STM32入门教程、《STM32F10xxx参考手册》、W25Q64 Datasheet
📌 前置文章:江科大 STM32 入门:W25Q64 详解——从硬件到 Flash 操作原理
写这篇文章的时候,我把江科大的视频又回顾了一遍,发现以前囫囵吞枣跳过的细节,现在都能看懂了。软件SPI这节虽然代码量不小,但每一行都有它的道理——从GPIO模式的选择,到!!双取反的小技巧,再到地址分三次发送的顺序,都是前人踩坑踩出来的最佳实践。如果你也在学STM32,强烈建议亲手敲一遍代码,不要直接复制粘贴,只有自己写过,这些知识才真正属于你。
如果本文对你有帮助,欢迎点赞 👍 收藏 ⭐ !有任何问题欢迎在评论区交流,一起学习进步!

更多推荐


所有评论(0)