FPGA实战:SPI通信从零实现到稳定跑通的完整指南与避坑经验
2026/9/5 6:25:38 网站建设 项目流程

做FPGA开发也快十年了,从最早的Lattice到现在的Xilinx、Intel Cyclone系列都过过手。SPI这个接口看着简单,实际在工程里翻车的概率却不低。尤其是当你把它挂到FPGA上,时序、跨时钟域、片选策略、主机从机角色分配,每一个点都能让人调上半天。今天就拿这段时间我手头一个项目的真实经历,把SPI通信在FPGA上从零实现到稳定跑通的思路、代码、调试方法全捋一遍。不管你是刚摸FPGA的新手,还是被SPI折磨过的老哥们,这篇应该都能给你点参考。

1. SPI通信核心机制回顾

在动手写代码之前,先把SPI协议本身吃透。很多问题看起来是代码写错了,根子上其实是协议理解有偏差,或者是物理层、时序边界没考虑清楚。

1.1 物理层连接与信号定义

SPI全称Serial Peripheral Interface,摩托罗拉在七十年代搞出来的串行外设接口总线,四根线搞定全双工通信,到今天工业级板卡、传感器、存储芯片还在大规模用。FPGA里做SPI,本质就是把这四根线的时序用逻辑门实现出来。

  • SCLK(Serial Clock):时钟线,由主机产生,从机完全被动接收。
  • MOSI(Master Out Slave In):主机输出、从机输入。
  • MISO(Master In Slave Out):主机输入、从机输出。
  • CS/SS(Chip Select):片选信号,低电平有效。主机拉低时选中从机,通信结束后拉高释放总线。

工程里最常见的问题出在CS上,尤其是硬件自动片选和软件手动片选混用的时候,后面我单独开一节细说。这里先记住一个关键点:SPI不是总线协议,没有设备地址概念。 要跟谁通信,就把谁的CS拉低,同一时刻总线上只允许一个从机被选中,否则MISO会打架。

1.2 四种工作模式与时钟极性相位

SPI的四种模式由CPOL(Clock Polarity,时钟极性)和CPHA(Clock Phase,时钟相位)组合决定。CPOL决定空闲态时钟电平,CPHA决定数据采样沿。

模式CPOLCPHA空闲时钟数据采样沿数据切换沿
Mode 000低电平上升沿下降沿
Mode 101低电平下降沿上升沿
Mode 210高电平下降沿上升沿
Mode 311高电平上升沿下降沿

FPGA实现时,很多人直接在代码里写always @(posedge sclk)就完事,完全没考虑CPOL和CPHA的匹配。我实测过的项目里,传感器芯片比如AD7190要求Mode 3,Flash芯片W25Q128JV支持Mode 0和Mode 3,LCD驱动ST7789默认Mode 0,ADC芯片ADS1256则是Mode 1。 对接不同外设前,第一件事就是查数据手册里SPI章节的时序图,确定模式再开写。

从实现角度,主机侧时钟相位其实是最好控制的:你生成SCLK的计数器在哪个边沿翻转,决定的就是CPOL;你在模拟SCLK的时候,在每个SCLK周期的哪个点上锁存MISO、更新MOSI,决定的就是CPHA。所以主机的SPI模式是自己定义出来的,不匹配是设计失误。而从机侧就没这么简单了,你的采样窗口要严格对齐外部输入时钟的边沿,这在跨时钟域下很容易出问题,后面会专门说。

1.3 数据帧格式与传输粒度

SPI没有固定帧格式,常见的是8位、16位、24位、32位,甚至有些传感器是12位或者20位。FPGA实现时要注意,SPI的“位计数”是主机说了算的,主机发出多少个SCLK脉冲,从机就必须接收多少位,双方必须提前约定一致。

比如ADS1256的24位转换结果,如果主机只发16个时钟,从机只会输出高16位,低8位永远拿不到;如果主机发32个时钟,读到的结果还要自己移位。所以设计SPI控制器时,位的宽度最好做成参数化,不要写死在代码里。后续代码里我会把DATA_WIDTH设计成可配置的。

2. FPGA实现SPI的方案选型与架构设计

协议清楚了,接着就是架构层面的取舍。这里直接说结论:FPGA上实现SPI,不是只能写一版Verilog就通了,你得先想清楚角色(主机还是从机)、时钟域策略(同步设计还是异步桥接)、片选策略(硬件还是软件)这三大问题,不然返工成本非常高。

