【stm32】SPI Flash 驱动开发

🔥铅笔小新z:个人主页

🎬博客专栏:stm32学习

💫滴水不绝,可穿石;步履不休,能至渊。


目录


一、目标与准备

1.1 本节要做什么

目标一句话:用 STM32 的 SPI1 主机,以阻塞模式,驱动板子上的 W25Q64 SPI Flash,实现读 ID、读状态、写使能、擦除、写数据、读数据等一整套功能,并测试。

要完成这个目标,需要具备三块基础:

  1. SPI 主机阻塞模式的配置能力(CubeMX 配置 + 生成的代码看得懂);
  2. SPI 主机阻塞收发函数(HAL_SPI_Transmit / HAL_SPI_Receive / HAL_SPI_TransmitReceive)会用;
  3. W25Q64 的操作方法(这是本节的重点)。

需求清单(也就是我们最终要写出来的函数):

功能 说明
读芯片 ID 识别芯片型号,确认通信正常
读状态寄存器 获取 BUSY / WEL 两个关键标志
写使能 每次写/擦前必须执行
写禁止 手动上锁(较少用)
扇区擦除 最小擦除单位 4KB
整片擦除 清空整颗芯片
页编程 最小/最大写入单位,一页 256 字节
跨页写入 对页编程的封装,支持任意地址、任意长度
等待写完成 轮询 BUSY 位
读数据 任意地址、任意长度读取

1.2 硬件接线与片选设计(一个容易踩的坑)

在我的开发板上,SPI1 的 SCK、MOSI、MISO 是共享的 ,也就是说,多个从设备可能挂在同一组 SPI 信号线上。这时候,"到底跟谁说话"就靠片选(CS/NSS) 来决定。

这里有个坑:板子上和 SPI1 通信的从设备不止一个(比如还有 LCD),所以:

  • 我们只用 PE6 对 W25Q64 进行片选;
  • 必须同时开启 PE4,并把它默认拉高。原因是:如果不设置 PE4,可能会导致 LCD 被误选(PE4 默认电平不确定),从而产生未知错误;
  • 所以要把 PE4 和 PE6 都配置为输出,默认输出高电平 ;未来要选 W25Q64 时,只需要把 PE6 拉低即可。

一句话记忆:默认全部拉高(都不选),谁要干活,就把谁的 CS 拉低。

其它准备:

  • 用 USART1(PA9/PA10) 打印信息;
  • 用两个 LED:LED4(PF8)表示通信正常,LED5(PF9)表示通信异常;
  • 上位机用串口助手(如 comtool)。

1.3 CubeMX 配置要点

配置流程和上一节基本一致,关键点如下:

  1. 调试接口 :选择 Serial Wire(SWD)。如果误配置或禁止了 JTAG/SWD,烧录后可能再也连不上目标芯片。

  2. 晶振与时钟 :选择外部晶振 Crystal/Ceramic Resonator,板子是 8MHz HSE,系统主频配到 72MHz。

  3. SPI1 :标准 SPI 模式(Motorola)、8 位数据帧、MSB 优先、64 分频、Mode 0;不用硬件 NSS,采用软件 NSS 。

  4. 软件 NSS 引脚 :PE4(常拉高,给 LCD 用)、PE6(给 W25Q64 片选用),都默认输出高电平。

  5. USART1 :异步模式、波特率 115200、9 位数据(8 数据 + 1 校验)、奇校验、1 位停止位。上位机要保持一致。

  6. LED :PF8、PF9 配置为通用开漏输出,默认输出低电平(点亮)。

  7. 工程管理 :勾选 Use MicroLIB(方便 printf 重定向)、配置仿真器、添加新目录头文件搜索路径。

知识点总结 ①

SPI Flash 驱动开发的目标与片选设计:

本节的目标是把 SPI 主机阻塞模式的操作,封装成一套完整的 W25Q64 Flash 驱动,功能包括读 ID、读状态、写使能/禁止、扇区擦除、整片擦除、页编程、跨页写入、等待写完成、读数据。驱动开发本质上是把「芯片手册里的时序和指令」翻译成「一组可复用的函数」。在多从机共享 SPI 总线的场景下,同一时刻只能有一个从机被选中,靠片选 CS/NSS 区分;未选中的从机 CS 必须保持高电平,否则会误选、总线冲突。因此除了控制 W25Q64 的 PE6,还必须把共享总线的 LCD 片选 PE4 显式拉高,保证默认电平确定。核心原则:默认全部拉高,谁要干活就把谁的 CS 拉低。


二、驱动兼容性设计

2.1 用结构体抽走差异参数

我们希望写出来的驱动,不仅能用 W25Q64,还能兼容 W25Qxx 全系列。影响兼容性的因素有三个:

  1. 不同型号的页大小、扇区大小、块大小、总容量不同;
  2. 不同板子控制 Flash 的片选 IO 不同;
  3. 不同板子使用的 SPI 接口不同。

解决办法:把差异参数通过一个结构体传入,驱动初始化时保存下来。

BSP_W25Q64.h:

c 复制代码
#ifndef __BSP_W25Q64_H
#define __BSP_W25Q64_H

