拓冰建站拓冰建站
首页 / 资讯中心 / 正文

FPGA上Verilog实现SPI主机:时序、状态机与仿真调试全解析

简介一份FPGA实现SPI通讯协议的Verilog学习资源包面向正在学习串行接口时序或Verilog状态机设计的开发者。设计采用状态机编写主机发送模块可将32位十六进制数逐位发送并工作在SPI模式0从机接收模块未限制为32位代码结构清晰、便于按需求直接修改复用。压缩包共149个文件大小4.33MB以.v源文件、Testbench仿真文件为主同时包含Quartus工程配置.qpf/.qsf与ModelSim波形记录.wlf等辅助文件代码导入后即可运行并执行仿真。该资源已有1205人学习适合通过实际工程理解主从SPI通信过程也可将其中状态机模块提取出来用于更大FPGA项目的数据交互部分。1. 从“四根线”到完整收发通路SPI 在 FPGA 上的真实复杂度SPI 写在纸面上只需要四根线SCLK、MOSI、MISO、CS_N。但在 FPGA 上用 Verilog 把它写对从来不是四行代码的事。相位选错一位读回的 Flash ID 就全乱片选拉早一个周期OLED 屏幕最后一列像素就花掉。这个标题真正要做的事是把一条串行通讯协议拆成 FPGA 工程师能直接落地的状态机和计数器主模式为主收发双通道时序可仿真参数可改。适合两类人拿 FPGA 接传感器、Flash、屏幕的集成工程师以及想把协议实现和时序分析一次理清的数字 IC 学习者。下文的实现不依赖厂商 IP代码可以直接进 Vivado 或 Quartus 工程跑仿真再上板看波形。2. SPI 时序四模式与 Verilog 接口设计2.1 从时序图推导 CPOL/CPHASPI 协议里最容易让新人在第一天就踩坑的是 CPOL 和 CPHA 的组合。CPOL 决定 SCLK 在空闲时是高还是低CPHA 决定数据是在第一个边沿采样还是在第二个边沿采样。二者组合出 Mode 0 到 Mode 3不同的从机芯片支持的组合不同设计主模块时不能假设“所有 SPI 都一样”。我一般不建议背模式表而是按数据手册的时序图反推先看 SCLK 空闲电平确定 CPOL再找数据被采样的是哪个边沿判断 CPHA。以最常见的 Mode 0 为例SCLK 空闲为低主机在 SCLK 上升沿采样 MISO在 SCLK 下降沿更新 MOSI。从机正好反过来上升沿采 MOSI下降沿更新 MISO。理解这个镜像关系比记住“CPHA0 是前沿采样”这种口诀可靠得多因为一旦 CPOL 写反所谓“前沿”也会跟着反。另一个容易被忽略的点是SCLK 并不是越快越好。FPGA 里通常没有专门的分频器给 SPI 用而是用系统时钟计数产生 SCLK 边沿。SCLK 频率一旦接近系统时钟的一半计数器的误差、组合逻辑延迟、PCB 走线长度都会开始影响采样窗口。我的建议是把 SCLK 控制在系统时钟频率的十分之一以下留出足够的建立时间和保持时间尤其当对端是 W25Q 这类 Flash 或 SSD1306 这类屏幕时。2.2 主机侧模块划分时钟、片选、数据三件事Verilog 实现 SPI 主机设计上要把三件事拆开时钟生成、片选控制、数据移位。时钟生成用半个周期计数器实现片选控制由状态机负责数据移位则是一个可双向工作的移位寄存器。这三个部分共用同一个系统时钟不需要额外产生一个真正的独立时钟源否则在综合时会遇到时钟树和毛刺问题。片选的控制要格外小心。很多从机要求在 CS_N 拉低之后、第一个 SCLK 边沿之前MOSI 就已经处于有效电平。也就是说MOSI 的第一个位不能等到 SCLK 开始翻转才赋值而应该在 CS_N 拉低的同时或者更早准备好。反过来传输结束时 CS_N 的拉高也不能发生在最后一个 SCLK 边沿太近的位置否则从机可能把最后一个位丢掉。这两个时间点在 datasheet 里通常标为 t_cs 和 t_holdFPGA 侧的做法是给 CS_N 的状态切换单独留一个或半个时钟周期。数据通路上还要考虑一个基础事实SPI 是全双工协议MOSI 和 MISO 在同一个 SCLK 驱动下同时移动。与 I2C 相比SPI 不需要地址帧和 ACK结构简单得多但代价是片选信号必须单独管理。I2C 靠地址区分设备SPI 靠 CS_N 区分设备这个差异直接决定了多设备挂载时的设计方式后面第五章会展开。2.3 一张表把四种模式对应到采样沿模式CPOLCPHASCLK 空闲电平数据采样沿数据改变沿Mode 000低上升沿下降沿Mode 101低下降沿上升沿Mode 210高下降沿上升沿Mode 311高上升沿下降沿多数 SPI Flash 支持 Mode 0 和 Mode 3因为这两种模式下 SCLK 的极性和相位刚好匹配正常数据翻转习惯一些传感器则固定只支持 Mode 1 或 Mode 2。FPGA 主模块的参数化设计里至少要留出 CPOL 和 CPHA 两个可配置位避免每换一颗从机就重写一遍状态机。实现时采样沿和改变沿在状态机里体现为两个不同的判断分支。只要把“在哪一个计数点采样 MISO”和“在哪一个计数点更新 MOSI”分别写成独立逻辑切换模式就只是交换这两个分支的触发条件。下面一章的代码以 Mode 0 为基准实现改动点会在 3.3 里单独说明。3. 用 Verilog 状态机实现 SPI 主机代码与参数说明3.1 外设接口定义与计数器设计先定模块对外接口。主机侧需要接收上层模块的启动信号和待发送数据然后对外输出 SCLK、MOSI、CS_N同时读入 MISO最终把收到的数据回传给上层。内部需要两个计数器一个是半周期计数器half_cnt用来生成 SCLK 的翻转节奏另一个是位计数器bit_idx用来记录当前传输到第几位。verilog计数器这块很容易混半周期计数器决定 SCLK 的每个高/低电平持续多久位计数器决定整个字节走到哪一步两者职责完全分开。下面代码以 50MHz 系统时钟、输出 1MHz SCLK、8 位数据为例。CLK_DIV表示半个 SCLK 周期对应的系统时钟个数所以 50MHz 下取 25SCLK 就是 50MHz / (2 × 25) 1MHz。DATA_W做成参数方便以后改成 16 位或 32 位。3.2 状态机主体代码module spi_master #( parameter CLK_DIV 25, // 半周期计数50MHz 下 SCLK 50M / (2*25) 1MHz parameter DATA_W 8 // 单次传输位宽 )( input wire clk, input wire rst_n, input wire start, input wire [DATA_W-1:0] tx_data, output reg [DATA_W-1:0] rx_data, output reg sclk, output reg mosi, input wire miso, output reg cs_n, output reg done ); localparam IDLE 2d0; localparam TRANS 2d1; localparam FINISH 2d2; reg [1:0] state; reg [7:0] half_cnt; // 半周期计数范围 0 ~ 2*CLK_DIV-1 reg [3:0] bit_idx; // 当前传输位下标0 ~ DATA_W-1 reg [DATA_W-1:0] tx_sr; reg [DATA_W-1:0] rx_sr; reg rx_last; // 最后一个采样沿标志 always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; sclk 1b0; cs_n 1b1; mosi 1b0; done 1b0; half_cnt 8d0; bit_idx 4d0; end else begin case (state) IDLE: begin done 1b0; if (start) begin tx_sr tx_data; rx_sr {DATA_W{1b0}}; half_cnt 8d0; bit_idx 4d0; rx_last 1b0; cs_n 1b0; mosi tx_data[DATA_W-1]; // 最高位先上线 state TRANS; end end TRANS: begin // 前半周期 SCLK 拉低后半周期拉高 if (half_cnt CLK_DIV) begin sclk 1b0; half_cnt half_cnt 1b1; end else if (half_cnt CLK_DIV) begin // SCLK 上升沿采样 MISOMode 0 的采样点 sclk 1b1; rx_sr {rx_sr[DATA_W-2:0], miso}; half_cnt half_cnt 1b1; if (bit_idx DATA_W - 1) rx_last 1b1; end else if (half_cnt 2*CLK_DIV - 1) begin // SCLK 下降沿更新 MOSIMode 0 的改变点 sclk 1b0; half_cnt 8d0; if (bit_idx DATA_W - 1) begin state FINISH; // 最后一位不再移位 end else begin tx_sr {tx_sr[DATA_W-2:0], 1b0}; mosi tx_sr[DATA_W-2]; bit_idx bit_idx 1b1; end end else begin // 剩余时间维持 SCLK 高电平 sclk 1b1; half_cnt half_cnt 1b1; end end FINISH: begin cs_n 1b1; done 1b1; rx_data rx_last ? rx_sr : rx_data; state IDLE; end endcase end end endmodule这个状态机的执行流程是IDLE 收到start后拉低cs_n同时把tx_data的最高位直接放到 MOSI 上保证从机在第一个 SCLK 上升沿之前就能看到有效数据。随后进入 TRANShalf_cnt先数 25 个周期保持 SCLK 低数到 25 时拉高 SCLK 并采样 MISO再数 25 个周期完成整个 SCLK 高电平最后在2*CLK_DIV - 1这个点拉低 SCLK 并尝试移位输出下一个 bit。注意bit_idx DATA_W - 1时最后一个下降沿只拉低 SCLK不再更新 MOSI。原因是最低位已经在最后一个上升沿被从机采走此时再移动 MOSI 毫无意义反而可能在cs_n拉高前制造一次额外的沿变化被某些对时序敏感的从机误判。rx_last标记则用来保证rx_data只在收到完整字节时更新避免中间过程把半成品数据暴露给上层。代码里用的是时序逻辑生成 SCLK而不是把clk分频后接到全局时钟网络。这种做法的好处是 SCLK 与系统时钟之间的相位关系确定方便在仿真里精确对齐采样点代价是 SCLK 上会有一点寄存器到输出的延迟但对 1MHz 量级的 SPI 完全足够。3.3 参数说明与模式切换方法参数取值示例含义改动影响CLK_DIV25半周期对应的系统时钟数越小 SCLK 越快受从机上限约束DATA_W8单次传输位数改为 16 时移位寄存器和 bit_idx 宽度同步调整想切到 Mode 3也就是 CPOL1、CPHA1 的组合最直接的办法是把 SCLK 空闲电平改为高并把采样沿和改变沿对调上升沿更新 MOSI下降沿采样 MISO。实现上只需要改两处一处是 IDLE 和 FINISH 里的sclk初始电平另一处是 TRANS 里half_cnt CLK_DIV和half_cnt 2*CLK_DIV - 1两个分支的处理内容。不要把模式参数直接塞进状态机判断里那样综合出来的逻辑会有一堆优先级判断排错的时候非常痛苦。提示参数化时把CPOL和CPHA做成两个 input 端口在 IDLE 里根据 CPOL 赋初始值在 TRANS 里根据 CPHA 分配采样/移位动作。这样做出来的模块可以直接复用到不同从机而不需要维护多份代码。4. 仿真验证与上板调试SPI 时序怎么才算对4.1 最小 testbench 与波形检查点写完状态机第一件事不是上板是跑仿真。下面这个 testbench 用最简单的回环方式验证收发通路把 MOSI 直接反馈到 MISO如果模块在上升沿采样、下降沿移位那么发出去8hA5回来也应该是8hA5。回环法不能覆盖所有从机行为但能把主机的时序逻辑基本测通。timescale 1ns/1ps module tb_spi_master; reg clk 0; always #10 clk ~clk; // 20ns 周期50MHz reg rst_n 0; reg start 0; reg [7:0] tx_data 8hA5; wire [7:0] rx_data; wire sclk, mosi, cs_n; wire done; reg miso; spi_master #( .CLK_DIV(25), .DATA_W (8) ) u_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), .done (done) ); assign miso mosi; // 回环模拟从机返回数据 initial begin #100; rst_n 1b1; #20; start 1b1; #20; start 1b0; #3000; if (rx_data tx_data) $display(SPI loopback PASS: rx%h, rx_data); else $display(SPI loopback FAIL: rx%h, rx_data); $finish; end endmodule仿真里要盯几个点cs_n拉低到第一个 SCLK 上升沿之间mosi是否已经等于 0xA5 的最高位每个 SCLK 上升沿处miso是否已经稳定而非刚刚变化最后一个 SCLK 下降沿之后cs_n是否延迟一拍再拉高。把这三个点在波形上标出来基本就能排除绝大多数引脚时序问题。4.2 上板前先排查的三个高频 Bug第一类问题是末位丢失。症状是读回来的数据总是最低位为 0其余位都对。这通常是因为bit_idx在最后一个上升沿还没有到达DATA_W-1导致最后一个采样点被漏掉或者cs_n在最后一个数据位还没稳定时就拉高了。解决方法是把rx_last标志放在采样沿而不是放在移位沿判断。第二类问题是第一个 bit 错误最常见原因是mosi在cs_n拉低之后、第一个 SCLK 之前才开始赋值。从机在这个窗口看到 MOSI 是未定义的会把第一个位采错。第三类问题来自异步 MISO。如果 MISO 来自另一颗芯片且和系统时钟不同源直接接进采样寄存器会产生亚稳态风险。常见做法是先用两级寄存器把 MISO 同步到系统时钟域再送进状态机。4.3 用逻辑分析仪确认时序图仿真通过只能说明功能对不能说明物理连接对。上板之后我习惯先抓完整的 SPI 时序图再看数据内容。如果手头有逻辑分析仪直接接 SCLK、MOSI、MISO、CS_N 四根线采样率至少是 SCLK 的 8 倍以上如果没有独立仪器可以在 FPGA 工程里例化厂商提供的逻辑分析仪 IP触发条件设置为cs_n下降沿抓一拍完整传输。看波形时对照数据手册的时序图重点比较空闲电平、采样沿相对 MOSI 变化的位置以及 CS_N 与最后一个 SCLK 边沿的时间关系。很多时候数据全错不是 Verilog 逻辑问题而是从机要求 Mode 3 而代码跑在 Mode 0这类问题在波形上一眼就能看出来。5. 多从机片选、异步 FIFO 与长数据流的 SPI 进阶5.1 硬件片选与软件片选在 FPGA 中的取舍单一从机时片选最省事直接拉低即可。接多个从机时FPGA 里通常没有真正意义上的“硬件片选”这个概念cs_n都是通用 IO 口由逻辑控制区别只在于由谁、在什么时机改变它。软件拉片选的好处是灵活主状态机可以连续访问不同从机而不改变内部逻辑缺点是一旦在代码里用组合逻辑直接译码产生片选会在 CS_N 上出现毛刺从机可能误入传输状态。折中的做法是为每个从机单独分配一个片选寄存器由上层控制状态机只在自身 IDLE 时允许改写保证片选沿和 SCLK 边沿不打架。5.2 用异步 FIFO 把 SPI 数据接入系统总线实际项目里 SPI 往往不是独立模块而是挂在 AXI、Wishbone 或者自定义总线下上层需要连续读写一长串数据。这种情况下SPI 主机每完成一个字节就拉高done如果上层每次等到done再发起下一次传输吞吐率会明显下降。常见做法是在 SPI 主机外面套一个异步 FIFO上游把待发送数据写入 FIFOSPI 主模块在done之后立刻从 FIFO 取下一个字节继续传输回读数据同时压入另一个 FIFO。异步 FIFO 解决的是系统时钟域和 SPI 字节节奏之间的速率匹配问题深度取 16 或 32 就够覆盖大多数传感器连续采集场景不必追求大容量。5.3 在最后一个电平面前保持敬畏不管是单字节读取还是 FIFO 长传SPI 主机最容易出问题的时刻永远是 CS_N 拉高前后的那半个周期。流式传输时建议在最后一个字节的采样沿之后显式保持一个半周期的 SCLK 稳定期再拉高 CS_N如果对端要求 CS_N 拉高后 SCLK 必须保持一定电平则把FINISH状态拆成两步先停 SCLK下一拍再拉高片选。这个时间参数通常写在从机数据手册的时序表里别只看推荐的模式表和最大频率把t_cs、t_hold、t_su这几个时序参数和仿真波形对一遍比反复试板子更省时间。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门