2.1 主机模式时钟生成策略

主机模式下,SCLK必须由FPGA内部产生。这里有个常见误区:想当然地用计数器对系统时钟分频,却没有考虑占空比和相位抖动。对于低速SPI(1MHz以下),直接计数器分频完全没问题。但对于10MHz以上的高速SPI,我建议用PLL/MMCM产生专门的SPI时钟域,或者至少在RTL里把分频逻辑约束好,避免组合逻辑直接驱动SCLK引脚,那会造成毛刺。

两种常用做时钟的方式对比:

方式实现优点缺点
计数器分频系统时钟计数翻转简单灵活,频率实时可调高速时占空比与抖动控难保证
PLL输出调用IP核配置频率时钟质量高,适合高速外设频率固定,改动需重配IP

我在实际项目里是这样处理的:SPI时钟频率低于5MHz时用计数器分频,高于5MHz时改用PLL。注意计数器分频时SCLK的相位不能完全与系统时钟对齐,驱动外部器件没有问题,但如果SCLK还要反馈回FPGA内部采数据,就要特别小心建立时间。

2.2 从机模式外部时钟的跨时钟域处理

从机模式的复杂度和主机完全不在一个级别。外部主机产生的SCLK与FPGA内部时钟完全异步,如果直接用内部时钟去采样外部SCLK,很容易打拍打到亚稳态,或者漏采。

最稳的做法是:先对外部SCLK做两级寄存器同步(打两拍),然后用同步后的时钟沿作为事件触发,再配合和内部系统时钟的双时钟域握手。具体实现细节后面第四节会展开。

2.3 模块划分与整体架构

SPI控制器通常划分为三个独立的子模块,职责互不交叉,方便测试和复用。

  • spi_master_ctrl:主机核心状态机,产生片选、时钟、移位寄存器控制信号。
  • spi_slave_ctrl:从机核心状态机,识别片选与时钟沿,收发数据。
  • spi_reg_file:寄存器或FIFO接口,连接上层业务逻辑(如ADC采样控制、Flash命令解析)。

为什么要把从机和主机拆开?因为项目里经常需要同时挂多个SPI外设,有的芯片只能当从机(比如EEG信号采集前端ADS1299),有的模块只支持主机(比如一些电阻触摸屏控制器),拆开后单一模块可以独立复用,组合起来也更灵活。

3. 核心Verilog代码实现详解

架构定好了,下面进入最核心的环节:RTL代码。这一节不玩虚的,直接给出可落地的Verilog实现,并逐段解释设计意图。所有代码我都简化过,去除与主题无关的冗余逻辑,但保留工程上必需的边界处理。

3.1 参数化SPI主机模块实现

先看主机模块。这个模块支持参数化数据位宽、时钟分频系数、CPOL/CPHA模式选择。设计思路是:状态机按字节/数据帧为单位推进,每个数据帧内部用位计数器控制SCLK的上升、下降沿与数据移位。

module spi_master #( parameter DATA_WIDTH = 8, parameter CLK_DIV = 50, // 系统时钟/SCLK = CLK_DIV,设为偶数 parameter CPOL = 0, parameter CPHA = 0, parameter CS_HOLD_CLKS = 2 // 片选维持时钟数 )( 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, output reg sclk, output reg cs_n, output reg mosi, input wire miso ); localparam IDLE = 3'd0; localparam CS_SETUP = 3'd1; localparam TRANSFER = 3'd2; localparam CS_HOLD = 3'd3; localparam DONE = 3'd4; reg [2:0] state, next_state; reg [15:0] clk_cnt; reg [3:0] bit_cnt; reg [DATA_WIDTH-1:0] shift_reg; // 时钟分频:sclk由clk_cnt翻转产生 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sclk <= CPOL ? 1'b1 : 1'b0; clk_cnt <= 0; end else if (state == CS_SETUP || state == CS_HOLD) begin sclk <= CPOL ? 1'b1 : 1'b0; clk_cnt <= 0; end else if (state == TRANSFER) begin clk_cnt <= (clk_cnt == CLK_DIV - 1) ? 0 : clk_cnt + 1; if (clk_cnt == CLK_DIV - 1) sclk <= ~sclk; end end // 数据移位发送/接收 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin shift_reg <= 0; mosi <= 1'b0; end else if (state == CS_SETUP) begin shift_reg <= tx_data; mosi <= tx_data[DATA_WIDTH-1]; end else if (state == TRANSFER && clk_cnt == CLK_DIV/2 - 1) begin // 在SCLK沿前更新数据(对应CPHA=0时先更新后采样) mosi <= shift_reg[DATA_WIDTH-2]; shift_reg <= {shift_reg[DATA_WIDTH-2:0], miso}; end end // 接收数据锁存 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin rx_data <= 0; end else if (state == TRANSFER && clk_cnt == CLK_DIV - 1) begin rx_data <= {rx_data[DATA_WIDTH-2:0], miso}; end end // 状态机推进 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; bit_cnt <= 0; busy <= 0; cs_n <= 1'b1; end else begin state <= next_state; case (next_state) IDLE: busy <= 0; CS_SETUP: begin cs_n <= 1'b0; busy <= 1; end TRANSFER: begin if (bit_cnt == DATA_WIDTH) begin bit_cnt <= 0; end else if (sclk_rise_evt) begin bit_cnt <= bit_cnt + 1; end end CS_HOLD: cs_n <= 1'b0; DONE: cs_n <= 1'b1; endcase end end endmodule

