1. 为什么要在FPGA上实现SPI:从一次真实项目说起
1.1 那颗把时序卡死的ADC
我最早在FPGA里正经写SPI控制器,是因为一颗16位SAR ADC。这颗芯片的SPI接口要求主机在转换完成后一个很窄的窗口内把数据连续读走,否则下一轮转换会覆盖之前的结果。当时项目主控是MCU,SPI硬核同时还要服务一片SPI Flash和另外两片ADC,中断轮询模式下,最差情况会出现几百纳秒的响应抖动。就是这微不足道的几百纳秒,让ADC采样数据偶尔丢一个点。
换成FPGA之后,问题立刻消失。因为FPGA里实现的SPI Master,时序是完全确定的:多少个时钟周期启动、第几拍拉低CS、哪个边沿采样,全部预先算好,不受中断优先级和软件调度影响。这是SPI通信放在FPGA里和MCU外设之间最本质的区别——MCU是“尽量快去响应”,FPGA是“固定周期内必定完成”。
这个例子想说明的是,FPGA实现SPI不是让你用逻辑重新发明一遍单片机外设,而是为了在需要确定性、并发性或者非标准时序的时候,把SPI这个协议真正握在自己手里。这篇文章就是围绕这件事展开的:协议细节、Verilog实现、多设备挂载、调试排错,一路讲到我实际项目里验证过的写法。
1.2 SPI在FPGA项目里最常见的几类位置
我梳理一下平时在FPGA开发里见到SPI最多的场景,你可以对照自己的项目看看有没有落在这几个方向里:
- 多路ADC/DAC数据采集:FPGA做采样控制和实时处理,SPI是模拟前端和FPGA之间的数据通道。多个通道需要同步采样时,MCU串行外设一个个轮询很难满足。
- FPGA配置与存储:FPGA上电加载Flash、软核运行程序时的Flash读取,基本都是SPI或QSPI接口。很多板子上不止一片Flash,时序仲裁也得FPGA自己来。
- 显示屏与传感器:ST7789这类SPI屏、温湿度传感器、气压计、工业绝对编码器等,FPGA内部的主控逻辑通过SPI和它们交换数据。
- FPGA和处理器之间的板级通信:两个芯片之间传控制状态、传小批量实时数据,SPI因为线少、简单、容易时序收敛,经常被用作低成本互连方案。
这些场景里,SPI很少是项目里最出彩的模块,但它一旦不稳定,整个链路都会出问题。我见过太多人调试SPI的时间比写核心算法还长,所以这篇文章把协议、代码、调试一次讲明白,希望你能绕开我踩过的那些坑。
1.3 SPI和IIC的差异,决定了两者的设计思路完全不同
很多人会把SPI和IIC放在一起对比,这在FPGA实现层面是个很关键的区别。
SPI是全双工,主机发数据的同时从机也在回数据,而且没有应答机制。IIC是半双工,靠地址、ACK和时钟拉伸来管理链路,实现起来状态更多,总线冲突检测也更麻烦。FPGA实现IIC通常需要更复杂的位级状态机,SPI则更像一条纯粹的移位数据流,只要把CS和SCLK的时序卡准,几乎不需要考虑仲裁问题。
所以我做接口选型时的顺序是:能选SPI就优先SPI,尤其是FPGA做从机或者和转换器打交道的时候。SPI少掉“地址仲裁”这一层复杂度,调试成本明显更低。
2. SPI协议的四件事:模式、速率、位序和片选
代码不会骗人,但协议理解错了,代码写得再漂亮也没用。所以在写Verilog之前,先把SPI协议的几个关键点过一遍。
2.1 四根线和一次完整事务
标准SPI有四根线:SCLK、MOSI、MISO、CS。有些器件会把MOSI和MISO合并成一根双向线,有些还会把CS拆成两根,但绝大多数场景还是四线形态。
一次完整事务可以拆成三个阶段:CS拉低标志着事务开始,主机和从机配合SCLK一位一位交换数据,CS拉高标志着事务结束。CS拉低到第一个SCLK有效边沿之间的窗口是建立时间,最后一个SCLK边沿到CS拉高之间的窗口是保持时间,这两个参数在很多从设备手册里都有明确要求。FPGA实现时,我习惯在状态机里显式留出至少一个SCLK周期的余量,而不是让CS和第一个边沿发生在同一拍。这样能兼容市面上绝大多数从设备。
2.2 四种模式:一张表加两个判断技巧
SPI四种模式由CPOL和CPHA两个参数区分,CPOL决定SCLK空闲电平,CPHA决定采样边沿。
| 模式 | CPOL | CPHA | 空闲SCLK | 数据变化沿 | 采样边沿 |
|---|---|---|---|---|---|
| 模式0 | 0 | 0 | 低 | 下降沿 | 上升沿 |
| 模式1 | 0 | 1 | 低 | 上升沿 | 下降沿 |
| 模式2 | 1 | 0 | 高 | 上升沿 | 下降沿 |
| 模式3 | 1 | 1 | 高 | 下降沿 | 上升沿 |
这个表几乎每篇SPI文章都有,但我想补充两个实际判断技巧:
第一个技巧是模式0最常用,很多FPGA板卡自带的ADC、Flash、OLED屏都工作在模式0。新项目可以先按模式0设计,出问题再查手册改。第二个技巧是不要从代码里去猜模式,拿逻辑分析仪抓一下SCLK和MOSI,把两个bit之间的对应关系画出来,自然就知道是采样边沿选错了还是数据变化边沿选错了。
2.3 模式0的典型时序
以模式0为例,SCLK空闲低电平,主机在SCLK下降沿把数据更新到MOSI上,从机在上升沿采样;反过来,从机在下降沿把下一bit更新到MISO上,主机在上升沿采样。这个“下降沿变化、上升沿采样”的节奏是SPI最常见的。
写FPGA状态机时,我的思路是:内部用分频计数产生一个高低交替的时钟使能信号,再把这个信号的上升沿和下降沿转换成两个单周期脉冲。上升沿触发“采样输入”,下降沿触发“更新输出”。这样就把SPI的物理时序转成了普通同步逻辑,程序看起来更直观,也更容易约束。
2.4 位序:芯片手册里最容易忽略的坑
SPI协议本身没有规定数据必须MSB在前还是LSB在前。Flash、ADC绝大多数是MSB first,但SPI屏、部分传感器经常是LSB first,甚至有些器件还有寄存器可以切换位序。
如果调试时发现读回来的数值完全反向,优先查位序。FPGA里处理位序非常灵活,只需要把移位寄存器的方向反过来就行。真正花时间的是确认设备手册到底要哪种顺序。位序错了之后,模式、分频、引脚配置全对,读回来的数据也是稳定错误的,这种问题用ILA一抓就能看出来,但不去往位序上想会绕很久。
2.5 SCLK频率怎么定:分频计算和余量思维
SCLK不是越高越好。要考虑从设备支持的最大SCLK、板级线长和寄生电容、以及主机在目标频率下能否稳定满足建立保持时间。实际工程里我习惯按从设备最大SCLK的一半作为起步值,跑通后再逐步往上提。
分频系数计算很简单:系统时钟频率除以目标SCLK频率,得到分频倍数。比如系统时钟100MHz,目标SCLK是10MHz,分频系数就是10。代码里做占空比50%的分频,也就是计数到5翻转一次。我一般尽量选偶数分频系数,因为奇数分频会让SCLK变成非50%占空比,大多数设备没影响,但少数芯片对高电平和低电平的最短时间分别有要求,排查时容易头晕。
3. 一个通用SPI Master的Verilog实现
3.1 写代码前先想清楚需求
在动手写SPI控制器之前,我习惯先列需求:数据宽度固定8位还是可配置?是否需要连续多字节传输?时钟频率是固定还是需要运行时调节?极性相位是固定还是支持切换?把这些定下来,再决定用什么样的模块结构和参数设计。
对通用场景,我更推荐做一个轻量参数化Master,把DATA_WIDTH、SCK_DIV、CPOL、CPHA作为参数暴露出来,而不是在代码里把模式写死。这样从一个项目复用到另一个项目,只需要改例化参数,不需要动RTL。
3.2 SCLK生成:用内部脉冲而不是独立时钟
FPGA设计里一个常识又是重灾区的问题:不要在内部把sclk直接当触发器的时钟使用。原因很简单,内部翻转的时钟会让综合工具很难约束,也会在布局布线后引入明显的时钟偏斜风险。
我的做法是生成一个div_cnt分频计数器,控制sclk_r的高低翻转,然后用sclk_r和它延迟一拍后的旧值算出边沿脉冲:
wire sclk_rise = sclk_r && !sclk_prev; wire sclk_fall = !sclk_r && sclk_prev;这样所有寄存器仍然由系统全局时钟统一触发,sclk只是一个输出到引脚上的波形,逻辑内部靠“边沿脉冲”来决定何时移入移出数据。代价只是逻辑多了一个判断沿有效的if条件,但在现代FPGA上这点开销可以忽略。
3.3 握手信号设计
Master和FPGA内部其他模块之间,我通常用一组简单握手信号完成交互。start是发起请求信号,外部逻辑准备好tx_data后拉高start;Master检测到start有效且自身空闲,拉低CS进入传输状态,同时busy拉高;传输完成后busy拉低,done给出一个周期的高脉冲,rx_data输出收到的数据。
这套接口很干净,可以直接接状态机、接FIFO,或者包一层寄存器映射给CPU访问。在Zynq这类SoC里,也可以把这组合手信号改成AXI-Lite寄存器,后面第6节我会细说。
3.4 示例代码:模式0的核心状态机
下面是一段可以直接仿真和上板的核心代码,为了看起来清楚,我把状态精简成三个,分频模块和外围接口都在,状态机的数据通路逻辑是完整的。
module spi_master #( parameter DATA_WIDTH = 8, parameter SCK_DIV = 16, 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, output wire sclk, output reg mosi, input wire miso, output reg cs_n ); reg [15:0] div_cnt; reg sclk_r; reg sclk_prev; wire sclk_rise = sclk_r && !sclk_prev; wire sclk_fall = !sclk_r && sclk_prev; reg [DATA_WIDTH-1:0] tx_shift, rx_shift; reg [$clog2(DATA_WIDTH)-1:0] bit_cnt; reg [1:0] state; localparam IDLE = 2'b00; localparam TRANSFER = 2'b01; localparam FINISH = 2'b10; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin div_cnt <= 0; sclk_r <= CPOL; end else begin if (div_cnt == (SCK_DIV/2 - 1)) begin div_cnt <= 0; sclk_r <= ~sclk_r; end else begin div_cnt <= div_cnt + 1; end sclk_prev <= sclk_r; end end assign sclk = sclk_r; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; cs_n <= 1'b1; mosi <= 1'b0; busy <= 1'b0; done <= 1'b0; rx_data <= {DATA_WIDTH{1'b0}}; tx_shift <= {DATA_WIDTH{1'b0}}; rx_shift <= {DATA_WIDTH{1'b0}}; bit_cnt <= 0; end else begin case (state) IDLE: begin done <= 1'b0; if (start) begin tx_shift <= tx_data; bit_cnt <= 0; cs_n <= 1'b0; busy <= 1'b1; state <= TRANSFER; end end TRANSFER: begin if (sclk_fall) begin mosi <= tx_shift[DATA_WIDTH-1]; tx_shift <= {tx_shift[DATA_WIDTH-2:0], 1'b0}; end if (sclk_rise) begin rx_shift <= {rx_shift[DATA_WIDTH-2:0], miso}; if (bit_cnt == DATA_WIDTH - 1) state <= FINISH; else bit_cnt <= bit_cnt + 1; end end FINISH: begin cs_n <= 1'b1; rx_data <= rx_shift; busy <= 1'b0; done <= 1'b1; state <= IDLE; end endcase end end endmodule这段代码有几个细节值得专门说明:
- sclk_rise和sclk_fall是利用sclk_r和它寄存器延迟后的旧值算出来的单周期脉冲。sclk_r虽然最终输出到SCLK引脚,但内部不会把它当异步时钟用,所有状态跳转仍然发生在统一的系统时钟上升沿。
- 默认使用模式0,所以数据更新放在sclk_fall,数据采样放在sclk_rise。如果改成CPHA=1,需要交换两个动作的边沿,或者移动一个周期;CPOL只影响sclk空闲时的初始状态。
- FINISH状态单独存在,是为了保证最后一个时钟沿之后CS再拉高,避免提前释放导致最后一个bit被截断。这个细节在高速Flash操作里尤其重要。
3.5 从“发一个字节”到“连续收发”
上面的Master一次只能处理一个DATA_WIDTH的数据,实际项目里经常需要连续发一串数据,比如写Flash的时候要先发命令字节、地址字节,再发数据。我的做法是在Master外面包一层命令状态机,由它控制多次调用这个Master核,中间用FIFO把要发的数据和收到的数据缓存起来。
外部FIFO加Master的好处有几个:一是收发数据有缓冲,二是SPI控制器能和算法逻辑解耦,三是需要连续读多字节的时候可以始终不拉高CS,中间只续传下一笔数据。实现“不拉高CS”需要在两个事务之间进入一个保持CS低电平的等待状态,从设备仍然认为当前是一次长事务。这个能力在访问SPI Flash和某些传感器时非常关键。
4. 从机设计、多设备挂载与片选选择
4.1 SPI从机的实现核心
FPGA做从机的场景没有主机多,但确实存在,比如FPGA作为协处理器,主控器件通过SPI访问FPGA内部寄存器。从机侧的实现核心是:外部SCLK到来时,在正确的边沿采样MOSI,在需要的边沿把MISO数据推出去,同时CS信号决定是否参与总线。
从机相比主机麻烦的地方在于,外部SCLK和FPGA内部系统时钟往往是异步的。如果两端来自同一个晶振,算是准同步,风险低;如果是两颗芯片、两套时钟源,就必须做同步处理。我建议把SCLK和CS先通过两级同步器同步到系统时钟域,再在同步后的SCLK边沿进行采样。同步器会引入固定延迟,但大多数从机场景不要求同一个时钟沿立刻回数据,所以这个延迟可以接受。
4.2 从机的MISO必须三态输出
从机的MISO在CS没有被拉低时,原则上应该是高阻态,不能一直输出。原因是多设备共享MISO总线,如果两个从机同时驱动,轻则数据错乱,重则损伤引脚。FPGA里实现三态非常简单:
assign miso = (!cs_n_sync) ? miso_drive : 1'bz;FPGA的普通IO都支持三态,但板级设计我会加一个上拉电阻,默认把MISO置为高。这样从机释放总线期间,主机端读MISO不会浮空,逻辑分析仪也不容易看到不稳定的杂波。
4.3 硬件片选和软件片选:实际项目里的判断标准
“spi硬件片选与软件片选”这个搜索词很常见,我展开说下我的理解。
硬件片选在FPGA语境里,通常指CS信号由SPI控制器状态机在正确时刻自动生成,和SCLK边沿严格同步。软件片选则是通过一个普通GPIO输出,由外部模块或处理器用寄存器读写的方式去拉高拉低。
| 维度 | 硬件片选 | 软件片选 |
|---|---|---|
| 生成时机 | 跟随SPI状态机自动生成 | 任意寄存器写操作 |
| CS建立时间 | 可以精确设计 | 取决于软件调度 |
| 事务边界 | 自动对齐数据长度 | 需要软件保证边界 |
| 典型场景 | 标准SPI外设、多从机共享总线 | 非标时序、兼容性兜底 |
| 调试难度 | 波形直观 | 依赖软件配合 |
我的经验是:能用硬件片选就不要用软件片选。硬件片选天然和SCLK对齐,状态机跳转也好控制。软件片选适合那些CS和SCLK之间没有固定时序关系的场景,比如某些模块要求CS保持时间非常长,或者一个CS信号要控制多个内部寄存器访问。没有绝对好坏,只是控制粒度不同。
4.4 多从机共享总线的设计原则
在共享SCLK、MOSI、MISO的总线上挂多个从机,我建议遵循这几点:每个从机独立CS信号,由同一个SPI控制器输出,但任何一个时刻只能有一个CS为低;从机MISO必须是三态,只有自己CS为低时才输出;事务之间加一个空闲周期,让上一个从机的三态彻底释放,再选下一个从机;如果不同从机工作电压不同,先做电平转换再接总线。
遇到通信错误时,优先查CS切换时序。用ILA同时抓两个CS的波形,看第一个CS拉高和第二个CS拉低之间隔了多少拍。如果小于等于1拍,把间隔加长到3拍以上,往往能解决很多总线串扰问题。
5. 调试SPI通信:从抓波形到定位问题的实际经验
5.1 逻辑分析仪和ILA到底怎么分工
调试SPI,我两种工具都用,但侧重不同。
逻辑分析仪适合看引脚级真实波形,尤其是怀疑板级信号完整性问题的时候,比如过冲、振铃、毛刺,这些模拟特性在ILA里看不到。ILA或者SignalTap适合看FPGA内部的状态机跳转和寄存器值,能判断到底是内部逻辑错误还是外部芯片问题。
我的习惯是:先用ILA确认状态机跳转、CS时序、发送的tx数据和采到的rx数据是否符合预期;如果内部逻辑全对,但从设备就是不回数据,再用逻辑分析仪看引脚上的真实时序。这个顺序能最快把排查范围缩小。
5.2 用ILA抓SPI,触发条件怎么设
很多人抓SPI抓不住帧,是因为把触发条件设成了某个数据值,但数据出现的时机不确定。我更推荐用CS下降沿或者start信号上升沿作为触发条件。
具体在Vivado里,例化ILA时添加探针到CS、SCLK、MOSI、MISO、state这些信号,然后把CS_n设为下降沿触发。抓到的波形窗口尽量覆盖一整笔事务。如果一次只抓到一半,把采样深度调大,或者把触发位置设成“中段触发”,保留触发前的记录。这样抓到的波形才能完整反映事务时序。
5.3 一个真实案例:总是随机错一个bit
回到开头说的那颗ADC。那次问题表象是读数据偶尔错一位,但又不是固定某一位。一开始怀疑采样边沿,把模式改成模式1试,错误反而更多。最后用ILA抓了完整波形,放大多个bit周期逐段看,才发现问题有两个:一是SCLK高电平持续时间偏短,没有满足ADC的数据稳定时间要求;二是CS释放前的最后一个上升沿之后,我立刻拉高了CS,没留保持时间。
修法并不复杂:把分频系数加大,让SCLK从10MHz降到5MHz,同时在FINISH状态加一个等待拍,确保最后一个SCLK边沿和CS上升沿之间至少有半个SCLK周期的间隔。改完跑了整整一天,数据再没出过问题。
这类问题的根因,如果只盯着代码里的状态变量,很难发现。因为状态机逻辑本身是对的,问题出在物理时序余量不够。这也是我为什么反复强调用ILA抓真实波形,而不是只看代码逻辑。
5.4 常见故障速查表
| 疑似问题 | 常见根因 | 快速排查方式 |
|---|---|---|
| 从机完全不响应 | CS未拉低、SCLK没进从机、从机未上电 | 检查引脚约束,用逻辑分析仪量引脚 |
| 读回数据全1 | MISO被上拉,从机三态未释放 | 查从机供电、CS时序、代码三态 |
| 读回数据全0 | MISO被下拉,或从机一直输出0 | 查从机配置、总线是否短路 |
| 数据完全反向 | 位序MSB/LSB不匹配 | 查器件手册,改移位方向 |
| 偶发性错误 | 建立保持时间不足、板级干扰 | 降速、加事务间隔、检查走线 |
| 事务正常但数据不对 | 从设备配置命令不对 | 拆成单字节操作逐条验证 |
这张表是我平时排查的起点。很多看起来复杂的问题,根因都在这几项里。尤其是“时好时坏”的问题,优先考虑余量不足,而不是代码逻辑有bug。
5.5 跨时钟域处理:从机侧异步信号同步
如果FPGA做从机,外部SCLK进来是异步信号,直接拿它去触发寄存器会产生亚稳态风险。保险的做法是先用两级同步器同步到系统时钟域,再检测边沿:
reg sclk_sync1, sclk_sync2; 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_i; sclk_sync2 <= sclk_sync1; end end wire sclk_edge_rise = sclk_sync2 && !sclk_sync1; wire sclk_edge_fall = !sclk_sync2 && sclk_sync1;同步器会让信号晚两个时钟周期,但能消除亚稳态。低速SPI下完全没问题,高速场合就要考虑把外部SCLK引到专用时钟网络上。
6. 高速和特殊场景:FPGA SPI设计的边界
6.1 SCLK可以跑多快,瓶颈到底在哪
SCLK上限由三方面决定:FPGA的IO标准、内部逻辑路由延时、从设备本身的最高频率。普通LVCMOS电平的FPGA IO跑到几十MHz到百MHz并不难,难点在逻辑处理链路的延迟和外部走线的信号完整性。SCLK超过100MHz且走线较长时,要考虑端接、阻抗匹配和IO标准调整。
FPGA内部真正需要关注的约束是SCLK与MOSI、MISO之间的相对时序。综合工具不会自动分析SCLK和数据线之间的timing关系,因为SCLK只是普通输出信号,不是传统意义的全局时钟。建议在约束文件里为SCLK设置虚拟时钟,对MOSI和MISO设置输入输出延时约束,不然高速SPI很容易出现仿真正常、上板异常的尴尬。
6.2 非标SPI:Flash的连续读、Dummy周期和QSPI
SPI Flash的标准读操作不是简单的一次数据交换。它包含命令字节、地址字节、Dummy周期,然后才是连续数据输出。FPGA实现这类序列,我通常采用“命令+地址+dummy+数据”的复合状态机,而不是在几个通用Master之间切换。
复合事务最关键的一点:整个流程中CS不能拉高,一旦拉高,Flash就认为本次访问结束。所以复合状态机必须在自己内部维护CS保持。通用Master做不到这一点,因为两笔独立事务之间CS一定会释放。这也是我只把通用Master当作基础交换部件的原因,真正的项目里还会按设备定制一层协议引擎。
QSPI则是一次传输4bit,把MOSI/MISO扩展成四根双向IO。协议上比SPI复杂,但FPGA实现起来难点在IO方向切换和时序同步。如果你只是想让FPGA配置一颗QSPI Flash,我建议先固定用标准SPI读芯片ID,能正确回读后再启用QSPI模式,一步一步验证最稳。
6.3 在Zynq这类SoC里给SPI包一层AXI-Lite
如果你的平台是Zynq这种FPGA加Arm核的架构,SPI控制器通常要做成AXI-Lite外设,让PS端软件通过寄存器读写发起SPI事务。接口包装不复杂,把start、tx_data、rx_data、busy、done映射到寄存器位即可。
但我建议不要一上来就设计一个寄存器满天飞的大模块。先做最小寄存器集:一个DATA_REG,写入要发送的数据并自动触发;一个STATUS_REG,返回busy、done和rx_data;如果确实需要,再加DIV_REG和MODE_REG用于运行时改速率和模式。软件端先轮询busy完成收发,跑通后再考虑中断、FIFO和DMA。
SPI本身没有流控,DMA连续发送时从设备跟不上就会丢数据。之前做一个图像采集项目,FPGA的SPI DMA没有考虑传感器处理时间,连续读数据时偶尔漏帧,最后靠一个深度只有16的FIFO就解决了。所以先小后大,比一开始就上DMA更稳。
6.4 一个实用建议:调试FPGA SPI时先降速
如果你正被SPI不稳定卡住,我建议先把SCLK降到设计值的十分之一甚至几百kHz,然后跑一个最简单的回环测试:把MOSI和MISO短接,用0x55、0xAA这类数据,看Master能不能收到自己发出去的内容。能收到,说明代码链路基本没问题;收不到,大概率是引脚约束、IO方向或者状态机的问题。
反过来,回环测试没问题,接上真实从设备却出错,那问题多半出在从设备配置、协议模式或者板级信号上,和FPGA内部逻辑关系就不大了。这个分层的排查思路,是做SPI调试最值得养成的习惯。
在实际项目里把SPI做多了以后,你会发现真正难的从来不是写那几行Verilog,而是能不能把芯片手册、时序图、约束文件、示波器波形当成一个整体去看。SPI在FPGA设计里位置虽然小,但它往下通着引脚电平,往上通着数据通路和算法逻辑,一段不亮,整个业务就卡住。这篇文章把我的这些经验整理出来,希望能帮你少走几步弯路。