资讯动态

FPGA实现SPI通信:协议细节、状态机设计到W25Q64驱动全攻略

发布时间:2026/9/9 10:47:01 来源:尧图企业网站定制
做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串行时钟由主机产生MOSIMaster Output Slave Input主机输出从机输入MISOMaster 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 SPIVivado里配置一下就能用。但我的建议是除非是工程时间极度紧张、且确定不需要深改协议否则手写状态机更划算。理由有三第一SPI本身逻辑量很小一个完整Master模块写下来也就一百多行Verilog综合资源几十个LUT不值得为一个这么简单的东西引入一个带AXI接口、带中断控制器的大IP光学配置就要半天。第二IP核灵活性反而差。比如你想实现一个“多发几个字节再判断是否拉高CS”的定制序列IP核的寄存器配置绕来绕去远不如状态机里加一个计数器直接。第三手写状态机让你对时序有完全掌控。上板调试时遇到波形不对仿真和逻辑分析仪一抓你能立刻判断是哪一拍没对齐用IP核一旦出问题黑盒会让你无从下手。2.2 时钟怎么分从系统时钟到SCLKSCLK频率一般由系统时钟分频得到。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 3d0; localparam SETUP 3d1; localparam TRANSFER 3d2; localparam DONE 3d3; 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 1b1; busy 1b0; done 1b0; sclk CPOL; mosi 1b0; rx_data {DATA_WIDTH{1b0}}; end else begin case (state) IDLE: begin done 1b0; if (start) begin busy 1b1; cs_n 1b0; 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 1b1; end end DONE: begin cs_n 1b1; busy 1b0; done 1b1; rx_data shift_reg; state IDLE; end endcase end end endmodule这段代码做了一些简化实际使用时需要补充分频计数器并把SCLK边沿与发送/接收逻辑严格对齐。重点是看状态转移的思路IDLE等启动SETUP给CS拉低后的等待周期TRANSFER逐位收发DONE收尾。这种结构覆盖了绝大多数SPI Flash、ADC、传感器芯片的需求。4. 实战驱动W25Q64 SPI Flash4.1 W25Q64的命令体系和操作流程W25Q64是一颗64Mbit的SPI NOR Flash在很多板卡上用作FPGA的配置文件存储。它的操作命令是标准SPI协议加自定义命令字节常用命令如下命令字节码功能WRITE_ENABLE0x06写使能写操作前必须发READ_STATUS0x05读状态寄存器READ_DATA0x03读数据普通读速率较低FAST_READ0x0B快速读带8个dummy时钟PAGE_PROGRAM0x02页编程一次最多256字节SECTOR_ERASE0x20扇区擦除4KBREAD_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 1b0; spi_tx 8h00; state CTRL_IDLE; end else begin case (state) CTRL_IDLE: begin // send READ_ID command spi_tx 8h9F; spi_start 1b1; state CTRL_SEND_CMD; end CTRL_SEND_CMD: begin spi_start 1b0; if (spi_done) state CTRL_READ_BYTE1; end CTRL_READ_BYTE1: begin spi_tx 8h00; // dummy spi_start 1b1; 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 8h9F) 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引脚数4NN个从机2SCL/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基本不会成为你项目里的绊脚石。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价