FPGA新手必看:SPI协议下M25P16 Flash芯片擦除与读写全攻略(附时序详解)

最近在调试一个基于FPGA的数据采集板,需要将采集到的波形数据非易失地存储起来,M25P16这颗经典的SPI Flash芯片就成了我的首选。本以为照着数据手册把时序“画”出来就行,结果在实际操作中,从写使能到擦除等待,再到页编程的地址对齐,每一步都藏着细节。如果你也刚开始接触FPGA与Flash的交互,希望这篇融合了我踩坑经验的全流程解析,能帮你绕过那些数据手册里写了但容易被忽略的“暗礁”,真正把芯片用起来。

这篇文章不会照本宣科地复述命令码,而是聚焦于如何用FPGA的硬件思维去理解和实现SPI时序,并完成对M25P16的擦除、写入和读取。我们会从最根本的通信接口聊起,一步步构建状态机,最后讨论几个实际调试中高频出现的问题及其排查思路。

1. 理解基石:SPI协议与M25P16芯片架构

在动手写代码之前,我们必须先建立两个清晰的认知:一是FPGA将要使用的“语言”——SPI协议;二是对话的另一方——M25P16芯片的“记忆结构”。

1.1 SPI模式选择与FPGA实现要点

SPI协议本身很简单,一个主机(Master,这里是FPGA)通过四根线控制一个或多个从机(Slave,这里是Flash)。这四根线是:

  • SCLK (Serial Clock):时钟信号,由主机产生。
  • MOSI (Master Out Slave In):主机输出,从机输入的数据线。
  • MISO (Master In Slave Out):主机输入,从机输出的数据线。
  • CS# (Chip Select):片选信号,低电平有效。

关键在于时钟极性(CPOL)和时钟相位(CPHA)的组合,这决定了数据在时钟的哪个边沿被采样。M25P16支持模式0 (CPOL=0, CPHA=0)模式3 (CPOL=1, CPHA=1)。这两种模式有一个共同核心:数据在时钟上升沿被采样(捕获)。区别在于时钟空闲状态和输出数据变化的边沿。

提示:对于FPGA开发者,我强烈建议从模式0(CPOL=0, CPHA=0)开始。此时钟空闲为低,数据在上升沿采样,下降沿变化,逻辑上更直观,也最常用。

在FPGA端实现SPI Master,本质上是一个产生精确时钟序列的状态机。我们不是简单地用系统时钟去驱动SCLK,而是用系统时钟来生成一个更慢的、用于同步数据传输的SPI时钟(例如12.5MHz)。一个典型的操作周期(比如发送一个8位指令)可以分解为以下几个状态:

// 简化的状态定义示例(Verilog)
localparam S_IDLE     = 4'd0; // 空闲,CS#为高
localparam S_START    = 4'd1; // 启动,拉低CS#,等待tSLCH
localparam S_SEND_CMD = 4'd2; // 发送命令字节
localparam S_SEND_ADDR= 4'd3; // 发送地址字节(3字节)
localparam S_WRITE_DATA=4'd4; // 发送写数据
localparam S_READ_DATA =4'd5; // 读取数据
localparam S_WAIT     = 4'd6; // 等待操作完成(如擦除)
localparam S_STOP     = 4'd7; // 结束,拉高CS#,等待tSHSL

每个发送/接收状态内部,还需要一个计数器来精确控制8个时钟周期,确保在正确的边沿切换MOSI和锁存MISO。

1.2 M25P16的存储地图与关键命令

M25P16容量为16Mbit,也就是2MB。它的内部组织方式是你一切操作的基础:

存储层级数量大小(字节)说明
扇区 (Sector)3265,536擦除的最小单位。一次扇区擦除会将内部所有位变为1(0xFF)。
页 (Page)256 (每扇区)256编程(写入)的最小单位。一次页编程可以写入1到256个字节。
字节 (Byte)256 (每页)1寻址的基本单元。

所有操作都依赖于3字节的地址来定位。地址0x000000指向芯片的第一个字节,地址0x1FFFFF指向最后一个字节(2MB - 1)。

芯片有一组指令集,以下是最核心的几个,务必熟记:

指令名称指令码功能描述关键注意
WREN0x06写使能在执行任何修改存储器的操作(写、擦除)前,必须先发此命令
READ0x03读数据后跟3字节地址,然后持续输出数据。CS#拉高前可连续读。
PP (Page Program)0x02页编程后跟3字节地址,再接要写入的数据。地址必须页对齐
SE (Sector Erase)0xD8扇区擦除后跟目标扇区内任一地址(3字节)。擦除整个扇区。
BE (Bulk Erase)0xC7整片擦除擦除整个芯片。耗时极长(典型值40秒)

2. 时序的魔鬼细节:从理论到FPGA代码

数据手册里的时序图是“法律条文”,而我们的FPGA代码就是“执行方案”。两者必须严丝合缝。

