SPI-读写串行FLASH
更新于 2026年9月8日
本篇总结
使用 SPI 读写并擦除串行 FLASH(W25Q64)。
硬件平台:本例程基于 W55MH32L-EVB 开发板运行。
采购推荐:点击前往 WIZnet 官方淘宝店铺 选购 W55MH32L-EVB
本示例演示 W55MH32 的 SPI1 外设驱动片外串行 FLASH W25Q64,完整跑通「读 ID → 扇区擦除 → 擦除校验 → 页写入 → 读回比对」五个环节。SPI 以主模式、模式 3(CPOL=1/CPHA=1)运行,片选信号 PA4 由软件控制,每次操作都通过 printf 在串口上打印结果,可直接观察擦写是否成功。
SPI 简介
SPI(Serial Peripheral Interface)是由摩托罗拉公司提出的串行外围设备接口,是一种高速全双工的通信总线,广泛用于 ADC、LCD、FLASH 等设备与 MCU 之间要求通讯速率较高的场合。
SPI 通讯使用 3 条总线及 1 条片选线:SCK(时钟,由主机产生)、MOSI(主机输出/从机输入)、MISO(主机输入/从机输出)、NSS/CS(片选,低电平有效选中从机)。SPI 一共有四种通讯模式,由时钟极性 CPOL 和时钟相位 CPHA 决定,主机与从机需工作在相同模式下。本例程采用最常用的「模式 3」:CPOL=1(空闲高电平)、CPHA=1(偶数边沿采样)。
W25Q64 存储结构
W25Q64 是一颗 8MByte(64Mbit)的串行 NOR Flash,内部按层级组织,读写擦除各有自己的最小单位:
| 层级 | 大小 | 数量 | 说明 |
|---|---|---|---|
| Page(页) | 256 B | 32768 | 编程(写入)的最小单位,一次 Page Program 不能跨页 |
| Sector(扇区) | 4 KB | 2048 | 擦除的最小单位(16 页),编号 0 ~ 2047 |
| Block(块) | 64 KB | 128 | 16 个扇区,也可整块擦除 |
为什么必须先擦除再写入
NOR Flash 的物理特性是只能把 1 改成 0,不能把 0 改回 1。想让某个字节恢复成可写状态,唯一办法就是擦除——擦除会把整片区域全部置为 0xFF。因此本例程的顺序固定为「擦除 → 写入 → 读回」,跳过擦除直接写会得到错误数据。
前置工具准备
开始本例程前,请确认以下工具已安装:
- WIZ UartTool V1.0:串口调试工具:点击下载
- 逻辑分析仪(可选):抓取 SCK/MOSI/CS 波形,验证 SPI 时序
硬件连接
注意:W55MH32L-EVB 板载没有 SPI Flash
开发板上自带的存储器件只有 I2C EEPROM(AT24C16C)和 TF 卡槽(SDIO),没有焊接 W25Q 系列 SPI Flash。运行本例程需要自备一块 W25Q64 模块(或 W25Qxx 转接板),用杜邦线接到开发板的 IO 排针上。
把 W25Q64 模块的 6 个引脚接到 W55MH32L-EVB 的排针上(SPI1 引脚位于 P4 排针),同时用一根 Micro USB 线连接开发板 DEBUG USB 口到 PC,即可观察串口调试输出。
| 引脚 | 功能 | 方向 | 模式 |
|---|---|---|---|
| PA4 | SPI1_NSS (CS) | MCU → FLASH | 推挽输出(软件控制) |
| PA5 | SPI1_SCK | MCU → FLASH | 复用推挽输出 (AF_PP) |
| PA6 | SPI1_MISO | FLASH → MCU | 复用推挽输出 (AF_PP) |
| PA7 | SPI1_MOSI | MCU → FLASH | 复用推挽输出 (AF_PP) |
| 3V3 / GND | 电源 | — | 模块供电,注意共地 |
片选控制说明
实际应用中一般不使用 SPI 硬件 NSS 信号线,而是用普通 GPIO 软件控制 PA4 电平:拉低选中 FLASH、拉高释放。这样更灵活,也便于扩展多个从设备。W25Q64 的 /CS 必须在整条指令(命令字 + 地址 + 数据)期间保持低电平,指令结束才能拉高。
工程结构
工程位于 SDK 内 1.SDK\ModuleDemo\SPI_Flash\,核心文件如下:
SPI_Flash/
├── USER/
│ ├── main.c ← 擦除/写入/读回/比对主循环 + printf重定向
│ ├── w55mh32_conf.h ← 外设头文件集中包含
│ ├── w55mh32_it.c/h ← 中断服务函数
│ └── SPI.uvprojx ← KEIL MDK 工程文件
└── SYSTEM/
├── delay/
│ └── delay.c/h ← SysTick 延时函数
└── w25qxx/
├── w25qxx.c ← W25Q64 驱动:SPI 收发 + 页编程 + 扇区擦除
└── w25qxx.h ← 芯片 ID / 指令集 / 空间参数宏定义
步骤1:SPI 与 W25Q64 初始化
文件:SYSTEM/w25qxx/w25qxx.c | 函数:SPI_InitTest() 与 W25QXX_Init()
void SPI_InitTest(void)
{
GPIO_InitTypeDef GPIO_InitStructure;
SPI_InitTypeDef SPI_InitStructure;
/* 开启 GPIOA 与 SPI1 时钟 */
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_SPI1, ENABLE);
/* PA5/SCK, PA6/MISO, PA7/MOSI: 复用推挽输出 */
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_6 | GPIO_Pin_7;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
GPIO_SetBits(GPIOA, GPIO_Pin_5 | GPIO_Pin_6 | GPIO_Pin_7);
/* SPI1 参数:主模式 / 模式3 (CPOL=High, CPHA=2Edge) / 8位帧 / MSB先行 */
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_High; /* 空闲高电平 */
SPI_InitStructure.SPI_CPHA = SPI_CPHA_2Edge; /* 偶数边沿采样 */
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);
SPI_ReadWriteByte(0xff); /* 发起一次空传输,稳定 SPI 时序 */
}
void W25QXX_Init(void)
{
GPIO_InitTypeDef GPIO_InitStructure;
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
/* PA4: 片选 CS,推挽输出,初始拉高(非选中状态) */
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);
W25QXX_CS = 1; /* 先拉高片选,不选中 FLASH */
SPI_InitTest(); /* 初始化 SPI1 */
SPI_SetSpeed(SPI_BaudRatePrescaler_128); /* 提速到 128 分频 */
W25QXX_TYPE = W25QXX_ReadID(); /* 读取并记录芯片 ID */
}
两个函数分工:SPI_InitTest() 负责 SPI1 外设本身(开时钟 → PA5/6/7 复用推挽 → 配 SPI 参数 → 使能),W25QXX_Init() 负责 PA4 片选引脚并读回芯片 ID。注意初始化阶段用 256 分频求稳,之后再切到 128 分频提高读写速度。
如果你想自定义
- 改通讯速率 → 修改
SPI_SetSpeed()的入参,如SPI_BaudRatePrescaler_4(更高速)。W25Q64 标称最高 SCK 为 104MHz,别超规格 - 换用 SPI2 → 同步修改 GPIO 引脚(PB12-PB15)和时钟使能宏
RCC_APB1Periph_SPI2,并查阅数据手册确认引脚映射 - 改为模式 0 → 将
SPI_CPOL_High改为SPI_CPOL_Low、SPI_CPHA_2Edge改为SPI_CPHA_1Edge。模式 0 与模式 3 都能驱动 W25Q64
步骤2:读取芯片 ID
函数:W25QXX_ReadID() — 发送 0x90(Manufacturer/Device ID)指令 + 24 位地址 0x000000,随后读回 2 字节即「厂商 ID + 器件 ID」。
u16 W25QXX_ReadID(void)
{
u16 Temp = 0;
W25QXX_CS = 0; /* 拉低片选,选中器件 */
SPI_ReadWriteByte(0x90); /* 发送读 ID 命令 */
SPI_ReadWriteByte(0x00); /* 24 位地址 */
SPI_ReadWriteByte(0x00);
SPI_ReadWriteByte(0x00);
Temp |= SPI_ReadWriteByte(0xFF) << 8; /* 厂商 ID: 0xEF */
Temp |= SPI_ReadWriteByte(0xFF); /* 器件 ID: 0x16 */
W25QXX_CS = 1; /* 拉高片选,释放器件 */
return Temp;
}| 返回值 | 型号 | 容量 |
|---|---|---|
| 0xEF14 | W25Q16 | 2 MB |
| 0xEF15 | W25Q32 | 4 MB |
| 0xEF16 | W25Q64(本例程) | 8 MB |
| 0xEF17 | W25Q128 | 16 MB |
如果你想自定义
- 换成 W25Q128 → 把
main.c里的判断改成W25Q128,并把w25qxx.h里W25Q64_SIZE改为16 * 1024 * 1024 - 用 JEDEC ID → 发送
0x9F可读回 3 字节(厂商 + 容量 + 类型),信息更全,需相应改写读取函数
步骤3:扇区擦除与擦除校验
函数:W25QXX_Erase_Sector() — 传入扇区号(不是字节地址),内部乘 4096 换算成 24 位地址后再发 0x20 扇区擦除指令。
void W25QXX_Erase_Sector(u32 Dst_Addr)
{
Dst_Addr *= 4096; /* 扇区号 → 字节地址 */
W25QXX_Write_Enable(); /* 置位 WEL,允许写/擦 */
W25QXX_Wait_Busy();
W25QXX_CS = 0; /* 拉低片选 */
SPI_ReadWriteByte(W25X_SectorErase); /* 0x20 扇区擦除命令 */
SPI_ReadWriteByte((u8)((Dst_Addr) >> 16)); /* 24 位地址,高字节先发 */
SPI_ReadWriteByte((u8)((Dst_Addr) >> 8));
SPI_ReadWriteByte((u8)Dst_Addr);
W25QXX_CS = 1; /* 拉高片选,指令生效 */
W25QXX_Wait_Busy(); /* 等待内部擦除完成 */
}
/* 等待 BUSY 位清零:内部擦写期间状态寄存器 bit0 为 1 */
void W25QXX_Wait_Busy(void)
{
while ((W25QXX_ReadSR() & 0x01) == 0x01);
}
W25QXX_Wait_Busy() 是整个驱动的关键:擦除和写入都是异步的,主机发出指令后芯片需要几十毫秒到上百毫秒完成内部操作,期间必须用状态寄存器 BUSY 位轮询等待,否则后续指令会被丢弃。
擦除完成后,主循环会逐页把整个扇区读回来,确认每个字节都是 0xFF:
/* 逐页读回整个扇区,每页 256 字节,共 16 页 */
erased = 1;
for (page = 0; page < W25QXX_SECTOR_SIZE / W25QXX_PAGE_SIZE; page++)
{
W25QXX_Read(g_verifyBuf, TEST_ADDR + (uint32_t)page * W25QXX_PAGE_SIZE, W25QXX_PAGE_SIZE);
for (i = 0; i < W25QXX_PAGE_SIZE; i++)
{
if (g_verifyBuf[i] != 0xFF)
{
erased = 0;
break;
}
}
if (!erased) break;
}
printf(" Erase check: %s\n", erased ? "all 0xFF, OK" : "NOT clean!");如果你想自定义
- 换测试扇区 → 修改
TEST_ADDR。本例用W25Q64_SIZE - W25QXX_SECTOR_SIZE,即最后一个扇区 0x7FF000(扇区号 2047),避开其他数据 - 整片擦除 → 调用
W25QXX_Erase_Chip()(命令 0xC7),但耗时较长,注意别把整片数据都清掉了 - 避免死等 → 在
W25QXX_Wait_Busy()里加超时计数,超时则返回错误码,避免硬件异常时程序卡死
步骤4:写入与读回比对
函数:W25QXX_Write() — 这是驱动里最「聪明」的一层:它会先读出目标扇区的全部 4KB 内容,检查待写区域是否已是 0xFF;如果是,直接写;如果不是,先擦除整个扇区、再把新数据合并进 4KB 缓冲、最后整扇区回写,从而保护扇区内的其他数据不丢失。同时它还会自动处理跨页。
void W25QXX_Write(u8 *pBuffer, u32 WriteAddr, u16 NumByteToWrite)
{
u32 secpos; /* 扇区号 */
u16 secoff; /* 扇区内偏移 */
u16 secremain; /* 扇区剩余空间 */
u16 i;
u8 *W25QXX_BUF;
W25QXX_BUF = W25QXX_BUFFER; /* 指向 4KB 扇区缓冲 */
secpos = WriteAddr / 4096;
secoff = WriteAddr % 4096;
secremain = 4096 - secoff;
if (NumByteToWrite <= secremain) secremain = NumByteToWrite; /* 不超过 4096 字节 */
while (1)
{
W25QXX_Read(W25QXX_BUF, secpos * 4096, 4096); /* 读出整个扇区 */
for (i = 0; i < secremain; i++) /* 校验待写区域 */
{
if (W25QXX_BUF[secoff + i] != 0XFF) break; /* 不干净 → 需要擦除 */
}
if (i < secremain) /* 需要擦除 */
{
W25QXX_Erase_Sector(secpos); /* 擦掉这个扇区 */
for (i = 0; i < secremain; i++) /* 把新数据合并进缓冲 */
{
W25QXX_BUF[i + secoff] = pBuffer[i];
}
W25QXX_Write_NoCheck(W25QXX_BUF, secpos * 4096, 4096); /* 整扇区回写 */
}
else
W25QXX_Write_NoCheck(pBuffer, WriteAddr, secremain); /* 已擦除,直接写 */
if (NumByteToWrite == secremain)
break; /* 写入结束 */
else /* 没写完,跨扇区 */
{
secpos++; /* 扇区号 +1 */
secoff = 0; /* 偏移归零 */
pBuffer += secremain; /* 指针偏移 */
WriteAddr += secremain; /* 写地址偏移 */
NumByteToWrite -= secremain; /* 剩余字节数递减 */
if (NumByteToWrite > 4096)
secremain = 4096; /* 下一个扇区仍写不完 */
else
secremain = NumByteToWrite; /* 下一个扇区可以写完 */
}
};
}有了这层封装,main() 里的主循环就非常干净——擦除、写入、读回、比对四步,每 3 秒跑一轮:
const u8 TEXT_Buffer[] = {"WIZnet W55MH32 SPI W25Q64 TEST"};
#define SIZE sizeof(TEXT_Buffer)
/* 测试地址:W25Q64 最后一个 4K 扇区(8M - 4K = 0x7FF000) */
#define TEST_ADDR (W25Q64_SIZE - W25QXX_SECTOR_SIZE)
#define TEST_SECTOR (TEST_ADDR / W25QXX_SECTOR_SIZE)
int main(void)
{
W25QXX_Init();
/* 读芯片 ID,确认 W25Q64 已连接 */
while ((id = W25QXX_ReadID()) != W25Q64)
{
printf("W25Q64 Check Failed!\n");
printf("Read ID: 0x%04X\n", (unsigned int)id);
delay_ms(1000);
}
printf("W25Q64 Ready! ID: 0x%04X\n", (unsigned int)id);
while (1)
{
round++;
printf("\n---------- Round %u ----------\n", (unsigned int)round);
/* [1] 擦除测试扇区,并逐页校验是否全为 0xFF */
printf("[1] Erase sector %u (0x%06lX) ... ", (unsigned int)TEST_SECTOR, (unsigned long)TEST_ADDR);
W25QXX_Erase_Sector(TEST_SECTOR);
printf("Done\n");
/* ... 擦除校验循环见步骤3 ... */
/* [2] 写入测试字符串 */
printf("[2] Write %u bytes ... ", (unsigned int)SIZE);
W25QXX_Write((u8 *)TEXT_Buffer, TEST_ADDR, SIZE);
printf("Done\n");
/* [3] 读回数据 */
printf("[3] Read %u bytes ... ", (unsigned int)SIZE);
W25QXX_Read(datatemp, TEST_ADDR, SIZE);
printf("Done\n");
printf(" Data: %s\n", (char *)datatemp);
/* [4] 逐字节比对 */
errCnt = 0;
for (i = 0; i < SIZE; i++)
{
if (datatemp[i] != TEXT_Buffer[i]) errCnt++;
}
printf("[4] Compare: %s\n", (errCnt == 0) ? "PASS" : "FAIL");
delay_ms(3000);
}
}如果你想自定义
- 改写入内容 → 修改
TEXT_Buffer。SIZE是sizeof,会连结尾的'\0'一起写入,这样读回来能直接用%s打印 - 写入超过 256 字节 →
W25QXX_Write()会自动分页、跨扇区,直接传长数据即可;底层W25QXX_Write_NoCheck()负责按 256 字节切页 - 省 RAM → 驱动里
W25QXX_BUFFER[4096]是跨扇区回写用的扇区缓冲,若只用W25QXX_Write_NoCheck()且保证目标区已擦除,可省掉它
步骤4:printf 重定向
重写 fputc() 将 printf 输出重定向到 USART1,并自动在 \n 前补 \r 确保串口工具正确换行。
int fputc(int c, FILE *f)
{
if (c == '\n')
SER_PutChar('\r'); // 补回车,确保串口工具正确换行
return (SER_PutChar(c));
}
int SER_PutChar(int ch)
{
while (!USART_GetFlagStatus(USART_TEST, USART_FLAG_TC));
USART_SendData(USART_TEST, (uint8_t)ch);
return ch;
}运行验证
打开 WIZ UartTool V1.0 ,选择开发板对应 COM 口,波特率 115200 / 8N1,打开串口。按 RESET 启动后,串口会先打印系统时钟和芯片 ID,随后每 3 秒打印一轮完整的擦写读结果:
注:第一行时钟数值取决于工程里的实际时钟配置(system_w55mh32.c),以你板子实际打印为准。

