简介:基于FPGA的SPI通信接口设计是一份面向FPGA开发者和电子工程学习者的完整工程包,聚焦SPI总线协议在FPGA中的落地实现,涵盖协议原理、状态机设计、管脚分配及Quartus II开发流程。压缩包共428个文件,大小5.47MB,核心为VHDL源码、Quartus II工程文件(qpf/qsf)、仿真波形(vwf)及开发板原理图PDF等,工程中间文件一并保留,便于直接打开、复现与二次修改。已有1160人学习下载。借助这份工程包,读者可以拿到可综合的SPI接口代码与完整工程框架,理解MISO/MOSI/SCLK/CS四线时序控制,掌握主从设备通信的状态机编写思路,并参考管脚约束与仿真结果进行硬件调试,适合正在学习数字系统设计或需要快速集成SPI外设的开发人员。
1. 打开工程包最先看到什么:综合映射文件里的FPGA SPI设计
打开这份工程包,第一眼看到的不是README,而是一串spi.autos_3e92...map.atm和spi_out.vhd.bak。前者是Quartus II综合后留下的映射文件,后者是设计者保留的一份VHDL源文件备份,说明它不是把教程代码塞进新建工程的演示品,而是从真实开发流程里打包出来的。整套设计围绕FPGA上的SPI主接口展开:用Verilog状态机实现MISO/MOSI/SCLK/CS_n四线协议,配套管脚分配、开发板原理图和可编译工程,目标是直接跑通低速外设——W25Q64读ID、AD7124采样这类应用都属于这个范围。适合两类人:刚学FPGA开发、想找一个不依赖IP核的纯逻辑串行口的人;以及已经在用MCU做SPI、想弄懂FPGA实现时采样沿、管脚约束和调试方法的老手。
2. SPI时序的本质与FPGA里的选型:从四根线到CPOL/CPHA
2.1 四根线完成全双工:其实就是两个移位寄存器
SPI的物理层只有四根线:SCLK、MOSI、MISO、CS_n。主设备产生SCLK,CS_n低有效选中从设备;MOSI/MISO构成一组全双工移位路径。理解SPI最简单的方式,是把它看作两个首尾相接的移位寄存器:发送时主设备在某个时钟沿把tx_data的最高位移到MOSI,从设备在同一个或被移相的时钟沿把MISO上的一位移入自己的移位寄存器;一轮8拍下来,主机发送的8位进入从机,从机返回的8位被主机捕获。因此在FPGA里实现SPI,本质上不是实现什么复杂总线,而是管理好两个数据位流和一个可编程时钟。
在FPGA里用逻辑门实现UART,需要波特率发生器加双沿判断;SPI省去帧格式和起始位,代价是多带4根线,并且主设备对上升沿、下降沿都要有明确控制。这也是为什么很多FPGA项目里SPI比UART先跑通——它是同步协议,只要管脚约束正确,调试难度低一个数量级。
接一个具体从设备前,我一般先打开数据手册,翻到SPI时序图做两件事:第一,看SCLK空闲时是低还是高,记为CPOL;第二,看数据在哪个边沿被锁存,记为CPHA。以W25Q64为例,手册给出Mode 0和Mode 3,我习惯固定用Mode 0;AD7124则只能用Mode 0,配置错会一直读到噪声。
2.2 CPOL/CPHA四个模式,别凭经验猜
很多MCU工程里习惯把SPI固化为Mode 0,切到FPGA里也跟着填CPOL=0、CPHA=0,结果遇到Mode 1/2的设备就抓瞎。正确的判定规则是:CPOL看空闲电平,CPHA看采样沿是第几个边沿。CPHA=0意味着数据在第一个边沿稳定,CPHA=1时第一个边沿只是翻转时钟,数据在第二个边沿才有效。四个模式对照如下:
| 模式 | CPOL | CPHA | SCLK空闲 | 采样沿 | 典型器件 |
|---|---|---|---|---|---|
| Mode 0 | 0 | 0 | 低 | 上升沿 | W25Q64、OLED、AD7124 |
| Mode 1 | 0 | 1 | 低 | 下降沿 | 少量音频ADC |
| Mode 2 | 1 | 0 | 高 | 下降沿 | 部分SD卡 |
| Mode 3 | 1 | 1 | 高 | 上升沿 | W25Q64、ENC28J60 |
FPGA实现里的坑在于:CPOL和CPHA不是两个独立旋钮,它们共同决定MOSI该在哪个系统时钟沿更新。代码里如果只改空闲电平不改采样沿,会出现每帧错一位的现象,而且用逻辑分析仪看波形很难发现,因为SCLK和MOSI看起来都有输出。
确定模式的可执行步骤是:先在时序图里找DON'T CARE区域,看SCLK空闲保持的是高还是低;再假设一组数据,分别用四个模式跑一次,能稳定读回预期值的那个模式就是对的。实际项目中我更信任手册而不是猜,猜模式只用来做上板后的快速验证。
2.3 与I2C、UART对比:什么场景下非FPGA不可
选型时还要知道SPI为什么在某些场景下比I2C、UART更合适。三者不是替代关系,而是应用范围不同:
| 特性 | SPI | I2C | UART |
|---|---|---|---|
| 时钟源 | 主设备提供SCLK | 主设备提供SCL | 双方各自波特率 |
| 数据线 | MOSI/MISO分开 | SDA半双工 | TX/RX分开 |
| 地址机制 | 靠CS_n片选 | 7/10位地址 | 无 |
| 典型速率 | 几十MHz | 几MHz | 几Mbps |
| 从设备数量 | 每设备一根CS_n | 同总线多地址 | 一对一 |
如果FPGA上挂一个W25Q64,SPI明显比I2C合适,因为器件本身没有I2C版本;如果要挂多个温度传感器,I2C一根数据线能省管脚;UART的优势是线最少,但需要双方对波特率,FPGA里做UART还要处理亚稳态。在FPGA开发中,当外设是SPI且从设备数量多时,可以用一个FPGA把所有片选管理起来,外部MCU通过FMC或普通GPIO写几个寄存器就能切换通道,典型场景就是STM32H743和FPGA实现FMC通信,FPGA再转成多路SPI去驱动外部设备。
3. 参数化SPI主接口的Verilog实现:状态机、采样沿和testbench
3.1 先定接口,再写状态机
下面这段代码是我在工程里用的SPI主接口核心,支持CPOL/CPHA参数化,可以灵活接W25Q64这类Mode 0/3器件,也能切到Mode 1/2。代码里用一个半周期计数器和一个phase标志区分每个SCLK周期的第一个边沿和第二个边沿,从而统一判断采样沿。
module spi_master #( parameter SCLK_DIV = 8, // 系统时钟与SCLK的分频比,必须为偶数 parameter CPOL = 0, // 空闲电平:0为低,1为高 parameter CPHA = 0, // 采样相位:0为第一沿,1为第二沿 parameter DATA_WIDTH = 8 // 一次传输的位宽,≤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 sclk, output reg mosi, input wire miso, output reg cs_n, output reg busy ); localparam IDLE = 2'd0; localparam RUN = 2'd1; localparam POST = 2'd2; localparam HALF = SCLK_DIV / 2; reg [1:0] state; reg [15:0] clk_cnt; reg [3:0] bit_cnt; reg phase; wire half_done = (clk_cnt == HALF - 1); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; sclk <= CPOL; cs_n <= 1'b1; busy <= 1'b0; clk_cnt <= 16'd0; bit_cnt <= 4'd0; phase <= 1'b0; mosi <= 1'b0; end else begin case (state) IDLE: begin sclk <= CPOL; cs_n <= 1'b1; busy <= 1'b0; if (start) begin cs_n <= 1'b0; busy <= 1'b1; bit_cnt <= 4'd0; clk_cnt <= 16'd0; phase <= 1'b0; mosi <= tx_data[DATA_WIDTH-1]; state <= RUN; end end RUN: begin if (half_done) begin clk_cnt <= 16'd0; phase <= ~phase; if (phase == CPHA) begin // 采样沿:接收当前位 rx_data[bit_cnt] <= miso; if (bit_cnt == DATA_WIDTH-1) begin // 最后一位采完,进入POST补完整最后一个时钟周期 sclk <= ~sclk; state <= POST; end else begin bit_cnt <= bit_cnt + 1'b1; sclk <= ~sclk; end end else begin // 驱动沿:更新下一位 mosi <= tx_data[DATA_WIDTH-1 - bit_cnt]; sclk <= ~sclk; end end else begin clk_cnt <= clk_cnt + 1'b1; end end POST: begin // 让SCLK保持半个周期,再回到空闲电平并释放CS_n if (half_done) begin sclk <= CPOL; cs_n <= 1'b1; busy <= 1'b0; state <= IDLE; end else begin clk_cnt <= clk_cnt + 1'b1; end end endcase end end endmodule核心逻辑是半周期计数器。HALF = SCLK_DIV/2,clk_cnt每数到HALF-1就产生一次SCLK边沿;phase在边沿翻转,0表示当前是第一个边沿,1表示第二个边沿。因为CPHA参数本身就是采样沿所在相位,所以用phase == CPHA判断采样沿,这一个判断同时兼容四个模式,避免了在状态机里复制四个分支。
参数使用时注意:系统时钟50MHz、SCLK想跑1MHz时,SCLK_DIV设50;SCLK_DIV必须是偶数,否则HALF取整会破坏占空比。DATA_WIDTH通常为8,如果直接接24位ADC,把参数改成24,同时把bit_cnt位宽从reg [3:0]加宽到reg [7:0]。start拉高一个时钟周期即可,cs_n在下一拍拉低,MOSI预置最高位;之后每两个半周期完成一位,最后一个采样沿进入POST状态补完时钟周期,再回到IDLE。
3.2 为什么用单always状态机而不是三段式
三段式状态机适合逻辑复杂、输出多的情况;SPI主接口的协议本身IDLE/RUN/POST三个状态,输出cs_n、sclk、mosi都依赖边沿相位,用一个always块写反而容易保证非阻塞赋值顺序,避免组合输出毛刺。对于老玩家,我的边界建议是:如果这个模块要挂到AXI4-Lite总线上,就把协议引擎和总线接口分开,SPI核心仍用单always,用户侧再加独立的总线握手状态机。
工程里保留的spi_out.vhd.bak是用VHDL写的另一版。如果你更熟悉VHDL,可以对照同一组状态变量去读;要注意VHDL里signal和variable的更新时机,尤其是bit_cnt在采样沿递增时,用variable会引发综合结果不一致。
3.3 testbench至少要覆盖正常收发和CPOL/CPHA切换
仿真里我把SCLK_DIV设成8,这样50MHz时钟下SCLK约6.25MHz,仿真时间短;同时把外部miso手动置1,这样rx_data能直观看出每一位都采到了1,如果混进0就说明采样沿配置错位。testbench最小结构如下:
module tb_spi_master; reg clk = 0; reg rst_n = 0; reg start = 0; reg [7:0] tx_data = 8'h9F; wire [7:0] rx_data; reg miso = 1'b1; wire sclk, mosi, cs_n, busy; spi_master #( .SCLK_DIV(8), .CPOL(0), .CPHA(0) ) dut ( .clk(clk), .rst_n(rst_n), .start(start), .tx_data(tx_data), .rx_data(rx_data), .sclk(sclk), .mosi(mosi), .miso(miso), .cs_n(cs_n), .busy(busy) ); always #10 clk = ~clk; initial begin #30 rst_n = 1; @(posedge clk); start = 1'b1; @(posedge clk); start = 1'b0; #2000; $display("rx = %02h", rx_data); $finish; end endmodule仿真里把miso手动置1,既验证了采样路径,也暴露位序问题。把CPOL/CPHA在四组参数里各跑一遍,观察sclk与mosi的相对位置,比上板后再抓波形快得多。综合时再把SCLK_DIV改成实际分频比。
4. 从代码到板卡:Quartus II管脚分配、SDC约束与W25Q64联调
4.1 先看原理图,再进Pin Planner
SPI接错管脚不会烧板,但会浪费一下午。拿到开发板原理图,先确认三件事:IO电平是3.3V还是1.8V,MOSI/MISO有没有串联电阻,CS_n有没有被外部上拉。很多板子为了让器件默认不被选中,会在CS_n上接10k上拉;FPGA输出端配置为强驱动时,上拉电阻不影响逻辑,但I/O标准必须一致。
然后打开Quartus II的Pin Planner,把逻辑信号分配到物理引脚。以一块Cyclone IV开发板为例,映射如下:
| 信号 | FPGA管脚 | I/O标准 | 开发板位置 |
|---|---|---|---|
| clk | PIN_T9 | 3.3-V LVTTL | 50MHz晶振 |
| rst_n | PIN_B3 | 3.3-V LVTTL | KEY0 |
| sclk | PIN_E1 | 3.3-V LVTTL | 扩展口J9_18 |
| mosi | PIN_E2 | 3.3-V LVTTL | 扩展口J9_19 |
| miso | PIN_E3 | 3.3-V LVTTL | 扩展口J9_20 |
| cs_n | PIN_E4 | 3.3-V LVTTL | 扩展口J9_21 |
操作路径是Assignments → Pin Planner,在All Pins窗口选择信号,双击Location下拉引脚,I/O Standard选3.3-V LVTTL。改完保存到.qsf,重新编译。更可控的方式是用Tcl命令在Quartus Tcl Console里执行:
set_location_assignment PIN_E1 -to sclk set_location_assignment PIN_E2 -to mosi set_location_assignment PIN_E3 -to miso set_location_assignment PIN_E4 -to cs_n set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to sclk基于脚本的管脚分配方便版本管理,换板卡型号时只改映射文件,不用在GUI里重新拖一遍。要特别注意:如果原理图上CS_n所在bank是3.3V电源域,就不要把它强行分配到1.8V bank,否则即使编译通过,上电后IO状态也可能不对。
4.2 把SCLK当成虚拟时钟来做时序约束
很多FPGA开发者在低速SPI项目里不写时序约束,觉得1MHz频率跑得动;直到外设换成AD7124,或者SPI从设备要求MISO建立时间只有25ns,才开始怀疑综合器布局。其实低速设计也该写SDC,至少把外部路径的input/output delay注进来,让时序分析器看到真实路径。
因为SCLK是FPGA内部逻辑分频产生的,Quartus不会自动把它当时钟树看,所以要先为它建立一个虚拟时钟,再约束与SCLK同步输出的MOSI、CS_n以及外部输入的MISO。在.sdc文件里加入:
create_clock -name sys_clk -period 20.000 [get_ports clk] create_clock -name v_sclk -period 200.000 set_output_delay -clock v_sclk -max 80.000 [get_ports {sclk mosi cs_n}] set_output_delay -clock v_sclk -min 10.000 [get_ports {sclk mosi cs_n}] set_input_delay -clock v_sclk -max 90.000 [get_ports miso] set_input_delay -clock v_sclk -min 20.000 [get_ports miso]这里的period 200ns对应SCLK=5MHz,适合把SCLK_DIV设为10的场合。output delay max/min表示从虚拟SCLK沿到FPGA引脚的数据到达时间窗口,数值来自从设备手册中SCLK到MISO的Tco以及PCB走线时延估算。更精确的做法是在原理图阶段查从设备手册,把Tco_max写进约束,而不是用宽松示例值。约束写完还要在Settings里勾选SDC参与分析,再重新编译看时序报告。
如果不想深入研究,最低限度也要确保.sdc被工程加载,在.qsf里加:
set_global_assignment -name SDC_ENTRY_FILE spi.sdc否则约束文件不会生效,时序分析器以为外部路径不存在。
4.3 用W25Q64读ID验证整条链路
把SPI主模块实例化在顶层,分配好管脚后下载到板卡。用手动按键触发一次8位传输,发送命令0x9F。W25Q64的读ID命令由CS_n拉低、发送0x9F、再连续读3字节组成,返回Manufacturer ID、Memory Type和Capacity。由于我前面的模块每传输一帧都自动拉高CS_n,做不了连续读,所以顶层需要用一个小状态机控制“发命令再读数据”的过程,或者临时改模块把CS_n释放出来。
一次读ID的流程可以拆成四步:拉低cs_n;发送0x9F;连续发三帧任意数据,在每帧结束后从rx_data取1字节;拉高cs_n。比对返回值,正常是0xEF 0x40 0x18。如果读到0xFF,先量SCLK有没有输出;如果读到全0,检查MISO和MOSI有没有接反;如果只有第一字节正确、后面错,多半是CS_n在最后一字节前被提前拉高,导致从设备终止了后续读取。
这个现象很常见,也正好引到最后一个话题:你怎么管理CS_n,决定了SPI能不能做连续突发传输。
5. 把CS_n从状态机里拆出来:软件片选、连续读与SignalTap实测
5.1 软件片选和硬件片选,FPGA里应该怎么选
前文模块里的cs_n由SPI状态机自动管理,每传输一帧自动拉低、结束后自动拉高,这种叫硬件片选。它适合单次写寄存器、读状态这类短事务;遇到W25Q64连续读、OLED连续刷显存这类需求,就必须把CS_n从协议状态机里拆出来,改成软件片选——由更上层逻辑甚至外部MCU控制CS_n引脚,SPI状态机只在CS_n有效时产生SCLK和移位数据。
| 场景 | 硬件片选 | 软件片选 |
|---|---|---|
| 单次8位访问 | 适合 | 可以,但多写一层 |
| 连续读/多字节 | 需要扩展 | 适合 |
| 多从设备切换 | 每设备一套状态机 | GPIO控制不同CS_n |
| 时序可控性 | 内部确定 | 受上层逻辑影响 |
我的推荐做法是:把SPI状态机的CS_n改成输入信号,由外部总线控制片选时序。这样在上层状态机里就能实现“先拉低CS_n,发命令,再连续读多个字节,最后拉高CS_n”的完整事务。这与Linux SPI驱动里software chip select的思路一致:主控端用GPIO模拟片选,硬件只保证字节流在SCLK上的相位正确。
5.2 用SignalTap抓SPI波形:一个晚上搞定时序问题
Quartus II自带SignalTap II逻辑分析仪,不需要示波器也能看内部信号。设置路径:Assignments → Settings → SignalTap II Logic Analyzer;采样时钟用系统时钟50MHz,保证能抓到SCLK的每一个沿;把sclk、mosi、miso、cs_n和state加入观察列表;触发条件设为cs_n下降沿;采样深度1024。重新综合下载,运行一次读操作,打开SignalTap看波形。
SignalTap会占用片上RAM,资源紧张时只抓sclk和cs_n。看波形时重点确认两件事:一是在采样沿,MISO是否已经稳定;二是MOSI在驱动沿附近有没有发生跳变,跳变位置是否与采样沿重叠。如果采样沿处MISO刚好在翻转,说明SCLK相位没对齐。最简单可靠的修复是把采样点向后推半个系统时钟周期,在采样逻辑里多打一拍:
reg miso_sync; always @(posedge clk) begin miso_sync <= miso; end然后在采样沿用miso_sync替代miso。这个寄存器延迟能把采样点从边沿附近挪到数据稳定区,低速SPI项目里大部分随机漏位问题都能这样解决。如果看到SCLK上有毛刺,回到管脚约束检查I/O标准是否匹配、有没有开启Slew Rate。
本文还有配套的精品资源,点击获取