STM32 进阶封神之路(三十三):W25Q64 任意长度写入深度实战 ------ 从页限制到工业级通用读写(附完整代码 + 避坑指南)
上一篇我们完成了 STM32 硬件 SPI 驱动 W25Q64 的基础读写与擦除,实现了单页、单扇区的基础操作。但在实际项目中,我们往往需要写入任意长度、任意地址的数据(如数组、结构体、日志文件),而 W25Q64 的页编程限制、擦除规则、写使能要求等物理特性,是实现通用写入的核心障碍。
本文将彻底拆解 W25Q64 任意长度写入的底层原理、核心计算、分支处理、避坑细节,从物理限制分析到完整代码实现,再到数组 / 结构体读写实战与串口验证,全程超详细拆解,帮你彻底掌握 W25Q64 的工业级通用读写方案!
一、W25Q64 写入的物理限制:为什么不能直接写任意长度?
W25Q64 是 NOR Flash,其硬件特性决定了写入操作有严格的物理限制,这是实现任意长度写入必须攻克的核心前提:
1. 页编程限制(Page Program)
W25Q64 的页大小为256 字节,单次页编程指令(0x02)有严格限制:
- 单次写入最多 256 字节,绝对不能跨页(即不能跨越 256 字节的页边界);
- 若写入地址超过当前页尾,数据会回卷覆盖当前页的起始地址,导致数据错乱;
- 页编程只能将数据位从
1改写为0,无法从0改写为1(这是 Flash 的物理特性)。
2. 擦除限制(Erase)
Flash 的物理特性决定了:只有擦除操作能将数据位从0恢复为1,因此写入前必须先擦除:
- 擦除最小单位是4KB 扇区(Sector),部分型号支持 32KB/64KB 块擦除,不能按字节擦除;
- 频繁擦除同扇区会缩短 Flash 寿命(W25Q64 寿命为 10 万次擦写);
- 擦除耗时较长(扇区擦除约 60~200ms,全片擦除约 10 秒),擦除后扇区数据全为
0xFF。
3. 写使能限制(Write Enable)
W25Q64 的写 / 擦除操作必须先发送写使能指令(0x06,WREN) ,且写使能仅对下一条指令有效:
- 每一次页编程、扇区擦除前,都必须重新发送 WREN 指令,不能只发一次;
- 若未发送 WREN 直接执行写 / 擦除,指令会被忽略,写入无效。
4. 忙等待限制(Busy Wait)
W25Q64 执行写 / 擦除操作时,会进入 BUSY 状态(状态寄存器 Bit0 为 1):
- 必须轮询状态寄存器(指令 0x05,RDSR),直到 BUSY 位清零(WIP=0),才能执行下一条指令;
- 若在 BUSY 状态下发送新指令,指令会被忽略,导致操作失败。
5. 地址格式限制
W25Q64 的地址为24 位 ,发送时必须按高字节→中字节→低字节的顺序发送,不能颠倒:
- 地址范围:
0x000000~0x7FFFFF(8MB 容量); - 若地址发送顺序错误,会写入错误地址,导致数据错乱。
二、任意长度写入的核心思路:如何突破页限制?
要实现任意长度、任意地址的写入,核心是将长数据拆分为符合页限制的分段,按顺序写入,同时处理地址对齐、跨页、擦除等问题,核心思路如下:
1. 核心逻辑总览