这段代码有几个工程细节要重点说明:

  • CLK_DIV必须为偶数,保证SCLK占空比50%。实际中我没见过完全苛刻的占空比要求,但是50%对绝大多数从设备的时序计算最友好。
  • 发送与接收都用了移位寄存器,但没有分开两个寄存器,而是{shift_reg[DATA_WIDTH-2:0], miso}这样一位一拍,高位先发、低位后收,正好对应SPI的MSB First,发收同时完成。
  • 状态机里加了一个CS_HOLD状态,这是给那些需要CS低电平保持一段时间才能正确锁存数据的芯片用的。比如有些ADC在CS上升沿完成内部数据锁存,CS拉太低太快会丢数据。

3.2 参数化SPI从机模块实现

从机模块的难点在于外部SCLK完全异步,不能直接用posedge sclk触发。

module spi_slave #( parameter DATA_WIDTH = 8, parameter CPOL = 0, parameter CPHA = 0 )( input wire clk, input wire rst_n, input wire sclk, input wire cs_n, input wire mosi, output reg miso, output reg tx_done, input wire [DATA_WIDTH-1:0] tx_data, output reg [DATA_WIDTH-1:0] rx_data ); reg sclk_d1, sclk_d2, sclk_rise, sclk_fall; reg cs_d1, cs_d2, cs_start, cs_end; reg [3:0] bit_cnt; reg [DATA_WIDTH-1:0] shift_rx; reg [DATA_WIDTH-1:0] shift_tx; // 同步外部信号到内部时钟域 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sclk_d1 <= 0; sclk_d2 <= 0; cs_d1 <= 1; cs_d2 <= 1; end else begin sclk_d1 <= sclk; sclk_d2 <= sclk_d1; cs_d1 <= cs_n; cs_d2 <= cs_d1; end end assign sclk_rise = sclk_d2 & ~sclk_d1; // 检测外部时钟上升沿 assign sclk_fall = ~sclk_d2 & sclk_d1; // 检测外部时钟下降沿 assign cs_start = ~cs_d2 & cs_d1; assign cs_end = cs_d2 & ~cs_d1; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin bit_cnt <= 0; shift_rx <= 0; miso <= 1'bz; end else if (cs_start) begin bit_cnt <= 0; shift_tx <= tx_data; miso <= tx_data[DATA_WIDTH-1]; end else if (cs_end) begin tx_done <= 1; rx_data <= shift_rx; miso <= 1'bz; end else begin tx_done <= 0; case ({CPHA, sclk_rise, sclk_fall}) 3'b000, 3'b100: ; // 无关沿 3'b001, 3'b101: begin // 非采样沿切换输出 if (!cs_d2) begin miso <= shift_tx[DATA_WIDTH-2]; shift_tx <= {shift_tx[DATA_WIDTH-2:0], 1'b0}; end end 3'b010, 3'b110: begin // 采样沿收数据 if (!cs_d2) begin shift_rx <= {shift_rx[DATA_WIDTH-2:0], mosi}; bit_cnt <= bit_cnt + 1; end end endcase end end endmodule

