☰
FPGA实现SPI通信:协议细节、状态机设计到W25Q64驱动全攻略
2026/10/26 7:28:46 网站建设 项目流程

做FPGA开发的人,迟早都要跟SPI打交道。不管你是驱动板上那颗SPI Flash、配置ADC/DAC芯片、读传感器数据,还是跟MCU做板级通信,SPI都是最常用也最容易上手的总线协议之一。但正因为“最常用”,很多人在FPGA里实现SPI时反而踩了不少坑——时序图看懂了觉得简单,一写代码就翻车,仿真顶着协议来,上板就是读不到数据。

这篇文章就把我自己在FPGA里实现SPI通信的完整思路和经验摊开讲。从协议本身的细节、状态机怎么写、时序怎么约束,到实际驱动W25Q64这颗Flash的完整代码、仿真怎么测、上板怎么排查,整理成一套可直接落地的方案。不管是刚入门FPGA的萌新,还是被SPI时序折腾过的老手,这篇文章可以帮你少走很多弯路。

1. SPI协议其实很简单,但陷阱全在细节里

1.1 四根线,三种角色,先搞懂“谁跟谁说话”

SPI全称Serial Peripheral Interface,串行外设接口。物理上就四根线:

  • SCLK:串行时钟,由主机产生
  • MOSI:Master Output Slave Input,主机输出从机输入
  • MISO:Master Input Slave Output,主机输入从机输出
  • CS/SS:片选信号,低电平有效,由主机控制拉低哪个从机

这四根线组合起来就是一个典型的一主多从总线结构。主机想跟哪个从机通信,就把哪个从机的CS拉低,同时在SCLK上产生时钟。需要注意,SPI是全双工的,也就是说主机发数据的同时也在收数据,MOSI和MISO是同时工作的。这一点跟I2C半双工有本质区别,也直接影响了FPGA实现时的信号处理方式。

有个点容易被忽略:SPI没有标准的应答机制,主机发出时钟和数据后,从机是否真的收到、正确解析,主机是“不知道”的。很多问题之所以隐蔽,就是因为SPI本身缺少反馈层,你看到的只是“发出了数据”,至于从机有没有正确执行,得靠后续读回状态寄存器或者比对数据来判断。

1.2 四种模式:CPOL和CPHA到底怎么搭

SPI有四种工作模式,由CPOL(时钟极性)和CPHA(时钟相位)组合决定:

模式CPOLCPHA数据采样边沿空闲时钟电平
Mode 000上升沿低电平
Mode 101下降沿低电平
Mode 210下降沿高电平
Mode 311上升沿高电平

从机的数据手册里一定会写明它工作在哪种模式,比如W25Q64支持Mode 0和Mode 3,有些传感器芯片只支持Mode 0,有些高速ADC则强制要求Mode 2。FPGA实现SPI时,代码里最忌讳把模式写死——除非你永远只用一个从机,否则一旦换器件就需要改代码重新综合,麻烦不说还容易漏改。

我个人的习惯是把CPOL和CPHA做成顶层模块的输入参数,配合数据位宽一起作为可配置项。这样不同从机各接一个实例化模块就行,不需要改动内部逻辑。

1.3 为什么很多人写SPI代码会被时序图绕晕

最经典的坑是“数据在哪个沿变化、在哪个沿采样”。根据时序图,常见误解是:主机锁存数据用了上升沿,从机也应该用上升沿。实际上,为了满足建立时间和保持时间,主机的数据变化边沿和采样边沿一定是错开的,主机的发送边沿就是从机的采样边沿,反过来主机的采样边沿就是从机的发送边沿。

可以这样理解:发数据和收数据就像两个人隔着门缝互相递东西,一个人伸出去、另一个人接住,必须一先一后,不可能同一瞬间既递又接。如果主从双方“在同一时刻动手”,那链路上的数据就悬空了,采样采到不确定的电平,读回来的数据自然就是乱的。

