3、SPI控制器架构:顶层模块划分、时钟分频模块、数据移位模块、状态机控制模块、FIFO接口模块

好,咱们今天来聊聊SPI控制器的内部架构。说白了,就是把这个控制器拆开,看看里面到底有哪些零件,各自干什么活。

我个人习惯,在设计一个模块之前,先画一张大图。把顶层模块、子模块、它们之间的连线都理清楚。这样写代码的时候,心里才有底。

核心思路:SPI控制器本质上是一个“翻译官”。它把总线协议(比如APB、AXI)翻译成SPI的串行时序。所以,架构设计的关键就是处理好“并行”和“串行”的转换,以及“控制”和“数据”的分离。

下面这张图,是我自己总结的SPI控制器顶层架构。你一看就明白了。

SPI 控制器顶层 FIFO 接口模块 (APB/AXI 转 FIFO) 时钟分频模块 (产生 sclk) 状态机控制模块 (FSM 核心) 数据移位模块 (并串/串并转换) 总线 (PCLK) fifo_empty fifo_rdata[7:0] clk_div_en shift_en / load_en sclk mosi / miso / sclk / cs cs_n 图例 FIFO接口模块 时钟分频模块 状态机控制模块 数据移位模块 数据流 控制/状态信号

3.1 顶层模块划分

顶层模块,说白了就是个“包工头”。它不干具体的活,但负责把各个模块组织起来。

我一般会把顶层模块分成四个部分:

  • 时钟分频模块:负责产生SPI的串行时钟sclk。
  • 数据移位模块:负责把并行数据转成串行,或者反过来。
  • 状态机控制模块:这是整个控制器的“大脑”,决定什么时候发数据、什么时候收数据。
  • FIFO接口模块:负责和系统总线打交道,缓存数据。

你可能会问,为什么要把FIFO单独拿出来?嗯,这里有个坑。我曾经在一个项目里,把FIFO和状态机写在一起,结果代码乱成一锅粥。后来我学乖了,FIFO就是FIFO,状态机就是状态机,各管各的,调试起来方便多了。

3.2 时钟分频模块

时钟分频模块,它的任务很简单:把系统时钟(比如PCLK)分频成SPI需要的sclk。

为什么需要分频?因为SPI的速率通常比系统总线慢得多。比如系统时钟跑100MHz,SPI可能只需要10MHz。这时候就需要一个分频器。

我个人习惯用计数器来实现分频。代码大概长这样:

module clk_divider (
    input  wire        clk,      // 系统时钟
    input  wire        rst_n,    // 复位
    input  wire        en,       // 使能
    input  wire [7:0]  div,      // 分频系数
    output reg         sclk      // SPI时钟
);

    reg [7:0] cnt;

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            cnt  <= 8'd0;
            sclk <= 1'b0;
        end else if (en) begin
            if (cnt == div) begin
                cnt  <= 8'd0;
                sclk <= ~sclk;
            end else begin
                cnt <= cnt + 1'b1;
            end
        end else begin
            cnt  <= 8'd0;
            sclk <= 1'b0;
        end
    end

endmodule

小提示:分频系数div的值,决定了sclk的频率。公式是:sclk频率 = 系统时钟频率 / (2 * (div+1))。比如系统时钟100MHz,div=4,那么sclk就是10MHz。

3.3 数据移位模块

数据移位模块,是SPI控制器的“搬运工”。它负责把并行数据(比如8位)一位一位地送出去,或者把外面进来的串行数据拼成并行数据。

我记得有一次,我在调试一个SPI Flash的驱动,发现读出来的数据总是错位。查了半天,原来是移位模块的时钟沿没选对。SPI有四种模式(CPOL和CPHA的组合),移位模块必须根据模式来选择在sclk的上升沿还是下降沿采样。

代码实现上,我一般用一个移位寄存器:

module shift_register (
    input  wire        clk,
    input  wire        rst_n,
    input  wire        load_en,   // 加载并行数据
    input  wire        shift_en,  // 移位使能
    input  wire [7:0] data_in,   // 并行输入
    input  wire        miso,      // 串行输入
    output reg         mosi,      // 串行输出
    output reg [7:0]  data_out   // 并行输出
);

    reg [7:0] shift_reg;

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            shift_reg <= 8'd0;
            mosi      <= 1'b0;
            data_out  <= 8'd0;
        end else if (load_en) begin
            shift_reg <= data_in;
        end else if (shift_en) begin
            mosi      <= shift_reg[7];          // 先发高位
            shift_reg <= {shift_reg[6:0], miso}; // 移入低位
        end
    end

    // 当移位完成,输出并行数据
    always @(posedge clk) begin
        if (shift_en && (shift_reg[6:0] == 7'd0)) begin
            data_out <= {shift_reg[6:0], miso};
        end
    end

endmodule

注意:移位方向取决于SPI的模式。有些模式是MSB first,有些是LSB first。上面代码默认是MSB first。如果你需要LSB first,把mosi <= shift_reg[0] 和移位方向反过来就行。

3.4 状态机控制模块

状态机控制模块,是整个控制器的“大脑”。它决定了什么时候开始传输、什么时候结束、什么时候切换状态。

我常用的状态机有四个状态:

  • IDLE:空闲状态,等待触发信号。
  • LOAD:从FIFO加载数据到移位寄存器。
  • SHIFT:移位状态,一位一位地发送/接收数据。
  • DONE:完成状态,通知FIFO数据已经处理完毕。

状态转移图如下:

IDLE LOAD SHIFT DONE start 自动 bit_cnt == 8 done bit_cnt < 8

你想想看,这个状态机其实很简单。平时在IDLE待着,一旦收到start信号,就去LOAD数据,然后进入SHIFT状态,一位一位地移。移完8位,进入DONE状态,通知外面数据好了,然后回到IDLE。

3.5 FIFO接口模块

FIFO接口模块,是连接系统总线和SPI控制器的“缓冲带”。

为什么需要FIFO?因为系统总线和SPI的速率不匹配。总线可能一次发很多数据,但SPI只能慢慢串行发送。如果没有FIFO,CPU就得一直等着SPI发完,效率太低了。

我一般用同步FIFO,深度选8或16就够用了。代码实现上,可以用现成的IP,也可以自己写。这里我简单说一下接口:

信号名 方向 描述
fifo_wr_en 输入 写使能,由总线接口产生
fifo_wr_data 输入 写入的数据,来自总线
fifo_rd_en 输入 读使能,由状态机产生
fifo_rd_data 输出 读出的数据,送到移位模块
fifo_empty 输出 空标志,状态机据此判断是否继续
fifo_full 输出 满标志,总线接口据此判断是否可写

关键点:FIFO的读写时钟域要一致。如果系统总线和SPI控制器不在同一个时钟域,那就需要异步FIFO。不过咱们这个课程里,先假设都在同一个时钟域,简化设计。

好了,以上就是SPI控制器架构的四个核心模块。我个人觉得,只要把这四个模块理解透了,写代码就是水到渠成的事。下一章,咱们会深入每个模块的细节,看看RTL代码到底怎么写。

我的经验:刚开始做SPI控制器的时候,别想着一次搞定所有功能。先做一个最简单的版本,只支持模式0,只发8位数据。跑通了,再慢慢加功能。这样不容易出bug,也方便调试。

可可精品资料库