SPI通信FPGA实现这个话题隔三差五就会在群里被提起来一次。见得最多的提问是我的SCLK已经降到十几兆了为什么从机还是收不到数据——其实很多人没有意识到SCLK慢不等于时序对。FPGA上做SPI的真正难点不在速度而在你是否在正确的边沿做了正确的事。SPI本身只有四根线SCLK、MOSI、MISO、CS_N全双工主从结构速度可以拉得很高。FPGA实现SPI和单片机不一样单片机调用的SPI外设已经把数据移位、边沿采样、片选时序全封装好了你只需要配寄存器FPGA里一切都回到最底层你得自己盯着每一位数据什么时候放到总线上、什么时候从总线上取下来。换个角度说你等于用硬件逻辑重新发明一个SPI外设。这篇文章把协议拆解、模块设计、仿真联调、Flash读写这一整条链路都讲一遍。适合两种人看刚学完Verilog不知道怎么写第一个通信模块的入门者以及已经有模块但总在某些莫名其妙的通信问题上卡壳的开发者。看完之后SPI对你来说就不再是一个会读时序图但写不出代码的东西了。1. SPI协议并没有想象中简单四个信号背后的时序细节1.1 四个信号与主从关系SPI是典型的Master-Slave结构主机负责产生SCLK和CS_N从机被动响应。四条信号各干各的SCLK串行时钟由主机产生给从机提供采样节奏。MOSIMaster Output Slave Input主机发数据从机收数据。MISOMaster Input Slave Output从机发数据主机收数据。CS_N片选低有效主机控制。CS_N拉低从机开始工作CS_N拉高从机进入空闲状态。这里有个新手容易忽略的点SPI是同时收发。MOSI和MISO在同一个SCLK周期里各自传输一位数据所以它天生是全双工。后面做W25Q64这类SPI Flash读写时你想读数据就必须继续给SCLK哪怕主机没有数据要发也要假装发几个空字节把时钟顶出来。还有一个关键认知SPI协议本身没有定义应答机制。主机发一个字节从机是否收到、是否正确协议层完全不知道。所以实际项目里要么依赖从机返回的数据做校验要么靠CRC、命令应答这类应用层机制来保证可靠性。这一点和I2C的ACK机制差别很大很多人从I2C转过来会不适应。1.2 四种工作模式与采样边沿SPI模式由CPOL时钟极性和CPHA时钟相位两个参数决定CPOL决定SCLK空闲电平。CPOL0空闲低CPOL1空闲高。CPHA决定数据采样相位。CPHA0在第一个时钟沿采样CPHA1在第二个时钟沿采样。组合起来就是四种模式模式CPOLCPHA主机输出数据时机主机采样数据时机Mode 000SCLK下降沿SCLK上升沿Mode 101SCLK上升沿SCLK下降沿Mode 210SCLK上升沿SCLK下降沿Mode 311SCLK下降沿SCLK上升沿判断从机工作在哪个模式最好的办法是打开数据手册看时序图而不是猜。时序图上一般会画出数据变化沿和采样沿照着比对就行。比如W25Q64支持Mode 0和Mode 3大部分场景用Mode 0。OLED驱动芯片SSD1306、SD卡、各种传感器多数也是默认Mode 0或Mode 3所以入门阶段把Mode 0吃透已经能覆盖很多从机了。给新手一个建议如果从机手册实在看不懂可以用逻辑分析仪抓主机一个已知从机的波形反推模式。我调过一块音频芯片手册上写的是Mode 0 compatible但用标准Mode 0怎么都读不到正确寄存器最后抓波形发现它在SCLK上升沿更新数据主机应该在下降沿采样实际是Mode 1。所以手册最大经验次之波形最靠谱。1.3 FPGA实现SPI和单片机实现SPI的本质区别单片机上的SPI外设是一个硬件模块寄存器配好了硬件自动完成移位、采样的全部过程CPU只需要往数据寄存器写值、读值。FPGA上没有这个现成的外设你需要用寄存器、状态机、计数器和少量组合逻辑自己搭建一条数据通路。这里有一个本质区别单片机的SPI时序由硬件固定可配置项也就模式、时钟分频、位序这几个FPGA的SPI时序完全由你的代码决定你可以随时插入等待周期、自定义CS建立时间、甚至让同一个模块支持多种模式。灵活性高但代价是每一个时序细节都要自己负责。另一个区别是并行性。FPGA里你可以放下多个SPI控制器实例同时驱动不同的从机互不干扰。单片机上很多SPI外设只有一个或两个总线多了就得靠软件分时复用。这也是很多项目选FPGA做传感器采集板的原因——SPI设备多、数据量大软件分时处理容易来不及。2. 系统架构划分与状态机设计SPI Master的核心决策2.1 从顶到底的模块划分写SPI设计前先想清楚系统里有哪些模块别一上来就写时序逻辑。一个典型的FPGA SPI系统从上往下大致是层级模块职责应用层用户逻辑产生要发送的数据消费接收的数据接口层FIFO/寄存器组缓冲数据提供握手信号协议层SPI Master控制器完成字节级时序产生SCLK/MOSI/CS_N物理层IO约束/电平转换管脚分配电平匹配很多初学者直接拿一个always块边发边收看起来代码量小但后续要加FIFO、要支持多从机、要切换模式全部堆在一起根本改不动。我建议至少拆成两个模块一个纯粹的SPI字节控制器负责把一字节数据按SPI时序送出去并读回一字节另一个业务状态机负责按业务需求组合多个字节的传输。SPI控制器对外接口可以这样设计clk系统时钟。rst_n异步复位低有效。start启动信号单周期脉冲。tx_data待发送数据。rx_data接收数据。done传输完成单周期脉冲。busy模块忙标志busy1时不能再启动新传输。这种接口的好处是通用后面不管接Flash、接ADC、接OLED只需要换业务状态机SPI控制器可以原样复用。2.2 为什么状态机比直接计数更适合SPI时序SPI的每一比特都要经历数据建立-采样-数据变化几个阶段如果只用计数器翻转SCLK代码写起来确实简单但遇到下面的场景就会很难受某些从机要求CS_N拉低后至少等几个时钟周期再给SCLK某些从机要求两个字节之间插入延时某些从机要求主机在采样前留出额外等待时间。这些插入等待的需求用纯计数器也能做但状态一多代码就变成一团乱麻。状态机的优势就在于显式表达时序阶段每个状态代表SCLK的一个半周期或一个空闲阶段状态跳转条件就是计数到某个值。看代码的人一眼就能知道当前处于什么阶段后续加时序约束也只需要在状态机里加状态。而且状态机的输出逻辑非常自然。比如Mode 0下SCLK空闲为低进入第一个状态后保持SCLK为低计满半周期后跳到高电平状态高电平状态再计满半周期才采样。这样SCLK不是一个独立的自由运行时钟而是受状态机控制的受控时钟CS_N、MOSI的变化时机都能和SCLK精确对齐不会再出现那种波形看起来对实际上沿的位置差了一拍的问题。2.3 波特率和分频系数的取舍SCLK的最高频率由两个东西决定从机支持的最大SCLK频率以及FPGA到从机之间的物理链路质量。不要一上来就拉满工程上合理的选择是先定一个稳妥的基准频率跑通后再慢慢提频。分频系数的计算很简单SCLK频率等于系统时钟频率除以分频比。以100MHz系统时钟为例想要10MHz的SCLK分频比就是10半周期就是5个系统时钟周期。如果从机最高SCLK是40MHz我通常不会直接用40MHz而是先跑到10MHz把协议调通再逐步提到25MHz、30MHz。原因很简单高频下信号完整性问题、采样沿余量问题全都会冒出来到时候你分不清是协议代码的问题还是硬件链路的问题。另外注意一点SCLK低电平时间和高电平时间最好对称。分频系数是偶数时天然对称奇数时会有高低电平不均的问题。所以参数化设计里HALF_CLK这个值尽量用偶数分频折算哪怕SCLK频率不是整数也问题不大通信时序最重要的是采样点位置稳定而不是频率刚好卡在整数兆赫兹。3. 可复用的Verilog实现从零手写一个Mode 0的SPI Master3.1 核心代码框架下面是一个参数化的SPI Master适用于Mode 0CPOL0CPHA0支持任意字节宽度时钟分频可配。代码量不大但把SPI时序的核心流程完整覆盖了。module spi_master #( parameter DATA_WIDTH 8, parameter HALF_CLK 5 // 系统时钟 / (SCLK * 2) )( 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, output reg sclk, output reg cs_n, output reg mosi, input wire miso ); localparam IDLE 3d0; localparam CLK_LO 3d1; localparam CLK_HI 3d2; localparam STOP 3d3; reg [2:0] state; reg [DATA_WIDTH-1:0] shift_reg; reg [$clog2(DATA_WIDTH)-1:0] bit_cnt; reg [15:0] hal_cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; sclk 1b0; cs_n 1b1; mosi 1b0; done 1b0; rx_data {DATA_WIDTH{1b0}}; end else begin case (state) IDLE: begin done 1b0; cs_n 1b1; sclk 1b0; if (start) begin cs_n 1b0; shift_reg tx_data; mosi tx_data[DATA_WIDTH-1]; bit_cnt 0; hal_cnt 0; state CLK_LO; end end CLK_LO: begin if (hal_cnt HALF_CLK - 1) begin hal_cnt 0; sclk 1b1; state CLK_HI; end else begin hal_cnt hal_cnt 1b1; end end CLK_HI: begin if (hal_cnt HALF_CLK - 1) begin hal_cnt 0; sclk 1b0; rx_data {rx_data[DATA_WIDTH-2:0], miso}; if (bit_cnt DATA_WIDTH - 1) begin state STOP; end else begin bit_cnt bit_cnt 1b1; shift_reg {shift_reg[DATA_WIDTH-2:0], 1b0}; mosi shift_reg[DATA_WIDTH-2]; state CLK_LO; end end else begin hal_cnt hal_cnt 1b1; end end STOP: begin cs_n 1b1; done 1b1; state IDLE; end endcase end end endmodule这段代码的主体思路是IDLE状态等待startCS_N拉低的同时把待发送数据的最高位放到MOSI上然后进入CLK_LO状态。CLK_LO保持SCLK低电平半周期结束后拉高SCLK进入CLK_HI。CLK_HI保持SCLK高电平半周期结束时采样MISO、拉低SCLK同时把下一位数据移到MOSI上。循环8次后进入STOP状态释放CS_N并拉高done。3.2 发送与接收并行处理的关键点很多人第一次写SPI时会问一个问题发送和接收是不是要分别写两个计数器不需要。SPI是同时移位的发送一个bit的同时必然接收一个bit靠同一个bit_cnt控制就行。注意代码里发送和接收的移位方向发送时每过一个周期把shift_reg左移一位新的最高位变成原次高位送出的顺序是MSB first接收时用rx_data {rx_data[DATA_WIDTH-2:0], miso}把采到的bit放到最低位同时整个寄存器左移。这样第一个采到的bit会慢慢移动到最高位最终rx_data就是标准的MSB first数据。这段代码里采样点放在CLK_HI结束前的半周期位置也就是SCLK高电平的末尾。Mode 0下从机通常是在SCLK下降沿更新MISO所以SCLK高电平期间MISO已经稳定。在末尾采样相当于给从机输出留出了接近一个半周期的建立时间比在上升沿瞬间采样更稳。有的FPGA工程师习惯在上升沿打一拍采样也能工作但如果从机输出延迟较大偶发MISO采样错误就很头疼。我的做法是优先保证建立时间余量采样点稍微往后挪一点代价是SCLK前沿到采样点之间有一个半周期的延迟这完全不影响协议正确性。3.3 从Mode 0扩展到Mode 1/2/3的思路上面的代码默认CPOL0、CPHA0想支持其他模式核心就两个改动SCLK空闲电平以及移位和采样分别发生在哪个沿。CPOL决定空闲电平。Mode 2和Mode 3下IDLE状态里SCLK应该是高电平STOP之后也要回到高电平。CPHA决定采样沿。Mode 1里主机在SCLK上升沿移位、下降沿采样正好和Mode 0相反。改成参数化设计时可以加两个parameterCPOL和CPHA然后在状态机里根据参数决定CLK_LO和CLK_HI时SCLK的电平以及采样和移位动作放在哪个状态里。不过说实话工程中我更推荐用Vivado或Quartus自带的SPI IP或者针对某种模式单独写一个专用模块。一个模块支持全部四模式代码可读性和时序余量都会受影响除非项目需求明确否则没必要把参数化做得太复杂。4. 从仿真到上板实测过程中才暴露的几个坑4.1 Testbench搭建时一定要模拟真实从机行为仿真最大的价值不是看波形多漂亮而是在上板之前验证协议的正确性。很多人的Testbench只给SPI Master灌数据然后把MISO拉高或拉低这其实测不到时序问题。建议写一个简单的SPI从机模型逻辑就是检测到CS_N拉低后在每个SCLK的有效边沿采样MOSI存到寄存器并在下一个边沿把预设数据放到MISO上。代码如下module spi_slave_model #( parameter DATA_WIDTH 8 )( input wire sclk, input wire cs_n, input wire mosi, output reg miso, input wire [DATA_WIDTH-1:0] response ); reg [DATA_WIDTH-1:0] shift; always (posedge sclk or negedge cs_n) begin if (!cs_n) begin miso response[DATA_WIDTH-1]; end else begin miso response[DATA_WIDTH-2:0]; // 这里只是为了仿真不追求完全精确的从机行为 end end endmoduleTestbench里用always #5 clk ~clk;产生100MHz系统时钟start信号拉高一个周期然后等待done再比较rx_data和response是否一致。跑通之后再用$display打印每一次收发的结果比肉眼看波形高效得多。4.2 仿真没问题、上板就出错的三个高频问题第一个是start信号毛刺。如果start来自按键或者跨时钟域信号直接进状态机有可能触发半路复位或者状态跳飞。解决方法是加两级同步器或者做边沿检测确保状态机只在信号的稳定沿触发。第二个是CS_N和SCLK的相位关系。很多从机要求CS_N拉低后至少几微秒才能给SCLK或者CS_N释放后要等一段才能进行下一次传输。我的控制器里没有在IDLE到CLK_LO之间做CSN建立时间的等待所以如果从机手册里有tCSSN这类参数你需要在外层状态机里自行控制间隔。最简单的方式是在每个字节传输完成后强制等待几个时钟周期再发下一个字节。第三个是MISO没有做同步处理。虽然SPI时钟是FPGA自己产生的但MISO信号从引脚进来之后相对系统时钟仍然是异步输入。如果不做任何处理直接采样极端情况下会出现亚稳态导致偶发的一个bit错误。稳妥的做法是在控制器入口打两拍同步或者至少在采样路径上加一级寄存器。这个在上板信号质量差的时候尤其重要。4.3 用逻辑分析仪看波形时的一些建议上板调试时ILAIntegrated Logic Analyzer是你的眼睛。但很多新手用ILA看SPI抓不到有效数据原因往往是触发条件没设好。建议把采样时钟设为系统时钟不要用SCLK。触发条件设为cs_n 0的下降沿采样窗口稍微设大一点保证能抓到CS_N拉低期间的全部SCLK周期。观察时重点看四个要素CS_N拉低后MOSI的第一个bit是否已经稳定SCLK上升沿时MOSI是否处于数据窗口内SCLK下降沿时MISO的变化点每个字节结束后CS_N是否及时拉高。如果发现MOSI在SCLK有效沿附近变化说明移位时机太晚了要把移位动作往前挪。如果MISO采样结果全为1说明没有正确驱动MISO先检查从机供电和片选。ILA看到的问题绝大多数都是时序相位问题不是频率问题。5. 多从机扩展和SPI Flash读写实战5.1 硬件片选与软件片选怎么选多从机系统首先要解决片选问题。硬件片选就是每个从机单独接一根CS_N主机有多少个GPIO就能接多少个从机简单粗暴互不影响。软件片选则是通过译码器或IO扩展器用少量GPIO扩展出多路片选适合从机数量多但GPIO紧张的场景。实际工程里我强烈建议用硬件片选尤其是从机MISO不是三态输出的情况。SPI总线共享时如果两个从机的MISO同时驱低电平总线就冲突了。硬件片选至少保证同一时刻只有一个从机被激活。还要注意片选的编码方式。如果设计成00选从机1、01选从机2、10选从机3、11选从机4这种二进制的写法状态跳变时有中间毛刺风险两个CS可能瞬间同时有效。FPGA里最稳的写法是独热码4b0001选从机14b0010选从机2这样任意时刻最多只有一个位为1杜绝竞争风险。共享总线的场景比如ESP32用一根SPI同时接屏幕和SD卡核心也是片选切换。屏幕和SD卡分时工作同一时刻只选通一个。这里真正要注意的是模式兼容性如果屏幕工作在Mode 0而SD卡要求Mode 3那么每次切换设备都要重新配置SPI模式在FPGA里这就意味着每个从机要有独立的模式配置寄存器。5.2 用SPI Master读写W25Q64的关键流程W25Q64是SPI NOR Flash非常适合用来验证自研SPI模块。它支持Mode 0和Mode 3最高SCLK能达到几十兆甚至更高。上手第一步不是写Flash而是读JEDEC ID确认通信链路基本正常。读ID的命令是0x9F。操作流程是CS_N拉低发送0x9F然后连续接收3个字节接收过程中主机要给SCLK所以主机侧要连续发起3次空写。这里暴露了我前面那个单字节控制器的一个问题读ID要求CS_N在整个过程中保持低电平不能每个字节都拉高。而我的spi_master在每次字节传输结束后都会拉高CS_N如果直接用它读W25Q64一字节后Flash就认为命令结束了后面根本读不到ID。解决办法是给控制器增加一个last信号或者在外层用一个burst状态机把整个读ID过程包起来。更简单的做法是写一个专门控制W25Q64的顶层状态机里面复用SPI发送和接收的移位逻辑// 伪代码 CS_N 0; send_byte(8h9F); // 发送命令 dummy send_byte(8h00); // 读第1字节 mid send_byte(8h00); // 读第2字节 did send_byte(8h00); // 读第3字节 CS_N 1;W25Q64的厂商ID是0xEF内存类型是0x40容量是0x17。如果读出这三个字节和手册一致说明你的SPI Master时序完全正确可以继续做页编程和读数据。页编程一次最多256字节超过256字节必须拆成多页操作而且每页编程前要发0x06写使能命令。这些细节在Flash手册里写着但真正上手以后你会发现FPGA侧最容易出问题的还是CS_N的控制而不是SCLK频率。5.3 常见故障排查思路调SPI出问题时有一个排查顺序可以救命先看CS_N再看SCLK再看模式最后看数据。现象可能原因优先排查方向读回全为0xFFFlash未响应或总线浮空CS_N是否正常拉低SPI模式是否匹配读回数据固定错一位采样沿与从机输出沿冲突调整采样沿检查是Mode 0还是Mode 1偶发错位时好时坏MISO未同步或信号链路太长加同步寄存器降低SCLK频率多从机总线数据混乱多个从机MISO同时驱动检查片选是否为独热码MISO是否三态命令发出去没反应CS_N在每个字节之间拉高使用burst模式保证CS_N全程低有效这里再强调一次很多人遇到通信不稳定第一反应是把SCLK调低这只能掩盖问题不能解决根因。正确做法是用ILA把波形抓出来对照从机手册的时序图逐项检查找到真正的相位错误。调SPI最有成就感的一刻就是看到ILA里的MISO数据和预期完全一致那一刻你会发现自己真正理解了什么叫时序。这篇文章写到这里我不打算做什么高屋建瓴的总结。最后分享一个我自己的习惯SPI模块不要一开始就追求多功能先用Mode 0和最基础的从机把链路打通再加参数、加FIFO、加多从机支持。调不通的时候先用低速加长半周期排除协议问题再用ILA抓波形确认相位。SPI作为FPGA入门最适合练手的串行总线它不复杂但足够让你把时序这两个字刻进脑子里。等你真正写完一个稳定运行的SPI Master再回头去看I2C、UART会发现所有串行协议的本质都是相通的。