所以FPGA实现时,发送逻辑在SCLK的某个边沿更新MOSI,接收逻辑在SCLK的另一个边沿把MISO打一拍进来。这个“发送更新沿”和“采样沿”的错位,是整套逻辑的基石。

2. FPGA里实现SPI,先想清楚这四件事

2.1 用IP核还是手写状态机

Xilinx有个SPI IP核,也可以调AXI Quad SPI,Vivado里配置一下就能用。但我的建议是,除非是工程时间极度紧张、且确定不需要深改协议,否则手写状态机更划算。

理由有三:

第一,SPI本身逻辑量很小,一个完整Master模块写下来也就一百多行Verilog,综合资源几十个LUT,不值得为一个这么简单的东西引入一个带AXI接口、带中断控制器的大IP,光学配置就要半天。

第二,IP核灵活性反而差。比如你想实现一个“多发几个字节再判断是否拉高CS”的定制序列,IP核的寄存器配置绕来绕去,远不如状态机里加一个计数器直接。

第三,手写状态机让你对时序有完全掌控。上板调试时,遇到波形不对,仿真和逻辑分析仪一抓,你能立刻判断是哪一拍没对齐;用IP核一旦出问题,黑盒会让你无从下手。

2.2 时钟怎么分:从系统时钟到SCLK

SCLK频率一般由系统时钟分频得到。FPGA里推荐用计数器分频,不要用PLL专门去生成SCLK——除非你的SCLK要求很高、且与系统时钟频率不是整数倍关系,否则计数器更简单可控,还省了全局时钟资源的占用。

分频系数由系统时钟频率和目标SCLK频率决定:

分频系数 = 系统时钟频率 / (2 × 目标SCLK频率)

为什么是2倍?因为一般要生成占空比50%的时钟,计数器先数半个周期翻转一次。比如100MHz系统时钟要产生10MHz的SCLK,分频系数就是5,等价于一个周期内数5拍翻转一次。

实际项目中SCLK选多大,取决于从机支持的极限速率和板级信号完整性。比如W25Q64标准模式支持到33MHz,但很多人在PCB走线一般、又没有做阻抗匹配的情况下,跑20MHz以上就开始偶发读错了。SPI速率不是越高越好,稳定压倒一切。

2.3 参数化设计:数据位宽和模式做成可配置

好的SPI模块,应该像一把可调扳手,而不是一个只能拧特定螺丝的专用工具。至少要把这几个参数暴露出来:

  • DATA_WIDTH:数据位宽,8位最常见,但有些器件需要16位或24位
  • CPOL和CPHA:时钟极性和相位
  • SCLK_DIV:分频系数
  • LSB_FIRST:是否低位先传,少数字器件用LSB first,比如部分传感器

把这些做成Verilog的parameter或localparam,模块内部逻辑全部基于参数判断。后续接不同从机时,只需要在顶层改参数或者直接例化时重写parameter,就完成了适配。

3. SPI Master收发模块详解(附Verilog代码)

3.1 状态机设计与关键信号

写SPI Master,主流方案就是一个摩尔状态机。我习惯把状态拆分为这几个:

  • IDLE:空闲,等待启动信号,CS拉高
  • SETUP:拉低CS,产生SCLK的第一个边沿前的等待
  • TRANSFER:逐位发送/接收,内部用bit counter控制
  • DONE:发送完成,拉高CS,输出完成标志

核心是这个TRANSFER状态。里面每一拍做两件事:根据发送沿更新MOSI,根据采样沿把MISO打一拍并移入移位寄存器。bit counter从0到DATA_WIDTH-1,走完后再在DONE状态里把并行数据锁存到输出寄存器。

初学阶段最容易犯的错,是“发一位”和“收一位”用了同一个always块、又在一个时钟沿里既改SCLK又改数据,导致综合后时序错乱。SCLK产生逻辑和数据收发逻辑尽量分离,SCLK放在独立的分频模块里,数据收发模块再用SCLK边沿触发,结构清晰也方便约束。

3.2 发送接收数据要处理的细节

