4. SPI Flash控制器设计:SPI协议基础、FPGA端SPI Master实现、多页读写与擦除操作

好,咱们进入第四章。这一章我打算聊聊SPI Flash控制器。为什么把它单独拎出来讲?因为固件升级的核心,说白了就是往Flash里写数据、读数据、擦数据。你想想看,如果连Flash都控制不好,那升级方案就是空中楼阁。

我在做第一个交易系统项目时,就吃过这个亏。当时图省事,直接用CPU的GPIO模拟SPI时序去读写Flash,结果速度上不去不说,还经常因为中断被打断导致时序出错。后来我痛定思痛,老老实实用FPGA实现了专用的SPI Master控制器。嗯,从那以后,Flash操作再也没出过问题。

核心要点:FPGA实现SPI Flash控制器,关键是把时序控制做到硬件化、确定性。CPU只负责下发命令和检查状态,具体的字节搬运、页边界处理、擦除等待,全部交给FPGA的状态机去干。

4.1 SPI协议基础——先别急着写代码

SPI协议其实很简单。四根线:SCK、MOSI、MISO、CS_N。主设备(咱们的FPGA)控制时钟,从设备(Flash芯片)在时钟边沿采样或输出数据。

但这里有个坑——SPI有四种模式,由时钟极性(CPOL)和时钟相位(CPHA)决定。我见过不少新手在这上面栽跟头。Flash芯片通常工作在Mode 0或Mode 3,具体要看数据手册。

模式 CPOL CPHA 空闲时钟 数据采样边沿
Mode 0 0 0 低电平 上升沿
Mode 1 0 1 低电平 下降沿
Mode 2 1 0 高电平 上升沿
Mode 3 1 1 高电平 下降沿

我个人习惯用Mode 0。为什么?因为大部分Flash芯片默认支持Mode 0,而且上升沿采样的时序约束更容易满足。当然,如果你用的Flash芯片要求Mode 3,那就得改。记住,一切以数据手册为准

4.2 FPGA端SPI Master实现——状态机是王道

FPGA实现SPI Master,最直接的方式就是状态机。我一般把它分成三个状态:IDLE、TRANSFER、DONE。

  • IDLE:等待启动信号,CS_N拉高,SCK保持空闲电平。
  • TRANSFER:按位发送和接收数据。每来一个时钟边沿,移出一位数据到MOSI,同时从MISO采样一位数据。
  • DONE:传输完成,拉高CS_N(如果是单次传输),或者回到IDLE等待下一次操作。

这里有个细节:CS_N的控制。Flash芯片的很多操作(比如读、写、擦除)需要CS_N在整个命令序列期间保持低电平。你不能每传一个字节就把CS_N拉高一次,那样Flash会认为命令结束了。

我的经验:把CS_N的控制交给上层状态机,而不是SPI Master本身。SPI Master只负责字节级别的收发,CS_N由调用者(比如读操作状态机)来管理。这样设计更灵活,也更容易复用。

下面是一个简化的SPI Master Verilog代码框架。注意,这只是核心逻辑,实际项目中还需要考虑跨时钟域同步、FIFO接口等。