这段从机代码里的关键设计有两点:

  • 外部信号全部先打两拍同步,sclk_risesclk_fall是由clk触发的脉冲信号,之后所有逻辑都以clk为时钟,从根上规避了亚稳态。代价是检测沿比真实沿晚了几个内部时钟周期,但只要内部时钟频率远高于外部SCLK,这个延迟可以接受。内部时钟至少要比SCLK快4倍以上,这是经验值,代码里用case同时处理CPOL和采样沿的关系。
  • MISO在CS无效时输出高阻1'bz,这是多从机共享MISO总线的关键。如果CS没有选中还把MISO驱动成强高或低电瓶,会直接拉死MISO总线。

3.3 发送接收数据MSB/LSB顺序控制

SPI器件有的要求MSB first,有的要求LSB first。比如ST7789用的是MSB,但有些射频芯片(CC1101)可以用MSB或LSB配置。更麻烦的是,FPGA内部数据总线的字节序和SPI移位顺序可能不一致,这时就会遇到两个经典困惑:移位寄存器到底该从最高位还是最低位移,以及外部给的数据字节要不要先翻转。

工程上的统一方案是:在模块内固定用MSB first的发送逻辑,LSB需求在数据进入移位寄存器前做一次位反转。 位反转可以做成函数或者纯组合逻辑:

function [DATA_WIDTH-1:0] bit_reverse(input [DATA_WIDTH-1:0] d); integer i; for (i = 0; i < DATA_WIDTH; i = i + 1) bit_reverse[i] = d[DATA_WIDTH-1-i]; endfunction

这样做的好处是SPI核心逻辑只维护一种移位方向,错误率大幅下降。数据宽度是可参数化的,所以位反转函数也做成参数化。

4. 硬件片选与软件片选的实践对比

标题热词里专门有“spi硬件片选与软件片选”,这个点真不能跳过。我在实际项目里既吃过硬件片选的哑巴亏,也在软件片选上踩过坑,这里好好掰扯一下。

4.1 硬件片选与软件片选的基本差异

硬件片选由处理器(单片机/FPGA内部外设IP)在SPI传输开始时自动拉低CS,在传输结束后自动拉高,全程不需要软件干预。软件片选则是把CS当作普通GPIO,由用户代码在开始传输前手动拉低、结束后手动拉高。

一图胜千言,两者的工程差异如下:

维度硬件片选软件片选
控制粒度由SPI控制器自动,起始/结束固定完全靠程序控制,灵活
连续传输每包数据间CS会拉高再拉低可维持CS低电平,实现多包连续传
CPU占用不需要干预每字符前后都要操作GPIO
时序精度通常有硬件保证(但受FIFO影响)受软件调度延迟抖动影响较大
适用场景单包短数据、中断频繁的场景Flash页编程、长数据流、多字节帧

4.2 FPGA实现时为什么推荐软件片选

我发现有些新手在FPGA里搭SPI,习惯性模仿MCU外设的硬件片选思路,经常会遇到这样的现象:用IP核自带的自动CS功能,传输完成CS立刻拉高,但下一包数据又来的时候,CS才刚拉低,从机还没来得及释放总线。

在FPGA里,SPI控制器是自己用状态机写的,不存在“硬件自动”和“软件手动”的严格区分。 但逻辑上,我强烈建议把CS控制独立于数据移位状态机,单独用一个CS状态机控制。好处:

  • 可以自由设定CS建立时间(从拉低到第一个时钟边沿之间的延迟)。
  • 可以自由设定CS保持时间(最后一个时钟边沿到拉高之间的延迟)。
  • 可以方便实现“连续传输”:同一个事务内连续发送多个8/16位数据块,CS全程保持低,直到整帧事务结束。

代码里我专门用了CS_SETUP和CS_HOLD两个状态,就是干这个的。除非是那种指令地址数据三位一体的SPI Flash,否则基本都可以保持CS低电平连续写完一整个命令序列,不必中间频繁拉高拉低。

4.3 多从机级联时的片选管理

当FPGA作为主机连接多个从机时,每个从机必须独享一条CS线。理论上CS线可以复用普通IO,并行数量取决于FPGA引脚资源。

但有个细节很容易被忽略:CS拉高后,从机内部的复位和状态恢复需要时间。 不同芯片不一样,有的需要几微秒,有的需要几十微秒(比如CC1101切换模式后需要等待晶体稳定)。如果在CS拉高后立刻再次拉低开启下一包,从机可能还处于内部复位状态,第一字节直接丢。

通用的办法是在CS状态机里加一个CS_IDLE_GAP参数,两次CS有效之间强制插入空闲间隔。我一般设为8到64个SCLK周期,具体看从机手册的“CS high time”参数。这个参数不加,就容易出现“偶发丢字节”的疑难杂症。