若需进一步验证 SPI 时序,可用逻辑分析仪抓取 PA4(CS)/PA5(SCK)/PA7(MOSI) 波形:一次扇区擦除应能看到 CS 拉低后送出 0x20 + 24 位地址共 4 字节;一次页编程则应先出现 0x06(Write Enable),再出现 0x02(Page Program)+ 地址 + 数据。
常见问题
Q: 编译报错 "cannot open source file w55mh32.h"
检查 Project → Options → C/C++ → Include Paths,确保 SDK 的 Libraries 目录已添加到头文件搜索路径。
Q: 串口一直打印 "W25Q64 Check Failed!",Read ID 显示 0x0000 或 0xFFFF
说明 SPI 根本没读到数据。先确认:开发板本身不带 SPI Flash,必须外接 W25Q64 模块;再检查 6 根线(PA4/PA5/PA6/PA7/3V3/GND)是否接牢、是否共地;最后用逻辑分析仪看 CS 有没有拉低、SCK 有没有波形。读到 0x0000 多半是 MISO 断路或虚焊,读到 0xFFFF 则通常是模块没供电。
Q: Read ID 读到 0xEF17(或别的 ID),不是 0xEF16
说明模块不是 W25Q64——0xEF17 是 W25Q128,0xEF15 是 W25Q32,对照步骤2 的表格确认型号。可以改用 W25QXX_Write_NoCheck() 跳过型号检查,或把 main() 里的判断宏改成手上实际的型号。
Q: 擦除校验提示 "NOT clean!",或读回来的数据全是 0xFF
优先检查 SPI 模式是否匹配(CPOL/CPHA 组合),主机从机必须一致;其次确认 CS 在整条指令期间保持低电平,命令字 + 地址 + 数据是一帧,中途拉高会导致指令失效;若读回全 0xFF 且擦除正常,则大概率是写入没生效,检查 W25QXX_Write_Enable() 是否在每次写/擦前被调用(WEL 位在每次写操作后会自动清零)。
Q: 程序卡住不动,串口停在某一行
最常见是卡在 W25QXX_Wait_Busy()——芯片没响应时 BUSY 位永远不清零。建议在轮询里加超时计数;同时检查模块的 /WP(写保护)和 /HOLD 引脚是否已接到高电平,这两个脚悬空或被拉低会让芯片拒绝一切擦写指令。
Q: SPI 通讯速率太慢/太快导致从机无法响应
调整 SPI_SetSpeed() 的入参。分频系数越大速率越慢,本例程初始化用 256 分频求稳,随后切到 128 分频;正式应用可改 4 或 2 分频提速,但需确保不超过 W25Q64 数据手册标称的最大 SCK 频率(104MHz)。杜邦线较长时应适当降低速率。
总结
本文基于 W55MH32 的 SPI1 完成了对串行 FLASH W25Q64 的擦除、写入与读回校验:
- SPI1 配置为主模式、模式 3(CPOL=High/CPHA=2Edge)、8 位帧、MSB 先行;初始化用 256 分频,随后切到 128 分频
- 引脚分配:PA4(CS, 软件控制) / PA5(SCK) / PA6(MISO) / PA7(MOSI),CS 初始拉高,每条指令期间保持低电平
- W25Q64 为 8MByte,页 256B(写入最小单位)、扇区 4KB(擦除最小单位)、块 64KB;只能把 1 写成 0,所以写入前必须擦除
- 驱动分层清晰:
W25QXX_ReadID识别型号、W25QXX_Erase_Sector擦扇区、W25QXX_Write自动判断擦除并跨页写入、W25QXX_Read连续读回 - 主流程:读 ID 确认在线 → 擦除扇区并逐页校验全 0xFF → 写入字符串 → 读回并逐字节比对,通过 printf 观察 PASS/FAIL
- 注意:W55MH32L-EVB 板载不带 SPI Flash,运行本例程需外接 W25Q64 模块,接线见「硬件连接」