2.1 关键时间参数与状态机设计

除了基本的时钟频率,有三个时间参数直接影响通信的稳定性,必须在状态机中予以体现:

  1. tSLCH (CS# Low to SCK High): 片选CS#拉低后,到第一个SCK上升沿之间的时间。要求>5ns。在FPGA中,拉低CS#后,插入几个系统时钟周期的等待状态即可轻松满足。
  2. tCHSH (SCK High to CS# High): 最后一个SCK上升沿后,到CS#拉高之间的时间。要求>5ns。同样,在发送完最后一位数据后,状态机不要立刻拉高CS#,而是先跳转到一个短暂的等待状态。
  3. tSHSL (CS# High to CS# Low): 一次操作结束后CS#拉高,到下一次操作拉低CS#之间的时间。要求>100ns。这保证了芯片有足够时间处理上一条指令。这是最容易忽略的一点,尤其是连续发送不同指令时(如发完WREN立刻发PP),必须在状态机中确保CS#有足够的高电平时间。

下面是一个发送单字节命令(如WREN)的简化Verilog流程描述:

// 假设系统时钟clk为100MHz,SPI时钟sck_target为12.5MHz
// 这是一个概念性描述,非完整代码
always @(posedge clk) begin
    case(state)
        S_IDLE: begin
            cs_n <= 1'b1;
            sck <= 1'b0;
            if(start) state <= S_START;
        end
        S_START: begin
            cs_n <= 1'b0; // 拉低片选
            bit_cnt <= 0;
            sck_div_cnt <= 0;
            // 等待tSLCH,例如等待4个clk周期(40ns)
            if(wait_cnt == 3) state <= S_SEND_CMD;
        end
        S_SEND_CMD: begin
            // 生成12.5MHz的sck
            if(sck_div_cnt == 3) begin // 每4个clk周期,sck翻转一次
                sck <= ~sck;
                if(sck == 1'b0) begin // 在sck下降沿更新MOSI数据
                    mosi <= cmd_byte[7 - bit_cnt];
                end else begin // 在sck上升沿,位计数器递增
                    bit_cnt <= bit_cnt + 1;
                end
            end
            sck_div_cnt <= sck_div_cnt + 1;
            if(bit_cnt == 8) state <= S_STOP; // 8位发完
        end
        S_STOP: begin
            sck <= 1'b0;
            // 等待tCHSH,例如2个clk周期
            if(wait_cnt == 1) begin
                cs_n <= 1'b1; // 拉高片选
                state <= S_IDLE;
                // 此时进入IDLE,cs_n为高,自然满足tSHSL
            end
        end
    endcase
end

2.2 耗时操作的处理:擦除与编程

对于整片擦除(BE)扇区擦除(SE),发送完指令并拉高CS#后,芯片内部才开始真正的物理擦除过程,这需要毫秒甚至秒级的时间。在此期间,芯片会忽略新的指令。

  • 软件轮询法:发送Read Status Register (RDSR, 0x05)命令,检查状态寄存器的WIP (Write In Progress)位。为1表示忙,为0表示操作完成。FPGA可以周期性地(如每毫秒)发起一次读状态寄存器操作。
  • 固定延时法:根据数据手册的最大值(tBE最大40秒,tSE最大3秒)设置一个保守的定时器。这种方法逻辑简单,但效率低。对于整片擦除,我通常会在FPGA内用一个计数器实现40秒的等待状态。

页编程(PP)的时间短得多(典型值1.4ms),但也需要类似的等待完成机制。强烈推荐使用轮询法,因为它更高效、更可靠。

3. 实战演练:完整的擦除、写入与读取流程

现在,我们把所有模块组合起来,完成一个经典场景:擦除一个扇区 -> 向该扇区的某一页写入数据 -> 回读验证

3.1 步骤分解与状态流

假设我们要操作扇区0(地址范围0x000000 - 0x00FFFF)的第0页(页内偏移0)。

  1. 写使能 (WREN)

    • 拉低CS#。
    • 发送指令码0x06。
    • 拉高CS#。此时芯片的写使能锁存器被置位
  2. 扇区擦除 (SE)

    • 再次拉低CS#。
    • 发送指令码0xD8。
    • 发送3字节地址(例如0x000000,只要是扇区0内任意地址即可)。
    • 拉高CS#。
    • 进入等待状态,轮询状态寄存器直到WIP位为0。
  3. 页编程 (PP)

    • 发送WREN指令(每次写操作前都必须发)。
    • 拉低CS#。
    • 发送指令码0x02。
    • 发送3字节的页起始地址(必须256字节对齐,例如0x000000, 0x000100, ...)。本例用0x000000。
    • 发送要写入的数据字节(最多256个)。如果要写少于256字节,发完后直接拉高CS#。
    • 拉高CS#。
    • 进入等待状态,轮询直到页编程完成。
  4. 读取验证 (READ)

    • 拉低CS#。
    • 发送指令码0x03。
    • 发送3字节起始地址(0x000000)。
    • 连续读取相应数量的字节。时钟由FPGA持续提供,数据在MISO线上每个时钟上升沿输出。
    • 读取完成后,拉高CS#。
    • 将读回数据与写入数据比较。

3.2 FPGA中的高级控制逻辑

在实际的FPGA项目中,我们不会为一次操作写死代码。通常会设计一个SPI Flash控制器模块,它对外提供简单的接口,内部封装了复杂的状态机。

这个控制器的用户接口可能看起来像这样:

module spi_flash_controller (
    input wire clk,
    input wire rst_n,
    // 用户命令接口
    input wire cmd_valid,
    input wire [2:0] cmd_type, // 000:读, 001:写, 010:扇区擦, 011:整擦, 100:写使能
    input wire [23:0] addr,
    input wire [7:0] data_in,
    output reg data_out_valid,
    output reg [7:0] data_out,
    output reg busy,
    output reg done,
    output reg error,
    // SPI物理接口
    output reg cs_n,
    output reg sck,
    output reg mosi,
    input wire miso
);
    // ... 内部包含庞大的状态机,解析cmd_type,执行上述完整流程
endmodule

用户只需要在cmd_valid有效时,给出cmd_typeaddr,控制器就会自动完成包括WREN、等待、轮询在内的所有步骤,并通过done信号通知完成。busy信号告诉用户当前控制器不可用。

4. 调试技巧与常见问题排查

即使完全按照时序图编写代码,第一次成功读写也常常伴随着调试。这里分享几个我遇到过的典型问题。

4.1 问题清单与解决方案

  • 问题:写使能失败,后续擦除/写命令被忽略。

    • 排查:用逻辑分析仪或示波器抓取SPI波形。确认WREN指令的CS#波形是否正确:在发送0x06前后,CS#是否有足够的高电平时间(tSHSL > 100ns)?很多失败是因为连续发送指令时,CS#的“高脉冲”太短。
    • 解决:在状态机中,确保任何两个独立指令之间,CS#都有明确的、超过100ns的高电平阶段。可以专门设置一个S_INTER_CMD_DELAY状态。
  • 问题:页编程后,读回的数据是0xFF或随机值,而非写入值。

    • 排查1地址是否页对齐? 页编程的起始地址必须是256的整数倍(地址低8位为0)。试图向0x000001地址写数据是非法的,操作会被忽略。
    • 排查2:是否在页编程之前发送了WREN?并且WREN和PP指令之间CS#是否被正确拉高又拉低?
    • 排查3:页编程后是否等待了足够时间?是否通过轮询状态寄存器确认WIP位为0后才进行读取?
  • 问题:连续读取时,数据错位或全是0xFF。

    • 排查:重点看READ指令发送完地址后的第一个SCK上升沿。MISO上的数据是在这个上升沿之后才有效的。你的FPGA代码是否在正确的时钟边沿(对于模式0,是SCK上升沿)采样MISO?采样逻辑可能比发送逻辑延迟一个时钟边沿。
    • 解决:确保你的“数据接收逻辑”与“时钟生成逻辑”严格同步。一个可靠的做法是在SCK的上升沿(采样沿)触发一个进程来寄存MISO的值。
  • 问题:整片擦除时间过长,系统像卡死一样。

    • 解决:这是正常的。tBE最大可达40秒。确保你的等待状态机或延时计数器设置正确。在此期间,避免频繁发送其他命令去打扰芯片。最好用状态机实现一个超时保护,或者使用轮询法,在等待期间可以执行其他低优先级任务。

4.2 工具推荐:逻辑分析仪是你的最佳伙伴

对于SPI这类低速串行协议调试,一个支持协议分析功能的逻辑分析仪(即使是廉价版本)价值连城。它能将SCK、MOSI、MISO、CS#的波形直接解码成十六进制的指令和数据,让你一眼就能看出:

  • 指令码是否正确。
  • 地址字节顺序对不对(M25P16是MSB先发)。
  • CS#的时序是否合规。
  • 读数据时,MISO上的数据流是什么。

比起用示波器看波形猜数据,逻辑分析仪能极大提升调试效率。在项目初期,务必把SPI引脚引到FPGA的测试针或空闲IO上,方便抓取信号。

调试SPI Flash就像和一位严格遵守协议的老派绅士对话,你必须把每一条指令的格式、每一个时序的间隔都做到分毫不差。一旦打通了这个流程,你会发现它其实非常稳定可靠。最后一个小建议:在FPGA代码中,为关键的状态转换和错误条件设置一些LED指示灯或寄存器状态,这样当问题发生时,你能快速定位到是在擦除、编程还是读取阶段出了错,而不是面对一片寂静的Flash茫然无措。

Logo

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

更多推荐