FPGA实现SPI通信:从协议原理到Verilog代码实战

FPGA实现SPI通信:从协议原理到Verilog代码实战
1. 项目概述从零到一在FPGA上实现SPI通信搞FPGA开发绕不开和各种通信协议打交道。SPISerial Peripheral Interface作为最常用、最经典的串行通信协议之一几乎是每个FPGA工程师的必修课。它不像I2C那样需要上拉电阻和复杂的仲裁机制也不像UART那样需要精确的波特率匹配SPI以其全双工、高速、协议简单的特点在Flash、ADC、DAC、传感器、显示屏驱动等外设连接中占据着核心地位。我刚开始接触FPGA时总觉得SPI很简单不就是四根线SCLK, MOSI, MISO, CS按着时序图翻转吗但真到自己动手用Verilog去实现一个稳定可靠的SPI Master或Slave时才发现里面藏着不少“坑”时钟域如何处理片选信号怎么同步不同模式CPOL, CPHA下的数据采样和输出边缘如何精确控制这些细节直接决定了通信的成败。这篇笔记就是我结合多次项目实战从最基本的协议解析到可复用的Verilog代码实现一步步拆解FPGA上的SPI通信。无论你是刚入门FPGA正在寻找一个具体的协议实现练手还是已经有一定经验想优化自己的SPI控制器设计这里面的思路、代码和踩过的“坑”或许都能给你带来一些参考。我们会从协议本质出发用状态机清晰地构建控制器并讨论如何让设计更健壮、更易于集成。最终的目标是让你能获得一个经过验证的、参数化的SPI Master模块可以直接用到你的下一个项目里。2. SPI协议核心与FPGA实现思路拆解2.1 SPI协议的本质一个同步的移位寄存器环理解SPI最形象的比喻就是一个分布式的、通过四根线连接起来的巨型移位寄存器。主设备Master内部有一个移位寄存器从设备Slave内部也有一个。当主设备产生时钟SCLK时这两个寄存器就通过MOSIMaster Out Slave In和MISOMaster In Slave Out两根数据线连接起来同步地进行数据移位。主设备移出的数据通过MOSI进入从设备同时从设备移出的数据通过MISO进入主设备。一个时钟周期就完成了一次双向的数据交换。片选CS信号则用于在多个从设备中选择当前与主设备连接的那一个。协议的核心参数只有两个时钟极性CPOL和时钟相位CPHA。它们的组合构成了SPI的四种模式Mode 0-3。CPOL (Clock Polarity): 决定SCLK空闲时的电平。CPOL0空闲时为低电平CPOL1空闲时为高电平。CPHA (Clock Phase): 决定数据在哪个时钟边沿被采样。CPHA0在第一个时钟边沿即SCLK从空闲状态第一次跳变时采样CPHA1在第二个时钟边沿采样。对于FPGA实现而言最关键的是要严格根据你选定的模式确定数据输出时刻主设备应在哪个边沿将数据放到MOSI线上。数据采样时刻主设备应在哪个边沿从MISO线上读取数据。以最常用的Mode 0 (CPOL0, CPHA0)为例SCLK空闲为低。第一个边沿是上升沿第二个边沿是下降沿。CPHA0意味着在第一个边沿上升沿采样。因此数据必须在上升沿到来之前就保持稳定。所以主设备需要在SCLK的下降沿即前一个周期的第二个边沿更新MOSI数据而从设备同样需要在下降沿更新MISO数据。这样当上升沿到来时双方都能采样到对方已稳定的数据。把这个时序关系用RTL语言描述清楚是设计的第一步。2.2 FPGA实现SPI Master的总体架构在FPGA中实现SPI Master我推荐采用一个清晰的状态机FSM来统领整个流程这比纯粹用计数器控制要健壮和易维护得多。一个典型的状态机可以包含以下几个状态IDLE: 空闲状态。拉高片选CS通常低有效SCLK保持在空闲电平由CPOL决定。等待发送启动信号。START: 启动状态。拉低片选CS并等待一段时间T_{CS_SU}以满足从设备的片选建立时间要求。这个等待可以通过计数器实现。SHIFT: 移位状态。这是核心状态。在此状态下根据CPOL和CPHA生成正确的SCLK时钟并在正确的边沿切换数据。通常需要一个位计数器bit_cnt来记录已移位的位数。STOP: 停止状态。在最后一个数据位传输完成后可能需要等待一段时间T_{CS_HOLD}再拉高CS或者根据协议要求将SCLK恢复到空闲状态。然后跳转回IDLE。整个模块的接口除了标准的SPI四线SCLK, MOSI, MISO, CS_n外还应包括clk,rst_n: 系统时钟和复位。start_i: 启动一次传输的脉冲信号。data_tx_i: 要发送的数据如8位或16位。data_rx_o: 接收到的数据。busy_o: 忙信号高电平表示正在传输中。cpol_i,cpha_i: 可选动态配置模式参数。如果外设固定可以在模块内参数化。注意很多初学者会忽略片选建立和保持时间T_{CS_SU}, T_{CS_HOLD}。虽然有些简单的从设备不要求但对于如SPI Flash这类严格遵循协议的外设不满足时序会导致读写失败。最稳妥的办法是在START和STOP状态加入可配置的延时计数器。3. 关键模块设计与Verilog代码实现解析3.1 状态机与主控制器设计下面是一个针对Mode 0和Mode 3CPHA0的SPI Master状态机核心代码片段。我将其设计为参数化可以方便地配置数据位宽。module spi_master #( parameter DATA_WIDTH 8, parameter CLK_DIV 4 // 系统时钟分频系数决定SCLK频率 )( input wire clk, input wire rst_n, // 用户接口 input wire start_i, input wire [DATA_WIDTH-1:0] data_tx_i, output reg [DATA_WIDTH-1:0] data_rx_o, output reg busy_o, // SPI物理接口 output reg sclk_o, output reg mosi_o, input wire miso_i, output reg cs_n_o ); // 状态定义 localparam S_IDLE 2b00; localparam S_START 2b01; localparam S_SHIFT 2b10; localparam S_STOP 2b11; reg [1:0] state, next_state; reg [3:0] clk_cnt; // 用于SCLK分频和边沿生成的计数器 reg [$clog2(DATA_WIDTH)-1:0] bit_cnt; // 位计数器 reg [DATA_WIDTH-1:0] tx_reg, rx_reg; // 发送/接收移位寄存器 // 状态转移逻辑 always (posedge clk or negedge rst_n) begin if (!rst_n) state S_IDLE; else state next_state; end always (*) begin next_state state; case (state) S_IDLE: if (start_i) next_state S_START; S_START: if (clk_cnt 4d1) next_state S_SHIFT; // 简单延时 S_SHIFT: if (bit_cnt DATA_WIDTH clk_cnt 4d0) next_state S_STOP; S_STOP: if (clk_cnt 4d3) next_state S_IDLE; // 简单延时 default: next_state S_IDLE; endcase end // 主计数器与SCLK生成 (CPOL0) always (posedge clk or negedge rst_n) begin if (!rst_n) begin clk_cnt 0; sclk_o 1b0; // CPOL0, 空闲低电平 end else begin case (state) S_IDLE: begin clk_cnt 0; sclk_o 1b0; end S_START: begin clk_cnt clk_cnt 1; sclk_o 1b0; end S_SHIFT: begin clk_cnt clk_cnt 1; // 生成SCLK每CLK_DIV个系统时钟SCLK翻转一次 if (clk_cnt CLK_DIV/2 - 1) sclk_o 1b1; else if (clk_cnt CLK_DIV - 1) begin sclk_o 1b0; clk_cnt 0; end end S_STOP: begin clk_cnt clk_cnt 1; sclk_o 1b0; end endcase end end这个状态机清晰地划分了传输阶段。CLK_DIV参数决定了SCLK的频率F_sclk F_clk / (2 * CLK_DIV)。在S_SHIFT状态通过clk_cnt计数器生成占空比50%的SCLK。3.2 数据移位与采样逻辑实现数据移位是核心中的核心。对于Mode 0数据在SCLK下降沿更新在上升沿采样。我们需要在clk_cnt计数到特定值时操作移位寄存器。// 位计数器逻辑 always (posedge clk or negedge rst_n) begin if (!rst_n) bit_cnt 0; else begin case (state) S_IDLE, S_START: bit_cnt 0; S_SHIFT: begin // 在SCLK的下降沿对应CPOL0 clk_cnt从高到低跳变点递增位计数 // 我们可以在clk_cnt计满归零时即一个完整的SCLK周期结束递增 if (clk_cnt CLK_DIV - 1) begin if (bit_cnt DATA_WIDTH) bit_cnt 0; else bit_cnt bit_cnt 1; end end default: bit_cnt 0; endcase end end // 发送移位寄存器 (MOSI) always (posedge clk or negedge rst_n) begin if (!rst_n) begin tx_reg 0; mosi_o 1b0; end else begin case (state) S_IDLE: begin tx_reg data_tx_i; // 锁存待发送数据 mosi_o 1b0; end S_SHIFT: begin // 在SCLK下降沿更新MOSI数据 // 判断下降沿CPOL0时sclk_o从1变0。我们可以用clk_cnt到达特定值来模拟 if (clk_cnt CLK_DIV/2 - 1) begin // 此时sclk_o为高下一个时钟它变低 // 先移位最高位(MSB)先发送是常见约定也可改为LSB tx_reg {tx_reg[DATA_WIDTH-2:0], 1b0}; mosi_o tx_reg[DATA_WIDTH-1]; end end default: mosi_o 1b0; endcase end end // 接收移位寄存器 (MISO) always (posedge clk or negedge rst_n) begin if (!rst_n) begin rx_reg 0; data_rx_o 0; end else begin case (state) S_SHIFT: begin // 在SCLK上升沿采样MISO数据 // 判断上升沿CPOL0时sclk_o从0变1。对应clk_cnt从(CLK_DIV/2 -1)变为(CLK_DIV/2) // 我们在上升沿后的那个系统时钟周期采样 if (clk_cnt CLK_DIV/2) begin rx_reg {rx_reg[DATA_WIDTH-2:0], miso_i}; end end S_STOP: begin // 一次传输结束将接收移位寄存器的值输出 data_rx_o rx_reg; end default: ; endcase end end // 忙信号与片选信号 always (posedge clk or negedge rst_n) begin if (!rst_n) begin busy_o 1b0; cs_n_o 1b1; end else begin busy_o (state ! S_IDLE); case (state) S_IDLE: cs_n_o 1b1; default: cs_n_o 1b0; // START, SHIFT, STOP期间片选有效 endcase end end实操心得采样时刻的同步至关重要。上面代码在clk_cnt CLK_DIV/2时采样这假设miso_i相对于系统时钟clk是同步的。然而MISO信号是来自外部从设备的它与FPGA的系统时钟是异步关系直接采样可能导致亚稳态。更稳健的做法是使用两级触发器进行同步化处理或者更推荐的做法是在SCLK的边沿对于FPGA是已知的去采样然后打拍同步到系统时钟域。但这里又涉及到跨时钟域问题。一个工程上常用的简化策略是将SCLK的频率设置得远低于FPGA系统时钟比如1/10或更低这样在系统时钟下看MISO信号其变化相对缓慢亚稳态风险降低。但对于高速SPI必须严格设计同步电路。4. 仿真测试与上板验证全流程4.1 编写Testbench进行行为仿真设计完成后必须通过仿真验证。我们需要编写一个Testbench模拟一个SPI从设备与我们的Master进行对话。这里模拟一个简单的从设备它会在主设备发送数据的同时返回一个固定的数据比如0xAA。timescale 1ns / 1ps module tb_spi_master(); reg clk, rst_n, start; reg [7:0] data_tx; wire [7:0] data_rx; wire busy, sclk, mosi, cs_n; reg miso; // 实例化被测模块 spi_master #( .DATA_WIDTH(8), .CLK_DIV(4) // SCLK clk / 8 ) u_spi_master ( .clk(clk), .rst_n(rst_n), .start_i(start), .data_tx_i(data_tx), .data_rx_o(data_rx), .busy_o(busy), .sclk_o(sclk), .mosi_o(mosi), .miso_i(miso), .cs_n_o(cs_n) ); // 生成时钟 initial begin clk 0; forever #10 clk ~clk; // 50MHz时钟 end // 模拟从设备行为 reg [7:0] slave_data 8hAA; // 从设备准备返回的数据 always (negedge sclk or posedge cs_n) begin // 在SCLK下降沿或片选无效时更新从设备输出 if (cs_n 1b1) begin miso 1bz; // 高阻态 end else begin // 假设从设备也是MSB first在SCLK下降沿移位输出 slave_data {slave_data[6:0], 1b0}; miso slave_data[7]; end end // 主测试流程 initial begin rst_n 0; start 0; data_tx 8h00; miso 1b0; #100 rst_n 1; #50; data_tx 8h55; // 准备发送的数据 start 1; #20 start 0; // 等待传输完成 wait(busy 0); #100; // 检查接收数据 if (data_rx 8hAA) begin $display([PASS] Received data is 0x%h, as expected., data_rx); end else begin $display([FAIL] Received data is 0x%h, expected 0xAA., data_rx); end #100 $finish; end endmodule在仿真工具如ModelSim, VCS, Vivado Simulator中运行这个Testbench观察波形。你需要重点关注cs_n拉低后是否有一段建立时间sclk的频率和占空比是否正确mosi数据是否在SCLK下降沿变化miso数据是否在SCLK上升沿被正确采样到rx_reg中传输8位后cs_n是否拉高data_rx_o是否更新为0xAA4.2 上板调试与逻辑分析仪抓取真实波形仿真通过后就可以进行上板验证了。这是最激动人心也最容易出问题的环节。引脚分配与约束根据你的开发板原理图将mosi_o,miso_i,sclk_o,cs_n_o分配到正确的FPGA引脚上。注意miso_i是输入要正确设置I/O标准如LVCMOS3.3V。连接外设找一个简单的SPI从设备比如一个SPI接口的LED驱动芯片如74HC595或ADC芯片如MCP3008。按照电路图连接好线。务必注意电平匹配如果外设是5V TTLFPGA是3.3V LVCMOS可能需要电平转换电路。使用逻辑分析仪这是调试数字通信协议的利器。将逻辑分析仪的探头连接到SPI的四根线上。设置触发条件为cs_n的下降沿。然后启动FPGA程序发送数据。你可以清晰地看到真实的时序波形。检查时序参数测量SCLK频率、CS建立/保持时间、数据建立/保持时间相对于SCLK采样边沿。与从设备数据手册的要求进行对比。检查数据内容对照波形手动解码MOSI和MISO线上的数据流看是否与预期一致。踩坑记录我曾遇到一个棘手问题仿真一切正常但上板后SPI Flash就是读不出ID。用逻辑分析仪抓波形发现SCLK的频率是对的但MOSI数据的变化边沿太靠近SCLK的采样边沿了导致从设备采样setup时间不足。原因是我的代码里mosi_o的更新与clk_cnt的判断条件过于“临界”。解决方法是在数据更新路径上插入一个寄存器拍让数据提前半个SCLK周期甚至更早稳定下来。教训是仿真只能验证逻辑功能时序问题必须通过实际硬件波形来排查。5. 高级话题与设计优化5.1 支持全模式Mode 0-3的参数化设计前面的代码主要针对Mode 0。一个健壮的SPI Master应该能支持所有四种模式。我们可以通过输入信号cpol_i和cpha_i来动态控制。关键在于SCLK的生成和数据采样的边沿选择需要根据模式动态计算。我们可以定义两个关键的边沿时刻data_change_edge: 数据MOSI和从设备内部的MISO需要变化的时刻。data_sample_edge: 数据MISO和从设备采样MOSI被采样的时刻。通过CPOL和CPHA可以推导出如果CPHA 0:data_sample_edge是SCLK的第一个边沿相对于空闲状态的变化边沿。data_change_edge则是另一个边沿。如果CPHA 1:data_sample_edge是SCLK的第二个边沿。data_change_edge则是第一个边沿。在代码实现上我们可以根据cpol_i和cpha_i在状态机中灵活选择clk_cnt的计数值作为边沿判断条件从而控制mosi_o的更新和miso_i的采样。这会使状态机稍微复杂一些但结构依然清晰。5.2 异步FIFO在高速SPI数据缓冲中的应用当SPI用于高速连续数据传输时例如从高速ADC读取数据流主控制器需要快速将接收到的数据取走以免丢失。如果用户逻辑如通过AXI总线将数据写入DDR来不及处理就需要一个缓冲器。异步FIFO是解决这个问题的最佳选择。它的写时钟域是SPI模块的时钟或由SCLK衍生读时钟域是用户逻辑的系统时钟。SPI接收逻辑每收完一个完整数据字就将其写入FIFO用户逻辑在需要时从FIFO中读取。FIFO的空/满标志可以方便地进行流控。集成异步FIFO后你的SPI Master模块就从一个简单的“发起-等待”接口升级成了一个具有持续数据传输能力的DMA-like控制器实用性大大增强。5.3 时序约束与跨时钟域处理对于高速SPI设计必须在综合实现阶段添加正确的时序约束。对输出信号SCLK, MOSI, CS_n需要设置set_output_delay约束指定这些信号相对于板级时钟或虚拟时钟在FPGA引脚外的延迟要求以确保满足从设备的建立/保持时间。对输入信号MISO需要设置set_input_delay约束告诉工具MISO信号相对于时钟在FPGA引脚上的到达时间。对于miso_i的采样如前所述存在跨时钟域问题。最规范的处理方法是在SCLK的采样边沿例如对于Mode 0是上升沿用一个触发器采样miso_i这个触发器的时钟是SCLK或其反相时钟。这相当于在从设备的时钟域内进行第一次采样。然后将采样后的数据通过一个同步器两级触发器链同步到FPGA的系统时钟域供后续逻辑使用。这种方法能最大程度降低亚稳态风险但需要仔细处理SCLK时钟域的逻辑。6. 常见问题排查与调试技巧实录即使按照上述步骤设计调试过程中也难免会遇到问题。下面是我总结的一些常见问题及其排查思路做成一个速查表问题现象可能原因排查方法完全无通信从设备无响应1. 物理连接错误线接反、虚焊。2. 片选CS信号有效电平弄反有的设备高有效。3. 电源或地未连接。4. FPGA引脚分配错误或未约束。1. 用万用表检查连通性。2. 用逻辑分析仪看CS信号确认有效电平。3. 测量从设备供电电压。4. 检查约束文件确认引脚位置和I/O标准正确。能通信但数据错误1. SPI模式CPOL/CPHA不匹配。2. 数据位顺序MSB/LSB不匹配。3. 时序不满足建立/保持时间。4. 从设备需要特定命令格式。1. 用逻辑分析仪抓波形对照数据手册看SCLK极性和采样边沿。2. 检查发送和接收的移位方向。3. 测量数据相对SCLK边沿的时序调整代码中数据更新/采样的时刻。4. 仔细阅读从设备数据手册的指令集部分。通信不稳定时好时坏1. 信号完整性问题长线、无阻抗匹配导致振铃。2. 电源噪声大。3. 跨时钟域亚稳态。1. 缩短连接线或在靠近FPGA引脚处加串行电阻如22Ω-100Ω。2. 检查电源滤波在从设备电源引脚加旁路电容。3. 对MISO信号增加同步器或降低SCLK频率。FPGA仿真正常上板失败1. 未添加正确的时序约束。2. 代码中存在异步复位释放问题或门控时钟。3. 上电初始化顺序问题从设备未就绪。1. 检查时序报告确保无违规。2. 检查综合后的网表避免使用异步逻辑生成时钟或复位。3. 上电后增加足够延时如毫秒级再开始SPI操作。调试技巧化繁为简先从最低速最大CLK_DIV开始测试成功后再逐步提高速度。分而治之先单独测试发送功能将MISO线拉高或拉低看接收端能否收到预期值。再测试接收功能用杜邦线手动在SCLK边沿切换MISO电平模拟从设备。善用内部逻辑分析仪如Xilinx的ILA或Intel的SignalTap它们可以像逻辑分析仪一样捕获FPGA内部信号无需外接设备对于调试状态机、计数器非常方便。打印调试法在仿真中可以在关键状态切换或数据收发时用$display打印信息。对于复杂交互可以编写一个文件记录所有SPI事务便于事后分析。实现一个可靠的SPI控制器就像是与外部世界建立一种精确的握手语言。从理解协议的本质时序到用状态机将其翻译成Verilog代码再到通过仿真和实测波形去验证和修正每一步都需要耐心和细致。这个过程最能锻炼一个数字逻辑工程师的基本功。当你第一次看到自己编写的代码通过那几根纤细的导线成功地驱动起一块屏幕或读取到一个传感器的数值时那种成就感就是驱动我们不断探索的动力。希望这篇笔记里详尽的步骤、可运行的代码以及那些“踩坑”经验能帮你更顺畅地走过这段路。

最新新闻

日新闻

周新闻

月新闻