Verilog实现SPI主机:CPOL/CPHA时序设计与Testbench验证
2026/9/15 18:18:41 网站建设 项目流程

简介:SPI总线在FPGA/Verilog项目中的验证资料包,适合数字逻辑设计学习者、嵌入式开发者及需要快速上手SPI主从通信的工程师。压缩包内共579个文件、9.11MB,包含13个Verilog源文件(v),以及大量Quartus工程文件(tdf、bsf、qsf/qpf、vwf仿真波形等),并有编译报告、summary、readme等辅助说明,可还原完整FPGA开发与仿真流程。已有262人学习。内容涵盖SPI主机控制器、从机模型及testbench验证平台,代码中可看到p_spi、spi_mosi、spi_ad7687等模块,适合对照协议原理边读代码边跑仿真,理解SCLK、MOSI、MISO、SS信号如何生成与交互;同时Quartus工程和波形文件能帮助排查时序问题,是学习Verilog实现SPI及进行功能验证的完整参考。

1. 用 Verilog 写 SPI 不只是时序,验证才是主战场

调试 SPI 从机没反应时,很多人第一反应是查代码里的 SCLK 生成,实际上八成问题出在极性、相位和片选时序约定上。Verilog 写 SPI 主机不难,难的是让仿真波形和板级行为对上:仿真过了不代表上板就通,这背后是分频参数、数据沿选择和验证手段的配合。这篇内容围绕一个可综合的 SPI 主机 RTL 展开,从协议约定讲到 Testbench 验证,再落到连续传输和排错,适合正在做 FPGA 外设驱动、IC 验证或想把嵌入式 SPI 逻辑换成 RTL 实现的人。

2. SPI 协议的最小约定:极性相位、片选与采样点

2.1 四线之外还要定下谁先动

SPI 四根线 SCLK、MOSI、MISO、CS_N 大家都不陌生,但真正决定能不能通信的是“谁在哪个时刻动数据”。主机决定 SCLK 频率和 CS_N 拉低时机,从机只能跟随。比如 CS_N 必须先于第一个有效数据沿建立,否则从机可能把第一个 bit 丢掉;通信结束后 CS_N 最好在 SCLK 回到空闲电平后再释放,这一条很多从机手册都写得很细。

我一般会把“CS_N 拉低到第一个 SCLK 沿之间的时间”当作一个显式参数来看,而不是靠运气。很多 Verilog 例程把 CS_N 和 SCLK 的生成写在同一个状态机里,仿真能跑,但换成另一颗从机芯片就出问题,原因正是这个建立时间没有留足。在 RTL 里体现为:进入传输状态后先拉低 CS_N,再等至少一个系统时钟周期才开始翻转 SCLK,这样才符合大多数从机的时序要求。

2.2 CPOL/CPHA 四种模式与数据采样关系

CPOL 决定 SCLK 空闲电平,CPHA 决定数据是在第一个边沿还是第二个边沿变化。这里最容易记混的是:CPHA=0 时数据变化发生在 trailing edge,采样发生在 leading edge;CPHA=1 反过来。直接背表格比推理解释更实用:

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

从机数据手册里通常会给类似的时序图,只要找到采样沿就能确定 CPOL/CPHA。比如手册写“数据在 SCLK 上升沿被采样”,同时 SCLK 空闲为低,那就对应 Mode 0 的接收行为。此时主机侧就要保证 MOSI 数据在上升沿之前稳定,且 MISO 的读取也在这条沿上做,两个方向共享同一条采样沿。

写 RTL 时我用两个内部信号把“驱动沿”和“采样沿”分开,避免在状态机里到处写posedge sclk

// 用 CPOL 先定出 leading/trailing 边沿 wire sclk_rise = sclk & ~sclk_d; wire sclk_fall = ~sclk & sclk_d; wire leading = CPOL ? sclk_fall : sclk_rise; wire trailing = CPOL ? sclk_rise : sclk_fall; // CPHA=0 数据在 trailing 变化,采样在 leading // CPHA=1 数据在 leading 变化,采样在 trailing wire drv_edge = CPHA ? leading : trailing; wire smp_edge = CPHA ? trailing : leading;

sclk_d是 SCLK 打一拍后的延迟值,用来做边沿检测。drv_edge是主机改变 MOSI 的时刻,smp_edge是主机采样 MISO 的时刻。把这两个沿独立出来之后,状态机里就不需要再关心具体是上升沿还是下降沿,只关心数据动作。

2.3 为什么先从 Motorola 四线模式做起

