
🔥铅笔小新z:个人主页
🎬博客专栏:stm32学习
💫滴水不绝,可穿石;步履不休,能至渊。

目录
一、目标与准备
1.1 本节要做什么
目标一句话:用 STM32 的 SPI1 主机,以阻塞模式,驱动板子上的 W25Q64 SPI Flash,实现读 ID、读状态、写使能、擦除、写数据、读数据等一整套功能,并测试。
要完成这个目标,需要具备三块基础:
- SPI 主机阻塞模式的配置能力(CubeMX 配置 + 生成的代码看得懂);
- SPI 主机阻塞收发函数(
HAL_SPI_Transmit/HAL_SPI_Receive/HAL_SPI_TransmitReceive)会用; - 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 配置要点
配置流程和上一节基本一致,关键点如下:
-
调试接口 :选择
Serial Wire(SWD)。如果误配置或禁止了 JTAG/SWD,烧录后可能再也连不上目标芯片。
-
晶振与时钟 :选择外部晶振
Crystal/Ceramic Resonator,板子是 8MHz HSE,系统主频配到 72MHz。

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

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

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

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

-
工程管理 :勾选
Use MicroLIB(方便 printf 重定向)、配置仿真器、添加新目录头文件搜索路径。
知识点总结 ①
SPI Flash 驱动开发的目标与片选设计:
本节的目标是把 SPI 主机阻塞模式的操作,封装成一套完整的 W25Q64 Flash 驱动,功能包括读 ID、读状态、写使能/禁止、扇区擦除、整片擦除、页编程、跨页写入、等待写完成、读数据。驱动开发本质上是把「芯片手册里的时序和指令」翻译成「一组可复用的函数」。在多从机共享 SPI 总线的场景下,同一时刻只能有一个从机被选中,靠片选 CS/NSS 区分;未选中的从机 CS 必须保持高电平,否则会误选、总线冲突。因此除了控制 W25Q64 的 PE6,还必须把共享总线的 LCD 片选 PE4 显式拉高,保证默认电平确定。核心原则:默认全部拉高,谁要干活就把谁的 CS 拉低。
二、驱动兼容性设计
2.1 用结构体抽走差异参数
我们希望写出来的驱动,不仅能用 W25Q64,还能兼容 W25Qxx 全系列。影响兼容性的因素有三个:
- 不同型号的页大小、扇区大小、块大小、总容量不同;
- 不同板子控制 Flash 的片选 IO 不同;
- 不同板子使用的 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 是芯片的"身份证",包含制造商、类型、容量信息。读取步骤:
- CS 拉低,选中设备;
- 发送指令码
0x9F; - 芯片在时钟下降沿移出 3 个字节(制造商 ID
0xEF、类型、容量),MSB 优先; - 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 字节)。
关于页编程,必须知道的关键点:
- NOR Flash 可以按字节写入,但单次不能超过一页(256 字节);NAND Flash 必须整页写入。W25Q64 是 NOR Flash。
- 地址回卷(Wrap):如果起始地址不是页对齐的(低 8 位不为 0),且写入的数据超过了本页剩余空间,地址会回卷到本页开头,覆盖之前写的数据。
- 页编程之所以叫"页"编程,是因为芯片内部地址指针自增被限制在页边界内(低 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(任意长度) │
│ 跨页写入 = 循环取"剩余数据/本页空间"较小值 │
│ 六、测试:整片擦除/随机读写/扇区擦除/页读写,逐项核对 │
└─────────────────────────────────────────────────────────────┘
学完这一节,你应该能回答:
- 为什么 SPI1 上还要把 PE4 拉高?
flash_t结构体解决了什么问题?- BUSY 和 WEL 分别代表什么,为什么写/擦前要先写使能?
- 为什么扇区擦除传入地址必须是 4096 的整数倍?
- 页编程的"地址回卷"是什么,为什么跨页写入要拆成多次?
- 擦除和写入为什么必须先擦后写?
