FPGA实现SPI通信:协议解析、Verilog代码与调试实战
1. 为什么要在FPGA上实现SPI从一次真实项目说起1.1 那颗把时序卡死的ADC我最早在FPGA里正经写SPI控制器是因为一颗16位SAR ADC。这颗芯片的SPI接口要求主机在转换完成后一个很窄的窗口内把数据连续读走否则下一轮转换会覆盖之前的结果。当时项目主控是MCUSPI硬核同时还要服务一片SPI Flash和另外两片ADC中断轮询模式下最差情况会出现几百纳秒的响应抖动。就是这微不足道的几百纳秒让ADC采样数据偶尔丢一个点。换成FPGA之后问题立刻消失。因为FPGA里实现的SPI Master时序是完全确定的多少个时钟周期启动、第几拍拉低CS、哪个边沿采样全部预先算好不受中断优先级和软件调度影响。这是SPI通信放在FPGA里和MCU外设之间最本质的区别——MCU是“尽量快去响应”FPGA是“固定周期内必定完成”。这个例子想说明的是FPGA实现SPI不是让你用逻辑重新发明一遍单片机外设而是为了在需要确定性、并发性或者非标准时序的时候把SPI这个协议真正握在自己手里。这篇文章就是围绕这件事展开的协议细节、Verilog实现、多设备挂载、调试排错一路讲到我实际项目里验证过的写法。1.2 SPI在FPGA项目里最常见的几类位置我梳理一下平时在FPGA开发里见到SPI最多的场景你可以对照自己的项目看看有没有落在这几个方向里多路ADC/DAC数据采集FPGA做采样控制和实时处理SPI是模拟前端和FPGA之间的数据通道。多个通道需要同步采样时MCU串行外设一个个轮询很难满足。FPGA配置与存储FPGA上电加载Flash、软核运行程序时的Flash读取基本都是SPI或QSPI接口。很多板子上不止一片Flash时序仲裁也得FPGA自己来。显示屏与传感器ST7789这类SPI屏、温湿度传感器、气压计、工业绝对编码器等FPGA内部的主控逻辑通过SPI和它们交换数据。FPGA和处理器之间的板级通信两个芯片之间传控制状态、传小批量实时数据SPI因为线少、简单、容易时序收敛经常被用作低成本互连方案。这些场景里SPI很少是项目里最出彩的模块但它一旦不稳定整个链路都会出问题。我见过太多人调试SPI的时间比写核心算法还长所以这篇文章把协议、代码、调试一次讲明白希望你能绕开我踩过的那些坑。1.3 SPI和IIC的差异决定了两者的设计思路完全不同很多人会把SPI和IIC放在一起对比这在FPGA实现层面是个很关键的区别。SPI是全双工主机发数据的同时从机也在回数据而且没有应答机制。IIC是半双工靠地址、ACK和时钟拉伸来管理链路实现起来状态更多总线冲突检测也更麻烦。FPGA实现IIC通常需要更复杂的位级状态机SPI则更像一条纯粹的移位数据流只要把CS和SCLK的时序卡准几乎不需要考虑仲裁问题。所以我做接口选型时的顺序是能选SPI就优先SPI尤其是FPGA做从机或者和转换器打交道的时候。SPI少掉“地址仲裁”这一层复杂度调试成本明显更低。2. SPI协议的四件事模式、速率、位序和片选代码不会骗人但协议理解错了代码写得再漂亮也没用。所以在写Verilog之前先把SPI协议的几个关键点过一遍。2.1 四根线和一次完整事务标准SPI有四根线SCLK、MOSI、MISO、CS。有些器件会把MOSI和MISO合并成一根双向线有些还会把CS拆成两根但绝大多数场景还是四线形态。一次完整事务可以拆成三个阶段CS拉低标志着事务开始主机和从机配合SCLK一位一位交换数据CS拉高标志着事务结束。CS拉低到第一个SCLK有效边沿之间的窗口是建立时间最后一个SCLK边沿到CS拉高之间的窗口是保持时间这两个参数在很多从设备手册里都有明确要求。FPGA实现时我习惯在状态机里显式留出至少一个SCLK周期的余量而不是让CS和第一个边沿发生在同一拍。这样能兼容市面上绝大多数从设备。2.2 四种模式一张表加两个判断技巧SPI四种模式由CPOL和CPHA两个参数区分CPOL决定SCLK空闲电平CPHA决定采样边沿。模式CPOLCPHA空闲SCLK数据变化沿采样边沿模式000低下降沿上升沿模式101低上升沿下降沿模式210高上升沿下降沿模式311高下降沿上升沿这个表几乎每篇SPI文章都有但我想补充两个实际判断技巧第一个技巧是模式0最常用很多FPGA板卡自带的ADC、Flash、OLED屏都工作在模式0。新项目可以先按模式0设计出问题再查手册改。第二个技巧是不要从代码里去猜模式拿逻辑分析仪抓一下SCLK和MOSI把两个bit之间的对应关系画出来自然就知道是采样边沿选错了还是数据变化边沿选错了。2.3 模式0的典型时序以模式0为例SCLK空闲低电平主机在SCLK下降沿把数据更新到MOSI上从机在上升沿采样反过来从机在下降沿把下一bit更新到MISO上主机在上升沿采样。这个“下降沿变化、上升沿采样”的节奏是SPI最常见的。写FPGA状态机时我的思路是内部用分频计数产生一个高低交替的时钟使能信号再把这个信号的上升沿和下降沿转换成两个单周期脉冲。上升沿触发“采样输入”下降沿触发“更新输出”。这样就把SPI的物理时序转成了普通同步逻辑程序看起来更直观也更容易约束。2.4 位序芯片手册里最容易忽略的坑SPI协议本身没有规定数据必须MSB在前还是LSB在前。Flash、ADC绝大多数是MSB first但SPI屏、部分传感器经常是LSB first甚至有些器件还有寄存器可以切换位序。如果调试时发现读回来的数值完全反向优先查位序。FPGA里处理位序非常灵活只需要把移位寄存器的方向反过来就行。真正花时间的是确认设备手册到底要哪种顺序。位序错了之后模式、分频、引脚配置全对读回来的数据也是稳定错误的这种问题用ILA一抓就能看出来但不去往位序上想会绕很久。2.5 SCLK频率怎么定分频计算和余量思维SCLK不是越高越好。要考虑从设备支持的最大SCLK、板级线长和寄生电容、以及主机在目标频率下能否稳定满足建立保持时间。实际工程里我习惯按从设备最大SCLK的一半作为起步值跑通后再逐步往上提。分频系数计算很简单系统时钟频率除以目标SCLK频率得到分频倍数。比如系统时钟100MHz目标SCLK是10MHz分频系数就是10。代码里做占空比50%的分频也就是计数到5翻转一次。我一般尽量选偶数分频系数因为奇数分频会让SCLK变成非50%占空比大多数设备没影响但少数芯片对高电平和低电平的最短时间分别有要求排查时容易头晕。3. 一个通用SPI Master的Verilog实现3.1 写代码前先想清楚需求在动手写SPI控制器之前我习惯先列需求数据宽度固定8位还是可配置是否需要连续多字节传输时钟频率是固定还是需要运行时调节极性相位是固定还是支持切换把这些定下来再决定用什么样的模块结构和参数设计。对通用场景我更推荐做一个轻量参数化Master把DATA_WIDTH、SCK_DIV、CPOL、CPHA作为参数暴露出来而不是在代码里把模式写死。这样从一个项目复用到另一个项目只需要改例化参数不需要动RTL。3.2 SCLK生成用内部脉冲而不是独立时钟FPGA设计里一个常识又是重灾区的问题不要在内部把sclk直接当触发器的时钟使用。原因很简单内部翻转的时钟会让综合工具很难约束也会在布局布线后引入明显的时钟偏斜风险。我的做法是生成一个div_cnt分频计数器控制sclk_r的高低翻转然后用sclk_r和它延迟一拍后的旧值算出边沿脉冲wire sclk_rise sclk_r !sclk_prev; wire sclk_fall !sclk_r sclk_prev;这样所有寄存器仍然由系统全局时钟统一触发sclk只是一个输出到引脚上的波形逻辑内部靠“边沿脉冲”来决定何时移入移出数据。代价只是逻辑多了一个判断沿有效的if条件但在现代FPGA上这点开销可以忽略。3.3 握手信号设计Master和FPGA内部其他模块之间我通常用一组简单握手信号完成交互。start是发起请求信号外部逻辑准备好tx_data后拉高startMaster检测到start有效且自身空闲拉低CS进入传输状态同时busy拉高传输完成后busy拉低done给出一个周期的高脉冲rx_data输出收到的数据。这套接口很干净可以直接接状态机、接FIFO或者包一层寄存器映射给CPU访问。在Zynq这类SoC里也可以把这组合手信号改成AXI-Lite寄存器后面第6节我会细说。3.4 示例代码模式0的核心状态机下面是一段可以直接仿真和上板的核心代码为了看起来清楚我把状态精简成三个分频模块和外围接口都在状态机的数据通路逻辑是完整的。module spi_master #( parameter DATA_WIDTH 8, parameter SCK_DIV 16, 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 wire sclk, output reg mosi, input wire miso, output reg cs_n ); reg [15:0] div_cnt; reg sclk_r; reg sclk_prev; wire sclk_rise sclk_r !sclk_prev; wire sclk_fall !sclk_r sclk_prev; reg [DATA_WIDTH-1:0] tx_shift, rx_shift; reg [$clog2(DATA_WIDTH)-1:0] bit_cnt; reg [1:0] state; localparam IDLE 2b00; localparam TRANSFER 2b01; localparam FINISH 2b10; always (posedge clk or negedge rst_n) begin if (!rst_n) begin div_cnt 0; sclk_r CPOL; end else begin if (div_cnt (SCK_DIV/2 - 1)) begin div_cnt 0; sclk_r ~sclk_r; end else begin div_cnt div_cnt 1; end sclk_prev sclk_r; end end assign sclk sclk_r; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; cs_n 1b1; mosi 1b0; busy 1b0; done 1b0; rx_data {DATA_WIDTH{1b0}}; tx_shift {DATA_WIDTH{1b0}}; rx_shift {DATA_WIDTH{1b0}}; bit_cnt 0; end else begin case (state) IDLE: begin done 1b0; if (start) begin tx_shift tx_data; bit_cnt 0; cs_n 1b0; busy 1b1; state TRANSFER; end end TRANSFER: begin if (sclk_fall) begin mosi tx_shift[DATA_WIDTH-1]; tx_shift {tx_shift[DATA_WIDTH-2:0], 1b0}; end if (sclk_rise) begin rx_shift {rx_shift[DATA_WIDTH-2:0], miso}; if (bit_cnt DATA_WIDTH - 1) state FINISH; else bit_cnt bit_cnt 1; end end FINISH: begin cs_n 1b1; rx_data rx_shift; busy 1b0; done 1b1; state IDLE; end endcase end end endmodule这段代码有几个细节值得专门说明sclk_rise和sclk_fall是利用sclk_r和它寄存器延迟后的旧值算出来的单周期脉冲。sclk_r虽然最终输出到SCLK引脚但内部不会把它当异步时钟用所有状态跳转仍然发生在统一的系统时钟上升沿。默认使用模式0所以数据更新放在sclk_fall数据采样放在sclk_rise。如果改成CPHA1需要交换两个动作的边沿或者移动一个周期CPOL只影响sclk空闲时的初始状态。FINISH状态单独存在是为了保证最后一个时钟沿之后CS再拉高避免提前释放导致最后一个bit被截断。这个细节在高速Flash操作里尤其重要。3.5 从“发一个字节”到“连续收发”上面的Master一次只能处理一个DATA_WIDTH的数据实际项目里经常需要连续发一串数据比如写Flash的时候要先发命令字节、地址字节再发数据。我的做法是在Master外面包一层命令状态机由它控制多次调用这个Master核中间用FIFO把要发的数据和收到的数据缓存起来。外部FIFO加Master的好处有几个一是收发数据有缓冲二是SPI控制器能和算法逻辑解耦三是需要连续读多字节的时候可以始终不拉高CS中间只续传下一笔数据。实现“不拉高CS”需要在两个事务之间进入一个保持CS低电平的等待状态从设备仍然认为当前是一次长事务。这个能力在访问SPI Flash和某些传感器时非常关键。4. 从机设计、多设备挂载与片选选择4.1 SPI从机的实现核心FPGA做从机的场景没有主机多但确实存在比如FPGA作为协处理器主控器件通过SPI访问FPGA内部寄存器。从机侧的实现核心是外部SCLK到来时在正确的边沿采样MOSI在需要的边沿把MISO数据推出去同时CS信号决定是否参与总线。从机相比主机麻烦的地方在于外部SCLK和FPGA内部系统时钟往往是异步的。如果两端来自同一个晶振算是准同步风险低如果是两颗芯片、两套时钟源就必须做同步处理。我建议把SCLK和CS先通过两级同步器同步到系统时钟域再在同步后的SCLK边沿进行采样。同步器会引入固定延迟但大多数从机场景不要求同一个时钟沿立刻回数据所以这个延迟可以接受。4.2 从机的MISO必须三态输出从机的MISO在CS没有被拉低时原则上应该是高阻态不能一直输出。原因是多设备共享MISO总线如果两个从机同时驱动轻则数据错乱重则损伤引脚。FPGA里实现三态非常简单assign miso (!cs_n_sync) ? miso_drive : 1bz;FPGA的普通IO都支持三态但板级设计我会加一个上拉电阻默认把MISO置为高。这样从机释放总线期间主机端读MISO不会浮空逻辑分析仪也不容易看到不稳定的杂波。4.3 硬件片选和软件片选实际项目里的判断标准“spi硬件片选与软件片选”这个搜索词很常见我展开说下我的理解。硬件片选在FPGA语境里通常指CS信号由SPI控制器状态机在正确时刻自动生成和SCLK边沿严格同步。软件片选则是通过一个普通GPIO输出由外部模块或处理器用寄存器读写的方式去拉高拉低。维度硬件片选软件片选生成时机跟随SPI状态机自动生成任意寄存器写操作CS建立时间可以精确设计取决于软件调度事务边界自动对齐数据长度需要软件保证边界典型场景标准SPI外设、多从机共享总线非标时序、兼容性兜底调试难度波形直观依赖软件配合我的经验是能用硬件片选就不要用软件片选。硬件片选天然和SCLK对齐状态机跳转也好控制。软件片选适合那些CS和SCLK之间没有固定时序关系的场景比如某些模块要求CS保持时间非常长或者一个CS信号要控制多个内部寄存器访问。没有绝对好坏只是控制粒度不同。4.4 多从机共享总线的设计原则在共享SCLK、MOSI、MISO的总线上挂多个从机我建议遵循这几点每个从机独立CS信号由同一个SPI控制器输出但任何一个时刻只能有一个CS为低从机MISO必须是三态只有自己CS为低时才输出事务之间加一个空闲周期让上一个从机的三态彻底释放再选下一个从机如果不同从机工作电压不同先做电平转换再接总线。遇到通信错误时优先查CS切换时序。用ILA同时抓两个CS的波形看第一个CS拉高和第二个CS拉低之间隔了多少拍。如果小于等于1拍把间隔加长到3拍以上往往能解决很多总线串扰问题。5. 调试SPI通信从抓波形到定位问题的实际经验5.1 逻辑分析仪和ILA到底怎么分工调试SPI我两种工具都用但侧重不同。逻辑分析仪适合看引脚级真实波形尤其是怀疑板级信号完整性问题的时候比如过冲、振铃、毛刺这些模拟特性在ILA里看不到。ILA或者SignalTap适合看FPGA内部的状态机跳转和寄存器值能判断到底是内部逻辑错误还是外部芯片问题。我的习惯是先用ILA确认状态机跳转、CS时序、发送的tx数据和采到的rx数据是否符合预期如果内部逻辑全对但从设备就是不回数据再用逻辑分析仪看引脚上的真实时序。这个顺序能最快把排查范围缩小。5.2 用ILA抓SPI触发条件怎么设很多人抓SPI抓不住帧是因为把触发条件设成了某个数据值但数据出现的时机不确定。我更推荐用CS下降沿或者start信号上升沿作为触发条件。具体在Vivado里例化ILA时添加探针到CS、SCLK、MOSI、MISO、state这些信号然后把CS_n设为下降沿触发。抓到的波形窗口尽量覆盖一整笔事务。如果一次只抓到一半把采样深度调大或者把触发位置设成“中段触发”保留触发前的记录。这样抓到的波形才能完整反映事务时序。5.3 一个真实案例总是随机错一个bit回到开头说的那颗ADC。那次问题表象是读数据偶尔错一位但又不是固定某一位。一开始怀疑采样边沿把模式改成模式1试错误反而更多。最后用ILA抓了完整波形放大多个bit周期逐段看才发现问题有两个一是SCLK高电平持续时间偏短没有满足ADC的数据稳定时间要求二是CS释放前的最后一个上升沿之后我立刻拉高了CS没留保持时间。修法并不复杂把分频系数加大让SCLK从10MHz降到5MHz同时在FINISH状态加一个等待拍确保最后一个SCLK边沿和CS上升沿之间至少有半个SCLK周期的间隔。改完跑了整整一天数据再没出过问题。这类问题的根因如果只盯着代码里的状态变量很难发现。因为状态机逻辑本身是对的问题出在物理时序余量不够。这也是我为什么反复强调用ILA抓真实波形而不是只看代码逻辑。5.4 常见故障速查表疑似问题常见根因快速排查方式从机完全不响应CS未拉低、SCLK没进从机、从机未上电检查引脚约束用逻辑分析仪量引脚读回数据全1MISO被上拉从机三态未释放查从机供电、CS时序、代码三态读回数据全0MISO被下拉或从机一直输出0查从机配置、总线是否短路数据完全反向位序MSB/LSB不匹配查器件手册改移位方向偶发性错误建立保持时间不足、板级干扰降速、加事务间隔、检查走线事务正常但数据不对从设备配置命令不对拆成单字节操作逐条验证这张表是我平时排查的起点。很多看起来复杂的问题根因都在这几项里。尤其是“时好时坏”的问题优先考虑余量不足而不是代码逻辑有bug。5.5 跨时钟域处理从机侧异步信号同步如果FPGA做从机外部SCLK进来是异步信号直接拿它去触发寄存器会产生亚稳态风险。保险的做法是先用两级同步器同步到系统时钟域再检测边沿reg sclk_sync1, sclk_sync2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin sclk_sync1 1b0; sclk_sync2 1b0; end else begin sclk_sync1 sclk_i; sclk_sync2 sclk_sync1; end end wire sclk_edge_rise sclk_sync2 !sclk_sync1; wire sclk_edge_fall !sclk_sync2 sclk_sync1;同步器会让信号晚两个时钟周期但能消除亚稳态。低速SPI下完全没问题高速场合就要考虑把外部SCLK引到专用时钟网络上。6. 高速和特殊场景FPGA SPI设计的边界6.1 SCLK可以跑多快瓶颈到底在哪SCLK上限由三方面决定FPGA的IO标准、内部逻辑路由延时、从设备本身的最高频率。普通LVCMOS电平的FPGA IO跑到几十MHz到百MHz并不难难点在逻辑处理链路的延迟和外部走线的信号完整性。SCLK超过100MHz且走线较长时要考虑端接、阻抗匹配和IO标准调整。FPGA内部真正需要关注的约束是SCLK与MOSI、MISO之间的相对时序。综合工具不会自动分析SCLK和数据线之间的timing关系因为SCLK只是普通输出信号不是传统意义的全局时钟。建议在约束文件里为SCLK设置虚拟时钟对MOSI和MISO设置输入输出延时约束不然高速SPI很容易出现仿真正常、上板异常的尴尬。6.2 非标SPIFlash的连续读、Dummy周期和QSPISPI Flash的标准读操作不是简单的一次数据交换。它包含命令字节、地址字节、Dummy周期然后才是连续数据输出。FPGA实现这类序列我通常采用“命令地址dummy数据”的复合状态机而不是在几个通用Master之间切换。复合事务最关键的一点整个流程中CS不能拉高一旦拉高Flash就认为本次访问结束。所以复合状态机必须在自己内部维护CS保持。通用Master做不到这一点因为两笔独立事务之间CS一定会释放。这也是我只把通用Master当作基础交换部件的原因真正的项目里还会按设备定制一层协议引擎。QSPI则是一次传输4bit把MOSI/MISO扩展成四根双向IO。协议上比SPI复杂但FPGA实现起来难点在IO方向切换和时序同步。如果你只是想让FPGA配置一颗QSPI Flash我建议先固定用标准SPI读芯片ID能正确回读后再启用QSPI模式一步一步验证最稳。6.3 在Zynq这类SoC里给SPI包一层AXI-Lite如果你的平台是Zynq这种FPGA加Arm核的架构SPI控制器通常要做成AXI-Lite外设让PS端软件通过寄存器读写发起SPI事务。接口包装不复杂把start、tx_data、rx_data、busy、done映射到寄存器位即可。但我建议不要一上来就设计一个寄存器满天飞的大模块。先做最小寄存器集一个DATA_REG写入要发送的数据并自动触发一个STATUS_REG返回busy、done和rx_data如果确实需要再加DIV_REG和MODE_REG用于运行时改速率和模式。软件端先轮询busy完成收发跑通后再考虑中断、FIFO和DMA。SPI本身没有流控DMA连续发送时从设备跟不上就会丢数据。之前做一个图像采集项目FPGA的SPI DMA没有考虑传感器处理时间连续读数据时偶尔漏帧最后靠一个深度只有16的FIFO就解决了。所以先小后大比一开始就上DMA更稳。6.4 一个实用建议调试FPGA SPI时先降速如果你正被SPI不稳定卡住我建议先把SCLK降到设计值的十分之一甚至几百kHz然后跑一个最简单的回环测试把MOSI和MISO短接用0x55、0xAA这类数据看Master能不能收到自己发出去的内容。能收到说明代码链路基本没问题收不到大概率是引脚约束、IO方向或者状态机的问题。反过来回环测试没问题接上真实从设备却出错那问题多半出在从设备配置、协议模式或者板级信号上和FPGA内部逻辑关系就不大了。这个分层的排查思路是做SPI调试最值得养成的习惯。在实际项目里把SPI做多了以后你会发现真正难的从来不是写那几行Verilog而是能不能把芯片手册、时序图、约束文件、示波器波形当成一个整体去看。SPI在FPGA设计里位置虽然小但它往下通着引脚电平往上通着数据通路和算法逻辑一段不亮整个业务就卡住。这篇文章把我的这些经验整理出来希望能帮你少走几步弯路。