
作为一个常年和FPGA打交道的人我见过太多人一听到SPI就觉得“太简单了不就是四根线嘛”结果真正在FPGA里动手实现的时候却被各种细节折磨到怀疑人生。SPI确实是数字接口里的入门协议但它“入门越浅、深坑越深”的特性恰恰让它在面试、项目联调、甚至量产维护时频频成为拦路虎。这篇内容写给两类人一类是刚接触FPGA开发、想通过SPI这个小切口把时序逻辑真正跑通的初学者另一类是已经写过SPI但总在波形上翻车的开发者比如数据对不上、采样时机不对、多从机挂载时莫名丢数据。我会从协议本身容易忽略的细节开始逐步拆解一个可复用的SPI主机/从机Verilog实现思路再结合上板验证和时序约束把整套套路讲清楚。1. 为什么放着现成的软件SPI不用非要上FPGA1.1 软件模拟SPI的瓶颈在哪里很多单片机项目里SPI完全可以靠两个GPIO翻转加延时函数搞定几十千赫兹到一两兆赫兹的时钟也没啥问题。但一旦进入FPGA主导的系统情况就变了。第一个瓶颈是频率。FPGA系统里动辄几十兆甚至上百兆的SPI时钟很常见比如驱动高速ADC、DAC、Flash或者SD卡。软件模拟的方式在几兆赫兹以下还能挣扎一下到了10MHz以上普通的循环翻转GPIO会产生极大的不确定性抖动协议本身的建立保持时间根本无法保证。第二个瓶颈是实时性。软件模拟SPI时CPU必须在整个传输过程中持续参与一个字节一个字节地喂数据。如果系统中同时有DMA、中断、任务调度时序上稍有穿插波形就会变形。FPGA里的SPI控制器是独立硬件逻辑在工作主逻辑只需要在传输开始前把数据写进寄存器、结束后读出结果即可相当于把“通信这件事”从主流程里彻底摘了出去。第三个瓶颈是多路扩展。FPGA项目里经常同时挂多个SPI设备每个设备有自己的片选和速率诉求。用软件去分时模拟代码复杂度会爆炸式增长而FPGA里可以轻松例化多份SPI控制器或者用一个控制器加译码器管理多路片选逻辑资源消耗却微乎其微。所以结论很直接在FPGA场景下SPI不是“能不能用软件做”的问题而是“必须用硬件逻辑做才够可靠”的问题。1.2 SPI在FPGA项目中的典型应用矩阵从我实际接触过的项目来看SPI在FPGA系统里的角色大致可以分为三类。搞清楚自己的设备属于哪一类直接决定了你该写主机还是从机、该关注哪些细节。应用场景典型器件FPGA角色核心痛点高速数据采集AD7606、ADS8688主机持续输出SCLK并读回采样值采样时序严格、连续读取吞吐量大非易失存储W25Q64、W25Q128主机按指令序列读写Flash命令、地址、数据多阶段切换配置类接口DAC8568、数字电位器主机按需写入配置字通常要求锁存边沿时序精确面板屏驱动LCD主控、触摸芯片主机或从机视架构而定数据量不大但格式多变与MCU/SOC通信STM32、Zynq ARM侧从机接收配置或上报状态跨时钟域、寄存器读写映射在STM32H743通过FMC总线连接FPGA、再让FPGA去扩展外部SPI设备的架构里SPI控制器就成为了“FMC接口”和“物理设备”之间的翻译层。这时候SPI写的质量直接影响整条数据链路的稳定性。2. SPI协议里最容易翻车的四个细节2.1 四种模式CPOL/CPHA不是靠死记硬背的很多资料讲到SPI模式直接甩出一张“CPOL与CPHA组合对应的四种模式表格”让人背。但实际工程里你真正需要做的是把从机芯片手册里的时序图看懂了再写代码。CPOL决定的是SCLK空闲电平。CPOL0空闲时时钟为低有效脉冲是正脉冲CPOL1空闲时时钟为高有效脉冲是负脉冲。CPHA决定的则是数据采样边沿。CPHA0在第一个边沿采样CPHA1在第二个边沿采样。“第一个边沿”指SCLK从空闲电平发生跳变的那一下。组合起来就是四个模式模式CPOLCPHA采样边沿数据变化边沿Mode 000上升沿下降沿Mode 101下降沿上升沿Mode 210下降沿上升沿Mode 311上升沿下降沿实际项目中最多见的是Mode 0其次是Mode 3。比如W25Q系列Flash普遍支持Mode 0和Mode 3AD7606默认用Mode 0。你得打开对应芯片的数据手册确认而不是想当然套用“SPI都是上升沿采样”。2.2 数据线上的建立时间和保持时间SPI是同步接口数据的采样完全依赖SCLK边沿。但协议上没有强制规定数据必须在边沿前多久稳定、边沿后保持多久这些参数完全由从机芯片决定。以经典时序为例主机在SCLK下降沿把数据翻转出去从机在上升沿采样。这意味着从机的采样边沿到来之前数据线上必须已经稳定了一个“建立时间”采样边沿之后数据还必须继续维持一个“保持时间”。FPGA里的解决办法很简单数据变化边沿和采样边沿至少错开半个SCLK周期。所以在主机设计时我习惯让数据在半周期前翻转、采样边沿落在另一半周期这样就天然满足绝大多数从机几十纳秒级别的建立保持要求。如果SCLK频率非常高比如100MHz半周期只有5ns这时就要更精细地控制驱动强度和IO延时了。2.3 片选信号不是随便拉低拉高就完事SPI的CS片选往往是最容易被忽视的信号。很多从机要求CS拉低后SCLK必须保持若干个时钟的稳定才开始有效操作操作结束后CS拉高的时刻也必须在SCLK停止翻转之后。另一个细节是CS的恢复时间。连续两次片选操作之间很多芯片要求CS保持高电平至少几十纳秒否则内部状态机可能来不及复位。我之前调一块ADC时ILA抓到的波形看起来完全正常CS拉低、SCLK给足16个脉冲、CS拉高。但数据就是隔三差五出现一个错误点。后来连续读了好几次ID寄存器才发现CS拉高后我几乎立刻又拉低了下一轮而这个芯片刚好要求CS高电平保持至少50ns。修复方法就是在CS拉高后加一个计数延时问题直接消失。2.4 主从机的移位方向冲突SPI协议里MOSI和MISO各自独立两个方向的数据可以在同一个时钟周期内同时传输这就是全双工。但在实现时最容易犯的错是把主机和从机的发送/接收时序搞反。比如主机打算发送0xA5这个字节按照“高位先出”的方式发送顺序是1、0、1、0、0、1、0、1。如果从机是“低位先出”那么主机发出的0xA5到了从机那儿就变成了0xA5的位序反转读出来是0xA5在另一个方向上的重排。所以写FPGA代码之前先确认两边器件的数据移位顺序是否一致不一致就必须在FPGA内部做位序调整。3. 手写一个参数化的SPI主机控制器3.1 顶层划分与核心思想一个标准的FPGA SPI主机我习惯把它拆成三块逻辑时钟分频与边沿生成、发送移位寄存器、接收移位寄存器。三者配合一个简单的状态机统一调度。核心思想是SCLK由FPGA内部计数分频产生MOSI在SCLK的某个边沿更新MISO在另一个边沿被采集CS按照状态机的阶段来控制。这样设计的好处是逻辑清晰、便于复用。后续不管接Flash还是ADC只需调整参数化配置数据位宽、时钟分频系数、CPOL/CPHA不需要重写整个模块。3.2 Verilog实现细节下面给出一段可用的SPI主机控制核心代码兼顾了参数化配置和基本的读写操作支持。module spi_master #( parameter DATA_WIDTH 8, parameter SCLK_DIV 10, // 系统时钟分频系数SCLK clk / SCLK_DIV parameter CPOL 0, // 时钟极性 parameter CPHA 0 // 时钟相位 )( 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, // 对外SPI接口 output reg sclk, output reg cs_n, output reg mosi, input wire miso ); localparam IDLE 2d0; localparam TRANS 2d1; localparam FINISH 2d2; reg [1:0] state; reg [7:0] clk_cnt; reg [3:0] bit_cnt; reg [DATA_WIDTH-1:0] tx_reg; reg [DATA_WIDTH-1:0] rx_reg; wire sclk_en (clk_cnt (SCLK_DIV/2 - 1)); wire trans_finish (bit_cnt DATA_WIDTH); // 主状态机 always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; sclk CPOL; cs_n 1b1; mosi 1b0; done 1b0; clk_cnt 0; bit_cnt 0; rx_data 0; end else begin case (state) IDLE: begin done 1b0; if (start) begin cs_n 1b0; // 拉低片选 tx_reg tx_data; bit_cnt 0; state TRANS; end end TRANS: begin // SCLK 分频翻转 if (clk_cnt SCLK_DIV - 1) begin clk_cnt 0; sclk ~sclk; end else begin clk_cnt clk_cnt 1; end // 在SCLK的特定边沿更新MOSI、采集MISO if (sclk_en) begin if (!CPHA) begin // CPHA0在SCLK的第一个边沿之前准备数据之后边沿采样 if (sclk CPOL) begin mosi tx_reg[DATA_WIDTH-1]; tx_reg {tx_reg[DATA_WIDTH-2:0], 1b0}; rx_reg {rx_reg[DATA_WIDTH-2:0], miso}; bit_cnt bit_cnt 1; end end else begin // CPHA1数据在SCLK的第二个边沿之后更新 if (sclk !CPOL) begin mosi tx_reg[DATA_WIDTH-1]; tx_reg {tx_reg[DATA_WIDTH-2:0], 1b0}; rx_reg {rx_reg[DATA_WIDTH-2:0], miso}; bit_cnt bit_cnt 1; end end end if (trans_finish) begin state FINISH; end end FINISH: begin cs_n 1b1; // 拉高片选 done 1b1; rx_data rx_reg; state IDLE; end endcase end end endmodule这段代码默认按高位先出处理。SCLK_DIV参数建议配置成偶数保证高低电平时间一致。如果系统时钟是100MHz、想要10MHz的SCLKSCLK_DIV就是10。3.3 状态机设计里的关键节点主机状态机之所以分成IDLE、TRANS、FINISH三步是为了把“CS由高到低”、“数据逐位收发”、“CS由低到高”这三个阶段彻底分开。这样做的好处有几个时序上CS和SCLK的关系完全可控避免了CS和首个时钟边沿同时发生的极端情况。逻辑层次清晰后续要插入“命令阶段”“地址阶段”“数据阶段”的多阶段操作时只需要在状态机上做扩展。复位后处于IDLECS默认拉高、时钟默认停在空闲电平不会误触发任何从机。实际项目里我通常还会在FINISH状态加一个计数器用于保证CS拉高后的最小恢复时间。不要觉得这个多余很多莫名其妙的偶发错误就是少了这一步。4. 从机实现与一主多从的工程化处理4.1 从机侧设计的核心矛盾FPGA作为从机的情况在Zynq这类异构平台里非常常见。ARM侧通过SPI访问FPGA里的寄存器FPGA这边需要一个从机控制器来解析主机发来的命令。从机设计的核心矛盾在于SCLK不是由自己产生的你既控制不了它的频率也控制不了它什么时候来。从机只能在SCLK的边沿上被动接收数据、被动发送数据。这就引出了两个问题问题一亚稳态。外部输入的SCLK、MOSI、CS相对于FPGA内部时钟是完全异步的。如果直接用内部时钟去采这些信号极可能出现亚稳态。处理方法是对所有外部输入信号做两级触发器同步。问题二采样边沿到数据输出的时间。从机在SCLK的采样边沿采集数据的同时也可能需要在同一时刻或者下一个边沿把数据打到MISO上。这个“数据变化时刻”必须计算到内部组合逻辑的路径时延里如果SCLK频率过高时序会非常紧张。一个常用的低速从机设计思路是用SCLK的上升沿作为所有逻辑的采样时钟把FPGA内部逻辑完全和SCLK对齐。这样从机就变成了一个“在外部时钟驱动下的SPI终端”省去了大量跨时钟域处理。缺点是这个设计没法跑太高的SCLK但用于寄存器配置场景完全够用。4.2 一主多从的片选译码与三态处理FPGA要同时管理多个SPI从设备时最常见的方法是“共享SCLK、MOSI单独片选、单独MISO”。每个从机的MISO在未被选中时必须处于高阻态否则会互相打架。FPGA里实现三态MISO输出用inout类型端口inout wire miso_io; assign miso_io cs_sel ? miso_out : 1bz;多个从机的MISO引脚可以直接并联到FPGA的一个输入引脚上。但要注意未选中通道的高阻态是“逻辑上断了”电气上可能还有寄生电容耦合和串扰。如果从机数量多、走线长建议在每个从机的MISO线上串联一个几十欧姆的电阻减小反射。片选译码的逻辑很直接高位地址译码生成各设备的CS同时确保任意时刻只能有一个设备处于选中状态。这里面有一个工程上的坑CS切换瞬间旧设备的CS还没完全拉高、新设备的CS还没完全拉低时MISO总线上会出现短暂的竞争状态。所以在FPGA逻辑里CS切换之间务必插入一个空闲周期先让旧设备彻底释放总线再去选新设备。4.3 跨时钟域参数配置的处理思路如果FPGA作为从机接收到的是来自外部主机比如STM32的配置数据这些数据最终要影响FPGA内部的时钟域逻辑。那问题就来了在SCLK域接收的数据要安全地使用在内部主时钟域必须做跨时钟域处理。我常用的做法是握手同步。内部逻辑在读寄存器时不会直接去读异步输入的原始寄存器而是先通过一个同步器把“数据有效”信号同步进来再在内部时钟沿上锁存数据。对于多位并行数据优先使用异步FIFO或者双缓冲寄存器避免多位同时跳变时被采到半新半旧的状态。实际上很多初学者会在这一步偷懒直接在主时钟域里去采样外部输入的MOSI数据结果就是偶发的数据错位且极难定位。建议从一开始就把“异步信号进内部时钟域必须先同步”当成铁律来执行。5. 上板验证与调试用ILA和示波器把问题“看穿”5.1 ILA触发条件设置的经验写完代码、综合实现、下载到板子上接下来才是真正考验工程能力的部分。Vivado里ILA集成逻辑分析仪几乎是验证SPI时序最趁手的工具。但直接用ILA去抓SPI信号有个容易踩的坑SPI信号频率可能比你设置的采样时钟快或者忙闲比例悬殊导致触发后抓到的几乎全是空闲波形。我习惯的做法是先把ILA的采样时钟设为系统主时钟全部SPI信号都作为探测信号挂上去。然后设置触发条件为“CS_N下降沿有效”。这样每次片选拉低就会捕获一次完整的传输过程。另一个好用的技巧是把ILA的触发位置设在窗口的中间。这样既能抓到CS拉低之前一小段“前戏”状态也能看到CS拉高之后的行为方便检查CS恢复时间。5.2 波形对照法定位数据错位抓到的波形怎么判断对不对我一般按三步走第一步确认CS。CS拉低期间SCLK的脉冲个数必须和你设定的数据位宽一致。多了少了说明状态机里位计数出了问题。第二步确认采样点。找到SCLK的有效采样边沿回头看MOSI、MISO在采样边沿前后是否已经稳定。如果数据变化边沿和采样边沿贴得太近说明分频设计有缺陷得调整SCLK_DIV的奇偶性或者改变数据更新时序。第三步确认收发数据的位序。把抓取到的MOSI位流按顺序排列和预期发送的数据做对比。如果发现数据是反的多半是移位方向写反了。用这个三步法我在实际调试中解决过不下五个“看起来时好时坏”的SPI问题而且95%的情况最后都能归结到上述三种原因之一。5.3 示波器抓SPI波形的注意事项有ILA辅助时逻辑层面的问题好查但有些信号完整性问题还是得上示波器。比如SCLK振铃过大导致从机误采样或者MOSI被某个负载拉偏导致低电平不够低。示波器抓SPI的关键设置是触发电平。SCLK空闲电平和有效脉冲电平不同触发模式建议设置为“边沿触发源选择SCLK触发电平设在中间值”这样每次开始传输时示波器能在第一个时钟边沿稳定触发。另外探头的接地线要尽量短地线长了就会引入高频噪声尤其抓10MHz以上的SCLK时波形会“毛”很多影响判断。6. 时序约束与常见坑提前排雷6.1 SPI引脚需要做哪些时序约束很多新手写了SPI代码、板子上也能跑但综合时各种时序违例或者换一颗FPGA后彻底罢工根源在于没有给SPI接口设置合理的IO约束。SPI是FPGA与外部器件的同步接口在主时钟域看来输入数据MISO的到达时间是不确定的必须在约束里告诉工具“这个数据什么时候来”。最基础的做法是通过set_input_delay和set_output_delay来约束set_input_delay -clock [get_clocks {sclk}] -max 5 [get_ports {miso}] set_input_delay -clock [get_clocks {sclk}] -min 2 [get_ports {miso}]这些值怎么定需要结合从机的输出时序参数来计算如果拿不准可以先参考数据手册的典型值再通过ILA实测数据来修正。如果你使用的是Zynq这类带硬核的芯片还要注意外部引脚约束是否通过了MIO或EMIO的正确分配搞错一个bank信号可能根本出不来。6.2 五个高频踩坑点与排查方向复盘了这些年帮人解决的问题SPI项目的坑集中在五个地方。做成表格方便排查时对照。现象最可能原因排查方向读到的数据偶尔错一位采样边沿和数据变化边沿太近调整SCLK相位或采样时机多个从机时乱码MISO总线竞争或CS切换过快检查三态控制、增加CS空闲间隔全套逻辑但设备无响应CPOL/CPHA模式选错对照芯片手册时序图重新核对低速率正常高速率异常信号完整性或IO驱动能力不足示波器看波形、调整IO slew rate上电后第一帧数据错误复位释放后立即触发传输确保FPGA复位稳定后再释放start信号这些坑如果等它自然出现再排查通常要花掉比写SPI逻辑多三到五倍的时间。事前预防永远是性价比最高的方案。6.3 面试题视角SPI最常被问的几个问题最后顺手聊一个很多初学者关心的点FPGA面试里SPI到底怎么考。最常见的必问题就是“SPI有几种工作模式”直白考察CPOL/CPHA理解。然后是“FPGA做SPI主机时状态机怎么设计”这题主要考察对时序过程的理解和组织能力。再有就是“跨时钟域如何处理外部输入的SPI信号”考察对亚稳态是否有概念。回答这些题的核心不是背答案而是把工程中真正发生过的细节讲清楚。比如“我遇到过CS恢复时间不够导致的数据错误后来加了一个等待周期解决”这种具体案例远比空洞的术语堆砌要加分。平时写代码时留意这些现象面试时自然有东西讲。做了这么多SPI的项目我个人最深的体会是SPI的代码本身并不难难的是对整个通信过程的“时序直觉”。这种直觉只能靠反复看波形、反复和手册对照来积累。如果你刚开始接触建议从最简单的Mode 0单一从机项目做起用ILA抓住每一帧波形亲手确认每一位数据的收发走通一遍之后再去碰多从机和高速场景。这条路走扎实了后面再碰I2C、UART甚至PCIe都会顺畅很多。