作为一个常年跟 FPGA 打交道的工程师,SPI 这个协议我估计闭着眼都能写出来。但每次帮新人看代码,发现大家栽跟头的地方翻来覆去就那么几个:时序不满足、跨时钟域处理出错、片选信号乱拉。这篇就把 SPI 在 FPGA 里的实现从头到尾捋一遍,从协议本身到 Verilog 代码,再到上板调试的坑,一次性讲透。
1. 先把 SPI 协议本身啃透
1.1 四根线背后的设计哲学
SPI 全称 Serial Peripheral Interface,摩托罗拉在上世纪 80 年代搞出来的。它只有四根线:
- SCLK:串行时钟,由主机产生,从机只能被动接收
- MOSI:Master Output Slave Input,主机输出、从机输入
- MISO:Master Input Slave Output,主机输入、从机输出
- CS:片选信号,低电平有效,由主机控制,决定跟哪个从机通信
这四根线的设计其实非常巧妙。SCLK 由主机产生意味着时钟源只有一个,天然避免了像 I2C 那样需要时钟同步和仲裁的麻烦。CS 信号的出现让总线上可以挂多个从机,只要 CS 拉低对应的设备,其他设备就把 MISO 置为高阻态,不干扰通信。
在实际项目中,我经常用一句大白话给新人解释:SPI 就是主机说一句,从机回一句,一来一回,靠时钟对齐。这句“一来一回”特别重要——SPI 是全双工的,也就是说主机发数据的同时也在收数据,这在很多场景下能省一半时间。
1.2 CPOL 和 CPHA 到底怎么选
这是新手最容易懵的地方。CPOL(Clock Polarity)决定时钟空闲时的电平,CPHA(Clock Phase)决定数据在哪个边沿采样。组合起来就是四种模式:
| 模式 | CPOL | CPHA | 时钟空闲电平 | 采样边沿 |
|---|---|---|---|---|
| Mode 0 | 0 | 0 | 低电平 | 上升沿 |
| Mode 1 | 0 | 1 | 低电平 | 下降沿 |
| Mode 2 | 1 | 0 | 高电平 | 下降沿 |
| Mode 3 | 1 | 1 | 高电平 | 上升沿 |
这里有个特别容易踩的坑:很多芯片文档里写的“数据在上升沿稳定、下降沿变化”并不是绝对真理,一定要看具体器件手册。比如 W25Q系列 Flash 默认支持 Mode 0 和 Mode 3,但 ADC 芯片 ADS1256 只支持 Mode 1。你如果不管三七二十一用 Mode 0 去读 ADS1256,读回来的数据全是乱的。
从FPGA的角度看,我建议优先用 Mode 0,因为上升沿采样、下降沿变化这种“边沿对称”的模式,在时序约束里最好做,时钟余量也比较宽裕。但前提是片外器件必须支持。判断方法也简单:看数据手册里的时序图,重点关注 SCLK 空闲时是高还是低,以及数据在哪个边沿被锁存。
1.3 SPI 和 I2C、UART 的选型差异
项目中经常遇到有人问:为什么不用 I2C?这里我按自己的工程经验做一个对比:
| 维度 | SPI | I2C | UART |
|---|---|---|---|
| 线数 | 4根(可减少) | 2根(SDA+SCL) | 2根(TX+RX) |
| 速率 | 最高可达几十~上百Mbps | 标准100k/400k,快速1M | 典型115200~几Mbps |
| 通信方式 | 全双工 | 半双工 | 全双工 |
| 多设备 | 靠CS一主多从 | 靠地址寻址 | 点对点 |
| 时序复杂度 | 简单、灵活 | 有ACK、仲裁 | 最简单 |
| 适用场景 | 高速、大数据量 | 低速、多设备、节省引脚 | 远距离、简单互联 |
SPI 在 FPGA 项目中的地位,说白了就是**“速度不够用、引脚不够省、协议不够复杂”的最佳折中点**。像 ADC 采样数据回传、Flash 读写、LCD 屏幕刷屏、传感器寄存器配置,SPI 都是主力。I2C 在 FPGA 里做的话,还得自己搞一个开漏输出和三态门,麻烦不少。
1.4 硬件片选和软件片选的区别
这个细节很多人不太在意,但我遇到过不止一次因为片选处理不当导致 Flash 读写偶发失败的情况。
硬件片选就是用 FPGA 的一个 GPIO 直接连到从机的 CS 引脚。好处是时序完全可控——CS 拉低后,你可以在任意时刻开始产生 SCLK,时钟结束后再拉高 CS。大多数场景都推荐这种方式,尤其是在速率较高或时序要求严格的时候。
软件片选则是把 CS 信号通过协议“编”进数据流里,常见于一些特殊的串行器件,比如单线协议或者需要发送特定命令头才能唤醒的芯片。这种模式下,FPGA 的 CS 引脚可能一直拉低,靠数据帧格式来区分通信对象。
实操中我的建议是:能用硬件片选就别用软件片选。硬件片选的时序干净利落,调试时用示波器一眼就能看出通信窗口;软件片选虽然省一根线,但排查问题时会多一层不确定性。
2. FPGA 实现 SPI 的整体设计思路
2.1 自己写 Verilog 还是用 IP 核
这是设计开始前必须决策的问题。Xilinx 提供了 AXI Quad SPI IP 核,用起来确实省事,但说实话,大部分项目里我还是倾向于自己写 RTL。原因有三个:
- SPI 协议本身不复杂,状态机加移位寄存器就能搞定,写一遍对协议的理解深度完全不一样
- IP核的灵活性有时候反而是负担,改一个参数就要重新配置和综合,调试周期拉长
- 自己写的代码可控性强,比如你可以精确控制 CS 拉低到 SCLK 第一个边沿的时间间隔,这在对接某些“脾气怪”的芯片时很关键
当然,如果项目时间紧、SPI 只是辅助功能而且对时序要求不高,用 IP核也完全合理。我刚入行时也用过,但后来发现,调试IP核内部信号远不如调试自己的状态机直观。
2.2 设计目标与模块划分
假设我们要实现一个通用的 SPI 控制器,既能做主又能做从,支持四种模式可配,数据位宽 8 位,最高时钟 50MHz。模块划分如下:
spi_master.v:SPI 主机状态机+移位寄存器spi_slave.v:SPI 从机逻辑spi_controller.v:顶层控制器,管理读写请求、数据缓冲clock_divider.v:时钟分频模块,产生 SCLK
这种结构的好处是职责单一、可复用性强。我最早写的时候把主从逻辑写在一个文件里,结果改一处就要测半天,后来拆开后清爽多了。
2.3 为什么推荐状态机的方式
SPI 的时序本质上是一个有限状态机:空闲→启动→传输→结束。用状态机实现的好处是:
- 代码逻辑和协议时序一 一对应,易于理解和维护
- 可以方便地插入超时、错误处理等逻辑
- 后续如果要扩展成多字节读写、DMA 接口,只需要增加状态即可
我之前试过用纯计数器+移位的方式写,代码是短了,但出问题后根本不知道哪拍的时序错了。后来全部改为状态机写法,出错时一眼就能定位到是启动阶段还是传输阶段。对于FPGA工程来说,优雅的代码远没有好调试的代码重要。
3. SPI 通信 FPGA 实现的代码与关键细节
3.1 跨时钟域处理:所有问题的根源
在设计 SPI 控制器之前,必须先想清楚跨时钟域(CDC)问题。SPI外围器件的输入和 FPGA 内部逻辑往往是异步的,典型场景是:
- FPGA 的主时钟比如 100MHz,而 SPI 从机的 SCLK 可能只有几 MHz
- 外部 ADC 输出的 SPI 数据与 FPGA 内部逻辑时钟完全没有相位关系
处理不好 CDC,轻则偶发数据错误,重则系统死机。我见过最典型的错误是直接拿 SCLK 当触发时钟去采样 MISO 数据,这在 SCLK 频率较低、寄存器时序余量足够时勉强能跑,但一旦温度变化或者芯片版本更换,马上就冒问题。
我的处理方案是:尽量把外部信号用两级触发器同步到 FPGA 系统时钟域,再用系统时钟去采样。具体到 SPI 从机模式,我会把 SCLK 和 CS 都用系统时钟同步一遍,然后检测 SCLK 的边沿事件,再用这个边沿事件作为移位寄存器的使能信号,而不是直接把 SCLK 当时钟。这样做的好处是,整个设计只有一个时钟域,时序约束简单,也不容易出现亚稳态。
3.2 SPI Master 核心代码逐段解析
这里给出一段经过工程验证的 SPI Master 核心代码框架:
module spi_master #( parameter CPOL = 1'b0, parameter CPHA = 1'b0, parameter DATA_WIDTH = 8 )( input wire clk, // 系统时钟,例如 50MHz 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 sclk, // 串行时钟 output reg cs_n, // 片选,低有效 output reg mosi, // 主机输出 input wire miso, // 主机输入 output reg busy // 忙标志 ); localparam IDLE = 3'd0; localparam SETUP = 3'd1; localparam TRANSFER = 3'd2; localparam FINISH = 3'd3; reg [2:0] state; reg [DATA_WIDTH-1:0] shift_reg; reg [$clog2(DATA_WIDTH):0] bit_cnt; reg miso_d1, miso_d2; // 两级同步 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; cs_n <= 1'b1; sclk <= CPOL; mosi <= 1'b0; busy <= 1'b0; bit_cnt <= 0; end else begin // 默认情况:保持当前状态 case (state) IDLE: begin cs_n <= 1'b1; if (start) begin cs_n <= 1'b0; // 拉低片选 shift_reg <= tx_data; bit_cnt <= 0; busy <= 1'b1; state <= SETUP; end end SETUP: begin // 等待半个SCLK周期,让从机准备好 sclk <= CPOL; // 仍保持空闲电平 state <= TRANSFER; end TRANSFER: begin // 根据 CPOL/CPHA 产生 SCLK 并在正确边沿采样/移出数据 // 这里演示 Mode 0:空闲低电平,上升沿采样 sclk <= ~sclk; // 产生时钟翻转 // 具体的数据移位逻辑需要配合边沿条件 if (sclk == 1'b0) begin // 上升沿即将到来 // 在上升沿之前把下一bit放到MOSI上 mosi <= shift_reg[DATA_WIDTH-1]; // 同步采样MISO miso_d1 <= miso; miso_d2 <= miso_d1; shift_reg <= {shift_reg[DATA_WIDTH-2:0], miso_d2}; bit_cnt <= bit_cnt + 1; if (bit_cnt == DATA_WIDTH-1) begin state <= FINISH; end end end FINISH: begin cs_n <= 1'b1; // 拉高片选,结束通信 sclk <= CPOL; busy <= 1'b0; state <= IDLE; end endcase end end assign rx_data = shift_reg; endmodule这个框架关键点有三个:
- 片选时序:IDLE 状态下 CS 保持高,检测到 start 后立即拉低并转移到 SETUP 状态。这样 CS 拉低到第一个 SCLK 沿之间有了充足的建立时间
- SCLK 生成:通过状态机内部的时钟翻转实现,而不是直接用高频时钟分频。这样做的好处是SCLK 的占空比和相位与状态机紧密相关,数据变化和采样点精确可控
- MISO 同步采样:miso_d1 和 miso_d2 是两级同步寄存器,能有效消除亚稳态。实际工作时,在 SCLK 的采样沿前一点把 MISO 打拍,确保采到稳定值
3.3 SPI Slave 设计的关键点
作为从机时,FPGA 需要接收外部主机发来的 SCLK,此时绝对不能把 SCLK 直接作为设计的主时钟。我的做法是:
// 用系统时钟同步外部 SCLK 和 CS reg sclk_sync1, sclk_sync2; reg cs_n_sync1, cs_n_sync2; wire sclk_rising = sclk_sync2 & ~sclk_sync1; // 检测上升沿 wire sclk_falling = ~sclk_sync2 & sclk_sync1; // 检测下降沿 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sclk_sync1 <= 1'b0; sclk_sync2 <= 1'b0; end else begin sclk_sync1 <= sclk; sclk_sync2 <= sclk_sync1; end end这里的核心思想是将异步的 SCLK 同步到系统时钟域,然后检测边沿标志。这样外部 SCLK 频率只要远低于系统时钟频率(经验法则是系统时钟至少是 SCLK 的 5 倍以上),就能稳定采样。
从机的数据逻辑不需要太复杂:检测到 SCLK 边沿时,根据模式把一个 bit 移入或移出。CS 拉高时表示一帧传输结束,把接收到的 8bit 数据锁存到输出寄存器。
3.4 一个完整案例:FPGA 与 STM32H743 的 FMC-SPI 桥接
在实际项目中,FPGA 很少单独作为 SPI 传输的端点。我去年做过一个项目,用 STM32H743 通过 FMC 总线并行访问 FPGA,FPGA 再通过 SPI 控制四个 ADC 芯片采集数据。
整个链路是:
- STM32H743 的 FMC 并行接口向 FPGA 写入控制字和数据
- FPGA 内部把并行数据转成 SPI 时序,分时轮询四个从机
- 采集结果通过 FPGA 的寄存器接口返回给 STM32
这里的难点在于:STM32 的 FMC 时序和 SPI 时序是两种完全不同的协议,中间需要一个数据缓冲和状态协调机制。我的做法是在 FPGA 内部开一组双口 RAM,主机写入的数据先存进 FIFO,SPI Master 状态机从 FIFO 取数据发送,收到的数据再写回另一个 FIFO。这样两边速率失配的问题就自然解决了。
这个案例也说明,SPI 在 FPGA 里从来不是孤立存在的,它往往是一长串数据通路中的一环,设计时要考虑前后模块的接口和时序配合。
4. 时序约束与上板调试实战
4.1 必须写好的时序约束
很多初学者写 SPI 代码后直接上板,发现有数据错误也不知道从哪查。其实很多时候问题出在没有定义好跨时钟域约束。对于 SPI 这种低速接口,至少需要:
set_input_delay:约束 MISO 输入数据的到达时间set_output_delay:约束 MOSI 和 SCLK 输出数据的输出时间set_false_path:对于 CS 信号与 SCLK 之间的路径,如果确定不需要严格分析,可以设为 false path
以 Xilinx Vivado 为例,SPI 接口的约束可以这样写:
set_input_delay -clock [get_clocks sclk] -max 10 [get_ports miso] set_input_delay -clock [get_clocks sclk] -min 2 [get_ports miso] set_output_delay -clock [get_clocks sclk] -max 8 [get_ports mosi] set_output_delay -clock [get_clocks sclk] -min 1 [get_ports mosi]如果你用的是系统时钟直接采 MISO,建议把 MISO 输入约束设为相对系统时钟的输入延迟。当然,最稳妥的办法是把 SPI 接口的信号都约束在同一个时钟域下——即用系统时钟作为唯一的时序参考。
4.2 如何用 ILA 和示波器定位问题
上板调试最大的痛苦是“看起来好像通了,但数据不对”。我的调试套路是:
第一步,用 ILA(集成逻辑分析仪)抓内部信号。把 CS、SCLK、MOSI、MISO、状态机 state 寄存器全抓出来,看一帧数据从启动到结束是否完整。这一步能排除 80% 的逻辑错误。比如你发现 CS 拉低后 SCLK 一直不变,那大概率是状态机卡住了;如果 SCLK 有波形但 MOSI 数据不对,检查移位寄存器使能条件。
第二步,示波器看物理层波形。重点观察 SCLK 的占空比是否异常、MISO 数据的建立时间和保持时间是否满足要求。我曾经遇到过一个“偶发错误”,用 ILA 看内部完全正常,示波器一测才发现 MISO 数据变化沿和 SCLK 采样沿太接近,余量只有 1~2ns。这种问题不是逻辑错误,是时序余量不够,解决办法要么降低 SCLK 频率,要么调整采样点位置。
第三步,逻辑分析仪抓整帧通信。如果你同时用到主从两端的 SPI,用一个支持协议解析的逻辑分析仪是最快的。它能直接标出每一 bit 是 0 还是 1,一眼看出哪一位错了。
4.3 常见的五类故障与排查速查表
| 故障现象 | 可能原因 | 排查方法与解决 |
|---|---|---|
| CS 拉低后无响应 | 状态机未启动,或 SCLK 分频参数错误 | ILA 观察 state 是否进入 TRANSFER,检查 start 信号是否满足时序要求 |
| 数据全对但多/少一位 | 数据位宽不匹配,或 CPOL/CPHA 模式选错 | 核对从机手册的时序图,用逻辑分析仪看位计数 |
| 偶发数据错误 | 跨时钟域亚稳态或者时序余量不足 | 增加两级同步寄存器,降低 SCLK 频率,调整 ILA 采样点 |
| MISO 一直高/低 | 从机未正确响应,可能片选地址错误或从机没上电 | 示波器量 MISO 引脚电平,确认从机状态,检查电源和复位 |
| 高速通信时波形失真 | PCB 走线过长或信号完整性差 | 降低 SCLK 频率,调整输出驱动强度,考虑串联终端电阻 |
4.4 关于仿真:ModelSim 和 Vivado Simulator 的取舍
上板之前一定要做仿真,这是铁律。我自己习惯用 Vivado Simulator 做功能性仿真,因为它集成在 Vivado 里,不需要额外配置,波形查看也方便。
SPI 仿真的关键是要写一个模拟从机行为的 testbench。最简单的做法是用行为级代码模拟一个从机:检测 SCLK 边沿,把接收到的 bit 存入寄存器,同时把预设的数据从 MISO 发出去。这样仿真跑一轮,就能同时验证主机的发送和接收通路。
有个小技巧:仿真时钟频率可以适当调高一点,比如实际 SCLK 是 10MHz,仿真可以设成 100MHz,这样跑一轮完整 8bit 传输只花仿真上的很短时间,方便快速迭代验证。
5. 实操心得与经验总结
说实话,SPI 在 FPGA 里的实现难度是“会者不难,难者不会”。我把过去几年踩过的坑浓缩成几条:
第一,一切要以器件手册为准。同样的 SPI,不同芯片的时序细节差异巨大。有的芯片要求 CS 拉低后至少等 100ns 才能产生时钟,有的芯片要求数据在 SCLK 下降沿前就稳定。这些数字只有手册能告诉你,猜是猜不出来的。
第二,调不通不是代码问题,是执行顺序问题。我的调试顺序永远是:先看物理层波形 → 再看内部逻辑 → 最后怀疑代码。很多新人一上来就盯着 Verilog 代码猛看,其实如果示波器上连波形都没有,那肯定是工程配置的问题,而不是逻辑的问题。
第三,客制化需求要提前封装。SPI 虽然协议简单,但每个器件总有一些“特殊要求”,比如某寄存器写入后需要等待、某指令需要不同的位宽。我会把这些特殊逻辑封装成单独的模块,而不是散落在主状态机里,否则后期维护起来会非常痛苦。
第四,通用性值得多花一点时间。如果项目里 SPI 会对接多个外设,参数化设计非常划算。把数据位宽、CPOL/CPHA、SCLK 分频系数做成参数,后期一个新器件接入可能只需要改两三行配置,不用动核心代码。
第五,留足时序余量是王道。不要卡着上限跑。比如器件最高支持 50MHz SPI,我通常只配到 25MHz 左右。高速数字设计里,余量就是稳定性和后续调试空间,省出来的那点时间在量产后会变成十倍百倍的坑。
SPI 通信的 FPGA 实现看似简单,但真正在工程中稳定跑起来,需要理解协议本质、处理好跨时钟域、写对时序约束,再配上一套靠谱的调试手段。把这五块吃透了,基本上 SPI 相关的项目都能稳稳拿下。