ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

FPGA设计中的SPI Master协议解析与参数化Verilog实现

2026/9/13 11:48:00 拓冰建站 浏览量
FPGA设计中的SPI Master协议解析与参数化Verilog实现 简介面向FPGA开发者的SPI接口Vivado实现代码包提供经实测验证的SPI通信Verilog/VHDL示例涵盖主机/从机架构、SCK、MOSI、MISO、CS四线信号定义及时钟极性CPOL/CPHA配置可直接套用至传感器、存储器等外部设备通信场景。压缩包共39个文件约87KB以v/hdl源码为核心辅以仿真测试平台sim_1、Vivado工程文件xpr、配置脚本bat/tcl及log日志文件类型覆盖设计、仿真、配置与记录等环节目录结构清晰便于直接移植与二次开发。目前已有1095人学习下载。通过学习可完整掌握在Vivado中创建SPI IP核、编写软件驱动、仿真验证与硬件调试的流程readme.txt与用户指南类文档还能帮助理解SPI时序配置及常见排错思路适合快速上手SPI接口通信的入门及中级FPGA工程师参考使用。1. 为什么在 Vivado 里自己写 SPI Master 模块从设备对不上时序时第一反应别去改从设备寄存器先回头查主机这一侧的沿。SPI.zip 里这组资源就是一套能在 Vivado 里直接打开、仿真、落板的 SPI 主接口 RTL 工程readme、例程和仿真文件都配齐了。相比从 IP Catalog 拖一个 AXI Quad SPI 下来自己维护一套 SPI Master 的最大价值在于每一个沿、每一拍赋值都是可见的调 Flash、ADC、传感器这类从设备时CPOL/CPHA 配错、采样点偏移这种问题可以直接定位到代码行而不是在黑盒里猜。适合两类人一类是 FPGA 入门想在一个完整工程里看 SPI 到底怎么写另一类是被 IP 核时序行为卡住、想换回可控代码的工程师。readme 里写了版本和引脚约束按步骤搭就行。2. SPI 时序拆解CPOL/CPHA 与参数化 RTL 实现2.1 四条线与四种模式SPI 只有四根线SCK、MOSI、MISO、CS_N。看似简单真正决定兼容性的只有两个参数CPOL 和 CPHA。CPOL 决定 SCK 空闲电平CPHA 决定数据在 SCK 的第一个边沿还是第二个边沿被采样组合起来就是四种模式。模式CPOLCPHASCK 空闲电平数据采样沿典型从设备Mode 000低电平第一个边沿上升沿多数 ADC、FlashMode 101低电平第二个边沿下降沿部分传感器Mode 210高电平第一个边沿下降沿少数专用芯片Mode 311高电平第二个边沿上升沿W25Q 系列 Flash主机和从机必须工作在同一个模式下。配置错时最典型的故障现象是读回来的数据整体移位或者高字节和低字节对调像 SPI Flash 读 JEDEC ID 返回0x000000这类问题多半不是线接错而是模式没对上。在 FPGA 里模式只是一个 parameter改完重新综合即可这也是自研代码在排错上比 IP 核直观的地方。2.2 参数化 spi_master 代码下面这段是压缩包中 SPI 接口代码的简化可读版本。它把 CPOL、CPHA、分频系数和数据位宽全部做成了参数适用于 Vivado 下的绝大多数板级场景module spi_master #( parameter CPOL 1b0, // 时钟极性0 空闲低1 空闲高 parameter CPHA 1b0, // 时钟相位0 第一沿采样1 第二沿采样 parameter CLK_DIV 16, // SCK 频率 clk / (2 * CLK_DIV) parameter DATA_WIDTH 8 // 单帧数据位宽 )( 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 sck, output reg cs_n, input wire miso, output reg mosi ); localparam IDLE 2d0; localparam TRAN 2d1; localparam DONE 2d2; reg [1:0] state; reg [7:0] clk_cnt; reg [3:0] bit_cnt; reg [DATA_WIDTH-1:0] tx_shift; reg [DATA_WIDTH-1:0] rx_shift; // 分频计数到这两个位置时SCK 各翻转一次 wire half_end (clk_cnt CLK_DIV - 1); wire full_end (clk_cnt CLK_DIV * 2 - 1); // 主机改变 MOSI 的沿与从机采样沿错开 wire tx_edge CPHA ? half_end : full_end; // 主机采样 MISO 的沿 wire rx_edge CPHA ? full_end : half_end; // SCK 生成空闲电平跟随 CPOL always (posedge clk or negedge rst_n) begin if (!rst_n) sck CPOL; else if (state IDLE) sck CPOL; else if (half_end || full_end) sck ~sck; end // 状态机IDLE - TRAN - DONE always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; cs_n 1b1; busy 1b0; clk_cnt 8d0; bit_cnt 4d0; end else case (state) IDLE: begin if (start) begin state TRAN; cs_n 1b0; busy 1b1; clk_cnt 8d0; bit_cnt 4d1; tx_shift {tx_data[DATA_WIDTH-2:0], 1b0}; mosi tx_data[DATA_WIDTH-1]; end end TRAN: begin if (half_end || full_end) clk_cnt 8d0; else clk_cnt clk_cnt 8d1; if (tx_edge) begin mosi tx_shift[DATA_WIDTH-1]; tx_shift {tx_shift[DATA_WIDTH-2:0], 1b0}; if (bit_cnt DATA_WIDTH - 1) state DONE; else bit_cnt bit_cnt 4d1; end if (rx_edge) begin rx_shift {rx_shift[DATA_WIDTH-2:0], miso}; if (bit_cnt DATA_WIDTH - 1) rx_data {rx_shift[DATA_WIDTH-2:0], miso}; end end DONE: begin cs_n 1b1; busy 1b0; state IDLE; end endcase end endmodule设计里 SCK 不是真正由 FPGA 的时钟分频输出而是用一个计数器在half_end和full_end两个点翻转寄存器这样的好处是 SCK 的每个周期都由系统时钟精确控制仿真和实测波形完全一致。tx_edge和rx_edge是这段代码的核心主机改变 MOSI 的时机必须和采样 MISO 的时机错开否则在 Mode 0 下会出现数据建立时间不足收发的数据错位。参数调整时注意换算关系SCK 频率 系统时钟频率 / (2 * CLK_DIV)。例如 100 MHz 系统时钟配CLK_DIV20SCK 就是 2.5 MHz这正好是大多数 SPI Flash 和传感器手册推荐的常规工作频率。DATA_WIDTH改成 16 就可以直接挂两字节寄存器类从设备不需要动状态机。2.3 状态机与首比特时序三段式状态机在这里的边界很清楚IDLE等待上层拉起startTRAN按tx_edge/rx_edge逐位收发DONE释放片选并清busy。进入TRAN时代码同时把tx_data的最高位放到mosi上并将tx_shift预移一位。这个细节不要删否则 CPHA0 模式从机在第一个采样沿会采到一个未定义电平。bit_cnt记录已经放上总线的位数。初始为 1表示首位在片选拉低的同时已经就位在最后一个tx_edge上bit_cnt累加到DATA_WIDTH-1此时不再计数而是直接跳DONE。DONE只有一拍片选释放后回到IDLE保证连续两次start之间 CS_N 有至少一个系统时钟的高电平恢复时间。2.4 压缩包工程文件怎么对号入座SPI.zip 解压后的目录结构和 Vivado 工程标准布局一致。拿到手建议先看 readme.txt它会写明器件型号、时钟频率和板卡引脚分配再对照工程目录确认目录 / 文件作用SPI.xprVivado 工程入口双击或用open_project打开SPI.srcs/sources_1RTL 源码与 XDC 约束文件核心代码在这里SPI.sim/sim_1仿真工程包含 testbenchSPI.runs综合和实现结果含日志与 bit 文件SPI.cache工程缓存可删除不影响功能readme.txt版本、器件、引脚说明最先看这个如果本机 Vivado 版本和工程不匹配SPI.xpr很可能打不开。这种情况不用强行升级版本直接新建工程把sources_1下的.v和.xdc文件加进去就行IP 核如果有的话重新生成一次。3. Vivado 工程搭建从 .xpr 恢复工程到生成比特流3.1 工程恢复与目录检查Vivado 打开.xpr时会自动重建缓存和运行目录。如果解压后SPI.runs和SPI.cache丢了并不影响工程本身Vivado 会在综合时自动重建。比较稳妥的做法是把整个 SPI 目录放在不含中文和空格的路径下例如D:/fpga_proj/spi否则部分版本在生成比特流阶段会报路径解析错误。打开后在 Sources 面板确认顶层模块是否被识别约束文件是否被active。很多人忽略这一点XDC 文件加进工程但没有设为used_in_synthesis和used_in_implementation导致管脚完全没被约束上板后信号悬空。3.2 XDC 引脚与时钟约束如果开发板原理图把 SPI 接口定义在指定引脚XDC 里需要显式声明set_property PACKAGE_PIN M14 [get_ports sck] set_property IOSTANDARD LVCMOS33 [get_ports sck] set_property PACKAGE_PIN N14 [get_ports mosi] set_property IOSTANDARD LVCMOS33 [get_ports mosi] set_property PACKAGE_PIN P14 [get_ports miso] set_property IOSTANDARD LVCMOS33 [get_ports miso] set_property PACKAGE_PIN R14 [get_ports cs_n] set_property IOSTANDARD LVCMOS33 [get_ports cs_n] create_clock -period 10.000 -name sys_clk [get_ports clk]PACKAGE_PIN和IOSTANDARD必须成对出现只看原理图的网络名不查封装号是高频翻车点。create_clock的-period要和板上晶振一致比如 100 MHz 晶振写 10 ns。这里如果约束错误综合不会报错但实现阶段时序收敛会非常困难甚至生成比特流失败。3.3 用 Tcl 跑完综合、实现、比特流Vivado 的 GUI 操作每一步都有对应 Tcl 命令批量复现工程时直接跑脚本效率更高# 打开工程若已打开可省略 open_project SPI.xpr # 综合-jobs 指定并行线程数建议不超过物理核心数 launch_runs synth_1 -jobs 4 wait_on_run synth_1 # 实现并生成比特流 launch_runs impl_1 -to_step write_bitstream -jobs 4 wait_on_run impl_1 # 打开实现结果查看时序报告 open_run impl_1 report_timing_summary -delay_type max -max_paths 10wait_on_run必须接在launch_runs后面否则脚本会继续往下走命令之间没有依赖关系最终拿到一个旧比特流或者直接报错。synth_1和impl_1是 Vivado 默认的 run 名称如果工程改名过要从get_runs里确认实际名称。综合阶段常见错误是[Synth 8-3331] design has unconnected port这通常不是语法问题而是端口在 RTL 里没接或者顶层模块选错了。先查synth_1目录下的runme.log里面的报错行会精确到模块名和信号名比在 GUI 里翻 Message 窗口快得多。3.4 约束不完整时的报错处理管脚约束缺失时Vivado 会在实现阶段报[Place 30-574] Poor placement for routing between an IO pin and BUFG意思是 IO 引脚和时钟缓冲之间的布线质量差。看到这条不要急着改布局先回 XDC 把所有未约束端口补齐。另有一个很多人踩的坑set_property写在了create_clock前面导致引脚约束和时钟约束被分开处理。Vivado 对 XDC 的执行顺序敏感建议把引脚约束放在文件前半部分时钟约束放在后半部分并用注释分隔。综合和实现日志里如果出现WARNING: [Vivado 12-507] No ports matched说明端口名字里有拼写错误特别是cs_n和csn这种差异。4. 仿真验证testbench 回环与从机移位模型4.1 回环与从机模型的选择Vivado 仿真第一步不是接真实从设备而是先做回环测试。把 MOSI 直接连回 MISO相当于一个理想的透明从机主机发送什么就收到什么。这样可以独立验证主机的发送和接收沿是否错开如果设计里把采样沿和驱动沿放在同一位置回环测试会直接采到旧值收到的数据整体移位。回环通过后再换成一个行为级从机模型模拟 Flash 这类设备在 CS 拉低后先输出一个固定字节的行为。这样能验证 CS_N 低电平期间的完整时序包括首比特的建立时间。4.2 testbench 代码timescale 1ns/1ps module tb_spi_master; reg clk 0; reg rst_n 0; reg start 0; reg [7:0] tx_data 8h00; wire [7:0] rx_data; wire busy, sck, cs_n, mosi; reg miso; // 100 MHz 时钟 always #5 clk ~clk; spi_master #( .CPOL(0), .CPHA(0), .CLK_DIV(10) ) dut ( .clk(clk), .rst_n(rst_n), .start(start), .tx_data(tx_data), .rx_data(rx_data), .busy(busy), .sck(sck), .cs_n(cs_n), .miso(miso), .mosi(mosi) ); // 回环理想从机把主机发出的数据原样返回 assign miso mosi; initial begin rst_n 1b0; #100 rst_n 1b1; #20 tx_data 8hC5; start 1b1; #20 start 1b0; // 等待一帧传输结束然后比对收发数据 (negedge busy); if (rx_data 8hC5) $display(PASS: loopback rx 0x%02x, rx_data); else $display(FAIL: rx 0x%02x, rx_data); #200; $finish; end endmoduleCLK_DIV10让 SCK 频率降到 5 MHz波形更容易观察。start保持 20 ns 高电平相当于一个系统时钟周期确保状态机在IDLE状态能采样到启动信号。(negedge busy)是仿真的标准同步手段busy拉低表示整帧传输完成此时rx_data已经被正确锁存。回环测试的判定逻辑很简单tx_data0xC5如果rx_data不是0xC5优先检查tx_edge和rx_edge的极性配置而不是去改 testbench。在 Mode 0 下主机应在 SCK 下降沿改变 MOSI、上升沿采样 MISO把这两个沿反过来回环数据就会错位。4.3 移位从机模型验证收发回环测试通过后用下面的模型替换assign miso mosi模拟一个 CS 拉低后先回发固定值0xA5的从设备reg [7:0] slv_rx; always (negedge sck) begin if (cs_n) slv_rx 8hA5; // CS 高时从机寄存器复位 else slv_rx {slv_rx[6:0], mosi}; // 下降沿采样 MOSI end assign miso slv_rx[7];这个模型把从机的行为简化成两个动作SCK 下降沿把mosi移入内部寄存器同时把寄存器最高位送到miso。它不追求物理级精确但足以验证主机的 MISO 采样窗口是否落在数据稳定的半周期内。仿真时观察sck的下降沿和miso翻转点之间的距离如果出现建立时间不足把CLK_DIV调大一倍再跑。4.4 仿真常见坑仿真最常踩的坑有三个一是复位释放后不等待几个周期就立刻发start导致状态机还在复位态丢失请求二是 testbench 里用#1000这种绝对时间断言换时钟频率后仿真就挂三是在 Vivado 仿真器里直接跑不带timescale的文件#5这类延时会被解析成 0。这三个问题都会表现为波形看起来有 SCK但数据全错。定位时先把busy、cs_n、mosi三根信号加到波形窗口对照sck沿逐拍检查。5. 板上调试ILA 探针与一针见血的 SPI Flash 读 ID5.1 在工程里挂 ILA 抓波形仿真通过只说明 RTL 行为正确真实管脚上的信号还需要 ILA 来抓。常见做法是在 RTL 里例化一个 ILA IP把sck、cs_n、mosi、miso四根线全部接进去采样深度设 1024 足够抓几帧数据create_ip -name ila -vendor xilinx.com -library ip -version 6.2 -module_name ila_0 set_property -dict [list \ CONFIG.C_NUM_OF_PROBES {4} \ CONFIG.C_PROBE0_WIDTH {1} \ CONFIG.C_PROBE1_WIDTH {1} \ CONFIG.C_PROBE2_WIDTH {1} \ CONFIG.C_PROBE3_WIDTH {1} \ ] [get_ips ila_0]生成 IP 后在顶层模块里把待测信号连接到 ILA 的 probe 端口。上板后用 Hardware Manager 打开目标触发条件设成cs_n下降沿这样每次访问从设备时 ILA 都会准确抓一帧完整波形。用 ILA 抓 SPI 信号比示波器方便的地方在于它可以同时看到主机内部状态机的busy信号直接判断当前帧是发送阶段还是完成阶段。5.2 用读 ID 命令验证模式配置板上如果有 W25Q64 这类 SPI Flash有一个比盲调快得多的验证方法发送 JEDEC 读 ID 命令0x9F然后读三个字节。Winbond 的厂商 ID 是0xEF后面两个字节是型号和容量。配置 Mode 0 下如果读回0xEF 0x40 0x17说明 CPOL/CPHA、片选时序和 MISO 路径全部正常如果读回的数据是0x00 0x00 0x00优先怀疑 MISO 没接好用回环测试先确认线路如果读回的是移位后的乱值基本可以确定 CPHA 配反了。读 ID 比读任意数据更可靠因为 JEDEC ID 是固定值任何一位错误都能直接看出是时钟问题还是数据问题。调 SPI 从设备时先把命令发出去在 ILA 里数 SCK 沿的个数确认一帧确实是 8 个 SCK再去看数据内容。SCK 多一个沿或少一个沿通常就是分频计数器的边界条件写错了。5.3 回环短接与误接检查硬件调试最后一招把 MOSI 和 MISO 用杜邦线短接主机发0xA5ILA 抓回环波形的rx_data。如果回环收到的还是0xA5说明主机收发链路完整问题出在从设备一侧。如果回环收到的数据错位回 XDC 检查miso管脚是不是被复用成了其他外设功能。很多开发板的 SPI 引脚和 JTAG、LED 共用XDC 里多一句set_property就会把信号引到错误的外设上。短接测试时记得把从设备的片选引脚拉高避免板上从设备同时驱动 MISO 造成争用。把这一整套流程放在 SPI Flash 的读 ID 上跑一遍就能把协议、代码、约束和硬件链路一次性验证完。本文还有配套的精品资源点击获取