news 2026/9/7 10:44:20

FPGA实现SPI通信:从协议原理到Verilog实战详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
FPGA实现SPI通信:从协议原理到Verilog实战详解

1. 项目概述与SPI协议再认识

搞FPGA的人迟早会和SPI打交道。这协议太常见了,ADC采集、DAC输出、Flash读写、传感器配置、显示屏初始化,几乎每个项目里都能碰到几路SPI。我最早接触SPI是在一个数据采集项目里,当时要同时挂三片ADC和一片Flash,全部走SPI接口,主控用的是国产FPGA。一开始觉得SPI简单,不就是四根线嘛,MOSI、MISO、SCLK、CS,谁不会啊。结果真正写Verilog实现的时候,各种细节问题全冒出来了:时钟极性搞反、采样点没对准、片选时序不对、高速下数据不稳定。调试那几天真是欲哭无泪。

这个项目标题是"SPI通信FPGA实现",本质上是讲怎么在FPGA里用Verilog或者VHDL去实现SPI的主机或者从机功能。它本身是一个技术实现类的项目,适合刚入门FPGA但已经具备基础数字电路知识的人,也适合做嵌入式开发想了解一下FPGA侧SPI实现思路的人。这篇文章我不会只贴一堆代码让你抄,而是把SPI协议里那些文档不会告诉你的细节、踩坑经历、选型思路一并讲清楚。

先说清楚一个概念:SPI全称是Serial Peripheral Interface,串行外设接口,由Motorola在20世纪80年代提出。它属于同步串行通信接口,传数据的时候必须有时钟线配合。和UART这种异步通信不一样,SPI不需要双方约定波特率,主机给从机提供时钟,数据按位传输,速率可以做得很高。常规SPI接口速率从几百kHz到上百MHz都能跑,具体上限取决于从机支持能力和PCB走线质量。FPGA实现SPI的好处在于,你可以完全掌控时序,实现一些非标准的、扩展的SPI协议,比如多字节连续读写、任意长度数据的传输、多个从机灵活片选等,这些都是通用MCU的硬件SPI外设做不到的。

我建议你在动手写代码之前,先花点时间看看SPI协议本身,把帧格式、四种工作模式、全双工半双工这些基本概念搞透。有些初学者上来就找代码抄,抄完了发现和自己的器件对不上,也不知道怎么改,根因就是协议没吃透。哪怕你的目标是尽快跑通功能,也请先把下面这几节看完,磨刀不误砍柴工。

1.1 核心需求解析

SPI通信的FPGA实现,拆开来看有几个层次的需求。第一个层次是满足基本通信需求:能够在FPGA与外部设备之间建立稳定、可靠的SPI链路。更具体地说,设计者需要决定FPGA作为主机还是从机。绝大多数场合FPGA作为主机,主动发起读写操作,外接ADC、DAC、Flash、传感器等从机设备。

第二个层次是时序与性能的匹配。SPI没有像PCIe那样的复杂链路训练机制,它的时序是严格由主机SCLK决定的。所以FPGA内部所产生的SCLK频率、占空比、建立保持时间是否满足从机要求,是通信成败的关键。比如很多ADC芯片要求SCLK高电平期间数据稳定,采样边沿在SCLK上升沿或者下降沿。CPOL和CPHA两个位就是干这个用的,后面会细讲。

第三个层次是系统集成能力。在真实项目里SPI往往不是独立存在的,它要挂到系统总线上,接收CPU或状态机的指令,再把数据送出去。所以一个合格的FPGA SPI模块除了接口逻辑本身,还应当有寄存器接口(方便软件配置)、FIFO缓冲(应对突发传输)、中断或标志位(通知上层处理完成)。我早期的实现里就只做了裸的SPI收发逻辑,上系统后发现CPU要等SPI传完才能干别的事,效率极低,后来加了FIFO和状态标志才解决问题。

所以这篇文章的内容组织方式如下:从SPI协议和时序模型讲起,然后给出完整的FPGA SPImaster实现方案(包括顶层架构,关键模块,Verilog代码和测试思路),再分析硬件片选与软件片选的选择,最后是时序约束和问题排查实录。全部内容围绕"如何用FPGA实现一个稳健、可控、好集成的SPI通信模块"展开。

1.2 SPI、IIC与UART怎么选

和SPI相关的常见问题里,总绕不开"SPI和IIC有什么区别""选哪个好"。做了这么多年FPGA,我个人的经验判断标准其实很明确:

SPI最大的特点是快和简单。全双工,收发同时进行,没有应答机制,硬件开销小。如果外设是高分辨率ADC、高速DAC或者大容量Flash,SPI基本上是不二之选。举个例子,我用过的一款ADC,采样率2MSPS,输出位数16bit,用SPI接口最高时钟能跑到80MHz,一帧数据32个时钟周期,也就是4us多一点能采一个点。如果用IIC,就算把时钟拉满400kHz,一次读16bit数据加上各种协议开销至少60个时钟周期,150us都下不来,性能差了几十倍。

