简介:SPI作为微控制器与外部设备间广泛应用的串行接口,具有简单高效的特点;针对其Verilog实现,该压缩包提供了完整的工程文件与源码,面向FPGA开发者和数字逻辑设计学习者,帮助解决SPI通信中的时钟同步、数据收发与片选控制等问题。资源共包含167个文件,压缩包整体大小仅649KB,核心内容为Verilog源码(.v文件),同时包含ISE工程文件、UCF引脚约束、仿真波形记录(.wlf)、工程日志及综合中间文件等内容;目录结构清晰,完整覆盖源码、约束与仿真输出,便于按流程查阅。目前已有482人学习下载。设计提供SPI主模块与从模块的完整可用代码,支持通过CPOL和CPHA设定四种通信模式,可适应不同外设的时序要求;关键逻辑包括分频计数产生SCK、边沿检测同步、移位寄存器收发数据、SS片选控制等,采用参数化写法可灵活调整数据位宽与传输速率,适合在FPGA上快速验证或作为学习模板复用。工程资料中还保留了综合与仿真全过程记录,有助于理解从RTL到FPGA实现的整个流程,对课程设计、毕业设计和产品开发均有参考价值;对于需要学习数字接口设计、完成课程作业或进行FPGA项目开发的读者,这套资料能提供从代码到仿真验证的闭环参考。
1. SPI Verilog 实现前必须想清楚的三个问题
一个很常见的现象:照着网上例程写了个 SPI 主控,仿真波形漂亮,接到 W25Q64 上读 JEDEC ID,读回来全是 0。多数时候问题不在协议本身,而在采样沿选错、第一位数据的建立时序不对,或者片选释放得太早。SPI 看起来只有四根线,但 CPOL/CPHA 的组合、MSB/LSB 顺序、多字节传输时片选保持策略,任何一个环节出错,表现都是“数据不对但波形又很像”。
这篇内容把 SPI Verilog 实现拆成可落地的路径:先从时序图反推模块接口,再给出一份可综合、可调模式的主控代码,接着用 Testbench 和真实 Flash 验证,最后落到多字节收发和软硬件片选的取舍上。适合正在写 FPGA SPI 接口、被时序细节卡住的工程师,也适合想把 SPI 彻底吃透的 Verilog 初学者。
2. 从 SPI 时序图反推 Verilog 模块划分
2.1 对照数据手册确定 CPOL/CPHA 与采样沿
SPI 主控最核心的参数不是波特率,而是 CPOL 和 CPHA。CPOL 决定 SCLK 空闲电平,CPHA 决定数据是在第一个边沿还是第二个边沿被采样。这两个参数组合出四种模式,很多器件手册直接写“支持 Mode 0/3”,但有些只给时序图,需要自己判断。
| 模式 | CPOL | CPHA | SCLK 空闲电平 | 数据采样沿 | 常见器件 |
|---|---|---|---|---|---|
| Mode 0 | 0 | 0 | 低 | 上升沿(第一个沿) | W25Q64、MAX31865 |
| Mode 1 | 0 | 1 | 低 | 下降沿(第二个沿) | 部分 AD 采集芯片 |
| Mode 2 | 1 | 0 | 高 | 下降沿(第一个沿) | 部分传感器 |
| Mode 3 | 1 | 1 | 高 | 上升沿(第二个沿) | W25Q64、SD Card |
判断方法很简单:看数据在哪个边沿变化,在哪个边沿稳定。如果数据在 SCLK 下降沿变化、上升沿稳定,那就是 Mode 0;反过来,数据在上升沿变化、下降沿稳定,就是 Mode 1。拿示波器抓一下现有驱动的 SCLK 和 MOSI,比对着手册猜更快。
提示:不要凭“Flash 一般用 Mode 0”去套所有器件。同一颗芯片的不同指令、不同工作模式,片选时序可能完全不一样。
2.2 主控接口定义:四线制端口与参数化设计
确定了模式之后,下一步是定义模块端口。一个典型的 SPI 主控需要两类接口:一类接系统总线(时钟、复位、启动、数据),另一类接外部器件(SCLK、MOSI、MISO、CS)。数据位宽做成参数,方便后续扩展成 16 位、32 位传输。
module spi_master #( parameter DATA_WIDTH = 8, // 单次传输位宽 parameter CPOL = 0, // 时钟极性 parameter CPHA = 0, // 时钟相位 parameter CLK_DIV = 10 // 分频系数,sclk = clk / CLK_DIV )( 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 done, // 传输完成脉冲 output reg sclk, // SPI 时钟 output reg mosi, // 主出从入 input wire miso, // 主入从出 output reg cs_n // 片选,低有效 );MISO 必须是 input,MOSI 和 SCLK 是 output,这是 SPI 规范定的方向,不能反。done设计成单周期脉冲,调用方看到done为高就可以读取rx_data并启动下一拍。片选cs_n放在主控内部控制还是由上层控制,会影响多字节传输的写法,后面第 5 章专门说。
2.2.1 状态机与计数器:两种写法的适用边界
SPI 主控有两种常见写法:纯计数器和三段式状态机。纯计数器适合固定单字节回环测试,代码量小,但扩展多字节连续传输时要加额外逻辑。三段式状态机可读性和可维护性更好,一般把传输拆成 IDLE、XFER、HOLD 三个状态。
- IDLE:等待 start,拉低 cs_n,准备数据。
- XFER:按位收发数据,驱动 SCLK 翻转。
- HOLD:结束后拉高 cs_n,输出 done 脉冲。
这里用状态机还有一个好处:可以在 IDLE 和 XFER 之间插入等待周期,满足从机的 CS 建立时间,这在连接 AD7124 这类对时序敏感的器件时非常有用。
3. 用 Verilog 写一个支持四模式的 SPI 主控
3.1 可直接综合的主控模块代码
下面这份代码是我在实际项目里常用的结构,支持四种 SPI 模式,位宽和分频系数都是参数化配置。核心思路是用 SCLK 翻转前的电平判断即将出现的是第一个沿还是第二个沿,从而决定是采样还是移位。
module spi_master #( parameter DATA_WIDTH = 8, parameter CPOL = 0, parameter CPHA = 0, parameter CLK_DIV = 10 )( 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 done, output reg sclk, output reg mosi, input wire miso, output reg cs_n ); localparam IDLE = 2'd0; localparam XFER = 2'd1; localparam HOLD = 2'd2; reg [1:0] state; reg [7:0] clk_cnt; reg [3:0] bit_cnt; reg [DATA_WIDTH-1:0] tx_buf; reg [DATA_WIDTH-1:0] rx_buf; // 在 clk_cnt 溢出翻转 SCLK 的那一拍,sclk 还是旧值 // CPHA=0:第一个沿采样;CPHA=1:第二个沿采样 wire sample_here = (CPHA == 1'b0) ? (sclk == CPOL) : (sclk != CPOL); wire shift_here = (CPHA == 1'b0) ? (sclk != CPOL) : (sclk == CPOL); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; sclk <= CPOL; cs_n <= 1'b1; mosi <= 1'b0; done <= 1'b0; clk_cnt <= 8'd0; bit_cnt <= 4'd0; end else begin case (state) IDLE: begin done <= 1'b0; if (start) begin cs_n <= 1'b0; bit_cnt <= 4'd0; // CPHA=0:第一位数据在 CS 拉低后就要就绪 if (CPHA == 1'b0) begin mosi <= tx_data[DATA_WIDTH-1]; tx_buf <= {tx_data[DATA_WIDTH-2:0], 1'b0}; end else begin tx_buf <= tx_data; end state <= XFER; end end XFER: begin if (clk_cnt == (CLK_DIV/2 - 1)) begin clk_cnt <= 8'd0; // 先判断沿类型,再翻转 SCLK if (shift_here) begin mosi <= tx_buf[DATA_WIDTH-1]; tx_buf <= {tx_buf[DATA_WIDTH-2:0], 1'b0}; end if (sample_here) begin rx_buf <= {rx_buf[DATA_WIDTH-2:0], miso}; if (bit_cnt == DATA_WIDTH-1) begin rx_data <= {rx_buf[DATA_WIDTH-2:0], miso}; state <= HOLD; end else begin bit_cnt <= bit_cnt + 1; end end sclk <= ~sclk; end else begin clk_cnt <= clk_cnt + 1; end end HOLD: begin cs_n <= 1'b1; done <= 1'b1; state <= IDLE; end endcase end end endmodule3.1.1 关键逻辑说明
sample_here和shift_here是整个设计的核心。以 Mode 0 为例,CPOL=0、CPHA=0,SCLK 空闲为低。当clk_cnt溢出准备翻转时,如果当前 SCLK 还是低,说明即将产生上升沿,这是第一个沿,sample_here为真,主机在这个沿采样 MISO;如果当前 SCLK 是高,即将产生下降沿,这是第二个沿,shift_here为真,主机在这个沿把下一位数据放到 MOSI。
CPHA=1 时正好反过来:第一个沿是移位沿,第二个沿是采样沿,所以sample_here的条件取反。用这种方式判断边沿,比打一拍检测sclk跳变少一个时钟周期延迟,避免采样点整体后移。
IDLE 状态里对tx_buf的处理也需要特别注意。CPHA=0 时,从机在第一个采样沿就要读到第一位数据,所以 CS 拉低后必须立刻把tx_data的最高位放到 MOSI 上,同时把tx_buf左移一位,让移位寄存器最高位变成第二位数据。CPHA=1 时第一位在第一个移位沿才给出,不需要预置。
3.1.2 参数设置与时钟分频
CLK_DIV必须是偶数,因为 SCLK 每CLK_DIV/2个系统时钟翻转一次,一个完整 SCLK 周期需要CLK_DIV个系统时钟。比如系统时钟 50MHz,想要 5MHz 的 SCLK,CLK_DIV取 10。
| CLK_DIV | 系统时钟 50MHz 时 SCLK 频率 | 适用场景 |
|---|---|---|
| 4 | 12.5MHz | W25Q64 快速读,短走线 |
| 10 | 5MHz | 常规 Flash 读写 |
| 20 | 2.5MHz | 传感器采集,抗干扰 |
| 50 | 1MHz | 长线连接、低速 AD 芯片 |
SCLK 频率不是越高越好。连接线超过 10cm、没有阻抗匹配时,建议把频率降到 5MHz 以下。MOSI 数据在 SCLK 边沿前需要建立时间,CLK_DIV越大,建立时间越充裕,这是低速场景下优先调大分频系数的原因。
3.2 和软件模拟 SPI 的差异
MCU 上经常用 GPIO 软件模拟 SPI,每个 bit 靠延时翻转引脚。FPGA 里写 SPI 主控本质上也是“软件模拟”,只不过这个“软件”变成了状态机,运行在硬件时钟上。软件模拟的优势是灵活,引脚任意选;Verilog 实现的优势是时序精确、不占 CPU、可以同时跑多个 SPI 实例。
但 Verilog 实现有一个容易踩的坑:软件模拟时可以每 bit 之间延时半周期,而 Verilog 主控的 SCLK 必须连续翻转,不能中途停顿。如果从机要求字节之间有间隔,需要在状态机里显式插入等待周期,否则从机可能把两个字节当成一个连续流。
4. 仿真验证与真实器件调试
4.1 Testbench:用回环从机验证时序
写一个简单的回环 Testbench,让 MISO 在 SCLK 下降沿输出移位寄存器中的值,主机在上升沿采样。这样能同时验证发送和接收两条路径。
`timescale 1ns/1ps module tb_spi_master; reg clk = 0; reg rst_n = 0; reg start = 0; reg [7:0] tx_data = 8'hA5; wire [7:0] rx_data; wire done, sclk, mosi, cs_n; reg miso = 0; always #10 clk = ~clk; // 50MHz spi_master #( .DATA_WIDTH(8), .CPOL(0), .CPHA(0), .CLK_DIV(10) ) u_spi ( .clk(clk), .rst_n(rst_n), .start(start), .tx_data(tx_data), .rx_data(rx_data), .done(done), .sclk(sclk), .mosi(mosi), .miso(miso), .cs_n(cs_n) ); // 回环从机:每个 SCLK 下降沿送出最高位,同时移入 MOSI reg [7:0] loop_buf; always @(negedge sclk or negedge cs_n) begin if (!cs_n) begin miso <= loop_buf[7]; loop_buf <= {loop_buf[6:0], mosi}; end end initial begin #20 rst_n = 1; #30 start = 1; #20 start = 0; wait (done == 1); $display("rx_data = %02h", rx_data); #100 $finish; end endmodule这段代码里,loop_buf初始值是 0,所以第一次回环读到的rx_data会是 0,这正常。测试的重点是观察 SCLK 的翻转频率、CS 的拉低时机、MOSI 数据是否在每个移位沿提前就绪。把loop_buf初始化为8'hC3,就能验证主机能不能正确采到固定数据。
4.1.1 仿真常见的两个问题
仿真时最容易发现的两类问题:一是done信号一直不拉高,说明bit_cnt没有计数到边界,检查sample_here的极性是否反了;二是rx_data整体移位一位,通常是 CPHA 参数和器件时序不匹配,把 CPHA 取反试试。
4.2 连接 W25Q64 读 JEDEC ID 的验证流程
W25Q64 是验证 SPI 主控最常用的器件,支持 Mode 0 和 Mode 3。读 JEDEC ID 的指令是0x9F,发送完指令后从机连续返回 3 个字节 ID,整个过程 CS 必须保持低电平。
// 伪代码:用主控模块连续三次调用读指令 // 第 0 次:tx_data = 8'h9F,忽略 rx_data // 第 1 次:tx_data = 8'h00,rx_data 为 ID 第 1 字节 // 第 2 次:tx_data = 8'h00,rx_data 为 ID 第 2 字节 // 第 3 次:tx_data = 8'h00,rx_data 为 ID 第 3 字节调用方式是在每次done拉高后立即给下一次start拉高。注意 W25Q64 在发送指令期间 MISO 还在输出无意义数据,所以第一次的rx_data要丢弃。正常读出来的 ID 前两个字节是0xEF 0x40,第三字节是容量码,例如0x17对应 8Mb。
如果读回全是0xFF,先看 CS 是否真的在整个传输期间保持低电平。用逻辑分析仪抓波形,确认指令字节准确出现在 MOSI 上,再检查 MISO 有没有接错引脚。
4.3 调试 SPI Verilog 代码的故障定位方法
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 读回全 0 | 采样沿选错、MISO 未连接、CS 没拉低 | 抓 SCLK 和 MISO 波形对比 |
| 数据错位 1 bit | CPHA 参数与器件不匹配 | 反转 CPHA 重新仿真 |
| 数据反序 | MSB/LSB 顺序搞反 | 检查移位方向和寄存器索引 |
| 仿真正常、上板失败 | 引脚约束错误、三态处理不当 | 检查 XDC 约束和 IO 标准 |
| MISO 偶尔跳变 | 干扰严重、建立时间不足 | 降低 SCLK 频率,加滤波 |
MISO 毛刺在高频模式下容易出现,常见做法是在主时钟域对 MISO 打两拍,再用三点滑动窗口滤波。这个技巧对长线传输特别有效。
reg miso_d1, miso_d2; always @(posedge clk) begin miso_d1 <= miso; miso_d2 <= miso_d1; end wire miso_clean = (miso & miso_d1) | (miso & miso_d2) | (miso_d1 & miso_d2);注意:滑动窗口滤波会引入至少两个时钟周期延迟,对 SCLK 高于 10MHz 的场景要慎用,否则建立时间会变得非常紧张。
5. SPI Verilog 多字节收发与软硬件片选的取舍
5.1 多字节连续读的状态机扩展
单个字节的主控模块改成多字节模式,核心改动是把 HOLD 状态变成“判断是否还有下一字节”。读 W25Q64 这类操作要求 CS 在整个指令和数据传输期间保持低电平,所以不能每读完一个字节就拉高 CS。常见做法是增加一个byte_total参数和一个byte_index计数器,在 HOLD 状态判断:
// HOLD 状态扩展示意 HOLD: begin if (byte_index == byte_total - 1) begin cs_n <= 1'b1; done <= 1'b1; state <= IDLE; end else begin byte_index <= byte_index + 1; state <= XFER; // 保持 CS 为低,继续下一字节 end end这里cs_n在 HOLD 状态不能无条件拉高,否则多字节读会被截断。这也是软件片选比硬件片选灵活的地方:硬件片选由外设自动控制,很多 MCU 的 SPI 外设会在每个字节之间自动拉高片选,做 Flash 连续读时就非常别扭。在 FPGA 里自己控制cs_n,想保持多久就保持多久。
5.2 软件片选与硬件片选的实际选择
在纯 FPGA 设计中,我一般直接选择软件片选,也就是用状态机里的寄存器控制 CS。好处有两个:一是多字节传输时 CS 的保持时间完全可控;二是可以通过调整状态机自由插入等待周期,适配慢速从机。代价是自己要保证 CS 拉低、拉高的建立和保持时间,通常至少留出半个 SCLK 周期。
硬件片选更适合 SoC 场景,比如 ARM 处理器自带的 SPI 控制器,片选由硬件模块管理,CPU 只需要写寄存器。Linux 下的 SPI 驱动也支持软件拉片选,通过设备树配置cs-gpios把片选映射到 GPIO,本质上和 FPGA 里的软件片选一个思路——把控制权交给驱动代码。
关于 DMA,独立编写的 SPI Verilog 主控不需要考虑 DMA,主控本身就在硬件里。只有把 SPI 挂到 CPU 总线上、需要搬运大块数据时才涉及 DMA 请求,FPGA 侧用 FIFO 缓存收发数据就足够了。
最后提一个实用细节:无论单字节还是多字节,cs_n的拉低动作一定要和start信号在同一拍内用非阻塞赋值完成。这样从机看到的第一个 SCLK 边沿一定在 CS 稳定之后,不会出现片选还没拉低、时钟先跳的违例。把这一点写进状态机,比事后调时序约束省事得多。
本文还有配套的精品资源,点击获取