发送有个细节需要注意:MOSI上最后一位数据要保持到CS拉高之后。有些从机在CS上升沿时还会去采最后一个数据,如果MOSI在CS拉高瞬间就变了,有可能导致最后一位数据丢失。处理办法是在DONE状态、CS拉高的同一拍,MOSI保持不变,等CS稳定后再回到IDLE。

接收的细节更隐蔽:MISO在采样沿到来之前可能还在跳变,直接采样会采到不稳定电平。常规做法是在离采样沿还有一点时间的地方提前打一拍——这件事在纯数字逻辑里比较难做,更务实的方案是让SPI时钟低一点,给信号留足够的稳定时间,然后直接用采样沿的寄存器去采。只要建立时间满足,就不会采错。

3.3 一段可用的SPI Master代码

下面给出一个经过实际项目验证的SPI Master模块。为了简洁,这里省略了分频细节,只保留核心逻辑结构:

module spi_master #( parameter DATA_WIDTH = 8, 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 busy, output reg done, output reg sclk, output reg mosi, input wire miso, output reg cs_n ); localparam IDLE = 3'd0; localparam SETUP = 3'd1; localparam TRANSFER = 3'd2; localparam DONE = 3'd3; reg [2:0] state; reg [3:0] bit_cnt; reg [DATA_WIDTH-1:0] shift_reg; // sclk generate: simplified toggle logic // Full implementation should use counter for proper frequency division always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; cs_n <= 1'b1; busy <= 1'b0; done <= 1'b0; sclk <= CPOL; mosi <= 1'b0; rx_data <= {DATA_WIDTH{1'b0}}; end else begin case (state) IDLE: begin done <= 1'b0; if (start) begin busy <= 1'b1; cs_n <= 1'b0; shift_reg <= tx_data; bit_cnt <= 0; state <= SETUP; end end SETUP: begin // wait one cycle before first edge state <= TRANSFER; end TRANSFER: begin if (bit_cnt < DATA_WIDTH) begin // send data on SCLK edge depending on CPHA mosi <= shift_reg[DATA_WIDTH-1]; shift_reg <= {shift_reg[DATA_WIDTH-2:0], miso}; // bit counter increments on proper edge if (bit_cnt == DATA_WIDTH-1) state <= DONE; else bit_cnt <= bit_cnt + 1'b1; end end DONE: begin cs_n <= 1'b1; busy <= 1'b0; done <= 1'b1; rx_data <= shift_reg; state <= IDLE; end endcase end end endmodule

这段代码做了一些简化,实际使用时需要补充分频计数器,并把SCLK边沿与发送/接收逻辑严格对齐。重点是看状态转移的思路:IDLE等启动,SETUP给CS拉低后的等待周期,TRANSFER逐位收发,DONE收尾。这种结构覆盖了绝大多数SPI Flash、ADC、传感器芯片的需求。

4. 实战:驱动W25Q64 SPI Flash

4.1 W25Q64的命令体系和操作流程

W25Q64是一颗64Mbit的SPI NOR Flash,在很多板卡上用作FPGA的配置文件存储。它的操作命令是标准SPI协议加自定义命令字节,常用命令如下:

命令字节码功能
WRITE_ENABLE0x06写使能,写操作前必须发
READ_STATUS0x05读状态寄存器
READ_DATA0x03读数据(普通读,速率较低)
FAST_READ0x0B快速读,带8个dummy时钟
PAGE_PROGRAM0x02页编程,一次最多256字节
SECTOR_ERASE0x20扇区擦除(4KB)
READ_ID0x9F读JEDEC ID,用来验证通信

操作流程上看,写操作必须先写使能,再擦除、再编程。读数据则随时可以发。驱动这颗Flash,第一步不是急着写读函数,而是发一条READ_ID命令,读回0xEF、0x40、0x17这三个字节,确认通信链路通了再往下走。我见过的调试翻车案例里,有一小半都是跳过这一步直接去读数据,结果数据全是0xFF,绕了一圈才发现是接线问题。

4.2 读ID和读数据的代码实现

