11、SPI总线通信:SPI模式详解(CPOL/CPHA)、Master模式实现、Slave模式实现、Flash读写实战

SPI总线,说实话,是我在FPGA开发中用得最多的串行接口之一。它简单、高效,而且几乎所有的Flash、ADC、传感器都支持它。今天我们就来彻底搞懂SPI,从模式选择到Master/Slave实现,最后用Flash读写来实战验证。

11.1 SPI协议基础:四根线搞定一切

SPI全称是Serial Peripheral Interface,由Motorola公司推出。它用四根线实现全双工通信:

  • SCLK:串行时钟,由Master产生
  • MOSI:Master输出,Slave输入
  • MISO:Master输入,Slave输出
  • CS:片选信号,低电平有效

我刚开始接触SPI时,总觉得它比I2C简单多了。确实,SPI没有地址概念,没有应答机制,就是纯粹的移位寄存器对移位寄存器。你想想看,Master每输出一个时钟,就同时发送一位数据并接收一位数据,多干脆。

11.2 SPI的四种模式:CPOL和CPHA详解

这里有个坑,很多新手会栽在这里。SPI有四种模式,由CPOL(时钟极性)和CPHA(时钟相位)决定。

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

我个人习惯用模式0,因为大部分Flash芯片默认就是模式0。但要注意,有些传感器用的是模式3,比如某些ADXL345加速度计。我曾经在项目里因为没仔细看数据手册,默认用了模式0,结果读出来的数据全是乱的,排查了半天才发现是模式不匹配。

核心要点:CPOL决定时钟空闲时的电平,CPHA决定数据在哪个时钟沿被采样。CPHA=0时,数据在第一个时钟沿采样;CPHA=1时,数据在第二个时钟沿采样。

11.3 Master模式实现:用状态机控制SPI时序

在FPGA里实现SPI Master,我推荐用状态机。为什么?因为状态机可以清晰地控制每个时钟周期的行为,而且容易调试。

下面是一个典型的SPI Master状态机设计:

