目录
[一 概述](#一 概述)
[二 W25Q64的介绍](#二 W25Q64的介绍)
[常用 SPI 指令](#常用 SPI 指令)
[三 代码部分](#三 代码部分)
一 概述
我已经在前面一章《stm32-掌握SPI原理(一)》,已经讲解完了spi的一些底层架构的内容,本篇文章,我们会通过W25Q64这个芯片去给大家展示如何利用SPI原理去往W25Q64这个芯片里烧写内容,并且通过串口打印出来相应的内容。本篇文章他会更加强调于应用,其实会用底层函数就行,大家不需要独立写出相应的SPI.c文件里的代码。
二 W25Q64的介绍
简介
W25Q64 是 Winbond 出品的一款 串行 SPI Flash 存储器 ,全名为 W25Q64JV ,其中"64"表示其容量为 64 Mbit = 8MB 。它采用 SPI 通信协议 ,适用于嵌入式系统中需要大容量非易失性存储的场景,Flash 是非易失性存储器 **断电后数据不会丢失,可以多次擦写,但寿命是有限的。**如:
物联网设备、数据记录器、音频/图像缓存、配置文件存储等。
硬件样子

主要特性
特性项 | 描述 |
---|---|
接口协议 | SPI(最高支持 104MHz) |
工作电压 | 2.7V ~ 3.6V(所以后续接线的时候一定要接3.3V) |
容量 | 64 Mbit(= 8MB) |
最小可写单位 | 页(Page) = 256 字节 |
最小可擦除单位 | 扇区(Sector) = 4KB |
较大擦除单位 | 块(Block)= 64KB 或 32KB |
擦除整片 | 支持 |
ID 读取 | 支持读取厂商 ID 和设备 ID |
状态寄存器 | 三个(SR1, SR2, SR3) |
我们写数据时,通常以"页"为单位写,以"扇区"或"块"为单位擦除,而我们今天的w25q64用的是"扇区"来擦除。
常用 SPI 指令
指令名称 | 指令码 | 功能 |
---|---|---|
Write Enable | 0x06 |
写使能 |
Page Program | 0x02 |
页写(最多 256 字节) |
Read Data | 0x03 |
读取数据 |
Sector Erase | 0x20 |
擦除 4KB 扇区 |
Block Erase | 0xD8 |
擦除 64KB 块 |
Chip Erase | 0xC7 |
整片擦除 |
Read Status Register | 0x05 |
读取状态寄存器1 (目的是判断是否繁忙) |
Manufacturer/Device ID | 0x90 |
读取芯片 ID |
我们后面结合代码去讲解,就有许多指令,大家有个印象就行,不需要我们背下来,以上是常用的一些指令,如果有需要,去翻阅w25q64的芯片手册即可。
三 代码部分
前言
在正式进入代码学习之前,我们要进行硬件的连接
W25Q64 引脚 | 名称 | 功能说明 | STM32 连接引脚( SPI1) |
---|---|---|---|
1 | CS | 片选(低电平有效) | PA4 |
2 | DO | 数据输出(MISO) | PA6 |
3 | GND | 地 | GND |
4 | DI | 数据输入(MOSI) | PA7 |
5 | CLK | 串行时钟 | PA5 |
6 | VCC | 电源 | 3.3V |
SPI.c
cs
void W25Q64_SPI_Init(void)
{
GPIO_InitTypeDef GPIO_InitStructure;
SPI_InitTypeDef SPI_InitStructure;
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_SPI1, ENABLE);
// CS 管脚(PA4)
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
GPIO_SetBits(GPIOA, GPIO_Pin_4); // 拉高片选
// SCK (PA5), MOSI (PA7)
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_7;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_Init(GPIOA, &GPIO_InitStructure);
/*
为什么要把cs配置成推挽输出,而把SCK和MOSI配置成复用推挽呢?
答:这些引脚(如 PA5、PA7)是由 SPI1 外设控制的。
你不能用 GPIO_SetBits/GPIO_ResetBits 控制它,而是通过 SPI_SendData 控制。
而对于CS来说,他只是作为普通的IO口
*/
// MISO (PA6)
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(GPIOA, &GPIO_InitStructure);
// SPI 配置
SPI_InitStructure.SPI_Direction = SPI_Direction_2Lines_FullDuplex;
SPI_InitStructure.SPI_Mode = SPI_Mode_Master;
SPI_InitStructure.SPI_DataSize = SPI_DataSize_8b;
SPI_InitStructure.SPI_CPOL = SPI_CPOL_Low; // CPOL=0,空闲为低
SPI_InitStructure.SPI_CPHA = SPI_CPHA_1Edge; // CPHA=0,第一个边沿采样
SPI_InitStructure.SPI_NSS = SPI_NSS_Soft;
SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_256;
SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB;
SPI_InitStructure.SPI_CRCPolynomial = 7;
SPI_Init(SPI1, &SPI_InitStructure);
SPI_Cmd(SPI1, ENABLE);
}
//发送/接收一个字节
uint8_t w25q64_spi_swap_byte(uint8_t data)
{
//TXE 为 1 表示可以写入数据了
while(SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET);
SPI_I2S_SendData(SPI1, data);
//RXNE 为 1 表示已经有数据被接收到,可以读取数据
while(SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) == RESET);
//再将SPI1读取的数据,再返还给这个函数的返回值
return SPI_I2S_ReceiveData(SPI1);
}
以上代码是在上一篇文章《stm32-掌握SPI原理(一)》中已经写好的代码,如果以上代码没有看明白,请看上一篇文章,接下来,我们进行其他代码的讲解。
新加代码第一部分:
cs
void W25Q64_CS_LOW(void) { GPIO_ResetBits(GPIOA, GPIO_Pin_4); }
void W25Q64_CS_HIGH(void) { GPIO_SetBits(GPIOA, GPIO_Pin_4); }
因为SPI通信是需要先把片选信号拉低再进行数据通信,如果想关闭SPI通信则拉高片选。
新加代码第二部分:
cs
void W25Q64_WriteEnable(void)
{
W25Q64_CS_LOW();
w25q64_spi_swap_byte(0x06); // 写使能指令
W25Q64_CS_HIGH();
}
发送 0x06
指令,让 Flash 进入"写允许"状态(必须的前置步骤)
cs
uint8_t W25Q64_ReadSR1(void)
{
uint8_t status;
W25Q64_CS_LOW();
w25q64_spi_swap_byte(0x05);
/*
发送指令 0x05 给 W25Q64 芯片,表示我要读取状态寄存器1
通过 SPI 发送 0x05 指令,这是 W25Q64 固定的"读取状态寄存器1"命令。
目的要判断是否繁忙
*/
status = w25q64_spi_swap_byte(0xFF);
/*
这一步"其实是为了接收",发什么无所谓,0xFF 只是占位
这是因为 SPI 协议的本质是"全双工同步通信",
所以你在 接收数据时,必须同时"发送"一个字节,不然 SPI 时钟不会运行
Slave 就不会把数据发送出来。
*/
W25Q64_CS_HIGH();
return status;
}
该函数的作用是读取状态寄存器1,判断 Flash 当前状态,常用于判断忙不忙(BSY 位),那么W25Q64里面的状态寄存器1到底有什么呢,W25Q64 中的状态寄存器1(SR1)具体内容如下:
位号 | 名称 | 含义说明 |
---|---|---|
Bit7 | SRP | 状态寄存器保护位 |
Bit6 | SEC | 扇区保护指示 |
Bit5 | TB | 顶部/底部保护区域选择位 |
Bit4 | BP2 | 块保护位 2 |
Bit3 | BP1 | 块保护位 1 |
Bit2 | BP0 | 块保护位 0 |
Bit1 | WEL | 写使能标志位(Write Enable Latch) |
Bit0 | BUSY | Flash 忙标志位(1 = 正在编程/擦除,0 = 空闲) |
而我们要判断flash忙不忙,就只需要看Bit0这一位即可,如果他忙了,就不让SPI通信,如果不忙就进行通信。
新加代码第三部分:
cs
void W25Q64_WaitBusy(void)
{
while((W25Q64_ReadSR1() & 0x01) == 0x01);
//这里用的是 &(按位与),而不是 &&(逻辑与)
}
在这里给大家简单的总结一下&和&&的区别:
运算符 | 名称 | 用于 | 示例 |
---|---|---|---|
& |
按位与 | 对两个数的二进制位逐位比较 | 0x05 & 0x01 → 0x01 |
&& |
逻辑与 | 用于判断两个条件都为真 | a && b → 如果都非 0,则为真 |
而我们在这里,只需要判断第一位Bit0位是不是1,如果是1,就会卡在W25Q64_WaitBusy这个函数里,不往下运行。如果是0,代表空闲。
新加代码第四部分:
cs
void W25Q64_SendAddress(uint32_t addr)
{
w25q64_spi_swap_byte((addr >> 16) & 0xFF);
w25q64_spi_swap_byte((addr >> 8) & 0xFF);
w25q64_spi_swap_byte(addr & 0xFF);
/*
将一个24位地址拆成3个字节,从高到低依次通过 SPI 发送给 Flash 芯片,是读、写、擦操作的前置步骤
*/
}
新加代码第五部分(读):
cs
void W25Q64_ReadData(uint32_t addr, uint8_t* buf, uint32_t size)
{
uint32_t i;
W25Q64_CS_LOW();
w25q64_spi_swap_byte(0x03);
W25Q64_SendAddress(addr);
for(i = 0; i < size; i++)
buf[i] = w25q64_spi_swap_byte(0xFF);
W25Q64_CS_HIGH();
}
为什么我们这里是先发送0x03(读数据的命令),而不是像I2C一样先发地址呢?这就是SPI独特的地方,举个通俗的比喻:你要去图书馆借书,流程是这样的:
你说:"我要看书" ------ 这一步就像 w25q64_spi_swap_byte(0x03); 发读取指令。
图书馆说:"你要看哪本?" ------ 你就给它地址:0x000123
图书馆才从书架上帮你取数据 ------ 开始读数据阶段。
这是因为 SPI 是"流式指令式通信",不像 I2C 那样有"设备地址 + 内部寄存器地址"的概念。W25Q64 是 SPI Flash,它规定:
你要读取数据,必须先发一个读命令(0x03),再告诉我地址,我才知道你要读哪里
新加代码第六部分(写):
cs
void W25Q64_WritePage(uint32_t addr, uint8_t* data, uint16_t size)
{
uint16_t i;
W25Q64_WriteEnable();
W25Q64_CS_LOW();
w25q64_spi_swap_byte(0x02);
W25Q64_SendAddress(addr);
for(i = 0; i < size; i++)
w25q64_spi_swap_byte(data[i]);
W25Q64_CS_HIGH();
W25Q64_WaitBusy();
}
如果大家观察细心的话会发现:写比读多了两个函数W25Q64_WriteEnable()和W25Q64_WaitBusy();这是因为在 W25Q64 中,所有会更改数据的操作(写入/擦除)都必须经过"写使能"授权,并等待芯片完成操作;而读取操作是非破坏性的,因此不需要这些前置步骤。
新加代码第七部分(擦):
cs
void W25Q64_EraseSector(uint32_t addr)
{
W25Q64_WriteEnable();
W25Q64_WaitBusy();
W25Q64_CS_LOW();
w25q64_spi_swap_byte(0x20);
W25Q64_SendAddress(addr);
W25Q64_CS_HIGH();
W25Q64_WaitBusy();
}
擦除 Flash 中以"扇区"为单位的 4KB 区域
新加代码第七部分(获取地址ID):
cs
uint16_t W25Q64_ReadID(void)
{
uint16_t device_id = 0;
W25Q64_CS_LOW();
w25q64_spi_swap_byte(0x90);
w25q64_spi_swap_byte(0x00);
w25q64_spi_swap_byte(0x00);
w25q64_spi_swap_byte(0x00);
device_id = w25q64_spi_swap_byte(0xFF) << 8;
device_id |= w25q64_spi_swap_byte(0xFF);
W25Q64_CS_HIGH();
return device_id;
}
获取芯片的制造商 ID 和设备 ID,这个并不是必须要用的,大家选择性运用
main.c
cs
#include "stm32f10x.h"
#include "spi.h"
#include "usart.h"
uint8_t tx_data[16] = "Hello STM32!";
uint8_t rx_data[16] = {0};
int main(void)
{
my_usart_Config(); // 串口初始化
W25Q64_SPI_Init(); // SPI 初始化
printf("Start test...\r\n");
uint16_t id = W25Q64_ReadID();
printf("Flash ID = 0x%04X\r\n", id);
if(id == 0xEF16) // W25Q64 正常 ID;w25q128 的 ID 0xEF17
{
printf("W25Q64 detected.\r\n");
// 擦除扇区
W25Q64_EraseSector(0x000000);
printf("Sector erased.\r\n");
// 写入一页,sizeof(rx_data) 的作用是:告诉函数要读取多少个字节的数据
W25Q64_WritePage(0x000000, tx_data, sizeof(tx_data));
printf("Page written.\r\n");
// 读取数据
W25Q64_ReadData(0x000000, rx_data, sizeof(rx_data));
// 打印读取的数据
printf("Read data: %s\r\n", rx_data);
}
else
{
printf("W25Q64 not found.\r\n");
}
while(1);
}
如上是我利用串口打印出来的w25q64里面的内容,当然,我会给大家讲的明明白白的,第零页是0x000 000,那第二页呢?如果我想写到别的页上怎么办呢?
页编号 | 起始地址(十六进制) | 范围 |
---|---|---|
第 0 页 | 0x0000 00 |
0x000000 ~ 0x0000FF |
第 1 页 | 0x0001 00 |
0x000100 ~ 0x0001FF |
第 2 页 | 0x0002 00 |
0x000200 ~ 0x0002FF |
第 3 页 | 0x0003 00 |
0x000300 ~ 0x0003FF |
... | ... | ... |
第 255 页 | 0x00FF 00 |
0x00FF00 ~ 0x00FFFF |
第 256 页 | 0x0100 00 |
0x010000 ~ 0x0100FF |
... | ... | ... |
so,我们如果想写到别的页上内容的话,大家明白了吧,我们只需要改写前四个位置的数值
- 比如你想写第 3 页,就把地址写成
3 × 256 = 0x000300
, - 想写第 10 页?那就
10 × 256 = 0x000A00
。 - 总之:页号 × 256 = 你要写入的起始地址。
我再多说一句:W25Q64正常ID是0xEF16;w25q128的ID是0xEF17
四 运行结果