2. 关键计算:拆分数据的核心公式
要正确拆分数据,必须先计算 4 个核心参数,这是任意长度写入的数学基础:
c
运行
// 页大小定义(W25Q64固定256字节/页)
#define sFLASH_SPI_PAGESIZE 256
// 1. 计算写入地址在当前页内的偏移(0~255)
Addr = WriteAddr % sFLASH_SPI_PAGESIZE;
// 2. 计算当前页剩余可写字节数(从偏移到页尾)
count = sFLASH_SPI_PAGESIZE - Addr;
// 3. 计算总长度可写满的整页数(向下取整)
NumOfPage = NumByteToWrite / sFLASH_SPI_PAGESIZE;
// 4. 计算写完整页后剩余的零头字节数
NumOfSingle = NumByteToWrite % sFLASH_SPI_PAGESIZE;
参数说明:
Addr:写入地址相对于页起始的偏移,用于判断是否跨页、是否页对齐;count:当前页剩余空间,用于处理地址不对齐时的首段写入;NumOfPage:完整的 256 字节页的数量,用于循环写入整页;NumOfSingle:最后不足一页的字节数,用于写入零头。
3. 分支处理:两种核心场景
根据地址是否页对齐,分为两种场景,分别处理:
场景 1:地址页对齐(Addr = 0)
写入地址刚好是页的起始地址(如0x000000、0x000100),无需处理当前页剩余空间:
- 若长度≤256:直接单页写入;
- 若长度 > 256:循环写入整页(每次 256 字节),最后写入剩余零头。
场景 2:地址不对齐(Addr ≠ 0)
写入地址在页中间(如0x000010、0x0001FF),必须先处理当前页:
- 第一步:先写满当前页的剩余空间(
count字节); - 第二步:循环写入后续整页(每次 256 字节);
- 第三步:最后写入剩余零头(
NumOfSingle字节)。
三、W25Q64 任意长度写入完整代码实现
基于上述思路,我们实现 W25Q64 的通用写入函数,包含基础驱动、核心计算、分支处理、忙等待、写使能等完整逻辑。
1. 基础驱动与宏定义
c
运行
#include "stm32f10x.h"
#include "spi.h"
#include "delay.h"
// W25Q64基础宏定义
#define sFLASH_SPI_PAGESIZE 256 // 页大小256字节
#define sFLASH_SECTOR_SIZE 4096 // 扇区大小4KB
#define sFLASH_DEVICE_SIZE 0x800000// 总容量8MB
// W25Q64指令定义
#define sFLASH_CMD_WREN 0x06 // 写使能
#define sFLASH_CMD_RDSR 0x05 // 读状态寄存器
#define sFLASH_CMD_PAGEPROG 0x02 // 页编程
#define sFLASH_CMD_SECTORERASE 0x20 // 扇区擦除
#define sFLASH_CMD_READDATA 0x03 // 读数据
#define sFLASH_DUMMY_BYTE 0xFF // 虚拟字节
#define sFLASH_WIP_FLAG 0x01 // 忙标志位
// 片选控制宏
#define sFLASH_CS_LOW() GPIO_ResetBits(GPIOA, GPIO_Pin_4)
#define sFLASH_CS_HIGH() GPIO_SetBits(GPIOA, GPIO_Pin_4)
// 测试用结构体定义
struct TestStruct
{
uint8_t data1;
uint16_t data2;
uint32_t data3;
};
2. 基础工具函数(写使能、忙等待、单页写入)
(1)写使能函数
c
运行
/**
* @brief W25Q64写使能(每次写/擦除前必须调用)
* @param None
* @retval None
*/
void sFLASH_WriteEnable(void)
{
sFLASH_CS_LOW(); // 拉低片选
SPI_Send_Byte(sFLASH_CMD_WREN); // 发送写使能指令0x06
sFLASH_CS_HIGH(); // 拉高片选
delay_us(10); // 等待指令生效
}
(2)忙等待函数
c
运行
/**
* @brief 等待W25Q64写操作完成(轮询状态寄存器)
* @param None
* @retval None
*/
void sFLASH_WaitWriteEnd(void)
{
uint8_t flashstatus = 0;
sFLASH_CS_LOW(); // 拉低片选
SPI_Send_Byte(sFLASH_CMD_RDSR); // 发送读状态寄存器指令0x05
// 循环等待WIP位(Bit0)清零
do
{
flashstatus = SPI_Send_Byte(sFLASH_DUMMY_BYTE);
} while((flashstatus & sFLASH_WIP_FLAG) == SET);
sFLASH_CS_HIGH(); // 拉高片选
}
(3)单页写入函数(单次最多 256 字节,不跨页)
c
运行
/**
* @brief W25Q64单页写入(内部调用,不对外暴露)
* @param pBuffer: 数据缓冲区
* @param WriteAddr: 写入地址(必须在同一页内)
* @param NumByteToWrite: 写入长度(≤256)
* @retval None
*/
static void sFLASH_WritePage(uint8_t* pBuffer, uint32_t WriteAddr, uint16_t NumByteToWrite)
{
uint16_t i = 0;
sFLASH_WriteEnable(); // 写使能(必须每次调用)
sFLASH_CS_LOW(); // 拉低片选
// 发送页编程指令0x02
SPI_Send_Byte(sFLASH_CMD_PAGEPROG);
// 发送24位地址(高→中→低)
SPI_Send_Byte((uint8_t)(WriteAddr >> 16));
SPI_Send_Byte((uint8_t)(WriteAddr >> 8));
SPI_Send_Byte((uint8_t)WriteAddr);
// 发送数据
for(i = 0; i < NumByteToWrite; i++)
{
SPI_Send_Byte(pBuffer[i]);
}
sFLASH_CS_HIGH(); // 拉高片选
sFLASH_WaitWriteEnd(); // 等待写完成
}
3. 任意长度写入核心函数
c
运行
/**
* @brief W25Q64任意长度、任意地址写入
* @param pBuffer: 数据缓冲区
* @param WriteAddr: 写入起始地址
* @param NumByteToWrite: 写入长度(任意长度)
* @retval None
*/
void sFLASH_WriteBuffer(uint8_t* pBuffer, uint32_t WriteAddr, uint16_t NumByteToWrite)
{
// ====================== 变量定义 ======================
uint16_t NumOfPage = 0; // 整页数
uint16_t NumOfSingle = 0; // 零头字节数
uint16_t Addr = 0; // 页内偏移
uint16_t count = 0; // 当前页剩余空间
uint16_t temp = 0; // 临时变量
// ====================== 核心计算 ======================
// 1. 计算页内偏移
Addr = WriteAddr % sFLASH_SPI_PAGESIZE;
// 2. 计算当前页剩余空间
count = sFLASH_SPI_PAGESIZE - Addr;
// 3. 计算整页数
NumOfPage = NumByteToWrite / sFLASH_SPI_PAGESIZE;
// 4. 计算零头字节数
NumOfSingle = NumByteToWrite % sFLASH_SPI_PAGESIZE;
// ====================== 分支1:地址页对齐(Addr=0) ======================
if(Addr == 0)
{
// 情况A:长度≤256,直接单页写入
if(NumOfPage == 0)
{
sFLASH_WritePage(pBuffer, WriteAddr, NumByteToWrite);
}
// 情况B:长度>256,循环写入整页 + 零头
else
{
// 循环写入整页
while(NumOfPage--)
{
sFLASH_WritePage(pBuffer, WriteAddr, sFLASH_SPI_PAGESIZE);
WriteAddr += sFLASH_SPI_PAGESIZE;
pBuffer += sFLASH_SPI_PAGESIZE;
}
// 写入最后零头
if(NumOfSingle != 0)
{
sFLASH_WritePage(pBuffer, WriteAddr, NumOfSingle);
}
}
}
// ====================== 分支2:地址不对齐(Addr≠0) ======================
else
{
// 情况A:长度≤当前页剩余空间,直接写入当前页
if(NumByteToWrite <= count)
{
sFLASH_WritePage(pBuffer, WriteAddr, NumByteToWrite);
}
// 情况B:长度>当前页剩余空间,分三段写入
else
{
// 第一段:写满当前页剩余空间
NumByteToWrite -= count;
sFLASH_WritePage(pBuffer, WriteAddr, count);
WriteAddr += count;
pBuffer += count;
// 第二段:循环写入整页
NumOfPage = NumByteToWrite / sFLASH_SPI_PAGESIZE;
while(NumOfPage--)
{
sFLASH_WritePage(pBuffer, WriteAddr, sFLASH_SPI_PAGESIZE);
WriteAddr += sFLASH_SPI_PAGESIZE;
pBuffer += sFLASH_SPI_PAGESIZE;
}
// 第三段:写入最后零头
NumOfSingle = NumByteToWrite % sFLASH_SPI_PAGESIZE;
if(NumOfSingle != 0)
{
sFLASH_WritePage(pBuffer, WriteAddr, NumOfSingle);
}
}
}
}
4. 通用读取函数(任意长度、任意地址)
c
运行
/**
* @brief W25Q64任意长度、任意地址读取
* @param pBuffer: 数据缓冲区
* @param ReadAddr: 读取起始地址
* @param NumByteToRead: 读取长度
* @retval None
*/
void sFLASH_ReadBuffer(uint8_t* pBuffer, uint32_t ReadAddr, uint16_t NumByteToRead)
{
uint16_t i = 0;
sFLASH_CS_LOW(); // 拉低片选
SPI_Send_Byte(sFLASH_CMD_READDATA); // 发送读数据指令0x03
// 发送24位地址(高→中→低)
SPI_Send_Byte((uint8_t)(ReadAddr >> 16));
SPI_Send_Byte((uint8_t)(ReadAddr >> 8));
SPI_Send_Byte((uint8_t)ReadAddr);
// 读取数据
for(i = 0; i < NumByteToRead; i++)
{
pBuffer[i] = SPI_Send_Byte(sFLASH_DUMMY_BYTE);
}
sFLASH_CS_HIGH(); // 拉高片选
}
5. 扇区擦除函数
c
运行
/**
* @brief W25Q64扇区擦除(4KB)
* @param SectorAddr: 扇区地址(必须是4KB对齐)
* @retval None
*/
void sFLASH_EraseSector(uint32_t SectorAddr)
{
sFLASH_WriteEnable(); // 写使能
sFLASH_CS_LOW(); // 拉低片选
// 发送扇区擦除指令0x20
SPI_Send_Byte(sFLASH_CMD_SECTORERASE);
// 发送24位地址
SPI_Send_Byte((uint8_t)(SectorAddr >> 16));
SPI_Send_Byte((uint8_t)(SectorAddr >> 8));
SPI_Send_Byte((uint8_t)SectorAddr);
sFLASH_CS_HIGH(); // 拉高片选
sFLASH_WaitWriteEnd(); // 等待擦除完成
}
四、实战验证:数组 + 结构体读写与掉电不丢失测试
基于上述驱动,我们实现按键控制的数组 / 结构体读写测试,验证任意长度写入的正确性与掉电不丢失特性。
1. 测试代码实现
c
运行
#include "stm32f10x.h"
#include "usart.h"
#include "w25q64.h"
#include "key.h"
#include "delay.h"
// 测试数组
uint8_t w25q64_buff[7] = {"123456"};
uint8_t read_buff[7] = {0};
// 测试结构体
struct TestStruct write_struct = {0x11, 0x2222, 0x33333333};
struct TestStruct read_struct;
int main(void)
{
uint8_t key = 0;
// 系统初始化
SystemInit();
USART1_Init(115200);
KEY_Init();
sFLASH_Init();
printf("W25Q64任意长度写入测试系统\r\n");
printf("=======================================\r\n\r\n");
printf("按键1:写入数组\r\n");
printf("按键2:读取数组\r\n");
printf("按键3:擦除扇区+写入结构体\r\n");
printf("按键4:读取结构体+对比\r\n\r\n");
while(1)
{
key = KEY_Scan(0);
switch(key)
{
// 按键1:写入数组(地址0x000000,长度7字节)
case 1:
printf("开始写入数组\r\n");
sFLASH_WriteBuffer(w25q64_buff, 0x000000, sizeof(w25q64_buff));
printf("结束写入数组\r\n\r\n");
break;
// 按键2:读取数组
case 2:
printf("开始读取数组\r\n");
sFLASH_ReadBuffer(read_buff, 0x000000, sizeof(read_buff));
printf("结束读取数组,内容是%s\r\n\r\n", read_buff);
break;
// 按键3:擦除扇区+写入结构体(地址0x1000,独立扇区避免覆盖数组)
case 3:
printf("开始擦除扇区\r\n");
sFLASH_EraseSector(0x1000); // 结构体用独立扇区,避免覆盖数组
printf("结束擦除扇区\r\n");
printf("开始写入结构体数据\r\n");
sFLASH_WriteBuffer((uint8_t*)&write_struct, 0x1000, sizeof(write_struct));
printf("结束写入结构体数据\r\n\r\n");
break;
// 按键4:读取结构体+对比
case 4:
printf("开始读取结构体数据\r\n");
sFLASH_ReadBuffer((uint8_t*)&read_struct, 0x1000, sizeof(read_struct));
printf("结束读取结构体数据\r\n");
// 打印结构体数据
printf("结构体数据:data1=%02x, data2=%04x, data3=%08x\r\n",
read_struct.data1, read_struct.data2, read_struct.data3);
// 对比一致性
if(memcmp(&write_struct, &read_struct, sizeof(write_struct)) == 0)
{
printf("结构体数据一致!掉电不丢失!\r\n\r\n");
}
else
{
printf("结构体数据不一致!\r\n\r\n");
}
break;
default:
break;
}
delay_ms(10);
}
}
2. 串口测试效果
plaintext
W25Q64任意长度写入测试系统
=======================================
按键1:写入数组
按键2:读取数组
按键3:擦除扇区+写入结构体
按键4:读取结构体+对比
[17:01:44.706]收◆开始擦除扇区
[17:01:44.765]收◆结束擦除扇区
开始写入结构体数据
结束写入结构体数据
[17:01:45.580]收◆开始读取结构体数据
结束读取结构体数据
结构体数据:data1=11, data2=2222, data3=33333333
结构体数据一致!掉电不丢失!
[17:01:50.832]收◆开始读取数据
结束读取数据,内容是123456
五、必须注意的细节:最容易踩的坑(避坑指南)
1. 擦除相关坑
- 必须先擦除再写入:Flash 只能写 0,若未擦除直接写入,原数据为 0 的位无法改写为 1,导致数据错乱;
- 擦除单位限制:最小擦除单位是 4KB 扇区,不能按字节擦除,写入前需擦除对应扇区;
- 擦除耗时:扇区擦除需 60~200ms,必须等待 BUSY 清零,不能立即写入;
- 寿命限制:频繁擦除同扇区会缩短寿命,建议将频繁修改的数据与静态数据分扇区存储。
2. 页写入相关坑
- 禁止跨页:单次页编程绝对不能跨 256 字节边界,否则数据回卷覆盖;
- 写使能必须每次调用:每一次页编程、擦除前都要重新发送 WREN,不能只发一次;
- 忙等待必须加:每次写 / 擦除后必须轮询状态寄存器,直到 BUSY 清零;
- 地址格式正确:24 位地址必须按高→中→低顺序发送,不能颠倒。
3. 数据与地址管理坑
- 地址隔离:不同数据(数组、结构体、日志)必须用不同扇区,避免互相覆盖;
- 长度计算 :用
sizeof()计算结构体 / 数组长度,不要用strlen()(结构体非字符串); - 数据校验:写入后读取对比,或添加 CRC 校验,确保数据一致性;
- 地址对齐:结构体写入建议地址 4KB 对齐,避免跨扇区。
4. 硬件与时序坑
- SPI 时序正确:CS 拉低→发命令→发地址→发数据→CS 拉高,顺序不能乱;
- 时钟速率:写入时建议≤50MHz,高速易出错;
- 电源稳定:3.3V 供电,VCC 加 0.1μF 去耦电容,避免电源干扰;
- 上拉电阻:SPI 引脚加 4.7KΩ 上拉电阻,避免总线电平不稳定。
六、ILI9341 8080 并口驱动补充(扩展知识点)
1. ILI9341 与 8080 的关系
ILI9341 是 LCD 驱动芯片,8080 是它支持的一种并行通信接口标准:
- 8080 接口是并行通信协议,由数据口(8/16bit)、WR(写信号)、RD(读信号)、CS(片选)、DC(数据 / 命令选择)组成;
- 8080 接口速度快、接线多,是 MCU 常用的并行屏接口;
- ILI9341 同时支持 SPI 接口(6~8 根线)和 8080 并口(15~20 根线)。
2. 8080 接口判断方法
- 引脚数量 + 名称:8080 接口引脚多(15~20 根),SPI 接口引脚少(6~8 根);
- 模块丝印:8BIT/16BIT/8080 表示 8080 并口,SPI 表示 SPI 接口;
- 原理图 / 驱动代码:有 WR、RD、DB0~DB7 引脚为 8080,只有 SDA、SCL 为 SPI。
3. 8080 模拟驱动核心函数
c
运行
// 写数据
void LCD_WR_DATA(u16 data)
{
LCD_CS_LOW(); // 拉低片选
LCD_DATA(); // DC拉高,写数据
LCD_R_DATA_DISABLE(); // 读时钟失能
DATAOUT(data); // 放置数据
LCD_W_DATA_ENABLE(); // 写使能(制造上升沿)
LCD_W_DATA_DISABLE(); // 写使能完成
LCD_CS_HIGH(); // 拉高片选
}
// 写命令
void LCD_WR_REG(u16 command)
{
LCD_CS_LOW(); // 拉低片选
LCD_COMMAND(); // DC拉低,写命令
LCD_R_DATA_DISABLE(); // 读时钟失能
DATAOUT(command); // 放置命令数据
LCD_W_DATA_ENABLE(); // 写使能
LCD_W_DATA_DISABLE(); // 写使能完成
LCD_CS_HIGH(); // 拉高片选
}
七、总结:W25Q64 任意长度写入核心要点
1. 核心要点回顾
- 物理限制:页大小 256 字节、只能写 0、必须擦除、写使能、忙等待;
- 核心计算:页内偏移、剩余空间、整页数、零头字节;
- 分支处理:地址对齐 / 不对齐两种场景,分段写入;
- 避坑关键:写使能每次调用、禁止跨页、擦除前置、忙等待;
- 实战验证:数组 / 结构体读写、掉电不丢失、数据校验。
2. 进阶学习方向
- FATFS 文件系统:基于 W25Q64 实现文件系统,支持文件读写;
- DMA 传输:SPI+DMA 实现高速读写,降低 CPU 占用;
- 坏块管理:添加 Flash 坏块检测与管理,提升可靠性;
- 低功耗优化:W25Q64 掉电模式,结合 STM32 低功耗延长续航;
- 多扇区管理:实现日志、配置、固件的分扇区存储与管理。
掌握 W25Q64 任意长度写入后,你已具备嵌入式 Flash 存储的工业级开发能力,可应用于数据存储、固件升级、日志记录等场景。下一篇我们将学习基于 W25Q64 的 FATFS 文件系统实现,打造嵌入式存储的完整解决方案!