读ID的序列很简单:拉低CS,发0x9F,然后连续读三个字节,拉高CS。把之前那个SPI Master模块例化后,控制好指令发送和数据接收即可:

// Read JEDEC ID from W25Q64 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin spi_start <= 1'b0; spi_tx <= 8'h00; state <= CTRL_IDLE; end else begin case (state) CTRL_IDLE: begin // send READ_ID command spi_tx <= 8'h9F; spi_start <= 1'b1; state <= CTRL_SEND_CMD; end CTRL_SEND_CMD: begin spi_start <= 1'b0; if (spi_done) state <= CTRL_READ_BYTE1; end CTRL_READ_BYTE1: begin spi_tx <= 8'h00; // dummy spi_start <= 1'b1; state <= CTRL_WAIT_BYTE1; end // ... wait for done, store spi_rx into id_reg[15:8] endcase end end

这样做的本质是把每次SPI Master的一次8位传输当成一个原子操作,上层控制器按器件手册命令序列一步步调用它。好处是逻辑清晰,可维护性强,缺点是需要几十个时钟周期才能完成一次多字节交换,但对Flash这种本身就不追求单字节吞吐的器件完全够用。

4.3 为什么读出来的数据老是FF

如果你驱动W25Q64,读ID得到0xFF 0xFF 0xFF,或者在仿真里看到您读出来的寄存器值全为1,先不要急着怀疑代码逻辑。这个问题有相当概率是以下几个原因:

  • CS没有真正拉低,或者拉低前SCLK已经有跳变。某些从机对时序要求严格,CS下降沿之后必须过一段时间才能来第一个时钟沿。解决方法是SETUP状态里等待几个系统时钟周期再开始发数据。
  • SCLK频率太高,从机来不及响应。尤其用杜邦线连接开发板时,10MHz以上就很容易出问题。降低分频系数再试,往往立刻就好。
  • 同步问题:从MISO采回来的数据没有打两拍做同步处理,亚稳态导致采样错误。FPGA里跨时钟域信号一定要同步。
  • 命令发错或位序不对:W25Q64默认MSB first,如果你的Master模块配了LSB_FIRST,那命令就完全反了。

读ID这个操作,其实就是一个“通信自检”。把这条链路打通了,后面读数据和写数据都只是换命令字节、按状态机走一遍的机械动作,没有本质难度。

5. 仿真验证:先把波形对齐再上板

5.1 testbench怎么写

FPGA开发里最划算的投入就是仿真。SPI模块尤其如此——SPI的时序是确定的,仿真完全能把问题暴露干净。

testbench的思路:例化SPI Master模块,用一个模拟从机模型去响应它。这个从机模型不需要真的模拟W25Q64的全部功能,只需要在收到特定命令后回几个字节,比如收到0x9F就回0xEF、0x40、0x17。

下面是模拟从机响应读ID的关键写法示意:

// Simulated W25Q64 for testbench always @(negedge sclk) begin if (!cs_n) begin if (recv_bits < 8) begin recv_data <= {recv_data[6:0], mosi}; recv_bits <= recv_bits + 1; end else begin // shift out response bytes based on received command if (recv_data == 8'h9F) begin miso_sim <= id_data[7-id_byte_cnt*8 -:8]; end end end end

写testbench要注意:MISO的驱动时机要和从机真实的响应时机对齐,不能想当然地随手赋值,否则仿真把Master的时序验证得再漂亮,上板还是对不上。

5.2 看波形时的几个重点

仿真跑起来之后,波形上重点看三块:

  • CS相对SCLK的位置:CS拉低后到第一个SCLK上升沿之间有没有足够的建立时间,CS拉高前最后一个沿之后MOSI有没有保持住。
  • 发送与采样的边沿错位:MOSI从在一个沿变化,数据在另一个沿被采样,两者不冲突。
  • MISO采样的位置:把光标放在SCLK采样沿,确认此时MISO已稳定,而不是刚好在跳变。

如果仿真波形里这三点都正常,上板出问题的概率会小很多。反过来,如果仿真里波形就是糊的,那一定不是板子的问题,先把逻辑改对再说。

