最近在做一个需要大容量存储的项目,W25Q64这颗64Mbit的SPI Flash芯片进入了我的视线。作为嵌入式开发中的"老朋友",W25Q系列以其稳定的性能和亲民的价格,成为了很多项目中外部存储的首选方案。今天我就来详细聊聊如何在STM32平台上,通过硬件SPI和软件模拟SPI两种方式操作这款芯片。
在实际项目中,我们常常会遇到硬件SPI引脚被占用的情况,这时候软件模拟SPI就派上用场了。不过两者在性能和稳定性上的差异,以及具体实现时的注意事项,都是需要开发者心中有数的。接下来,我会从芯片特性、硬件连接、驱动编写到性能测试,一步步带大家深入理解SPI Flash的操作要点。
W25Q64是Winbond公司推出的一款SPI接口的NOR Flash,容量为64Mbit(8MB),采用3.3V供电。这颗芯片有几个关键特性值得关注:
芯片内部结构上,W25Q64由多个存储阵列组成,通过内部逻辑控制器管理数据的读写和擦除操作。值得注意的是,Flash存储器的特性决定了它不能像RAM那样直接覆盖写入,必须先擦除再写入。
W25Q64通过SPI接口接收各种指令来实现不同功能,以下是几个最常用的指令:
特别注意:每次写操作前必须发送WREN指令,且每次上电后默认是写禁止状态。
以STM32F103系列为例,硬件SPI的典型连接方式如下:
| W25Q64引脚 | STM32引脚 | 功能说明 |
|---|---|---|
| CS | PA4 | 片选信号 |
| DO(IO1) | PA6(MISO) | 主入从出 |
| DI(IO0) | PA7(MOSI) | 主出从入 |
| CLK | PA5(SCK) | 时钟信号 |
| VCC | 3.3V | 电源 |
| GND | GND | 地线 |
硬件SPI的优势在于其时钟频率可以很高(理论上可达18MHz),且由硬件自动处理时钟和数据同步,CPU占用率低。
当硬件SPI不可用时,我们可以用任意GPIO模拟SPI时序:
c复制// 定义软件SPI引脚
#define SOFT_SPI_CS_PIN GPIO_PIN_4
#define SOFT_SPI_CS_PORT GPIOA
#define SOFT_SPI_SCK_PIN GPIO_PIN_5
#define SOFT_SPI_SCK_PORT GPIOA
#define SOFT_SPI_MOSI_PIN GPIO_PIN_7
#define SOFT_SPI_MOSI_PORT GPIOA
#define SOFT_SPI_MISO_PIN GPIO_PIN_6
#define SOFT_SPI_MISO_PORT GPIOA
软件SPI的灵活性高,但速度较慢(通常在几百KHz级别),且会占用较多CPU资源。
首先初始化硬件SPI接口:
c复制void SPI1_Init(void)
{
SPI_HandleTypeDef hspi1;
hspi1.Instance = SPI1;
hspi1.Init.Mode = SPI_MODE_MASTER;
hspi1.Init.Direction = SPI_DIRECTION_2LINES;
hspi1.Init.DataSize = SPI_DATASIZE_8BIT;
hspi1.Init.CLKPolarity = SPI_POLARITY_LOW;
hspi1.Init.CLKPhase = SPI_PHASE_1EDGE;
hspi1.Init.NSS = SPI_NSS_SOFT;
hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_4; // 9MHz @72MHz PCLK
hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB;
hspi1.Init.TIMode = SPI_TIMODE_DISABLE;
hspi1.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE;
hspi1.Init.CRCPolynomial = 10;
if (HAL_SPI_Init(&hspi1) != HAL_OK)
{
Error_Handler();
}
}
然后是基本的读写函数:
c复制uint8_t SPI1_ReadWriteByte(uint8_t TxData)
{
uint8_t RxData;
HAL_SPI_TransmitReceive(&hspi1, &TxData, &RxData, 1, 1000);
return RxData;
}
void W25Q64_Read(uint8_t* pBuffer, uint32_t ReadAddr, uint16_t NumByteToRead)
{
W25Q64_CS_LOW();
SPI1_ReadWriteByte(W25Q64_READ_DATA);
SPI1_ReadWriteByte((ReadAddr >> 16) & 0xFF);
SPI1_ReadWriteByte((ReadAddr >> 8) & 0xFF);
SPI1_ReadWriteByte(ReadAddr & 0xFF);
while(NumByteToRead--) {
*pBuffer++ = SPI1_ReadWriteByte(0xFF);
}
W25Q64_CS_HIGH();
}
软件SPI需要手动控制每个时钟沿的数据变化:
c复制void SOFT_SPI_WriteByte(uint8_t byte)
{
for(uint8_t i=0; i<8; i++) {
HAL_GPIO_WritePin(SOFT_SPI_SCK_PORT, SOFT_SPI_SCK_PIN, GPIO_PIN_RESET);
if(byte & 0x80) {
HAL_GPIO_WritePin(SOFT_SPI_MOSI_PORT, SOFT_SPI_MOSI_PIN, GPIO_PIN_SET);
} else {
HAL_GPIO_WritePin(SOFT_SPI_MOSI_PORT, SOFT_SPI_MOSI_PIN, GPIO_PIN_RESET);
}
HAL_GPIO_WritePin(SOFT_SPI_SCK_PORT, SOFT_SPI_SCK_PIN, GPIO_PIN_SET);
byte <<= 1;
}
}
uint8_t SOFT_SPI_ReadByte(void)
{
uint8_t byte = 0;
for(uint8_t i=0; i<8; i++) {
byte <<= 1;
HAL_GPIO_WritePin(SOFT_SPI_SCK_PORT, SOFT_SPI_SCK_PIN, GPIO_PIN_SET);
if(HAL_GPIO_ReadPin(SOFT_SPI_MISO_PORT, SOFT_SPI_MISO_PIN)) {
byte |= 0x01;
}
HAL_GPIO_WritePin(SOFT_SPI_SCK_PORT, SOFT_SPI_SCK_PIN, GPIO_PIN_RESET);
}
return byte;
}
Flash存储器必须先擦除才能写入,擦除有三种粒度:
c复制void W25Q64_SectorErase(uint32_t SectorAddr)
{
W25Q64_WriteEnable();
W25Q64_WaitBusy();
W25Q64_CS_LOW();
SPI1_ReadWriteByte(W25Q64_SECTOR_ERASE);
SPI1_ReadWriteByte((SectorAddr >> 16) & 0xFF);
SPI1_ReadWriteByte((SectorAddr >> 8) & 0xFF);
SPI1_ReadWriteByte(SectorAddr & 0xFF);
W25Q64_CS_HIGH();
W25Q64_WaitBusy();
}
void W25Q64_BlockErase(uint32_t BlockAddr)
{
W25Q64_WriteEnable();
W25Q64_WaitBusy();
W25Q64_CS_LOW();
SPI1_ReadWriteByte(W25Q64_BLOCK_ERASE);
SPI1_ReadWriteByte((BlockAddr >> 16) & 0xFF);
SPI1_ReadWriteByte((BlockAddr >> 8) & 0xFF);
SPI1_ReadWriteByte(BlockAddr & 0xFF);
W25Q64_CS_HIGH();
W25Q64_WaitBusy();
}
void W25Q64_ChipErase(void)
{
W25Q64_WriteEnable();
W25Q64_WaitBusy();
W25Q64_CS_LOW();
SPI1_ReadWriteByte(W25Q64_CHIP_ERASE);
W25Q64_CS_HIGH();
W25Q64_WaitBusy();
}
重要提示:扇区擦除约需100ms,块擦除约需1s,整片擦除约需30s,期间不要断电!
写入操作以页为单位,每页256字节:
c复制void W25Q64_PageProgram(uint8_t* pBuffer, uint32_t WriteAddr, uint16_t NumByteToWrite)
{
W25Q64_WriteEnable();
W25Q64_WaitBusy();
W25Q64_CS_LOW();
SPI1_ReadWriteByte(W25Q64_PAGE_PROGRAM);
SPI1_ReadWriteByte((WriteAddr >> 16) & 0xFF);
SPI1_ReadWriteByte((WriteAddr >> 8) & 0xFF);
SPI1_ReadWriteByte(WriteAddr & 0xFF);
while(NumByteToWrite--) {
SPI1_ReadWriteByte(*pBuffer++);
}
W25Q64_CS_HIGH();
W25Q64_WaitBusy();
}
我分别用硬件SPI和软件SPI进行了读写测试,结果如下:
| 测试项 | 硬件SPI(9MHz) | 软件SPI(~500KHz) |
|---|---|---|
| 读取1KB数据 | 1.2ms | 22ms |
| 写入256字节 | 3ms | 55ms |
| 扇区擦除(4KB) | 100ms | 105ms |
| CPU占用率 | <5% | >80% |
从测试结果可以看出,硬件SPI在速度上有绝对优势,特别是在连续读写时。而软件SPI由于需要CPU参与每一位的时序控制,效率较低。
利用W25Q64的大容量特性,我们可以实现一个可靠的日志存储系统:
c复制#define LOG_START_ADDR 0x000000
#define LOG_SECTOR_SIZE 4096
#define LOG_MAX_SECTORS 128
typedef struct {
uint32_t write_ptr;
uint16_t sector_count;
} LogSystem;
void Log_Init(LogSystem* sys)
{
// 初始化日志系统
sys->write_ptr = LOG_START_ADDR;
sys->sector_count = 0;
// 查找最后一个写入位置
// ...实现略...
}
void Log_Write(LogSystem* sys, const char* msg)
{
uint16_t len = strlen(msg);
if(len > 256) len = 256; // 限制单条日志长度
// 检查是否需要擦除新扇区
if((sys->write_ptr % LOG_SECTOR_SIZE) == 0) {
if(sys->sector_count >= LOG_MAX_SECTORS) {
// 实现循环覆盖
sys->write_ptr = LOG_START_ADDR;
sys->sector_count = 0;
}
W25Q64_SectorErase(sys->write_ptr);
sys->sector_count++;
}
// 写入日志
W25Q64_PageProgram((uint8_t*)msg, sys->write_ptr, len);
sys->write_ptr += len;
}
W25Q64还可以用来存储备份固件,实现安全的OTA升级:
这种设计可以有效防止升级过程中断电导致的系统无法启动问题。
对于硬件SPI,我们可以启用DMA来进一步减少CPU占用:
c复制void SPI1_DMA_Init(void)
{
// DMA控制器时钟使能
__HAL_RCC_DMA1_CLK_ENABLE();
// 配置Tx DMA
hdma_tx.Instance = DMA1_Channel3;
hdma_tx.Init.Direction = DMA_MEMORY_TO_PERIPH;
hdma_tx.Init.PeriphInc = DMA_PINC_DISABLE;
hdma_tx.Init.MemInc = DMA_MINC_ENABLE;
hdma_tx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
hdma_tx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
hdma_tx.Init.Mode = DMA_NORMAL;
hdma_tx.Init.Priority = DMA_PRIORITY_HIGH;
HAL_DMA_Init(&hdma_tx);
// 关联DMA到SPI
__HAL_LINKDMA(&hspi1, hdmatx, hdma_tx);
// 类似配置Rx DMA...
}
void W25Q64_Read_DMA(uint8_t* pBuffer, uint32_t ReadAddr, uint16_t NumByteToRead)
{
uint8_t cmd[4] = {
W25Q64_READ_DATA,
(ReadAddr >> 16) & 0xFF,
(ReadAddr >> 8) & 0xFF,
ReadAddr & 0xFF
};
W25Q64_CS_LOW();
HAL_SPI_Transmit(&hspi1, cmd, 4, 100);
HAL_SPI_Receive_DMA(&hspi1, pBuffer, NumByteToRead);
// 需要在DMA完成中断中拉高CS
}
对于更复杂的应用,可以在W25Q64上实现一个简单的文件系统:
存储布局设计:
关键数据结构:
c复制typedef struct {
char name[16];
uint32_t size;
uint32_t start_sector;
uint32_t timestamp;
} FileEntry;
typedef struct {
uint32_t magic;
uint16_t version;
uint16_t file_count;
FileEntry files[16];
uint32_t free_sectors[128]; // 位图表示空闲扇区
} SuperBlock;
这种简单的文件系统虽然功能有限,但对于嵌入式应用来说已经足够,而且实现起来相对简单可靠。
调试SPI通信时,逻辑分析仪是不可或缺的工具。我通常使用Saleae Logic Analyzer来抓取SPI波形,设置步骤如下:
连接探头:
设置采样率:至少10倍于SPI时钟频率
添加SPI协议分析器:
通过分析波形,可以快速定位是时序问题、数据问题还是协议问题。
在没有逻辑分析仪的情况下,可以通过串口输出调试信息:
c复制void DumpBuffer(const char* prompt, uint8_t* buf, uint16_t len)
{
printf("%s (%d bytes):\r\n", prompt, len);
for(uint16_t i=0; i<len; i++) {
printf("%02X ", buf[i]);
if((i+1)%16 == 0) printf("\r\n");
}
printf("\r\n");
}
// 使用示例
uint8_t test_data[256];
W25Q64_Read(test_data, 0x1000, 256);
DumpBuffer("Read data", test_data, 256);
这种方法虽然简单,但在初期调试时非常有效。
经过实际项目验证,我总结了以下选择原则:
优先使用硬件SPI的情况:
可以考虑软件SPI的情况:
混合使用的方案:
有些项目中,我会同时实现两种驱动,通过宏定义切换:
c复制#define USE_HARDWARE_SPI 1
#if USE_HARDWARE_SPI
#define SPI_ReadWriteByte SPI1_ReadWriteByte
#else
#define SPI_ReadWriteByte SOFT_SPI_ReadWriteByte
#endif
这样可以根据实际需求灵活选择,也方便后期优化。
W25Q64本身有几种省电模式,合理使用可以降低系统功耗:
深度掉电模式(DP, 0xB9):
待机模式:
使用建议:
实现示例:
c复制void W25Q64_EnterPowerDown(void)
{
W25Q64_CS_LOW();
SPI1_ReadWriteByte(0xB9); // DP指令
W25Q64_CS_HIGH();
}
void W25Q64_ReleasePowerDown(void)
{
W25Q64_CS_LOW();
SPI1_ReadWriteByte(0xAB); // 释放指令
// 需要等待至少20us
Delay_us(30);
W25Q64_CS_HIGH();
}
在关键应用中,我们需要考虑各种异常情况的处理:
写入验证:
ECC校验:
坏块管理:
示例代码:
c复制#define ECC_POLY 0x1D // 常用ECC多项式
uint8_t Calculate_ECC(uint8_t* data, uint16_t len)
{
uint8_t ecc = 0;
for(uint16_t i=0; i<len; i++) {
ecc ^= data[i];
for(uint8_t j=0; j<8; j++) {
if(ecc & 0x80) {
ecc = (ecc << 1) ^ ECC_POLY;
} else {
ecc <<= 1;
}
}
}
return ecc;
}
bool Verify_Page(uint32_t addr, uint8_t* data, uint16_t len)
{
uint8_t ecc_stored, ecc_calculated;
W25Q64_Read(&ecc_stored, addr + len, 1); // 假设ECC存在数据后
ecc_calculated = Calculate_ECC(data, len);
if(ecc_stored != ecc_calculated) {
// 错误处理逻辑
return false;
}
return true;
}
当需要更大存储容量时,可以并联多个W25Q64芯片,通过不同的片选信号控制:
硬件连接:
软件实现:
c复制#define W25Q64_CS1_PIN GPIO_PIN_4
#define W25Q64_CS1_PORT GPIOA
#define W25Q64_CS2_PIN GPIO_PIN_5
#define W25Q64_CS2_PORT GPIOB
// 更多片选引脚...
void Select_Chip(uint8_t chip_num)
{
// 先取消所有片选
HAL_GPIO_WritePin(W25Q64_CS1_PORT, W25Q64_CS1_PIN, GPIO_PIN_SET);
HAL_GPIO_WritePin(W25Q64_CS2_PORT, W25Q64_CS2_PIN, GPIO_PIN_SET);
// ...其他片选
// 选择指定芯片
switch(chip_num) {
case 1:
HAL_GPIO_WritePin(W25Q64_CS1_PORT, W25Q64_CS1_PIN, GPIO_PIN_RESET);
break;
case 2:
HAL_GPIO_WritePin(W25Q64_CS2_PORT, W25Q64_CS2_PIN, GPIO_PIN_RESET);
break;
// ...其他芯片
default:
break;
}
}
经过多个项目的实践,我总结了以下宝贵经验:
上电初始化时序:
温度影响:
长期数据保存:
异常处理:
性能瓶颈:
代码优化:
最后分享一个实际调试中发现的问题:有一次发现写入的数据偶尔会出错,最终发现是电源走线过长导致电压跌落。解决方案是在芯片VCC引脚就近放置一个0.1μF+10μF的电容组合。这个教训告诉我,硬件设计同样重要,不能只关注软件实现。