第四章:量化交易数据接口——SPI协议详解与实战

各位同学,今天我们来聊聊量化交易系统里一个绕不开的话题——SPI接口。说实话,我做了十几年FPGA,从高频交易到工业控制,SPI这玩意儿几乎天天见。它不像PCIe那么复杂,也不像UART那么慢,属于那种「看着简单,用好了真香」的协议。

在量化交易系统里,SPI主要干两件事:采集ADC数据控制DAC输出。你想想看,行情数据从模拟信号变成数字信号,靠的是ADC;交易指令从数字信号变成模拟信号去驱动硬件,靠的是DAC。而SPI就是连接FPGA和这些转换器件的桥梁。

核心知识点速览

  • SPI协议的四线制原理(SCK、MOSI、MISO、CS)
  • 四种工作模式(CPOL/CPHA组合)
  • SPI Master的Verilog实现
  • SPI Slave的Verilog实现
  • ADC数据采集实战(以AD7689为例)
  • DAC数据输出实战(以AD5668为例)
SPI协议详解 协议基础 四线制:SCK, MOSI, MISO, CS 4种工作模式(CPOL/CPHA) 全双工通信原理 Verilog实现 SPI Master 状态机 SPI Slave 状态机 时钟分频与数据对齐 实战应用 ADC数据采集(AD7689) DAC数据输出(AD5668) 时序约束与调试技巧 量化交易数据接口:从协议到硬件实现

4.1 SPI协议基础——别被四根线吓到

SPI全称是Serial Peripheral Interface,串行外设接口。说白了就是一根时钟线、两根数据线、一根片选线。我刚开始学的时候觉得这玩意儿太简单了,结果第一次调ADC的时候就被时序坑了一把。

四根线各司其职:

  • SCK(Serial Clock):时钟线,由Master产生。频率决定了通信速度。
  • MOSI(Master Out Slave In):主发从收线。
  • MISO(Master In Slave Out):主收从发线。
  • CS(Chip Select):片选线,低电平有效。谁拉低谁说话。

个人经验:我习惯把CS叫做「话筒线」。谁拿到话筒谁发言,多个Slave设备就是轮流拿话筒。记得在切换CS的时候留几个时钟周期的间隔,不然Slave设备反应不过来。

4.2 四种工作模式——CPOL和CPHA的排列组合

SPI有四种模式,由CPOL(时钟极性)和CPHA(时钟相位)决定。很多初学者在这里栽跟头,我当年也是。

模式 CPOL CPHA 数据采样沿 常用场景
模式0 0 0 上升沿采样 最常用,兼容性最好
模式1 0 1 下降沿采样 某些ADC使用
模式2 1 0 下降沿采样 部分DAC使用
模式3 1 1 上升沿采样 某些传感器使用

为什么会搞出四种模式?说白了就是不同厂家芯片的设计习惯不一样。我建议你拿到芯片数据手册后,第一件事就是看时序图,找到CPOL和CPHA的值。千万别想当然用模式0,我吃过这个亏。

避坑指南:我曾经在项目里用AD7689 ADC,数据手册上写的是模式1。我习惯性用了模式0,结果读出来的数据全是乱的。查了两天才发现是模式不匹配。从那以后,我每用一个新芯片,第一件事就是对着数据手册画时序图。

4.3 SPI Master的Verilog实现——状态机是王道

实现SPI Master,我推荐用状态机。别用计数器硬怼,后期维护起来想哭。下面是我常用的三段式状态机框架:

module spi_master #(
    parameter CLK_DIV = 4,      // 时钟分频系数
    parameter DATA_WIDTH = 16   // 数据位宽
)(
    input  wire         clk,
    input  wire         rst_n,
    input  wire         start,
    input  wire [DATA_WIDTH-1:0] tx_data,
    output reg  [DATA_WIDTH-1:0] rx_data,
    output reg         busy,
    // SPI接口
    output reg         sck,
    output reg         mosi,
    input  wire        miso,
    output reg         cs
);

    localparam IDLE = 2'b00;
    localparam TRANSFER = 2'b01;
    localparam DONE = 2'b10;

    reg [1:0] state, next_state;
    reg [3:0] clk_cnt;
    reg [4:0] bit_cnt;
    reg sck_en;

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

    // 状态机第二段:次态逻辑
    always @(*) begin
        case (state)
            IDLE: next_state = start ? TRANSFER : IDLE;
            TRANSFER: next_state = (bit_cnt == DATA_WIDTH) ? DONE : TRANSFER;
            DONE: next_state = IDLE;
            default: next_state = IDLE;
        endcase
    end

    // 状态机第三段:输出逻辑
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            cs <= 1'b1;
            sck <= 1'b0;
            mosi <= 1'b0;
            busy <= 1'b0;
            rx_data <= 0;
            clk_cnt <= 0;
            bit_cnt <= 0;
        end else begin
            case (state)
                IDLE: begin
                    cs <= 1'b1;
                    busy <= 1'b0;
                    bit_cnt <= 0;
                    clk_cnt <= 0;
                end
                TRANSFER: begin
                    cs <= 1'b0;
                    busy <= 1'b1;
                    // 时钟分频逻辑
                    if (clk_cnt == CLK_DIV-1) begin
                        clk_cnt <= 0;
                        sck <= ~sck;
                        // 在SCK的上升沿发送数据
                        if (sck) begin
                            mosi <= tx_data[DATA_WIDTH-1-bit_cnt];
                            rx_data <= {rx_data[DATA_WIDTH-2:0], miso};
                            bit_cnt <= bit_cnt + 1;
                        end
                    end else begin
                        clk_cnt <= clk_cnt + 1;
                    end
                end
                DONE: begin
                    cs <= 1'b1;
                    busy <= 1'b0;
                end
            endcase
        end
    end
endmodule

个人习惯:我写SPI Master时,喜欢把时钟分频和位计数分开处理。这样代码可读性强,调试也方便。另外,记得在TRANSFER状态里处理SCK的边沿,别在IDLE状态里乱动时钟。

4.4 SPI Slave的Verilog实现——跟着Master的节奏走

Slave的实现比Master简单,因为时钟是Master给的。你只需要跟着SCK的节奏采样和发送数据就行。但有个坑:Slave的时钟域是外部输入的,一定要做同步处理。