5. 跨时钟域与数据同步的细节处理

前面从机代码里已经用到两级同步器,但这里还是单独开一节。因为跨时钟域是SPI在FPGA上出bug最多的重灾区,很多项目“仿真全对,上板就错”就是栽在这里。

5.1 为什么不能直接采样外部信号

外部主机产生的SCLK和MISO信号,相对FPGA内部时钟是完全异步的。直接用always @(posedge clk)去采样,一旦信号沿恰好落在内部时钟建立保持窗口附近,寄存器输出就会进入亚稳态。亚稳态可能持续几十到几百皮秒甚至更长,且最终稳定值无法预测,有时候会给出一个错误的0/1,有时候会给出高低电平之间的中间电压。

两级同步器并不能消除亚稳态,它只是给了亚稳态一个完整的时钟周期去稳定,再去采样第二拍就基本不会再出现不可控结果。 这是FPGA设计的基本原则:任何跨时钟域信号都必须打至少两拍才能参与逻辑判断。

5.2 同步器的小心机:沿检测与总线信号

SPI跨时钟域的核心信号是SCLK和CS,这两者必须先同步到内部时钟域,再检测边沿。

关于边沿检测,有一个经验性做法:同步后信号进行打拍逻辑,用sync_regsync_ff组合出脉冲。如果被同步信号本身是一个慢速的SCLK,用到的脉冲宽度只是内部时钟的一个周期,这正好适合状态机使用。

另一个容易踩的坑是数据信号MISO。数据线不能直接打两拍后当普通信号用吗?可以,但要注意:如果MISO在CS有效期间连续变化,你采样时打两拍只是稳定了采样点,不代表采准确了。必须保证采样点相对于数据变化沿有足够的建立时间。 所以数据信号打两拍后,还要在SCLK的有效沿(由同步后的脉冲指示)那天锁存,才算是真正的跨时钟域安全采样。

5.3 异步FIFO的引入时机

当SPI数据量很大时(比如连续采集ADC多通道数据,或者从SPI Flash读一大块数据),如果每收一包数据都打断上层逻辑,要么丢包要么CPU忙死。更合理的做法是在SPI控制器与上层逻辑之间加一个异步FIFO,SPI侧写入,用户侧读取。

异步FIFO复杂度高,但好在标准做法成熟。Xilinx可以直接用XPM_FIFO参数化生成,在代码里实例化:

xpm_fifo_async #( .FIFO_MEMORY_TYPE ("auto"), .FIFO_READ_LATENCY (1), .FIFO_WRITE_DEPTH (1024), .WRITE_DATA_WIDTH (8), .READ_DATA_WIDTH (8), .READ_MODE ("std") ) u_fifo ( .rst (~rst_n), .wr_clk (spi_clk), .wr_en (rx_data_valid), .din (rx_data), .rd_clk (user_clk), .rd_en (user_rd_en), .dout (user_data), .full (fifo_full), .empty (fifo_empty) );

FIFO深度1024对大多数SPI应用够用,如果要传更大数据,也可以把FIFO换成简单的RAM地址计数器,但中断交互就复杂很多了。异步FIFO方案对从机模式尤其重要,因为外部主机的SCLK频率与内部逻辑时钟经常差好几倍,没法靠轮询保证实时性。

6. 调试与验证:实测经验与波形分析

SPI控制器写完了,不代表能跑。没有示波器和逻辑分析仪,你根本不知道线上跑的是什么妖魔鬼怪。这节说说我在实际调试过程中摸索出来的一整套验证思路。

6.1 ModelSim/Questa仿真验证要点

很多人写SPI仿真只写一个testbench,拉几个激励信号,仿真跑通就以为完事了。真实情况是,SPI时序bug在仿真里最常见,但也是最容易忽略的。

必须加入的仿真激励场景:

  1. 主机发送全0和全1数据,验证SCLK边沿与数据切换沿的相对位置,防止建立时间不足。
  2. 主机发送0xAA/0x55,这类数据在线上会产生最高频率的翻转,可以直观看到波形,迅速定位MSB/LSB顺序问题。
  3. 从机模式仿真时,必须人为产生SCLK抖动。SCLK时钟周期设成连续变化,比如每周期相差2ns,验证同步器在非均匀时钟下不丢bit。
  4. 片选信号在传输过程中间拉高再拉低,验证从机状态机的复位逻辑是否正确。