#ifdef __cplusplus
extern "C" {
#endif

#include <stdio.h>
#include <stdint.h>
#include <stdbool.h>
#include "main.h"

/**
 * @brief W25Qxx 系列 Flash 初始化结构体
 * @details 把不同芯片、不同板子之间的差异参数,统一放进这个结构体
 */
typedef struct
{
    uint32_t page_size;              // 页大小(W25Q64 为 256 字节)
    uint32_t sector_size;            // 扇区大小(W25Q64 为 4096 字节 = 4KB)
    uint32_t block_size;             // 块大小(W25Q64 为 64KB)
    uint32_t mem_size;               // 总容量(W25Q64 为 8MB)
    uint32_t chip_id;                // 芯片 ID(W25Q64 为 0xEF4017)
    SPI_HandleTypeDef *spi_handler;  // SPI 句柄指针(告诉驱动用哪个 SPI)
    GPIO_TypeDef *nss_port;          // 软件片选端口(如 GPIOE)
    uint16_t nss_pin;                // 软件片选引脚(如 GPIO_PIN_6)
} flash_t;

bool BSP_W25Qxx_Init(flash_t *init);
uint32_t BSP_W25Qxx_ID(void);

#ifdef __cplusplus
}
#endif

#endif

2.2 初始化与 ID 自检

Bsp_W25Q64.c 里的初始化实现:

c 复制代码
#include "Bsp_W25Q64.h"

static flash_t w25qxx;           // 驱动内部维护的 W25Q64 对象
static bool w25qxx_init = false; // 标记对象是否初始化完成

/**
 * @brief 用外部传入的参数,初始化驱动内部的 Flash 对象
 * @param init 用户填好的初始化结构体
 * @return true 成功 / false 失败
 */
bool BSP_W25Qxx_Init(flash_t *init)
{
    if (init == NULL)
        return false;

    // 先把用户传入的参数,拷贝到驱动内部的 static 对象里
    w25qxx_init        = true;      // 先置 true,因为读 ID 需要"已初始化"这个前提
    w25qxx.page_size   = init->page_size;
    w25qxx.sector_size = init->sector_size;
    w25qxx.block_size  = init->block_size;
    w25qxx.mem_size    = init->mem_size;
    w25qxx.chip_id     = init->chip_id;
    w25qxx.spi_handler = init->spi_handler;
    w25qxx.nss_port    = init->nss_port;
    w25qxx.nss_pin     = init->nss_pin;

    // 为了保证兼容性,只有读到的 ID 和用户声明的 ID 相同,才认为初始化成功
    uint32_t id = BSP_W25Qxx_ID();
    if (id != w25qxx.chip_id)
    {
        w25qxx_init = false; // 校验失败,把状态改回未初始化
        printf("w25qxx init failed, ID error, current id is : 0x%08x, your id is : 0x%08x\n", id, w25qxx.chip_id);
        return false;
    }
    printf("w25qxx init success, ID=0x%08x\n", id);
    return w25qxx_init;
}

在 main.c 中的使用方式:

c 复制代码
// 配置 W25Qxx 初始化参数:把差异信息显式传给驱动
flash_t w25qxx_init = {
    .page_size   = 256,             // 页大小 256 字节
    .sector_size = 4096,            // 扇区 4KB
    .block_size  = 64 * 1024,       // 块 64KB
    .mem_size    = 8 * 1024 * 1024, // 总容量 8MB
    .chip_id     = 0xEF4017,        // W25Q64 的 JEDEC ID
    .spi_handler = &hspi1,          // 使用 SPI1
    .nss_port    = GPIOE,           // CS 端口
    .nss_pin     = GPIO_PIN_6       // CS 引脚
};

BSP_W25Qxx_Init(&w25qxx_init); // 初始化

设计思想:把"变化的部分"抽出来当参数,把"不变的部分"写进驱动。 这就是面向对象里"依赖注入"的朴素版本。

知识点总结 ②

驱动兼容性设计(依赖注入思想):

不同 W25Qxx 型号的页/扇区/块/容量不同,不同板子的片选 IO 和 SPI 接口也不同。为了让一套驱动适配全系列,我们把这些差异参数抽象成 flash_t 结构体,在初始化时由用户传入。驱动内部用 static 变量保存这些参数,供所有函数使用。这种「把差异当参数传入、把共性写死在驱动里」的设计,本质是依赖注入和面向接口思想。它带来的好处是:换芯片、换板子时,只需修改初始化参数,驱动代码完全不用动。初始化时还会读一次真实 ID 与用户声明对比,实现自检。


三、基础操作:指令码、片选与状态

3.1 指令码定义

如下图所示表格是芯片指令码的定义:

对 W25Qxx 操作,第一步永远是发送一个字节的指令码,告诉芯片"我这次要干什么"。先把用到的指令码集中定义好:

c 复制代码
/* W25Qxx 常用指令码定义 */
#define W25QXX_WRITE_ENABLE      0x06  // 写使能:允许后续写入/擦除
#define W25QXX_WRITE_DISABLE     0x04  // 写禁止:关闭写使能锁存
#define W25QXX_READ_STATUS_REG1  0x05  // 读状态寄存器 1(含 BUSY、WEL)
#define W25QXX_READ_STATUS_REG2  0x35  // 读状态寄存器 2
#define W25QXX_WRITE_STATUS_REG  0x01  // 写状态寄存器
#define W25QXX_PAGE_PROGRAM      0x02  // 页编程(写入,最多一页 256 字节)
#define W25QXX_SECTOR_ERASE      0x20  // 扇区擦除(4KB)
#define W25QXX_BLOCK_ERASE       0xD8  // 块擦除(64KB)
#define W25QXX_CHIP_ERASE        0xC7  // 整片擦除
#define W25QXX_JEDEC_ID          0x9F  // 读 JEDEC ID
#define W25QXX_READ_DATA         0x03  // 读数据
#define W25QXX_FAST_READ         0x0B  // 快速读
#define W25QXX_POWER_DOWN        0xB9  // 掉电模式
#define W25QXX_RELEASE_PDOWN     0xAB  // 退出掉电模式

