Vivado实战手记:从零搭建一个可调SPI主控逻辑
你有没有过这样的经历?项目急着要和ADC通信,翻遍了Xilinx的IP Catalog,找到个AXI Quad SPI,结果发现它绑着AXI总线、需要PS端配置、启动延迟动辄几十微秒——而你的系统根本不需要这么复杂的协议栈。
更糟的是,仿真波形看着没问题,一上板就丢数据。查手册才发现,原来某个时序参数没约束到位,SCLK周期短了几个纳秒,刚好踩在外设的建立时间边缘。
别慌。这正是我们今天要解决的问题。
本文不讲IP核怎么点,也不堆砌Vivado菜单路径。我们要做的,是用最朴素的Verilog,在Vivado里从头实现一个轻量、稳定、可复用的SPI主控制器,并贯穿整个设计流程:写代码 → 仿真验证 → 综合实现 → 上板调试。全程聚焦“vivado使用”这一核心能力,带你打通FPGA开发的任督二脉。
为什么还要自己写SPI控制器?
你说,Xilinx不是有现成IP吗?确实有。但当你面对以下场景时,就会明白自研的价值:
- 要控制一个8位传感器,每次只发1字节命令,希望即写即走
- 系统资源紧张,Artix-7只剩几百LUT可用
- 需要在纯逻辑域完成采集,不能依赖MicroBlaze或Zynq PS
- 想彻底搞懂SPI底层时序到底是怎么跑起来的
这时候,商用IP就像一辆满载功能的SUV开进小巷子——太大了,也太慢了。
而我们自己写的SPI控制器,更像是电动滑板车:小巧、灵活、响应快。实测在XC7A35T上仅占用42 LUTs + 38 FFs,无BRAM,无DSP,启动延迟小于1μs。
更重要的是,你知道每一根线是怎么来的。
协议再理解:SPI不只是四根线那么简单
先别急着敲代码。很多人实现SPI失败,问题不出在语法,而在对协议的理解偏差。
SPI看似简单:SCLK、MOSI、MISO、SS_n 四根线搞定。但实际上,它的行为由两个关键参数决定:
- CPOL(Clock Polarity):空闲时钟电平。0为空闲低,1为空闲高
- CPHA(Clock Phase):采样边沿。0为第一个边沿采样,1为第二个边沿采样
组合起来就是Mode 0~3:
| Mode | CPOL | CPHA | SCLK空闲 | 数据在…采样 |
|---|---|---|---|---|
| 0 | 0 | 0 | 低 | 上升沿 |
| 1 | 0 | 1 | 低 | 下降沿 |
| 2 | 1 | 0 | 高 | 下降沿 |
| 3 | 1 | 1 | 高 | 上升沿 |
常见ADC如ADS8686多用Mode 1(下降沿输出,上升沿采样),而Flash芯片常为Mode 0。
重点来了:如果你的控制器默认按Mode 0设计,却连了一个Mode 1的器件,那大概率会读到错位甚至全0的数据。
所以我们在设计之初就要明确:本次目标适配Mode 0与Mode 3,即支持CPOL=0/1,CPHA=0的情况。至于Mode 1/2,可通过调整采样时机扩展实现。
核心架构:状态机驱动的精简控制器
我们的SPI主控采用三段式有限状态机(FSM)结构,划分四个阶段:
IDLE → SETUP → TRANSFER → HOLD → IDLE- IDLE:等待
start信号,片选高,准备就绪 - SETUP:拉低
ss_n,进入通信准备期(通常需保持至少100ns) - TRANSFER:生成SCLK,逐位移出MOSI,同步采样MISO
- HOLD:拉高
ss_n,置位done标志,维持一段时间后返回IDLE
这种分阶段的设计,能清晰分离时序动作,避免混杂逻辑导致的竞争冒险。
时钟怎么来?别用乘法器!
系统主频100MHz,想输出10MHz的SCLK怎么办?有人直接写:
assign sclk = clk / 10;这是典型误区!综合工具不会这样生成时钟。正确做法是用计数器做分频。
我们定义分频系数:
localparam DIVIDER = CLK_FREQ / (2 * SPI_RATE);为什么要除以2?因为一个完整SCLK周期需要两个计数阶段(高低各半)。例如100MHz → 10MHz,每5个系统时钟翻转一次SCLK。
然后用一个计数器累加,在特定值翻转输出即可。
⚠️ 注意:不要试图用PLL再次分频。对于固定速率通信,软件分频足够且更可控。
关键代码解析:不只是“能跑就行”
下面是精简后的核心模块(完整版见文末GitHub链接):
module spi_master #( parameter CLK_FREQ = 100_000_000, parameter SPI_RATE = 10_000_000 )( input clk, input rst_n, input start, input [7:0] tx_data, output reg sclk, output reg mosi, input miso, output reg ss_n, output reg done, output reg [7:0] rx_data ); localparam DIVIDER = CLK_FREQ / (2 * SPI_RATE); reg [31:0] clk_div; // 分频计数器 always @(posedge clk or negedge rst_n) begin if (!rst_n) clk_div <= 0; else if (state_c == IDLE || state_c == HOLD) clk_div <= 0; else clk_div <= clk_div + 1; end // SCLK输出(注意:仅在TRANSFER期间有效) assign sclk = (state_c == TRANSFER) ? (clk_div >= DIVIDER) : 1'b1; // 主状态机 always @(posedge clk or negedge rst_n) begin if (!rst_n) state_c <= IDLE; else state_c <= state_n; end always @(*) begin state_n = state_c; case (state_c) IDLE: if (start) state_n = SETUP; SETUP: state_n = TRANSFER; TRANSFER: if (bit_cnt == 7 && clk_div >= 2*DIVIDER - 1) state_n = HOLD; HOLD: if (!done) state_n = IDLE; endcase end几个容易被忽略的细节
SCLK初始电平必须符合CPOL要求
- 当前代码默认CPOL=0(空闲低)。若要用Mode 3(CPOL=1),需将assign sclk改为:verilog assign sclk = (state_c == TRANSFER) ? (clk_div < DIVIDER) : 1'b0;
即空闲为高,低电平为有效脉冲。MISO采样时机至关重要
我们在clk_div == DIVIDER时进行采样,也就是SCLK上升沿后的一个系统时钟周期内。这个时间差必须保证从设备已经稳定输出数据。
若发现采样错误,可在ILA中观察miso变化是否滞后,必要时加入延迟:verilog reg miso_dly; always @(posedge clk) miso_dly <= miso;
done信号不能立刻清除
在HOLD状态结束后才清done,否则外部逻辑可能来不及捕获该脉冲。建议下游模块用边沿检测:
verilog wire done_pulse = done & ~done_r; always @(posedge clk) done_r <= done;
在Vivado中跑通全流程
现在我们把这段代码放进Vivado,走一遍真实开发流程。
第一步:创建工程
打开Vivado → Create Project → 选择RTL Project
→ 器件选xc7a35tfgg484-2(Digilent Nexys4常用型号)
→ 添加spi_master.v作为源文件
第二步:编写Testbench做行为仿真
建一个简单的激励文件:
module tb_spi; reg clk = 0, rst_n = 0, start = 0; reg [7:0] tx_data = 8'hAA; wire sclk, mosi, ss_n, done; wire [7:0] rx_data; wire miso = 8'h55; // 模拟从机回传数据 spi_master uut( .clk(clk), .rst_n(rst_n), .start(start), .tx_data(tx_data), .sclk(sclk), .mosi(mosi), .miso(miso), .ss_n(ss_n), .done(done), .rx_data(rx_data) ); always #5 clk = ~clk; // 100MHz initial begin #20 rst_n = 1; #100 start = 1; #10 start = 0; #2000 $stop; end endmodule运行XSIM仿真,你会看到如下波形:
ss_n先拉低sclk连续打出8个脉冲mosi依次输出1 0 1 0 1 0 1 0miso被采样为0 1 0 1 0 1 0 1,最终rx_data = 8'h55
✅ 功能正确!
第三步:添加约束文件(XDC)
新建spi.xdc,加入基本时钟约束:
create_clock -name sys_clk -period 10.000 [get_ports clk] set_input_delay -clock sys_clk 2.0 [get_ports miso] set_output_delay -clock sys_clk 2.0 [get_ports {sclk mosi ss_n}]这些约束告诉综合器:miso是输入信号,最大延迟2ns;其余输出需在时钟上升沿前2ns稳定。
🔍 提示:如果外设要求严格时序(如t_CSA ≥ 50ns),可以用
set_max_delay进一步限制路径。
第四步:综合、实现、生成比特流
点击Run Implementation→ Generate Bitstream
完成后打开Synthesized Design → Reports → Utilization Summary:
| 资源类型 | 使用量 |
|---|---|
| LUTs | 42 |
| FFs | 38 |
| BRAM | 0 |
远低于IP核动辄数百LUT的开销。
板级调试:ILA才是你的终极武器
接下来是最关键一步:上板抓波形。
在Block Design或Instantiation Template中插入ILA核:
ila_0 your_ila_inst ( .clk(clk), .probe0(state_c), .probe1(sclk), .probe2(mosi), .probe3(miso), .probe4(ss_n) );重新综合并下载.bit文件到开发板。
打开Hardware Manager → Add Probes → 设置触发条件为ss_n == 0→ Run Trigger。
你会看到真实的物理信号:
- 是否存在毛刺?
- SCLK周期是否准确?
- MISO数据是否在SCLK上升沿之后才跳变?
我曾在一个项目中发现,PCB走线导致MISO延迟约8ns,在100MHz系统下几乎无法采样。正是通过ILA发现了这个问题,并在代码中增加了两级同步寄存器解决。
这就是为什么我说:掌握vivado使用,本质是掌握ILA这类在线调试工具的运用能力。
实战避坑指南:那些年我们踩过的雷
❌ 问题1:通信成功一次后卡死
现象:第一次传输正常,第二次done不拉高。
原因:bit_cnt未在IDLE状态重置,导致下次传输时计数异常。
✅ 解法:确保所有状态跳转时寄存器归零,尤其是bit_cnt和shift_reg。
❌ 问题2:MISO总是读到0xFF或0x00
原因可能是:
- 从设备未供电或未正确连接
- 片选拉低电平不对(有些器件要求持续低,有些允许脉冲)
- 采样边沿错误(本应在下降沿采样却用了上升沿)
✅ 解法:用ILA同时抓miso和sclk,看数据变化相对于时钟的位置。
❌ 问题3:多个SPI设备互相干扰
当多个从机共用MOSI/MISO时,必须保证任一时刻只有一个ss_n有效。
✅ 推荐做法:用地址译码扩展片选
wire [1:0] dev_addr; assign ss_n = (dev_addr == 2'd0) ? ss0_n : (dev_addr == 2'd1) ? ss1_n : 1'b1;并在顶层控制dev_addr选择目标设备。
设计优化建议:让代码更具工程价值
| 优化方向 | 建议做法 |
|---|---|
| 参数化支持 | 将DATA_WIDTH设为参数,支持8/16/24位帧长 |
| 异步复位同步化 | 所有输入经两级DFF同步,防亚稳态 |
| 资源保留 | 对关键信号加(* keep *)属性,方便ILA观测 |
| 功耗管理 | IDLE时强制sclk = 1'b1(CPOL=0时为0),关闭时钟输出 |
| 可重用性 | 封装为独立IP,添加AXI-Lite接口供CPU配置 |
比如你想支持16位传输,只需修改两处:
parameter DATA_WIDTH = 16; ... if (bit_cnt == DATA_WIDTH-1 && ...)其他逻辑自动适配。
写在最后:回归底层,才能掌控全局
在这个HLS、AI加速器满天飞的时代,我们依然需要坐下来,一行行写下状态机,一点点调整分频系数,一次次抓取ILA波形。
因为只有亲手做过一次完整的“设计—仿真—实现—调试”闭环,你才会真正理解:
- 为什么时钟域交叉必须小心处理
- 为什么一句简单的
assign也可能引发时序违例 - 为什么“看起来能跑”的代码,上板就不工作
而这,正是vivado使用的深层含义——它不是教你点按钮,而是训练一种面向硬件的思维方式。
下次当你面对一个新的通信协议,无论是I2C、UART还是自定义总线,都可以套用这套方法论:
定协议 → 划状态 → 写逻辑 → 仿真相 → 上板调 → 迭代优。
这才是FPGA工程师的核心竞争力。
📌 本文完整工程已上传至GitHub: github.com/example/vivado-spi-master
欢迎Star & Fork,也欢迎在评论区分享你在SPI调试中的“血泪史”。