IIC的优势是接线省,两线制,支持多主机总线仲裁,每个设备有地址,可以通过地址区分多个设备。适合低速、对引脚数量敏感的场景,比如说给几个温度传感器、EEPROM这种低速率器件做配置和状态读取。IIC的ACK机制和仲裁机制让它比SPI更可靠、更适合板级多设备互联,但复杂度也更高。

UART在FPGA里更多的是做调试口和低速通信口用,比如把采集结果直接通过串口打印到上位机。UART虽然只需要两根线(TX、RX),但双方各自要有独立的时钟源,因此通信速率由双方约定,一般为9600、115200、460800等固定波特率。

如果让我给一个快速选型建议:数据量大、速率要求高、链路简单点对点就选SPI;多设备低速配置读写就选IIC;异构系统间通信、调试日志输出就选UART。这些都是从实用角度出发的选择逻辑,也是我项目里最常见的三种串行通信方式。

2. SPI协议核心机制与时序模型

既然要用FPGA实现SPI,就得先把SPI的协议机制和时序模型彻底搞清楚。别嫌这些基础东西烦,很多后期调试的痛苦,都是因为前期对协议的理解不够扎实。下面我按线序、工作模式、数据传输三个阶段来拆解。

2.1 四根线各自的作用与连接结构

标准的SPI有四个信号:

  • SCLK:串行时钟,由主机输出,从机接收。SCLK是个方波信号,每来一个边沿,双方移入/移出一位数据。SCLK的频率就是SPI通信速率。
  • MOSI:Master Output Slave Input,主机输出、从机输入。主机在这个线上把数据发给从机。
  • MISO:Master Input Slave Output,从机输出、主机输入。从机在这个线上回传数据。
  • CS/SS:片选信号,低电平有效。主机把CS拉低,表示选中某个从机设备,只有被选中的设备才会参与通信。

从拓扑结构上看,SPI是典型的一主多从结构。多个从机的MOSI和MISO可以并联到同一条总线上,但每个从机需要有独立的CS信号。所以FPGA作为主机时,如果挂了N个从机,就需要N个CS输出端口。MISO线要求比较特殊,因为所有从机的MISO都接在一起,从机没有被选中时要输出高阻态,否则多个设备的输出会打架。市面上绝大多数SPI从机芯片内部已经实现了这个逻辑,FPGA这边无需额外处理。

有一种所谓的QSPI或者Dual SPI/Quad SPI接口,是SPI的变种,把MISO/MOSI复用来做双向数据线,达到更高速率。这个后续有机会单独写,本文聚焦标准SPI。

从物理连接上我需要提醒一点:SPI属于同步接口,信号边沿陡峭,在高速率(几十MHz以上)时反射和串扰都会比较明显。PCB设计上要注意SCLK尽量不要太靠近其他高速翻转信号,走线保持短而直,有条件的话串接33Ω左右的匹配电阻。板级经验不足的同学容易忽略这些,结果在实验室调试时发现通信偶发错误,拿示波器量信号又看不出明显问题,其实就是信号完整性引起的。

2.2 CPOL和CPHA四种模式彻底讲透

SPI有四种工作模式,由CPOL(时钟极性)和CPHA(时钟相位)两位共同决定。这也是新手最容易混淆的地方。我尽量用大白话讲清楚。

CPOL决定SCLK在空闲状态时是高电平还是低电平。CPOL=0表示空闲时SCLK为低电平,有效状态是高电平;CPOL=1则相反,空闲时SCLK为高电平,有效状态是低电平。

CPHA决定数据采样发生在SCLK的第几个边沿。CPHA=0表示数据在第一个边沿采样,CPHA=1表示在第二个边沿采样。这里"第一个边沿"和"第二个边沿"是相对于一个bit周期而言的,指的是SCLK的跳变沿,可能是上升沿也可能是下降沿,具体由CPOL决定。

把两位组合起来:

工作模式CPOLCPHA采样边沿数据变化边沿典型应用
Mode 000上升沿下降沿绝大多数器件
Mode 101下降沿上升沿部分ADC/DAC
Mode 210下降沿上升沿某些Flash
Mode 311上升沿下降沿部分传感器

我用Mode 0举例子,这是最常用的一种模式。CS拉低后,SCLK从低电平开始,第一个边沿是上升沿,CPHA=0所以在上升沿采样数据。主机在下降沿更新MOSI上的数据,从机在上升沿采这个数据。对于从机发往主机的数据也一样:从机在下降沿更新MISO上的数据,主机在上升沿采样。这样每个时钟周期正好传输1 bit,数据在采样边沿到来之前已经稳定了至少半个时钟周期。

