第一次接触STM32的硬件SPI和W25Q64 Flash芯片时,我完全被各种专业术语搞晕了。后来才发现,理解它们就像理解快递收发系统一样简单。SPI就像快递公司的运输通道,而W25Q64则是仓库。SPI负责数据的运输,W25Q64负责存储这些"货物"。
硬件SPI是STM32内置的高速通信接口,相比软件模拟SPI,它的优势非常明显。实测在72MHz系统时钟下,硬件SPI传输速率能达到18Mbps(使用4分频),而软件SPI通常连1Mbps都难以稳定维持。更重要的是硬件SPI不占用CPU资源,发送数据后CPU可以立即处理其他任务。
W25Q64这颗8MB容量的Flash芯片,我用游标卡尺测量过,尺寸仅有8mm x 6mm,却可以存储多达160万汉字。它的内部结构就像一栋大楼:整颗芯片分成128个块(Block),每个块包含16个扇区(Sector),每个扇区有16页(Page),每页256字节。这种结构设计使得我们可以根据需要擦除不同大小的区域——整栋楼全拆(芯片擦除)、拆某一层(块擦除)、拆某个房间(扇区擦除)或者只是清理桌面(页编程)。
刚开始配置SPI引脚时,我犯了个低级错误。按照STM32F103的数据手册,SPI1的引脚应该是PA5(SCK)、PA6(MISO)、PA7(MOSI),但实际接线时我把MOSI和MISO接反了,结果调试了一整天。后来发现,MISO(Master In Slave Out)应该接Flash的DO引脚,MOSI则接DI,这个命名是从主控角度看的。
正确的引脚初始化应该这样写:
c复制// SPI1引脚配置
GPIO_InitTypeDef GPIO_InitStructure;
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
// SCK和MOSI配置为复用推挽输出
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_7;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
// MISO配置为上拉输入
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_Init(GPIOA, &GPIO_InitStructure);
// CS引脚配置为普通推挽输出
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_Init(GPIOA, &GPIO_InitStructure);
SPI初始化结构体中有几个关键参数需要特别注意:
c复制SPI_InitTypeDef SPI_InitStructure;
SPI_InitStructure.SPI_Direction = SPI_Direction_2Lines_FullDuplex; // 全双工模式
SPI_InitStructure.SPI_Mode = SPI_Mode_Master; // 主机模式
SPI_InitStructure.SPI_DataSize = SPI_DataSize_8b; // 8位数据格式
SPI_InitStructure.SPI_CPOL = SPI_CPOL_Low; // 时钟极性
SPI_InitStructure.SPI_CPHA = SPI_CPHA_1Edge; // 时钟相位
SPI_InitStructure.SPI_NSS = SPI_NSS_Soft; // 软件控制片选
SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_4; // 18MHz
SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB; // 高位在前
SPI_InitStructure.SPI_CRCPolynomial = 7; // CRC多项式
这里最容易出错的是CPOL和CPHA的组合,W25Q64要求模式0(CPOL=0,CPHA=0)或模式3(CPOL=1,CPHA=1)。我建议先用模式0,因为大多数SPI设备都兼容这个模式。时钟分频系数要根据实际情况调整,初期调试可以用大分频(如128分频),稳定后再逐步提高速度。
W25Q64有几十条指令,但最常用的就几条。我把它们封装成函数,使用起来更方便。比如读取芯片ID的函数:
c复制void W25Q64_ReadID(uint8_t *manufacturerID, uint16_t *deviceID)
{
SPI_CS_LOW(); // 使能片选
SPI_ReadWriteByte(W25Q64_CMD_JEDEC_ID); // 发送JEDEC ID指令
*manufacturerID = SPI_ReadWriteByte(W25Q64_DUMMY_BYTE); // 读取制造商ID
*deviceID = SPI_ReadWriteByte(W25Q64_DUMMY_BYTE) << 8; // 读取设备ID高字节
*deviceID |= SPI_ReadWriteByte(W25Q64_DUMMY_BYTE); // 读取设备ID低字节
SPI_CS_HIGH(); // 禁用片选
}
这个函数的使用示例:
c复制uint8_t manID;
uint16_t devID;
W25Q64_ReadID(&manID, &devID);
printf("制造商ID:0x%02X, 设备ID:0x%04X\n", manID, devID);
Flash芯片写操作前必须先发送写使能指令,这个设计是为了防止误操作。我封装了两个基础函数:
c复制void W25Q64_WriteEnable(void)
{
SPI_CS_LOW();
SPI_ReadWriteByte(W25Q64_CMD_WRITE_ENABLE);
SPI_CS_HIGH();
}
void W25Q64_WaitBusy(void)
{
SPI_CS_LOW();
SPI_ReadWriteByte(W25Q64_CMD_READ_STATUS_REG1);
while((SPI_ReadWriteByte(W25Q64_DUMMY_BYTE) & 0x01) == 0x01); // 检查BUSY位
SPI_CS_HIGH();
}
这里有个坑要注意:写使能指令会在每次写操作后自动失效,所以每次写数据前都要重新使能。等待就绪函数在擦除和编程操作后必须调用,否则后续操作可能会失败。
Flash存储器有个特性:写操作只能把1变成0,要把0变成1必须擦除整个扇区。擦除函数实现如下:
c复制void W25Q64_SectorErase(uint32_t sectorAddr)
{
W25Q64_WriteEnable(); // 必须先写使能
SPI_CS_LOW();
SPI_ReadWriteByte(W25Q64_CMD_SECTOR_ERASE);
SPI_ReadWriteByte((sectorAddr >> 16) & 0xFF); // 24位地址
SPI_ReadWriteByte((sectorAddr >> 8) & 0xFF);
SPI_ReadWriteByte(sectorAddr & 0xFF);
SPI_CS_HIGH();
W25Q64_WaitBusy(); // 等待擦除完成
}
使用时要注意:扇区地址必须是4K对齐的,比如0x0000、0x1000、0x2000等。擦除一个扇区通常需要60-400ms,期间芯片不会响应其他指令。
W25Q64的页编程函数一次最多写入256字节,跨页时需要分多次写入:
c复制void W25Q64_PageProgram(uint32_t addr, uint8_t *data, uint16_t len)
{
W25Q64_WriteEnable();
SPI_CS_LOW();
SPI_ReadWriteByte(W25Q64_CMD_PAGE_PROGRAM);
SPI_ReadWriteByte((addr >> 16) & 0xFF);
SPI_ReadWriteByte((addr >> 8) & 0xFF);
SPI_ReadWriteByte(addr & 0xFF);
for(uint16_t i=0; i<len; i++) {
SPI_ReadWriteByte(data[i]);
}
SPI_CS_HIGH();
W25Q64_WaitBusy();
}
读取数据相对简单,没有长度限制:
c复制void W25Q64_ReadData(uint32_t addr, uint8_t *buf, uint32_t len)
{
SPI_CS_LOW();
SPI_ReadWriteByte(W25Q64_CMD_READ_DATA);
SPI_ReadWriteByte((addr >> 16) & 0xFF);
SPI_ReadWriteByte((addr >> 8) & 0xFF);
SPI_ReadWriteByte(addr & 0xFF);
for(uint32_t i=0; i<len; i++) {
buf[i] = SPI_ReadWriteByte(W25Q64_DUMMY_BYTE);
}
SPI_CS_HIGH();
}
在实际项目中,我通常会在这基础上再封装一层,实现类似文件系统的读写接口,方便应用层调用。
默认情况下SPI时钟分频较大,传输速度较慢。通过实验,我发现W25Q64在3.3V电压下最高支持80MHz时钟。STM32F103的SPI1在72MHz系统时钟下,使用4分频可以达到18MHz:
c复制SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_4;
但要注意,提高SPI时钟可能会影响信号完整性。如果发现数据错误,可以尝试:
标准库的SPI接口一次只能发送/接收一个字节,效率较低。我们可以改用DMA方式或寄存器直接操作实现连续传输:
c复制void SPI_WriteMultiBytes(uint8_t *data, uint32_t len)
{
for(uint32_t i=0; i<len; i++) {
while(!(SPI1->SR & SPI_I2S_FLAG_TXE)); // 等待发送缓冲区空
SPI1->DR = data[i];
}
while(SPI1->SR & SPI_I2S_FLAG_BSY); // 等待传输完成
}
对于大量数据读写,这种方法可以显著提高速度。我在移植FatFs文件系统时,使用这种方法使文件读取速度提升了3倍以上。
如果发现写入的数据读取出来不正确,可以按照以下步骤排查:
如果芯片完全无响应,可以:
有一次我遇到芯片无响应的问题,最后发现是PCB上的SPI走线太长(超过10cm)导致信号衰减。后来缩短走线并加上上拉电阻就解决了。
一个好的Flash驱动应该采用分层设计:
这种设计使得代码更容易移植和维护。当需要更换Flash芯片时,只需修改中间层即可。
对于需要等待的操作(如擦除、编程),建议使用状态机而非阻塞式等待:
c复制typedef enum {
FLASH_IDLE,
FLASH_ERASING,
FLASH_PROGRAMMING
} FlashState;
FlashState flashState = FLASH_IDLE;
void Flash_Task(void)
{
static uint32_t startTime;
switch(flashState) {
case FLASH_ERASING:
if(W25Q64_IsBusy() == 0) {
flashState = FLASH_IDLE;
printf("擦除完成\n");
}
break;
case FLASH_PROGRAMMING:
if(W25Q64_IsBusy() == 0) {
flashState = FLASH_IDLE;
printf("编程完成\n");
}
break;
case FLASH_IDLE:
default:
break;
}
}
这种方法特别适合在RTOS或事件驱动系统中使用,可以避免长时间阻塞其他任务。