🚀 我的STM32学习专栏:[点击直达 | 持续更新中]

本文基于江科大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,强烈建议亲手敲一遍代码,不要直接复制粘贴,只有自己写过,这些知识才真正属于你。

如果本文对你有帮助,欢迎点赞 👍 收藏 ⭐ !有任何问题欢迎在评论区交流,一起学习进步!

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