Mode 1呢,CPOL=0,但CPHA=1。SCLK空闲为低,但第一个边沿(上升沿)不算,第二个边沿(下降沿)才采样。这种情况下主机和从机都会在上升沿更新数据,下降沿采样数据。换句话说主机和从机的动作是同步的,都在同一个边沿更新数据,下一个边沿去采样。

说实话,对FPGA实现来说,你不需要在代码里显式地区分这四种模式,你只需要根据从机数据手册里的时序图选择一种模式,然后让SCLK的相位、采样时机和更新时机严格对齐就行。更聪明的做法是写一个参数化模块,用两个parameter控制CPOL和CPHA,然后在采样和更新条件里加上这两个参数判断。后面给代码的时候细说。

我之前遇到过一次很奇怪的现象:一块ADC在模式0下工作正常,同样的寄存器配置换成模式3也能初始化成功,但读取的数据偶尔错位。查了半天发现该芯片采样手册里推荐的其实是Mode 1,之前能用纯属巧合,因为寄存器配置阶段SCLK比较慢,建立时间充裕,掩盖了模式错误的时序问题。所以一定要严格按照数据手册选模式,别心存侥幸。

2.3 全双工传输过程逐位拆解

SPI是全双工通信,主机发数据的同时也在收数据。理解这一点非常关键,因为很多初学者以为SPI是"发送完再接收",实际上并非如此。每个时钟周期都会同时完成发送1 bit和接收1 bit的操作。

以Mode 0、8bit传输为例,完整过程是这样的:

CS拉低,从机被选中,从机在自己的SS端口检测到低电平后开始准备数据。在第一个SCLK上升沿到来之前,主机已经把第7位数据(最高位)放到MOSI线上,从机也把第7位数据放到MISO线上。第一个上升沿到来时,双方同时采样对方的线上数据。下降沿到来时,双方各自更新下一位数据到线上。如此循环,经过8个SCLK周期,双方各收到了8个bit,组合起来就是完整的一字节。

注意一个细节:在传输过程中MOSI和MISO上的数据都是随时钟边沿不断更新的,不存在"传输开始"和"传输结束"的额外握手。因此,主机必须精确控制传输的字节数和位序。大多数SPI器件是MSB-first,即最高位先发。如果你的器件要求LSB-first,就需要在实现时做位序反转。这个操作在移位寄存器里很自然:MSB-first就是左移,LSB-first就是右移。

在FPGA实现中,主机需要维护一个发送移位寄存器和接收移位寄存器。每个SCLK周期,发送移位寄存器把最高位送出去然后左移一位,接收移位寄存器从MISO上采一位数据并移入寄存器低位。8个周期后,发送寄存器的值全部发完,接收寄存器的值正好是一字节完整数据。

我强烈建议你把SPI的字节传输过程画成一个时序图,把CS、SCLK、MOSI、MISO四条波形的变化画出来,标出数据有效区间和采样点。我在做每一个新器件驱动时都会干这件事,一份清晰的手绘时序图,往往比对着数据手册看半天更有效。等你有经验了,还可以直接在FPGA工程里写一个简单的testbench,把主机的SCLK和数据线拉出来做仿真波形,验证协议逻辑是否正确。

3. FPGA实现SPI主机的架构设计

协议讲完了,现在进入正题:怎么写Verilog代码实现一个SPI主机。这里我给出的设计思路偏向于"通用性和可复用性",结构上会把模块拆分为顶层控制器、发送FIFO、接收FIFO、波特率发生器、SPI协议引擎几个部分。这样做的好处是项目里换一颗从机芯片,只需要修改极少部分代码,甚至只需要调整参数就行。

3.1 模块划分与整体架构

顶层模块设计成AXI-Lite接口和寄存器接口,这是很多FPGA工程师实际项目里的做法。你有两种选择:一是做一个纯粹的SPI接口IP,对外暴露简单的握手信号(start、busy、tx_data、rx_data);二是带寄存器接口,方便软核或者外部MCU通过总线配置。如果只是做纯逻辑链路,选第一种就够用。如果是要嵌入到SoC系统里,建议用第二种。

从底层实现来看,SPI主机模块分为五块:

  • clk_div:SPI时钟生成模块,将系统时钟分频产生SCLK。
  • spi_master_fsm:SPI主状态机,负责整个传输过程的时序控制。
  • shift_reg:移位寄存器模块,处理数据的并转串和串转并。
  • tx_fifo / rx_fifo:发送和接收缓冲,FIFO深度按需求调整。
  • reg_if:寄存器接口,用于配置分频系数、控制启停、读取状态。

