FPGA实现SPI通信:从协议细节到上板调试全攻略
2026/9/5 10:28:49 网站建设 项目流程

SPI接口在嵌入式系统里几乎是无处不在,但真要把SPI通信用FPGA做扎实,很多人一开始容易掉进"代码能跑就行"的误区。波形乱、时序不满足、跑着跑着丢数据,这些问题多半不是FPGA本身的问题,而是对SPI协议的理解还停在"大概这么回事"的层面。这篇文章我结合自己实际做过的项目,把FPGA实现SPI的关键点拆开讲清楚,从协议细节到代码结构,从仿真到上板调试,尽量把常见的坑都给你指出来。

1. 为什么要在FPGA里自己写SPI

很多人的第一反应是,SPI这么简单的协议,单片机上有现成的硬件外设,配置一下寄存器就能用,为什么还要在FPGA里费劲去写?这个问题的答案,直接决定你要不要看下去。

在我做过的项目里,遇到FPGA需要SPI的场景主要有这么几类:

第一类是用FPGA做主控,需要跟多个SPI从设备通信。比如同时挂一个ADC、一个Flash、一个SD卡,如果都用IO口模拟或者用逻辑去拼,状态机会变得非常复杂。SPI协议的时序容限其实很宽松,FPGA用状态机实现SPI主模式,代码量不大,但换来的灵活性非常高——你可以随意控制时钟极性、相位、速率、片选时序,甚至可以做多从机轮询、连续突发读。

第二类是接口转换。比如STM32H743这类MCU跟FPGA通过FMC总线通信,FPGA内部需要把FMC接口的数据转成SPI去控制外设;或者是FPGA作为SPI从设备,接收MCU下发的配置命令,这时候你不可能去用别人的IP核,自己实现一个SPI Slave往往是更好的选择。

第三类是因为速率或者时序的苛刻要求。SPI的极限速率不低,但MCU的硬件SPI有时钟分频档位限制,未必能刚好满足某个传感器要求的时序窗口;而FPGA则可以直接用PLL输出一个刚好满足要求的时钟,用任意波特率来做。

还有一个很现实的原因:FPGA开发中,SPI经常是学习状态机、时钟域、数据流设计的绝佳练手项目。搞懂SPI的FPGA实现,等于打通了FPGA逻辑设计的任督二脉。

2. SPI协议里容易被忽略的细节

2.1 SPI的四根线和两种角色

SPI的全称是Serial Peripheral Interface,串行外设接口,通常由四根线组成:

  • SCLK:串行时钟,由主设备产生
  • MOSI:主出从入(Master Output Slave Input)
  • MISO:主入从出(Master Input Slave Output)
  • CS/SS:片选信号,低电平有效,由主设备控制

这个接口的架构决定了它的一个核心特点:全双工。在每一个时钟周期,主设备通过MOSI发送一位数据,同时从设备通过MISO回送一位数据。这在写Flash、读ADC这些场景,主设备在发送命令字节的同时就能收到从设备返回的状态字节,效率很高。

全双工意味着,只要时钟在跑,两边都在采样,都在移位。这一点用Verilog写状态机时非常关键:你不可能"只发不收",也不可能"只收不发"。所以设计时既要有发送移位寄存器,又要有接收移位寄存器,发送和接收是同时完成的,只是你关心不关心接收到的内容而已。

2.2 四种工作模式是怎么来的

SPI的四种模式,由CPOL(Clock Polarity,时钟极性)和CPHA(Clock Phase,时钟相位)两个参数组合而来,这是实现时最容易出错的地方,也是调板子时和外部芯片对齐的最关键参数。

CPOL决定空闲状态时SCLK的电平:

  • CPOL = 0:空闲时SCLK为低电平
  • CPOL = 1:空闲时SCLK为高电平

CPHA决定数据采样发生在哪个边沿:

  • CPHA = 0:在第一个边沿采样(第一个边沿可能是上升沿,也可能是下降沿,取决于CPOL)
  • CPHA = 1:在第二个边沿采样