这里还要提醒一下,仿真中的时序是理想化的,RTL仿真不通过,大概率逻辑有问题;仿真通过了,上板依然可能失败,原因往往是物理时序条件没满足,示波器才是终极裁判。

6.2 ILA/SignalTap在线调试抓波形

上板调试时,在Vivado里用ILA(Integrated Logic Analyzer)在FPGA内部挂探针,抓取SPI控制器的关键内部信号,效率远高于外部逻辑分析仪。

建议抓取信号集合:

  • 主机模式:sclkcs_nmosimisostate[2:0]bit_cnt[3:0]
  • 从机模式:同步后的sclk_d2sclk_risesclk_fallcs_d2shift_rx

第一步先看状态机是否按预期跳转,第二步看位计数是否正常从0到7递增,第三步看数据在哪个时刻被锁存。

有一个非常典型的故障现象:ILA抓到的数据在仿真里完全正确,但上板后首字节最高位总出错。这个90%情况是CS拉低到第一个SCLK上升沿之间的建立时间不够,从机还没准备好就把第一个有效位发出去了。解决方法是加大CS_SETUP状态的时间。

6.3 常见问题速查表:从现象定位根因

实际调试过程中,我把碰到的SPI问题按现象整理成了一个速查表,方便快速定位:

现象可能原因排查方向
全部数据错位(整体便宜1位)MSB/LSB顺序配置错误检查数据手册的位序要求
首字节最高位丢失CS建立时间不足增大CS_SETUP持续周期
数据稳定但偶尔丢一字节从机在CS拉低后未复位完成增大CS_IDLE_GAP间隔
数据全部为固定值(0x00/0xFF)MISO/MOSI接反,或MISO总线多从机冲突检查物理连接与高阻态输出
仿真正确,上板在高速时错乱时钟质量差、逻辑布局不合理改用PLL,检查IO约束
SCLK上有毛刺,波形不干净组合逻辑直接驱动SCLK改为D触发器输出寄存器,不要组合直出

6.4 双通道对比法:无需示波器的数据分析技巧

没有示波器怎么办?有一种不用额外硬件的笨办法,但非常好用:把FPGA收到的原始数据和期望数据都通过串口打印或者DMA搬移到内存,在PC端写脚本对比。如果数据从某个固定字节开始整体错位,基本可以断定是对齐问题;如果是随机乱码,重点查硬件时序。

这个方法在调试SPI Flash读取时特别高效:先读JEDEC ID,确定的0xEF 0x40 0x18,如果读出来不是这个,基本可以断定时序配置就有问题,而不是Flash和数据内容的问题。 先锁定简单命令,再调试复杂功能,这个是调试SPI外设的不二法门。

7. SPI与I2C协议的选型思考

热词里出现了“iic和spi的区别”,而且很多项目里都会有这个纠结。这里顺带聊聊我的选择思路,因为在FPGA项目里这个决策影响后面整个接口设计。

两者最核心的区别:SPI速度快、线多、无应答机制;I2C速度慢、线少、带应答和地址。 I2C只需要两根线(SCL、SDA),通过地址区分设备;SPI四根线,信号简单直接,但一个从机一条CS,设备多了线就多。

FPGA项目里的选型建议:

  • 追求吞吐率:选SPI。W25Q128 SPI Flash读速可达40Mbps以上,I2C最快也就3.4Mbps(高速模式),差了不止一个量级。
  • 引脚资源紧张:选I2C。有些BGA封装FPGA引脚密集,但PCB布线难度大,两根线的好处很明显。
  • 从机数量多:I2C用地址区分,SPI每从机一根CS,超过4个从机时SPI线数优势基本消失。
  • 实时性要求高:SPI。I2C的时钟同步和应答机制在复杂总线上带来的延迟和不确定性,远比SPI大。

FPGA上实现I2C比SPI麻烦很多,因为I2C是真正的总线协议,漏极开路、地址仲裁、时钟拉伸、应答超时这些都要做。而SPI本质上还是移位寄存器的扩展,难度至少低一半。

8. 实战案例:FPGA驱动SPI NOR Flash完整流程

前面讲了协议、代码、调试方法,这里用一个完整实例把整个流程串起来。FPGA读SPI NOR Flash(以W25Q128JV为例)是最典型也最有代表性的SPI应用场景,这个案例做完,基本可以应付绝大多数SPI外设。

8.1 初始化流程与命令序列