5.3 时序违例问题排查

综合实现后有时会看到时序违例的报告,多半出现在SCLK的分频逻辑或者MISO的采样路径上。处理思路:

  • 查看违例路径:如果是SCLK网络上的NET routing延迟过大,考虑给SCLK加BUFG或者直接用ODDR输出到引脚。
  • 如果违例出现在MISO采样路径上:说明从MISO引脚到寄存器的组合逻辑太长,插入一级寄存器做同步,相当于把采样延迟一个系统时钟周期,从协议上完全可接受。
  • 不要为了“消灭违例”而盲目降频,把SCLK从20MHz降到1MHz,虽然UNGET 时序报告变绿了,但性能没意义。先分析违例发生在哪些路径上,再针对性修复。

6. 板级调试中的坑与排查技巧

6.1 常见问题速查表

现象可能原因排查方法
读ID全FFCS未拉低/接线错误逻辑分析仪或示波器查CS波形
读ID全00SCLK没来/数据一直是0量SCLK引脚有无时钟
数据少一位发送/采样沿错位查最后一个bit的处理
偶发性错误信号完整性问题降频、加滤波电容
挂上从机后其他功能异常引脚复用/上拉冲突查引脚分配表
仿真正常上板异常电平标准不匹配检查bank电压和IO standard

这张表是我多年调试经验的浓缩。其中“仿真正常上板异常”最折磨人,大部分情况下不是逻辑问题,而是电平标准或引脚物理连接问题。比如FPGA bank电压是2.5V,而Flash电源是3.3V,电平不匹配时逻辑高位读成低位,数据必然错。

6.2 片选信号:硬件片选和软件片选的选择

热词里多次提到“硬件片选与软件片选”,这是个很实际的设计抉择。硬件片选是FPGA通过专门的片选引脚直接控制从机CS,响应快、时序可控。软件片选则是用GPIO配合逻辑模拟,本质也是引脚控制,但可以更灵活地分配给任意引脚。

一个典型的场景是多个从机共享SPI总线。硬件片选的方案是给每个从机分配独立GPIO,谁需要通信就拉低谁的CS;软件片选则是在协议层自己维护一个“当前选中设备”的寄存器,通过内部逻辑切换。

我的建议是:片选尽量用硬件引脚控制,不要用协议层“软选择”。SPI没有地址概念,CS就是唯一的设备寻址手段,如果把它简化成软件逻辑,一旦并发场景下逻辑遗漏,就会同时选中两个从机,总线冲突烧毁器件也不是没可能。尤其FPGA这种并行性很强的平台,天然适合用多路GPIO做多CS控制,没理由去模仿I2C那套地址软选择的玩法。

6.3 SPI速率极限:为什么明明时钟对了还是读到乱码

不少人喜欢把W25Q64跑满33MHz甚至上到40MHz,然后发现读出来的数据偶尔错一个字节。原因可能不在FPGA,而在板级回路电感、走线长度和信号反射。

SPI本身没有均衡和校验机制,一旦高速传输出现误码,接收端毫不知情。这就是为什么工业应用中经常给SPI链路增加CRC校验层,或者用双倍采样/重复发送来提高可靠性。FPGA实现时,你可以做的调速建议是:

  • 先按从机数据手册推荐的最大频率的一半来跑,确认数据稳定后再逐级提速。
  • 如果一定要高频,SCLK尽量走专用时钟引脚,MOSI和MISO走短线,PCB上减少噪声源。
  • 从机端如果有WP和HOLD引脚,必须按手册接好,悬空时设备可能进入误锁存状态。

另外,SPI屏幕刷新率的问题也常被问,其实原理一样:SPI带宽决定了屏幕的理论刷新率上限,但实际能跑多高,取决于总线的稳定性和主控侧是否持续供得上数据。指望SPI驱动TFT跑高帧率,本身就不太现实,这也是这类屏幕通常用于信息显示而不是视频播放的原因。

7. 进阶扩展:从单从机到多从机,再到跨平台移植

