1. 从零开始,认识你的W25Qxx FLASH芯片

大家好,我是老张,一个在嵌入式硬件圈子里摸爬滚打了十多年的工程师。今天咱们不聊那些虚头巴脑的理论,就实实在在地聊聊我们项目里几乎“人手一片”的W25Qxx系列FLASH芯片。你可能在玩树莓派、STM32,或者任何一款单片机时都见过它,W25Q16、W25Q32、W25Q64这些型号就像老朋友一样熟悉。但你真的了解它肚子里的“货”是怎么摆的吗?读写它的时候,又有哪些“坑”在等着我们?这篇文章,我就把我这些年用W25Qxx芯片踩过的坑、总结的经验,掰开了揉碎了讲给你听,目标是让你看完就能上手,彻底搞懂它的存储结构和读写门道。

首先,我们得明确一点,W25Qxx系列芯片是一种串行外设接口(SPI)的NOR FLASH。说人话就是,它通过几根线(时钟、数据入、数据出、片选)和你的主控芯片(比如单片机)通信,用来存储程序代码、配置文件、用户数据等等。它的最大优点就是便宜、容量选择多、接口简单,而且数据掉电不丢失。但如果你以为对它发个读写的命令就万事大吉,那可能就会在项目后期遇到各种奇怪的问题,比如数据写入失败、擦除时间过长影响系统实时性等等。所以,深入理解它的内部结构,是稳定使用它的第一步。

2. 庖丁解牛:彻底搞懂存储结构(块、扇区、页、字节)

很多新手朋友拿到芯片手册,看到里面又是块(Block),又是扇区(Sector),还有页(Page),头都大了。它们之间到底是个什么关系?我该按哪个单位来操作?别急,我们用一个最形象的比喻来理解:你可以把整个FLASH芯片想象成一本很厚的书。

  • 字节(Byte):这本书里的每一个字。这是最小的存储单元。
  • 页(Page):这本书里的一行字。在W25Qxx系列里,一行是固定的256个“字”,也就是256字节。这是编程(写入)操作的最小单位。什么意思?就是说,你一次写入的数据,可以少于256字节,但芯片内部实际上是以页为管理基础进行操作的。
  • 扇区(Sector):由连续的16行(页) 组成的一个“自然段”。所以一个扇区的大小是 16页 * 256字节 = 4096字节 = 4KB。这是擦除操作的最小单位之一。你想修改这个自然段里的任何一个字,都必须把整个自然段擦掉重写。
  • 块(Block):由连续的16个自然段(扇区) 组成的一个“章节”。所以一个块的大小是 16扇区 * 4KB = 65536字节 = 64KB。这是另一个更常见的擦除单位,擦除一个块比逐个擦除16个扇区更快。

它们的关系总结成一张表,一目了然:

存储单元包含关系容量关键操作
字节 (Byte)最小单元1 Byte读写的基本单位
页 (Page)1页 = 256字节256 Byte写入的最小单位
扇区 (Sector)1扇区 = 16页4 KB (4096 Byte)擦除的最小单位之一
块 (Block)1块 = 16扇区64 KB (65536 Byte)擦除的常用单位

这里有一个非常重要的核心原则,也是新手最容易栽跟头的地方:FLASH存储器在写入新数据前,必须先对目标区域进行擦除,而且擦除操作只能以扇区或块为单位进行,擦除后的状态是全FF(即所有位为1)。写入操作则是将相应的位从1变为0。 这意味着,你不能像操作RAM那样随意覆盖某个字节。如果你想修改某个扇区里的一个数据,你必须先把整个扇区(4KB)的数据读出来,在内存(比如单片机的RAM)里修改好,然后擦除整个扇区,最后再把整个扇区的新数据写回去。理解这一点,是正确进行FLASH读写的关键。

2.1 不同容量芯片的结构映射