小技巧:把指令码做成宏,既方便阅读(看到 W25QXX_SECTOR_ERASE 比看到 0x20 直观得多),又方便以后换芯片时集中修改。

3.2 从设备使能:软件片选

操作 SPI 从机前,必须先通过片选选中它。我们用软件片选:把 CS 拉低 = 选中;拉高 = 释放。

c 复制代码
/**
 * @brief 使能 W25Qxx(选中)
 * @note  把片选引脚拉低,芯片被激活,开始响应 SPI
 */
static void BSP_W25Qxx_CS_Enable(void)
{
    HAL_GPIO_WritePin(w25qxx.nss_port, w25qxx.nss_pin, GPIO_PIN_RESET); // 拉低
}

/**
 * @brief 禁用 W25Qxx(释放)
 * @note  把片选引脚拉高,结束本次通信
 */
static void BSP_W25Qxx_CS_Disable(void)
{
    HAL_GPIO_WritePin(w25qxx.nss_port, w25qxx.nss_pin, GPIO_PIN_SET);   // 拉高
}

再次强调:硬件 NSS 模式下 SPI 外设会自动控制片选;软件 NSS 模式下必须由用户手动用 IO 控制,灵活性更高。我们整个驱动都采用软件片选。

3.3 读取芯片 ID

JEDEC ID 是芯片的"身份证",包含制造商、类型、容量信息。读取步骤:

  1. CS 拉低,选中设备;
  2. 发送指令码 0x9F;
  3. 芯片在时钟下降沿移出 3 个字节(制造商 ID 0xEF、类型、容量),MSB 优先;
  4. CS 拉高,释放设备。

具体时序图如下:

c 复制代码
/**
 * @brief 读取 W25Qxx 的 JEDEC ID
 * @return 24 位 ID:高 8 位制造商、中 8 位内存类型、低 8 位容量
 */
uint32_t BSP_W25Qxx_ID(void)
{
    if (!w25qxx_init)          // 未初始化直接返回 0
        return 0;

    uint8_t cmd = W25QXX_JEDEC_ID;  // 读 ID 指令
    uint8_t id[3] = {0};            // 存放 3 字节 ID
    HAL_StatusTypeDef status = HAL_OK;

    BSP_W25Qxx_CS_Enable();         // 1. 选中芯片

    // 2. 发送读 ID 指令
    status = HAL_SPI_Transmit(w25qxx.spi_handler, &cmd, 1, HAL_MAX_DELAY);
    if (status != HAL_OK)
    {
        printf("read id: write cmd(0x%02X) failed, status=%d\r\n", cmd, status);
        goto END;                   // 出错也要记得释放片选
    }

    // 3. 读取 3 字节 ID
    status = HAL_SPI_Receive(w25qxx.spi_handler, id, 3, HAL_MAX_DELAY);
    if (status != HAL_OK)
    {
        memset(id, 0, sizeof(id));
        printf("read id: read id failed, status=%d\r\n", status);
        goto END;
    }

END:
    BSP_W25Qxx_CS_Disable();        // 4. 释放芯片
    // 组合成 24 位返回值
    return (id[0] << 16) | (id[1] << 8) | id[2];
}

W25Q64 的 ID 是 0xEF4017:EF 是华邦(Winbond),40 是类型,17 代表 64Mbit 容量。

3.4 读取状态寄存器:BUSY 和 WEL

在对 Flash 擦除或编程前,必须先查询它的状态。W25Q64 有 3 个状态寄存器,但我们只需关注状态寄存器 1 的两个标志位:

  • BUSY(忙标志,bit0) :只读位。当芯片正在执行页编程、扇区擦除、块擦除、整片擦除等操作时,该位为 1;操作完成后自动清 0。BUSY=1 期间,除了"读状态寄存器"和"暂停"指令,芯片会忽略其他指令。

  • WEL(写使能锁存,bit1) :只读位。执行"写使能"指令后置 1;执行写禁止、页编程、擦除、写状态寄存器等操作后自动清 0。芯片默认是写禁止(WEL=0) 。

用一句话概括:

WEL 是"入场券" :芯片默认上锁,每次写/擦前必须手动领票(写使能),操作完票据自动作废。

BUSY 是"进度条":芯片擦写要花时间(物理充放电),BUSY 就像"施工中"的牌子,告诉主控先等等。

具体时序图如下:

读取状态寄存器指令码是 0x05:

c 复制代码
#define W25QXX_READ_STATUS_REG1 0x05

/**
 * @brief 读取 W25Qxx 状态寄存器 1 的值
 * @return 状态值;未初始化或通信失败返回 0xFF
 */