7.1 多从机共享SPI总线的设计要点

SPI总线上挂多个从机非常常见,最常见的组合就是FPGA外挂Flash和SD卡,或者屏幕加传感器。这类设计的核心要点是:

  • 管理好CS分配:每个从机独立CS,其余三个信号共享。同一时刻只能有一个CS拉低,否则两个从机同时驱动MISO,总线冲突。
  • MISO是否需要隔离:如果从机在CS拉高时MISO处于高阻态,那就可以直接做线与;如果从机片选失效时MISO仍驱动输出(部分老器件),就需要在三态门上做处理。
  • 地址空间规划:在FPGA内部为不同从机建立不同的逻辑接口,不要让上层模块直接操作总线,否则维护成本会指数级上升。

比如热词里有人问“ESP32屏幕与SD卡共享SPI哪个好”,其实共享SPI的问题不在于哪个从机“优先”,而在于CS切换的效率和共享期间的带宽分配。如果你在做GUI刷新,屏幕占用了大量SPI带宽,SD卡写入就会变得非常慢。解决思路有两种:一个是提高SCLK把总带宽做上去,另一个是把对实时性要求不高的设备挪到另一条SPI总线上。

7.2 SPI与I2C的选择:什么场景用哪个

FPGA项目里,SPI和I2C经常同时出现,选型标准可以简单归纳:

维度SPII2C
速率高(几十MHz常见)低(标准模式100k/400k)
引脚数4+N(N个从机)2(SCL/SDA)
寻址方式CS线选7位地址
多主支持弱有仲裁机制
实现复杂度简单相对复杂

追求速率、从机数量少的场景用SPI,比如驱动Flash、ADC、LCD屏幕。追求引脚节省、速率要求低的场景用I2C,比如挂一些传感器、RTC时钟、EEPROM。FPGA里实现I2C比SPI麻烦得多,因为I2C有协议状态机、应答位和仲裁机制。所以项目里能用SPI解决的,我一般不会主动用I2C。

7.3 从FPGA到单片机:一样的协议,不同的实现逻辑

热词里频繁出现STM32和FPGA实现SPI的对比。很多从单片机转FPGA的人会带着“寄存器操作”的惯性思维,觉得SPI就是一个硬件外设、配置好寄存器就能收发。但在FPGA里,你就是这个“硬件外设本身”。

同样的SPI协议,STM32上SPI外设会自动处理CS时序、自动收发、自动置标志位,代码只是在合适时机读写DR寄存器。FPGA里就需要你亲自设计状态机、分频器、移位寄存器,但换来的是极大的自由度——想做任意时序的SPI、想在收发过程中插入特定延时、想同时驱动多个从机,都是改改逻辑的事。

这个差异是一个“使用者”和“设计者”的视角转换。理解了这一点,你再看SPI就不仅是看懂时序图,而是真正理解它为什么是这个时序。

8. 一点实操体会

做FPGA的SPI通信,说到底是三件事:把协议看懂、把状态机写好、把时序对齐验证透。协议看懂不难,四线两沿的问题翻来覆去就那些;状态机也不复杂,IDLE、TRANSFER、DONE加上分频计数器就撑起了绝大部分需求;真正花时间的是对时序细节的反复推敲和板级调试时的耐心排查。

我个人经验是,SPI模块一定要参数化写,尽量一套逻辑打天下。把CPOL、CPHA、位宽、分频系数做成可配置项,后续接什么芯片都只需要在顶层填参数,不用翻逻辑。还有一点,仿真要当成正式设计的一部分来做,别图省事跳过,SPI这类确定性协议的前期仿真节省的调试时间,比写仿真代码本身多一个数量级。

如果你正准备往FPGA里加SPI功能,或者手头的SPI模块调不通,不妨照着上面的思路从头推一遍:先看时序图确认边沿,再写Master和模拟从机仿真对齐波形,最后上板用逻辑分析仪量CS和SCLK。这三步走稳,SPI基本不会成为你项目里的绊脚石。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询