3、SPI协议详解:SPI四线制(SCK、MOSI、MISO、CS)、四种工作模式(CPOL/CPHA)、多从机拓扑、Verilog实现SPI主控制器

3.1 聊聊SPI四线制:SCK、MOSI、MISO、CS

SPI,全称Serial Peripheral Interface,说白了就是串行外设接口。我最早接触它是在做传感器驱动的时候,那时候觉得这协议真简单,就四根线,比I2C好理解多了。

这四根线分别是:

  • SCK(Serial Clock):时钟线,由主设备产生。我习惯叫它“心跳线”,因为所有数据都在它的节拍下传输。
  • MOSI(Master Out Slave In):主设备输出,从设备输入。数据从主设备流向从设备。
  • MISO(Master In Slave Out):主设备输入,从设备输出。数据从从设备流向主设备。
  • CS(Chip Select):片选线,低电平有效。你想想看,一根CS线对应一个从设备,选谁就把谁拉低。

这里有个细节我提醒一下:SPI是全双工的。什么意思?就是MOSI和MISO可以同时传输数据。我在项目中遇到过有人把它当半双工用,白白浪费了一半带宽。

核心要点:SPI的传输是“交换”的过程。主设备发一个bit,同时从设备也发一个bit。所以即使你只想读数据,也得先发一个字节出去(通常是0x00或0xFF)。

3.2 四种工作模式:CPOL和CPHA

CPOL是时钟极性,CPHA是时钟相位。这两个参数决定了数据在时钟的哪个边沿采样。嗯,这里要注意,很多初学者就是在这栽跟头。

我直接给个表格,你一看就明白:

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

我个人习惯用模式0,因为大部分SPI外设默认就是模式0。但有一次我调试一个AD转换芯片,死活读不出数据,折腾了半天才发现它用的是模式3。从那以后,我每次看芯片手册第一件事就是确认CPOL和CPHA。

避坑指南:我曾经因为主设备和从设备的模式不匹配,导致数据全是0xFF。检查方法很简单:用示波器抓SCK和MOSI,看数据是在上升沿还是下降沿变化,再对照表格就能确定当前模式。

3.3 多从机拓扑:怎么接多个设备?

SPI支持多从机,但方式跟I2C不一样。I2C是靠地址区分,SPI是靠片选线区分。说白了,每个从设备独占一根CS线。

常见的拓扑有两种:

  • 独立片选:每个从设备一根CS线,SCK、MOSI、MISO共享。这是最常用的方式,我推荐你用这个。
  • 菊花链:从设备串联,数据从一个传到下一个。这种方式省CS线,但延迟大,我一般只在FPGA内部用。

你想想看,如果接了8个从设备,那就需要8根CS线。主设备的GPIO够不够?我建议用3-8译码器来扩展,比如74HC138,用3根GPIO控制8个CS。

注意:多从机共享MISO线时,必须保证未选中的从设备输出为高阻态(Hi-Z)。否则多个设备同时驱动MISO,轻则数据错误,重则烧毁IO口。我曾经见过一块板子就是因为这个原因,MISO引脚直接冒烟了。

3.4 Verilog实现SPI主控制器

好了,理论说完了,咱们直接上代码。我写了一个简单的SPI主控制器,支持模式0,可配置时钟分频。

module spi_master (
    input  wire       clk,          // 系统时钟
    input  wire       rst_n,        // 复位,低有效
    input  wire       start,        // 启动传输
    input  wire [7:0] data_in,      // 要发送的数据
    output reg  [7:0] data_out,     // 接收到的数据
    output reg        busy,         // 忙标志
    // SPI 接口
    output reg        sck,          // SPI时钟
    output reg        mosi,         // 主出从入
    input  wire       miso,         // 主入从出
    output reg        cs_n          // 片选,低有效
);

    // 状态机定义
    localparam IDLE  = 2'b00;
    localparam TRANS = 2'b01;
    localparam DONE  = 2'b10;

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

    // 时钟分频参数(假设系统时钟50MHz,目标SPI时钟1MHz)
    localparam DIV_CNT = 25;  // 50MHz / (2*25) = 1MHz
    reg [4:0] clk_div;
    reg sck_en;

    // 状态机
    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 = TRANS;
            TRANS:  if (bit_cnt == 3'd7 && clk_div == DIV_CNT-1) next_state = DONE;
            DONE:   next_state = IDLE;
            default: next_state = IDLE;
        endcase
    end

    // 时钟分频和SCK生成
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            clk_div <= 0;
            sck_en  <= 0;
        end else if (state == TRANS) begin
            if (clk_div == DIV_CNT-1) begin
                clk_div <= 0;
                sck_en  <= 1;
            end else begin
                clk_div <= clk_div + 1;
                sck_en  <= 0;
            end
        end else begin
            clk_div <= 0;
            sck_en  <= 0;
        end
    end

    // SCK生成(模式0:空闲低电平,上升沿采样)
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            sck <= 1'b0;
        else if (state == TRANS && sck_en)
            sck <= ~sck;
        else if (state == IDLE || state == DONE)
            sck <= 1'b0;
    end

    // 数据发送和接收
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            shift_reg <= 8'd0;
            bit_cnt   <= 3'd0;
            mosi      <= 1'b0;
            data_out  <= 8'd0;
            cs_n      <= 1'b1;
            busy      <= 1'b0;
        end else begin
            case (state)
                IDLE: begin
                    if (start) begin
                        shift_reg <= data_in;
                        bit_cnt   <= 3'd0;
                        cs_n      <= 1'b0;   // 拉低片选
                        busy      <= 1'b1;
                    end
                end
                TRANS: begin
                    // 在SCK下降沿更新MOSI(模式0)
                    if (sck_en && sck == 1'b1) begin
                        mosi <= shift_reg[7];
                        shift_reg <= {shift_reg[6:0], 1'b0};
                    end
                    // 在SCK上升沿采样MISO(模式0)
                    if (sck_en && sck == 1'b0) begin
                        shift_reg <= {shift_reg[6:0], miso};
                        if (bit_cnt == 3'd7)
                            data_out <= {shift_reg[6:0], miso};
                        bit_cnt <= bit_cnt + 1;
                    end
                end
                DONE: begin
                    cs_n <= 1'b1;   // 拉高片选
                    busy <= 1'b0;
                end
            endcase
        end
    end

endmodule

这段代码我解释一下:

  • 状态机有三个状态:IDLE等待启动,TRANS传输数据,DONE结束。
  • 时钟分频器把系统时钟分频成SPI时钟,这里我设的是1MHz。
  • 数据在SCK的下降沿发送(MOSI更新),上升沿接收(MISO采样),符合模式0。
  • 传输完成后自动拉高CS,释放总线。

个人经验:我建议你在仿真时先跑一遍,重点看CS的时序。很多问题出在CS拉低后SCK还没稳定,或者传输结束后CS拉高太快。我曾经因为CS拉高和最后一个SCK边沿的时序关系没处理好,导致从设备认为传输还没结束。

嗯,SPI协议就讲到这里。说白了,它就是一个简单、高效、全双工的串行接口。你只要记住四根线、四种模式、多从机靠CS区分,再加上这段Verilog代码,基本就能应付大部分项目了。下一章咱们聊I2C,那个比SPI稍微复杂一点,但也是嵌入式工程师的必修课。