uint8_t BSP_W25Qxx_Status1_Reg(void)
{
    if (!w25qxx_init)
        return 0xFF;

    uint8_t cmd = W25QXX_READ_STATUS_REG1;
    uint8_t status = 0xFF;
    HAL_StatusTypeDef state = HAL_OK;

    BSP_W25Qxx_CS_Enable();                                  // 选中
    state = HAL_SPI_Transmit(w25qxx.spi_handler, &cmd, 1, HAL_MAX_DELAY); // 发指令
    if (state != HAL_OK)
    {
        printf("HAL_SPI_Transmit cmd 0x%02x error, state is: %d\n", cmd, state);
        goto END;
    }
    state = HAL_SPI_Receive(w25qxx.spi_handler, &status, 1, HAL_MAX_DELAY); // 读状态
    if (state != HAL_OK)
    {
        status = 0xFF;
        printf("HAL_SPI_Receive cmd 0x%02x error, state is: %d\n", cmd, state);
        goto END;
    }
END:
    BSP_W25Qxx_CS_Disable();   // 释放
    return status;
}

一个细节 :状态寄存器只有 8 位,为什么读的时候如果时钟不停会一直返回?因为芯片支持连续读------只要你不停时钟,它就把这 8 位数据反复输出。这主要是为了方便主控轮询 :不用反复发 0x05,只要 CS 拉低一直读,直到 BUSY 变 0 为止。但我们只读 8 位、只关注 2 位就够了。

3.5 写使能 / 写禁止

W25Q64 默认写保护,每次页编程、扇区擦除、块擦除、整片擦除前,都必须先"写使能"(WEL=1)。

  • 写使能指令码:0x06
  • 写禁止指令码:0x04

具体时序图如下:

c 复制代码
#define W25QXX_WRITE_ENABLE  0x06
#define W25QXX_WRITE_DISABLE 0x04

/**
 * @brief W25Qxx 写使能:置位 WEL,允许后续编程/擦除
 */
void BSP_W25Qxx_Write_Enable(void)
{
    if (!w25qxx_init)
        return;

    uint8_t cmd = W25QXX_WRITE_ENABLE;

    BSP_W25Qxx_CS_Enable();                                  // 选中
    HAL_StatusTypeDef state = HAL_SPI_Transmit(w25qxx.spi_handler, &cmd, 1, HAL_MAX_DELAY);
    if (state != HAL_OK)
    {
        printf("HAL_SPI_Transmit cmd 0x%02x error, state is: %d\n", cmd, state);
    }
    BSP_W25Qxx_CS_Disable();                                 // 释放
}

/**
 * @brief W25Qxx 写禁止:清零 WEL,手动上锁
 */
void Bsp_W25Qxx_Write_Disable(void)
{
    if (!w25qxx_init)
        return;

    uint8_t cmd = W25QXX_WRITE_DISABLE;

    BSP_W25Qxx_CS_Enable();                                  // 选中
    HAL_StatusTypeDef state = HAL_SPI_Transmit(w25qxx.spi_handler, &cmd, 1, HAL_MAX_DELAY);
    if (state != HAL_OK)
    {
        printf("HAL_SPI_Transmit cmd 0x%02x error, state is: %d\n", cmd, state);
    }
    BSP_W25Qxx_CS_Disable();                                 // 释放
}

关于写禁止的两个细节:

  • 自动锁定(最常用) :实际编程中很少手动发 0x04,因为芯片每完成一次写入/擦除都会自动写禁止,防止误操作;
  • 上电保护:芯片刚上电时默认就是写禁止状态,保证电源不稳时不会破坏数据。

3.6 等待写完成

擦除/编程需要芯片花时间(几毫秒到几十秒不等)。在继续下一步前,必须等待 BUSY=0。

c 复制代码
/* 状态寄存器位定义 */
#define W25QXX_STATUS_BUSY  0x01  // 忙标志位
#define W25QXX_STATUS_WR_EN 0x02  // 写使能标志位

/**
 * @brief 等待 W25Qxx 写操作完成
 * @note  本质就是一直读状态寄存器,直到 BUSY 位变 0
 */
void BSP_W25Qxx_Waitfor_Write_Complete(void)
{
    if (!w25qxx_init)
        return;
    // 只要 BUSY 位还是 1,就继续读
    while (BSP_W25Qxx_Status1_Reg() & W25QXX_STATUS_BUSY);
}

也可以写成"发一次指令,然后连续读"的高效版本:

c 复制代码
void BSP_W25Qxx_Waitfor_Write_Complete(void)
{
    if (!w25qxx_init) return;

    uint8_t cmd = W25QXX_READ_STATUS_REG1;
    uint8_t status = 0xFF;

    BSP_W25Qxx_CS_Enable();                                  // 选中
    HAL_SPI_Transmit(w25qxx.spi_handler, &cmd, 1, HAL_MAX_DELAY); // 发一次读状态指令

    // 循环读:发 0xFF 只是为了产生时钟,让 Flash 吐出下一个字节
    do 
    {
        HAL_SPI_TransmitReceive(w25qxx.spi_handler, (uint8_t[]){0xFF}, &status, 1, HAL_MAX_DELAY);
    } while (status & W25QXX_STATUS_BUSY);                   // 一直读到 BUSY=0

    BSP_W25Qxx_CS_Disable();                                 // 释放
}

再补充两个查询辅助函数:

c 复制代码
bool Bsp_W25Qxx_Is_Busy(void)
{
    if (!w25qxx_init)
        return false;
    return BSP_W25Qxx_Status1_Reg() & W25QXX_STATUS_BUSY; // 忙返回 true
}

