简介:《FPGA通过SPI对ADC配置系列文章》是一份面向FPGA工程师、硬件开发者和高速ADC调试入门者的技术文档,围绕如何用FPGA经SPI接口完成ADC寄存器配置展开,兼顾协议基础与工程时序分析。压缩包共1个docx文件,约787KB,内容以系列文章形式组织,便于按主题连续阅读和检索。文档从SPI串行外设接口的基本概念讲起,说明4线模式中SDI、SDO、SCLK、CS的作用,以及3线模式将SDI与SDO合并为SDIO的差异;随后以TI的ADS52J90为例,拆解SEN、SCLK、SDIN、SDOUT的写/读时序、24bit地址加数据格式、SEN使能要求和tSCLK、tSEN_SU、tDSU等时序参数,并提示代码实现中的裕量问题。已有1871人学习,适合需要理解ADC配置流程、对照datasheet分析时序或排查SPI配置问题的读者参考。
1. 项目定位:FPGA通过SPI配置ADC,这份系列文档解决什么问题
板子上的高速ADC上电后寄存器是默认值,采样格式、通道使能、测试模式都没配,FPGA通过SPI读回来全是0,查了两天才发现是CS拉低的时间不够。这类问题在数据采集板卡上太常见了:ADC能出时钟、能出数据,但配置寄存器写不进去,或者写进去读出来对不上。FPGA通过SPI对ADC配置这件事,说到底是把datasheet里的时序图翻译成Verilog状态机,再把时间参数翻译成分频系数和采样沿。
这份系列文档一共五篇,从SPI是什么讲起,先拆4线模式的读写时序,再拆3线模式的三态控制,最后落到Verilog实现。它适合做数据采集、软件无线电、医疗成像前端这类板卡的FPGA逻辑工程师,也适合刚接手ADC配置模块、对着时序图不知道从哪下手的开发者。SPI ADC这个组合看着简单,真到上板阶段,翻车点基本都在时序裕量和三态切换上。
2. 硬件层拆解:4线/3线SPI信号线、命名映射与三态口
2.1 4线SPI的4根线到底谁驱动谁
4线SPI的4根线是SDI、SDO、SCLK、CS。方向固定:SCLK和CS永远由FPGA输出,SDI由FPGA输出进ADC,SDO由ADC输出进FPGA。这4根线里,SCLK是节奏,CS是门控,SDI/SDO是数据。很多ADC厂商不按通用名命名,比如某厂商把CS叫SEN,把SDI叫SDIN,把SDO叫SDOUT,SCLK不变。看datasheet第一件事就是把命名映射列出来,不然时序图对不上代码。
| 通用名 | 某厂商命名 | 方向 | 作用 |
|---|---|---|---|
| CS | SEN | FPGA → ADC | 帧使能,低有效 |
| SDI | SDIN | FPGA → ADC | 地址和数据输入 |
| SDO | SDOUT | ADC → FPGA | 寄存器数据输出 |
| SCLK | SCLK | FPGA → ADC | 串行时钟 |
4线模式的好处是读写可以同时进行,但ADC配置场景里读写很少并发,所以4线更多是为了布线和调试方便:读的时候SDO单独一根,示波器好抓。FPGA侧这4根线一般直接接普通IO,不需要特殊原语,但SCLK如果频率高,建议加IOBUF或者用ODDR输出,避免时钟走普通布线资源。
2.2 3线SPI为什么不是简单少一根线
3线SPI把SDI和SDO合并成SDIO,一根线分时复用。写的时候FPGA驱动SDIO,读的时候ADC驱动SDIO。问题就在这里:一根线上有两个驱动器,必须靠三态门分时切换。FPGA端的SDIO要定义成inout,内部用tri_en控制:tri_en=1时FPGA输出,tri_en=0时FPGA端高阻,ADC输出。
有些ADC的3线模式更简单,SDIO只做输入,只能写不能读,这种实现起来就是普通输出口,不用三态。但带读回功能的3线ADC,三态控制是绕不开的。3线模式省一根线,代价是方向切换的时序要卡准。切早了,FPGA和ADC同时驱动,仿真出X,上板可能发热;切晚了,ADC已经开始输出,FPGA还没释放,读到的第一位可能是错的。SDIO线上一般会加一个上拉电阻,保证总线空闲时电平确定,这个电阻在原理图上别省。
2.3 时序参数从datasheet里怎么读
时序图上的箭头不是装饰,每个箭头对应一个参数。常见的有以下几个,代码里不是直接写ns值,而是换算成分频系数和计数器阈值。
| 参数 | 含义 | 示例值 | 代码落地方式 |
|---|---|---|---|
| tSCLK | SCLK周期最小值 | 50ns | 分频后周期要大于它,40MHz 8分频得5MHz |
| tSEN_SU | SEN下降沿到第一个SCLK上升沿 | 8ns | CS拉低后延时几个系统时钟再动SCLK |
| tDSU | SDIN相对SCLK上升沿的建立时间 | 5ns | 数据在SCLK下降沿改,上升沿采样 |
| tOUT_DV | SDOUT在SCLK下降沿后的有效时间 | 12~28ns | FPGA在下一个上升沿采样 |
| tEN_SDIO | SDIO由输入切输出的时间 | 10ns | 写完地址最后一位后立即拉低tri_en |
| tDIS_SDIO | SDIO由输出切回输入的时间 | 10ns | 读完数据后延时再拉高tri_en |
系统时钟40MHz,周期25ns,要产生5MHz SCLK就是8分频,半周期4个系统时钟等于100ns,远大于tSCLK的50ns,裕量够。tSEN_SU=8ns,在8分频下CS拉低到第一个SCLK上升沿隔了100ns,也够。关键是tOUT_DV=28ns,FPGA在SCLK上升沿采样时,数据已经稳定了至少半个SCLK周期,所以上升沿采样是安全的。
注意:不同ADC的tOUT_DV差异很大,有的只有几ns,这时候要么降低SCLK频率,要么把采样点往后挪。不能拿一款芯片的代码直接套另一款。
另外,时序参数要看最坏情况,不能只看典型值。datasheet给的范围是12ns到28ns,代码里要按28ns算裕量,而不是按12ns算。我一般会把SCLK频率先降到1MHz跑通,再逐步往上提,提不上去就说明裕量不够,回头看是哪个参数卡住了。
3. 4线SPI时序与实现:24bit帧、读回采样沿与Verilog状态机
3.1 写时序:24bit一帧,SEN全程拉低
4线写操作一帧24bit:先8bit地址A7~A0,再16bit数据D15~D0。SEN在整帧期间保持低,帧前帧后为高。SCLK每个上升沿采样SDIN的一位,24个上升沿采完一帧。这里有一个容易忽略的点:地址和数据之间没有间隙,第8个上升沿采完A0,第9个上升沿马上采D15,中间不能多打拍。
// 4线SPI写寄存器:24bit = 8bit addr + 16bit data // 行为级写法,便于仿真理解,综合时改状态机 task spi_write; input [7:0] addr; input [15:0] data; integer i; begin sen_n = 1'b0; // 帧开始,SEN拉低 #(SEN_SETUP); // 满足tSEN_SU,实际给100ns for (i = 23; i >= 0; i = i - 1) begin sclk = 1'b0; if (i > 15) sdi = addr[i-16]; // 先发地址,高位在前 else sdi = data[i]; // 再发数据,高位在前 #(HALF_PERIOD); // 数据建立时间 sclk = 1'b1; // 上升沿ADC采样 #(HALF_PERIOD); end sclk = 1'b0; #(SEN_HOLD); // 帧后保持 sen_n = 1'b1; // 帧结束,SEN拉高 end endtask逻辑说明:循环从23到0,前8次i>15走地址分支,后16次走数据分支。sclk先拉低再放数据,保证数据在上升沿前稳定。HALF_PERIOD是半个SCLK周期,按5MHz算100ns。这个task里的延时不可综合,只用于仿真验证时序关系,真正上板要改成状态机加计数器。
参数说明:SEN_SETUP至少8ns,代码里给100ns;SEN_HOLD同样留裕量。地址位宽8位,数据位宽16位,这是这款ADC的帧格式,换芯片要按datasheet改。
3.2 读时序:先写地址,再在下降沿输出、上升沿采样
读操作分两段:先往SDIN写8bit地址,然后ADC在SCLK下降沿把对应寄存器的16bit数据从SDOUT逐位输出。注意SDOUT在下降沿变化,经过tOUT_DV后才稳定,所以FPGA在下一个上升沿采样最稳。很多新手会在下降沿直接采,仿真看着对,上板因为tOUT_DV不够,高位偶尔出错。
// 读寄存器:先写8bit地址,再收16bit数据 // SDOUT在SCLK下降沿更新,FPGA在上升沿采样 localparam RD_ADDR = 2'd1, RD_DATA = 2'd2; reg [4:0] bit_cnt; reg [15:0] rd_shift; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; bit_cnt <= 5'd0; rd_shift <= 16'd0; end else case (state) RD_ADDR: begin sen_n <= 1'b0; sclk <= 1'b0; sdi <= addr[7 - bit_cnt]; // 高位先出 if (bit_cnt == 5'd7) begin bit_cnt <= 5'd0; state <= RD_DATA; end else begin bit_cnt <= bit_cnt + 1'b1; end end RD_DATA: begin sclk <= ~sclk; // 产生SCLK if (sclk) // 上升沿采样SDOUT rd_shift <= {rd_shift[14:0], sdo}; if (bit_cnt == 5'd15) begin sen_n <= 1'b1; // 收完16bit,SEN拉高 state <= IDLE; end else if (!sclk) begin bit_cnt <= bit_cnt + 1'b1; end end endcase end逻辑说明:RD_ADDR状态逐位发地址,RD_DATA状态翻转SCLK并在上升沿移位收数据。sclk<=~sclk放在时钟沿里,实际综合出来是分频计数器更稳,这里为了看清采样沿。rd_shift是16位移位寄存器,每来一个上升沿左移一位,把sdo塞到最低位。
参数说明:bit_cnt位宽5位够数24;SEN在RD_DATA收完第16位后拉高,满足帧后保持时间。读回的数据要和写进去的值比对,不一致就说明时序或者地址有问题。
3.3 SCLK分频与状态机骨架
系统时钟40MHz,8分频得5MHz。分频计数器3位,计数0~7,计到3拉低、计到7拉高,占空比50%。分频计数器要自由跑,不要用门控时钟,否则综合和时序分析都麻烦。
// 40MHz -> 5MHz SCLK,8分频 reg [2:0] div_cnt; reg sclk_r; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin div_cnt <= 3'd0; sclk_r <= 1'b0; end else if (div_cnt == 3'd7) begin div_cnt <= 3'd0; end else begin div_cnt <= div_cnt + 1'b1; end sclk_r <= (div_cnt < 3'd4) ? 1'b0 : 1'b1; // 前4拍低,后4拍高 end // 主状态机 localparam IDLE = 3'd0, WR_REG = 3'd1, WR_WAIT = 3'd2, RD_ADDR_S = 3'd3, RD_DATA_S = 3'd4, DONE = 3'd5; reg [2:0] state, next_state;逻辑说明:div_cnt自由跑,sclk_r按计数值切高低,这样SCLK和系统时钟同步,避免门控时钟。状态机用三段式或两段式都行,关键是每个状态里sclk的边沿要和数据对齐。WR_REG状态发24bit写帧,WR_WAIT等SEN拉高后再进读,读状态按3.2的流程走。
参数说明:分频系数8对应5MHz;如果要改频率,改div_cnt比较值即可,比如4分频得10MHz,但要确认不超过tSCLK最小值的倒数。状态机编码用3位够6个状态,实际项目里状态多了要扩位宽。
4. 3线SPI时序与实现:SDIO三态切换与inout写法
4.1 3线帧格式:控制命令+13bit地址+8bit数据
3线一帧24bit:1bit R/W + 2bit W1W0 + 13bit地址A12~A0 + 8bit数据D7~D0。R/W=1读,0写;W1W0一般设00,表示每次读写一个寄存器。写操作:1bit 0 + 2bit 00 + 13bit地址 + 8bit数据。读操作:1bit 1 + 2bit 00 + 13bit地址,然后SDIO切输出,ADC在下8个SCLK下降沿吐出8bit数据。注意读操作的命令段只有16bit,不是24bit,数据段由ADC驱动。
// 3线SPI帧拼接:写=1+00+13bit addr+8bit data // 读=1+00+13bit addr,然后SDIO切输入收8bit wire [23:0] wr_frame = {1'b0, 2'b00, addr13, data8}; wire [15:0] rd_frame = {1'b1, 2'b00, addr13}; // 16bit命令段逻辑说明:读帧只发16bit命令,数据段由ADC驱动。写帧24bit全由FPGA驱动。R/W位在最高位,W1W0固定00,地址13位,数据8位。
参数说明:addr13是13位地址,data8是8位数据;如果芯片支持多字节读写,W1W0要改,但配置寄存器场景一般设00。
4.2 SDIO方向切换:tEN_SDIO与tDIS_SDIO
读操作时,FPGA写完地址最后1bit A0后,ADC在接下来的SCLK下降沿把SDIO从输入切输出,切换时间tEN_SDIO最小10ns。FPGA必须在同一个下降沿附近把tri_en拉低,让出总线。写操作结束时,ADC在SCLK上升沿后tDIS_SDIO最小10ns切回输入,FPGA再把tri_en拉高。这个切换点卡不准,读回的数据第一位就会丢或者出现总线冲突。
// SDIO三态控制:tri_en=1 FPGA输出,tri_en=0 FPGA高阻 inout sdio; wire sdi_int = sdio; // 读回的数据 reg sdo_int; // 要写出的数据 reg tri_en; assign sdio = tri_en ? sdo_int : 1'bz; // 读操作:发完地址最后1bit后拉低tri_en always @(posedge clk or negedge rst_n) begin if (!rst_n) tri_en <= 1'b1; else if (state == RD_ADDR_S && bit_cnt == 5'd15) tri_en <= 1'b0; // 地址发完,让出SDIO else if (state == RD_DATA_S && bit_cnt == 5'd7) tri_en <= 1'b1; // 8bit收完,收回SDIO end逻辑说明:assign那行是三态门本体,tri_en为0时sdio呈高阻,ADC可以驱动。sdi_int直接接sdio,读的时候拿到的就是ADC输出。读地址阶段tri_en为1,FPGA驱动;地址发完立即拉低,正好卡在ADC切换输出的下降沿附近。
参数说明:tEN_SDIO和tDIS_SDIO都要大于等于10ns,8分频下半个SCLK是100ns,裕量足够。如果SCLK提到20MHz,半周期25ns,仍大于10ns,但仿真要重点看切换沿。tri_en的复位值设1,保证上电后FPGA先控制总线。
4.3 三态控制的Verilog写法与仿真观察点
三态控制的坑主要在仿真:如果testbench里ADC模型没有在正确时刻驱动sdio,或者FPGA的tri_en和ADC的oe信号重叠,sdio会出现X。仿真时重点看三个信号:tri_en、sdio、adc_oe,要求在任何时刻最多只有一个为有效驱动。我一般会在testbench里加一条断言,跑回归时自动抓总线冲突。
// 仿真检查:tri_en和adc_oe不能同时为1 always @(posedge clk) begin if (tri_en === 1'b1 && adc_oe === 1'b1) $display("ERROR: SDIO bus conflict at %0t", $time); end逻辑说明:这是仿真断言,上板前跑一遍能提前发现方向切换重叠。adc_oe是ADC行为模型的输出使能,实际芯片内部有tEN_SDIO延迟,模型里也要加#10延迟才真实。
参数说明:断言里的1'b1表示有效驱动,如果模型用高阻表示释放,比较时要用===而不是==,避免X传播。仿真通过后再上板,能省很多返工。
5. 避坑排查:仿真、时序、三态与上板翻车点
5.1 读回来全是0或全1
现象:FPGA发起读操作,rd_shift一直是16'h0000或16'hFFFF,写进去的配置读不回来。
原因:SEN/CSB没有在整帧期间保持低,或者帧前建立时间不够,ADC根本没进入SPI模式。也有可能是SCLK频率超过tSCLK最小值对应的上限,ADC采样不到。
解决:检查状态机里sen_n的赋值,确保读写全程为0,帧前至少留tSEN_SU。用示波器抓SEN和第一个SCLK上升沿,看间隔。SCLK先降到1MHz试,能读回来再提频。
5.2 写进去读出来地址偏移一位
现象:写地址0x10,读0x10回来是写0x08的值,数据整体右移了一位。
原因:SDIN在SCLK上升沿采样,但代码在下降沿驱动数据,或者bit_cnt边界差一拍,地址最后一位被当成数据第一位。读写状态切换时SCLK多打了一个沿也会这样。
解决:统一模型,FPGA在SCLK下降沿改SDIN,ADC在上升沿采样。仿真里把SCLK、SDIN、sen_n三个信号对齐看,数第8个上升沿对应的SDIN是不是A0。状态切换时把SCLK强制拉低一拍再进下一状态。
5.3 3线读操作SDIO出现X
现象:仿真波形里sdio在读写切换处变红,读回数据不定,上板偶尔读到0xFF。
原因:FPGA的tri_en拉低太晚,ADC已经输出,两边同时驱动。或者testbench里ADC模型的输出使能没有延迟,和FPGA的tri_en完全重叠。
解决:在写完地址最后1bit的SCLK下降沿立即拉低tri_en,并确认ADC模型的tEN_SDIO延迟。加4.3的断言,跑回归。上板时用示波器看SDIO在切换沿有没有台阶,有台阶说明两边同时在驱动。
5.4 上板SCLK频率不对或波形振铃
现象:示波器看SCLK不是5MHz,或者上升沿有过冲,ADC偶尔配置失败。
原因:分频计数器复位值写错,比较值差一;或者PCB走线长、端接不对,SCLK作为时钟信号振铃明显。
解决:先仿真看div_cnt和sclk_r,确认8分频。上板先用1MHz跑通,再逐步提频。SCLK走线尽量短,必要时串阻。如果FPGA引脚支持,用ODDR输出SCLK,波形更干净。
5.5 换一款ADC后时序不满足
现象:同一套代码换到另一款ADC,写配置偶尔成功偶尔失败,读回数据错位。
原因:不同厂商tDSU、tOUT_DV、tEN_SDIO差异大,原代码的裕量不够。帧格式也可能不同,比如地址位宽从8位变13位。
解决:把时间参数和帧格式抽成parameter,按新datasheet重算分频系数和采样沿;读回校验加超时重试。换芯片先跑读写回环,再动采样通路。
6. 二次开发:把寄存器配置改成参数化表驱动
一开始我是每配一个寄存器就改一次状态机,后来寄存器上了30多个,改到怀疑人生。现在我的做法是把配置序列抽成两张表:地址表和数据表,状态机只负责按索引遍历,写完一个自动跳下一个。换项目时只改表,不动状态机。
// 参数化寄存器表:换项目只改这里 localparam REG_NUM = 4; wire [7:0] reg_addr [0:REG_NUM-1] = '{8'h10, 8'h11, 8'h12, 8'h13}; wire [15:0] reg_data [0:REG_NUM-1] = '{16'h0001, 16'h0002, 16'h0003, 16'h0004}; reg [3:0] reg_idx; // 状态机里写完一帧后 if (reg_idx == REG_NUM - 1) begin reg_idx <= 4'd0; state <= DONE; end else begin reg_idx <= reg_idx + 1'b1; state <= WR_REG; end这段代码把“写哪个寄存器”和“怎么写”解耦。验证方法也简单:写完之后切到读模式,按同样的索引把寄存器读回来,和reg_data逐项比对,不一致就拉一个error标志。我一般会在上板前先把读回比对跑通,再去看采样数据。
另一个改造点是3线和4线共用一套状态机,用parameter SPI_MODE区分:4线时sdo和sdi分开,3线时用tri_en控制sdio。这样同一份代码能覆盖两种板卡,不用维护两个分支。表驱动的代价是寄存器地址和数据占一点BRAM或者LUT,但对配置寄存器这种几十到几百项的量级,资源完全不是问题。
从那以后我每次换ADC,都强制先把读写回环跑一遍,再动采样通路。希望帮到你。
本文还有配套的精品资源,点击获取