这种拆分方式比较常规,但能保证每个模块职责单一,代码可读性和可维护性都会好很多。对于刚入门的初学者,我建议先把spi_master_fsm和shift_reg合起来写一个极简版,先跑通逻辑,再逐步加上FIFO和寄存器接口。

系统的整体工作流程是:上层逻辑或CPU把要发送的数据写入tx_fifo,然后写一个启动寄存器。SPI主状态机检测到启动信号后,拉低CS,产生SCLK,同时从tx_fifo取数逐位移出,并把采样的数据写入rx_fifo。全部字节发送完毕后,拉高CS,置位完成标志。上层可以查询标志位或者接收中断来读取数据。

3.2 时钟分频与SCLK生成策略

FPGA的系统时钟一般是50MHz、100MHz或者更高。SCLK频率必须由系统时钟分频而来。分频系数等于系统时钟频率除以目标SCLK频率,再除以2,因为SCLK一个完整周期包含高低两个电平。

举个例子,系统时钟100MHz,目标SCLK 10MHz,分频系数=100/10/2=5。也就是说计数器从0计到4,前5个周期输出低电平,后5个周期输出高电平,合起来就是10MHz。更严谨的说法是,SCLK半周期为5个系统时钟周期,一个全周期10个系统时钟周期。

分频系数建议写成parameter,工程里可以针对不同的从机器件配置不同的分频系数。很多从机芯片的最高SCLK频率都不一样,同一块板子上如果挂了Flash和ADC,Flash可以跑高点,ADC要保守一点,这时候分频系数分开配置就很重要。

一个小技巧:SCLK的频率不要直接拉满到从机允许的最大值。我曾经为了让Flash读写快点,把SCLK配到了芯片标称的最高频率80MHz。实际跑起来偶发数据错误,排查下来是PCB走线长了点,信号边沿质量不够好。降到50MHz后一切正常。工程上留20%-30%余量是常态,除非你非常确定PCB信号完整性很好。

3.3 主状态机设计与状态跳转

spi_master_fsm是整个SPI主机的核心。我通常用三段式状态机来实现,状态定义如下:

  • IDLE:空闲状态。等待启动信号。启动后拉低CS,跳转到发送状态。
  • TRANSFER:传输状态。计数器从0计数到(bit_width-1),每个SCLK时钟周期移位一次。计数满后判断是否还有更多字节要发送,有则继续,无则跳转到STOP。
  • STOP:停止状态。拉高CS,产生完成脉冲,返回到IDLE。

这里有个细节要注意:状态机是在系统时钟域工作的,而SCLK是分频产生的逻辑时钟。如果直接在状态机里等SCLK边沿,容易出现跨时钟域的问题。我建议的做法是,在系统时钟域里控制一个"SPI时钟使能"信号,每当分频计数器到特定值时,产生一个单脉冲作为SCLK的驱动使能。SPI状态机在这个脉冲下进行计数和数据移位,SCLK本身可以作为一个寄存器输出。

这种设计被称为"时钟使能"(clock enable)方式,避免生成门控时钟。在FPGA里,门控时钟容易引发保持时间和时序收敛问题,是资深工程师尽量避免的写法。虽然直接assign sclk = clk & en也能工作,但综合工具在时钟布线和时序分析上会对这种写法做出比较保守的处理,容易吃时序违例。

4. 关键Verilog代码实现

下面给出一个完整的SPI master Verilog实现。为了便于理解,我做了一个偏教学化的版本,去掉FIFO和寄存器接口,只保留核心收发逻辑和clk_div,方便你直接仿真和学习。实际项目需要扩展的话,在这个基础上加就行。

4.1 参数化SPI Master完整代码

