简介:这份文档围绕FPGA通过SPI配置ADC展开,面向正在学习高速ADC接口、需要理解配置时序与FPGA实现思路的硬件及FPGA开发者。内容从SPI串行外设接口的基本概念讲起,覆盖4线模式与3线模式的信号构成,并结合ADS52J90等芯片分析读写时序,强调SEN、SCLK、SDIN、SDOUT的配合关系以及“寄存器地址加寄存器数值”的写入格式。文档还提醒关注tSCLK、tSEN_SU、tDSU等时序参数并预留裕量,对读操作中SDOUT在下降沿输出、FPGA在上升沿采样等关键点也有说明,可帮助读者建立从协议理解到代码实现的完整框架。压缩包仅含1个docx文件,约787KB,便于集中阅读和整理笔记。目前已有1871人学习,适合作为ADC SPI配置的入门梳理与工程参考。
1. 一块新板子上电,ADC 死活不出数据——先查 SPI 配置
板子焊回来,电源、时钟、模拟输入全对着,FPGA 抓回来的采样数据却是全 0 或者固定码。查了一圈电源纹波、LVDS 眼图、参考电压,最后发现是 ADC 内部寄存器压根没配——不少高速 ADC 上电默认待在待机或低功耗模式,模拟通道没使能,输出驱动器没开,数据线当然是死的。这类问题绕不开一件事:FPGA 通过 SPI 把配置字写进 ADC 内部寄存器。
这份系列资料就是沿着这条路走的:SPI 是什么、4 线时序怎么读、3 线时序怎么读、Verilog 怎么落地,五篇连成一条线。它不讲协议教科书,讲的是拿 datasheet 里的时序图去对齐代码。适合手上有 FPGA + ADC 采集板、需要自己写配置逻辑的人;也适合平时只调 IP 核、时序一不对就抓瞎的人。
2. 4 线还是 3 线:先把 ADC 的 SPI 接口选对
动手写代码之前,有一件事必须先想明白:这颗 ADC 的 SPI 到底是几根线。选错了不是改两行代码的事,是整个状态机和端口定义都要推倒重来。
2.1 四根信号线各管什么,别被厂商命名绕晕
通用的四线 SPI 是 SDI、SDO、SCLK、CS 四根:SDI 是 FPGA 输出、进 ADC 的串行数据;SDO 是 ADC 输出、进 FPGA 的串行数据;SCLK 是 FPGA 给出的时钟;CS 是 FPGA 给出的片选使能。四根线里三根方向固定由 FPGA 驱动,只有 SDO 是反向的。
麻烦在于厂商不爱用这套命名。以一颗高速 ADC(型号 ads52j90)为例,它的手册上写的是 SEN、SCLK、SDIN、SDOUT,看着像另一套协议,实际上一一对应:SEN 对应 CSB,SDIN 对应 SDI,SDOUT 对应 SDO,SCLK 不变。第一次看手册的时候我在命名上耗了半小时,后来养成习惯,拿到新器件先做一张对照表再去读时序图。
| 通用命名 | 某高速 ADC 命名 | 某多通道 ADC 命名 | 方向 |
|---|---|---|---|
| CS / CSB | SEN | CSB1 / CSB2 | FPGA → ADC |
| SCLK | SCLK | SCLK | FPGA → ADC |
| SDI | SDIN | SDIO(复用) | FPGA → ADC |
| SDO | SDOUT | SDIO(复用) | ADC → FPGA |
这里有个认知差要提前纠正:ADC 的 SPI 接口是拿来配寄存器的,不是拿来传采样数据的。采样数据走的是 LVDS 或者并行总线,SPI 上跑的是控制字。我见过有人一开始以为 SPI 读完就能拿到波形,翻了半天手册才发现读回来的是 8bit 或 16bit 的寄存器值。
2.2 3 线模式省的不是一根线,是一套三态逻辑
三线模式把 SDI 和 SDO 合并成一根 SDIO。PCB 上确实少走一根线,多片 ADC 挂同一条总线时布线压力小很多。但代价转移到了逻辑侧:这根线在写操作时是 FPGA 驱动、ADC 接收,在读操作时反过来,ADC 驱动、FPGA 接收。方向切换的时机错了,轻则读回全 1,重则两边同时驱动打架。
某些器件把这个简化做到了极致,三线里的 SDIO 只当输入用,没有输出功能,实现起来跟四线没差别。但只要器件手册上写了读寄存器功能,三态控制就跑不掉。
选型上我的判断很直接:管脚够、走线不紧张,优先四线,逻辑简单、仿真干净、出问题好定位;板子空间卡死或者要多片级联共用总线,才上三线,并且提前把三态时序单独验证一遍。别为了省一根线给自己埋一个星期的调试量。
2.3 帧格式差异比线数差异更致命
线数决定端口定义,帧格式决定状态机怎么切。这两件事经常被混着看,结果就是端口接对了、数据全错位。
| 器件 | 模式 | 帧结构 | 单帧长度 |
|---|---|---|---|
| ads52j90 | 4 线 | 8bit 地址 + 16bit 数据 | 24bit |
| 某多通道 ADC | 3 线 | 1bit 读写标志 + 2bit 字节数 + 13bit 地址 + 8bit 数据 | 24bit |
四线的帧只有地址和数据两段,简单直白。三线的帧前面多了一个控制头,读写方向、传输字节数都编码在里面,地址位宽也不同。这意味着三线的读写不能共用同一段移位逻辑,写操作要发满 24bit,读操作只发前 16bit 就要切换方向。
提示:拿到任何一颗新 ADC,先把「帧结构」和「时序参数」两张表抄到自己的笔记里,再开始画状态机。手册里这两部分通常隔了几十页,来回翻很容易漏。
3. 4 线 SPI 的写与读:24bit 帧怎么切,Verilog 怎么落
四线模式的时序是所有 SPI 配置的基础,把这套逻辑吃透,三线只是在它上面加一层方向控制。
3.1 写帧的位序:先地址后数据,中间不能断
以 ads52j90 为例,一次写操作是连续 24 个 SCLK:前 8 个上升沿送 A7~A0 的寄存器地址,后 16 个上升沿送 D15~D0 的寄存器值。整个过程 SEN 一直保持低电平,写完最后一个 bit 之后才拉高。
三条准则在任何一颗 ADC 上都成立:SEN 不拉低 SPI 不工作,读写完成必须拉高;SDIN 上的数据在 SCLK 上升沿被 ADC 采走;SDIN 上的内容一定是先地址后数据,中间不允许插入空闲周期。第三条尤其容易被忽略——有人写完地址想歇几个时钟再发数据,ADC 那边直接把后面的数据当成了新地址。
3.2 把 datasheet 的四个数字翻成约束
时序图下面那堆参数不是给你看的,是给你算裕量的。ads52j90 手册里几个关键值:
| 参数 | 含义 | 最小值 | 对我们的约束 |
|---|---|---|---|
| tSCLK | SCLK 周期 | 50ns | SCLK 频率上限 20MHz |
| tSEN_SU | SEN 下降沿到第一个 SCLK 上升沿 | 8ns | 片选要提前拉低 |
| tDSU | SDIN 建立时间 | 5ns | 数据要提前摆好 |
| tOUT_DV | SDOUT 从下降沿到有效 | 12~28ns | 决定采样点选哪 |
我一般按系统时钟 40MHz 设计,周期 25ns,做 8 分频得到 5MHz 的 SCLK,周期 200ns。这个频率离 20MHz 上限有 4 倍余量,看着浪费,但低速能跑通是高速能跑通的前提,先用慢时钟把功能调对,再往上提。
验算一下裕量:SEN 拉低到第一个上升沿是半个 SCLK 周期 100ns,比 8ns 的 tSEN_SU 富余 92ns;SDIN 在下降沿更新、到下一个上升沿被采,中间隔 100ns,比 5ns 的 tDSU 富余 95ns;SDOUT 在下降沿后最多 28ns 稳定,到下一个上升沿还有 72ns 余量。三个数字都过一遍,心里才有底。
3.3 分频出 SCLK,再把写帧推出去
先看时钟产生和写帧移位这部分的骨架:
// 40MHz 系统时钟,DIV=8 分频得到 5MHz SCLK localparam DIV = 8; reg [3:0] div_cnt; reg sclk; reg sen; reg sdin; reg [23:0] tx_shift; // {A7~A0, D15~D0},共 24bit reg [4:0] bit_cnt; // 每个系统时钟推进分频计数器,半周期翻转一次 SCLK always @(posedge clk or negedge rst_n) begin if (!rst_n) div_cnt <= 4'd0; else if (div_cnt == DIV-1) div_cnt <= 4'd0; else div_cnt <= div_cnt + 1'b1; end // tick_fall 出现在 SCLK 下降沿所在的那一拍 wire tick_fall = (div_cnt == DIV-1); wire tick_rise = (div_cnt == DIV/2-1); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sclk <= 1'b0; sen <= 1'b1; sdin <= 1'b0; end else if (wr_en) begin if (tick_rise) sclk <= 1'b1; // 上升沿,ADC 在此采样 sdin if (tick_fall) begin sclk <= 1'b0; // 下降沿,FPGA 更新 sdin sdin <= tx_shift[23]; // 高位先出 tx_shift <= {tx_shift[22:0], 1'b0}; end end else begin sclk <= 1'b0; // 空闲时 SCLK 保持低 end end这段代码里有两个设计选择值得说清楚。一是 SCLK 用寄存器直接产生而不是组合逻辑,好处是不会有毛刺,坏处是引入一个系统时钟的延迟,算裕量时要把这 25ns 扣掉。二是数据在下降沿更新、上升沿被采,中间隔了半个 SCLK 周期,正好满足 tDSU。
有个细节容易踩:进入写状态的第一拍必须把第一个 bit 提前摆到 sdin 上,同时拉低 SEN,这样第一个上升沿才能采到 A7。如果按「先拉 SEN,下一个下降沿再放数据」的写法,第一个 bit 就丢了,整帧错位一位,写进去的寄存器值全是乱的。
3.4 读操作:写地址是引子,采数据在上升沿
读操作分两段。前 8 个 SCLK 把地址 A7~A0 送到 SDIN,最后一位 A0 在上升沿锁存之后,ADC 开始在接下来的每个 SCLK 下降沿输出一位寄存器值,16 个下降沿输出完 D15~D0。
关键在采样点的选择。SDOUT 在下降沿变化后,要经过 tOUT_DV(12~28ns)才稳定,如果 FPGA 也在下降沿附近去采,很容易采到跳变中的电平。放到上升沿采就稳了——此时距上一个下降沿已经过去半个周期 100ns,数据早就稳定。
// 读阶段:上升沿采样 SDOUT,共 16 个 bit always @(posedge clk or negedge rst_n) begin if (!rst_n) begin rx_shift <= 16'd0; end else if (rd_en && tick_rise && bit_cnt >= 5'd8) begin // bit_cnt 从 0 数到 23,第 8 个上升沿之后开始接收数据 rx_shift <= {rx_shift[14:0], sdout}; end end计数器在这里承担了双重职责:0~7 的时候在发地址,8~23 的时候在收数据。用一个计数器统管两段,比开两个计数器省事,也避免了切换瞬间计数不同步的问题。
3.5 读写一体的状态机骨架
把写和读串起来的状态机大致长这样:
| 状态 | 动作 | 退出条件 |
|---|---|---|
| IDLE | SEN=1,SCLK=0,等 start | start 有效 |
| W_FRAME | SEN=0,发 8bit 地址 + 16bit 数据 | bit_cnt 到 23 |
| W_END | 等最后一个上升沿采完,SEN 拉高 | 半个 SCLK 周期 |
| R_FRAME | SEN=0,发 8bit 地址 | bit_cnt 到 7 |
| R_DATA | 继续给 SCLK,上升沿收 16bit | bit_cnt 到 23 |
| R_END | SEN 拉高,输出 rdata,置 done | 下一拍 |
写和读之间必须插一个 SEN 拉高的间隔。这不是保守,是手册要求:SEN 不拉高,ADC 认为上一次传输还没结束,新的帧头会被当成上帧的延续。
4. 3 线 SPI 的 SDIO 三态:方向切换卡在哪个边沿
三线的难点全在一根线上:同一根 SDIO,写的时候 FPGA 驱动,读的时候 ADC 驱动。方向切换早一拍晚一拍都是错。
4.1 帧头多了控制字,地址位宽也变了
以某多通道高速 ADC(型号 AD9249)为例,它的 SPI 有 CSB1、CSB2、SDIO、SCLK 四根,但 CSB1 和 CSB2 可以并在一起由一路片选控制,实际就是三线。多片 ADC 挂同一条总线时,靠各自的 CSB 选通区分。
帧格式跟四线完全不同:1bit 读写标志 + 2bit 字节数 + 13bit 地址 + 8bit 数据,一共 24bit。读写标志为 1 表示读、为 0 表示写(不同厂商标法可能相反,抄代码前一定对着自己那颗的手册确认这一位,我见过把它搞反的,发出去的是读命令,自然什么也写不进去);字节数那两位一般填 00,表示每次只读写一个寄存器;地址是 13bit,比四线宽,因为这类多通道器件的寄存器空间更大。
写操作要发满 24bit,这个和四线一样。读操作只发前 16bit(控制头 + 地址),发完立刻切方向,接下来 8 个 SCLK 由 ADC 驱动 SDIO 送回 8bit 数据。
4.2 inout 端口和三态使能,翻早翻晚都出事
FPGA 侧的 SDIO 必须定义成 inout,靠一个三态使能信号控制方向:
inout wire sdio; // 双向数据线 wire sdio_in; // 从管脚读回来的值 wire sdio_out; // 要发出去的值 reg tri_en; // 1: FPGA 驱动;0: 高阻,交给 ADC 驱动 assign sdio = tri_en ? sdio_out : 1'bz; assign sdio_in = sdio;tri_en 为 1 时 FPGA 驱动总线,为 0 时释放成高阻。写操作全程 tri_en 保持 1。读操作前半段(发控制头和地址)tri_en 也是 1,地址最后一位 A0 在 SCLK 上升沿被 ADC 锁存之后,ADC 需要 tEN_SDIO(最小 10ns,以 SCLK 下降沿为参考零点)才把 SDIO 从输入切成输出并开始驱动。
于是 FPGA 这边拉低 tri_en 的时刻就有了窗口:必须晚于 A0 那个上升沿(否则地址最后一位发不出去),又必须早于 ADC 开始驱动(否则两边同时驱动,总线打架)。工程做法是在 A0 上升沿之后延迟若干个系统时钟再拉低,40MHz 下延迟两拍就是 50ns,比 10ns 的 tEN_SDIO 富余 40ns,同时距离下一个 SCLK 下降沿还有 50ns,来得及。
读操作结束也有对称的问题:ADC 输出完 8bit 之后,需要 tDIS_SDIO(同样最小 10ns,以 SCLK 上升沿为参考)才释放总线。所以读完之后不能马上发起下一次访问,中间要留至少一个 SCLK 周期的空隙,等 ADC 把线放开,FPGA 再把 tri_en 拉高。
4.3 三态控制的代码写法
// 读操作:发完 13bit 地址后切换方向 localparam SEND_HDR = 3'd1, SEND_ADDR = 3'd2, SWITCH = 3'd3, RECV = 3'd4; reg [1:0] dir_dly; // 方向切换延时计数 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin tri_en <= 1'b1; dir_dly <= 2'd0; end else begin case (state) SEND_HDR, SEND_ADDR: tri_en <= 1'b1; // 前 16bit 由 FPGA 驱动 SWITCH: begin // A0 上升沿之后延迟两拍再释放总线,满足 tEN_SDIO if (dir_dly == 2'd2) begin tri_en <= 1'b0; dir_dly <= 2'd0; end else begin dir_dly <= dir_dly + 1'b1; end end RECV: tri_en <= 1'b0; // 接收阶段保持高阻 default: tri_en <= 1'b1; endcase end end // 接收:上升沿采 sdio_in,共 8bit always @(posedge clk or negedge rst_n) begin if (!rst_n) rx_byte <= 8'd0; else if (state == RECV && tick_rise) begin rx_byte <= {rx_byte[6:0], sdio_in}; end endtri_en用寄存器输出而不是组合逻辑,是为了避免地址最后一位和方向切换在同一个时钟沿上打架。dir_dly那个延时计数器看着多余,实际是三线模式能不能读到正确值的关键——省掉它,总线会在切换瞬间出现一段争用,仿真里表现为 X,上板表现为偶尔读对偶尔读错。
4.4 仿真里先看 X,再看波形
三线模式的仿真有一个很实用的判据:如果 SDIO 在接收阶段出现一片红色的 X,基本可以断定三态切换有问题。X 的来源无非两种,要么 FPGA 侧 tri_en 没拉低(两边都在驱动),要么 ADC 模型侧还没开始驱动而 FPGA 已经撒手(线上没人驱动)。
我一般会在 testbench 里给 SDIO 加一个简单的上拉,把「没人驱动」这种情况变成明确的 1,避免 X 传播到整个波形里掩盖真问题。同时把 tri_en 和 sdio 一起拉出来看,两个信号的翻转时刻差了多少个时钟,一眼就能看出来。
5. 避坑:SPI 配 ADC 最常见的五处翻车
时序图看得再明白,落到代码和板子上还是会出问题。下面五条都是实打实调试出来的。
5.1 SEN 拉高早了一个周期,最后一个 bit 丢了
现象:写进去的寄存器值总是差最后一位,比如想写 0x00FF 读回来是 0x007F,或者干脆整帧错位。
原因:状态机在发完第 24 个 bit 之后立刻拉高了 SEN,但 ADC 是在 SCLK 上升沿采这个 bit 的,SEN 提前拉高会让这次采样失效。
解决:发完最后一个 bit 后至少等半个 SCLK 周期,等那个上升沿过去再拉高 SEN。稳妥做法是等一整个 SCLK 周期。
5.2 SCLK 空闲电平不对,ADC 认成半个时钟
现象:第一帧永远错一位,后续帧正常。
原因:SCLK 在空闲时如果是高电平,SEN 拉低的瞬间线上就出现了一个下降沿,某些 ADC 会把它当成第一个时钟边沿。
解决:SCLK 空闲电平固定为低,且只在 SEN 有效期间才允许翻转。代码里加一个sclk_en门控,空闲时直接锁死为 0。
5.3 三线读操作读到全 1
现象:读寄存器回读值恒为 0xFF,写操作看起来正常。
原因:SDIO 上根本没有器件在驱动。要么 tri_en 没拉低,FPGA 一直在推;要么 tri_en 拉早了,地址最后一位没发出去,ADC 认为帧不完整,不响应读请求。
解决:用 ILA 抓 tri_en 和 SDIO,确认 tri_en 的下降沿落在 A0 的上升沿之后、下一个 SCLK 下降沿之前。同时检查 ADC 的 CSB 是否在整个读周期内保持低。
5.4 分频计数器跨状态没清零,帧长多出几个周期
现象:逻辑分析仪上数出来一帧有 26 个或 28 个 SCLK,多出来的几个时钟把 ADC 搞懵了。
原因:分频计数器 div_cnt 在状态切换时没有一起复位,从上一个状态残留的计数值继续往下数,导致当前帧的 SCLK 相位错乱。
解决:每个状态跳转时把 div_cnt 一并清零,让每一帧的 SCLK 都从同一个相位起步。这点在有多个配置帧连续发送的时候特别重要。
5.5 只按低速验证,提高 SCLK 就翻车
现象:5MHz 下一切正常,提到 10MHz 读回值开始随机出错。
原因:板级走线延时、FPGA 输出延迟、ADC 输入建立时间叠加起来,在低速时被半个周期的余量盖住了,频率一上去余量不够就暴露。
解决:不要按手册的理论上限去设时钟。手册给的 20MHz 是在理想条件下的值,实际板子上先按一半来,跑通了再往上试。另外把 SDOUT 的采样点从上升沿再往后挪一点(比如上升沿后几个系统时钟),用额外的相位裕量换稳定性。
6. 上板验证:从回读比对到 ILA 抓波形的闭环
配置逻辑写完,最容易犯的错是直接去看采样波形,数据不对就怀疑模拟前端。我的顺序反过来:先把 SPI 通道验证到位,再谈数据。
第一步做寄存器回读比对。挑几个读写行为确定的寄存器,写一个已知值再读回来,和写入值逐位对比。这一步能筛掉大部分时序问题和帧格式问题。我一般会建一张小表,把地址、写入值、期望回读值、实测回读值列出来,跑一遍全绿再继续。
| 地址 | 写入值 | 期望回读 | 说明 |
|---|---|---|---|
| 0x00 | 0x0018 | 0x0018 | 通道使能,读回值应与写入一致 |
| 0x05 | 0x0003 | 0x0003 | 输出驱动强度 |
| 0x11 | 0x0000 | 0x0000 | 掉电控制,写 0 退出待机 |
第二步挂 ILA 抓四根线:SEN、SCLK、SDIN、SDOUT。看三件事——SEN 拉低期间数出来的 SCLK 个数是不是正好 24 个(三线读操作是 16 个);SDIN 上第一个 bit 是不是地址的 A7;SDOUT 上的数据在上升沿附近是不是稳定的平电平。这三条一眼能看出来,比逐行读代码快得多。
第三步做个反向验证:故意把地址写错一位,看读回来的值是不是如预期地变了。如果写错地址读回来还是原值,说明读通道根本没工作,之前读到的「正确值」可能只是总线上拉电阻的功劳。这一步有点自虐,但能识破假成功。
// 回读比对的仿真断言,跑仿真时能提前暴露问题 always @(posedge clk) begin if (done && check_en) if (cfg_rdata !== expect_data) $display("[FAIL] addr=%02h write=%04h read=%04h expect=%04h", cfg_addr, cfg_wdata, cfg_rdata, expect_data); end这段断言放进 testbench,每次改代码跑一遍仿真,比上板抓波形省时间。参数说明上,cfg_rdata是读回的 16bit 寄存器值,expect_data是从手册查出来的复位默认值或者上一次写入值,check_en用来屏蔽上电初期的无效比对。
从那以后我每次调新 ADC,都强制走一遍「回读比对 → ILA 数帧长 → 故意写错一位」这三步,哪怕这颗器件我之前配过。省下来的不是调试时间,是排查方向上的反复横跳。希望帮到你。
本文还有配套的精品资源,点击获取