组合起来就是大家常说的SPI Mode 0、1、2、3。Mode 0(CPOL=0, CPHA=0)是最常见的模式,空闲低电平,上升沿采样,下降沿切换数据;Mode 3(CPOL=1, CPHA=1)也很常见,很多Flash芯片默认支持Mode 0和Mode 3。

这里我强烈建议,在写FPGA代码之前,先画一张时序图,把CPOL、CPHA对应的采样边沿和切换边沿都标清楚。不要凭感觉写,因为外部芯片的数据手册通常会用"Data setup time"、"Data hold time"、"Valid edge"这类描述,你需要把手册上的时序图翻译成你状态机的边沿动作。

2.3 SPI和IIC到底差在哪

热搜词里有不少人搜"spi和iic的区别",我在调试中也经常被问到。简单说,SPI是全双工、四线、无寻址、无应答;IIC是半双工、两线、带地址、带应答。SPI适合高速、大块、连续的数据传输;IIC适合低速率、简单控制、多设备挂总线。FPGA项目里的传感器数据采集、Flash读写、LCD驱动,SPI出现频率明显更高;IIC更多用在EEPROM、温湿度传感器这些低速场合。

说实话,我个人的体会是,如果你已经掌握SPI,再用FPGA实现IIC的核心难点,只剩下"应答时序"这一块。逻辑设计的基本套路是相通的。

2.4 数据格式和字节序

SPI协议本身没有规定数据的位序是MSB first还是LSB first,也没有规定一次传输的位宽一定是8位。协议只是定义了"怎么把位送出去"和"什么时候采样"。

因此,FPGA里的SPI实现必须要有"位宽参数化"和"位序可配置"的思维方式。我经常看到有人写死8位,结果换了一个12位的ADC,整个状态机都要改。在RTL设计里,把数据位宽定义成参数,把移位的顺序定义成变量,这种灵活性在SPI实现里太重要了。特别是ADC芯片,12位、16位、24位都很常见,你不可能每一次都重写一遍SPI逻辑。

3. 整体架构设计:先定需求再写代码

3.1 明确你的SPI是主还是从

这是设计开始前必须先回答的问题。主模式和从模式的代码差别非常巨大。

主模式的特点:由FPGA自己产生SCLK,时序主动权在自己手上。你做设计时,可以控制SCLK什么时候翻转、数据什么时候更新、什么时候采样。难点在于,你要保证输出给外部设备的时序符合它的要求。

从模式的特点:SCLK和CS都是外部输入的。这时候你的FPGA逻辑处于被动地位,要跟随时钟的节奏,难点在于跨时钟域和异步信号处理。因为SCLK是在FPGA外部产生的,它和FPGA的内部时钟是异步关系,你接收到的数据和时钟天然存在相位不固定的问题。

从我自己的经验来看,如果项目允许,尽量把FPGA设计成SPI主模式。主模式简单得多,调试也直观,逻辑分析仪一抓波形就能看出问题;而从模式,尤其是高速从模式,光是一个异步信号同步化就要费不少功夫,而且容易出现亚稳态问题。

3.2 确定时序参数

在你写代码前,把下面这个表格填清楚,这张表就是你的设计规格:

参数说明示例值设计依据
SCLK频率主模式时由FPGA产生10MHz外部从设备支持的最大SCLK
CPOL空闲电平极性0外部从设备手册
CPHA数据采样相位0外部从设备手册
数据位宽一次传输的位数8/16/24从设备寄存器或命令长度
CS有效电平片选极性低有效从设备手册
CS建立时间CS拉低到第一个SCLK边沿的时间t_setup >= 20ns从设备手册中的CS setup time
CS保持时间最后一个SCLK边沿到CS释放的时间t_hold >= 20ns从设备手册中的CS hold time

这些参数里,CS建立时间、保持时间经常被人忽略。很多外部设备对CS到SCLK的时序是有要求的,你如果CS刚拉低就出时钟,可能第一个字节就会出错。稍微有一点冗余的建立时间,反而更稳妥。

3.3 寄存器配置还是状态机

FPGA里实现SPI,有两种常见的做法:

一种是使用FPGA厂商提供SPI IP核。Xilinx有AXI Quad SPI,Intel有SPI Core。优点是稳定、经过验证,缺点是配置复杂、资源占用大、不够灵活。如果你只是想把SPI挂上总线,用IP核是省事的;但如果你的SPI需要做特殊时序,比如中途切换模式、多从机动态切换、和某个非标准外设对接,IP核反而碍手碍脚。

另一种是我今天主要讲的做法:用状态机自己写。SPI协议本身的时序并不复杂,用Verilog写一个可参数化的SPI Master,通常也就100多行核心逻辑。自己写的优势是,你可以完全掌控时序每一个边沿,出了问题看波形就知道是自己哪一步写错了,调试体验特别好。对于FPGA学习者来说,自己写一遍SPI,对"状态机如何控制时序"这个核心概念的理解会深入非常多。

4. 可复用的SPI Master核心代码实现

4.1 接口定义和模块划分

我自己用的SPI Master模块,接口设计是分成两层的:外层是简单的读写请求接口,内层是处理具体时序的SCLK生成和移位逻辑。

module spi_master #( parameter CLK_FREQ = 50_000_000, // FPGA系统时钟频率 parameter SCLK_FREQ = 10_000_000, // SPI时钟频率 parameter DATA_WIDTH = 8, // 数据位宽 parameter CPOL = 0, // 时钟极性 parameter CPHA = 0, // 时钟相位 parameter MSB_FIRST = 1 // 1:MSB先发送,0:LSB先发送 )( input wire clk, // 系统时钟 input wire rst_n, // 异步复位,低有效 // CPU/用户逻辑侧接口 input wire start, // 启动一次传输,脉冲信号 input wire [DATA_WIDTH-1:0] tx_data, // 要发送的数据 input wire [7:0] clk_div, // 时钟分频系数,可动态调整 output reg busy, // 忙指示,高电平表示正在传输 // SPI物理接口 output reg sclk, // 串行时钟 output reg cs_n, // 片选,低有效 output reg mosi, // 主出从入 input wire miso // 主入从出 );

端口划分上有个经验:把clk_div设计成可配置的寄存器而不是固定参数。这样在同一个工程里,你可以用软件动态调整SPI速率,适配不同从设备。比如FPGA和STM32连接时,STM32这边可能就有好几路SPI设备需要不同的速率。

4.2 状态机的三种状态设计

SPI主模式的发送流程,可以抽象成三个状态:IDLE、TRANSFER、DONE。核心思想是,在TRANSFER状态下,用一个计数器来产生SCLK,用另一个计数器记录已发送的位数。

localparam IDLE = 2'd0; localparam TRANSFER = 2'd1; localparam DONE = 2'd2; reg [1:0] state; reg [7:0] clk_cnt; // 时钟分频计数器 reg [7:0] bit_cnt; // 位计数器 reg [DATA_WIDTH-1:0] shift_reg; // 移位寄存器 reg sclk_r; // SCLK的寄存器版本

这个状态机的关键设计思路是:不直接用一个高频时钟去产生边沿,而是用分频计数器产生SCLK的翻转事件。每数到分频系数的一半,翻转一次SCLK。采样的时候,通过判定SCLK的当前电平和CPOL/CPHA的关系,来决定是更新数据还是采样数据。

4.3 移位和数据采样的正确姿势

在SPI主模式下,数据发送相对简单:每次SCLK的"数据切换边沿"到来时,移位寄存器移出一位,输出到MOSI。数据接收则恰恰相反,在SCLK的"采样边沿"到来时,把MISO上的电平打进来。

这里有个很核心的模块技巧:边沿检测。你需要在FPGA的主时钟域里检测SCLK的边沿。最常见的做法是,把SCLK寄存两拍,然后做异或逻辑:

reg sclk_d1; reg sclk_d2; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sclk_d1 <= 1'b0; sclk_d2 <= 1'b0; end else begin sclk_d1 <= sclk; sclk_d2 <= sclk_d1; end end wire sclk_rising = sclk_d1 & ~sclk_d2; wire sclk_falling = ~sclk_d1 & sclk_d2;