bool Bsp_W25Qxx_Is_Writeable(void)
{
    if (!w25qxx_init)
        return false;
    return BSP_W25Qxx_Status1_Reg() & W25QXX_STATUS_WR_EN; // WEL=1 返回 true
}

思考:为什么读状态、读数据不需要等 BUSY?因为读是实时的,发完就返回。如果读数据也要等 BUSY,那"等 BUSY"本身又依赖读状态,就陷入"鸡生蛋蛋生鸡"的死循环了。

知识点总结 ③

W25Qxx 指令码、软件片选与状态寄存器:

W25Qxx 的每次操作都从发送一个字节的指令码开始,如写使能 0x06、读状态 0x05、页编程 0x02、扇区擦除 0x20、整片擦除 0xC7、读 JEDEC ID 0x9F、读数据 0x03。工程上把指令码定义成宏。片选采用软件 NSS:拉低 PE6 选中、拉高释放,每次操作「选中→收发→释放」成对出现。状态寄存器 1 中两个关键只读位:BUSY(bit0)表示芯片是否正在擦写,操作完成后自动清 0;WEL(bit1)是写使能锁存位,执行写使能置 1、执行写/擦或写禁止后自动清 0,芯片默认 WEL=0。开发逻辑:写入前先写使能(WEL=1),写入后循环读状态等待 BUSY=0。


四、擦除操作

4.1 扇区擦除

W25Q64 中:擦除的最小单位是扇区(4KB) ,写入的最大单位是页(256 字节)。写之前必须先擦除(因为 Flash 只能把 1 写成 0,擦除会把整块变成 0xFF)。

扇区擦除指令码:0x20,后面跟 24 位地址(A23~A0)。

擦除流程的关键细节:

  • 芯片内部的擦除逻辑是在 CS 上升沿触发的,也就是说:下发擦除指令后不会立即开始,必须取消片选(拉高 CS),擦除才开始;
  • 虽然要求输入 24 位地址,但扇区擦除会自动忽略低 12 位(因为 4KB=2¹²),所以传入地址最好是 4096 的整数倍;
  • 擦除完成后 WEL 自动变 0。要连续擦除两个扇区,必须遵循:写使能 → 擦除 1 → 等 BUSY → 写使能 → 擦除 2 → 等 BUSY。

具体时序图如下:

c 复制代码
#define W25QXX_SECTOR_ERASE 0x20

/**
 * @brief 擦除 W25Qxx 的一个扇区(4KB),擦除后全为 0xFF
 * @param sector_address 扇区起始地址,必须是 4096 的整数倍
 */
void Bsp_W25Qxx_Sector_Erase(uint32_t sector_address)
{
    if (!w25qxx_init)
        return;

    // 地址对齐检查:低 12 位必须为 0
    if (sector_address & 0xFFF)
    {
        printf("Sector_Erase: address 0x%x not sector aligned\n", sector_address);
        return;
    }
    // 地址范围检查:不能超过总容量
    if (sector_address >= w25qxx.mem_size)
    {
        printf("Sector_Erase: address 0x%x out of range\n", sector_address);
        return;
    }

    // 构造擦除命令序列:指令码 + 24 位地址(高位在前)
    uint8_t cmd[4] = {
        W25QXX_SECTOR_ERASE,
        (uint8_t)(sector_address >> 16),
        (uint8_t)(sector_address >> 8),
        (uint8_t)(sector_address)
    };

    // 坑:擦除前必须先写使能,否则擦除不会执行
    Bsp_W25Qxx_Write_Enable();

    BSP_W25Qxx_CS_Enable();                                  // 选中
    // 发送擦除命令(注意:只是"交代任务",不会立即擦除)
    HAL_StatusTypeDef state = HAL_SPI_Transmit(w25qxx.spi_handler, cmd, 4, HAL_MAX_DELAY);
    if (state != HAL_OK)
    {
        printf("Sector_Erase: cmd 0x%02x failed, status = %d\n", cmd[0], state);
        BSP_W25Qxx_CS_Disable();
        return;
    }

    // 拉高片选,擦除在 CS 上升沿正式开始
    BSP_W25Qxx_CS_Disable();

    // 等待擦除完成(BUSY=0)
    BSP_W25Qxx_Waitfor_Write_Complete();
}

格外注意:除非特殊要求,绝不要把擦除写在 while 循环里! Flash 是有寿命的,如果你不小心让它一直擦,芯片很快就坏了。

4.2 整片擦除

整片擦除指令码:0xC7(或 0x60),不需要地址。

具体时序图如下:

c 复制代码
#define W25QXX_CHIP_ERASE 0xC7

/**
 * @brief 擦除整个 W25Qxx 芯片(不可逆,慎用)
 */
void Bsp_W25Qxx_Chip_Erase(void)
{
    if (!w25qxx_init)
        return;

    uint8_t cmd = W25QXX_CHIP_ERASE;

    Bsp_W25Qxx_Write_Enable();   // 先写使能

    BSP_W25Qxx_CS_Enable();                                  // 选中
    HAL_StatusTypeDef state = HAL_SPI_Transmit(w25qxx.spi_handler, &cmd, 1, HAL_MAX_DELAY);
    if (state != HAL_OK)
    {
        printf("Chip_Erase: cmd 0x%02x failed, status = %d\n", cmd, state);
        BSP_W25Qxx_CS_Disable();
        return;
    }
    printf("Chip_Erase: done, status = %d\n", state);

    BSP_W25Qxx_CS_Disable();                                 // 拉高 CS,开始擦除
    BSP_W25Qxx_Waitfor_Write_Complete();                     // 等待完成(时间单位是"秒")
}

