1. 项目背景:为什么在FPGA上自己写SPI控制器
先说个我自己的经历。两年前做一块采集板,主控用的是Zynq,外设挂了一片工业级的ADC,接口就是SPI。当时想省事,直接调PS端的SPI控制器,结果折腾了一下午,发现驱动配置复杂不说,时序还总是差那么几个纳秒。后来一气之下,在PL端用Verilog写了一个SPI主机模块,一百行不到的代码,问题全解决了,时序完全可控,带宽还比原来高出一截。
从那以后,我在项目里遇到SPI接口的外设,基本都会优先考虑在FPGA里自己实现控制器,而不是依赖处理器自带的SPI外设。原因其实不难理解:
- FPGA里的逻辑是并行运行的,SPI的时钟SCLK可以由内部时钟分频得到,收发数据可以和外部逻辑无缝衔接,不需要经过CPU干预。
- 很多场景下,SPI从机设备的时序要求比较特殊,比如某些ADC要求CS拉低后必须等待几个时钟周期才能发起SCLK,或者需要连续读取多个通道的数据。处理器的SPI控制器可能不支持这么灵活的自定义时序,但FPGA完全可以做到。
- 如果系统里有多路SPI外设,FPGA实现多个SPI主机也就是例化几次模块的事,不额外占用PCB面积,也不用增加物料成本。
另外,SPI协议本身是实现其它很多协议的基础。比如SD卡就是工作在SPI模式下初始化的,很多Nor Flash、传感器、触摸屏控制器都支持SPI接口。把这套逻辑吃透了,后面再做SD卡控制器、Flash控制器、甚至I3C接口,上手速度都会快很多。
这篇博客我打算从SPI的基本原理讲起,然后给出一份可以直接用的Verilog实现,包括主机和从机的代码框架,再配合仿真和上板实测的流程,最后聊聊我在实际项目中踩过的坑和总结下来的经验。适合正在学FPGA的初学者,也适合在项目里需要快速实现SPI通信的工程师参考。
2. SPI协议速览:四种模式与时序核心
很多初学者上来就写代码,结果波形出来完全不对,往往是对协议本身的理解不够透彻。所以这里先把SPI的关键知识点梳理清楚,这些内容也是后面代码实现的直接依据。
2.1 四根线的分工与数据交换逻辑
SPI(Serial Peripheral Interface)是Motorola在70年代末提出的一种同步串行通信协议。标准情况下需要四根信号线:
| 信号名 | 方向 | 作用 |
|---|---|---|
| SCLK | 主机→从机 | 串行时钟,由主机产生 |
| MOSI | 主机→从机 | Master Output Slave Input,主机发数据 |
| MISO | 从机→主机 | Master Input Slave Output,从机发数据 |
| CS/SS | 主机→从机 | 片选信号,低电平有效,选中对应从机 |
SPI和UART、I2C最大的不同在于,它是一种全双工的同步协议。发送和接收是同时进行的:主机在SCLK的某个边沿把MOSI上的数据发送出去,同时在同一时刻采样MISO上的数据。换句话说,每次时钟脉冲到来,主机和从机实际上完成了“一次数据交换”,双方各发出去一位、各接收进来一位。
理解这一点非常重要,因为它直接决定了代码怎么写:发送模块和接收模块本质上是一个状态机,不能拆成独立的两套逻辑。我见过有人把SPI的发送FIFO和接收FIFO完全分开设计,结果读取逻辑经常对不上,就是这个原因。
2.2 CPOL和CPHA:四种工作模式是怎么来的
SPI的四种模式由两个参数决定:
- CPOL(Clock Polarity):SCLK空闲时的电平。CPOL=0表示空闲为低电平,CPOL=1表示空闲为高电平。
- CPHA(Clock Phase):数据采样和切换的相位关系。CPHA=0表示在第一个边沿采样,CPHA=1表示在第二个边沿采样。
组合起来就是常见的SPI Mode 0~3,对应关系如下:
| 模式 | CPOL | CPHA | 数据采样边沿 | 数据切换边沿 |
|---|---|---|---|---|
| Mode 0 | 0 | 0 | 上升沿 | 下降沿 |
| Mode 1 | 0 | 1 | 下降沿 | 上升沿 |
| Mode 2 | 1 | 0 | 下降沿 | 上升沿 |
| Mode 3 | 1 | 1 | 上升沿 | 下降沿 |
实际项目里最常用的是Mode 0和Mode 3,这两种模式都在上升沿采样,时序最容易满足。多数Flash芯片默认支持Mode 0或Mode 3。我踩过一个坑,某颗传感器芯片数据手册只写了SPI时序图,没有直接标注Mode几,结果按Mode 0配置怎么都不出数,后来对照时序图一帧一帧比对,才发现它要求的是Mode 1。所以拿到新芯片,第一件事就是对着数据手册的时序图把CPOL和CPHA确认清楚,不要想当然。
2.3 数据帧格式与MSB/LSB问题
常规SPI通信是8位一字节,但很多外设用的是16位帧格式,例如某些音频编解码器和ADC需要一次性发送16位控制字。更复杂的帧格式包括24位、32位和可变长度帧。
工程上有两种组织方式:
- 固定位宽,直接按帧传输:适合比较器、ADC这类数据格式固定的设备。
- 可变位宽,由计数器控制:在通用的SPI控制器里常用,例如MIPI I3C或SD卡的标准SPI模式。
还需要注意MSB传输顺序。SPI标准协议默认MSB先行,但也有芯片偏偏要用LSB先行,比如部分LCD驱动IC和传感器。如果复用模块,最好把移位方向做成参数可配。
不过这里要提个建议:不要为了通用性把模块设计得过于复杂。我见过有工程师把SPI控制器写成了类似通用DMA的架构,支持任意长度、任意模式、任意位序、FIFO、中断,结果代码量两千行,最终调了半个月,性能还未必比得上一个精简版本。FPGA的每个逻辑单元都是有成本的,按需设计才是正道。
3. 主机端Verilog实现:状态机为主的可复用方案
这一节给出一个相对完整的SPI主机实现,支持参数化时钟分频、四种模式配置、8/16/32位数据宽度,代码量不大,适合直接移植到项目里使用。
3.1 模块端口定义与参数设计
module spi_master #( parameter DATA_WIDTH = 8, parameter CLK_DIV = 10, // 系统时钟频率 / SPI时钟频率,例如50MHz / 5MHz = 10 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, // SPI物理接口 output reg sclk, output reg cs_n, output reg mosi, input wire miso );参数里面最关键的是CLK_DIV。这个值的计算方法是系统时钟频率除以SPI时钟频率,比如系统时钟100MHz、SPI目标时钟10MHz,那么CLK_DIV就是10。注意这里的含义是倍数关系,需要产生的是分频后的时钟沿触发,而不是直接用always块生成内部时钟网络。
3.2 时序控制的两种实现思路
主机的核心逻辑其实就一句话:控制SCLK产生正确的脉冲序列,同时在正确的时间采样MISO。具体实现有两种主流写法:
第一种是分频后沿触发。用计数器对系统时钟分频,每当计数器归零时产生一个sclk_tick脉冲,状态机在这个脉冲的驱动下推进一位。好处是逻辑清晰,不容易产生毛刺。
第二种是直接计数采样。不生成显式的分频时钟,而是用计数器记录分频位置,在不同计数位置执行不同的操作,比如在计数到中间位置时采样MISO。这种方式更加鲁棒,也是我目前比较推荐的做法。
我这里给出的是第二种方式的简化版本,核心代码如下:
reg [DATA_WIDTH-1:0] shift_reg; reg [7:0] tick_cnt; reg [7:0] bit_cnt; wire sclk_tick = (tick_cnt == CLK_DIV/2 - 1); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin tick_cnt <= 0; bit_cnt <= 0; cs_n <= 1'b1; busy <= 1'b0; done <= 1'b0; shift_reg <= 0; end else begin if (start && !busy) begin busy <= 1'b1; done <= 1'b0; cs_n <= 1'b0; bit_cnt <= 0; tick_cnt <= 0; shift_reg <= tx_data; end else if (busy) begin if (sclk_tick) begin // 生成SCLK翻转逻辑(根据CPOL/CPHA调整) // 采样MISO和移位逻辑 if (bit_cnt == DATA_WIDTH) begin busy <= 1'b0; done <= 1'b1; cs_n <= 1'b1; rx_data <= shift_reg; end end end end end上面的代码只是一个骨架,完整实现里还需要加入SCLK翻转、MISO采样、边沿控制等逻辑。
3.3 状态机完整设计:一次传输的完整流程
我把一次完整的SPI传输过程用状态机拆解成五个状态:
| 状态 | 行为 | 进入条件 |
|---|---|---|
| IDLE | 等待start信号,CS保持高 | 复位或done拉高 |
| CS_LOW | CS拉低,等待t_setup时间 | 收到start |
| TRANSFER | 按bit_cnt逐位收发数据 | 等待时间结束 |
| CS_HIGH | CS拉高,等待t_hold时间 | 所有位传输完毕 |
| DONE | 拉高done信号,回IDLE | CS高电平保持结束 |
比较重要的一步是在CS_LOW状态下延时几个时钟周期再开始第一个SCLK。虽然很多SPI从机不要求这个建立时间,但一些严格芯片的数据手册明确标注了t_cs_sclk的最小值。用一个通用的延时计数器解决,匹配所有设备都更安全。
采样逻辑我用了一个技巧:在SCLK的上升沿前后各延时半个分频周期采样。比如CLK_DIV=10时,SCLK周期是10个系统时钟,那么在第2.5个时钟位置采样最稳妥。工程上一般先做分频计数器,把SCLK翻转推迟半步,这样采到的数据就是稳定电平。
3.4 信号同步与跨时钟域的工程习惯
在FPGA工程中,来自外部的信号都必须经过同步处理。SPI的MISO来自外接从机芯片,频率可能和FPGA内部时钟毫无关系,如果不处理就大概率出现亚稳态。
reg miso_s1, miso_s2; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin miso_s1 <= 1'b0; miso_s2 <= 1'b0; end else begin miso_s1 <= miso; miso_s2 <= miso_s1; end end wire miso_sync = miso_s2;用两级触发器进行同步,是处理跨时钟域最基础、也最可靠的方式。SPI的时钟频率普遍不高(几MHz到几十MHz),两级同步足够满足需求。如果外部信号频率非常高或者设计要求更高的可靠性,可以再加一级或者使用专门的异步FIFO方案,但SPI场景通常用不上那么重的手段。
4. 从机端Verilog实现:时序解析与数据收发
主机端的代码是大多数项目的核心,但有些场景必须在FPGA里实现SPI从机。比如FPGA作为协处理器挂在ARM或MCU下,通过SPI接口接收配置命令和数据。我做过一个高速数据采集项目,MCU就是通过SPI向FPGA发送控制字和读取采样结果的。
4.1 从机模块的端口与整体结构
SPI从机的设计思路和主机完全不同,因为它没有主动权,一切时序都由外部主机决定。从机模块的任务是:被动解析SCLK边沿,完成数据的移位接收和移位发送。
module spi_slave #( parameter DATA_WIDTH = 8, parameter CPOL = 0, parameter CPHA = 0 )( input wire clk, // 系统时钟,用于同步和采样 input wire rst_n, input wire sclk, // 外部主机提供的SPI时钟 input wire cs_n, // 片选,低有效 input wire mosi, output reg miso, // 用户接口 output reg rx_valid, output reg [DATA_WIDTH-1:0] rx_data, input wire [DATA_WIDTH-1:0] tx_data, input wire tx_valid, output reg tx_ready );和主机端最大的区别在于,SCLK是从外部输入进来的,无法保证与内部系统时钟同步。所以所有的边沿判断都得用系统时钟去采样,而且要注意避免毛刺。
4.2 SCLK边沿检测与数据采样时机
在从机端,检测SCLK边沿是核心操作。我用两级触发器同步SCLK,然后通过组合逻辑判断上升沿和下降沿:
reg sclk_s1, sclk_s2; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sclk_s1 <= 1'b0; sclk_s2 <= 1'b0; end else begin sclk_s1 <= sclk; sclk_s2 <= sclk_s1; end end wire sclk_posedge = sclk_s2 & ~sclk_s1; // 上升沿 wire sclk_negedge = ~sclk_s2 & sclk_s1; // 下降沿有了这两个信号,就能根据CPHA参数决定什么时候采样数据:
- CPHA=0时,数据在第一个边沿(上升沿或下降沿,取决于CPOL)被采样,从机应该在SCLK的第一个边沿之后把数据放到MOSI上,在第二个边沿输出下一位。
- CPHA=1时,数据在第二个边沿被采样,从机应该在第一个边沿就输出数据,第二个边沿保持。
这部分的逻辑容易写错,我的经验是严格按照数据手册的时序图来写case,不要试图用一句话概括所有模式。详细的对照表如下:
// 采样条件 wire sample_en = (CPOL == 0 && CPHA == 0) ? sclk_posedge : (CPOL == 0 && CPHA == 1) ? sclk_negedge : (CPOL == 1 && CPHA == 0) ? sclk_negedge : sclk_posedge; // 数据更新条件 wire update_en = (CPOL == 0 && CPHA == 0) ? sclk_negedge : (CPOL == 0 && CPHA == 1) ? sclk_posedge : (CPOL == 1 && CPHA == 0) ? sclk_posedge : sclk_negedge;4.3 从机接收与发送的实现细节
从机的状态机相对简单,主要受cs_n控制。当cs_n为低时,接收逻辑逐位采样mosi,同时根据tx_data逐位输出到miso。注意从机必须在CS拉低之前就把发送数据准备好,因为第一个SCLK边沿到来时就必须把第一位数据放到MISO上,留给从机的反应时间非常短。
这里有一个很关键的工程细节:从机不能等到发现CS拉低了才开始准备TX数据,而应该在上一帧传输结束后就立即准备好下一帧的数据。我在代码里是这样处理的:
always @(posedge clk or negedge rst_n) begin if (!rst_n) begin tx_ready <= 1'b0; end else if (cs_n) begin // CS空闲:准备好发送数据 shift_tx <= tx_data; tx_ready <= 1'b1; end else if (update_en && !cs_n) begin // 传输中:逐位移出 shift_tx <= {shift_tx[DATA_WIDTH-2:0], 1'b0}; tx_ready <= 1'b0; end endCS空闲时就锁存发送数据,是最稳妥的做法。否则外部主机SCLK一旦开始跑,从机还没准备好数据,MISO上输出的就是无效电平,接收方就收到了乱码。
4.4 多从机场景与三态输出的处理
如果总线上挂了多个SPI从机,每个从机的MISO引脚需要支持三态输出,在CS为高时输出高阻态,否则会互相拉低冲突。
assign miso = cs_n ? 1'bz : shift_tx[DATA_WIDTH-1];这是SPI和I2C的一大区别,I2C靠开漏来避免冲突,SPI必须靠片选+三态。如果设计里没有处理好这一点,两个从机同时驱动MISO就会烧毁器件或导致逻辑错误。
如果FPGA作为从机,需要确认FPGA引脚支持三态输出。大多数FPGA引脚都可以配置为三态,但要注意约束文件里需要标注IOBUF或设置为inout类型,否则综合工具可能会报错。
5. 仿真验证:从Testbench到波形检查
写完RTL代码不仿真就上板,基本等于赌博。我在项目里养成的习惯是,每写完一个模块,先做功能仿真,确认逻辑正确以后再进行综合和布局布线。SPI模块的仿真相对容易,因为协议本身是标准化的,可以直接写一个行为模型来模拟从机。
5.1 Testbench环境搭建与激励生成
Testbench的核心工作有两件:一是生成SPI主机需要的start信号和tx_data数据,二是模拟一个SPI从机设备,回传已知的应答数据。
下面是一个最小化的Testbench骨架:
module tb_spi_master(); reg clk; reg rst_n; reg start; reg [7:0] tx_data; wire [7:0] rx_data; wire busy; wire done; spi_master #( .DATA_WIDTH(8), .CLK_DIV(10), .CPOL(0), .CPHA(0) ) u_spi_master ( .clk(clk), .rst_n(rst_n), .start(start), .tx_data(tx_data), .rx_data(rx_data), .busy(busy), .done(done), .sclk(sclk), .cs_n(cs_n), .mosi(mosi), .miso(miso) ); // 模拟从机行为 reg [7:0] slave_rx; always @(posedge sclk or negedge cs_n) begin if (!cs_n) slave_rx <= {slave_rx[6:0], mosi}; // 在SCLK上升沿采样MOSI end assign miso = (cs_n == 1'b0) ? slave_reply[7:0] : 1'bz; initial begin clk = 0; forever #10 clk = ~clk; // 50MHz end initial begin rst_n = 0; #100 rst_n = 1; #100; start <= 1'b1; tx_data <= 8'hA5; #20 start <= 1'b0; wait (done == 1); $display("rx_data = %h", rx_data); $finish; end endmodule5.2 时序验证的重点检查项
仿真跑起来后,不要只看done信号有没有拉高就完事了,要逐个检查关键时间节点:
- CS拉低后到第一个SCLK上升沿的延时是否满足从机的建立时间要求。
- MISO数据的采样点:把光标放在SCLK的上升沿上,检查此时MISO的数据是否稳定,如果采到了跳变沿附近的数据,说明采样时机不对。
- 最后一位数据发送完后CS的拉高时机:有些从机要求数据位结束后SCLK保持低电平一段时间再拉高CS,如果CS拉高太早,最后一拍数据可能没有被正确捕获。
- 连续两次传输之间的间隔:需要确认从机有足够的时间内部处理数据。
还有一个仿真注意事项:SPI仿真时如果用了forever #10 clk = ~clk这种方式生成时钟,有一个隐藏风险。当Testbench在等待done信号时,如果逻辑里存在组合环或者漏判条件,仿真会一直卡住。建议给仿真加上超时机制,比如用#10000 $error("timeout")来兜底。
5.3 覆盖边界条件:数据全0、全1与交替序列
代码写完后的第一轮仿真通过后,我通常会把测试数据换成几种特殊值再跑一轮:
8'h00和8'hFF,检查连续同电平传输时是否存在时序偏移。8'hAA和8'h55,检查最高频翻转时信号完整性。- 从机回传数据使用伪随机序列,验证从机返回数据的接收路径是否正确。
这三个测试数据分别覆盖了“直流电平稳定”、“高频翻转”和“非规律翻转”三种工况。如果这三种都过了,基本可以认为逻辑没问题。真实的信号完整性问题比如反射、串扰,那是PCB层面的问题,仿真只能保证逻辑功能正确,不能保证硬件电气性能。
6. 上板实测:从约束文件到逻辑分析仪调试验证
仿真通过只是万里长征第一步,真正到了板子上,问题才会浮出水面。这里分享几个我在上板实测环节积累的经验。
6.1 引脚约束与时序约束的配置细节
SPI接口的引脚约束其实很简单,难的是时序约束。如果只是把SPI时钟频率控制在几MHz以内,而且外设和FPGA之间走线不长,时序约束不做也能跑。但如果频率上到20MHz以上,或者外设要求严格的建立保持时间,就必须做约束。
基础的引脚约束在XDC文件中这样写:
set_property PACKAGE_PIN AB16 [get_ports {sclk}] set_property IOSTANDARD LVCMOS33 [get_ports {sclk}] set_property PACKAGE_PIN AB17 [get_ports {cs_n}] set_property IOSTANDARD LVCMOS33 [get_ports {cs_n}] set_property PACKAGE_PIN AA16 [get_ports {mosi}] set_property IOSTANDARD LVCMOS33 [get_ports {mosi}] set_property PACKAGE_PIN AA17 [get_ports {miso}] set_property IOSTANDARD LVCMOS33 [get_ports {miso}]时序约束的关键在于create_clock和set_input_delay:
create_clock -name sys_clk -period 20.000 [get_ports {clk}] set_input_delay -clock [get_clocks spi_sclk] -max 5.0 [get_ports {miso}] set_input_delay -clock [get_clocks spi_sclk] -min 2.0 [get_ports {miso}]不过要提醒一句,SPI的时序约束很容易做错,因为SPI的SCLK不是FPGA内部时钟,而是由内部逻辑翻转产生的。如果SCLK频率不超过30MHz,而且线路长度很短,很多工程师实际上是不单独约束SCLK的,而是依靠系统时序分析来保证。如果这部分拿不准,建议把SCLK分组设为虚拟时钟,再通过set_false_path排除和系统时钟的跨时钟域路径,避免Vivado报大片的时序违例把真正的问题淹没掉。
6.2 用ILA抓取SPI波形的操作细节
上板调试最有用的工具就是ILA(Integrated Logic Analyzer)。在Vivado里,把cs_n、sclk、mosi、miso以及done、busy信号加入调试窗口,设置采样深度为1024,触发条件设为cs_n下降沿。
这里分享一个经常踩的坑:ILA的采样时钟必须比被测信号频率高至少一个数量级。如果你用系统时钟100MHz作为ILA采样时钟,去抓10MHz的SCLK信号,采样点之间会有100ns的间隔,能看清基本的电平变化,但如果要分析数据在SCLK边沿附近是否建立稳定,就有点力不从心了。
另外,ILA触发条件不要设置得太复杂。我见过有人设置cs_n下降沿 AND mosi上升沿 AND done下降沿,结果怎么都触发不了,因为多个条件同时发生的时刻根本不存在。最简单的做法是只触发cs_n下降沿,然后在波形里通过miso和mosi的电平变化手动判断数据位置。
6.3 实测中遇到的典型案例:SPI数据错位问题
有一次调试SPI Flash的读取操作,发现读出来的数据和预期完全对不上——不是全0也不是全F,而是看起来像“错位”的乱码。用ILA抓了波形以后发现一个很有意思的现象:MISO上的数据在SCLK上升沿到来时处于高阻状态,上升沿之后才慢慢变成有效数据。
这个问题的根源是Flash芯片的输出延迟时间t_V太大,超过了主机的建立时间要求。当时我的主机在上升沿采样,但Flash的数据在上升沿之后才稳定,所以采到了尚未稳定的电平。
解决方案有两种:一是降低SCLK频率,给从机更多的时间来响应;二是修改采样相位,在下降沿采样。第二种方案效果更明显,因为下降沿到来的时候,数据已经稳定了半个周期。
这个案例说明一个道理:如果数据出现系统性错位,优先看波形,不要怀疑代码逻辑。代码的问题通常导致的是完全无输出或者固定错误,而不是这种看起来有规律的错位。
7. 工程实践中的常见坑:从学到的教训中避雷
SPI看起来简单,但实际工程里坑非常多。把我在多个项目中遇到过的典型问题整理如下,都是真实踩过、修复过、又验证过的经验。
7.1 时钟极性配置错误:数据全对的假象
这个坑很隐蔽。在某些情况下,CPOL和CPHA配置错误并不会导致通信完全失败,而是会得到“部分数据正确、部分数据错误”的结果。比如用CPOL=1 CPHA=1去尝试读取一颗CPOL=0 CPHA=0的Flash,如果Flash内部对时序不敏感,可能会正常响应读ID命令,但读取数据时出现字节顺序颠倒。
我当时排查这个问题花了半天时间,最后是用逻辑分析仪逐位比对,发现数据的字节顺序调换了,而不是位顺序调换。原因是SPI主机在错误的边沿采样,每一次都晚了一个节拍,导致帧边界错位。
这里的教训是:拿到一颗新芯片,一定要先发送读ID或读状态寄存器命令,验证通信的正确性,然后再发送实际数据命令。读ID命令的返回数据是固定的,可以快速判断时序配置是否正确。
7.2 CS信号的毛刺与复位期间的电平问题
FPGA上电复位期间,所有信号都处于未定义状态。如果此时CS恰好处于低电平,而从机是Flash或者Sensor,就可能在复位期间被误触发一次传输,导致内部状态混乱。
解决办法是在顶层模块加一个上电复位延时:
reg [15:0] rst_cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin rst_cnt <= 0; end else if (rst_cnt != 16'hFFFF) begin rst_cnt <= rst_cnt + 1'b1; end end assign sys_rst_n = rst_n && (rst_cnt == 16'hFFFF);同时,在SPI主机模块里,cs_n在复位期间必须被强制拉高,不能是z状态或者悬空。Verilog里复位初始化的写法要保证cs_n在所有分支下都有确定的赋值。
7.3 系统时钟与SPI时钟的频率匹配问题
很多初学者把CLK_DIV参数理解错了。CLK_DIV代表的是“系统时钟频率/SPI时钟频率”的比值,这个值直接决定了SCLK的时间精度。但要注意,如果CLK_DIV取了奇数,SCLK的高低电平宽度就不相等,时序看起来会有轻微的占空比失真。
例如系统时钟40MHz,SPI时钟10MHz,那么CLK_DIV=4,高低电平各占两个系统时钟周期。但如果系统时钟30MHz,SPI时钟10MHz,CLK_DIV=3,那么低电平一个周期、高电平两个周期,占空比就变成了33.3%/66.7%。
大多数SPI从机对占空比的要求是40%~60%,所以奇数分频也不是不能用,但一定要确认芯片手册。如果芯片对占空比要求严格,建议在系统时钟和SPI时钟之间加一个PLL做一个整分频,或者用DCM产生一个正好两倍于SPI时钟的时钟,以保证50%的占空比。
7.4 MISO线上拉电阻与空闲状态的确定
SPI总线在空闲状态下,MISO的电平取决于从机在CS为高时输出什么。有些芯片设计为高阻态,需要外部上拉或下拉电阻来确定电平;有些芯片则始终驱动MISO线。
如果MISO悬空,FPGA内部采样到的电平就是不确定的,可能导致状态机误判。工程建议是在MISO信号上加一个10kΩ上拉电阻,这样空闲状态默认为高电平。注意选值不要太低,否则会增加功耗,而且多个从机共用MISO时上拉电阻太低会影响信号驱动能力。
8. 从SPI到其它协议的演进路径
SPI作为最基础的同步串行协议,掌握之后可以很快上手其它类似协议的实现。这里聊聊几个值得关注的方向,对做FPGA开发的工程师很有参考价值。
8.1 双线高速模式:QSPI与OSPI
在NOR Flash和部分存储器的应用场景中,标准SPI的8位单线传输带宽已经成了瓶颈。QSPI在标准四线基础上增加了IO2和IO3两根数据线,可以实现x4模式。OSPI则进一步扩展到了x8模式。
FPGA实现QSPI控制器并不难,核心是在标准SPI状态机的基础上增加模式切换逻辑:命令阶段使用单线、数据阶段切换到4线并行。关键点在于,切换的时候要保证所有IO方向的正确性,尤其在读操作时,IO0~IO3在命令阶段是输入,在数据阶段要切换为输出。这个方向切换如果处理不当,会造成总线冲突。
8.2 与DMA结合的高吞吐路径
如果SPI通信数据量比较大,比如每秒超过几MB,单纯靠CPU中断交互的效率就太低。FPGA的方案通常是在SPI控制器后面挂一个FIFO,接收到的数据先缓存到FIFO,然后通过DMA搬移到内存。
我在一个项目里就是这样做的:SPI接收数据直接写入AXI-Stream FIFO,DMA把FIFO里的数据搬到DDR,整个链路CPU零参与。实测下来,SPI时钟50MHz、8位数据宽度时,吞吐率可以达到接近6.25MB/s,CPU占用率几乎可以忽略。
如果只是简单的MCU+FPGA结构,也可以用一组寄存器缓冲+硬件标志位来实现“伪DMA”效果:外部主机通过SPI写入数据,FPGA内产生中断标志,处理器在空闲时读取。
8.3 SPI与FMC/AMBA总线桥接的实践经验
STM32H743处理器的FMC接口和Xilinx FPGA组合在工业产品里很常见。FMC本身支持并行NOR接口时序,可以模拟成外扩的并行总线接口,把FPGA内部的寄存器空间映射到MCU的地址空间里。
相比SPI,FMC的优势是带宽高、时延低,缺点是引脚占用多。有些项目两种接口都会用上:高速数据通道走FMC,低速配置通道走SPI。
如果做类似设计,要注意FMC的时序参数和FPGA内部逻辑的匹配问题。FMC接口的读信号和写信号时序相对规范,但在FPGA侧需要做地址译码和读写控制逻辑。常见的问题是MCU端配置了异步模式,访问时等待周期太长,导致FPGA内部的握手信号超时。建议先用逻辑分析仪抓一次FMC读写的实际波形,确认地址建立时间、数据保持时间的具体数值,再在FPGA代码里匹配这些时序。
9. 项目复盘与工程师的三个习惯
写到这里,整个SPI通信FPGA实现的核心内容基本讲完了。从协议原理、RTL实现、仿真验证到上板调试,再到协议演进的方向,这是一条完整的认知链路,也是我从多个项目中慢慢积累起来的。
如果让我总结在这类基础外设控制模块开发中最重要的经验,大概就三条。
第一,协议参数的确认永远是最优先的。不要根据“大多数芯片都是Mode 0”的经验去猜测,一定以数据手册的时序图为准。花十分钟把时序图看明白,后面能省一天的调试时间。
第二,波形验证的效率远高于逻辑走读。代码写完之后,不管是仿真还是上板,尽快把波形抓出来看,眼见为实。我见过太多工程师对着代码一行行“人肉仿真”,效率低、容易遗漏,而实际上只要仿真跑一遍,很多逻辑漏洞立刻现形。
第三,模块设计要控制复杂度,不要为了“通用”而“通用”。SPI控制器的本质是一个状态机,而不是一个通用的DMA。把不太用到的功能去掉,把核心逻辑做精简,不仅代码量小、容易维护,时序收敛也更轻松。
SPI协议并不复杂,但每一次和真实硬件打交道的过程,都可能暴露出新的问题。希望这篇博客里的实现代码和调试思路,能帮你少走一些弯路。也欢迎在实际项目中遇到类似问题时,回来对照一下其中的排查流程,应该会有点帮助。