这个两拍寄存的做法,既完成了边沿检测,也把SCLK信号同步到了系统时钟域,避免亚稳态。很多人写SPI主模式,直接用sclk去做时钟触发always块,这在低速时问题不大,但在复杂工程里容易出很多时序问题。用系统时钟打两拍检测边沿,可以避免跨时钟域分析,也方便在状态机里处理。

采样时需要根据CPHA配置,决定是上升沿采样还是下降沿采样:

wire sclk_sample_edge = (CPHA == 0) ? sclk_rising : sclk_falling; wire sclk_shift_edge = (CPHA == 0) ? sclk_falling : sclk_rising;

CPHA=0时,第一个边沿采样,第二个边沿切换;CPHA=1时反过来。再加上CPOL决定空闲电平,这样一个通用驱动就出来了。

4.4 完整状态机逻辑

关键的核心状态机代码如下:

always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; sclk <= CPOL; cs_n <= 1'b1; mosi <= 1'b0; busy <= 1'b0; clk_cnt <= 8'd0; bit_cnt <= 8'd0; shift_reg <= {DATA_WIDTH{1'b0}}; end else begin case (state) IDLE: begin cs_n <= 1'b1; sclk <= CPOL; busy <= 1'b0; if (start) begin cs_n <= 1'b0; // 拉低片选 state <= TRANSFER; bit_cnt <= 0; clk_cnt <= 0; shift_reg <= tx_data; busy <= 1'b1; end end TRANSFER: begin // SCLK生成逻辑:计数到分频系数的一半时间翻转一次 if (clk_cnt == (clk_div/2 - 1)) begin clk_cnt <= 0; sclk <= ~sclk; end else begin clk_cnt <= clk_cnt + 1; end // 数据移位:在数据切换边沿 if (sclk_shift_edge) begin if (MSB_FIRST) begin mosi <= shift_reg[DATA_WIDTH-1]; shift_reg <= {shift_reg[DATA_WIDTH-2:0], 1'b0}; end else begin mosi <= shift_reg[0]; shift_reg <= {1'b0, shift_reg[DATA_WIDTH-1:1]}; end bit_cnt <= bit_cnt + 1; end // 数据采样:在采样边沿 if (sclk_sample_edge) begin if (MSB_FIRST) begin shift_reg <= {shift_reg[DATA_WIDTH-2:0], miso}; end else begin shift_reg <= {miso, shift_reg[DATA_WIDTH-1:1]}; end end if (bit_cnt == DATA_WIDTH && sclk_shift_edge) begin state <= DONE; end end DONE: begin cs_n <= 1'b1; // 释放片选 state <= IDLE; busy <= 1'b0; end endcase end end assign rx_data = shift_reg; // 实时透出接收移位寄存器,方便用户在IDLE后读取

这里有三个细节要注意:

第一,我在TRANSFER里同时检测了移位边沿和采样边沿,这两个边沿是交替出现的。当你用系统时钟去驱动时,上升沿和下降沿间隔了半个SCLK周期,所以它们不会冲突,因为一个always块里是顺序执行的,完美避开了竞争。

第二,判断"发完最后一位"的时机很关键。我是在bit_cnt等于DATA_WIDTH,并且最后一个移位边沿到来时才跳转到DONE。这样保证SCLK完整走完最后一位,CS释放时不会截断最后一个数据。很多人CS拉早了,最后一个bit直接被切掉,就是从设备收不到完整字节的原因。

第三,从设备返回的数据在最后一个采样边沿就已经全部在shift_reg里了,所以从DONE回到IDLE之后,rx_data已经是完整的数据了。不需要额外等一拍。

4.5 从模式设计提示

如果你的场景是FPGA做从设备,接收MCU的命令,设计思路需要做几处调整。

从模式的核心是:SCLK和CS都是从外部进来的,必须先在FPGA内部做两级同步,然后检测SCLK的边沿。因为外部时钟和FPGA内部时钟是异步的,直接使用外部时钟做触发是不安全的,尤其在系统时钟频率和SCLK频率可比的场景。

