1. 项目概述从协议到硬件的SPI通信实践搞FPGA的迟早要和各种通信协议打交道。SPISerial Peripheral Interface算是其中最基础、最常用的一种。它不像I2C那样需要复杂的地址寻址和应答机制也不像UART那样对波特率有严格要求SPI的核心就是“简单粗暴”——全双工、高速、点对点同步通信。我这次笔记记录的是在FPGA平台上从零开始搭建一个SPI主控制器Master并与一个SPI Flash存储器如W25Q128进行通信的完整过程。这不仅仅是写几行Verilog代码那么简单更重要的是理解协议时序、硬件接口的电气特性以及如何在FPGA内部优雅地设计状态机来驾驭这个看似简单的四线制总线。对于刚接触FPGA通信的朋友来说SPI是一个绝佳的起点。它能让你深刻体会到数字逻辑设计中“时钟”和“同步”的至高无上地位。通过这个项目你不仅能学会如何驱动一个具体的SPI从设备更能掌握一种通用的通信控制器设计方法论这套方法稍加修改就能应用到I2C、UART甚至自定义协议上。无论是做数据采集、传感器读取还是配置外部的ADC/DAC芯片SPI都是你工具箱里的必备利器。下面我就把这次实践中的设计思路、代码细节、调试坑点以及一些个人心得毫无保留地分享出来。2. SPI协议核心与FPGA实现思路拆解2.1 SPI协议四线制与工作模式深度解析SPI协议物理上通常由四根线构成这是理解其一切行为的基础SCLK (Serial Clock) 时钟信号由主设备产生。这是整个通信的节拍器所有数据都在其边沿进行采样。时钟极性和相位是SPI模式的灵魂。MOSI (Master Out Slave In) 主设备数据输出从设备数据输入。主设备通过这根线向从设备发送指令或数据。MISO (Master In Slave Out) 主设备数据输入从设备数据输出。主设备通过这根线读取从设备的数据。CS/SS (Chip Select / Slave Select) 片选信号低电平有效。这是主设备用来“点名”某个从设备的信号。在多从机系统中每个从机都有独立的片选线。SPI有四种工作模式由时钟极性CPOL和时钟相位CPHA两个参数组合而成。这两个参数决定了时钟空闲时的电平以及数据采样的边沿。模式CPOL (Clock Polarity)CPHA (Clock Phase)时钟空闲状态数据采样边沿数据变化输出边沿000低电平上升沿下降沿101低电平下降沿上升沿210高电平下降沿上升沿311高电平上升沿下降沿注意 “采样”是指接收方对于MOSI是从机对于MISO是主机读取数据线的时刻。“变化”是指发送方将新数据位放到数据线上的时刻。最关键的原则是数据必须在采样边沿之前保持稳定建立时间并在之后保持一段时间保持时间。在FPGA设计中我们通常让数据在采样边沿的相反边沿发生变化这样就能为对方留出充足的建立和保持时间窗口。以最常见的Mode 0 (CPOL0, CPHA0)为例时钟空闲时为低电平。在时钟的上升沿主从双方同时采样数据主机采样MISO从机采样MOSI在时钟的下降沿双方同时准备下一个要发送的数据位并放到数据线上。这种“同时收发”的特性正是SPI全双工的精髓。2.2 FPGA侧SPI主控制器设计哲学在FPGA内部实现SPI主控制器本质上就是设计一个能够精确产生上述四根线时序的状态机。这里有几个关键的设计决策点1. 时钟域与分频FPGA内部通常运行在较高的系统时钟如50MHz、100MHz。而SPI的SCLK频率可能从几百KHz到几十MHz不等。我们需要一个分频器来产生合适频率的SCLK。一个稳健的做法是使用系统时钟驱动一个计数器根据分频系数生成SCLK的使能脉冲sclk_en而不是直接生成SCLK。所有与SPI时序相关的操作如移位、计数都在这个使能脉冲的驱动下进行。这样做的好处是整个SPI控制器的核心逻辑仍然与系统时钟同步避免了复杂的多时钟域处理只在最后输出到管脚时用寄存器打一拍保证时序纯净。2. 状态机设计一个清晰的有限状态机FSM是控制流程的核心。状态可以包括IDLE 空闲状态等待启动命令。CS拉高SCLK保持空闲电平。START 启动状态。拉低CS信号等待若干个系统时钟周期以满足从设备对CS下降沿到第一个SCLK边沿的建立时间要求t_CS2SCLK。TRANSFER 数据传输状态。在这个状态下根据比特计数器在每一个SCLK使能脉冲来临时执行数据移位和比特计数。同时控制SCLK的翻转。STOP 传输结束状态。拉高CS等待满足从设备对CS上升沿后的保持时间要求t_SCLK2CS。完成后返回IDLE。3. 数据缓冲区与移位寄存器我们需要两个移位寄存器一个用于发送tx_shift_reg一个用于接收rx_shift_reg。在TRANSFER状态每个SCLK周期根据CPHA在适当的边沿通常是SCLK使能脉冲对应的边沿将tx_shift_reg的最高位MSB或最低位LSB送到MOSI同时将MISO上的数据移入rx_shift_reg。发送和接收是同时进行的。数据宽度如8位、16位、32位需要可配置。4. 硬件片选与软件片选硬件片选 将CS作为一个普通的GPIO输出由状态机直接控制其拉低和拉高。这是最常用、最直接的方式。软件片选 在某些特殊场景下你可能需要用其他GPIO口或者通过复杂的逻辑组合来模拟CS信号但这通常会增加时序的不确定性除非从设备手册明确允许或需要否则不建议在FPGA中主动使用。我的设计选择是一个参数化可配置CPOL、CPHA、数据位宽、分频系数的SPI主控制器模块采用使能脉冲驱动的同步设计配合一个清晰的三段式状态机。3. Verilog代码实现与关键模块详解3.1 SPI主控制器核心模块设计下面是我实现的SPI Master核心模块的关键代码片段和解释。为了通用性我使用了parameter来定义关键参数。module spi_master #( parameter CPOL 0, // 时钟极性 parameter CPHA 0, // 时钟相位 parameter DATA_WIDTH 8, // 数据位宽 parameter CLK_DIV 32 // 系统时钟分频系数SCLK频率 sys_clk / (2 * CLK_DIV) )( input wire sys_clk, input wire sys_rst_n, // 用户接口 input wire start_i, // 启动传输脉冲 input wire [DATA_WIDTH-1:0] tx_data_i, // 待发送数据 output reg [DATA_WIDTH-1:0] rx_data_o, // 接收到的数据 output reg busy_o, // 忙标志高电平表示正在传输 output reg done_o, // 传输完成脉冲一个时钟周期高电平 // SPI物理接口 output reg sclk_o, output reg mosi_o, input wire miso_i, output reg cs_o ); // 状态定义 localparam S_IDLE 3b001; localparam S_START 3b010; localparam S_TRANSFER 3b100; reg [2:0] state_c, state_n; // 分频计数器与SCLK使能生成 reg [15:0] clk_cnt; wire sclk_en; // SCLK翻转使能信号 assign sclk_en (clk_cnt CLK_DIV - 1); // 比特计数器与移位寄存器 reg [3:0] bit_cnt; // 计数范围需能覆盖DATA_WIDTH reg [DATA_WIDTH-1:0] tx_shift_reg; reg [DATA_WIDTH-1:0] rx_shift_reg; // SCLK内部信号用于生成最终输出 reg sclk_internal; // 三段式状态机第一部分状态寄存器 always (posedge sys_clk or negedge sys_rst_n) begin if (!sys_rst_n) state_c S_IDLE; else state_c state_n; end // 三段式状态机第二部分次态逻辑 always (*) begin state_n state_c; // 默认保持 case (state_c) S_IDLE: begin if (start_i) state_n S_START; end S_START: begin // 等待CS建立时间这里简化处理直接进入传输 // 实际应根据从设备t_CS2SCLK要求插入等待周期 state_n S_TRANSFER; end S_TRANSFER: begin if (bit_cnt DATA_WIDTH) begin // 所有位传输完毕 state_n S_IDLE; // 简化直接回IDLE。更严谨应有STOP状态。 end end default: state_n S_IDLE; endcase end // 三段式状态机第三部分输出逻辑与计数器控制 always (posedge sys_clk or negedge sys_rst_n) begin if (!sys_rst_n) begin clk_cnt 0; bit_cnt 0; tx_shift_reg 0; rx_shift_reg 0; sclk_internal CPOL; // 空闲电平 cs_o 1b1; busy_o 1b0; done_o 1b0; rx_data_o 0; end else begin done_o 1b0; // 默认拉低只在一个周期内拉高 case (state_n) // 注意这里用次态state_n是摩尔型状态机的常见写法 S_IDLE: begin cs_o 1b1; busy_o 1b0; sclk_internal CPOL; // 保持空闲电平 clk_cnt 0; bit_cnt 0; if (start_i) begin tx_shift_reg tx_data_i; // 锁存发送数据 busy_o 1b1; end end S_START: begin cs_o 1b0; // 拉低片选 // 可以在这里加入一个延时计数器满足t_CS2SCLK end S_TRANSFER: begin // 分频计数器逻辑 if (clk_cnt CLK_DIV - 1) begin clk_cnt clk_cnt 1; end else begin clk_cnt 0; end // SCLK生成与数据移位逻辑在使能脉冲时处理 if (sclk_en) begin // 首先翻转SCLK。注意第一个边沿可能是采样边沿也可能是变化边沿取决于CPHA。 sclk_internal ~sclk_internal; // 处理数据移位。这是一个需要仔细根据CPHA和CPOL推导的部分。 // 以下以Mode 0 (CPOL0, CPHA0)为例 // 空闲时SCLK0。第一个使能脉冲到来时sclk_internal从0-1上升沿采样边沿。 // 我们需要在SCLK为低电平时即上升沿之前将数据放到MOSI上。 // 因此数据变化发生在sclk_internal为低且即将发生上升沿的时刻。 // 在代码中我们可以在sclk_en有效且sclk_internal等于CPOL时即即将进入第一个边沿前变化数据。 // 接收数据则在边沿发生后进行。 // 简化版通用处理逻辑需根据CPHA调整 // 假设我们在SCLK的边沿sclk_en触发进行所有操作并利用sclk_internal的当前值判断是哪个边沿。 // 更清晰的写法是引入一个边沿检测信号。 end // 比特计数与状态转移判断 // 当传输完所有位后拉高done_o并锁存接收数据 if (bit_cnt DATA_WIDTH) begin done_o 1b1; rx_data_o rx_shift_reg; // 状态转移由状态机逻辑处理这里不重复 end end endcase end end // 将内部SCLK和MOSI寄存器输出到端口消除毛刺 always (posedge sys_clk or negedge sys_rst_n) begin if (!sys_rst_n) begin sclk_o CPOL; mosi_o 1b0; end else begin sclk_o sclk_internal; // MOSI的输出时机必须严格匹配协议要求通常从移位寄存器直接引出 // 例如mosi_o tx_shift_reg[DATA_WIDTH-1]; // 但具体赋值位置需根据CPHA在状态机中精确控制 end end endmodule实操心得上面的代码是一个高度简化的框架重点展示了状态机、分频和核心数据通路的结构。最复杂、最容易出错的部分就是根据CPHA和CPOL精确控制MOSI数据变化和MISO数据采样的时刻。我强烈建议将这部分逻辑单独写成一个task或function或者用case语句根据{CPOL, CPHA}的四种模式分别编写虽然代码量稍大但可读性和可维护性极高。切忌试图用一个复杂的条件判断语句覆盖所有模式调试起来会是噩梦。3.2 SPI Flash读写操作的上层封装有了SPI Master核心控制器我们还需要一个上层模块比如叫spi_flash_driver来组织具体的Flash操作命令序列。以读取Flash芯片ID命令0x9F为例拉低CS。通过MOSI发送命令字节0x9F。同时从MISO读取3个字节的ID制造商ID、存储器类型、容量ID。拉高CS。这个上层模块的任务就是将“读取ID”这个用户指令翻译成一系列对底层spi_master模块的调用。它需要维护一个发送数据队列命令地址哑元数据并解析接收到的数据流。这通常又是一个状态机状态包括“发送命令”、“发送地址”、“读取数据”、“等待完成”等。// 在spi_flash_driver模块中的状态机片段示例 case (state) CMD_SEND: begin if (spi_done) begin // 等待SPI主控制器完成一个字节传输 tx_byte next_byte_to_send; // 准备下一个字节如地址高位 spi_start 1b1; state ADDR_SEND_HIGH; end else begin spi_start 1b0; end end // ... 其他状态 endcase这个驱动模块的设计体现了“分层”的思想底层spi_master只关心如何正确地传输一个数据帧上层驱动则关心特定设备的命令集和操作流程。两者通过简单的启动start、忙busy、完成done信号交互。4. 仿真验证与板级调试全记录4.1 ModelSim/QuestaSim功能仿真仿真对于验证时序逻辑至关重要。我们需要编写一个测试平台Testbench模拟SPI从设备如Flash模型的行为。1. 创建从设备模型一个简单的SPI从设备模型它会监听CS和SCLK根据主设备的MOSI数据模拟收到的命令在MISO上回复预定义的数据。例如当收到0x9F命令后接下来的三个SCLK周期分别输出0xEF0x400x18这是某品牌Flash的ID。2. 编写测试流程在Testbench中初始化系统等待复位完成然后让上层驱动模块发起一个“读ID”操作。观察spi_master和spi_flash_driver的信号特别是cs_osclk_omosi_omiso_i以及最终接收到的rx_data_o。3. 关键检查点CS拉低到第一个SCLK边沿的延迟是否符合从设备规格SCLK的频率和占空比是否正确MOSI数据是否在正确的SCLK边沿相对于CPHA发生变化并保持稳定在采样边沿MISO数据是否被正确捕获传输完指定位数后CS是否被正确拉高整个字节或帧传输完成后done_o脉冲是否产生接收数据是否正确通过波形图Waveform直观地对比这些信号是调试状态机和时序最有效的方法。务必把CPHA和CPOL的所有四种模式都仿真一遍。4.2 板上调试与逻辑分析仪抓取仿真通过后就可以综合并下载到FPGA开发板了。板级调试离不开逻辑分析仪。我使用的是Digilent的Analog Discovery 2AD2它的数字逻辑分析仪功能足够应对SPI。1. 连接与设置将AD2的至少4个数字通道分别连接到FPGA板上的SCLK、MOSI、MISO、CS信号。在WaveForms软件中设置协议分析器为SPI并正确指定各通道角色设置与FPGA程序一致的CPOL和CPHA模式。2. 触发与抓取设置触发条件为CS的下降沿。启动FPGA程序逻辑分析仪会在CS拉低时捕获一整段SPI通信波形。软件会自动解析出十六进制的命令和数据。3. 问题诊断实录问题一数据错位。现象逻辑分析仪解析出的数据与预期不符比如发送0x9F解析出来是0x3F或0x7E。排查这通常是MSB/LSB顺序弄反了。SPI协议本身没有规定先传高位还是低位这取决于从设备。大部分器件是MSB first。检查代码中移位寄存器的方向还是并与器件手册对比。问题二采样错误。现象读取的数据全是0或全为1。排查检查CPHA设置。如果CPHA设错采样边沿就对不上数据稳定的窗口。用逻辑分析仪仔细看MISO数据的变化点是否在你的FPGA采样边沿附近确保你的FPGA在正确的边沿采样MISO。问题三通信不稳定。现象偶尔能成功经常失败。排查检查时序余量。SCLK频率是否过高从设备可能无法在高速下稳定工作。尝试降低分频系数。检查PCB走线是否存在过长或干扰。在SCLK和MOSI的输出寄存器后插入(* IOB “TRUE” *)约束对于Xilinx确保信号是从IOB寄存器直接输出减少内部走线延迟。踩坑心得上拉电阻很重要如果SPI总线上的MOSI、MISO、CS线是悬空的比如从设备未上电或未连接FPGA的输入可能会处于浮空状态产生随机振荡导致逻辑错误。务必在硬件上为这些信号线添加适当的上拉电阻如4.7kΩ到10kΩ尤其是在使用PMOD接口等连接器时。这是一个非常隐蔽的坑症状可能是程序第一次下载后工作正常断电重启后就异常。5. 性能优化与高级应用探讨5.1 提升吞吐率FIFO与DMA思想引入基础的SPI控制器一次只能处理一帧数据。在实际应用中比如连续读取Flash的一个扇区256字节频繁地启动、传输、完成、再启动效率很低且会占用大量CPU或软核资源。优化方案在SPI Master和用户逻辑之间加入一个FIFOFirst In, First Out缓冲区。发送FIFO用户逻辑可以提前将多个字节的命令和数据写入发送FIFO。SPI Master控制器在传输完一个字节后如果检测到发送FIFO非空就自动读取下一个字节并开始传输直到FIFO为空且当前字节传输完毕才拉高CS结束本次“会话”。这实现了连续传输Continuous Transfer对于Flash的连续读READ命令至关重要。接收FIFOSPI Master接收到的数据自动存入接收FIFO。用户逻辑可以异步地从FIFO中读取数据。更进一步可以设计一个轻量级的DMA直接存储器访问控制器它能够根据用户配置如起始地址、传输长度自动从内存如FPGA内部的Block RAM中读取数据块送入发送FIFO或将接收FIFO的数据块写入内存。这样用户逻辑只需要发起一次传输请求就可以完成大量数据的搬移极大解放了处理器。5.2 多从机管理与片选扩展一个SPI主设备可以连接多个从设备通常有两种方式独立片选最常用每个从设备有独立的CS线。主设备通过控制不同的CS线来选择与哪个从机通信。在FPGA中这意味着需要实例化多个CS输出信号但在同一时刻只有一根CS线是低电平。你的SPI Master模块需要增加一个cs_sel_i输入端口用来选择激活哪个CS线。菊花链Daisy-chain所有从设备的MOSI、MISO首尾相连形成一个环。数据在主设备发出的SCLK驱动下在所有从设备的移位寄存器中循环移动。这种方式只需要一根CS线但协议变得特殊每个从设备需要支持菊花链模式且传输的数据帧是所有从设备数据位的总和。实现起来主设备只需要发送足够长度的数据所有从设备位数之和就能同时写入并读出所有从设备的数据。对于独立片选方式在FPGA中实现多从机管理时需要注意总线冲突当切换通信的从设备时必须确保在拉高当前CS后等待足够时间满足从设备的CS取消选择时间t_CSH再拉低下一个从设备的CS。同时SCLK和MOSI在空闲期间应保持稳定通常是CS拉高时的状态避免干扰其他从设备。5.3 时序约束与跨时钟域处理当SPI接口速度很高比如达到50MHz以上时就必须考虑FPGA内部的时序约束。输出延迟约束set_output_delay 你需要告诉FPGA工具SCLK、MOSI、CS信号相对于板级时钟或某个虚拟时钟在FPGA引脚输出后需要多长时间到达从设备的输入端。这个时间包括PCB走线延迟。约束的目的是让工具优化布局布线保证信号在从设备端的建立/保持时间。输入延迟约束set_input_delay 对于MISO信号你需要告诉工具数据从从设备发出后经过PCB走线到达FPGA引脚时的延迟是多少。这样工具才能保证FPGA内部在采样MISO时满足其建立/保持时间要求。如果SPI控制器的工作时钟sys_clk和产生SCLK的时钟使能sclk_en与处理用户逻辑的时钟不是同一个就会涉及跨时钟域CDC。例如用户逻辑在100MHz时钟域下发指令而SPI控制器工作在25MHz时钟域。这时控制信号如start_i和数据信号tx_data_i需要经过同步器如两级触发器才能送入SPI控制器时钟域。同样状态信号如done_obusy_o和接收数据rx_data_o也需要同步回用户时钟域。处理不当会导致亚稳态造成数据丢失或控制失灵。6. 常见问题排查速查与进阶思考6.1 问题排查速查表现象可能原因排查步骤与解决方法完全无通信1. 硬件连接错误2. CS信号未拉低3. 电源或地未接好4. FPGA程序未运行1. 用万用表或示波器检查连线。2. 用逻辑分析仪抓取CS信号。3. 检查电源指示灯。4. 检查FPGA配置完成指示灯。能发送但读回数据全为01. MISO线连接错误或断路2. 从设备未正确响应如命令错误3. CPHA设置错误采样边沿不对4. 从设备需要特定初始化如Flash需先解除写保护1. 检查MISO物理连接。2. 确认发送的命令字节完全正确用逻辑分析仪核对。3. 核对器件手册的SPI模式调整CPHA。4. 阅读从设备数据手册的初始化序列。读回数据错误如位错位1. MSB/LSB顺序错误2. 数据位宽设置错误3. 时钟极性CPOL错误1. 调整移位寄存器方向或。2. 确认传输的字节数检查比特计数器逻辑。3. 用逻辑分析仪看SCLK空闲电平调整CPOL。高速下通信不稳定1. SCLK频率超过从设备或FPGA IO极限2. PCB走线过长信号完整性差3. 时序约束未添加或不当1. 降低分频系数降低SCLK频率测试。2. 检查波形是否有过冲、振铃。缩短走线或加串行电阻。3. 添加正确的set_output_delay和set_input_delay约束。多从机切换时相互干扰1. CS切换时序不满足t_CSH2. 空闲时MOSI/SCLK状态跳动1. 在CS拉高后插入足够时钟周期的等待。2. 确保在CS为高时MOSI和SCLK输出保持恒定CPOL决定的空闲电平。6.2 从SPI到其他串行协议的思考掌握了SPI的设计精髓后你会发现其他同步串行协议如I2C或异步协议如UART在FPGA上的实现思路是相通的状态机是骨架精确的时序控制是血肉。I2C 同样需要状态机但状态更多起始、地址读写、应答、数据、停止等。时序上不仅要控制数据线SDA在时钟线SCL低电平期间变化高电平期间稳定还要处理起始条件SCL高时SDA下降沿和停止条件SCL高时SDA上升沿。多主机仲裁和时钟拉伸是额外的挑战。UART 没有时钟线完全依靠预先约定好的波特率。核心是一个高精度的波特率发生器通常用计数器实现。状态机负责检测起始位下降沿然后在每个比特位的中间点进行采样。需要特别注意抗干扰因为异步通信容易受到毛刺影响。设计一个稳健的通信接口除了正确的状态跳转对异常情况的处理同样重要。例如SPI传输过程中如果系统复位如何安全地停止从设备无响应MISO一直为高阻怎么办在状态机中加入超时机制和错误恢复状态是产品级代码必备的素养。最后我个人体会是FPGA玩通信协议初期是照着时序图“画瓢”中期是理解状态机设计模式后期则是追求极致的性能和可靠性。每一次调试尤其是用逻辑分析仪抓到问题波形并最终解决的那一刻都是对数字电路理解的一次深化。这个SPI项目虽然基础但它为你打开了一扇门门后是FPGA与真实世界交互的广阔天地。