FPGA SPI接口设计全解析:Verilog实现、时序仿真与工程交付
2026/9/16 12:28:10 网站建设 项目流程

简介:基于FPGA的SPI通信接口设计资源包,面向FPGA初学者与嵌入式开发者,提供从协议理解到工程实现的完整路径。SPI作为一种同步串行接口,常用于连接传感器、存储器与显示模块,本包正是围绕其在FPGA中的实际落地展开。压缩包共428个文件,以Quartus工程文件(cdb/hdb/qsf)、VHDL源码(vhd)、BDF原理图、仿真波形(vwf/cvwf)及PDF/DOCX说明文档为主,整体仅6.42MB。内容涵盖SPI控制器状态机设计、时钟生成、移位寄存器与片选逻辑,详细介绍了四种工作模式、电平转换等关键知识点;VHDL代码配有注释,便于阅读与二次开发;说明文档还介绍了操作步骤、配置选项与常见问题排查。配套原理图清晰展示FPGA与从设备的硬件连接,仿真文件可用来验证不同工况下的通信时序;已有299人学习下载,适合作为课程设计、项目开发的学习模板,也可按需修改后复用于其他工程。

1. 一份FPGA SPI接口设计,为什么代码、原理图和仿真要同时交付

SPI 这个协议看起来太简单了,四根线、一个主设备、一个从设备,时钟沿上把数据移出去再移进来,很多人在单片机上用硬件外设或者软件模拟都做过。但在 FPGA 里写 SPI 是另一回事:没有现成的 SPI 外设模块帮你管片选、管时钟极性和相位,你需要用 Verilog 把一个分频器、一个移位寄存器、一个状态机组合起来,让它们在每一个时钟沿上都严格符合从设备 datasheet 里那张时序图。只写代码不写仿真,就像只画原理图不标引脚,板子一上电就抓瞎。这份设计之所以要把代码、原理图、说明文档和仿真放在一起交付,是因为 SPI 接口的成败恰恰藏在时序参数和引脚连接这些“看不见的地方”,四样东西互相验证,才能算一个可用的设计闭环。适合正在入门 FPGA、想把接口设计做规范、或者需要把 SPI 从设备接进自己系统的开发者。

2. SPI时序拆解与FPGA侧的设计取舍:从固件思维切到硬件思维

2.1 四种模式不是背出来的:先看懂 CPOL 和 CPHA 在时序图上的位置

SPI 协议本身只有四根信号:SCLK、MOSI、MISO、CS_N。主设备在 SCLK 上产生时钟,在 MOSI 上发出数据,在 MISO 上收回数据,CS_N 拉低表示一次传输开始。麻烦的是“在哪个沿采样、在哪个沿改变数据”,这组关系由 CPOL(时钟极性)和 CPHA(时钟相位)共同决定,也就是常说的 SPI Mode 0 到 Mode 3。

模式CPOLCPHASCLK 空闲电平数据采样沿数据改变沿
Mode 000上升沿下降沿
Mode 101下降沿上升沿
Mode 210上升沿下降沿
Mode 311上升沿下降沿

读这张表的正确方式不是记数字,而是先把 CPOL 画出来。CPOL 为 0,空闲时 SCLK 是低电平,第一个沿必然是上升沿;CPOL 为 1,空闲时是高电平,第一个沿必然是下降沿。CPHA 决定第一个沿用来干什么:CPHA 为 0,数据在第一个沿被采样,也就是数据要提前准备好;CPHA 为 1,数据在第二个沿被采样,第一个沿只负责改变数据。

所以设计 FPGA 的 SPI Master 时,不要试图在代码里“兼容”所有模式,而是把 CPOL 和 CPHA 设计成模块的 input 参数,由使用者在例化时根据从设备 datasheet 明确指定。最常见的从设备都支持 Mode 0 和 Mode 3,比如 W25Q 系列 Flash 默认支持 Mode 0 和 Mode 3,这时优先选 Mode 0,因为它和大多数逻辑分析仪默认配置一致,排查起来少一层心智负担。

2.2 为什么 FPGA 侧不要用延迟链产生 SCLK

在单片机上写 SPI,很多人习惯用延时函数翻转时钟:拉高、延时、拉低、延时。这个思路在 FPGA 上一定要改掉,原因有两个。

第一,FPGA 里没有“延时函数”,综合工具也不会把 for 循环里的延时解释成真实的延时电路。你写 delay 循环,综合器要么把它优化掉,要么把它实现成一长串组合逻辑,时序完全不可控。第二,SPI 接口需要的是与系统时钟有确定相位关系的 SCLK,而这个相位关系必须建立在时钟分频的基础上,而不是建立在组合逻辑延迟上。