采样数据也建议用系统时钟打拍后采样,而不是直接用SCLK作为寄存器的时钟。因为FPGA内部走线的时钟树需要统一,分散的时钟会导致布局布线的时序问题,容易出现物理层面不可复现的bug。

5. 硬件片选与软件片选的抉择

5.1 硬件片选的优势

硬件片选是指,CS信号直接由SPI模块在传输开始/结束时自动控制,用户只需要发出start信号,模块自己完成CS拉低、发送、释放。这种方式的好处是:

  • 时序可控性最好:你可以精确控制CS拉低到第一个SCLK边沿的时间,保证满足从设备的建立时间要求
  • 不需要用户软件介入:避免用户在CS拉低之后、启动传输之前的一段窗口期里受到干扰
  • 方便实现连续传输模式:在Flash的连续读、连续写场景,硬件片选可以自动维持CS为低,直到整块数据搬完

我在W25Q128这类Flash芯片上,硬件片选体验非常好,时序干净,读出来的数据基本不需要做额外的毛刺处理。

5.2 软件片选的灵活之处

软件片选是把CS信号作为一个普通的GPIO,由用户逻辑或软件在SPI传输之前手动拉低,传输结束后再手动拉高。

这适合什么场景呢?第一个是SPI总线上挂了多个从设备,你需要动态选择,比如一个ADC、一个Flash、一个SD卡,它们各自有独立的CS。第二个是从设备的CS时序有特殊要求,需要在中间人为地制造一些缝隙,比如某些传感器要求CS低电平保持时间大于某个值。第三个是调试阶段,你还没有完全掌握从设备的CS时序要求,先用软件片选手动测出窗口,再固化到硬件控制里。

5.3 我的选择经验

我的习惯是:默认用硬件片选,因为可靠性高,状态机自己知道什么时候该拉CS、什么时候该释放,不会出现软件延时抖动导致CS时序漂移的问题;只有遇到多个从设备需要同时挂在回应件场景下才用软件映射,把CS信号做成一个Memory Map的寄存器。

另外,有些芯片对CS的要求比较"娇气"。比如AFE4490这类高精度模拟前端,它的SPI接口对CS时序非常敏感,CS释放过早会导致寄存器写入失败。这类芯片我建议哪怕是FPGA环境,也做一次芯片手册的时序核对,确认CS建立/保持时间满足之后再决定方案。

6. 仿真验证:实战经验分享

6.1 搭建一个可复现的testbench

在我自己的开发流程里,SPI模块写完之后,第一件事不是直接上板,而是先在仿真环境里跑一遍。仿真可以快速确认:

  • 字节顺序是否正确(MSB first还是LSB first)
  • CPOL和CPHA是否按预期工作
  • CS时序是否满足
  • 发送的数据和接收的数据是否能对上

一个基本的testbench包含两部分:时钟生成和激励产生。