W25Q128JV支持Mode 0和Mode 3,我用Mode 0,8位命令字,MSB first。上电后必须先发0xAB(Release Power Down)/ 0x06(Write Enable)等命令。

典型读取流程:

  1. 拉低CS。
  2. 发送0x03(Read Data)命令。
  3. 发送24位地址(高字节在前)。
  4. 连续读取N个字节数据,CS全程保持低。
  5. 拉高CS结束。

如果一次读超过Flash页边界(一页通常256字节),Flash内部地址会自动回卷,所以跨页读取必须分多次操作。这一点在FPGA实现时要特别小心,很多人第一次调通单页读取后,直接连续读大块数据发现数据错乱,就是因为没有处理跨页。

8.2 状态机设计:读操作FIFO化

实际工程里我不会用笨办法每个字节轮询一次,而是把SPI读数据直接灌入异步FIFO,上层逻辑无阻塞地拿数据。

// 简化代码:SPI主机读Flash数据并写入FIFO spi_master #( .DATA_WIDTH(8), .CLK_DIV(20), // 假设系统100MHz,SPI 5MHz .CPOL(0), .CPHA(0), .CS_HOLD_CLKS(4) ) u_spi_master ( .clk(clk_100m), .rst_n(rst_n), .start(tx_start), .tx_data(fifo_din), // 命令/地址写入 .rx_data(flash_data), .busy(spi_busy), .sclk(sclk), .cs_n(flash_cs_n), .mosi(mosi), .miso(miso) ); // 接收数据有效时写入FIFO always @(posedge clk_100m) begin if (spi_busy && rx_valid) fifo_wr_en <= 1; end

这里有个细节:读数据命令0x03之后,主机必须持续拉低CS并继续提供SCLK,Flash才会在后面每个时钟的下降沿输出一个字节数据。所以主机状态机的设计不能发完命令就停,要允许连续传输模式。我上面主机模块里的TRANSFER状态设计成一次能传多个数据帧,就是为这个场景准备的。

8.3 Flash数据校验与错误恢复

用SPI Flash做配置存储或者固件升级,数据校验必须做。最简单的CRC16校验可以放在FPGA内部,读完整块算一个CRC值,和写入时存储的CRC比较。错误恢复策略我建议是:连续三字节CRC错误就重新初始化Flash,而不是盲目重试同一块区域。

关于擦写寿命要注意,Flash擦除按扇区/块进行,通常一个扇区4KB,页编程按256字节。频繁改写不要总是擦同一个扇区,做磨损均衡。这个虽然在FPGA里不像MCU那么常见,但做数据记录类项目时真的会遇到。

9. 高速SPI设计的进阶优化

低速SPI控制器大家都差不多,到了20MHz以上,很多设计就开始“碰运气”了。这个部分谈谈怎么把SPI速度往上拉,或者说至少保证高数据率下的稳定性。

9.1 串行器/解串器和DDR模式的思路

如果追求极高吞吐率,可以考虑在FPGA里用硬核串行器(OSERDES/ISERDES)来实现SPI的数据移位,而不是用普通逻辑做移位寄存器。

Xilinx 7系列FPGA的OSERDES可以支持DDR模式,一个时钟周期内采样两个bit。这意味着如果外部SCLK为50MHz,理论上可以做到100Mbps的数据率。当然这要求从机也支持DDR SPI(很多高速ADC支持),并且PCB信号完整性得跟得上。

普通SPI模式下,用逻辑移位寄存器的瓶颈在于:每个bit的建立时间要满足,而你的数据切换沿和采样沿之间只有半个SCLK周期。 SCLK越高,这个窗口越小。到了50MHz以上,纯逻辑设计基本压不住走线延迟,这时候就必须用IOB里自带的寄存器,或者干脆用硬核串行器。

9.2 引脚约束与信号完整性

高速SPI对PCB布局布线有要求:

  • SCLK与MOSI/MISO走线尽量等长,避免时钟与数据到达偏差。
  • CS走线不要走太长,CS时序对建立时间影响很大。
  • 如果板子上SPI走线较长(超过几厘米),建议串联33欧姆电阻,降低振铃。

Vivado里时序约束重点约束SCLK与MOSI的相对相位,可以用set_output_delay约束数据线相对SCLK的延迟。在做高速SPI之前,最好先看看demo板原理图是怎么布线的,照猫画虎能少踩很多坑。

9.3 时钟占空比与数据有效窗口的粗算