常见做法是:用系统时钟对 SCLK 做偶数分频,分频后的信号去驱动移位寄存器和片选逻辑。整个设计只有一个时钟域,所有状态都跟着系统时钟沿变化,SCLK 由分频计数器产生,这样天然避免了跨时钟域的亚稳态问题。某个版本的 RTL 设计里确实有人直接用 PLL 生成 SCLK,但这会让 SCLK 变成第二个时钟域,后续所有逻辑都要做 CDC 处理,付出和收益不成正比。

2.3 分频系数与数据宽度的设计余量

SPI 时钟频率不是想多快就多快。从设备 datasheet 里会给一个最大 SCLK 频率,比如 W25Q128 的读操作最高支持 104 MHz,但这是极限值,实际设计要考虑 PCB 走线、电平转换芯片的延迟、FPGA 内部逻辑到引脚的距离。我一般会按最大频率的 1/2 到 2/3 留余量,比如从设备支持 50 MHz,系统时钟是 100 MHz,分频系数取 2,SCLK 就是 50 MHz;如果不放心,取 4,SCLK 就是 25 MHz,稳定性优先。

数据宽度上,最常见的 SPI 传输是 8 位,但很多传感器和 Flash 支持 16 位或连续多字节传输。模块里的数据宽度应该做成参数,而不是写死,这样同一个模块既能读 8 位寄存器,也能在一次 CS_N 拉低期间连续读写多个字节。这个点在做 FPGA 开发时最容易忽略:把“一次传输”和“一个字节”混为一谈,导致从设备需要你连续发命令地址数据时,你的状态机不知道什么时候该释放片选。

3. FPGA SPI通信接口的Verilog实现:可综合代码、参数表和引脚约束

3.1 模块端口设计:把主从、位宽、极性都做成可配参数

先明确一个最基本的问题:这个模块是 Master 还是 Slave?标题里的“通信接口设计”通常指 Master,因为 FPGA 在系统中往往扮演主控角色,去读 ADC、配置 DAC、访问 Flash。如果要做 Slave,设计思路完全不同,需要被动响应外部时钟,本节以 Master 为例,这也是最普遍的需求。

模块端口建议按下表设计,把参数全部放在 module 声明里的 parameter 位置,调用者不改代码、只改例化参数就能适配不同从设备。

参数/端口方向位宽说明
clkinput1系统时钟,所有逻辑同步于此
rst_ninput1异步复位,低有效
startinput1单周期脉冲,拉高表示开始一次传输
data_ininputDATA_WIDTH要发送的数据
data_outoutputDATA_WIDTH接收到的数据
busyoutput1高电平表示正在传输,完成后拉低
sclkoutput1SPI 时钟,由 clk 分频产生
mosioutput1主出从入
misoinput1主入从出
cs_noutput1片选,低有效,传输期间拉低
CPOLparameter1时钟极性
CPHAparameter1时钟相位
DATA_WIDTHparameter8数据位宽
DIVIDERparameter2分频系数,SCLK = clk / DIVIDER

3.2 可综合的 SPI Master 核心代码:状态机 + 移位寄存器 + 分频器

下面是一段可直接综合的 SPI Master 代码,覆盖单次传输,状态机分 IDLE、TRANSFER、DONE 三个状态。代码里没有使用任何平台相关原语,Vivado、Quartus 都能直接跑。