明白了基本结构,我们来看看不同型号的芯片,这本“书”到底有多厚。所有W25Qxx系列都遵循上述的“页-扇区-块”结构,区别主要在于“块”的数量。

  • W25Q16: 32个块。总容量 = 32块 * 64KB/块 = 2048KB = 2MB。
  • W25Q32: 64个块。总容量 = 64块 * 64KB/块 = 4096KB = 4MB。
  • W25Q64: 128个块。总容量 = 128块 * 64KB/块 = 8192KB = 8MB。
  • W25Q128: 256个块。总容量 = 256块 * 64KB/块 = 16384KB = 16MB。

你会发现,从W25Q16到W25Q128,容量翻倍,块数也翻倍,但每个块的大小固定为64KB。这使得我们的驱动代码在管理不同容量芯片时,逻辑可以非常统一:只需要知道总块数,就能计算出整个地址空间。

2.2 地址空间的“分水岭”:3字节 vs 4字节

事情从W25Q256开始变得有点不一样了。W25Q256有512个块,总容量32MB。W25Q512有1024个块,总容量64MB。容量变大了,寻址空间也需要更大。

  • W25Q16, W25Q32, W25Q64, W25Q128: 它们使用 3字节(24位) 地址。最大能寻址 2^24 = 16,777,216 个字节,正好是16MB。所以W25Q128是能用3字节地址寻址的极限型号。
  • W25Q256, W25Q512: 它们需要 4字节(32位) 地址来覆盖更大的存储空间。

这是驱动开发中一个至关重要的兼容性陷阱! 如果你写好了W25Q64的驱动,直接拿去操作W25Q256,很可能会失败。因为发送读/写/擦除命令时,你跟随命令发送的地址长度从3字节变成了4字节。很多底层SPI发送函数需要相应调整。我早期就犯过这个错,调试了半天才发现地址没发全,芯片根本不响应。解决办法通常是在驱动里做一个芯片容量判断,或者通过读取芯片ID(后面会讲)来动态决定发送的地址字节数。

3. 实战指南:芯片读写操作全流程解析

理论说再多,不如一行代码。接下来,我们进入实战环节,看看如何跟W25Qxx芯片“对话”。整个通信基于SPI总线,你需要先确保你的单片机SPI外设初始化正确,时钟极性(CPOL)和相位(CPHA)通常设置为模式0或模式3(具体以芯片手册为准),并且片选(CS)引脚由软件控制。

3.1 第一步:识别你的芯片——读取ID

在开始读写之前,确认你手头的芯片型号是个好习惯。每个W25Qxx芯片都有一个唯一的制造商ID和设备ID。

// 假设已有 SPI 发送/接收单字节的函数:spi_transfer()
#define W25Q_CMD_READ_ID 0x9F // 读取JEDEC ID的命令

void w25q_read_id(uint8_t *manufacturer_id, uint8_t *device_id) {
    uint8_t id_buffer[3];
    // 拉低片选,开始通信
    W25Q_CS_LOW();

    // 发送读ID命令
    spi_transfer(W25Q_CMD_READ_ID);
    // 连续接收3个字节
    id_buffer[0] = spi_transfer(0xFF); // 制造商ID,华邦是0xEF
    id_buffer[1] = spi_transfer(0xFF); // 存储器类型,如0x40
    id_buffer[2] = spi_transfer(0xFF); // 容量ID,决定具体型号

    // 拉高片选,结束通信
    W25Q_CS_HIGH();

    *manufacturer_id = id_buffer[0];
    *device_id = (id_buffer[1] << 8) | id_buffer[2]; // 通常将后两个字节合并为设备ID
}

收到的设备ID(后两个字节)可以用来判断具体型号:

  • 0x4014 对应 W25Q16
  • 0x4015 对应 W25Q32
  • 0x4016 对应 W25Q64
  • 0x4017 对应 W25Q128
  • 0x4018 对应 W25Q256
  • 0x4019 对应 W25Q512

拿到这个ID,你就能在程序里动态配置地址长度(3字节或4字节)以及其他参数了。