简单量化一下:假设系统时钟100MHz,SCLK=50MHz(CLK_DIV=2),每个SCLK周期20ns。Mode 0模式下,MOSI在SCLK低电平期间更新数据,上升沿采样。数据建立时间是半个SCLK周期(10ns)减去MOSI翻转到布线到达从机的延迟。

如果PCB走线延迟2ns,从机输入建立时间要求5ns,那么剩给FPGA内部寄存器的输出延迟窗口只有3ns。这个数字已经非常紧张了。所以50MHz以上,工程设计就必须严格计算时序预算。

能用PLL产生更高倍频的串行时钟时,可以考虑把内部逻辑频率提到200MHz、用逻辑打拍实现更细粒度的相位控制,但本质没有改变物理窗口的限制。最彻底的办法其实是采用ISERDES/OSERDES这类专用IO逻辑,让数据寄存直接绑定在IO资源里,减少路径延迟。

10. 工程应用中的经验总结与避坑指南

最后这部分,我把这些年在SPI+FPGA项目里踩过的坑、总结出来的经验系统性列一下。没有高深理论,全是实打实的操作建议。

10.1 最容易忽视的5个细节

  • 上拉/下拉电阻:从机MISO输出高阻时,如果没有外部下拉,浮空输入会在FPGA里读到不稳定的值。一般加10k下拉,或者FPGA内部使能弱下拉。
  • 电平标准:3.3V SPI接口与5V器件接口,不是所有情况都能直连。很多传感器是1.8V逻辑电平,FPGA的bank电压要和器件匹配,否则测出波形是“好的”,但芯片就是不工作。
  • 电源去耦:SPI速度上去后,芯片电源管脚如果去耦不佳,会导致偶然性数据错误。这个和FPGA逻辑无关,但属于SPI系统故障排查的常见根因。
  • 热插拔问题:SPI不支持热插拔,尤其是CS和SCLK没做保护和隔离的情况下,上电瞬间的毛刺会导致从机锁死在未知状态。
  • 指令间隔:某些外部设备在两个指令之间要求指令间隔(比如SPI Flash的Write Status Register后需要等待内部忙)。FPGA侧要留出足够的时间,不要发完一个命令马上发下一个。

10.2 从机模式和主机模式的选择策略

有些芯片既支持主又支持从,比如一些FPGA和MCU之间的SPI连接。我的建议是,在整个系统里最好只有一个主机,其他都是纯从机。多主机SPI需要处理总线仲裁和冲突检测,复杂度直接上一个台阶,除非必要别这么干。

另外,用FPGA做从机给MCU用的时候,从机侧的响应延迟要尽量控制在一个SCLK周期内。尤其是在SCLK高达20MHz以上的场景下,FPGA从机如果还要通过内部总线仲裁才能返回数据,时序很容易超标。 这种情况下,最稳的方案是预先在从机模块内部开一个双端口RAM,MCU要读哪个寄存器,地址提前写入,FPGA从机直接根据地址从RAM取数,数据路径上没有仲裁延迟。

10.3 代码规范与可维护性

SPI控制器代码最忌讳的是一股脑写成一个巨型always块。我自己的经验:

  • 每个模块只做一件事,主机/从机/寄存器接口分开。
  • 参数全部参数化,不写死位宽和分频比。
  • 状态编码用本地参数名,不要用魔法数字。
  • 关键跨时钟域信号注释标明“CDC signal, do not optimize”,防止综合工具在综合时把这关键路径优化掉。

还有一个很多人没注意的:仿真testbench里加$monitor连续打印SPI数据,看似方便,但仿真速度会急剧下降。建议只在关键阶段开启,或者干脆只打印数据帧的接收完成信号,减少IO输出开销。

结尾的话

SPI通信FPGA实现,说难也难,说简单也简单。难在跨时钟域、时序边界和调试手段的完备性,简单在协议本身内容不多,一次把原理吃透、代码模板调好,后面不同项目就是改参数和接口的事情。我个人建议,无论项目多急,都先在仿真里把主机和从机相互通信的case跑透,再上板测真实外设。别嫌麻烦,仿真阶段多花半天,一般来说能省掉上板后三天抓波形的痛苦。最后再分享一个小经验:所有SPI外设的调试,先从读JEDEC ID这类固定值寄存器开始,先证明通路,再调复杂功能。这个顺序要是反了,出了问题你往往不知道是哪一层的问题。

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

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

立即咨询