FEATURED · 精选文章

FPGA实现SPI通信实战:从时序设计到Flash读写调试

发布时间 / 2026/9/9 11:33:09
来源 / 创域科博编辑部
栏目 / 资讯中心
FPGA实现SPI通信实战:从时序设计到Flash读写调试 刚把一块SPI接口的Flash芯片调通趁着热乎劲还没过把这段时间用FPGA实现SPI通信的思路和踩坑过程整理一下。这篇文章不谈理论空话完全是从实际项目里捋出来的经验涉及时序设计、状态机框架、参数计算还有调试技巧能给正在做或者准备做类似工作的朋友一个实在的参考。SPI这个协议说简单也简单四根线搞定主从通信但真要在FPGA里把它实现得稳定可靠里面门道并不少。尤其当你需要对接不同厂商的从设备、跑较高的时钟频率、或者要应对各种异常时序的时候纯靠“网上抄一段代码就跑”的思路大概率会翻车。这篇文章会从一个完整的SPI主机控制器出发把协议解析、Verilog实现、Flash读写实战、常见问题排查这几块全部过一遍适合有数字电路基础、正在学FPGA或者工作中需要自己写SPI控制器的工程师阅读。1. 内容整体设计与思路拆解1.1 为什么用FPGA实现SPI而不是直接用单片机很多朋友一开始会有个疑问SPI这么成熟的协议STM32、ESP32这些MCU都有硬件外设为什么还要用FPGA来实现这个问题我在项目初期也反复琢磨过实际做下来主要有三个理由。第一是灵活性。MCU的SPI外设虽然好用但引脚复用固定、时序参数受寄存器配置限制遇到非标准的SPI变种比如某些传感器需要极长的空闲时间、某些Flash支持Dual/Quad模式就有点束手束脚。FPGA则完全不一样SPI时序是你自己用代码画出来的想怎么调整就怎么调整。第二是并行处理能力。FPGA可以同时跑多个SPI控制器分别挂载不同速率的从设备互不干扰这对多传感器数据采集系统特别友好。第三是高速与确定性。FPGA的时钟管理单元能让SPI时钟做到非常精准时序抖动小而且每个时钟周期做什么都是确定的不存在中断延迟问题这对有严格时序要求的场景很重要。但话说回来FPGA实现SPI也有自己的代价所有逻辑都要自己写、自己验证开发周期比MCU调库要长。所以我的建议是如果只是简单挂一两个低速设备用MCU更省事如果涉及多路、高速、特殊时序或者需要和FPGA内部其他逻辑深度协同那就值得用FPGA来干这件事。1.2 SPI协议核心要点回顾在写代码之前先把SPI协议的几个关键概念捋清楚。SPI全称Serial Peripheral Interface是一种同步串行通信接口主设备产生时钟信号从设备被动响应。整个通信涉及四根线SCLK串行时钟、MOSI主出从入、MISO主入从出、CS片选低电平有效。SPI最容易被搞混的地方就是四种工作模式由CPOL时钟极性和CPHA时钟相位组合决定。CPOL决定空闲时SCLK的电平CPOL0时空闲低电平CPOL1时空闲高电平。CPHA决定数据采样时刻CPHA0时在第一个跳变沿采样CPHA1时在第二个跳变沿采样。四种模式就是这两种参数的四种组合信号时序有微妙但关键的区别。模式CPOLCPHA采样沿移位沿Mode 000上升沿下降沿Mode 101下降沿上升沿Mode 210下降沿上升沿Mode 311上升沿下降沿实际项目中绝大多数SPI Flash、SD卡、传感器都工作在Mode 0或Mode 3。比如经典的W25Q64 Flash默认支持Mode 0和Mode 3这两种模式在SCLK空闲电平和采样沿上刚好相反但都能正常工作关键是要在初始化时配对好。我自己习惯统一用Mode 0减少思维负担除非从设备手册明确要求其他模式。另一个容易忽视的点是MSB还是LSB先行。大部分设备是MSB先行但也有一些外设比如某些音频芯片用LSB先行写代码前一定要看数据手册确认。这个要是搞反了数据读回来全是反的而且很难排查。1.3 硬件片选与软件片选的取舍从热搜词里注意到不少人在问SPI硬件片选和软件片选的问题。这个在FPGA实现里同样存在而且比MCU场景更需要提前想清楚。所谓硬件片选就是把CS信号交给专门的逻辑管理通常由状态机在事务开始时自动拉低、在事务结束时自动拉高。所谓软件片选则是由上层逻辑或微处理器在发起一次SPI传输前手动拉低CS并在预期长度的事务完成后手动拉高CS。在FPGA实现里我的建议是用状态机托管硬件片选但保留软件控制事务长度的能力。为什么因为SPI协议的很多操作不是简单的一个字节交换比如Flash的读操作需要先发命令字节、再发地址字节然后才能读数据整个过程中CS必须保持低电平。如果片选逻辑只支持单字节事务这种多阶段命令就没办法实现。所以比较合理的做法是设计一个可配置事务长度的SPI控制器CS由控制器根据内部计数器自动控制但事务的启动和长度由外部逻辑指定这样既保证了CS时序的确定性又保留了灵活性。2. 核心细节解析与实操要点2.1 SPI控制器的整体架构设计一个完整可用的SPI主机控制器我习惯把它拆成三个层次物理层、协议层、应用层。物理层负责最基础的引脚时序包括SCLK的生成、MOSI数据的移位输出、MISO数据的移位输入、CS的拉低拉高。这个层的核心是一个状态机和一个移位寄存器。协议层负责把“读取从设备某个寄存器”“向从设备写入一段数据”这类操作翻译成物理层能执行的字节序列。这一层做的事情通常是组织命令帧、解析响应数据、处理CRC等。应用层则是最贴近业务的比如你要在Flash的某个地址写入一页数据应用层调用协议层的“页编程”接口传入地址和数据即可。个人经验把这三层在代码结构上分开写比把所有逻辑揉在一段always块里要容易调试得多。你可以分模块去验证物理层单独仿真、协议层可以用简单的从设备模型做仿真应用层再逐步对接真正的芯片。2.2 系统时钟与SPI时钟的分频设计FPGA的SPI控制器通常不直接使用系统时钟当SCLK而是要通过分频产生。分频方式有两种整数分频和小数分频NCO。整数分频最常用也最容易理解。假定系统时钟是50MHz目标SCLK是10MHz那么分频系数就是5。用计数器每数到5翻转一次SCLK就行。但这里有个细节如果你直接用计数器翻转法产生SCLK数据移位和采样的边沿控制要尤其注意否则容易产生亚稳态或者采样点不对的情况。我推荐的做法是在主时钟域里用一个计数器生成一个“分频使能脉冲”当脉冲有效时认为SCLK完成一次电平翻转。这样整个控制逻辑仍然工作在系统主时钟域只是通过使能信号来节拍化推进数据采样和移位都基于这个使能脉冲展开逻辑会更干净。举个例子如果系统时钟50MHz想得到10MHz的SCLK那么分频系数DIV5。计数器从0计到4再回到0每个计数周期产生一个shift_enable脉冲用这个脉冲驱动状态机执行一次移位操作。SCLK信号本身由这个使能脉冲的状态交替产生高低电平即可。如果目标SCLK频率比较怪异比如3.6864MHz整数分频可能有点尴尬。此时可以考虑使用NCO方式累加器控制翻转时刻精度相对更高但实现复杂度也上去了。实际项目里没有特殊要求的话选择整数分频能整除的结果就够了省心又稳定。2.3 数据采样与边沿对齐最容易出错的地方SPI时序里面最容易出错的就是数据变化沿和采样沿的配合关系。要在RTL设计里把这个关系想清楚否则代码一上板就会遇到随机性数据错误。以Mode 0为例。Mode 0规定SCLK空闲为低主机在SCLK下降沿也就是第一位移出的时刻改变MOSI从机在SCLK上升沿采样数据。反过来从机在SCLK下降沿把MISO数据送出来主机要在SCLK上升沿采样MISO。那么在FPGA实现里你要保证的是所有数据移位操作都在“低→高”变化前完成所有采样操作都在“低→高”变化后的稳定窗口内完成实际操作中我习惯用系统时钟打一拍让采样信号延后半个SCLK周期这样能保证采到的是稳定的数据线上信号而不是边缘处的危险区域。具体说就是当检测到采样沿来临的时候不立即采样而是等一个系统时钟周期再采样。这种方法简单有效代价只是一位数据的延迟换取的是极高的稳定性。还有一个常被忽视的细节跨时钟域处理。如果MISO信号来自一个和你FPGA系统时钟完全异步的外部设备在FPGA里直接采样有亚稳态风险。稳妥做法是先把MISO用两级触发器同步一下再做边沿检测和采样。虽然会引入两拍延迟但对SPI这种低速协议来说完全可接受。3. 实操过程与核心环节实现3.1 基于状态机的SPI主机控制器Verilog实现下面给出一个我在项目中实际使用的SPI主机控制器核心代码。为了保持篇幅可控这里展示的是核心状态机和移位逻辑部分。这个设计支持可配置的字节长度和事务长度兼容Mode 0 / Mode 3。module spi_master #( parameter CLK_FREQ 50_000_000, parameter SCLK_FREQ 10_000_000, parameter CPOL 0, parameter CPHA 0, parameter DATA_WIDTH 8 )( 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 DIV_CNT CLK_FREQ / SCLK_FREQ / 2; localparam IDLE 3d0, SETUP 3d1, SHIFT 3d2, FINISH 3d3; reg [2:0] state; reg [15:0] clk_cnt; reg [2:0] bit_cnt; reg [DATA_WIDTH-1:0] tx_buf; reg [DATA_WIDTH-1:0] rx_buf; reg sclk_r; reg sample_en; reg [1:0] miso_sync; assign miso_synced miso_sync[1]; always (posedge clk or negedge rst_n) begin if (!rst_n) begin miso_sync 2b0; end else begin miso_sync {miso_sync[0], miso}; end end always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; sclk_r CPOL; cs_n 1b1; mosi 1b0; done 1b0; bit_cnt 3b0; clk_cnt 16b0; tx_buf {DATA_WIDTH{1b0}}; rx_buf {DATA_WIDTH{1b0}}; rx_data {DATA_WIDTH{1b0}}; sclk CPOL; end else begin done 1b0; case (state) IDLE: begin cs_n 1b1; clk_cnt 16b0; if (start) begin cs_n 1b0; tx_buf tx_data; bit_cnt 3b0; state SETUP; end end SETUP: begin if (CPHA 0) begin mosi tx_buf[DATA_WIDTH-1]; state SHIFT; end else begin state SHIFT; end end SHIFT: begin clk_cnt clk_cnt 1b1; if (clk_cnt DIV_CNT - 1) begin clk_cnt 16b0; sclk_r ~sclk_r; sample_en 1b1; end else begin sample_en 1b0; end if (sample_en) begin if ((CPOL 0 CPHA 0) || (CPOL 1 CPHA 1)) begin if (sclk_r 1b0) begin // 下降沿前一拍更新MOSI mosi tx_buf[DATA_WIDTH-1]; rx_buf {rx_buf[DATA_WIDTH-2:0], miso_synced}; tx_buf {tx_buf[DATA_WIDTH-2:0], 1b0}; if (bit_cnt DATA_WIDTH-1) bit_cnt 3b0; else bit_cnt bit_cnt 1b1; end end else begin if (sclk_r 1b1) begin mosi tx_buf[DATA_WIDTH-1]; rx_buf {rx_buf[DATA_WIDTH-2:0], miso_synced}; tx_buf {tx_buf[DATA_WIDTH-2:0], 1b0}; if (bit_cnt DATA_WIDTH-1) bit_cnt 3b0; else bit_cnt bit_cnt 1b1; end end end if (bit_cnt DATA_WIDTH-1 clk_cnt DIV_CNT/2) begin state FINISH; end end FINISH: begin sclk_r CPOL; cs_n 1b1; rx_data rx_buf; done 1b1; state IDLE; end endcase end end assign sclk sclk_r; endmodule这段代码的核心思路是分频计数器管理SCLK翻转sclk_r保存当前的时钟极性sample_en信号指示采样窗口。在SHIFT状态下用sample_en脉冲去决定何时移出数据、何时采集数据。因为加了MISO两级同步采样到的数据相对稳定。这里有几个地方需要重点解释说明。第一DIV_CNT的计算方式是CLK_FREQ / SCLK_FREQ / 2这是为了生成50%占空比的SCLK。系统时钟每计数到半个SCLK周期就翻转一次所以除以2。第二状态机里的FINISH状态会在最后一个bit传输完成后自动进入并且恢复CS为高。这个设计保证了即使上层逻辑没有及时处理done信号CS时序也不会出错。第三如果你需要的是多字节事务比如读Flash要先发命令和地址实际使用时不会直接调用这个单字节控制器而是用一个上层状态机把多个单字节事务串起来。每发完一个字节控制器都会拉高CS一次。那怎么办我在下一节的Flash读写案例里给出了解决方案核心是在上层扩展一个“连续事务模式”。3.2 读写W25Q64 Flash的完整实战结合热搜词里多次出现的“SPI读写W25Q64”我用这个最常见的SPI Flash来演示怎么把单字节控制器扩展成实际可用的读写接口。W25Q64是一款容量为8MB的SPI NOR Flash支持标准SPI、Dual SPI和Quad SPI。标准SPI模式下发送命令、地址和数据都是8位大量应用都跑在Mode 0或Mode 3。它有几个常用的命令0x90读ID、0x06写使能、0x03读数据、0x02页编程、0x20扇区擦除。要在FPGA里读Flash最直接的办法是写一个封装状态机把读操作分解成连续发送几个字节的事务。module w25q64_read #( parameter ADDR_WIDTH 24 )( input wire clk, input wire rst_n, input wire read_start, input wire [ADDR_WIDTH-1:0] read_addr, output reg [7:0] read_data, output reg read_done, // SPI接口 output reg spi_start, output reg [7:0] spi_tx, input wire [7:0] spi_rx, input wire spi_done ); localparam CMD_READ 8h03; reg [2:0] state; reg [1:0] byte_cnt; localparam SEND_CMD 3d0, SEND_ADDR 3d1, READ_DATA 3d2, WAIT_DONE 3d3; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state SEND_CMD; byte_cnt 2b0; spi_start 1b0; read_data 8b0; read_done 1b0; end else begin read_done 1b0; case (state) SEND_CMD: begin spi_tx CMD_READ; spi_start 1b1; state WAIT_DONE; end WAIT_DONE: begin spi_start 1b0; if (spi_done) begin if (byte_cnt 0) begin byte_cnt 2d1; state SEND_ADDR; end else if (byte_cnt 1) begin byte_cnt 2d2; state SEND_ADDR; end else if (byte_cnt 2) begin byte_cnt 2d3; state SEND_ADDR; end else begin byte_cnt 2b0; state READ_DATA; end end end SEND_ADDR: begin case (byte_cnt) 2d1: spi_tx read_addr[23:16]; 2d2: spi_tx read_addr[15:8]; 2d3: spi_tx read_addr[7:0]; endcase spi_start 1b1; state WAIT_DONE; end READ_DATA: begin spi_start 1b1; state WAIT_DONE; end endcase if (state WAIT_DONE spi_done byte_cnt 2b0) begin if ( {state, byte_cnt} {WAIT_DONE, 2d3} ) begin read_data spi_rx; read_done 1b1; end end end end endmodule这个上层封装演示了把“命令 3字节地址 读数据”组合成完整事务的方法。由于每次调用底层单字节控制器后CS都会被拉高对于W25Q64的标准读操作来说其实是可以的——标准读支持任意时刻拉低CS重新发起操作没有状态残留问题。但要注意如果是连续读0x03命令后连续读多个字节或者页编程0x02命令一次性写256字节CS必须在整个操作期间保持低电平。这种情况下单纯拼凑单字节控制器就不够用了。解决办法有两个方向在底层控制器里增加一个“连续模式”由上层指定本次事务的字节总数控制器内部连续移位CS保持拉低直到所有字节发送完毕。在保持CS低的过程中不断向控制器喂数据。这个需要控制器接口设计成可流式输入输出。我实际项目里使用的是第一种方案把spi_master增加一个transfer_len端口事务开始时锁存总长度内部用计数器判断什么时候拉高CS。改造起来不算复杂但能覆盖绝大多数SPI从设备的操作要求。3.3 时序参数计算方法与验证手段SPI时序设计里有一堆参数要关心SCLK频率、建立时间、保持时间、CS到SCLK的间隔、事务结束后的空闲时间。如果你用的是成熟芯片比如W25Q64数据手册里这些参数都写得明明白白照着满足就行。以W25Q64为例手册规定最大SCLK频率是104MHz实际因走线质量而异我通常保守设计到50MHz以内CS下降沿到第一个SCLK上升沿至少要有几十纳秒的建立时间最后一个SCLK沿到CS上升沿也有类似要求。FPGA实现里怎么满足最简单的办法是在状态机里增加额外的等待周期。还是拿我的控制器举例。代码里从IDLE状态进入SETUP状态时CS已经先拉低了然后才产生SCLK。如果DIV_CNT够大这个过程天然满足建立时间。但如果你SCLK跑得很快比如50MHz一个SCLK周期才20ns可能不够某些从设备的建立时间要求。这时候可以在SETUP状态里插入几个空闲时钟周期比如增加一个CS_SETUP_CNT参数在拉低CS后等N个周期再开始翻转SCLK。这个N根据实际芯片手册计算公式很简单N t_CS_SCLK / t_clk再向上取整。验证时序的手段我推荐三种ModelSim仿真、片上逻辑分析仪ILA、示波器实测。ModelSim仿真最快速尤其适合验证协议逻辑正确性。我会写一个简单的SPI从设备仿真模型把自己的控制器挂上去跑读写事务检查数据一致性。仿真模型不用太复杂一个移位寄存器加若干延迟即可。片上逻辑分析仪ILA是调试利器。把SCLK、CS、MOSI、MISO和关键状态信号抓出来看能够直观地看到时序是否满足预期。特别是在跑真实Flash时ILA能看到具体的命令时序对照数据手册逐项检查。示波器实测是终极手段。重点看SCLK的上升下降时间是否过慢、信号过冲是否严重、CS与SCLK的相对位置是否正确。有一次我在高云FPGA上调试SCLK全速跑50MHz时波形塌陷严重后来发现是板级走线过长且没有串阻降频到20MHz后一切正常。这种问题靠仿真和ILA都发现不了只能靠示波器。4. 常见问题与排查技巧实录4.1 典型问题速查表用FPGA实现SPI通信时遇到的绝大多数问题都有规律可循。我把实际项目中踩过的坑和身边同事遇到的典型问题整理成一张速查表方便定位。现象可能原因排查思路读回数据全FF或全00片选时序不对、模式配置错误检查CS是否在整个事务期保持低电平核对CPOL/CPHA数据偶尔错位偶尔正常采样点刚好落在数据变化沿上MISO加同步打拍调整采样沿或调整时钟频率同一套代码在自己板子上正常换板子就挂板级走线阻抗不一致、地弹降频测试检查SCLK/CS走线的串阻、电容与从设备握手超时时钟分频系数算错、事务长度配置错误用ILA抓SCLK频率和CS时序对比手册从设备能写不能读或能读不能写写使能命令未发送、状态寄存器轮询缺失检查Flash命令序列是否先发0x06写使能是否等待WIP位清零SPI时钟有毛刺或不完整脉冲分频逻辑在异步复位释放期间出错确保计数器在复位后初始化为确定值避免在counter溢出时产生尖峰4.2 数据错位的经典故障定位案例有段时间我调试一块ADC芯片SPI读回来的数据老是隔几位出现一个字节错位。最开始怀疑是模式配置错了反复确认是Mode 0没问题又怀疑是数据线连接虚焊重新焊了一遍还是老样子。后来我用ILA把全部的信号抓出来一帧一帧对比ADC数据手册上的时序图才发现问题出在采样时刻选择上。我的控制器是在检测到SCLK上升沿的同一拍里完成MISO采样和移位这意味实际采样发生在上升沿的边沿位置而MISO在上升沿之后还有一小段保持时间才稳定于是采到的是不确定电平。修复方案就是我前面提到的先在采样使能到来时把MISO同步打两拍存到中间寄存器等到下一个系统时钟周期再把它推进移位寄存器。这个“延迟一拍采样”的小技巧解决了后续几乎所有SPI数据错位问题。如果你也遇到间歇性数据错误强烈建议先检查这一条。4.3 SCLK频率上限怎么定很多朋友喜欢一上来就把SCLK拉满觉得越跑快越好。实际上SCLK的极限受多个因素制约从设备手册规定的上限FPGA引脚输出驱动能力和压摆率板级走线长度与传输线效应主控状态机在极限频率下能否稳定采样综合下来我给初学者的建议是先把功能跑通频率放在10MHz以内验证正确后再逐步提频。10MHz下50MHz系统时钟有5倍分频系数计数器逻辑不紧张时序也相对宽松大多数从设备都能正常工作。等你把整个通信链路验证扎实了再通过ILA实测波形来判断能不能往上冲。如果确实需要高速SPI比如要驱动高分辨率显示屏或者高速ADC建议从这几个方面同时优化缩短PCB走线、在SCLK/MOSI上串33Ω电阻、选用压摆率更高的输出引脚、主控逻辑用流水线重定时减少组合逻辑延迟。4.4 从设备上电时序与复位处理还有一个经常被忽略但实际很致命的问题从设备上电后需要一段时间稳定。尤其是一些内置晶振的传感器或带内部稳压的芯片上电瞬间可能还没准备好接收SPI命令。如果你的FPGA在配置完成后立刻发起SPI访问大概率会失败。解决办法是在顶层设计中加入固定的上电延时。最简单的方法是配置FPGA启动后先等待若干毫秒再开始业务逻辑或者使用一个计数器产生延时信号只有延时完成才允许SPI控制器退出复位状态。对于W25Q64这类Flash手册通常会给出上电延迟时间照做就行。另外要注意从设备的复位引脚。如果复位引脚一直处于无效状态或者悬空芯片可能根本没有正常工作。之前调试某款射频收发芯片时SPI一直无响应折腾了半天才发现是复位引脚没拉高处理完之后一切正常。SPI不响应时先检查复位、供电、时钟这几个基础条件再怀疑协议问题。4.5 调试工具链推荐最后聊聊调试工具。FPGA调试SPI我常用的一套组合是Vivado / Quartus / Gowin配套逻辑分析仪ILA / SignalTap / 高云LA抓内部信号解决协议逻辑问题Saleae逻辑分析仪或者兼容型号抓外部引脚波形核实时序是否符合预期示波器查信号质量、沿陡峭度、过冲振铃串口小助手 USB转TTL把SPI读到的数据实时打印出来快速确认读写结果是否正确其中逻辑分析仪的重要性怎么强调都不为过。很多人调SPI靠猜今天改个模式、明天改个分频运气好能调通运气不好就蒙圈了。我的建议是学会看波形把“觉得不对”变成“知道哪里不对”。你在ILA里把整段事务抓下来逐周期对照数据手册90%的问题当场就能看出来。5. 从掌控时序到掌控系统做完SPI控制器和W25Q64读写这个项目后最大的感受是SPI这个协议虽然简单但把FPGA实现做扎实了验明正身的东西其实是“时序看得见”的本事。当你能够在波形上准确指出每一个字节是在哪个时刻被采样、CS和SCLK之间的间隔是不是足够、数据变化沿和采样沿有没有打架很多之前玄学一样的“偶发故障”会瞬间变得清晰起来。后续我打算在这个SPI控制器的基础上继续扩展一是把Dual/Quad SPI模式加上让Flash读取带宽翻几倍二是把控制器接到一个简单的CPU总线上让软核处理器可以通过寄存器读写来访问SPI外设三是把常见从设备传感器、ADC、SD卡的驱动模块沉淀成复用IP方便以后的项目直接调用。SPI这个东西做一次觉得平常做透一次你会对整个数字系统的时序思维都有新的理解。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