整片擦除和扇区擦除流程几乎一样,唯一要注意的是擦除时间单位是秒(S),实测约 20 秒。

知识点总结 ④

W25Qxx 擦除操作(扇区/整片):

W25Q64 的最小擦除单位是扇区(4KB),写入前必须先擦除(Flash 只能将 1 写为 0,擦除把整区变成 0xFF)。扇区擦除指令为 0x20,流程:写使能 → 拉低 CS → 发送 0x20 和 24 位地址 → 拉高 CS(擦除在 CS 上升沿真正开始)→ 轮询等待 BUSY=0。擦除会忽略地址低 12 位,所以传入地址必须 4096 对齐;擦除完成后 WEL 自动清零,连续擦除需每次重新写使能。扇区擦除典型 45ms、最大 400ms。整片擦除指令为 0xC7,流程相同但不需地址,耗时以秒计(约 20 秒),不可逆。切记不要在循环中反复擦除,以免损耗 Flash 寿命。


五、写入与读取

5.1 页编程(写入)

页编程指令码:0x02,后面跟 24 位地址 + 数据(1~256 字节)。

关于页编程,必须知道的关键点:

  1. NOR Flash 可以按字节写入,但单次不能超过一页(256 字节);NAND Flash 必须整页写入。W25Q64 是 NOR Flash。
  2. 地址回卷(Wrap):如果起始地址不是页对齐的(低 8 位不为 0),且写入的数据超过了本页剩余空间,地址会回卷到本页开头,覆盖之前写的数据。
  3. 页编程之所以叫"页"编程,是因为芯片内部地址指针自增被限制在页边界内(低 8 位自增),不会进位到下一页。所以要写超过一页的数据,必须分多次写。

具体时序图如下:

c 复制代码
#define W25QXX_PAGE_PROGRAM 0x02

/**
 * @brief 向 W25Qxx 的指定页写入数据
 * @param page_address      目标页号(第几页)
 * @param page_inner_offset 页内偏移(0~255)
 * @param wbuffer           待写入数据缓冲区
 * @param num_byte          写入字节数(1~256)
 */
void Bsp_W25Qxx_Write_Page(uint32_t page_address, uint16_t page_inner_offset,
                           uint8_t *wbuffer, uint16_t num_byte)
{
    if (!w25qxx_init)
        return;
    if (wbuffer == NULL || num_byte == 0)
        return;
    // 安全检测:页内偏移 + 写入长度 不能超过一页
    if (page_inner_offset + num_byte > w25qxx.page_size)
        return;

    // 计算实际物理地址 = 页号 × 页大小 + 页内偏移
    uint32_t write_address = page_address * w25qxx.page_size + page_inner_offset;

    // 构造命令:指令码 + 24 位地址
    uint8_t cmd[] = {
        W25QXX_PAGE_PROGRAM,
        (uint8_t)(write_address >> 16),
        (uint8_t)(write_address >> 8),
        (uint8_t)(write_address),
    };

    HAL_StatusTypeDef state = HAL_OK;

    Bsp_W25Qxx_Write_Enable();   // 写使能(必须)
    BSP_W25Qxx_CS_Enable();      // 选中

    // 发送命令和地址
    state = HAL_SPI_Transmit(w25qxx.spi_handler, cmd, 4, HAL_MAX_DELAY);
    if (state != HAL_OK)
    {
        printf("Write_Page: cmd failed, status = %d\n", state);
        BSP_W25Qxx_CS_Disable();
        return;
    }
    // 发送数据(CS 必须全程保持低电平)
    state = HAL_SPI_Transmit(w25qxx.spi_handler, wbuffer, num_byte, HAL_MAX_DELAY);
    if (state != HAL_OK)
    {
        printf("Write_Page: data failed, status = %d\n", state);
        BSP_W25Qxx_CS_Disable();
        return;
    }

    BSP_W25Qxx_CS_Disable();              // 拉高 CS,编程开始
    BSP_W25Qxx_Waitfor_Write_Complete();  // 等待完成
}

5.2 读数据

读数据指令码:0x03,后面跟 24 位地址,然后连续读出。

具体时序图如下:

c 复制代码
#define W25QXX_READ_DATA 0x03

/**
 * @brief 从 W25Qxx 指定地址读取数据
 * @param read_address 起始地址
 * @param rbuffer      接收缓冲区
 * @param rsize        读取字节数
 */