3.2 第二步:擦除——为写入做准备

如前所述,写入前必须擦除。擦除有三种粒度:扇区擦除(4KB)、块擦除(64KB)和整片擦除。整片擦除耗时非常长(可能几秒到十几秒),会阻塞整个系统,在实际项目中慎用。 最常用的是扇区擦除和块擦除。

擦除操作的关键步骤:

  1. 写使能(Write Enable): 发送命令 0x06。芯片在执行任何修改存储单元内容的操作(编程、擦除)前,必须被“使能”。
  2. 等待就绪: 发送读状态寄存器1的命令 0x05,检查BUSY位(bit0)。只有BUSY位为0(空闲)时,才能进行下一步。这是一个必须的等待过程!
  3. 发送擦除命令和地址:
    • 扇区擦除命令:0x20 (4KB Sector Erase)
    • 块擦除命令:0xD8 (64KB Block Erase)
    • 后面紧跟3字节或4字节的起始地址(地址必须对齐到扇区或块的边界)。
  4. 再次等待就绪: 擦除操作开始后,BUSY位会变为1。你必须持续查询状态寄存器,直到BUSY位变回0,才算擦除完成。
// 扇区擦除示例(假设为3字节地址芯片)
void w25q_erase_sector(uint32_t sector_addr) {
    // 确保地址是4KB对齐的
    sector_addr &= 0xFFFFF000;

    // 1. 写使能
    w25q_write_enable();

    // 2. 等待芯片空闲
    w25q_wait_busy();

    // 3. 发送扇区擦除命令和地址
    W25Q_CS_LOW();
    spi_transfer(0x20); // 扇区擦除命令
    spi_transfer((sector_addr >> 16) & 0xFF); // 地址高字节
    spi_transfer((sector_addr >> 8) & 0xFF);  // 地址中字节
    spi_transfer(sector_addr & 0xFF);         // 地址低字节
    W25Q_CS_HIGH();

    // 4. 等待擦除操作完成
    w25q_wait_busy();
}

3.3 第三步:页编程(写入)——精细操作

擦除干净后(全是0xFF),我们就可以写入了。写入操作称为“页编程”(Page Program),一次最多可以写入256字节(一页)。但这里有几个极其重要的细节:

  1. 不能跨页写入: 如果你要写入的数据起始地址不是页的起始地址(即地址的低8位不为0),并且写入长度加上起始偏移超过了256字节,那么超出的部分不会自动写入下一页,而是从当前页的起始地址开始“回绕”覆盖。这会导致数据错误!所以,在驱动里必须处理好页边界。
  2. 写入前必须确保目标区域是已擦除状态(0xFF)。如果试图将0变为1,操作会无效。这就是为什么修改数据必须先擦后写。

一个健壮的页编程函数需要处理页边界:

// 向指定地址写入数据,自动处理页边界
uint8_t w25q_write_page(uint32_t addr, uint8_t *data, uint16_t len) {
    uint16_t page_remain;
    uint16_t write_len;
    uint16_t data_offset = 0;

    if (len == 0) return 0; // 无需写入

    while (len > 0) {
        // 计算当前页剩余空间
        page_remain = 256 - (addr % 256);
        // 本次实际写入长度 = min(剩余长度, 页剩余空间)
        write_len = (len > page_remain) ? page_remain : len;

        // 1. 写使能
        w25q_write_enable();
        w25q_wait_busy();

        // 2. 发送页编程命令和地址
        W25Q_CS_LOW();
        spi_transfer(0x02); // Page Program命令
        // 发送3字节或4字节地址(根据芯片型号)
        spi_transfer((addr >> 16) & 0xFF);
        spi_transfer((addr >> 8) & 0xFF);
        spi_transfer(addr & 0xFF);
        // 3. 发送数据
        for (uint16_t i = 0; i < write_len; i++) {
            spi_transfer(data[data_offset + i]);
        }
        W25Q_CS_HIGH();

        // 4. 等待写入完成
        w25q_wait_busy();

        // 更新地址、数据偏移和剩余长度
        addr += write_len;
        data_offset += write_len;
        len -= write_len;
    }
    return 1; // 写入成功
}