module spi_master #( parameter CPOL = 1'b0, // 时钟极性 parameter CPHA = 1'b0, // 时钟相位 parameter DATA_WIDTH = 8, // 数据位宽 parameter SCLK_DIV = 10 // 分频系数,SCLK = clk / (SCLK_DIV*2) )( input wire clk, input wire rst_n, input wire start, // 启动一次传输 input wire [DATA_WIDTH-1:0] tx_data, // 待发送数据 output wire [DATA_WIDTH-1:0] rx_data, // 接收到的数据 output reg busy, // 忙标志 output reg done, // 传输完成脉冲 output reg sclk, // 串行时钟 output reg cs_n, // 片选,低有效 output reg mosi, // 主机输出 input wire miso // 主机输入 ); localparam CNT_MAX = SCLK_DIV - 1; reg [$clog2(SCLK_DIV)-1:0] clk_cnt; // 分频计数器 reg sclk_en; // 时钟使能脉冲 reg [DATA_WIDTH-1:0] tx_shift; reg [DATA_WIDTH-1:0] rx_shift; reg [$clog2(DATA_WIDTH+1)-1:0] bit_cnt; localparam IDLE = 2'b00; localparam TRANSFER = 2'b01; localparam STOP = 2'b10; reg [1:0] state; //-------------------------------------------------- // 时钟分频:产生sclk和sclk_en // 当CNT_DONE为高时,翻转sclk,并产生一个单脉冲sclk_en //-------------------------------------------------- reg clk_toggle; wire cnt_done = (clk_cnt == CNT_MAX); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin clk_cnt <= 0; clk_toggle <= 1'b0; end else if (state == IDLE) begin clk_cnt <= 0; clk_toggle <= 1'b0; end else if (cnt_done) begin clk_cnt <= 0; clk_toggle <= ~clk_toggle; end else begin clk_cnt <= clk_cnt + 1'b1; end end // sclk_en:在sclk翻转前后更新数据/采样数据的使能 // 简单起见,在clk_toggle翻转的那一拍产生使能 reg sclk_en_r; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sclk_en_r <= 1'b0; end else begin sclk_en_r <= cnt_done; end end assign sclk_en = cnt_done; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sclk <= CPOL; end else if (state == IDLE) begin sclk <= CPOL; end else if (cnt_done) begin sclk <= clk_toggle; end end //-------------------------------------------------- // 状态机主体 //-------------------------------------------------- always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; cs_n <= 1'b1; tx_shift <= 0; rx_shift <= 0; bit_cnt <= 0; busy <= 1'b0; done <= 1'b0; mosi <= 1'b0; sclk_en_r <= 1'b0; end else begin done <= 1'b0; case (state) IDLE: begin cs_n <= 1'b1; busy <= 1'b0; if (start) begin state <= TRANSFER; cs_n <= 1'b0; // 拉低片选 tx_shift <= tx_data; // 载入待发送数据 bit_cnt <= 0; busy <= 1'b1; end end TRANSFER: begin // 在sclk_en时,根据CPOL/CPHA决定采样和移位 if (sclk_en) begin // 更新MOSI:在正确边沿把最高位移出去 if ((CPOL == 1'b0 && CPHA == 1'b0 && clk_toggle == 1'b0) || // Mode 0: 下降沿更新 (CPOL == 1'b1 && CPHA == 1'b1 && clk_toggle == 1'b1) || // Mode 3: 下降沿更新 (CPOL == 1'b0 && CPHA == 1'b1 && clk_toggle == 1'b1) || // Mode 1: 上升沿更新 (CPOL == 1'b1 && CPHA == 1'b0 && clk_toggle == 1'b0)) begin // Mode 2: 上升沿更新 mosi <= tx_shift[DATA_WIDTH-1]; end // 采样MISO:在采样边沿采入数据 if ((CPOL == 1'b0 && CPHA == 1'b0 && clk_toggle == 1'b1) || // Mode 0: 上升沿采样 (CPOL == 1'b1 && CPHA == 1'b1 && clk_toggle == 1'b0) || // Mode 3: 下降沿采样 (CPOL == 1'b0 && CPHA == 1'b1 && clk_toggle == 1'b0) || // Mode 1: 下降沿采样 (CPOL == 1'b1 && CPHA == 1'b0 && clk_toggle == 1'b1)) begin // Mode 2: 上升沿采样 rx_shift <= {rx_shift[DATA_WIDTH-2:0], miso}; end // 移位控制:在采样边沿执行移位 if ((CPOL == 1'b0 && CPHA == 1'b0 && clk_toggle == 1'b1) || (CPOL == 1'b1 && CPHA == 1'b1 && clk_toggle == 1'b0) || (CPOL == 1'b0 && CPHA == 1'b1 && clk_toggle == 1'b0) || (CPOL == 1'b1 && CPHA == 1'b0 && clk_toggle == 1'b1)) begin tx_shift <= {tx_shift[DATA_WIDTH-2:0], 1'b0}; bit_cnt <= bit_cnt + 1'b1; end end // 传输完DATA_WIDTH位后进入STOP状态 if (bit_cnt == DATA_WIDTH - 1 && sclk_en) begin state <= STOP; end end STOP: begin cs_n <= 1'b1; // 拉高片选 busy <= 1'b0; done <= 1'b1; // 完成脉冲 state <= IDLE; end endcase end end assign rx_data = rx_shift; endmodule

这段代码里有几个地方需要仔细看:

关于MOSI更新和采样边沿的匹配,我用了四组条件分别对应四种工作模式。你可以根据自己的需求裁剪掉不需要的模式,保留一种常用模式会让代码更简洁。但如果你打算把这个模块用于不同项目,保留全模式支持是值得的。

