全部例程

SPI-读写串行FLASH

W55MH32

更新于 2026年9月8日

本篇总结

使用 SPI 读写并擦除串行 FLASH(W25Q64)。

🔑 KEIL MDK、UART调试、SPI、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 B32768编程(写入)的最小单位,一次 Page Program 不能跨页
Sector(扇区)4 KB2048擦除的最小单位(16 页),编号 0 ~ 2047
Block(块)64 KB12816 个扇区,也可整块擦除

为什么必须先擦除再写入

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,即可观察串口调试输出。

引脚功能方向模式
PA4SPI1_NSS (CS)MCU → FLASH推挽输出(软件控制)
PA5SPI1_SCKMCU → FLASH复用推挽输出 (AF_PP)
PA6SPI1_MISOFLASH → MCU复用推挽输出 (AF_PP)
PA7SPI1_MOSIMCU → 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_LowSPI_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;
}
返回值型号容量
0xEF14W25Q162 MB
0xEF15W25Q324 MB
0xEF16W25Q64(本例程)8 MB
0xEF17W25Q12816 MB

如果你想自定义

  • 换成 W25Q128 → 把 main.c 里的判断改成 W25Q128,并把 w25qxx.hW25Q64_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_BufferSIZEsizeof,会连结尾的 '\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),以你板子实际打印为准。

WIZ UartTool 串口输出 — W25Q64 擦除写入读回结果

若需进一步验证 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 模块,接线见「硬件连接」

下载本章例程

我们提供完整的工程文件以及配套开发板,方便你随时测试,快速完成产品开发:

开发环境: Keil MDK5 配套开发板

Andy

W5500 技术支持专家