module spi_master #(
    parameter CLK_DIV = 4  // 系统时钟分频系数,决定SCK频率
)(
    input  wire       clk,
    input  wire       rst_n,
    // 用户接口
    input  wire       start,
    input  wire [7:0] tx_data,
    output reg  [7:0] rx_data,
    output reg        done,
    // SPI物理接口
    output reg        sck,
    output reg        mosi,
    input  wire       miso,
    output reg        cs_n   // 注意:这里cs_n由外部控制
);

    // 状态机
    localparam IDLE     = 2'b00;
    localparam TRANSFER = 2'b01;
    localparam DONE     = 2'b10;

    reg [1:0] state, next_state;
    reg [2:0] bit_cnt;      // 位计数器,0~7
    reg [7:0] shift_reg;    // 移位寄存器

    // 时钟分频
    reg [3:0] clk_cnt;
    wire sck_toggle = (clk_cnt == CLK_DIV/2 - 1);

    // 状态转移
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) state <= IDLE;
        else state <= next_state;
    end

    // 次态逻辑
    always @(*) begin
        next_state = state;
        case (state)
            IDLE:     if (start) next_state = TRANSFER;
            TRANSFER: if (bit_cnt == 7 && sck_toggle) next_state = DONE;
            DONE:     next_state = IDLE;
        endcase
    end

    // 数据路径
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            bit_cnt <= 0;
            shift_reg <= 0;
            rx_data <= 0;
            done <= 0;
            sck <= 0;  // Mode 0: 空闲低电平
            mosi <= 0;
            clk_cnt <= 0;
        end else begin
            case (state)
                IDLE: begin
                    bit_cnt <= 0;
                    done <= 0;
                    sck <= 0;
                    if (start) shift_reg <= tx_data;
                end
                TRANSFER: begin
                    // 时钟分频
                    clk_cnt <= (clk_cnt == CLK_DIV-1) ? 0 : clk_cnt + 1;
                    if (sck_toggle) begin
                        sck <= ~sck;  // 翻转SCK
                        if (sck) begin  // SCK下降沿,移出下一位
                            mosi <= shift_reg[7];
                            shift_reg <= {shift_reg[6:0], miso};
                            bit_cnt <= bit_cnt + 1;
                        end
                    end
                end
                DONE: begin
                    done <= 1;
                    rx_data <= shift_reg;
                end
            endcase
        end
    end

endmodule

这段代码我故意写得比较简洁。实际项目中,你还需要处理连续传输的场景——比如读一个256字节的页,需要连续发送256个字节的时钟。这时候,状态机需要支持「连续模式」,即传输完一个字节后不拉高CS_N,而是继续下一个字节。

4.3 多页读写与擦除操作——这才是重头戏

Flash芯片的操作,说白了就是发命令、传地址、传数据。常见的命令有:

  • 0x03:读数据(Read Data)
  • 0x02:页编程(Page Program),即写数据
  • 0x20:扇区擦除(Sector Erase),擦除4KB
  • 0xD8:块擦除(Block Erase),擦除64KB
  • 0xC7:整片擦除(Chip Erase)
  • 0x05:读状态寄存器(Read Status Register)
  • 0x06:写使能(Write Enable)

这里我要强调一个关键点:写操作之前必须先写使能。每次写操作(页编程、擦除)之前,都要发送0x06命令,让Flash进入写使能状态。否则,Flash会无视你的写命令。我曾经在调试时花了一整天,才发现是忘了发写使能命令。嗯,这种低级错误,犯过一次就再也不会忘了。

注意:写使能命令执行后,Flash的状态寄存器中的WEL位会置1。你可以通过读状态寄存器(0x05)来确认。但更常见的做法是直接发送写命令,因为Flash内部会处理这个时序。不过,如果你需要严格保证可靠性,建议在每次写操作前都检查一下WEL位。

4.4 多页读写——跨越页边界的艺术

Flash芯片的页大小通常是256字节。写操作(页编程)一次最多写一页,而且不能跨页。什么意思?就是说,如果你要写的数据起始地址是0x00FF,然后写10个字节,那么第2个字节会写到0x0100——这已经属于下一页了。Flash芯片不会自动帮你翻页,它会从0x0100开始继续写,但前提是CS_N没有拉高。

等等,这里有点绕。我解释清楚:

Flash的页编程命令(0x02)后面跟着3字节地址,然后是一串数据。只要你CS_N保持低电平,Flash会连续接收数据,并依次写入从起始地址开始的连续地址。但是,当地址到达页边界时,它会回绕到该页的起始地址,而不是自动进入下一页。

举个例子:起始地址0x00FF,你发送了4个字节的数据。Flash会这样写:

  • 字节0 -> 地址0x00FF
  • 字节1 -> 地址0x0000(回绕到页起始)
  • 字节2 -> 地址0x0001
  • 字节3 -> 地址0x0002