关于片选时序,代码里CS拉低和拉高的时刻是在状态机的IDLE和STOP里处理的。实际应用时,有些器件要求CS拉低后等待一小段时间再开始SCLK,有些要求SCLK停止后保持CS低电平一段时间再拉高。这些要求在状态机里预留几个空转周期即可。比如把STOP状态拆成STOP_WAIT状态,多等几个周期再拉高CS。

关于rx_data的生成,我直接用了寄存器赋值,实际使用时如果对时序要求更严格,可以考虑在STOP状态把rx_shift锁存到输出寄存器,避免在传输过程中rx_data值中途变化。这个看你自己的需求。

4.2 极简应用示例与Testbench说明

用这个master模块,应用层调用方式很简单:

// 调用示例:向一个SPI DAC写入0x3F spi_master #( .CPOL(1'b0), .CPHA(1'b0), .DATA_WIDTH(8), .SCLK_DIV(10) // 系统时钟100MHz,SCLK=5MHz ) u_spi_master ( .clk (clk), .rst_n (rst_n), .start (spi_start), .tx_data (8'h3F), .rx_data (spi_rx_data), .busy (spi_busy), .done (spi_done), .sclk (sclk), .cs_n (cs_n), .mosi (mosi), .miso (miso) );

写testbench时,重点验证以下几件事:第一,CS拉低后第一个有效数据位是否在SCLK第一个有效边沿前建立;第二,采样的数据位是否和外部从机模拟的数据一致;第三,连续多字节传输时片选信号是否正确维持低电平;第四,完成信号done是否只在最后一个字节传输结束时出现。

一个比较实用的仿真验证方式是在testbench里例化一个SPI slave的模拟模型,和master对着跑。从机模型不需要太复杂,用一个移位寄存器接收数据并输出一个固定值到MISO就行。这样可以看到master发出的tx_data被从机正确接收,同时master也能采到从机回传的固定值。两边一致,链路就是通的。

5. 硬件片选与软件片选的实际取舍

做FPGA SPI项目时,片选信号的产生方式是个容易忽略但影响很大的工程细节。工程界常说的"硬件片选"和"软件片选"其实指的是片选信号的两种连接和控制方式。

5.1 两种片选方式的原理对比

硬件片选指的是FPGA通过专用的GPIO引脚直接连接从机CS引脚。片选信号的拉低和拉高完全由FPGA内部的逻辑控制,和SPI数据线协同工作。这种方式的优点是响应快、时序可控,FPGA可以在精确的时刻拉低CS;缺点是占用的FPGA引脚多,每多挂一个从机多占一个引脚。

软件片选的叫法有点误导,不是真的用软件去控制,一般是指通过寄存器的方式,外接一个译码器或者锁存器,把CS的控制信号通过总线地址写操作产生。例如用GPIO扩展芯片或者74HC138译码器,通过写不同的地址值来选择不同的从机。这种方式节省了FPGA引脚,但是片选跳变的时刻依赖于总线写操作的时序,灵活性变差。

在我经历的项目中,如果从机数量不超过四个,我基本都用硬件片选,直接在FPGA里做多个CS输出。如果从机数量多且支持SPI菊花链拓扑,可以考虑软件片选配合外部译码电路。还有一种折中方案,FPGA侧只引出一根CS信号,外部加一个简单的移位寄存器或者反相器阵列来扩展多路片选。

5.2 片选时序对通信稳定性的影响

不管哪种片选方式,片选时序都必须满足从机芯片的要求。很多从机手册里会给出CS相对于SCLK的建立时间(t_setup)和保持时间(t_hold)。比如有些Flash芯片要求CS拉低后至少50ns才能出现第一个SCLK上升沿,有些ADC则要求最后一个SCLK下降沿之后还要保持CS低电平至少10ns。

FPGA实现时,可以在状态机里增加一个"片选延时计数器",在IDLE到TRANSFER之间加入若干个空闲SCLK周期。这个空闲周期的数量可以通过parameter配置,方便适配不同从机。

另外有一个非常隐蔽的坑:在SPI多字节连续读写时,某些从机要求CS在整个传输期间持续保持低电平,而另一些则允许每字节之间CS有一个短暂的释放(甚至要求释放)。如果实现时不区分这一点,很容易出现字节间隔的通信错误。好的做法是在状态机中为"字节间隔是否拉高CS"做一个参数控制位,比如BYTE_GAP_CS = 1表示每字节间拉高CS,=0表示保持低电平。适配不同芯片时改参数就行。

6. 时序约束、验证与信号完整性实践

写完代码只是第一步。SPI接口要在实际板上稳定工作,时序约束和验证手段是绕不开的。很多初学者习惯直接下板调试,发现问题后再拿示波器到处量,这样效率很低。正确做法是先用仿真验证功能正确性,再做时序约束确保电路满足建立保持时间,最后下板用ILA实测。