// SPI Master状态机 - 紫光同创PGL22G实现
module spi_master (
    input  wire        clk,        // 系统时钟 50MHz
    input  wire        rst_n,      // 复位
    input  wire        start,      // 启动传输
    input  wire [7:0]  tx_data,    // 发送数据
    output reg  [7:0]  rx_data,    // 接收数据
    output reg         busy,       // 忙标志
    output reg         sclk,       // SPI时钟
    output reg         mosi,       // Master输出
    input  wire        miso,       // Master输入
    output reg         cs_n        // 片选
);

    // 状态定义
    localparam IDLE  = 3'd0;
    localparam START = 3'd1;
    localparam SHIFT = 3'd2;
    localparam STOP  = 3'd3;

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

    // 状态转移
    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 = START;
            START:  next_state = SHIFT;
            SHIFT:  if (bit_cnt == 4'd8) next_state = STOP;
            STOP:   next_state = IDLE;
            default: next_state = IDLE;
        endcase
    end

    // 时序逻辑:数据移位和时钟生成
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            sclk <= 1'b0;
            mosi <= 1'b0;
            cs_n <= 1'b1;
            busy <= 1'b0;
            bit_cnt <= 4'd0;
            shift_reg <= 8'd0;
            rx_data <= 8'd0;
        end else begin
            case (state)
                IDLE: begin
                    cs_n <= 1'b1;
                    sclk <= 1'b0;      // 模式0:空闲低电平
                    busy <= 1'b0;
                    if (start) begin
                        shift_reg <= tx_data;
                        bit_cnt <= 4'd0;
                    end
                end
                START: begin
                    cs_n <= 1'b0;      // 拉低片选
                    busy <= 1'b1;
                end
                SHIFT: begin
                    // 上升沿发送数据
                    sclk <= 1'b1;
                    mosi <= shift_reg[7];  // MSB先发
                    shift_reg <= {shift_reg[6:0], miso}; // 同时采样MISO
                    bit_cnt <= bit_cnt + 1;
                end
                STOP: begin
                    sclk <= 1'b0;
                    cs_n <= 1'b1;      // 拉高片选
                    rx_data <= shift_reg; // 锁存接收数据
                    busy <= 1'b0;
                end
            endcase
        end
    end
endmodule

个人经验:我在实际项目中,通常会在SHIFT状态里加一个时钟分频计数器。因为系统时钟往往比SPI时钟快很多,比如50MHz的系统时钟要产生10MHz的SPI时钟,就需要分频。这个分频逻辑我一般放在状态机外面,用单独的计数器模块实现。

11.4 Slave模式实现:响应Master的时钟

Slave模式相对简单,因为它完全被动,跟着Master的时钟走。但有个关键点:Slave的时钟必须和Master同步,而且数据要在正确的时钟沿输出。

// SPI Slave实现 - 模式0
module spi_slave (
    input  wire        sclk,       // 来自Master的时钟
    input  wire        cs_n,       // 片选
    input  wire        mosi,       // 来自Master的数据
    output reg         miso,       // 输出给Master的数据
    input  wire [7:0]  tx_data,    // 要发送的数据
    output reg  [7:0]  rx_data     // 接收到的数据
);

    reg [3:0] bit_cnt;
    reg [7:0] shift_reg;

    always @(posedge sclk or posedge cs_n) begin
        if (cs_n) begin
            // 片选无效时复位
            bit_cnt <= 4'd0;
            shift_reg <= tx_data;  // 加载要发送的数据
        end else begin
            // 在上升沿采样MOSI
            shift_reg <= {shift_reg[6:0], mosi};
            bit_cnt <= bit_cnt + 1;
            if (bit_cnt == 4'd7) begin
                rx_data <= {shift_reg[6:0], mosi}; // 接收完成
            end
        end
    end

    // 在下降沿输出MISO(模式0)
    always @(negedge sclk or posedge cs_n) begin
        if (cs_n)
            miso <= 1'bz;  // 高阻态
        else
            miso <= shift_reg[7]; // 输出最高位
    end
endmodule

注意:Slave的MISO在片选无效时必须置为高阻态,否则多个Slave会打架。我曾经在一个多Slave系统里忘了加这个,结果MISO线上信号冲突,数据全乱了。排查了整整一天才找到问题。

11.5 Flash读写实战:以W25Q64为例

好了,理论讲完了,我们来点实战。W25Q64是一款常用的SPI Flash,容量64Mbit。它的操作很简单:发命令、发地址、读写数据。

常用的Flash命令:

  • 0x9F:读ID,返回3个字节的设备ID
  • 0x06:写使能,每次写操作前必须发
  • 0x02:页编程,一次最多写256字节
  • 0x03:读数据,从指定地址开始读
  • 0x05:读状态寄存器,检查是否忙

下面是一个完整的Flash读ID的Verilog实现:

// Flash读ID示例
module flash_read_id (
    input  wire        clk,
    input  wire        rst_n,
    input  wire        start,
    output reg  [23:0] device_id,
    output reg         done,
    // SPI接口
    output reg         flash_cs,
    output reg         flash_sclk,
    output reg         flash_mosi,
    input  wire        flash_miso
);

    // 状态机实现
    localparam IDLE   = 4'd0;
    localparam CMD    = 4'd1;  // 发送命令0x9F
    localparam DUMMY  = 4'd2;  // 等待8个时钟
    localparam READ1  = 4'd3;  // 读第一个字节
    localparam READ2  = 4'd4;  // 读第二个字节
    localparam READ3  = 4'd5;  // 读第三个字节
    localparam FINISH = 4'd6;

    reg [3:0] state;
    reg [3:0] bit_cnt;
    reg [7:0] shift_reg;

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            state <= IDLE;
            flash_cs <= 1'b1;
            done <= 1'b0;
        end else begin
            case (state)
                IDLE: begin
                    if (start) begin
                        state <= CMD;
                        flash_cs <= 1'b0;  // 拉低片选
                        bit_cnt <= 4'd0;
                        shift_reg <= 8'h9F; // 读ID命令
                    end
                end
                CMD: begin
                    // 发送8位命令
                    flash_sclk <= 1'b1;
                    flash_mosi <= shift_reg[7];
                    shift_reg <= {shift_reg[6:0], flash_miso};
                    bit_cnt <= bit_cnt + 1;
                    if (bit_cnt == 4'd7) begin
                        state <= DUMMY;
                        bit_cnt <= 4'd0;
                    end
                end
                DUMMY: begin
                    // 发送8个空时钟
                    flash_sclk <= 1'b1;
                    bit_cnt <= bit_cnt + 1;
                    if (bit_cnt == 4'd7) begin
                        state <= READ1;
                        bit_cnt <= 4'd0;
                    end
                end
                READ1: begin
                    // 读第一个字节
                    flash_sclk <= 1'b1;
                    shift_reg <= {shift_reg[6:0], flash_miso};
                    bit_cnt <= bit_cnt + 1;
                    if (bit_cnt == 4'd7) begin
                        device_id[23:16] <= {shift_reg[6:0], flash_miso};
                        state <= READ2;
                        bit_cnt <= 4'd0;
                    end
                end
                // READ2和READ3类似,略
                FINISH: begin
                    flash_cs <= 1'b1;
                    done <= 1'b1;
                    state <= IDLE;
                end
            endcase
        end
    end
endmodule

实战要点:Flash操作有几个坑要注意:

  • 写操作前必须先发写使令(0x06)
  • 写完后要轮询状态寄存器,等BUSY位清零
  • 页编程一次最多256字节,跨页要分多次写
  • 擦除操作很慢,块擦除可能要几百毫秒

11.6 避坑指南:SPI设计中的常见问题

做了这么多年FPGA,我在SPI上踩过的坑还真不少。这里分享几个最典型的:

  1. 时钟极性搞反:我曾经有个项目,Master和Slave都是我们自己做的,但两边CPOL设置不一致。结果通信时好时坏,最后用示波器一看,时钟空闲电平不一样。
  2. 片选信号没处理好:Flash的片选必须在整个操作过程中保持低电平,中间不能有毛刺。我建议用寄存器打两拍再输出,避免组合逻辑产生的毛刺。
  3. 时序余量不足:SPI时钟频率不是越高越好。Flash芯片有最小时钟周期要求,比如W25Q64最高支持80MHz,但实际布线长了、负载重了,频率就得降。
  4. 多字节传输的边界处理:读Flash时,如果连续读多个字节,片选要保持低,时钟不能停。我见过有人每读一个字节就拉高一次片选,结果数据全乱了。

我的调试习惯:遇到SPI通信问题,我第一件事就是拿示波器看波形。看片选有没有毛刺,看时钟和数据是否对齐,看数据在正确的时钟沿是否稳定。示波器是SPI调试最好的工具,没有之一。

好了,SPI的内容就讲到这里。从模式选择到Master/Slave实现,再到Flash读写实战,这套知识足够你应对大部分SPI应用场景了。下一章我们讲I2C总线,那个比SPI稍微复杂一点,但掌握了SPI再学I2C会轻松很多。