`timescale 1ns/1ps module tb_spi_master; reg clk = 0; reg rst_n = 0; reg start = 0; reg [7:0] tx_data = 8'hA5; wire [7:0] rx_data; wire busy; wire sclk; wire cs_n; wire mosi; reg miso = 0; // 25MHz系统时钟(50MHz也可以,这里为了看得清楚,用的25MHz) always #20 clk = ~clk; spi_master #( .CLK_FREQ (25_000_000), .SCLK_FREQ ( 5_000_000), .DATA_WIDTH (8), .CPOL (0), .CPHA (0), .MSB_FIRST (1) ) dut ( .clk (clk), .rst_n (rst_n), .start (start), .tx_data(tx_data), .busy (busy), .sclk (sclk), .cs_n (cs_n), .mosi (mosi), .miso (miso) ); // 模拟一个假从设备:把MISO回应为固定值,或者回环到MOSI always @(posedge sclk) begin miso <= mosi; // 回环模式,方便检查 end initial begin rst_n = 0; #100; rst_n = 1; #50; @(negedge clk); start = 1; tx_data = 8'hA5; #20; start = 0; // 等待传输完成 wait (!busy); $display("Transfer done, rx_data = %02x", rx_data); #100; $finish; end endmodule

上面这个testbench我加了一个回环逻辑,把MOSI直接回送到MISO,这样仿真环境里就能看到收发数据是否一致。如果tx_data = A5,rx_data也应该是A5,说明整个数据通路没有问题。

6.2 仿真波形里到底要看什么

波形打开之后,不要只盯着最后结果,需要逐个窗口核对:

第一段看CS拉低之后,到第一个SCLK上升沿的时间间隔。看它是否满足你设计的cs_setup时间要求。这段如果在仿真里都太短,说明状态机的CS拉低时机需要调整,比如可以单独加一拍CS_Setup状态。

第二段看SCLK是不是均匀的方波,有没有脉宽不对称的情况。我之前用分频计数器的时候,计数初值没设对,导致SCLK高电平时间比低电平时间长了1拍,波形看起来不错,但实际从设备对占空比敏感时就会出错。

第三段看MOSI在SCLK的哪个位置翻转。这个是最能检验CPOL/CPHA有没有配错的地方:CPHA=0时,MOSI应该在SCLK的下升沿后立刻更新,然后在上升沿采样,数据在采样窗口内保持稳定;如果观察到的现象恰好相反,说明CPHA配反了。

第四段看CS释放的时机。CS从低变高的那一刻,必须要等到最后一个SCLK边沿完成之后。如果CS和SCLK同时动作,多半是有问题的。

6.3 仿真中踩过的典型坑

仿真里最常见的坑,一个是"沿"和"电平"搞混。SPI协议是边沿触发,但仿真波形会因为延时设置导致边沿和采样窗口边界模糊。建议在testbench里加$monitor或者$display打印关键寄存器的变化,不要光看波形,有时候波形缩得太小,边沿错位肉眼根本看不出来。

另一个坑是起始条件没给够。像SPI这种有片选的协议,如果rst_n释放后没有等足够长时间就启动传输,可能出现一些奇怪行为。我个人习惯是复位后至少等系统时钟10拍以上再拉start,保证状态机和内部寄存器都回到确定的初始状态。

7. 上板调试中的常见故障与排查实录

7.1 波形看着没问题,设备就是不响应

这是最气人的一种情况。用逻辑分析仪抓波形,SCLK正常,MOSI也按时翻转,但外设始终没有任何回应。

这种时候第一件事是把数据手册翻出来,核对CPOL和CPHA配置。有一次我做STM32和FPGA通过SPI对接,两边都以为自己是"标准模式",结果一个默认Mode 0,一个默认Mode 3,看起来好像"标准"其实完全不同。SPI有4种模式,必须两边都核对清楚,这是最常见的配置错误。

其次,检查是否有从设备的时钟速率上限。如果FPGA主时钟很高,分频系数取了一个比较激进的值,SCLK超过了从设备的额定值,设备也会不响应。

再有一个隐藏问题:MISO信号没有上拉或下拉。MISO通常是推挽输出,但有些从设备在CS拉高时释放总线,变成高阻态。如果FPGA引脚没有启用内部上拉,MISO在总线空闲时就是浮空状态,一旦有一点点干扰,采样回来的数据就是乱的。

7.2 数据错位、串位

数据错位但波形"看起来正常",这是SPI调试里最隐蔽的问题。

第一个可能的原因是采样边沿和切换边沿没对准。如果CPHA配置不对,数据在采样瞬间刚好处于切换过渡阶段,采回来的就是毛刺值。这种问题在仿真里不容易发现,因为仿真环境里信号切换是瞬时的,而真实电路中MOSI上存在传播延时和上升沿时间。

解决方法:下降沿发数据、上升沿采样是Mode 0的标准做法。如果你发现数据错位,可以尝试把数据发送边沿往前提半个SCLK周期,给数据稳定留出余量,也就是用"在SCLK下沿之后的某个时刻更新MOSI,保证上升沿前数据已经稳定"这个思路调整。

第二个可能的原因,是时钟边沿检测失败。我之前说过要用两级同步检测边沿,如果你图省事,直接用sclk作为触发时钟的always块,在FPGA布局布线后偶尔会出现"偶尔丢一拍时钟"的怪问题。这种问题非常难查,最好在一开始就按同步设计的方法来写。

7.3 CS释放导致最后一个字节丢失

这是Flash和ADC类设备上非常典型的问题。最后一个字节的数据在最后一个SCLK边沿才有效,如果你的状态机检测到"位计数达到数据宽度"就立刻释放CS,很可能会把最后一位数据截掉。

我建议在状态机里设置一个"尾巴状态"或者"延后一拍释放CS"。也就是说,即使最后一比特已经发送完成,也要把CS维持低电平至少半个SCLK周期再释放。为从设备的数据锁存提供时间窗口。这也是为什么我前面的代码里,DONE状态单独占了状态机的一拍时间,目的就是为了让CS晚于SCLK释放。

7.4 FPGA和STM32通过FMC/SPI通信时容易犯的错

STM32H743这类高端MCU跟FPGA的接口方式很多,FMC(灵活的存储控制器)是很多人选用的方式。FMC把FPGA当作外部存储器来访问,FPGA这边则会做一个总线转SPI的桥接逻辑。这种情况下有一个很大的坑:FMC写入的数据位宽和SPI实际发送的位宽可能不一致。

比如FMC总线是16位的,而某个SPI从设备的寄存器只有8位。如果FPGA这边没有做高位屏蔽或者数据拼接,就会出现写一次命令,SPI发出的是两个字节,从设备收到多余的数据。解决的办法是:解析FMC的地址线,让每个寄存器地址精确映射到一次SPI传输,而不是让数据总线直接对接SPI的发送寄存器。

另外,FMC接口写入的时序比较快,而SPI本身相对慢。如果MCU侧连续写入多个寄存器,FPGA这边如果还没有处理完上一笔事务,新的请求就会丢失或者覆盖。所以务必要在FMC接口和SPI模块之间加FIFO做缓冲,并且提供满标志给MCU读取。这个细节我踩过太多次了,不加FIFO,MCU写太快,FPGA就会"漏命令"。

7.5 调试工具推荐

SPI调试建议使用逻辑分析仪,不要只依赖示波器。SPI是数字协议,逻辑分析仪可以把CS、SCLK、MOSI、MISO四根线一起抓下来,还带协议解析,直接看你发的是什么数据、收的是什么数据,极其直观。我用的是那种几十块钱的8通道逻辑分析仪,配合开源软件,日常SPI调试完全够用。

如果现场没有逻辑分析仪,用示波器至少同时看两路:一路SCLK,一路MOSI或CS。用余辉模式可以看到信号翻转的瞬态过程,判断时序余量。

8. 代码可维护性:参数化与通用化

8.1 参数化才是"一次写好,处处复用"

我见过不少同学写的SPI模块,数据和时钟都写死在代码里。一旦换芯片,整段代码改得面目全非。真正好用的SPI模块,应该参数化到"改几个parameter就能适配不同外设"的程度。

我自己的参数表一般是这样的:

  • DATA_WIDTH:支持8、12、16、24、32位
  • MSB_FIRST:可切换
  • CPOL、CPHA:可配置
  • CLK_DIV:可在运行时通过寄存器调整分频系数

有了这些参数,换一个ADC、换一个Flash,基本就是改初始值的事,状态机的骨架完全不用动。

8.2 异步FIFO隔离时钟域

如果在设计中,SPI的时钟域和系统时钟域不同(比如SPI SCLK来自PLL的独立时钟域),建议在SPI模块外面包一层异步FIFO。发送侧写FIFO用系统时钟,读FIFO送进SPI模块用SCLK时钟域;接收侧反过来。这样能有效避免跨时钟域导致的数据错乱。

我实际的项目中,经常遇到"MCU通过FMC总线访问FPGA,FPGA再通过SPI控制ADC",FMC侧和SPI侧的时钟不同步,FIFO几乎是必选项。而且FIFO深度建议不小于16,能缓冲Burst写入的峰值。

8.3 添加可读的状态寄存器

最后一条经验:在SPI模块里增加一个状态寄存器,把busy、error、当前状态、最近一次传输的bit数暴露出来。上板调试时,通过JTAG或者UART把这些寄存器读回来,能极大缩短排查时间。很多看似"玄学"的问题,其实只要看寄存器状态就能定位到具体环节。

我调试SPI从模式的时候,就把"最近一次SCLK边沿检测计数"写进状态寄存器,一旦MCU那边的时钟极性配置诡异,我通过寄存器看到的计数异常就能反向定位MCU的配置错误。

9. 速率评估与极限设计经验

9.1 能跑多快取决于什么

SPI的极限速率,不光是SCLK频率本身,还取决于FPGA内部逻辑的时序收敛情况。对于50MHz的SCLK,方波的上升沿和下降沿都快,信号完整性的要求随之提高,PCB上如果有长的走线,反射可能导致采样错误。

我自己的经验是,在FPGA的普通IO上,SPI频率跑到50MHz以上,就要开始关注IO走线长度、是否加了串联电阻、板厂的阻抗控制是否精确了。对于一般的低速外设(ADC、Flash、屏幕),10~30MHz是完全够用的区间,也是逻辑最容易做到稳定可靠的区间。

9.2 如何优化速率

如果你确实需要更高的SPI速率,优化的重点有两个:

一个是减少SCLK产生逻辑的组合逻辑延时。把分频计数器的比较逻辑做简化,比如"计数到0"比"计数到某个任意值"逻辑更快。一个是尽量把MOSI更新的逻辑靠后,保证数据在采样窗口内的建立时间尽可能长。

还有一个小技巧:在SCLK每个翻转沿之前一拍,就把MOSI更新好。这意味着你没有完全按SCLK的边沿来发数据,而是提前一点更新。这在FPGA内部实现上,等效于把数据切换和时钟翻转解耦,数据稳定窗口变宽。这也是很多优秀SPI驱动性能更好的原因。

10. 个人踩坑总结:几个值得反复咀嚼的教训

做了这么多年FPGA开发,SPI这个接口我前前后后写过不下十版,每次都有新的理解。有几条个人体会,分享给大家参考。

第一,永远不要相信"标准SPI"这种说法。不同的外设,时序定义千差万别。接到一个新芯片,第一件事一定是把手册里的时序图,翻译成自己状态机的四个关键节点:CS拉低位置、第一个采样边沿位置、最后一个采样边沿位置、CS释放位置。翻译完了再写代码,成功率能翻一倍。

第二,仿真和上板截然不同。仿真解决的是"逻辑对不对"的问题,上板解决的是"物理世界怎么匹配"的问题。很多问题仿真里根本不存在,因为仿真里没有传播延时、没有信号完整性问题、没有IO电平判定的模糊区。所以仿真过了,只是开始,不是结束。

第三,看波形要看出"趋势"而不是"瞬间"。SPI调试中,单次触发能抓到问题,但更隐蔽的问题是间歇性的。我建议逻辑分析仪用连续触发或者长时间抓取模式,看看很长时间内有没有偶发的错位,因为这种问题往往是片选时序边缘抖动或者外部干扰引起的。

第四,代码的可读性和可维护性极其重要。SPI模块虽然代码量不大,但至少要在文件头部把时序参数、状态定义、使用方式写清楚。因为三个月后你自己都未必记得当初为什么CPHA要这样配置。写设计笔记,是FPGA工程师职业素养的一部分。

第五,实在调不通的时候,把SCLK降速试试。很多时候硬件就在那里,只是你的时序余量不足。把SCLK降一半,很多问题会暴露得更清楚——如果降速后依然出错,说明问题不在速率,而在协议配置或者接线本身。这一步能帮你迅速缩小排查范围。

如果你打算把SPI接到非标的设备上,我建议先把标准的SPI主模式调通,再做定制化修改。把基础模块验证扎实,后面加什么花活都容易。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询