SPI 除了摩托罗拉四线模式,还有德州仪器的 SSI 模式和国家半导体的 Microwire 模式。标题锁的是 Verilog 的 SPI 程序,实际工程里遇到的大多数 Flash、ADC、传感器芯片,支持的都是摩托罗拉四线格式,所以先把四线模式写透,性价比最高。

Microwire 是半双工,SSI 模式用帧同步信号代替 CS_N 且时序更严格,这两个场景在 RTL 实现上属于“加状态”而不是“改框架”。我一般建议先把四线模式的 CPOL/CPHA 四种组合全部跑通,再考虑扩展其他模式。否则状态机里同时塞入帧同步逻辑,调试时很难分清是协议问题还是状态机跳转问题。

3. Verilog 实现 SPI 主机:分频、沿检测与状态机

3.1 参数化端口:把 SCLK 频率算清楚

可综合的主机模块至少要暴露这些端口:系统时钟、复位、启动信号、发送数据、接收数据、忙标志,以及四根 SPI 物理信号。真正的分频系数通过参数计算,不要写成硬编码的计数器。下面这份代码是一个可直接综合的 SPI 主机,支持 CPOL/CPHA 四种模式和 1 到 32 位数据宽度:

module spi_master #( parameter CLK_HZ = 50_000_000, // 系统时钟频率 parameter SCLK_HZ = 5_000_000, // 目标 SCLK 频率 parameter CPOL = 0, // SCLK 空闲电平 parameter CPHA = 0, // 采样相位 parameter DATA_WIDTH = 8 // 单次传输位宽 )( input wire clk, input wire rst_n, input wire start, // 单字节启动脉冲 output reg busy, // 忙标志 input wire [DATA_WIDTH-1:0] tx_data, // 发送数据 output reg [DATA_WIDTH-1:0] rx_data, // 接收数据 output reg sclk, output reg cs_n, output reg mosi, input wire miso ); // SCLK = CLK_HZ / (2 * DIV) localparam DIV = CLK_HZ / (2 * SCLK_HZ); localparam IDLE = 2'd0; localparam TRANS = 2'd1; localparam DONE = 2'd2; reg [1:0] state; reg [7:0] clk_cnt; reg [DATA_WIDTH:0] bit_cnt; reg sclk_d; reg done_d; wire tick = (clk_cnt == DIV - 1); wire sclk_rise = sclk & ~sclk_d; wire sclk_fall = ~sclk & sclk_d; wire leading = CPOL ? sclk_fall : sclk_rise; wire trailing = CPOL ? sclk_rise : sclk_fall; wire drv_edge = CPHA ? leading : trailing; wire smp_edge = CPHA ? trailing : leading;

参数里CLK_HZSCLK_HZ只是给人看的,真正参与逻辑的是DIV。比如 50MHz 系统时钟想要 5MHz SCLK,DIV = 50_000_000 / (2 * 5_000_000) = 5,也就是 SCLK 每 5 个系统时钟翻转一次。DIV必须是整数,如果除不尽,SCLK 占空比会不均匀,所以选频率时先算一下能不能整除。

3.2 用系统时钟分频生成 SCLK 与沿脉冲

SCLK 用寄存器在系统时钟边沿翻转,不要用组合逻辑直接搭一个振荡器,否则综合时会绕出意外的锁存器或毛刺:

// SCLK 分频生成 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sclk <= CPOL; sclk_d <= CPOL; clk_cnt <= 0; end else if (state == DONE || state == IDLE) begin // 空闲或结束阶段拉回 CPOL 电平 sclk <= CPOL; sclk_d <= CPOL; clk_cnt <= 0; end else if (tick) begin sclk <= ~sclk; sclk_d <= sclk; // 注意这里打拍的是翻转前的 sclk clk_cnt <= 0; end else begin sclk_d <= sclk; clk_cnt <= clk_cnt + 1; end end

tick是分频计数到最后一个值的脉冲,只有它出现时 SCLK 才翻转。sclk_dtick分支里采的是翻转前的 SCLK,这样下一拍组合逻辑会得到一个单时钟周期的沿脉冲,比如 SCLK 刚从 0 变 1 时,sclk_rise为高一个系统周期。这个脉冲宽度是系统时钟的一个周期,不是 SCLK 的半周期,状态机用这个脉冲去做动作不会漏沿。

3.3 收发状态机:驱动沿、采样沿和位计数

状态机分成 IDLE、TRANS、DONE 三态,核心逻辑只有两个动作:在smp_edge采集 MISO 数据,在drv_edge更新 MOSI 数据:

// 收发状态机 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; cs_n <= 1'b1; busy <= 1'b0; mosi <= 1'b0; bit_cnt <= 0; rx_data <= 0; done_d <= 0; end else begin case (state) IDLE: if (start) begin state <= TRANS; cs_n <= 1'b0; busy <= 1'b1; bit_cnt <= 0; // CPHA=0 第一位需要在采样沿之前就绪 mosi <= (CPHA == 1'b0) ? tx_data[0] : 1'b0; end TRANS: begin if (smp_edge) begin rx_data[bit_cnt[DATA_WIDTH-1:0]] <= miso; if (bit_cnt == DATA_WIDTH - 1) state <= DONE; else bit_cnt <= bit_cnt + 1; end // 驱动 MOSI,注意 bit_cnt 不越界 if (drv_edge && (bit_cnt < DATA_WIDTH)) mosi <= tx_data[bit_cnt]; end DONE: begin if (done_d) begin cs_n <= 1'b1; busy <= 1'b0; mosi <= 1'b0; state <= IDLE; done_d <= 0; end else begin done_d <= 1; end end endcase end end endmodule

bit_cntsmp_edge之后递增,drv_edge永远用递增后的值去驱动下一位。这样四种模式的节奏都能对上:Mode 0 是先采样、后驱动;Mode 1 是先驱动、后采样。CPHA=1时首位不需要在 CS_N 拉低时预置,因为在第一个 leading 沿就会驱动;CPHA=0则必须预置,否则第一个采样沿拿到的是无效数据。

DONE 状态多留了一拍,先把 SCLK 靠分频逻辑拉回 CPOL 空闲电平,再拉高 CS_N。这一拍对 CPHA=0 的两种模式尤其重要,避免 CS_N 在 SCLK 非空闲时直接释放。

3.4 综合时要注意的时钟与复位习惯

这套 RTL 里 SCLK、MOSI、CS_N 都是普通寄存器输出,不是时钟资源。状态机全部挂在系统时钟clk上,不要在工程里把 SCLK 当作异步时钟去驱动逻辑,否则时序约束会变得很麻烦。综合时给 SCLK、MOSI、CS_N 加上输出延迟约束,让这三根线相对系统时钟的偏斜尽量一致。FPGA 上如果 SCLK 频率偏高,考虑把 IOB 寄存器打到位,避免引脚上出现组合毛刺。

复位用异步复位、同步释放的组合,rst_n低电平时 SCLK 直接停在 CPOL 电平,CS_N 释放,这部分在仿真和上板行为一致。

4. 用 Testbench 做 SPI 验证:从机模型与波形检查

4.1 Testbench 结构与从机模型

只把 MOSI 和 MISO 短接的“回环验证”只能证明主机没把自己发死,证明不了协议时序是否正确。更有效的做法是在 Testbench 里放一个行为级从机模型,让它按 Mode 0 的规则去采样和驱动,最后比较收到的数据。下面是一份可运行的激励代码:

`timescale 1ns/1ps module spi_tb; reg clk = 0, rst_n = 0, start = 0; reg [7:0] tx_data = 0; wire [7:0] rx_data; wire sclk, cs_n, mosi, miso; always #10 clk = ~clk; // 50MHz spi_master #( .CLK_HZ(50_000_000), .SCLK_HZ(5_000_000), .CPOL(0), .CPHA(0), .DATA_WIDTH(8) ) dut ( .clk(clk), .rst_n(rst_n), .start(start), .tx_data(tx_data), .rx_data(rx_data), .sclk(sclk), .cs_n(cs_n), .mosi(mosi), .miso(miso) ); // 从机模型:Mode 0,上升沿采样 MOSI,下降沿更新 MISO reg [7:0] slave_tx = 8'h3C; reg [7:0] slave_rx = 0; reg [7:0] shft; reg miso_r = 0; assign miso = cs_n ? 1'b0 : miso_r; always @(posedge sclk) slave_rx <= {slave_rx[6:0], mosi}; always @(negedge sclk) begin miso_r <= shft[6]; shft <= {shft[5:0], 1'b0}; end // 主机侧激励 initial begin #100 rst_n = 1; #20; tx_data = 8'hC5; start = 1; #20 start = 0; end // 从机在 CS 拉低后把发送字节装入移位寄存器 initial begin @(negedge cs_n); shft = slave_tx; miso_r = shft[7]; end initial begin wait (busy == 1'b0); #10; if (rx_data === 8'h3C && slave_rx === 8'hC5) $display("SPI check PASS"); else $display("SPI check FAIL: rx=%02h slave_rx=%02h", rx_data, slave_rx); $finish; end initial begin $dumpfile("spi_tb.vcd"); $dumpvars(0, spi_tb); end endmodule

从机模型里miso_r在 CS_N 拉低时直接输出最高位,之后每个 SCLK 下降沿输出下一位。这正好模拟了一颗 Mode 0 从机的行为:主机在上升沿采样到的是稳定数据,主机发出去的数据也会在上升沿被从机收进slave_rx。比较两个方向的结果,能同时验证发送通路和接收通路。

4.2 四种模式下跑一遍回归

把 DUT 例化参数里的CPOLCPHA改成四种组合,从机模型的采样沿和驱动沿也要跟着换,否则两边相位对不上自然会失败。用 Verilog 的generate块可以把四个模式一次性例化,或者简单点,直接在 Testbench 里复制四个 DUT 实例,各自连接独立的从机模型,然后逐个启动传输。

iverilog -o spi_tb.vvp spi_master.v spi_tb.v vvp spi_tb.vvp

Questa、Vivado Simulator 里则可以用 batch 模式跑,波形文件用$dumpfile输出为标准 VCD,方便后续用 GTKWave 或 Vivado 波形窗口查看。四种模式跑完的判定条件只有一个:主机收到的数据等于从机发出的数据,从机收到的数据等于主机发出的数据。

4.3 波形检查与常见仿真报错

仿真报错不一定都是 RTL 的问题。如果用的是商业仿真器,出现类似error: failure to obtain a verilog simulation license的报告,先查 license 环境变量和端口配置,别急着改代码。如果波形里 SCLK 翻转了但cs_n一直为高,查start脉冲是否只维持了一个周期,或者复位是否在传输过程中又被拉低。

波形里最容易看错的是数据到底在哪条沿被采样。把光标放在 SCLK 上升沿,观察 MOSI 是否已经稳定,MISO 是否来自从机当前位的输出;再把光标移到下降沿,看 MOSI 是否刚刚变化。如果 MOSI 变化点和预期差了一个 bit,基本是 CPHA 选反了。

5. 验证收尾:多字节连续传输、软硬件片选与 3 个坑

5.1 多字节收发要包一层 FIFO 或帧计数

上面的主机每次传输结束都会拉高 CS_N,这在多字节读写场景里不够用。读一颗 Flash 通常需要“先发命令,再读一串数据”,CS_N 必须全程保持低。常见做法是给主机外面包一层帧控制状态机,把 CS_N 的控制权上提:只有帧的第一个字节才拉低 CS_N,最后一字节结束后再释放。中间每字节完成后保持 CS_N 有效,重新拉高start发起下一次传输,两次传输之间留一个系统时钟的间隔就够了。

如果对吞吐有要求,我不会在应用层一字节一字节地启动,而是把多字节封装成带 FIFO 接口的控制器,FIFO 空就自动停 SCLK,有数据就继续移。这个封装不改变内部沿检测逻辑,只是把start的触发源从外部脉冲换成内部状态机。

5.2 软件片选与硬件片选在 RTL 里的差别

RTL 里常说的“软件片选”就是状态机直接控制cs_n寄存器,像这份代码里这样,传输开始拉低、结束拉高,中间流程全由逻辑掌握。硬件片选则是把 CS_N 的仲裁交给独立模块,比如多从机场景下的片选译码器,或者带自动片选管理的 SPI 控制器。对应到 Linux 驱动的语境里,“软件拉片选”是指驱动把 CS 控制交给寄存器手动操作,而“硬件片选”是控制器在传输开始时自动断言 NSS。两者在调试思路上完全不同:软件片选出问题先看驱动时序,硬件片选出问题要查控制器配置。做 FPGA 验证时如果把这两者混在一起,经常会误判故障位置,所以我习惯在 Testbench 里把cs_n视为协议信号而不是状态标志位。

5.3 上板前必查的三个定点

第一,核对从机手册的采样沿。把从机的 datasheet 时序图打开,对照第 2 章表格确认 CPOL 和 CPHA,仿真通过不代表上板通,但相位反了上板基本必死。第二,确认分频后的 SCLK 频率是否超过从机上限。很多器件标称 20MHz,实际在 10MHz 以下才稳定,调试阶段我会先把 SCLK 降到目标频率的四分之一,跑通再提频。第三,检查 CS_N 释放时 SCLK 是否处于空闲电平。像这套代码里 DONE 状态会强制 SCLK 归位,如果从机手册要求“CS_N 上升沿前至少半个 SCLK 周期保持空闲电平”,就需要在 DONE 状态里再多等一个完整的 SCLK 周期。

验证的手段不需要复杂,一颗带 SPI 接口的 Flash 或者 ADC,插上逻辑分析仪看三根线:CS_N 的拉低点、SCLK 的边沿频率、MOSI/MISO 的数据变化点,十有八九能定位问题。多接一块示波器探头,边沿图比任何代码评审都诚实。

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

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

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

立即咨询