6.1 跨时钟域处理与同步设计思路

SPI master工作在系统时钟域中,但SCLK、MOSI、MISO这些信号是相对慢速的外部接口信号。如果SPI时钟不需要高达几十MHz,最简单的处理就是全部寄存器输出,不做内部多时钟域切换。

MISO作为输入信号,进入FPGA后理论上属于跨时钟域信号。如果MISO速率不高(几MHz),直接在系统时钟域中打两拍同步再使用基本也能满足要求。但如果SPI速率较高,建议使用IDELAY或者IOBUF原语来调整输入采样窗口。Xilinx 7系列架构中ODDR、IDDR、ISERDESE2等原语可以精确控制SDR/DDR数据采样,但这样做代码复杂度会显著上升。

我的建议是:在SPI速率低于50MHz时,直接用同步打拍解决;高于50MHz时,认真考虑使用专用I/O原语和区域时钟资源,别硬用普通逻辑去拼。此外,ASIC风格地把外部采样信号在FPGA内部打两拍再使用,能有效防止亚稳态传播,这是FPGA设计的基本功。

6.2 ILA在线调试与常见波形分析

下板调试时,ILA(Integrated Logic Analyzer)是即时抓取内部信号波形的利器。Xilinx Vivado里的ILA可以配置多个探针,把cs_n、sclk、mosi、miso、bit_cnt、state等信号引出来,观测实际波形。

抓到的波形一旦和预期不一致,按下面的思路排查:先看CS和SCLK的时序关系对不对,CS拉低后第一个SCLK沿有没有正确出现;再看MOSI上数据的位序对不对,是不是从MSB开始的;然后看MISO采样点的数据是否稳定,可以用一个只在采样沿有效的信号去标记采样点;最后看完整传输周期结束后CS是否正常释放。

常见波形问题归纳如下表:

现象可能原因排查手段
无SCLK输出分频器未启动或状态机卡在IDLE检查start信号、state状态
SCLK频率不对分频系数配置错误重新计算SCLK_DIV参数
MISO采样数据错位CPOL/CPHA配置错误对照数据手册时序图确认模式
数据偶发错误信号完整性问题或片选时序问题降低SCLK频率,检查PCB走线
完成信号一直不产生位计数器溢出或状态机死锁检查bit_cnt和状态跳转条件

6.3 从0到1完整FPGA项目流程建议

如果你是从零开始做带SPI接口的FPGA项目,我建议的流程是这样:

先明确通信需求,包括从机型号、最高SPI速率、数据位宽、单次传输字节数、是否连续读写。再根据需求确定FPGA型号和引脚分配,重点确认目标器件的I/O bank电压与从机接口电压是否匹配。然后编写SPI master模块,并同步编写testbench,在仿真环境里把功能验证通过。接着分配引脚、编写约束文件(XDC或SDC),包含时钟约束和I/O约束。综合实现后查看时序报告,确保SPI相关路径满足约束。然后下板,先用ILA抓取几个关键信号确认波形正确,再接上真实从机设备联调。联调通过后再做系统集成,加FIFO、寄存器接口、中断控制等。

整个过程不要跳步。我亲眼见过太多人省掉了仿真步骤直接下板,结果调了三天发现是时序模式搞错了,如果先做仿真十分钟就能发现。仿真的价值不仅仅在于验证功能,更在于能让你把调试的注意力集中在协议逻辑上,而不是信号引脚和示波器探头接触不良这种物理问题。

7. 项目实操心得与常见问题复盘

最后写一点不太容易从文档里学到的经验教训,算是给这篇文章收个尾。

7.1 建立时间与保持时间到底怎么理解

很多初学者搞不懂为什么FPGA内部逻辑满足时序约束这么重要。我举个例子,SCLK在FPGA内部是寄存器输出,它到达从机引脚需要T_co(寄存器输出延迟)加上T_pcb(PCB走线延迟)。与此同时,MOSI数据线上的数据也要经过类似路径到达从机。如果两个信号到达时刻相差悬殊,从机采样时就可能采到不确定的电平。这套东西在数字电路里叫建立时间和保持时间。

FPGA综合工具会帮你分析这些问题。你只要在约束文件里写清楚时钟频率和I/O延时,工具会自动计算每条路径的工具时延、器件时延并报告是否收敛。SPI这种速度不太高的接口,只要约束正确,时序收敛通常不是问题。真正需要重点关注的是那些没有约束的异步输入信号,它们才是隐患所在。

7.2 上拉电阻和双机通信的特殊处理

SPI的MISO线在多个从机复用场景下,必须保证没有被选中的从机输出高阻态,不然两条推挽输出直接打对线,轻则数据错误,重则烧毁芯片。硬件设计上可以在MISO线上加上拉电阻,保证总线悬空时是确定的高电平。FPGA内部的弱上拉在某些平台上也可以打开,但引脚上外接一个10kΩ上拉电阻更可靠。

