1. 项目概述
作为一名嵌入式开发工程师,我最近在深入学习STM32的SPI通信协议,并尝试通过软件模拟SPI的方式实现对W25Q64 Flash存储器的读写操作。这个过程中积累了不少实战经验,今天就把我的学习笔记整理分享给大家。
SPI(Serial Peripheral Interface)作为一种高速、全双工的同步串行通信接口,在嵌入式系统中应用极为广泛。而W25Q64作为Winbond公司推出的64M-bit串行Flash存储器,凭借其小封装、低功耗、高可靠性等特点,常被用于数据存储、固件升级等场景。
通过软件模拟SPI(即"软件SPI")来驱动W25Q64,不仅能帮助我们深入理解SPI协议的底层原理,还能在硬件SPI资源受限时提供灵活的替代方案。本文将详细介绍SPI协议的核心机制、W25Q64的关键特性,以及如何用GPIO口模拟SPI时序实现对存储器的完整读写操作。
2. SPI通信协议深度解析
2.1 SPI基础工作原理
SPI协议采用主从架构,通常由一个主设备(Master)和一个或多个从设备(Slave)组成。其核心信号线包括:
- SCLK(Serial Clock):时钟信号,由主设备产生
- MOSI(Master Out Slave In):主设备输出,从设备输入
- MISO(Master In Slave Out):主设备输入,从设备输出
- SS/CS(Slave Select/Chip Select):片选信号,低电平有效
与I2C等协议不同,SPI没有复杂的地址机制和应答信号,其数据传输完全由时钟信号同步控制。主设备通过产生时钟脉冲来驱动数据传输,每个时钟周期完成一位数据的发送和接收。
注意:SPI协议本身没有标准的速度定义,实际通信速率取决于设备特性和信号质量。W25Q64最高支持104MHz时钟频率,但软件SPI的速度会受CPU处理能力限制。
2.2 SPI的四种工作模式
SPI协议通过时钟极性(CPOL)和时钟相位(CPHA)两个参数定义了四种工作模式:
| 模式 | CPOL | CPHA | 时钟空闲状态 | 数据采样时刻 |
|---|---|---|---|---|
| 0 | 0 | 0 | 低电平 | 第一个边沿(上升沿) |
| 1 | 0 | 1 | 低电平 | 第二个边沿(下降沿) |
| 2 | 1 | 0 | 高电平 | 第一个边沿(下降沿) |
| 3 | 1 | 1 | 高电平 | 第二个边沿(上升沿) |
W25Q64支持模式0和模式3,这也是大多数SPI存储器采用的模式。在实际操作中,必须确保主从设备的工作模式完全一致,否则会导致通信失败。
2.3 软件SPI的实现原理
硬件SPI由微控制器的专用外设实现,而软件SPI则是通过普通GPIO口模拟SPI的时序。其核心在于:
- 用GPIO输出模拟SCLK时钟信号
- 用GPIO输出模拟MOSI数据线
- 用GPIO输入读取MISO数据线
- 用GPIO输出控制CS片选信号
软件SPI的优势在于:
- 不依赖特定硬件外设,可在任何GPIO上实现
- 可灵活调整时序参数,便于调试和学习
- 可同时控制多个SPI设备(硬件SPI通常资源有限)
但缺点也很明显:
- 通信速度较低(受CPU处理速度限制)
- 占用CPU资源,实时性较差
- 时序精度不如硬件SPI稳定
3. W25Q64 Flash存储器详解
3.1 关键特性参数
W25Q64是Winbond公司的64M-bit(8M-byte)串行Flash存储器,采用SPI接口,主要特性包括:
- 工作电压:2.7V~3.6V
- 最大时钟频率:104MHz(双线模式)/50MHz(单线模式)
- 页编程(Page Program)大小:256字节
- 扇区擦除(Sector Erase)大小:4KB
- 块擦除(Block Erase)大小:32KB/64KB
- 整片擦除时间:约10s
- 数据保存期限:20年
- 擦写次数:10万次
存储器内部被划分为128个块(Block),每个块包含16个扇区(Sector),每个扇区有16页(Page),每页256字节。这种层次化的存储结构直接影响擦除和编程操作的最小单位。
3.2 存储器指令集
W25Q64通过SPI接口接收指令来实现各种操作,常用指令包括:
| 指令名称 | 指令代码 | 功能描述 |
|---|---|---|
| Read Data | 0x03 | 读取存储数据 |
| Page Program | 0x02 | 页编程(写入) |
| Sector Erase | 0x20 | 扇区擦除(4KB) |
| Block Erase 32K | 0x52 | 32KB块擦除 |
| Block Erase 64K | 0xD8 | 64KB块擦除 |
| Chip Erase | 0xC7 | 整片擦除 |
| Read Status Register-1 | 0x05 | 读状态寄存器1 |
| Write Enable | 0x06 | 写使能 |
| Write Disable | 0x04 | 写禁止 |
每个指令都有特定的时序要求和参数格式。例如,读取数据时需要先发送0x03指令,再发送24位地址,然后才能连续读取数据。
3.3 状态寄存器与写保护机制
W25Q64有两个状态寄存器(Status Register-1和Status Register-2),其中Status Register-1最为关键,其各位定义如下:
| 位 | 名称 | 功能 |
|---|---|---|
| 7 | BUSY | 1=忙(正在执行操作),0=就绪 |
| 6 | WEL | 写使能锁存(1=使能,0=禁止) |
| 5 | BP0 | 块保护位(与BP1、BP2组合定义保护区域) |
| 4 | BP1 | 块保护位 |
| 3 | BP2 | 块保护位 |
| 2 | TB | 顶部/底部块保护选择 |
| 1 | SEC | 扇区/块保护选择 |
| 0 | SRP0 | 状态寄存器保护控制 |
在执行写操作前,必须先发送Write Enable(0x06)指令将WEL位置1。操作完成后,WEL会自动清零。BUSY位可用于判断内部操作是否完成,在编程或擦除期间应持续检查该位。
4. 软件SPI驱动实现
4.1 GPIO初始化配置
以STM32F103为例,我们选择以下GPIO口模拟SPI:
- PA4:CS(片选)
- PA5:SCLK(时钟)
- PA6:MISO(主入从出)
- PA7:MOSI(主出从入)
初始化代码如下(使用HAL库):
c复制void SPI_GPIO_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
__HAL_RCC_GPIOA_CLK_ENABLE();
// CS引脚配置为推挽输出
GPIO_InitStruct.Pin = GPIO_PIN_4;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
// SCLK和MOSI配置为推挽输出
GPIO_InitStruct.Pin = GPIO_PIN_5 | GPIO_PIN_7;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
// MISO配置为输入
GPIO_InitStruct.Pin = GPIO_PIN_6;
GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
GPIO_InitStruct.Pull = GPIO_PULLUP;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
// 初始状态:CS高电平(不选中)
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET);
}
4.2 软件SPI字节传输函数
实现SPI模式0(CPOL=0,CPHA=0)的字节收发函数:
c复制uint8_t SPI_SendByte(uint8_t byte)
{
uint8_t i, received = 0;
for(i = 0; i < 8; i++)
{
// 下降沿(准备数据)
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_RESET);
// 设置MOSI(高位先出)
if(byte & 0x80)
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_7, GPIO_PIN_SET);
else
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_7, GPIO_PIN_RESET);
byte <<= 1;
// 适当延时(根据时钟速度调整)
Delay_us(1);
// 上升沿(采样数据)
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET);
// 读取MISO
received <<= 1;
if(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_6))
received |= 0x01;
Delay_us(1);
}
return received;
}
提示:Delay_us()函数需要根据系统时钟频率实现微秒级延时。在STM32中可以使用SysTick定时器或简单的空循环实现。
4.3 W25Q64基础驱动函数
4.3.1 写使能/禁止
c复制void W25Q64_WriteEnable(void)
{
CS_LOW();
SPI_SendByte(0x06); // Write Enable指令
CS_HIGH();
}
void W25Q64_WriteDisable(void)
{
CS_LOW();
SPI_SendByte(0x04); // Write Disable指令
CS_HIGH();
}
4.3.2 读取状态寄存器
c复制uint8_t W25Q64_ReadStatusReg(uint8_t regNum)
{
uint8_t status = 0;
CS_LOW();
if(regNum == 1)
SPI_SendByte(0x05); // Read Status Register-1
else
SPI_SendByte(0x35); // Read Status Register-2
status = SPI_SendByte(0x00);
CS_HIGH();
return status;
}
4.3.3 等待就绪
c复制void W25Q64_WaitForReady(void)
{
while(W25Q64_ReadStatusReg(1) & 0x01); // 检查BUSY位
}
5. W25Q64读写操作实现
5.1 读取数据
读取数据的标准流程:
- 发送Read Data指令(0x03)
- 发送24位地址(A23-A0)
- 连续读取数据
实现代码:
c复制void W25Q64_ReadData(uint32_t addr, uint8_t *pData, uint16_t len)
{
CS_LOW();
// 发送读取指令
SPI_SendByte(0x03);
// 发送24位地址(高位在前)
SPI_SendByte((addr >> 16) & 0xFF);
SPI_SendByte((addr >> 8) & 0xFF);
SPI_SendByte(addr & 0xFF);
// 连续读取数据
while(len--)
{
*pData++ = SPI_SendByte(0xFF);
}
CS_HIGH();
}
5.2 页编程(写入数据)
页编程的限制和流程:
- 每次最多写入256字节(一页)
- 写入前必须先擦除(擦除后所有位为1)
- 只能将1改为0,不能将0改为1
- 跨页写入需要分多次操作
实现代码:
c复制void W25Q64_PageProgram(uint32_t addr, uint8_t *pData, uint16_t len)
{
// 参数检查
if(len > 256) len = 256;
if((addr & 0xFF) + len > 256) // 跨页检查
len = 256 - (addr & 0xFF);
// 写使能
W25Q64_WriteEnable();
CS_LOW();
// 发送页编程指令
SPI_SendByte(0x02);
// 发送24位地址
SPI_SendByte((addr >> 16) & 0xFF);
SPI_SendByte((addr >> 8) & 0xFF);
SPI_SendByte(addr & 0xFF);
// 发送数据
while(len--)
{
SPI_SendByte(*pData++);
}
CS_HIGH();
// 等待操作完成
W25Q64_WaitForReady();
}
5.3 扇区擦除
擦除是写入的前提,4KB扇区擦除实现:
c复制void W25Q64_SectorErase(uint32_t addr)
{
// 地址对齐到4KB边界
addr &= 0xFFF000;
// 写使能
W25Q64_WriteEnable();
CS_LOW();
// 发送扇区擦除指令
SPI_SendByte(0x20);
// 发送24位地址
SPI_SendByte((addr >> 16) & 0xFF);
SPI_SendByte((addr >> 8) & 0xFF);
SPI_SendByte(addr & 0xFF);
CS_HIGH();
// 等待擦除完成(典型时间50ms)
W25Q64_WaitForReady();
}
6. 实战经验与问题排查
6.1 软件SPI的时序优化
在实现软件SPI时,时钟速度是一个关键考量。通过实测发现:
- 在STM32F103@72MHz下,不使用延时的裸实现能达到约1MHz的SCLK频率
- 加入1us延时后,频率降至约500KHz
- 延时过长会导致W25Q64超时(默认超时时间约5ms)
优化建议:
- 尽量使用寄存器级操作代替HAL库函数,减少函数调用开销
- 根据实际需要调整延时,在可靠性和速度间取得平衡
- 对时序要求不严格的操作(如读取状态寄存器)可以用较低速度
- 大数据量传输时使用较高速度,但要做好错误处理
6.2 W25Q64常见问题排查
问题1:无法读取正确ID
症状:读取器件ID(0x90指令)返回全0或全FF
可能原因:
- 硬件连接错误(检查CS、CLK、MOSI、MISO接线)
- 电源不稳定(测量VCC电压应在2.7V-3.6V之间)
- SPI模式不匹配(确保主从设备CPOL/CPHA设置一致)
问题2:写入失败
症状:写入后读取数据不一致
排查步骤:
- 检查写使能指令是否成功执行(读取状态寄存器WEL位)
- 确认写入地址是否在已擦除区域(擦除后所有位应为1)
- 检查是否跨页写入(单次写入不能超过256字节或跨页边界)
- 确保在BUSY位清零后再进行后续操作
问题3:擦除时间异常
症状:扇区擦除时间远超标称值(典型值50ms)
可能原因:
- 电源电压不足导致内部电荷泵工作效率降低
- 环境温度过低(工作温度范围-40℃~85℃)
- 芯片损坏(可通过读取电子签名判断)
6.3 性能提升技巧
-
双线快速读取:W25Q64支持双线模式(同时使用MOSI和MISO传输数据),读取速度可翻倍。需要发送0x3B指令并配置相应模式。
-
多扇区预擦除:在写入大量数据前,先批量擦除多个扇区,减少等待时间。
-
缓存管理:在RAM中建立写入缓存,攒够一页数据后再统一写入,减少页编程次数。
-
状态轮询优化:不必连续轮询状态寄存器,适当间隔检查(如每10ms一次)可降低CPU负载。
7. 扩展应用实例
7.1 实现简单文件系统
基于W25Q64的存储特性,可以设计一个简易的文件系统:
c复制#define FS_SECTOR_SIZE 4096
#define FS_PAGE_SIZE 256
#define FS_MAX_FILES 16
typedef struct {
char name[16];
uint32_t start_sector;
uint32_t size;
uint8_t reserved[4];
} FileEntry;
typedef struct {
FileEntry files[FS_MAX_FILES];
uint32_t free_sector;
uint8_t checksum;
} FileSystemHeader;
void FS_Init(void)
{
// 检查文件系统头是否有效(位于扇区0)
FileSystemHeader header;
W25Q64_ReadData(0, (uint8_t*)&header, sizeof(header));
if(header.checksum != CalcChecksum(&header))
{
// 无效头,初始化新文件系统
memset(&header, 0, sizeof(header));
header.free_sector = 1; // 跳过扇区0
FS_UpdateHeader(&header);
}
}
uint8_t FS_CreateFile(const char *name, uint32_t size)
{
// 查找空闲位置并创建文件条目
// 实现文件创建逻辑...
}
void FS_WriteFile(uint8_t fileId, uint32_t offset, uint8_t *data, uint32_t len)
{
// 实现文件写入逻辑,处理跨扇区写入...
}
void FS_ReadFile(uint8_t fileId, uint32_t offset, uint8_t *data, uint32_t len)
{
// 实现文件读取逻辑...
}
7.2 固件在线升级方案
利用W25Q64作为固件存储器,实现IAP(In-Application Programming)功能:
- 将Flash划分为两个区域:运行区(存放当前固件)和更新区(存放新固件)
- 通过通信接口(如UART、USB)接收新固件数据,写入更新区
- 校验通过后,在重启时从更新区加载新固件
- 加入回滚机制,防止更新失败导致系统瘫痪
关键实现代码:
c复制#define FW_RUN_BASE 0x000000
#define FW_UPDATE_BASE 0x100000
void IAP_ReceiveFirmware(void)
{
// 擦除更新区域
for(uint32_t addr = FW_UPDATE_BASE; addr < FW_UPDATE_BASE + FW_MAX_SIZE; addr += 4096)
{
W25Q64_SectorErase(addr);
}
// 接收并写入新固件
uint32_t offset = 0;
while(offset < FW_MAX_SIZE)
{
if(UART_Receive(&rxBuffer, 256))
{
W25Q64_PageProgram(FW_UPDATE_BASE + offset, rxBuffer, 256);
offset += 256;
}
}
// 计算并验证校验和
if(VerifyChecksum(FW_UPDATE_BASE))
{
// 更新成功,设置标志位
SetBootFlag(BOOT_FROM_UPDATE);
}
}
通过软件SPI驱动W25Q64的过程让我对SPI协议有了更深入的理解。在实际项目中,如果对速度要求不高且硬件SPI资源紧张,软件SPI是一个可靠的替代方案。但要注意,软件实现会占用较多CPU资源,在实时性要求高的场景下还是建议使用硬件SPI。