module spi_master #( parameter DATA_WIDTH = 8, parameter CPOL = 0, parameter CPHA = 0, parameter DIVIDER = 2 )( input wire clk, input wire rst_n, input wire start, input wire [DATA_WIDTH-1:0] data_in, output reg [DATA_WIDTH-1:0] data_out, output reg busy, output reg sclk, output reg mosi, input wire miso, output reg cs_n ); localparam IDLE = 2'd0; localparam TRANSFER = 2'd1; localparam DONE = 2'd2; reg [DATA_WIDTH-1:0] shift_reg; reg [$clog2(DATA_WIDTH)-1:0] bit_cnt; reg [7:0] clk_cnt; reg sclk_r; // 分频计数器:产生 sclk_r,占空比 50% always @(posedge clk or negedge rst_n) begin if (!rst_n) begin clk_cnt <= 0; sclk_r <= CPOL; // 空闲电平等于 CPOL end else if (busy) begin if (clk_cnt == (DIVIDER/2 - 1)) begin clk_cnt <= 0; sclk_r <= ~sclk_r; end else begin clk_cnt <= clk_cnt + 1; end end else begin clk_cnt <= 0; sclk_r <= CPOL; end end // 片选与忙信号 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin busy <= 1'b0; cs_n <= 1'b1; end else if (start && !busy) begin busy <= 1'b1; cs_n <= 1'b0; end else if (state == DONE) begin busy <= 1'b0; cs_n <= 1'b1; end end // 主状态机:控制移位与收发 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; shift_reg <= 0; bit_cnt <= 0; mosi <= CPOL; data_out <= 0; end else begin case (state) IDLE: begin if (start && !busy) begin shift_reg <= data_in; bit_cnt <= 0; state <= TRANSFER; mosi <= data_in[DATA_WIDTH-1]; // 先发最高位 end end TRANSFER: begin // 在 SCLK 变化沿发送数据 if (clk_cnt == 0 && sclk_r == CPOL) begin mosi <= shift_reg[DATA_WIDTH-1]; end // 在 SCLK 采样沿接收数据 if (clk_cnt == DIVIDER/2 && sclk_r == ~CPOL) begin shift_reg <= {shift_reg[DATA_WIDTH-2:0], miso}; if (bit_cnt == DATA_WIDTH-1) begin state <= DONE; data_out <= {shift_reg[DATA_WIDTH-2:0], miso}; end else begin bit_cnt <= bit_cnt + 1; end end end DONE: begin state <= IDLE; end endcase end end assign sclk = sclk_r; endmodule

3.3 代码逻辑说明:数据为什么在 clk_cnt 的这两个位置翻转

这段代码有一个容易被忽略的关键:sclk_r 的翻转、mosi 的翻转、miso 的采样,都分别发生在不同的 clk_cnt 位置,而它们之间的相位差正好构成了 SPI 协议要求的建立时间和保持时间。

先看发送路径。IDLE 状态中,在start到来后,mosi被赋值为data_in的最高位,此时片选已经拉低,但 SCLK 还没开始翻转。接下来分频器开始工作,sclk_r从 CPOL 开始翻转。在clk_cnt == 0 && sclk_r == CPOL时,也就是 SCLK 即将改变的前一个系统时钟周期,代码把下一位数据放到mosi上。这样做的效果是:数据变化沿发生在 SCLK 翻转沿之前,给从设备留出了数据建立时间。

再看接收路径。clk_cnt == DIVIDER/2 && sclk_r == ~CPOL这个条件,恰好对应 SCLK 稳定后的中间位置。此时sclk_r已经是新的电平,数据线也已经稳定,在这里采样miso正好落在数据有效的窗口中心,最大程度避开跳变沿。用这个位置采样,对从设备的 t_hold 要求不那么敏感,这也是整段代码里最值得抄的部分。

参数调整时要注意:DIVIDER必须是偶数。如果写 3,DIVIDER/2在 Verilog 整形除法里等于 1,占空比会异常,SPI 时序直接错误。这一点在代码注释里要写明,也是一个高频踩坑点。

3.4 引脚约束和时序约束:XDC 里最少要写什么

代码逻辑正确还不够,FPGA 开发里引脚约束和时序约束必须配套。以 Xilinx Vivado 为例,最少需要在 XDC 文件里指定以下内容。

set_property PACKAGE_PIN C17 [get_ports {sclk}] set_property IOSTANDARD LVCMOS33 [get_ports {sclk}] set_property PACKAGE_PIN D17 [get_ports {mosi}] set_property IOSTANDARD LVCMOS33 [get_ports {mosi}] set_property PACKAGE_PIN D18 [get_ports {miso}] set_property IOSTANDARD LVCMOS33 [get_ports {miso}] set_property PACKAGE_PIN E17 [get_ports {cs_n}] set_property IOSTANDARD LVCMOS33 [get_ports {cs_n}]

PACKAGE_PIN 要根据你自己的板卡原理图修改,这里只是示例。IOSTANDARD 要根据 FPGA 和从设备的电平标准设置,3.3 V 从设备就配 LVCMOS33,1.8 V 就配 LVCMOS18。如果板上有电平转换芯片,还要在 XDC 里给 sclk 和 mosi 加上set_property SLEW FAST,保证高速翻转时边沿不太钝。spi 通信接口的 mosi 有时还需要检查是否要接上拉电阻,FPGA 内部一般配置为PULLUP,但外接从设备如果有内部上拉,就不要重复配置,否则会形成分压。

set_input_delayset_output_delay这两条时序约束,很多初学者会忽略。它们描述的是外部芯片相对 sclk 的建立保持时间。如果你的系统时钟跑得不高(比如 50 MHz 以下),不写这两条也能跑,只是收敛结果没有保证。建议至少写 output delay,因为 mosi 是 FPGA 发出的,delay 值可以参考从设备的 t_su 和 t_ho 反推。这个约束写好后,Vivado 时序报告里就能看到 SCLK 与数据之间的裕量,而不是只看到“时序未约束”的警告。