void Bsp_W25Qxx_Read_Data(uint32_t read_address, uint8_t *rbuffer, uint32_t rsize)
{
    if (!w25qxx_init)
        return;
    if (rbuffer == NULL || rsize == 0)
        return;
    // 范围检查
    if (read_address + rsize > w25qxx.mem_size)
        return;

    // 如果芯片正忙(比如在擦除),先等它完成
    if (Bsp_W25Qxx_Is_Busy())
        BSP_W25Qxx_Waitfor_Write_Complete();

    // 构造命令:指令码 + 24 位地址
    uint8_t cmd[] = {
        W25QXX_READ_DATA,
        (uint8_t)(read_address >> 16),
        (uint8_t)(read_address >> 8),
        (uint8_t)(read_address)
    };

    HAL_StatusTypeDef state = HAL_OK;

    BSP_W25Qxx_CS_Enable();   // 选中
    // 发送读命令和地址
    state = HAL_SPI_Transmit(w25qxx.spi_handler, cmd, 4, HAL_MAX_DELAY);
    if (state != HAL_OK)
    {
        printf("Read_Data: send cmd failed, status = %d\n", state);
        goto END;
    }
    // 连续接收数据
    state = HAL_SPI_Receive(w25qxx.spi_handler, rbuffer, rsize, HAL_MAX_DELAY);
    if (state != HAL_OK)
    {
        printf("Read_Data: read failed, status = %d\n", state);
        goto END;
    }
END:
    BSP_W25Qxx_CS_Disable();  // 释放
}

读数据的细节:

  • 读取不受页、扇区、块的限制,只要在容量范围内,可以任意长度;
  • 读取是实时的,不用等 BUSY,也没有"操作完成时长"的概念;
  • 读取不需要 WEL 标志。

5.3 跨页写入:封装出"任意地址任意长度"

页编程一次最多写一页,用起来不方便。我们想要的是:在任意起始地址写入任意长度的数据。于是写一个封装函数,内部自动处理分页。

c 复制代码
/**
 * @brief 向 W25Qxx 指定地址写入任意长度数据(自动跨页)
 * @param address 起始地址
 * @param wbuffer 数据缓冲区
 * @param wsize   写入字节数
 */
void Bsp_W25Qxx_Write_Data(uint32_t address, uint8_t *wbuffer, uint32_t wsize)
{
    if (!w25qxx_init)
        return;
    if (wbuffer == NULL || wsize == 0)
    {
        printf("Write_Data: invalid params\n");
        return;
    }
    if (address + wsize >= w25qxx.mem_size)
    {
        printf("Write_Data: out of range\n");
        return;
    }

    uint32_t bytes_written = 0;              // 已写入字节计数
    while (bytes_written < wsize)
    {
        uint32_t remaining_data = wsize - bytes_written;         // 还剩多少没写

        uint16_t page_number = address / w25qxx.page_size;       // 当前页号
        uint16_t page_offset = address % w25qxx.page_size;       // 页内偏移
        uint16_t remaining_space = w25qxx.page_size - page_offset; // 本页剩余空间

        // 本次最多能写:剩余数据 和 本页剩余空间 取较小值,保证绝不跨页
        uint16_t to_writen = remaining_data > remaining_space ? remaining_space : remaining_data;

        // 写入当前页
        Bsp_W25Qxx_Write_Page(page_number, page_offset, &wbuffer[bytes_written], to_writen);

        // 更新已写字节数和地址
        bytes_written += to_writen;
        address += to_writen;
    }
}

核心思路:每次只写"当前页能容纳的、且不超过剩余数据量"的部分,写完更新地址,循环直到全部写完。这就是把"页编程"升级成"通用写入"的关键封装。

5.4 回卷(Wrap)验证实验

为了亲眼看到"地址回卷"现象,我们做一个小实验:

c 复制代码
Bsp_W25Qxx_Sector_Erase(0);   // 先擦除,才能写

// 构造 260 字节数据,前 256 字节全填 0xFF(相当于"什么都没写")
// 后 4 字节填 1、2、3、4
uint8_t wrap_around[260] = {0};
for (uint32_t idx = 0; idx < 256; idx++)
    wrap_around[idx] = 0xFF;
wrap_around[256] = 0x1;
wrap_around[257] = 0x2;
wrap_around[258] = 0x3;
wrap_around[259] = 0x4;

// 一次页写入 260 字节(此时需要临时去掉页编程里的安全检测)
Bsp_W25Qxx_Write_Page(0, 0, wrap_around, 260);

// 读回 256 字节,观察 1、2、3、4 是不是跑到了页开头
uint8_t wrap_around_test[256] = {0};
Bsp_W25Qxx_Read_Data(0, wrap_around_test, 256);

实验现象:写入的 1、2、3、4 会出现在这一页的开头(地址 0~3),而不是页外。这正是"地址回卷"。

提示:做这个实验时,要临时去掉 Bsp_W25Qxx_Write_Page 里的 page_inner_offset + num_byte > page_size 检测,否则会被拦截。

知识点总结 ⑤

W25Qxx 页编程、读数据与跨页写入:

页编程指令 0x02,后跟 24 位地址和 1~256 字节数据,写入前必须先擦除并写使能。NOR Flash 支持按字节写入但单次不超过一页;NAND Flash 必须整页写入。页编程有地址回卷特性:起始地址未页对齐且数据超过本页剩余空间时,地址回卷到本页开头覆盖旧数据,因为芯片内部地址指针只在页边界内(低 8 位)自增。因此要写超过一页的数据必须分多次。读数据指令 0x03,任意地址任意长度、实时、不需写使能,但芯片 BUSY 时会被忽略。为满足「任意地址任意长度写入」,封装跨页写入函数:循环计算页号、页内偏移、本页剩余空间,每次写「剩余数据量与本页剩余空间中的较小值」,保证不跨页,直到写完。


六、功能测试

6.1 测试项

驱动写完后要边写边测。测试项包括:整片擦除、随机写入、随机读取、扇区擦除、页写入、页读取、周期性单字节读取。

