刚把一块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的电平:CPOL=0时空闲低电平,CPOL=1时空闲高电平。CPHA决定数据采样时刻:CPHA=0时在第一个跳变沿采样,CPHA=1时在第二个跳变沿采样。四种模式就是这两种参数的四种组合,信号时序有微妙但关键的区别。
| 模式 | CPOL | CPHA | 采样沿 | 移位沿 |
|---|---|---|---|---|
| Mode 0 | 0 | 0 | 上升沿 | 下降沿 |
| Mode 1 | 0 | 1 | 下降沿 | 上升沿 |
| Mode 2 | 1 | 0 | 下降沿 | 上升沿 |
| Mode 3 | 1 | 1 | 上升沿 | 下降沿 |
实际项目中,绝大多数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,那么分频系数DIV=5。计数器从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 = 3'd0, SETUP = 3'd1, SHIFT = 3'd2, FINISH = 3'd3; 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 <= 2'b0; 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 <= 1'b1; mosi <= 1'b0; done <= 1'b0; bit_cnt <= 3'b0; clk_cnt <= 16'b0; tx_buf <= {DATA_WIDTH{1'b0}}; rx_buf <= {DATA_WIDTH{1'b0}}; rx_data <= {DATA_WIDTH{1'b0}}; sclk <= CPOL; end else begin done <= 1'b0; case (state) IDLE: begin cs_n <= 1'b1; clk_cnt <= 16'b0; if (start) begin cs_n <= 1'b0; tx_buf <= tx_data; bit_cnt <= 3'b0; 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 + 1'b1; if (clk_cnt == DIV_CNT - 1) begin clk_cnt <= 16'b0; sclk_r <= ~sclk_r; sample_en <= 1'b1; end else begin sample_en <= 1'b0; end if (sample_en) begin if ((CPOL == 0 && CPHA == 0) || (CPOL == 1 && CPHA == 1)) begin if (sclk_r == 1'b0) 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], 1'b0}; if (bit_cnt == DATA_WIDTH-1) bit_cnt <= 3'b0; else bit_cnt <= bit_cnt + 1'b1; end end else begin if (sclk_r == 1'b1) 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], 1'b0}; if (bit_cnt == DATA_WIDTH-1) bit_cnt <= 3'b0; else bit_cnt <= bit_cnt + 1'b1; 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 <= 1'b1; rx_data <= rx_buf; done <= 1'b1; 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 = 8'h03; reg [2:0] state; reg [1:0] byte_cnt; localparam SEND_CMD = 3'd0, SEND_ADDR = 3'd1, READ_DATA = 3'd2, WAIT_DONE = 3'd3; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= SEND_CMD; byte_cnt <= 2'b0; spi_start <= 1'b0; read_data <= 8'b0; read_done <= 1'b0; end else begin read_done <= 1'b0; case (state) SEND_CMD: begin spi_tx <= CMD_READ; spi_start <= 1'b1; state <= WAIT_DONE; end WAIT_DONE: begin spi_start <= 1'b0; if (spi_done) begin if (byte_cnt == 0) begin byte_cnt <= 2'd1; state <= SEND_ADDR; end else if (byte_cnt == 1) begin byte_cnt <= 2'd2; state <= SEND_ADDR; end else if (byte_cnt == 2) begin byte_cnt <= 2'd3; state <= SEND_ADDR; end else begin byte_cnt <= 2'b0; state <= READ_DATA; end end end SEND_ADDR: begin case (byte_cnt) 2'd1: spi_tx <= read_addr[23:16]; 2'd2: spi_tx <= read_addr[15:8]; 2'd3: spi_tx <= read_addr[7:0]; endcase spi_start <= 1'b1; state <= WAIT_DONE; end READ_DATA: begin spi_start <= 1'b1; state <= WAIT_DONE; end endcase if (state == WAIT_DONE && spi_done && byte_cnt == 2'b0) begin if ( {state, byte_cnt} == {WAIT_DONE, 2'd3} ) begin read_data <= spi_rx; read_done <= 1'b1; 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这个东西,做一次觉得平常,做透一次,你会对整个数字系统的时序思维都有新的理解。