4. 用testbench把SPI仿真跑起来:激励怎么写、波形看什么、时序怎么调

4.1 自校验 testbench:让仿真结果自动告诉你对错

仿真不是跑一下看波形就完事。如果只是肉眼盯着波形数时钟沿,8 位数据还能看过来,16 位就费劲,连续读写根本看不过来。更可靠的做法是写自校验 testbench:在测试平台里例化一个假的 SPI Slave,把 Master 发出来的数据收下来,再在同一个仿真里回传,然后比对 data_out 和发送数据是否一致。

下面这段 testbench 覆盖了最基本的功能验证:发送 0xA5,期望回传同样的值。

`timescale 1ns / 1ps module tb_spi_master; reg clk; reg rst_n; reg start; reg [7:0] data_in; wire [7:0] data_out; wire busy; wire sclk; wire mosi; reg miso; wire cs_n; // 例化 DUT spi_master #( .DATA_WIDTH(8), .CPOL(0), .CPHA(0), .DIVIDER(2) ) dut ( .clk(clk), .rst_n(rst_n), .start(start), .data_in(data_in), .data_out(data_out), .busy(busy), .sclk(sclk), .mosi(mosi), .miso(miso), .cs_n(cs_n) ); // 生成 100 MHz 系统时钟 initial clk = 0; always #5 clk = ~clk; // 模拟 SPI Slave:回环接收并回发 always @(posedge sclk) begin if (!cs_n) begin // 在 SCLK 采样沿读取 mosi,并在下一个沿之前回传 miso <= mosi; // 简单回环,适用于验证接线 end end initial begin rst_n = 0; start = 0; data_in = 8'h00; miso = 1'b0; #100; rst_n = 1; // 等待复位完成 #80; start = 1; data_in = 8'hA5; #10; start = 0; // 等待传输结束 wait (!busy); #50; // 比对结果 if (data_out == 8'hA5) $display("Test Passed: data_out = %h", data_out); else $display("Test Failed: data_out = %h, expected %h", data_out, 8'hA5); #200; $finish; end endmodule

4.2 testbench 逻辑说明与关键参数

这个 testbench 里有三个关键设计点。第一个是时钟生成,always #5 clk = ~clk产生周期 10 ns 的时钟,对应 100 MHz,这个频率要和你的实际板卡时钟一致,仿真里频率不对,分频系数验证就没有意义。第二个是复位释放,rst_n先拉低 100 ns 再拉高,确保 DUT 内部所有寄存器都完成初始化。第三个是wait (!busy)这个语句,它的作用是等 Master 自己完成传输,而不是用固定延时硬等,这样无论数据位宽怎么改,测试平台的等待时间都能自适应。

对于 SPIDEV 来说,miso在仿真里通过回环赋值给mosi,这只能验证 Master 的发送和接收通路在同一套时序下能工作。真实从设备不一定支持回环,比如 ADC 芯片在发送控制字之后要等若干个 SCLK 周期才在 MISO 上产生数据,这时 testbench 里要用队列或计数器模拟这种延迟,而不是简单赋值。

4.3 波形上必须检查的四个时间点

仿真跑完后不要着急关掉波形窗口,按下面四个时间点依次看,能排查掉 90% 的时序问题。

这个检查思路要落到具体时间点。第一,看片选拉低后 SCLK 是否保持空闲电平至少半个 SCLK 周期。有些从设备要求 CS_N 拉低后经过 t_cs 时间才开始接收时钟,如果片选和 SCLK 同时变化,从设备可能识别不到第一个沿。第二,看 mosi 的第一个 bit 是否在 SCLK 第一个有效沿之前就绪。Mode 0 下,数据必须在上升沿之前建立,如果发现 mosi 的变化沿和 SCLK 第一个边沿重合,说明发送时机偏晚。第三,看采样时刻的构建保持时间。Master 在系统时钟的clk_cnt == DIVIDER/2位置采样 miso,所以在 SCLK 下降沿附近 MISO 是不稳定的,波形图里应该看到 MISO 的变化沿和采样沿错开。第四,看传输完成后的 sclk_r。它应该恢复为 CPOL 指定的空闲电平,而不会停在半空中。

如果发现某个沿的位置不对,调整方向只有一个:改代码里发送和采样的触发条件。发送太晚就把clk_cnt == 0这个条件改成sclk_r == CPOL的前一拍,或者直接把 mosi 的赋值挪到 SCLK 边沿产生之前一拍的位置。采样位置同理,往 SCLK 有效沿的中间靠。不要试图通过改分频系数来修正边沿关系,那会把 SCLK 频率也改掉,副作用太大。

4.4 仿真的另一个用途:验证跨字节连续传输

很多 SPI 从设备接收指令时需要连续传多个字节,比如往 Flash 写数据时,先发命令字节,再发 24 位地址,然后才是数据。如果 Master 只做单字节传输,状态机在没有外部干预的情况下不会自动开始第二个字节,CS_N 也一直保持低,这时从设备会一直在等地址字节。

解决思路是把模块改造成“连续传输模式”:参数CONTINUOUS为 1 时,DONE 状态不回到 IDLE,而是判断还有没有剩余字节,有就继续 TRANSFER,直到外部给出结束信号再拉高 CS_N。仿真里要额外测这个场景:数据宽度改成 8,连发 4 个字节,波形上看 CS_N 始终保持低,SCLK 不间断,字节之间只有一个 bit_cnt 回零的间隔。

5. 配套原理图与说明文档的组织:从模块框图到可交付的检查清单

5.1 原理图不是完整电路图,而是接口连接图

很多 FPGA 初学者把“原理图”理解成和单片机最小系统一样的大全图,电源、晶振、JTAG 全画上去。实际交付时,SPI 接口设计里的原理图更准确地说是一张接口连接图。它的价值在于标清楚 FPGA 引脚和外部设备引脚的一一对应关系,以及电平转换、上下拉、去耦这些边界条件。

典型的内容组织方式是把信号分成三类。第一类是电源和地,标注 FPGA 侧和从设备侧的供电电平,如果有电平转换芯片,把方向引脚(DIR)和使能引脚(OE)的连接也画出来。第二类是 SPI 信号本身,按 sclk、mosi、miso、cs_n 四根线分别标注网络名,网络名要和 RTL 代码里的端口名一致,方便后续对照。第三类是附加信号,比如从设备的中断输出、忙信号、复位引脚,这些信号在 FPGA 内部可能只是个普通输入,但原理图上必须说明它是开漏还是推挽、需不需要上拉。

有一个细节容易被忽略:FPGA 的 IO 没有类似 ARM 那种可配置的推挽/开漏模式吗?其实是有的,Xilinx 的 XDC 里通过set_property DRIVEset_property PULLUP控制。如果从设备的 MISO 是开漏输出,原理图里就要在外侧画上拉电阻,FPGA 侧配置成输入模式并关闭内部上拉,避免重复上拉影响信号幅度。这正好对应热词里提到的“推挽、开漏上拉”场景,实践里非常常见。

5.2 说明文档写什么:接口列表、时序参数、验证结果三件套

说明文档不用写成几十页的大部头,但至少要包含三张表。第一张是接口列表,列出 FPGA 引脚号、网络名、方向、电平标准、连接到从设备的哪个引脚。第二张是时序参数表,填上 SCLK 频率、分频系数、数据建立时间、保持时间,这些参数要和 RTL 设计时假设的一致。第三张是验证记录,写清楚仿真通过哪些用例、实测时用逻辑分析仪抓到的波形和仿真差多少 ns。

一个建议是参考 Xilinx 的 AXI Quad SPI IP 手册来组织自己的文档结构。AXI Quad SPI 是标准商业 IP,它的文档里会列出寄存器映射、时序图、编程步骤。你自己的 SPI 接口虽然简单,但文档结构可以对齐这个框架:先说模块功能、再说接口时序、然后是使用示例。这样将来想换成标准 IP 或者被别人 review 代码时,信息密度是够的。

5.3 一个提高可信度的验证技巧:仿真波形和 ILA 实测波形叠在一起对比

最后给一个具体技巧:当板子调通后,用 Vivado 的 ILA(Integrated Logic Analyzer)抓真实的 sclk、mosi、miso、cs_n 信号,导出为 CSV,再和 testbench 仿真波形放在同一个时间轴里对比。不要只在文档里写“测试通过”,而是把两张波形截图并排放在一起,标注出最大的时间偏差出现在哪个边沿。

这个做法的价值在于能暴露出仿真和实测的差异来源。比如仿真里 mosi 的建立时间是 20 ns,实测可能变成 12 ns,差出来的 8 ns 往往来自 PCB 走线长度和 IO 驱动强度。把这些差异记录到说明文档里,下一次改板子或换从设备时,你就知道该调哪些参数。这一步做完,这份“代码+原理图+说明文档+仿真”才真正成为可以被复用的完整设计交付物。

本文还有配套的精品资源,点击获取

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

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

立即咨询