尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

FPGA上从零实现SPI控制器:Verilog时序与状态机设计实战

FPGA上从零实现SPI控制器:Verilog时序与状态机设计实战 1. 为什么要在FPGA上自己写SPI控制器做FPGA开发的人基本都会遇到SPI。我最早接触SPI是在STM32上那时候觉得这东西太简单了库函数一调数据就出去了。直到把SPI移到FPGA里来实现才发现事情没那么轻松——毕竟MCU里的SPI外设是芯片厂家帮你设计好的而FPGA里你得自己用Verilog把一个完整的SPI协议栈搭出来。先想清楚一个问题FPGA里的SPI到底解决了什么不可替代的问题答案在速度和通道数。MCU的SPI外设通常就那么两三个跑个几十兆赫兹到头了。而FPGA的SPI可以用任意IO扩展出几十路每路跑到100MHz以上也不稀奇而且所有通道可以同时工作。比如你在做图像采集项目同时要控制多个ADC采样、配置多片传感器寄存器用FPGA做SPI控制器就可以实现并行配置而不是像MCU那样逐个轮询。另外一个常见的场景是SPI从机。很多FPGA项目里FPGA被设计成一颗SPI从设备挂载在MCU或SoC的SPI总线上。MCU通过SPI向FPGA发送控制字、读取状态寄存器。这种情况用现成的IP核当然也行但IP核是个黑盒子出了问题你连波形都懒得分析。尤其是当MCU那一侧有奇怪的时序要求、或者是非标准的SPI帧格式时自己写从机反而更灵活。还有一种场景是逻辑分析仪和调试工具。我见过不少工程师把FPGA里的SPI总线信号引出来接到自己写的逻辑分析仪模块上用ILA或SignalTap抓波形来调试SPI通信问题。这种场景下自研SPI控制器的内部信号比外部引脚更容易观察调试效率高很多。所以这篇博文的目标很明确从零开始在FPGA上实现一个可配置的主模式SPI控制器再做一版从模式SPI最后给出完整的仿真验证方法和实际调试经验。代码风格偏向可综合的RTL尽量少用原语和厂商特定模块保证代码在Vivado、Quartus、Lattice Diamond这些主流工具里都能直接跑。在正式写代码之前先把SPI协议本身聊透。2. SPI协议的那些反直觉细节SPI是Motorola在1979年定义的4线串行总线协议四条线分别是SCLK时钟、MOSI主出从入、MISO主入从出、CS/SS片选。看到这里你可能觉得没什么稀奇的——SCLK发时钟MOSI出数据MISO收数据CS拉低选中设备完事儿了。但真正做FPGA实现的时候有三个细节会让新手栽跟头。2.1 模式编号的坑CPOL和CPHA的排列组合SPI有四种工作模式编号从0到3由CPOL时钟极性和CPHA时钟相位两个bit决定模式CPOLCPHA时钟空闲电平数据采样边沿Mode 000低电平上升沿Mode 101低电平下降沿Mode 210高电平下降沿Mode 311高电平上升沿实际项目里90%以上都在用Mode 0或Mode 3因为这两种模式下数据采样边沿都是时钟的上升沿只是空闲电平和首位数据的建立时间略有差异。但如果你只实现了Mode 0遇到某个需要Mode 2的传感器比如某些TI的ADC就得回去改代码。所以设计SPI控制器时CPOL和CPHA一定要做成可配置的参数或寄存器。这里有一个特别容易混淆的点CPHA0时数据在时钟的第一个边沿之前就已经准备好也就是先行模式CPHA1时数据在时钟的第一个边沿之后才被采样也就是滞后模式。很多芯片手册通篇不写CPOL/CPHA只给一张时序图你得自己从波形上推断。判断方法很简单看时序图上SCLK在空闲时是高还是低如果是低就是CPOL0高就是CPOL1。再看数据是在第一个时钟边沿采样还是第二个时钟边沿采样。数据在第一个边沿采样的就是CPHA0第二个边沿采样的是CPHA1。多琢磨两张芯片手册的时序图这个就熟了。2.2 数据传输的位序先发高位还是先发低位SPI协议本身没有强制规定位序但绝大多数设备使用MSB First也就是先发最高位。典型的例外是W25Q系列的Flash支持MSB First但有些命令在特定模式可以配置成LSB First还有些传感器比如某些加速度计针对寄存器地址高位在前但数据载荷却可能是低位在前。这就带来一个很实际的问题FPGA里的移位寄存器到底该写成data_out {data_out[6:0], data_out[7]}还是data_out {data_out[0], data_out[7:1]}。我的建议是在RTL代码中不要直接写移位方向而是设计一个data_order参数用generate块或寄存器数组的方式在运行时选择LSB First或MSB First。虽然会多占几个LUT但兼容性好了不是一点半点。尤其是那些要兼容多种传感器/从设备的FPGA项目这个配置项能帮你省很多改代码的时间。2.3 片选信号的半时钟陷阱CS从高拉低的时刻与第一个SCLK上升沿之间需要至少半个时钟周期的建立时间这是SPI的硬性要求但很多FPGA实现里容易忽略——尤其是那些把CS和SCLK用同一个计数器控制的设计。假设你的SCLK分频系数是N也就是系统时钟每个SCLK周期对应N个系统时钟周期。如果CS拉低和SCLK第一个边沿在同一个计数状态里发生那么CS的建立时间就取决于组合逻辑延迟而不是确定的时钟周期。这在低速1MHz以下下问题不大但到了50MHz以上的SPI速率这种不确定的建立时间就是通信偶发失败的根源。正确做法是CS的拉低至少要比第一个SCLK边沿提前一个完整的系统时钟周期CS的拉高至少要比最后一个SCLK边沿晚一个完整的系统时钟周期。这个用状态机很好实现计数器状态里专门留一个cycle给CS建立/保持就行。3. 主模式SPI控制器从架构设计到Verilog实现设计一个SPI主控制器之前先想清楚它要和谁交互。在FPGA里SPI控制器通常挂在系统总线上比如AXI-Lite、Wishbone或者简单的寄存器接口供CPU软核或硬核配置和读写。但如果只是做纯逻辑的用途比如直接把Sensor数据采样进来发出去也可以做成FIFO接口。为了让这篇文章的内容能适配更多场景我设计的这个SPI主控制器采用寄存器接口包含以下寄存器控制寄存器0x00SPI使能、CPOL、CPHA、位序、主从模式选择速率寄存器0x04SCLK分频系数数据寄存器0x08写发送数据读接收数据状态寄存器0x0C忙标志、发送FIFO满、接收FIFO空片选寄存器0x10目标从设备的CS编号对了这里需要先决定一个关键问题硬件片选还是软件片选。3.1 硬件片选与软件片选的区别硬件片选就是SPI控制器自动控制CS引脚——发起传输时自动拉低对应的CS传输结束自动拉高。软件片选则是CS引脚作为普通的GPIO由CPU或逻辑在SPI传输之前手动拉低传输结束后手动拉高。这两种方式各有适用场景硬件片选适合每次传输是独立事务的场景。比如读一个传感器寄存器CS拉低发指令收数据CS拉高。一锤子买卖。优点是CPU负担小缺点是有些设备要求一次性完成多个连续的SPI读写操作比如Flash的Page Program需要先发命令再发地址最后发数据中间CS不能拉高硬件片选就不太好搞。软件片选适合需要CS在整个事务期间持续拉低的场景。CPU先拉低CS然后连续发起多次SPI字节传输最后拉高CS。优点是灵活缺点是多了一次GPIO操作的软件开销。在FPGA里做SPI主控制器我强烈建议支持这两种模式用一个寄存器位来切换。硬件片选模式下控制器在每次字节传输的开始拉低CS、结束拉高CS软件片选模式下控制器只负责SCLK和MOSI/MISO的数据搬移CS完全由外部逻辑或CPU通过GPIO控制。从硬件设计角度后者的CS引脚必须先配置成普通的输出IO而不是SPI控制器的专用输出。3.2 核心RTL代码解析下面给出主模式SPI控制器的关键RTL代码。为了控制篇幅这里只展示数据通路和状态机的核心逻辑完整代码可以在文末获取。// SPI Master Controller // 支持 Mode 0/1/2/3MSB/LSB First硬件/软件片选 // 接口采用简单的寄存器总线 module spi_master #( parameter DATA_WIDTH 8, parameter CPOL_DEF 1b0, parameter CPHA_DEF 1b0, parameter CLK_DIV_W 16 )( input wire clk, input wire rst_n, // 寄存器接口 input wire reg_wen, input wire reg_ren, input wire [3:0] reg_addr, input wire [DATA_WIDTH-1:0] reg_wdata, output reg [DATA_WIDTH-1:0] reg_rdata, // SPI引脚 output reg sclk, output reg mosi, input wire miso, output reg cs_n ); // 内部寄存器定义 reg spi_en; reg cpol; reg cpha; reg lsb_first; reg sw_cs_en; // 1: 软件片选0: 硬件片选 reg [CLK_DIV_W-1:0] clk_div; reg [3:0] cs_sel; reg [DATA_WIDTH-1:0] tx_data; reg [DATA_WIDTH-1:0] rx_data; // 内部信号 reg [CLK_DIV_W-1:0] div_cnt; reg [3:0] bit_cnt; reg [1:0] state; reg sclk_en; reg busy; localparam S_IDLE 2d0; localparam S_SETUP 2d1; localparam S_TRANSFER 2d2; localparam S_HOLD 2d3; // 状态机 always (posedge clk or negedge rst_n) begin if (!rst_n) begin state S_IDLE; sclk_en 1b0; busy 1b0; bit_cnt 4d0; div_cnt {CLK_DIV_W{1b0}}; sclk CPOL_DEF; mosi 1b0; cs_n 1b1; rx_data {DATA_WIDTH{1b0}}; end else begin case (state) S_IDLE: begin if (spi_en reg_wen reg_addr 4h2) begin tx_data reg_wdata; busy 1b1; bit_cnt 4d0; div_cnt {CLK_DIV_W{1b0}}; sclk_en 1b1; sclk cpol; // 硬件片选时自动拉低CS if (!sw_cs_en) cs_n 1b0; state S_SETUP; end end S_SETUP: begin // 等待半个SCLK周期的建立时间 if (div_cnt clk_div) begin div_cnt {CLK_DIV_W{1b0}}; state S_TRANSFER; // CPHA0时首位数据在第一个时钟边沿之前就绪 mosi lsb_first ? tx_data[0] : tx_data[DATA_WIDTH-1]; end else begin div_cnt div_cnt 1b1; end end S_TRANSFER: begin // 每个SCLK半周期处理一次 if (div_cnt clk_div) begin div_cnt {CLK_DIV_W{1b0}}; sclk ~sclk; // SCLK上升沿采样MISO if (sclk 1b0) begin if (lsb_first) rx_data {miso, rx_data[DATA_WIDTH-1:1]}; else rx_data {rx_data[DATA_WIDTH-2:0], miso}; end // SCLK下降沿更新MOSI if (sclk 1b1) begin if (bit_cnt DATA_WIDTH-1) begin // 传输完成 bit_cnt 4d0; sclk_en 1b0; sclk cpol; state S_HOLD; end else begin bit_cnt bit_cnt 1b1; if (lsb_first) mosi tx_data[bit_cnt1]; else mosi tx_data[DATA_WIDTH-2-bit_cnt]; end end end else begin div_cnt div_cnt 1b1; end end S_HOLD: begin // 保持半个SCLK周期确保最后一个数据的保持时间 if (div_cnt clk_div) begin div_cnt {CLK_DIV_W{1b0}}; // 硬件片选时自动拉高CS if (!sw_cs_en) cs_n 1b1; busy 1b0; state S_IDLE; end else begin div_cnt div_cnt 1b1; end end endcase end end // 寄存器写操作 always (posedge clk or negedge rst_n) begin if (!rst_n) begin spi_en 1b0; cpol CPOL_DEF; cpha CPHA_DEF; lsb_first 1b0; sw_cs_en 1b0; clk_div 16d100; cs_sel 4b1110; // 默认选中CS0 end else if (reg_wen) begin case (reg_addr) 4h0: begin spi_en reg_wdata[0]; cpol reg_wdata[1]; cpha reg_wdata[2]; lsb_first reg_wdata[3]; sw_cs_en reg_wdata[4]; end 4h1: clk_div reg_wdata; 4h4: cs_sel reg_wdata[3:0]; default: ; endcase end end // 寄存器读操作 always (posedge clk or negedge rst_n) begin if (!rst_n) reg_rdata {DATA_WIDTH{1b0}}; else if (reg_ren) begin case (reg_addr) 4h2: reg_rdata rx_data; 4h3: reg_rdata {{(DATA_WIDTH-4){1b0}}, busy, cpha, cpol, spi_en}; 4h4: reg_rdata {{(DATA_WIDTH-4){1b0}}, cs_sel}; default: reg_rdata {DATA_WIDTH{1b0}}; endcase end end endmodule这段代码的设计思路用一句话总结就是状态机控制SCLK的翻转和CS的时序数据通路完全由状态机驱动。S_SETUP状态确保了CS有效后到第一个SCLK边沿之间有至少一个完整时钟周期的建立时间。S_HOLD状态保证了最后一个SCLK边沿到CS失效之间同样有足够的保持时间。这两个状态是SPI可靠通信的关键也是和裸奔的计数器方案最大的区别。关于CPHA1的一个细节上面代码在S_SETUP阶段就放了首位数据这是因为代码里用sclk来判断边沿而sclk的初始值等于cpol。当CPHA1时第一个时钟边沿是下降沿数据在边沿之后的下一个半周期才更新。实际上CPHA1时设备是在第一个边沿之后的第二个边沿采样第一bit所以这段代码需要根据cpha微调。为了不把代码搞得太绕建议在完整工程里用parameter在generate块里做两套数据更新逻辑代码可读性和时序准确性都会更好。3.3 为什么SCLK和MOSI用寄存器输出这里有一个FPGA实现的重要细节SPI的SCLK和MOSI信号必须由时钟沿驱动的寄存器输出不能由组合逻辑输出。原因有两个。第一组合逻辑输出会有毛刺风险——状态转换时多个信号同时变化可能导致SCLK上出现窄脉冲这是SPI通信的致命伤。第二FPGA的内部走线延迟是不确定的组合逻辑输出信号的时序很难收敛。用寄存器输出后SCLK和MOSI的变化时刻严格跟随某个系统时钟的上升沿时序关系是确定的。缺点是多了一级寄存器延迟但SPI是低速接口这点延迟完全无所谓。在Vivado或Quartus里如果你希望SCLK和MOSI的所有逻辑能够优化到IOBI/O Block的寄存器里可以在XDC约束里加上set_property IOB TRUE [get_ports sclk]。不过现在大多数设计用寄存器输出就够了IOB优化对SPI这类低速接口收益不大。4. 从模式SPI的实现思路与关键细节主模式写完了从模式也不难。但主从之间的思维方式刚好相反主模式是你主动拉CS、主动发时钟从模式是你被动接收CS和SCLK只需要在正确的时间点采样和输出数据。设计从模式SPI控制器时最大的难点在于外部时钟域的信号采样。SCLK是外部信号频率可能比你内部系统时钟还高所以不能用简单的always (posedge sclk)来采那样没有跨时钟域处理容易采到亚稳态。推荐的做法是用系统时钟对SCLK和CS做两级同步然后检测SCLK的上升沿和下降沿// 外部SPI信号同步与边沿检测 reg [1:0] sclk_sync; reg [1:0] cs_n_sync; wire sclk_posedge sclk_sync[1] ~sclk_sync[0]; wire sclk_negedge ~sclk_sync[1] sclk_sync[0]; wire cs_n_falling cs_n_sync[1] ~cs_n_sync[0]; always (posedge clk or negedge rst_n) begin if (!rst_n) begin sclk_sync 2b11; // 高电平空闲 cs_n_sync 2b11; // CS高电平无效 end else begin sclk_sync {sclk_sync[0], sclk}; cs_n_sync {cs_n_sync[0], cs_n}; end end同步后的cs_n_falling信号可以用来复位从模式的位计数器确保每次CS拉低都从第0位开始收发。sclk_posedge和sclk_negedge则分别作为MISO数据更新和MOSI数据采样的触发条件。但这里要注意如果你的系统时钟频率不够高两个SCLK边沿之间没有足够的时间来完成状态转移从模式可能丢数据。举例来说如果系统时钟是50MHz而外部SCLK是25MHz那么每半个SCLK周期只有1个系统时钟周期的时间状态机可能跑不过来。所以从模式的系统时钟频率至少要高于SPI SCLK频率的4倍以上最好做到8到10倍留足裕量。MISO信号的输出同样要用寄存器打一拍避免组合逻辑输出导致数据竞争。从模式的MISO在非选中状态下要输出高阻态1bz这样才能和总线上其他SPI从设备共享MISO线。5. 跨时钟域与数据FIFO实际工程绕不开的环节一个SPI控制器如果只做单次寄存器读写那在真实项目里根本不够用。实际场景里SPI主控制器通常要搬运大量数据可能是向Flash写入一页数据可能是从ADC连续采样几百个点。这时候就要引入FIFO做数据缓冲。为什么需要FIFO因为SPI的发送和接收速率与CPU的读写速率是异步的。CPU可能用100MHz的时钟一次写入16字节到FIFO然后SPI控制器以2MHz的速率慢悠悠地把数据发出去。如果没有FIFOCPU就必须等SPI传完一个字节再写下一个效率极低。在FPGA设计中FIFO的跨时钟域处理是成熟的方案。Xilinx和Intel都有免费的FIFO IP核也可以用通用的异步FIFO代码。但我想分享的是一个经常被忽视的问题SPI的发送FIFO深度和DMA/CPU的配合。假设发送FIFO深度是64字节CPU一次性写入64字节。SPI控制器开始发送第0字节时FIFO空了大约63字节的空间。CPU如果靠查询FIFO的空满标志来补充数据最坏情况是在SPI发送完第63字节时才会去检查这时候FIFO已经空了SPI就不得不插入SCLK空闲周期来等待数据。这会严重影响SPI的连续传输性能。解决方案有三种FIFO深度做大比如256字节或512字节FIFO越深CPU响应时间的容忍度越高。中断触发写FIFOFIFO中数据低于阈值时触发中断CPU在中断服务程序里批量写入数据。引入DMA数据从内存直接搬运到FIFO完全不需要CPU干预。这才是高性能SPI传输的最终方案。在我的项目里SPIDMA的配合经历了两个阶段。先是用简单的状态机查询方式速率一高就出现SCLK空转现象——从示波器看SCLK有间断每个间断都意味着吞吐量损失。后来改成DMA方式SCLK连续翻转吞吐量基本打满。如果你在FPGA里做的是MCUFPGA的架构那么MCU通过SPI访问FPGA内部的寄存器时通常也用类似的思想——在FPGA内部做一个寄存器读写FIFOMCU把要写的寄存器地址和数据打包发过来FPGA解析后写入内部寄存器完成后通过状态寄存器告知MCU。这样能显著减少MCU的SPI访问次数。6. W25Q Flash驱动的真实案例从时序解读到代码落地讲完基础控制器我用一个最常见的SPI从设备——W25Q128 Flash——来做实战演示。这个芯片在FPGA开发板上几乎人手一片它的命令集覆盖了SPI通信的几乎所有典型场景单字节读、连续读、页编程、状态寄存器轮询。6.1 W25Q128的关键时序参数W25Q128是Winbond华邦的128Mbit串行Flash支持标准SPI、双线DIO和四线QIO。但作为起步先从标准SPI Mode 0/3说起。几个关键的时序参数详见 datasheet 中 AC CHARACTERISTICS 章节参数最小值单位SCLK频率标准SPI-104 MHzCS 拉低到第一个SCLK上升沿tSLCH5ns最后一个SCLK下降沿到CS拉高tCHSH5nsCS拉高到下一次CS拉低的最小间隔tSHSL30ns数据建立时间tSU3ns数据保持时间tH3ns作为对比如果你的FPGA系统时钟是50MHz周期20ns那么S_SETUP和S_HOLD状态各占20ns刚好满足5ns的建立保持要求还有大把余量。但如果你把SPI速率调高到80MHz周期12.5ns那么半个周期只有6.25ns留给建立保持的时间就非常紧张了。这也是我主张在状态机里预留S_SETUP和S_HOLD状态的根本原因——它们在低速时可以视为无所谓但在高速时就是通信成败的分水岭。6.2 读Flash ID的完整流程读JEDEC ID是验证SPI链路最经典的操作。W25Q128的0x9F命令会返回3个字节Manufacturer ID0xEF、Memory Type0x40、Capacity0x18。整个流程如下拉低CS - 发送0x9F - 接收3个字节 - 拉高CS在FPGA里实现时不能像MCU那样简单调用库函数。你需要按以下步骤设计状态机拉低CS等待 tSLCH。发送0x9F。注意发送的同时MISO上是无效数据可以忽略。连续接收3个字节。接收第1个字节时MISO上对应的是Flash响应的第1个字节。拉高CS等待 tSHSL 后才能发起下一次传输。很多初学者会搞混一个概念SPI是全双工协议发送和接收是同时发生的。你读Flash ID发送0x9F这个命令字节时Flash在MISO上并没有输出任何有效数据——因为Flash根本还没收到完整的命令。所以你的接收逻辑必须精确对齐从第几个字节开始MISO上的数据才是有意义的。这个第几个字节完全由协议决定和收发动作本身无关。在实际FPGA代码里我习惯用一个计数器记录当前传输的是第几个字节接收数据的有效窗口从第1个字节命令字节之后开始。这样代码逻辑清晰不容易错位。6.3 页编程与状态轮询写Flash比读Flash麻烦因为Flash写入前需要擦除而且编程操作需要时间。W25Q128页编程Page Program, 0x02的流程是发送写使能命令0x06让WEL位为1。发送页编程命令0x02 3字节地址 要写入的数据1~256字节。轮询状态寄存器0x05等待WIP位清零。这里最能体现SPI控制器硬件片选/软件片选设计价值的地方来了页编程期间的整个事务期间CS必须一直保持低电平。如果你的SPI控制器只有硬件片选每次字节传输都会自动拉高CS那么写Flash这个操作根本没法做。所以在做Flash驱动时我通常把SPI控制器配置成软件片选模式先用GPIO拉低CS然后连续发送命令、地址、数据最后GPIO拉高CS。整个过程SCLK保持连续数据传输无缝衔接。这个模式也是前面控制器里sw_cs_en这个配置位存在的意义。6.4 高速读连续数据的处理Flash连续读Fast Read, 0x0B在FPGA里有一个额外的性能问题。Fast Read的时序是命令字节、3字节地址、1字节dummy cycle之后Flash持续在MISO上输出数据。如果你要读一块256字节的数据区域到FPGA内存那就要连续收260多字节。用状态机一字节一字节地搬太浪费CPU/状态机资源。更好的做法是在SPI控制器后面挂一个字节到AXI-Stream的转换逻辑数据字节进来直接写入DMA描述符指定的内存地址。无论是Xilinx的AXI DMA还是Intel的mSGDMA都可以和自定义SPI控制器做这种对接。这块内容展开讲又是一个大话题这里先提一嘴后面单独写一篇Fast Read带宽优化的专题。7. SCLK频率计算与时钟分频设计说一个很多FPGA新手会困惑的问题SPI的SCLK频率到底怎么算分频系数怎么选SCLK是由FPGA系统时钟分频得到的。假设系统时钟clk频率为FSCLK目标频率为f那么分频系数N满足F / f N注意这里的N必须是偶数。因为SCLK在一个完整周期里需要翻转两次每次翻转之间隔N/2个系统时钟周期。如果N是奇数SCLK的高电平和低电平时间就不相等占空比就不是50%有些SPI从设备会工作不正常。举个例子系统时钟100MHz目标SCLK是10MHzN10每个半周期5个系统时钟周期。如果你的代码里状态机在div_cnt 5时翻转SCLK那么SCLK正好是10MHz方波。在实际项目里我一般会给SCLK频率留10%到20%的余量。为什么因为系统时钟本身可能有频率误差参考晶振的精度通常是±20ppm到±50ppm而且有些从设备对SCLK有严格的占空比要求比如要求45%~55%。留出余量可以避免SCLK频率刚好卡在设备规格的上限附近造成偶发的不稳定。还有一个细节SPI速率寄存器最好用实际分频系数而不是直接写目标频率。也就是说CPU或者逻辑在配置SPI速率时应该直接告诉控制器SCLK每个半周期等于几个系统时钟周期而不是SCLK频率是10MHz。这样控制器的内部逻辑更简单不需要做除法运算。对于那种需要动态调节SPI速率的设计比如Flash编程的时候用慢速、读数据的时候用高速可以留两个速率寄存器一个快速率、一个慢速率切换时直接改寄存器值即可。8. 板级调试用ILA/SignalTap抓出通信毛病的实战过程代码写完了仿真也过了上板之后通信仍然可能失败。这是FPGA最让人头疼的地方。我把自己排查SPI通信问题时的完整思路整理一下供你参考。8.1 第一步检查示波器波形先把SPI引脚的波形接到示波器或逻辑分析仪上看几个关键信号CS有效时是否干净利落有没有毛刺SCLK的频率和占空比是否和预期一致MOSI数据是否在SCLK的有效边沿前后满足建立保持时间MISO波形是否有明显的毛刺或电平缓变这四条如果都正常就说明FPGA侧的SPI时序没有问题问题大概率出在从设备一侧。如果SCLK有毛刺检查是不是组合逻辑输出SCLK了MOSI建立时间不够检查S_SETUP状态是否被优化掉了CS有毛刺检查是否在状态机里没有对CS做寄存器打拍。8.2 第二步用ILA抓FPGA内部信号FPGA内部的信号示波器是看不到的。这时候要用Vivado的ILAIntegrated Logic Analyzer或Quartus的SignalTap。我通常会在SPI控制器内部挂这几个探针信号state当前状态bit_cnt当前数据bit序号tx_data和rx_data发送和接收数据寄存器sclk和cs_n内部信号版本div_cnt分频计数器触发条件设置为cs_n下降沿触发然后观察一次完整传输的状态变化。如果状态机卡在S_TRANSFER出不来多半是bit_cnt计数逻辑有bug如果S_IDLE和S_TRANSFER之间反复跳可能是spi_en在传输过程中被意外拉低了。8.3 第三步连续传输时的常见毛病在连续传输模式下有几种非常典型的故障现象现象一第一个字节正确后续字节全错排查方向发送FIFO的读使能时序不对。FIFO读使能应该在传输状态机的S_TRANSFER的最后一拍拉起来提前一拍就会读到旧数据晚了一拍就读不到新数据。现象二间歇性多收或少收一个bit排查方向跨时钟域的SCLK边沿检测存在亚稳态。把两级同步寄存器加上再通过仿真验证同步延迟后的边沿信号与原始SCLK的关系。现象三数据对但是整体延迟了一个bit排查方向MISO的采样时刻偏晚或偏早。SPI规定在SCLK的某个固定边沿采样MISO如果你的代码在边沿后立刻采样会因为组合逻辑延迟导致采到上一个位的值。正确的做法是在边沿之后隔一个系统时钟周期再采样MISO。我在调试过程中踩过最深的坑就是波形上看起来一切正常但读回来的数据总是高位和低位交换。最后发现是从设备那边规定先发低位而我这边默认做了MSB先发。所以拿到新设备第一件事就是查它的datasheet或寄存器配置项确定位序不要想当然。8.4 仿真与上板的差异RTL仿真的局限性仿真时会有一种假象——你的SPI控制器在Testbench里工作得很完美但上板就不行了。原因通常是仿真里用的是理想时钟板子上有抖动和偏斜。仿真的IO没有驱动能力的概念而实际的SPI线存在负载电容和走线阻抗。仿真环境里MISO数据由Testbench在某个绝对时间点产生而板上的从设备对时序的响应有延迟。这正好解释了为什么真实的FPGA项目开发流程里仿真只是第一步板级调试才是验证通信正确性的最终手段。仿真过了不代表代码没有隐患只不过隐藏的时序问题在仿真环境里不会暴露而已。9. SPI与IIC、UART的选型思考很多人会纠结一个问题同一个场景到底选SPI、IIC还是UART尤其在FPGA设计里总线选型直接影响资源占用和接口数量。特性SPIIICUART信号线数量4SCLK, MOSI, MISO, CS2SCL, SDA1~2TX, RX通信模式全双工半双工全双工速率上限极高100MHz中通常≤3.4MHz低通常≤10Mbps从设备选择每设备一个CS7位/10位地址点对点协议复杂度低中有ACK/地址仲裁低FPGA资源占用低中低在FPGA项目里我的选择倾向是与高速外设通信高分辨率ADC、Flash、FPGA之间互联首选SPI或LVDSSPI实现简单、速率高。与低速传感器批量通信温湿度、加速度计等IIC更合适因为IIC可以一条总线挂很多设备节省IO。与PC或工控机通信UART最省IO若需更高速率选USB或以太网。很多FPGA工程里会同时用SPI和IIC——SPI接高速ADC采数据IIC接ChipIdea或EEPROM存配置。可以说掌握SPI的FPGA实现是通往更复杂总线协议如JESD204B、PCIe的必经之路。落实到具体工程量SPI的Verilog实现可以说是所有总线协议里性价比最高的——代码量不大但涉及的时序概念建立保持、跨时钟域、状态机设计覆盖了FPGA开发的绝大多数基础技能。把它写扎实了后面写CAN控制器、以太网控制器都会顺手很多。10. 从代码到IP核SPI模块的封装与复用项目做多了之后就会发现与其每次换一个项目都重新写SPI控制器不如把模块封装好作为自己的IP库沉淀下来。我把SPI主/从控制器封装成标准IP的建议是定义统一的寄存器映射标准。控制寄存器、状态寄存器、数据寄存器的偏移地址在IP内部保持一致这样接入不同项目时CPU侧的驱动代码几乎不用改。生成AXI-Lite或Wishbone接口。Xilinx和Intel的SOCZynq、SoC FPGA里AXI-Lite是最常用的轻量级外设总线。如果你的项目中SPI控制器要挂到ARM硬核上那就必须用AXI-Lite接口。如果只是挂在软核MicroBlaze或Nios II上Wishbone也足够了。用参数化设计来覆盖不同位宽和数据长度。比如把DATA_WIDTH参数化支持7位SPI部分LCD屏是9位或12位数据支持可变字节数传输。在一次Xilinx Zynq项目中我就把自研SPI挂到了AXI-Lite总线上并配了DMA实现了Zynq ARM通过SPI向FPGA内部寄存器批量写入配置参数的功能。整个模块用起来和Xilinx自带的AXI Quad SPI IP一样方便但资源占用更少而且代码完全可控出了bug能直接改。如果你不打算自己造轮子Xilinx的AXI Quad SPI和Intel的SPI IP Core也是成熟方案。尤其是AXI Quad SPI支持标准SPI、双线/四线扩展、以及从模式功能非常全面。缺点是需要花时间熟悉它的寄存器和中断机制。我的建议是先用IP核把项目跑通再在下一版里用自研模块替代IP核这样既验证了IP核的功能边界又对自己的实现建立了信心。我做过的几个FPGA项目里SPI控制器从无到有写了大概三个版本。第一版是最简单的状态机寄存器接口第二版加了FIFO和跨时钟域处理第三版才做成AXI-Lite接口并支持DMA。每一次重写都是因为实际项目暴露了新需求——做技术就是这样理论和实践之间的距离最终要靠具体项目来填平。这篇博文的内容就是我在这几轮迭代中积累下来的核心经验。希望能帮你在SPI的FPGA实现上少走一些弯路。
返回列表