双机通信时,如果两个FPGA都实现了SPI master,那两者之间只能一个做主机一个做从机。你要是两边都实现了master且都有定时发送的逻辑,那就得自己设计好握手信号,避免双方同时占据总线。我见过有的同学直接把两个master的MOSI接在一起,一通电数据就不对,道理就在这。

7.3 向非标准SPI器件的扩展思路

有些器件的接口严格来说不是标准SPI,但工作原理接近。比如某些LCD驱动芯片要求9位数据帧(1位DCX + 8位数据),又比如某些带命令/数据切换的传感器要求CS低电平时随SCLK发送的bit流里前后含义不同。这时候完全可以在SPI master模块之上包一层协议转换逻辑,把标准SPI的数据帧映射到器件需要的帧格式。

实现时注意不要破坏底层SPI主机的时序参数,保持CS/SCLK的基本关系不变。如果你要支持DMA传输或者突发读写,建议在UP层控制逻辑中维护一个传输描述符表,SPI主机只负责按描述符执行传输,这样架构清晰了很多。

8. 写在最后的一点经验

分享一个我自己项目里很深刻的教训。早年间做一块板子,FPGA通过SPI控制两片ADC同步采样,SCLK配到了25MHz。实验室单板调试一切正常,结果整机联调时数据偶发错误,而且只在机器运行几分钟后出现。拿示波器盯了半天MISO信号,发现随着温度升高,信号边沿变得圆润,采样点刚好落在边沿附近。后来把SCLK降到12.5MHz,问题彻底消失。从那以后我就养成了一个习惯:只要SPI速率超过从机标称值的一半,信号完整性就要当回事,绝不能只看功能仿真没问题就完事。

另外一个习惯是每做一个SPI项目,我都会在工程里放一个循环回环测试。就是让主机把一串已知数据发出去,从机模型(或者实际用一个Flash芯片)把收到的数据存起来再读回来比对。这个自动比对逻辑虽然写起来多花一点时间,但后面排查问题省下的时间远不止这些。如果你做的是ADC采集,也可以定期往DAC写一个斜坡信号,再采回来画波形,立刻能看出链路是否正常。

SPI通信的FPGA实现,看起来是个基础功能,但要做到稳定可靠、可复用、可集成,还是有不少细节要打磨。希望这篇文章能把那些文档不写、但又实际决定成败的东西讲清楚。你在自己的项目里如果有更特殊的SPI需求,比如超高速、多从机、非标准帧格式,也可以沿着这个思路继续扩展。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/7 10:43:41

DevOps:CI、CD、CB、CT、CD

目录 一、软件开发流程演化快速回顾 (一)瀑布模型 (二)原型模型 (三)螺旋模型 (四)增量模型 (五)敏捷开发 (六)DevOps 二、走近DevOps(Development 开发,Operations 运维) (一)开发全流程周期 (二) DevOps与传统开发方式区别 (三)DevOps 具体落…

作者头像 李华
网站建设 2026/9/7 10:38:25

ARM架构与交叉编译:从RISC原理到嵌入式工程实践全解析

看到DAY17这个序列号时&#xff0c;我第一反应是&#xff1a;这正好是很多人学ARM最容易卡住的位置。前面的汇编指令、开发板点灯都还算有趣&#xff0c;到了“架构”和“交叉编译”这两个词儿&#xff0c;抽象程度一下子拉高&#xff0c;不少人是背完概念就跑了&#xff0c;真…

作者头像 李华
网站建设 2026/9/7 10:38:10

武汉市路网矢量数据shp获取、清洗与转换全流程实操指南

简介&#xff1a;武汉市陆路路网矢量数据包&#xff0c;同时包含武汉市道路、行政区和边界三层矢量图层&#xff0c;适用于主流地理信息系统软件。数据仅保留陆路交通&#xff0c;不含地铁、铁路、水路和航空&#xff0c;可直接用于地图制图、道路网络分析与城市规划等场景&…

作者头像 李华
网站建设 2026/9/7 10:36:46

猫抓cat-catch:3种方式安装、免费嗅探网页视频的浏览器资源扩展

猫抓cat-catch&#xff1a;3种方式安装、免费嗅探网页视频的浏览器资源扩展 【免费下载链接】cat-catch 猫抓 浏览器资源嗅探扩展 / cat-catch Browser Resource Sniffing Extension 项目地址: https://gitcode.com/GitHub_Trending/ca/cat-catch 你打开一个网页视频&am…

作者头像 李华
网站建设 2026/9/7 10:33:34

音乐软件架构设计:从实时音频引擎到扩展机制

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华