module spi_slave #(
    parameter DATA_WIDTH = 16
)(
    input  wire         clk,        // 系统时钟,用于同步
    input  wire         rst_n,
    // SPI接口
    input  wire         sck,
    input  wire         mosi,
    output reg          miso,
    input  wire         cs,
    // 用户接口
    input  wire [DATA_WIDTH-1:0] tx_data,
    output reg  [DATA_WIDTH-1:0] rx_data,
    output reg         data_valid
);

    reg [2:0] sck_sync;
    reg sck_rising, sck_falling;
    reg [4:0] bit_cnt;

    // 同步SCK到系统时钟域
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) sck_sync <= 0;
        else sck_sync <= {sck_sync[1:0], sck};
    end

    assign sck_rising = (sck_sync[2:1] == 2'b01);
    assign sck_falling = (sck_sync[2:1] == 2'b10);

    // 数据接收与发送
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            bit_cnt <= 0;
            rx_data <= 0;
            miso <= 1'b0;
            data_valid <= 1'b0;
        end else if (!cs) begin
            if (sck_rising) begin
                // 在SCK上升沿采样MOSI
                rx_data <= {rx_data[DATA_WIDTH-2:0], mosi};
                bit_cnt <= bit_cnt + 1;
            end
            if (sck_falling) begin
                // 在SCK下降沿更新MISO
                miso <= tx_data[DATA_WIDTH-1-bit_cnt];
            end
            // 数据接收完成
            if (bit_cnt == DATA_WIDTH && sck_rising) begin
                data_valid <= 1'b1;
                bit_cnt <= 0;
            end
        end else begin
            data_valid <= 1'b0;
            bit_cnt <= 0;
        end
    end
endmodule

避坑指南:我曾经在Slave实现里直接用sck作为时钟驱动寄存器,结果综合出来一堆亚稳态问题。记住:外部输入的时钟一定要同步到本地时钟域。用三级触发器打拍是最稳妥的做法。

4.5 ADC数据采集实战——以AD7689为例

AD7689是一款16位、8通道的SAR ADC,在量化交易系统里很常见。它支持SPI模式1(CPOL=0, CPHA=1)。我习惯用250kHz的SCK频率,这样采样率能到15kSPS左右,够用。

AD7689的SPI时序要点:

  • CS拉低后,先发送24位配置字(包含通道选择、参考电压等)
  • 配置字发送完成后,等待转换完成(tconv时间)
  • 再发送16个SCK脉冲,读取16位转换结果
  • CS拉高,结束一次转换周期
// AD7689配置字示例
// 通道0,内部参考,正常模式
localparam [23:0] AD7689_CFG = 24'h00_00_00;

// 读取ADC数据的SPI事务
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        adc_start <= 1'b0;
        adc_channel <= 0;
    end else begin
        case (adc_state)
            IDLE: begin
                if (adc_req) begin
                    adc_start <= 1'b1;
                    adc_state <= SEND_CFG;
                end
            end
            SEND_CFG: begin
                // 发送24位配置字
                // ... SPI Master发送逻辑
                if (cfg_done) adc_state <= WAIT_CONV;
            end
            WAIT_CONV: begin
                // 等待转换完成,约4us
                if (conv_done) adc_state <= READ_DATA;
            end
            READ_DATA: begin
                // 读取16位数据
                // ... SPI Master接收逻辑
                if (read_done) begin
                    adc_data <= rx_data;
                    adc_state <= IDLE;
                end
            end
        endcase
    end
end

个人经验:AD7689的转换时间大约是4us,我建议在WAIT_CONV状态里用计数器精确等待,别用延时函数。FPGA的时序精度是纳秒级的,浪费了可惜。

4.6 DAC数据输出实战——以AD5668为例

AD5668是8通道、16位DAC,常用于生成模拟控制信号。它支持SPI模式0(CPOL=0, CPHA=0)。我一般在交易信号生成模块里用它输出模拟电压。

AD5668的SPI时序要点:

  • CS拉低后,发送24位数据帧
  • 前4位是命令(写、更新、复位等)
  • 中间4位是地址(选择哪个通道)
  • 后16位是DAC数据
  • CS拉高,数据锁存到DAC寄存器
// AD5668数据帧格式
// {4'b0011, 4'b0000, 16'd_value}  // 写通道0并更新

// DAC输出控制
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        dac_start <= 1'b0;
        dac_value <= 0;
    end else begin
        case (dac_state)
            IDLE: begin
                if (dac_req) begin
                    // 组装24位数据帧
                    dac_frame <= {4'b0011, dac_addr, dac_value};
                    dac_start <= 1'b1;
                    dac_state <= SEND_FRAME;
                end
            end
            SEND_FRAME: begin
                // 发送24位数据帧
                // ... SPI Master发送逻辑
                if (frame_done) begin
                    dac_state <= IDLE;
                    dac_done <= 1'b1;
                end
            end
        endcase
    end
end

关键提醒:DAC的输出更新时机很重要。AD5668有两种模式:同步更新(所有通道同时更新)和异步更新(每个通道独立更新)。在量化交易系统里,我建议用同步更新模式,保证所有通道的输出在同一时刻变化,避免时序偏差。

4.7 调试技巧与常见问题

最后分享几个我在项目中积累的调试技巧:

  1. 用逻辑分析仪抓波形——别光靠仿真。实际硬件上的信号质量、时序偏差,仿真看不出来。我习惯用Saleae逻辑分析仪,便宜好用。
  2. 先测回环——把MOSI和MISO短接,发什么收什么。如果回环测试通过,说明SPI Master基本没问题。
  3. 注意电平匹配——FPGA通常是3.3V或2.5V,有些ADC/DAC是5V的。电平转换芯片不能省,我见过烧芯片的案例。
  4. CS的毛刺——CS信号一定要干净。如果CS上有毛刺,Slave设备会误触发。加个施密特触发器或者软件去抖都行。

避坑指南:我曾经在一个高频交易项目里,SPI通信偶尔出错,查了三天发现是电源纹波太大导致SCK抖动。后来在FPGA的SPI模块输入端加了数字滤波,问题解决。记住:SPI的时钟质量直接影响通信可靠性

好了,SPI协议和实战就讲到这里。下一章我们会深入探讨另一个重要的数据接口——I2C协议。记住,SPI的核心就是「四根线、一个主、多个从、全双工」。把这个框架记牢了,后面调任何SPI设备都不怕。


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