3.4 第四步:读取——最简单的操作

相比写入和擦除,读取操作就简单直接多了,没有前置条件,也不需要等待(除了发送命令和地址时的极短时间)。发送读数据命令0x03,跟上起始地址(3或4字节),然后就可以连续读取数据了。芯片内部地址会自动递增,你可以一直读下去,直到跨过整个存储空间。

// 从指定地址读取数据
void w25q_read_data(uint32_t addr, uint8_t *buffer, uint16_t len) {
    W25Q_CS_LOW();
    spi_transfer(0x03); // Read Data命令
    // 发送地址
    spi_transfer((addr >> 16) & 0xFF);
    spi_transfer((addr >> 8) & 0xFF);
    spi_transfer(addr & 0xFF);
    // 连续读取数据
    for (uint16_t i = 0; i < len; i++) {
        buffer[i] = spi_transfer(0xFF);
    }
    W25Q_CS_HIGH();
}

4. 避坑指南与高级技巧

掌握了基本读写,我们再来聊聊实际项目中那些让人头疼的问题和优化技巧。

4.1 状态寄存器的正确使用

W25Qxx芯片内部有几个状态寄存器(Status Register),最常用的是状态寄存器1(SR1)。除了我们一直用的BUSY位,还有两个关键位:

  • WEL (Write Enable Latch, bit1): 写使能锁存位。发送0x06命令后,此位被置1,允许编程或擦除。操作完成后或断电后,此位自动清零。有些严谨的驱动会在写使能后读取此位进行确认。
  • WPEN (Write Protect Enable, bit7) 和块保护位(BP2, BP1, BP0): 用于写保护。如果你的数据存储区在系统运行中莫名其妙被修改,很可能是写保护没配置好,或者片选信号受到干扰导致误操作。对于关键数据区,合理配置块保护是必要的。

4.2 读写速度优化

SPI的时钟频率直接影响读写速度。W25Qxx系列支持很高的SPI时钟(比如W25Q64JV最高支持133MHz)。在确保信号完整性的前提下,尽量提高SPI时钟。此外,芯片支持双线/四线快速读命令(如0x3B, 0x6B等),可以利用额外的IO线在同一个时钟周期内传输2位或4位数据,大幅提升读取速度,特别适合从FLASH中直接执行代码(XIP)。但注意,写入速度通常受芯片内部编程电路限制,提升有限。

4.3 磨损均衡与坏块管理

虽然NOR FLASH的擦写次数(通常10万次)远高于EEPROM,但对于需要频繁写入数据的应用(如数据日志),仍需考虑磨损均衡。简单的策略是:不要固定在一个扇区反复擦写,而是采用“轮询”的方式使用多个扇区。更复杂的文件系统(如LittleFS, SPIFFS)内置了磨损均衡和坏块管理机制,对于复杂应用,直接移植这些文件系统是更稳妥的选择。

4.4 电源与信号完整性

FLASH芯片对电源波动比较敏感。在电路设计时,电源引脚附近一定要有足够的去耦电容(例如0.1uF和10uF并联)。SPI信号线,尤其是时钟线(CLK),应尽量短,并远离高频噪声源。如果布线较长,可以考虑串联小电阻(如22欧姆)进行阻抗匹配,减少振铃。

最后,分享一个我调试时的小习惯:在驱动初始化后,先不进行业务读写,而是做一个简单的“读写校验测试”。比如,找一个不用的扇区,写入一段固定的测试数据(如0-255的循环),然后立刻读回来比对。这个简单的测试能一次性验证你的SPI底层、片选控制、命令序列、地址计算以及基本的读写功能是否全部正常,能避免很多后期难以定位的软硬件综合问题。把W25Qxx这样的基础器件吃透,你的嵌入式开发功底会扎实很多。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