看到了吗?字节1覆盖了页首的数据。这就是所谓的页回绕。所以,如果你要写的数据跨越了页边界,必须拆分成多次页编程操作。

我设计的控制器里,有一个专门的模块来处理页边界。它会自动计算当前地址所在的页,以及该页剩余的空间,然后决定本次写多少字节。核心逻辑如下:

// 伪代码:多页写操作
function multi_page_write(start_addr, data, length):
    addr = start_addr
    remaining = length
    while remaining > 0:
        // 计算当前页剩余空间
        page_offset = addr & 0xFF          // 页内偏移
        page_remain = 256 - page_offset    // 本页剩余字节数
        bytes_this_page = min(remaining, page_remain)

        // 发送页编程命令
        send_command(0x06)                 // 写使能
        send_command(0x02)                 // 页编程
        send_address(addr)                 // 3字节地址
        send_data(data, bytes_this_page)   // 发送数据
        wait_for_busy()                    // 等待编程完成

        // 更新指针
        addr = addr + bytes_this_page
        data = data + bytes_this_page
        remaining = remaining - bytes_this_page

读操作就没有这个限制。你可以一次读任意长度的数据,Flash会自动跨页。但要注意,读操作的速度受限于SPI时钟频率。如果你的系统时钟是100MHz,SPI时钟分频到25MHz,那么理论读速度是25Mbps,约3.125MB/s。对于固件升级来说,这个速度通常够用了。

4.5 擦除操作——慢,但必须做

Flash芯片的特性是:写之前必须先擦除。因为Flash的写操作只能把1变成0,不能把0变成1。擦除操作会把整个扇区或块全部置为1(即0xFF)。

擦除操作很慢。扇区擦除(4KB)通常需要几十到几百毫秒,块擦除(64KB)需要几百毫秒到几秒。整片擦除就更慢了,可能好几秒。

所以,在固件升级方案中,我一般会这样设计:

  • 先擦除目标扇区(或块)
  • 然后逐页写入新固件
  • 最后读取校验

擦除期间,FPGA需要等待Flash完成操作。怎么等?轮询状态寄存器。发送0x05命令,读取状态寄存器的BUSY位(bit 0)。如果为1,说明Flash还在忙;如果为0,说明操作完成。

我的建议:不要用纯轮询的方式等待擦除完成,那样会浪费FPGA的处理能力。更好的做法是:启动擦除后,FPGA去做其他事情(比如处理网络数据),然后定时检查状态寄存器。或者,用Flash的「状态寄存器读」命令配合中断——不过大多数Flash芯片没有中断引脚,所以轮询还是最通用的方法。

4.6 实战中的避坑指南

最后,分享几个我在项目中踩过的坑:

  • CS_N的毛刺:FPGA上电时,IO引脚可能是高阻态或不确定状态。如果CS_N在初始化期间出现低电平毛刺,Flash可能会误判为命令开始。解决办法:在FPGA配置完成后,先拉高CS_N,再初始化SPI Master。
  • 写使能丢失:每次页编程或擦除操作后,Flash会自动清除写使能位。所以,如果你连续执行多个页编程,每个页编程之前都要重新发送写使能命令。不要偷懒。
  • 地址对齐:有些Flash芯片要求写操作的起始地址必须对齐到页边界(即地址的低8位为0)。虽然大多数芯片没有这个要求,但为了兼容性,我建议在固件升级方案中,总是从页边界开始写。
  • 时钟频率过高:SPI时钟频率不是越高越好。Flash芯片有最大时钟频率限制,而且PCB走线长度、信号完整性都会影响实际可用的频率。我一般保守一点,取数据手册标称频率的70%。

好了,这一章的内容就到这里。SPI Flash控制器是固件升级方案的基石,把它做扎实了,后面的工作就顺风顺水。下一章,我们会聊聊如何把固件数据从网络接口搬运到Flash——也就是数据通路的设计。


公众号:蓝海资料掘金营,微信deep3321