STM32 进阶封神之路(三十三):W25Q64 任意长度写入深度实战 —— 从页限制到工业级通用读写(附完整代码 + 避坑指南)

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)

写入地址刚好是页的起始地址(如0x0000000x000100),无需处理当前页剩余空间:

  • 若长度≤256:直接单页写入;
  • 若长度 > 256:循环写入整页(每次 256 字节),最后写入剩余零头。
场景 2:地址不对齐(Addr ≠ 0)

写入地址在页中间(如0x0000100x0001FF),必须先处理当前页:

  • 第一步:先写满当前页的剩余空间(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 文件系统实现,打造嵌入式存储的完整解决方案!

相关推荐
Hello_Embed4 小时前
嵌入式上位机开发入门(三):TCP 编程 —— Server 端实现
笔记·单片机·网络协议·tcp/ip·嵌入式
wefly20174 小时前
纯前端架构深度解析:jsontop.cn,JSON 格式化与全栈开发效率平台
java·前端·python·架构·正则表达式·json·php
odoo中国6 小时前
Claude Code 架构总览
架构·claude·自动编程·claude cdoe
a东方青6 小时前
Claude Code 架构概览:从启动入口、查询引擎到工具链与远程桥接
架构
ANii_Aini6 小时前
Claude Code源码架构分析(含可以启动的源码本地部署)
架构·agent·claude·claude code
Hello World . .6 小时前
ARM裸机学习6——UART
arm开发·单片机·嵌入式硬件
言之。6 小时前
Claude Code架构与设计原理深度解析(AI编程Agent核心课)
架构·ai编程
Zarek枫煜7 小时前
[特殊字符] C3语言:传承C之高效,突破C之局限
c语言·开发语言·c++·单片机·嵌入式硬件·物联网·算法
Lugas Luo7 小时前
SATA 硬盘识别延时:协议层与内核机制分析
linux·嵌入式硬件