SPI通信协议详解与FPGA实现:从时序到Verilog代码实战

发布时间:2026/9/6 13:15:09
SPI通信协议详解与FPGA实现:从时序到Verilog代码实战 SPI通信的FPGA实现从协议细节到工程落地如果你玩过STM32、ESP32这类MCUSPI大概率是你最常用的通信接口之一。但当你把SPI放到FPGA里实现时很多在MCU上被封装好的东西会突然变得“透明”时钟极性谁来管从机选择信号什么时候拉低数据是在上升沿采还是下降沿采双全工到底怎么同时收发这些问题MCU的外设帮你挡掉了大半FPGA里全都得自己写。我最早在FPGA上写SPI是因为一个高速数据采集项目ADC采样率拉到10MSPSMCU的软件模拟SPI根本扛不住DMA也折腾得够呛。后来把SPI主控逻辑搬进FPGA一条状态机搞定收发采样数据直接进FIFO才算是真正解了渴。也是从那次开始我意识到SPI在FPGA里并不只是一个“低速外设”它更像是一把理解时序设计、跨时钟域处理、状态机编写的钥匙。这篇内容我会从协议基础讲起但重点放在FPGA实现层面的选型和细节上附上可以直接参考的Verilog代码框架以及我实际调试中踩过的坑。适合刚接触FPGA通信接口设计的读者也适合那些MCU转FPGA、想搞清楚通信底层逻辑的工程师。1. SPI协议核心概念与FPGA实现思路1.1 SPI总线的本质一根时钟加三根线SPISerial Peripheral Interface本质上是一个同步串行接口。说它“同步”是因为它有一根专用的时钟线SCLK/SCK所有数据bit的采样与移位都对齐这根时钟。这在FPGA里极其友好——你不需要像UART那样做波特率对齐、采样点选取只要保证在正确的时钟沿采样数据即可。一个标准SPI接口有四根线SCLKSerial Clock主设备产生的串行时钟决定通信速率。MOSIMaster Out Slave In主设备输出、从设备输入的数据线。MISOMaster In Slave Out从设备输出、主设备输入的数据线。CS/SSChip Select / Slave Select片选信号通常低电平有效选中某个从设备。在FPGA里这四根线只要引脚约束正确逻辑上就是一个移位寄存器加一个计数器的问题。麻烦的是SPI的模式——也就是时钟极性和相位。注意SPI协议的时钟极性和相位如果不匹配表现出来就是通信数据全是乱的而且这种乱是“有规律的乱”比如所有bit都错位了或者首尾bit丢了。排查时最先确认的就是主从两端的CPOL和CPHA是否一致。1.2 CPOL与CPHASPI的四种工作模式CPOLClock Polarity决定空闲时SCLK的电平CPHAClock Phase决定数据采样发生在SCLK的哪个边沿。两者组合出4种模式模式CPOLCPHA空闲SCLK电平数据采样沿数据移位沿Mode 000低上升沿下降沿Mode 101低下降沿上升沿Mode 210高下降沿上升沿Mode 311高上升沿下降沿绝大多数SPI从设备默认使用Mode 0或Mode 3。FPGA实现时我习惯把CPOL和CPHA做成可配置的参数而不是写死。这样同一套代码可以适配不同外设灵活得多。CPOL/CPHA在FPGA里的实现原理其实特别简单CPOL决定空闲电平你只需要在IDLE状态下把SCLK寄存器的初值设为CPOL的值CPHA决定采样沿也就是决定你的状态机是在SCLK的上升沿还是下降沿把数据打进去。一个参数化设计通常就是一个generate或if-else的事。1.3 双全工为什么说SPI天然同时收发SPI是双全工通信——发数据的同时也在收数据。这一点和I2C不同SPI不需要方向切换时间主设备在SCLK每个周期移出一个bit的同时也采样一个bit进来。FPGA实现双全工收发的思路是用一个移位寄存器把发送数据逐位移出到MOSI同时用另一个移位寄存器把MISO上的数据逐位移入。整个过程发生在同一个时钟沿的控制下比如Mode 0下在上升沿采样MISO、在下降沿更新MOSI。有一种常见的错误理解是“发送完才能接收”其实收发是同步进行的一个8bit的传输周期无论是发还是收都需要8个SCLK周期。这一点在做通信协议设计时非常重要如果你希望“发送一个命令字节后立刻读取从设备返回的数据”那这个返回数据实际上是在发送过程中就已经被采进来了而不是发送结束后再单独去读。2. FPGA实现方案选型状态机 VS 移位寄存器2.1 两种实现路径对比SPI的FPGA实现业界主流做法大致分两种一种是纯状态机方式一种是移位寄存器加计数器方式。两者没有绝对优劣主要看你的使用场景和代码风格偏好。状态机方式把一次SPI传输拆解为IDLE、启动、时钟产生、数据移位、结束等多个状态每个状态做特定操作。优点是可读性强、逻辑清晰、容易插入等待和超时处理适合帧格式复杂的场景比如命令地址数据分阶段的从设备。缺点是代码量偏大状态多的时候容易出bug。移位寄存器方式把要发送的数据提前并入移位寄存器然后根据SCLK边沿逐位移出。优点是代码紧凑、资源占用小适合纯粹的block读写、连续传输场景。缺点是不太好处理复杂的帧格式也较难在传输过程中插入额外的控制逻辑。我个人建议如果你只是点个SPI屏幕、读个Flash芯片移位寄存器方式够用但如果你要接的传感器有复杂的寄存器读写时序比如先发命令、再发地址、再读数据直接上状态机否则后面加功能时你会非常痛苦。2.2 SPI Slave与Master实现的差异很多人以为SPI Slave实现起来和Master差不多只是角色对调。实际上Slave的FPGA实现难度明显更高核心原因是你没有时钟主动权。在Master模式下SCLK是你自己产生的所有信号变化都跟着你自己的逻辑走域内设计几乎没有时序问题。但在Slave模式下SCLK是外部输入的你需要在一个完全未知的时钟域下做采样和同步稍不留神就是亚稳态问题。最常见的处理方法是对SCLK做两级寄存器同步再检测边沿对CS信号也要同步并且要处理好CS有效后的第一个SCLK沿。另外Slave模式下数据输出的时序余量非常紧张。主设备在某个SCLK沿采样数据而你在FPGA内部经过同步、逻辑延迟后数据可能已经错过了采样窗口。解决思路一是尽量让数据输出路径短二是用ODDR之类的原语让数据在IOB附近翻转减少Tco。如果你刚开始做建议先实现Master跑通了再动手Slave。2.3 时钟频率选择的约束SCLK频率不是越高越好它受限于两个因素一是从设备支持的最大SCLK频率查数据手册二是FPGA内部逻辑能否在半个SCLK周期内完成数据路径上的传输Tco Tcomb Tsetup。对于100MHz的FPGA主时钟SCLK轻松跑到20MHz、50MHz问题不大如果追求更高就需要用DDR输出或者特殊原语去优化IO时序。实际项目里我一般会把SCLK控制在从设备额定频率的1/2到1/3留足信号完整性余量。SPI毕竟是板级通信走线长了、电平转换器带宽不够高速下波形畸变、码间干扰都是实打实的问题不是你逻辑正确就能绕开的。提示如果SPI走线较长或者线上有电平转换芯片比如3.3V转5V建议SCLK不要超过10MHz。电平转换芯片的传播延迟通常有几十到几百纳秒高速时会导致Master发出的数据与SCLK错位Slave采到的数据就乱了。3. 量化实现指标与目标分解3.1 先定指标再写代码一个可复用的设计目标动手写代码之前先把设计目标量化。我常用的一组目标是数据位宽8bit可配置最大支持32bit。SCKL频率可配置分频最低支持1MHz最高支持50MHzFPGA主时钟100MHz。支持SPI Mode 0/1/2/3可配置。支持MSB First / LSB First可配置。数据收发双全工带收发FIFO接口。Master模式为主预留Slave扩展接口。这几个指标看起来简单但每一项都对应着具体的代码细节。位宽可配置意味着移位寄存器的计数终止条件要参数化SCLK频率可配置意味着分频比不能写死MSB/LSB可切换意味着移位方向要能反转。这些最好在一开始就设计好后面加需求很麻烦。3.2 跨时钟域处理从SCLK域到系统时钟域这是FPGA实现SPI最容易忽略、也最容易出问题的环节。如果你的FPGA逻辑都在100MHz的时钟域而SCLK是20MHz那么MISO采样到的数据严格来说是在“SCLK域”你要把它用到系统时钟域里就必须做跨时钟域处理。跨时钟域的手段无非三种打两拍两级同步器、异步FIFO、握手。对于单bit控制信号用打两拍足够对于多bit数据必须用FIFO。在SPI Master实现中我通常的做法是系统时钟域负责控制状态机和FIFO读写SCLK域只负责移位寄存器的移出和移入。移满一个字节后用一个脉冲信号比如byte_done把数据锁存到输出寄存器再通过打两拍同步到系统时钟域。这样跨时钟域只有单bit脉冲信号处理起来非常安全。如果你图省事把MISO的打拍和移位都放在系统时钟域也不是不行但SCLK相对系统时钟的频率比不能太高且必须保证采样时刻远离SCLK跳变沿。实际测过频率比超过1/4后数据偶尔会错排查起来极难。3.3 资源与延迟估算一个简单case以8bit SPI Master为例状态机分频器移位寄存器LUT用量大概在100个左右FF大概80个左右。这在高密度FPGA里几乎可以忽略不计在低端CPLD里也能跑得开。如果你用MicroBlaze或软核处理器去模拟SPI那资源和时序完全是另一个量级的事——这也是FPGA实现SPI的核心价值同样的功能几乎不占CPU资源速度还可以做到很高。延迟方面一次8bit传输SCLK周期T则传输时间为8T再加上前后一些启动和结束状态的开销大约是8T若干系统时钟周期。比如SCLK20MHz一次8bit传输约400ns。如果连续大量传输吞吐率主要受限于SCLK频率和帧间间隔。4. SPI Master的Verilog实现可直接套用的代码框架4.1 顶层接口设计先把接口定义清楚。我喜欢让SPI模块尽量“被动”——对外只暴露FIFO接口和SPI物理引脚由外部模块决定何时发送、何时读取。这样模块复用性最好。module spi_master #( parameter DATA_WIDTH 8, parameter CLK_DIV 5, // 系统时钟 / (2*CLK_DIV) SCLK parameter CPOL 0, // 时钟极性 parameter CPHA 0, // 时钟相位 parameter MSB_FIRST 1 // 1: MSB first, 0: LSB first )( input wire clk, // 系统时钟 input wire rst_n, // 异步复位低有效 // 发送/接收接口FIFO式 input wire tx_valid, input wire [DATA_WIDTH-1:0] tx_data, output reg tx_ready, output reg rx_valid, output reg [DATA_WIDTH-1:0] rx_data, // SPI物理接口 output reg spi_sclk, output reg spi_cs_n, output reg spi_mosi, input wire spi_miso );参数里有两个容易踩坑的地方CLK_DIV的值决定了SCLK分频比公式是SCLK CLK / (2 * CLK_DIV)所以CLK_DIV5、100MHz系统时钟下SCLK10MHzCPOL和CPHA的组合决定采样沿写代码时一定要反复对照模式表太容易搞反。4.2 内部状态机设计状态机我习惯分成IDLE、WAIT_SCLK_H、WAIT_SCLK_L、SHIFT_DONE四个核心状态。不需要把每一bit都设计成一个状态那样代码太啰嗦。用一个bit计数器在SHIFT过程中累计到了DATA_WIDTH就退出。localparam IDLE 3d0; localparam START_CS 3d1; localparam SCLK_HIGH 3d2; localparam SCLK_LOW 3d3; localparam FRAME_END 3d4;一个关键设计是SCLK的产生不是在某个状态里直接翻转而是用分频计数器独立产生。状态机只负责在合适的时机“借用”这个翻转好的SCLK这样代码更清晰也方便后续扩展任意分频比。我是这样实现的// SCLK分频器自由运行产生clk_div_en脉冲 reg [$clog2(CLK_DIV1)-1:0] cnt_sclk; wire sclk_toggle (cnt_sclk CLK_DIV); always (posedge clk or negedge rst_n) begin if (!rst_n) cnt_sclk 0; else if (state IDLE) cnt_sclk 0; else if (sclk_toggle) cnt_sclk 0; else cnt_sclk cnt_sclk 1; end实际SCLK的输出不是简单翻转而是受CPOL和状态机控制。完整逻辑是这样的// 控制SCLK输出 // 空闲时SCLK CPOL传输时在sclk_toggle处翻转 reg sclk_phase; always (posedge clk or negedge rst_n) begin if (!rst_n) begin sclk_phase 1b0; end else if (state ! IDLE sclk_toggle) begin sclk_phase ~sclk_phase; end else if (state IDLE) begin sclk_phase 1b0; end end always (*) begin if (state IDLE) spi_sclk CPOL; else if (sclk_toggle) spi_sclk sclk_phase; else if (!sclk_toggle) spi_sclk sclk_phase; else spi_sclk CPOL; end这块代码初看绕但核心就一句话SCLK只有在非IDLE状态下才翻转空闲时固定为CPOL电平。调试时用示波器或ILA看SCLK波形如果空闲电平不对那就是CPOL没用对。4.3 数据移位与采样Mode 0/3的核心差异数据移位的核心是一个移位寄存器。以MSB First为例发送时每个SCLK下降沿把最高位移出去接收时每个SCLK上升沿把MISO采进来。但Mode不同边沿正好相反所以代码里直接用CPHA参数做条件判断。// 发送移位根据CPHA在对应边沿更新MOSI wire sample_edge (CPHA 0) ? sclk_falling : sclk_rising; wire shift_edge (CPHA 0) ? sclk_rising : sclk_falling;这是一种把“现象”参数化的写法逻辑简洁也容易验证。你只需要在仿真时把四种模式都跑一遍确认采样点符合协议即可。接收部分需要特别小心MISO在SCLK的某个边沿变化你要在下一个边沿采样。通常在shift_edge时把MISO移入在sample_edge时把数据锁存到rx_data。这样收发交错进行互不干扰。我把收发逻辑合并到了一个状态机里核心代码如下always (posedge clk or negedge rst_n) begin if (!rst_n) begin spi_mosi 1b0; rx_data 0; bit_cnt 0; end else begin case (state) START_CS: begin spi_mosi MSB_FIRST ? tx_data[DATA_WIDTH-1] : tx_data[0]; bit_cnt 1; end SCLK_HIGH: begin // 采样MISO——根据CPHA选择上升沿或下降沿 if (sample_edge (bit_cnt DATA_WIDTH)) begin if (MSB_FIRST) rx_data {rx_data[DATA_WIDTH-2:0], spi_miso}; else rx_data {spi_miso, rx_data[DATA_WIDTH-1:1]}; end end SCLK_LOW: begin // 移位MOSI并更新bit_cnt if (shift_edge) begin if (bit_cnt DATA_WIDTH) begin if (MSB_FIRST) spi_mosi tx_data[DATA_WIDTH-1-bit_cnt]; else spi_mosi tx_data[bit_cnt]; bit_cnt bit_cnt 1; end end end FRAME_END: begin rx_valid 1b1; end endcase end end这段代码是参考框架不是完整可直接编译的版本。实际使用中你还需要处理tx_valid握手、FIFO空满、帧结束后的rx_valid恢复等细节。我给过几个学生用过这个框架最常见的报错就是漏了bit_cnt在多bit传输时的递增逻辑导致只发了一个bit就结束了。注意这个代码示例是为了说明核心逻辑真正的工程版本需要额外考虑tx_valid拉低后rx_valid的清除时机、CS释放时MOSI的电平保持、连续多帧传输时FIFO的衔接等待。建议先跑通仿真再加功能。4.4 仿真验证四种模式全跑一遍写仿真Testbench时我习惯用一个模拟从设备BFM来配合验证。BFM里实现了SPI Slave的行为检测CS下降沿、按CPOL/CPHA采样和发送数据。仿真场景至少覆盖同一份代码跑Mode 0/1/2/3比对主从两端收发的数据一致性。连续发送多帧比如100帧用断言检查rx_data和tx_data是否一一对应。模拟从设备响应慢的场景验证超时处理。插入总线干扰比如MISO抖动验证模块会不会死锁。我当时调试最痛苦的一次是在Mode 3下数据最高位老是错。后来用波形对比发现因为Mode 3的采样沿是上升沿而FPGA在上升沿时SCLK刚从低变高如果组合逻辑没来得及稳定采样到的就是毛刺。解决方法是把MISO打一拍再做移位等效于把采样点往后挪半个时钟。5. 工程化细节硬件片选、多从机与IIC对比5.1 硬件片选与软件片选FPGA里怎么选“片选”在MCU里一般是指GPIO拉低拉高。但在FPGA里有硬件片选和软件片选两种说法本质区别在于片选信号的产生和管理方式。硬件片选片选信号由SPI控制器逻辑自动产生。进入传输状态时自动拉低CS传输结束后自动拉高。优点是主逻辑完全不用关心CS时序帧间隔一致性好缺点是灵活性差如果你想在帧中间拉高CS比如某些从设备的“暂停”功能硬件片选就实现不了。软件片选片选信号由用户逻辑控制SPI控制器只负责SCLK和数据线。用户想什么时候拉CS、什么时候释放CS都可以灵活得多。缺点是帧间隔时序需要自己保证CS的毛刺风险也更高。实际工程中我偏好折中控制器内部带一个自动CS的开关默认开启需要特殊时序时关闭切换到手动控制。这样一个模块适配两种场景很实用。5.2 SPI多从机的拓扑与隔离问题SPI多从机有两种接法独立CS方式每个从机一根CS线主机的MISO/MOSI/SCLK并联到所有从机。菊花链方式从机的MISO接到下一个从机的MOSI入口CS共享。独立CS方式最简单但要注意未选中的从机MISO必须是高阻态否则多个从机同时驱动MISO总线冲突通信直接瘫痪。这在FPGA做Master时尤其容易忽视。用FPGA实现多从机时我建议在MISO入口处做三态隔离逻辑——根据当前选中的CS决定哪个从机的MISO被采入。如果你用的从机MISO没有三态功能那就在每个从机的MISO输出串一个三态缓冲器。菊花链方式能省CS线但延迟逐级累加数据位宽也变成N*8bit做起来复杂不少一般只在从机不支持多CS时用。5.3 SPI与IIC的关键差异什么时候选谁热搜词里IIC和SPI的区别出现在一起说明这是初学者最常纠结的问题。我的判断标准很直接对比维度SPIIIC线数4根SCLK/MOSI/MISO/CS2根SCL/SDA速度通常可达数十MHz通常400kHz~1MHz3.4MHz算高速双工双全工半双工多从机靠CS片选地址独立靠7bit/10bit地址寻址帧格式简单无ACK有ACK/NACK帧相对复杂FPGA实现难度低-中中-高需要开漏、上拉、仲裁如果你在FPGA里实现IIC会比SPI痛苦不少——开漏输出在FPGA里要特殊处理不支持真正开漏IO的FPGA要用OD模式时序仲裁、时钟拉伸处理也麻烦。所以如果项目里接口可以自选我会优先选SPI。IIC的优势是接线少、支持多主机仲裁适合板级空间紧张、速率要求不高的场景。实战中SPI屏幕、Flash、ADC、DAC用SPI很普遍IIC多用在传感器配置、EEPROM、低速外设上。两块芯片各司其职不存在谁取代谁的问题。6. 常见问题与调试心得实录6.1 通信偶发错一个字节先别怀疑代码如果你在板上调试时发现SPI通信大部分时间正常但偶尔错一个字节先别急着翻代码。90%的可能不在逻辑而在信号完整性试试降低SCLK频率比如从20MHz降到5MHz如果问题消失基本就是SI问题。用示波器看CS信号是否有毛刺CS毛刺会导致从机误触发。检查地线SPI高速通信时主从两端的地电位差会导致采样误判。我之前调试一块高速ADC板子SPI读寄存器老是偶发错误。折腾了两天最后发现是ADC芯片的电源纹波太大导致MISO上耦合了噪声。在电源上并了个100nF10uF电容解决。教训是通信不稳定优先查电源和地其次才是代码。6.2 Slave模式下数据输出不确定时序收敛是关键当你是SPI Slave时MISO的输出时机完全由主设备决定。FPGA内部从检测到SCLK边沿到MISO有效输出中间有多少延迟直接决定主设备能否采到正确的bit。问题常出在FPGA内部逻辑延迟太长MISO数据紧赶慢赶还是错过采样点。解决办法把MISO的输出路径压缩到最短尽量不要跨逻辑层。如果实在满足不了可以在从机内部用SCLK打拍生成一个提前量。实在不行就用ODDR原语让MISO在IOB附近直接翻转省去内部路径延迟。这个问题的本质是时序约束不完全尤其是跨时钟域路径。建议在XDC里给SCLK、MISO加上合理的约束让工具帮你保证时序。6.3 片选毛刺问题CS低有效却被误触发CS毛刺是SPI调试里非常经典的坑。主设备逻辑上CS应该是“空闲为高、传输时拉低”但如果代码里CS信号的产生路径有组合竞争就可能在拉高拉低的瞬间产生毛刺。从设备一旦识别到CS下降沿就开始接收然后接收到的数据是错的表现为“整个通信全是乱的”。排查方法示波器看CS波形是否在跳变沿有“锯齿”或窄脉冲。确认CS信号在代码里是寄存器输出不是组合逻辑直接给到引脚。如果CS来自FPGA且必须组合产生至少做一次打拍消除毛刺。另外在FPGA内部实现多从机片选时切换CS的瞬间最好先让SCLK停在高或低电平取决于CPOL然后再切CS最后再开始SCLK。这个“CS建立时间”虽然协议没强制要求但好多从机数据手册里都写在电气特性里不看真会踩坑。6.4 时序余量不足为什么低速也出错还有一个容易忽略的因素从机对SCLK的占空比要求。FPGA产生的SCLK如果不是50%占空比某些严格片子的从设备会采不到正确数据。解决办法是检查分频逻辑确保奇数分频下占空比尽量接近50%或者干脆用偶数分频。另外帧与帧之间需要留一点间隔。很多从设备要求CS拉高到下一次CS拉低之间有一个最小延时比如100ns。FPGA实现时如果连续帧间隔太短从设备内部状态机来不及复位就会出现“上一帧正常、下一帧乱码”的现象。这也是我推荐状态机实现而不是纯寄存器移位的原因——状态机可以很容易地插入IDLE等待周期。7. 进阶从SPI Master到通信控制器的扩展之路当基本的SPI Master能稳定工作了你可以朝这几个方向扩展收发FIFO DMA接口对接AXI-Stream或自定义总线让CPU只负责搬运数据SPI收发完全由硬件完成。多从机仲裁管理做一个CS管理寄存器软件配置选择哪个从机控制器自动完成CS时序。多通道SPI复用用同一套核心逻辑时分复用支持多个SPI外设节约资源。响应式CS控制实现“CS拉低后等待从机准备好”的时序模式适配某些支持硬件流控的从设备。我后来把SPI Master核接到一个简易的寄存器总线上配合DMA把ADC数据搬到DDR整个链路完全不用CPU干预。这种“通信控制器”的思路几乎可以复用到每个嵌入式项目里——一旦你掌握了SPICAN、IIC、UART、以太网的FPGA实现思路都会触类旁通。FPGA实现SPI通信本身并不复杂复杂的是你如何在各种边界条件下保证通信的可靠性和灵活性。希望这篇内容能帮你少走些弯路。如果你也在调试中遇到了什么奇怪的问题欢迎留言交流我们一起踩坑。