c 复制代码
// 全局缓冲区
uint8_t rx_buffer[W25QXX_PAGE_SIZE];
uint8_t tx_buffer[W25QXX_PAGE_SIZE];
uint32_t first_page_address  = 0;         // 第一页地址
uint32_t last_page_address   = 0x7FFF00;  // 最后一页地址(8MB-256)
uint32_t first_sector_address = 0;        // 第一个扇区地址
uint32_t last_sector_address  = 0x7FF000; // 最后一个扇区地址

// 1. 整片擦除,读第一页和最后一页,确认全为 0xFF
Bsp_W25Qxx_Chip_Erase();
Bsp_W25Qxx_Read_Data(first_page_address, rx_buffer, sizeof(rx_buffer));
Bsp_W25Qxx_Read_Data(last_page_address, rx_buffer, sizeof(rx_buffer));

// 2. 随机写:往第一页写 "hello, bit!",最后一页写 "w25q64 flash."
Bsp_W25Qxx_Write_Data(first_page_address, (uint8_t *)"hello, bit!", 12);
Bsp_W25Qxx_Write_Data(last_page_address,  (uint8_t *)"w25q64 flash.", 14);

// 3. 随机读:读回来核对
Bsp_W25Qxx_Read_Data(first_page_address, rx_buffer, 12);
printf("Read First Page: %s\n", (char *)rx_buffer);

// 4. 扇区擦除:擦除后应全为 0xFF
Bsp_W25Qxx_Sector_Erase(first_sector_address);
Bsp_W25Qxx_Read_Data(first_page_address, rx_buffer, sizeof(rx_buffer));

// 5. 页写入:构造 0,1,2,...255 写入一页,再读回逐字节核对
for (uint32_t idx = 0; idx < W25QXX_PAGE_SIZE; idx++)
    tx_buffer[idx] = idx;
Bsp_W25Qxx_Write_Data(first_page_address, tx_buffer, W25QXX_PAGE_SIZE);

6.2 测试现象与结论

测试现象与预期一致,说明驱动正确:

  • 整片擦除后读回全为 0xFF;
  • 随机写入的字符串能原样读回;
  • 扇区擦除后该扇区全为 0xFF;
  • 页写入的 0~255 递增数据能逐字节读回。

知识点总结 ⑥

W25Qxx 驱动测试方法:

驱动开发应边写边测。完整测试应覆盖:整片擦除(读回全 0xFF)、随机写入与读取(验证任意地址读写)、扇区擦除(擦后全 0xFF)、页写入与页读取(构造 0~255 递增数据逐字节核对)、周期性单字节读取。测试时注意:写之前必须先擦除,读回的数据要与写入的一致。测试通过后,说明驱动的擦、写、读、状态检测等全链路正确。


全文总结(一张图看懂整节课)

复制代码
┌─────────────────────────────────────────────────────────────┐
│  一、准备:SPI1 主机 + W25Q64;软件NSS(PE6);PE4常拉高防误选      │
│  二、兼容:flash_t 结构体注入差异参数;初始化读ID自检              │
│  三、基础:指令码宏;CS拉低选中;读ID(0x9F);状态(BUSY/WEL)        │
│             写使能(0x06)/写禁止(0x04);等待BUSY=0              │
│  四、擦除:扇区0x20(4KB,地址需对齐);整片0xC7(以秒计)             │
│  五、读写:页编程0x02(≤256B,会回卷);读数据0x03(任意长度)         │
│             跨页写入 = 循环取"剩余数据/本页空间"较小值            │
│  六、测试:整片擦除/随机读写/扇区擦除/页读写,逐项核对               │
└─────────────────────────────────────────────────────────────┘

学完这一节,你应该能回答:

  1. 为什么 SPI1 上还要把 PE4 拉高?
  2. flash_t 结构体解决了什么问题?
  3. BUSY 和 WEL 分别代表什么,为什么写/擦前要先写使能?
  4. 为什么扇区擦除传入地址必须是 4096 的整数倍?
  5. 页编程的"地址回卷"是什么,为什么跨页写入要拆成多次?
  6. 擦除和写入为什么必须先擦后写?

相关推荐
夜听莺儿鸣10 小时前
502-002_Linux驱动开发模块化编程
linux·驱动开发·模块化编程
周洲083011 小时前
STM32 串口底层深度解析|USART 中断标志、硬件 FIFO,乱码根源底层定位
stm32·单片机·嵌入式硬件
女神下凡15 小时前
芯参谋(29):UFS UMCP 软件设计规范
arm开发·单片机·嵌入式硬件
李日华大战鸡红16 小时前
IC-MU128编码器使用教程
stm32
周洲083016 小时前
STM32片内Flash读写深度详解|掉电参数保存、底层原理
linux·前端·stm32
RISCV_Explorer17 小时前
RISC-V电源管理与低功耗实战——WFI指令、电源域划分与唤醒路径设计
单片机·嵌入式硬件·risc-v
zd84510150017 小时前
支持双向输入信号的开关量电路
单片机·嵌入式硬件
齐落山大勇17 小时前
【裸机程序】IMX 6ULL的中断机制的理解
单片机·嵌入式硬件
wixzjsh18 小时前
i.MX6ULL LCD 驱动开发实战:从引脚到时钟的完整配置
驱动开发