☰
FPGA通过SPI配置ADC:从寄存器解析到状态机设计
2026/10/15 8:13:43 网站建设 项目流程

简介:FPGA通过SPI对ADC配置系列文章以连载形式梳理SPI总线基础与FPGA配置ADC的完整流程,面向FPGA工程师、嵌入式开发者及硬件学习者,解决从协议原理到时序实现的核心问题。文档从SPI四线SDI/SDO/SCLK/CS与三线SDIO的机制讲起,再以ADS52J90为例展开4线模式读写时序、SEN/SCLK/SDIN/SDOUT信号行为及tSCLK/tSEN_SU/tDSU等关键参数,并归纳寄存器地址加数值的写入规则与时钟沿采样要点,兼顾AD9249等三线模式延伸。文中还给出4线写操作共同准则与读操作关键点,避免因SEN时序或时钟沿选择不当导致通信失败。整份资源包约787KB,包含1个docx文档,内容由浅入深,既有概念图解又有可落地的代码分析思路,适合作为ADC配置开发的常备参考。目前已有1870人学习下载,配套系列内容能帮助读者快速建立SPI配置知识框架并规避时序裕量不足等常见问题。

1. FPGA通过SPI配置ADC:要解决的不只是“能写寄存器”

ADC不是插上电就能出正确数据的。多数高速、多通道ADC上电后处于“能用但不好用”的状态,默认采样率、默认通道选择、默认增益往往和你想要的对不上,必须先把寄存器写对。而寄存器最通用的入口就是SPI,在FPGA侧做这件事,不是为省一颗MCU,而是为确定性和速度:FPGA能在几十个时钟周期内完成一次配置,配置触发还能和采集信号同步。这篇笔记想把整条链路讲透,从看懂ADC手册里的寄存器表和时序图,到写出一套能上板的SPI配置模块,再顺带把最容易翻车的四个场景摆出来。适合刚接手采集板卡的数字逻辑工程师,也适合被“配置完还是不对”反复折磨的熟手。

2. 把ADC手册翻译成SPI配置帧:先对齐协议,再写代码

直接动手写Verilog前,我一般先做一件事:把ADC数据手册翻到“SPI接口”和“寄存器映射”两个章节,把所有和时序有关的数字抄到一张纸上。FPGA侧的配置逻辑本质上是在复现手册时序,协议没对齐,后面代码再顺也是白费。

2.1 SPI四种模式里,ADC配置为什么默认选模式0

SPI是四线制:SCLK、MOSI、MISO、CS(片选)。频率和位宽可以变,但时序关系由两对参数决定——CPOL决定SCLK空闲电平,CPHA决定数据采样沿。组合出来就是四种模式:

模式CPOLCPHASCLK空闲电平数据采样沿
000低上升沿
101低下降沿
210高上升沿
311高下降沿

绝大多数ADC的配置接口都支持模式0和模式1,少数同时支持模式2、模式3。实际工程里,大家几乎默认用模式0去适配:SCLK空闲为低,主机在下降沿更新MOSI,从机在上升沿采样;从机在下降沿更新MISO,主机在上升沿采样。也就是说,主机发数据的时机和收数据的时机天然错开,不需要做额外的半周期对齐。另一个实际原因是,很多ADC内部的上电默认状态就是按模式0配置的,用其他模式得先确认手册里“SPI时序图”标明的是哪个CPOL/CPHA,否则第一次通信就可能错位。

选模式的顺序建议是:先看手册里SPI时序图,如果明确写了支持模式0,直接用;如果只给了一张时序图没有标模式,去看SCLK空闲电平,空闲为低且数据在上升沿锁存,就是模式0。FPGA侧不用做“自动适配多模式”,配置阶段固定模式0,采集数据的并行接口另说。

2.2 从手册里找齐三样东西:指令帧长、寄存器映射、上电时序

写配置模块之前,要把ADC手册里的三张表提炼出来,缺一不可。

第一是指令帧格式。SPI配置帧不是一个字节打天下,常见的有这么几类:

帧长常见位段划分典型用途
16位1位读/写 + 7位地址 + 8位数据中小规模寄存器型ADC
16位1位读/写 + 4位地址 + 11位数据配置项带增益/偏移的ADC
20位1位读/写 + 7位地址 + 12位数据软复位、通道序列配置
24位8位地址 + 16位数据并行风格寄存器直接映射

有的ADC还支持“数据回读”:发读指令时,后面跟着的MISO上会返回寄存器当前值。这一点后面第6章单独讲,但选帧长时就要先把读写位的位置确认好。

第二是寄存器映射表。表里每一行描述一个寄存器:地址、位段含义、复位值、读写属性。不要只在例程代码里复制一堆魔法数字,要把它整理成一张自己的配置清单,字段包括:寄存器地址、配置值、作用、写入时序约束。这样后面第4章写配置表时,地址和数据能直接填进去。

第三是上电时序。手册“应用信息”或者“时序特性”章节里会给出几个关键时间:上电稳定时间(t_POR)、CS拉低到第一个SCLK沿的最短时间、SCLK高/低最小脉宽、CS拉低最小宽度、前后两条配置帧之间的间隔。这些数值是配置模块里延时计数器的主要依据。

2.3 手工描一条配置帧的时间轴:从CS拉低到拉高

把三张表参数拼起来,一条完整配置帧的时间轴是这样的:系统上电后先等t_POR,期间CS保持高、SCLK保持空闲电平;拉低CS,保持t_CS;然后在SCLK驱动下按MSB在前发送R/W位、地址位和数据位;最后一个SCLK沿结束后,保持一小段t_HOLD;再拉高CS;CS保持高电平一段时间,才能发下一条帧。

把这套流程固化成FPGA逻辑前,我用一个小脚本把寄存器清单转成配置帧常量,省得手算十六进制数:

MODE = 'write' # write 或 read ADDR = 0x01 # 寄存器地址 DATA = 0x03 # 写寄存器时的数据 if MODE == 'write': word = (0 << 15) | (ADDR << 8) | DATA else: word = (1 << 15) | (ADDR << 8) | 0 print(f"16'h{word:04X}, // {MODE} addr=0x{ADDR:02X} data=0x{DATA:02X}")

这段脚本对应“16位帧:1位R/W + 7位地址 + 8位数据”的格式。换成其他帧长时,改移位位数即可,例如20位帧就把地址左移12位、数据放在低12位。输出结果能直接粘进Verilog配置表,减少人工换算出错的可能。注意这里R/W位约定是高位置1表示读,不同ADC可能反过来,以手册为准。

3. 写一个可复用的SPI主机模块:状态机加移位寄存器的标准套路

配置帧组织好后,需要一个SPI主机模块负责把帧按位发出去。这个模块做好之后可以复用到所有SPI外设配置场景,不止ADC。核心就是一个状态机加移位寄存器,关键在“什么时候放数据、什么时候采样、什么时候拉CS”这三个时机的处理。

3.1 接口定义和参数:把模式0、位宽、分频比做成可配参数

我一般会把SPI主机做成参数化模块,位宽和SCLK分频比都用parameter暴露出来,这样同一套代码既能配置16位寄存器型ADC,也能配置20位指令帧的器件。核心代码:

module spi_master #( parameter DATA_WIDTH = 16, // 单条传输的比特数,按ADC帧长设置 parameter CLK_DIV = 8 // SCLK频率 = 系统时钟 / CLK_DIV,需为偶数且>=4 )( input wire clk, // 系统时钟 input wire rst_n, // 异步复位,低有效 input wire start, // 启动一次传输,单周期脉冲 output reg busy, // 传输忙标志,高有效 input wire [DATA_WIDTH-1:0] tx_data, // 要发出的配置帧 output reg [DATA_WIDTH-1:0] rx_data, // 从MISO收回的数据 output reg sclk, // SPI时钟,模式0空闲为低 output reg mosi, // 主机输出 input wire miso, // 从机输出 output reg cs_n // 片选,低有效 ); localparam [1:0] IDLE = 2'd0, XFER = 2'd1, DONE = 2'd2; reg [1:0] state; reg [$clog2(CLK_DIV)-1:0] div_cnt; // SCLK分频计数器 reg [$clog2(DATA_WIDTH)-1:0] bit_cnt; // 当前发送到的位序号 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; busy <= 1'b0; sclk <= 1'b0; mosi <= 1'b0; cs_n <= 1'b1; div_cnt <= 0; bit_cnt <= 1; rx_data <= 0; end else begin case (state) IDLE: begin sclk <= 1'b0; // 模式0空闲为低 cs_n <= 1'b1; busy <= 1'b0; if (start) begin busy <= 1'b1; cs_n <= 1'b0; // 先拉低CS,给从机留出t_CS mosi <= tx_data[0]; // 第一个bit提前放好 bit_cnt <= 1; div_cnt <= 0; state <= XFER; end end XFER: begin // 上升沿前的最后一个计数值:此刻采样MISO if (div_cnt == CLK_DIV/2 - 1) begin rx_data[bit_cnt - 1] <= miso; if (bit_cnt == DATA_WIDTH) begin state <= DONE; // 最后一个bit已经采完 end end // 下降沿计数值:更新MOSI,准备发下一个bit else if (div_cnt == CLK_DIV - 1) begin if (bit_cnt < DATA_WIDTH) begin mosi <= tx_data[bit_cnt]; bit_cnt <= bit_cnt + 1; end end div_cnt <= (div_cnt == CLK_DIV - 1) ? 0 : div_cnt + 1; end DONE: begin cs_n <= 1'b1; // 采样结束后再释放CS busy <= 1'b0; state <= IDLE; end endcase end end endmodule

这个模块只做两件事:把tx_data逐位移出去,同时把MISO逐位收进rx_data。参数里DATA_WIDTH决定了单条帧的位宽,CLK_DIV决定了SCLK频率。模式固定为0,如果想支持其他模式,需要在SCLK空闲电平和采样沿上分别加参数控制,先不用折腾,ADC配置用模式0足够覆盖大多数器件。

3.2 状态机里的三个关键时机:放数据、采样、释放CS

这个模块的时序逻辑是经过设计的,三个时刻不要随便挪:

第一个关键时机在IDLE进入XFER时,CS拉低的同时直接把tx_data[0]放到MOSI上。这很重要。SPI从机在CS拉低后,等待第一个SCLK上升沿到来时直接采样第一位,如果MOSI还在上一位残留值,第一位就采错了。先放数据、再等时钟沿,是从机的读操作能成立的前提。

第二个关键时机在div_cnt == CLK_DIV/2 - 1时采样MISO。这个时刻对应SCLK的上升沿。为什么不等sclk真正变成高电平再采样?因为sclk由分频计数器产生,如果直接在sclk上升沿触发采样,数据可能在边沿竞争时取到不确定值。用计数器的“边沿前一个节拍”做采样,给组合逻辑留出稳定时间,仿真和上板都不会出现亚稳态窗口。

第三个关键时机在DONE状态里释放CS。最后一个bit在XFER中采样完成后,状态先跳到DONE,下一拍才把CS拉高。CS不是在边界沿上立刻释放,而是错开一拍,这能从时序上保证从机把最后一个数据正确锁存。实际工程里,有的从机对“最后一位采样与CS释放之间的保持时间”有明确要求,错开一拍总比紧贴边沿稳妥。

3.3 参数怎么调:SCLK频率、位宽和手册上的t_XXX对上

CLK_DIV和位宽这两个参数是配置模块最常改的地方。SCLK频率的计算公式是:

f_sclk = f_clk / CLK_DIV

比如系统时钟100MHz,CLK_DIV取10,SCLK就是10MHz。取8就是12.5MHz,取4是25MHz。两个约束条件决定CLK_DIV最小值:一是手册里SCLK最高频率,比如某ADC写“最大SCLK 50MHz”;二是实际PCB走线和MISO建立时间。我一般会把配置阶段的SCLK降到手册上限的四分之一,先把功能跑通,再逐步提速找边界。配置寄存器本身是低频操作,慢一点不影响采集性能,没必要一上来就顶着上限跑。

位宽参数直接对应该ADC的指令帧长。有一种常见错误是:ADC帧长16位,但只发了8位就拉高CS,从机把后面半帧当噪声处理,寄存器写了个残缺值。正确做法是把整帧拼成一个完整变量,DATA_WIDTH设成完整帧长,让移位寄存器一次发完。

4. 从一条指令到一张配置表:写一个下发配置的状态机

单个SPI主机模块解决了“怎么把一条帧发出去”的问题,但ADC初始化通常要连着写好几条寄存器。接下来要做一个上层控制状态机,负责按顺序取配置项、启动SPI传输、等待完成、再发下一条。这个模块就是配置引擎。

4.1 配置表怎么组织:用case语句当只读存储器用

最简单的做法是把配置项写成一个组合逻辑case,用配置序号做索引,输出完整的SPI帧。以“1位R/W + 7位地址 + 8位数据”的16位帧为例:

reg [15:0] cfg_word; always @(*) begin case (cfg_idx) 4'd0: cfg_word = {1'b0, 7'h00, 8'h80}; // 软复位,地址0x00写入0x80 4'd1: cfg_word = {1'b0, 7'h01, 8'h03}; // 通道使能,地址0x01写入0x03 4'd2: cfg_word = {1'b0, 7'h02, 8'h28}; // 采样率配置,地址0x02写入0x28 4'd3: cfg_word = {1'b0, 7'h03, 8'h01}; // PGA增益,地址0x03写入0x01 4'd4: cfg_word = {1'b0, 7'h04, 8'h00}; // 功耗模式,地址0x04写入0x00 default: cfg_word = 16'h0000; endcase end

这种写法可综合,也方便核对:每个case项就是手册寄存器表里的一行。cfg_idx是配置序号,从0递增,发完一条就加1,直到全部发完。如果不喜欢case,也可以把配置项初始化到Block RAM里,用地址读取,但那样需要额外准备初始化文件,case语句在一个工程里看起来最直白。

这里有个容易踩的细节:拼接符顺序必须和手册位段一致。上面代码里高位是R/W位,中间是地址,低位是数据。如果某颗ADC手册画的是“地址在高位、数据在低位、读写位夹在中间”,拼接顺序也要跟着变,别想当然套用。

4.2 下发引擎:上电延时、帧间隔、完成信号一次管够

有了配置表,还需要一个有限状态机控制下发节奏。流程是:上电后先等t_POR,然后逐条取出配置项、拉高start脉冲、等待SPI主机busy拉高再回落、再等一个帧间隔t_CS,继续下一条。全部发完置cfg_done信号。主体状态机如下:

localparam IDLE = 0, WAIT_POR = 1, GEN_START = 2, WAIT_BUSY = 3, WAIT_FREE = 4, GAP = 5, FINISH = 6; reg [15:0] timer; reg [7:0] cfg_idx; reg cfg_done; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; timer <= 0; cfg_idx <= 0; cfg_done <= 1'b0; end else begin case (state) IDLE: begin if (trig) begin // 外部触发一次初始化配置 cfg_idx <= 0; cfg_done <= 1'b0; timer <= T_POR; // 上电稳定延时 state <= WAIT_POR; end end WAIT_POR: begin if (timer == 0) state <= GEN_START; else timer <= timer - 1; end GEN_START: begin spi_start <= 1'b1; // 拉一个时钟周期的start脉冲 state <= WAIT_BUSY; end WAIT_BUSY: begin spi_start <= 1'b0; if (spi_busy) state <= WAIT_FREE; end WAIT_FREE: begin if (!spi_busy) begin if (cfg_idx == NUM_CFG - 1) state <= FINISH; else begin cfg_idx <= cfg_idx + 1; timer <= T_CS; // 两条配置帧之间的间隔 state <= GAP; end end end GAP: begin if (timer == 0) state <= GEN_START; else timer <= timer - 1; end FINISH: begin cfg_done <= 1'b1; state <= IDLE; end endcase end end

注意GEN_START在拉高spi_start的同一拍,cfg_word要根据当前的cfg_idx组合出来,所以cfg_word的组合逻辑直接由cfg_idx驱动,不经过寄存器,这样start有效时tx_data已经稳定。spi_master模块在IDLE状态检测到start后进入XFER,因此这个start脉冲必须是一个系统时钟周期宽,别用持续高电平,否则会重复启动。

T_POR和T_CS两个参数按手册填。T_POR是系统复位释放后到CS首次拉低之间的等待时间,常见值从几百微秒到几十毫秒不等。T_CS是两条配置帧CS拉高后的最小间隔,通常几个微秒就够。如果手册没写明t_POR,保守起见取10毫秒,用计数器换算成系统时钟个数。

4.3 为什么这套流程比CPU配置更适合FPGA侧

有些工程师会问:ADC旁边明明有控制器,为什么不让它通过GPIO模拟SPI来配,非要FPGA自己写状态机?常见的原因有三个。

第一是确定性。FPGA配置状态机的每个延时都是时钟周期的整数倍,不受操作系统调度、总线占用、中断响应影响。在高精度同步采集系统里,所有ADC的配置完成时刻可精确对齐,这对多通道相位一致性很关键。

第二是可触发。配置引擎可以由采集帧同步信号直接启动,这样在运行过程中需要切换增益或通道时,硬件自己能卡在安全窗口里完成重配,而不需要等外部处理器发指令。

第三是省事。数据采集链路里本来就有FPGA,SPI主机和配置引擎加起来占资源极少,比外部控制器通过并行总线间接控制要简单得多。配置过程还能随时用片内逻辑分析仪抓波形,不必依赖外部工具。

5. 板级调试避坑:SPI配置ADC最容易翻车的四个场景

代码写完不代表配置就对了,板级调试才是翻车重灾区。下面这几个场景我都遇到过,按“现象、原因、解决”总结,照着排查能省不少时间。

5.1 现象:上电后ADC不工作,寄存器读出来全是零

现象是ADC的转换数据一直为0或者固定值,SPI回读所有寄存器全是0x00,看起来像芯片没起来。

原因一:上电时序没满足。有些ADC需要电源稳定后再等t_POR,期间不能拉低CS,否则内部上电复位不完整,寄存器处于不可用状态。FPGA里复位释放和第一个CS拉低之间的延时少于手册要求,就会复现这个问题。

原因二:CS引脚在上电瞬间有毛刺。FPGA的IO在配置完成前电平不定,如果CS直接接到普通IO且没有拉电阻,上电过程中可能被拉低一下,让ADC进入一个不完整的SPI事务,之后所有配置都被忽略。

解决方式:CS信号在FPGA顶层加一个上电后延时释放逻辑,让CS在上电后至少再过t_POR才允许拉低。同时检查板子原理图,确认CS有没有外部上拉电阻,没有的话在FPGA内部约束一个弱上拉。最简单的验证方法是:用工具抓上电后CS、SCLK两根线的波形,看CS是否有非预期的低脉冲。

5.2 现象:配置随机失败,时好时坏

现象是同一份代码,这次配置成功、下次失败,回读寄存器值随机错几位,而且SCLK频率越高越明显。

原因一般是SCLK频率过高导致MISO建立时间不足,或者SPI信号上存在反射造成的毛刺。配置阶段SCLK是分频出来的信号,本身没有专门做IO时序约束,布线稍长时边沿抖动和串扰会让从机采到错误电平。

解决方式分三步:先把CLK_DIV调大,让SCLK降到手册上限的1/4,看问题是否消失;再用片内逻辑分析仪抓MISO,重点看SCLK上升沿附近的MISO是否在窗口内稳定;最后检查SPI三根线在PCB上是否等长走线、有没有串电阻。我一般习惯在MOSI和SCLK上串22欧到33欧电阻,消除过冲,配置稳定性立刻上一个台阶。

5.3 现象:配置“成功”了,但读回来对不上

现象是写入时总线事务正常,CS、SCLK波形看着都对,但读回来的数据和写入值不一致,甚至只有某几位对得上。

原因通常是帧长或位段理解错了。比如手册写的是20位帧,代码里按16位拼,那么地址位和数据位错位,寄存器值被打散。另一类是读写位方向搞反,把写指令当读指令发给从机,从机自然回不了数据。

解决方式:回到手册的SPI时序图,把每一格的位定义标出来,数一遍总位数,再对照代码里的拼接顺序。验证时写一个只发一条固定帧的测试用例,用逻辑分析仪解析出每一位,和期望帧逐位比对,不要直接在完整初始化流程里猜。第2章的Python脚本能在这里派上用场:把帧常量打印成二进制和十六进制,按位核对。

5.4 现象:配置完成后一启动采样,数据就错乱

现象是寄存器配置回读都正常,但一启动ADC转换,输出数据出现周期性毛刺、通道错位或数值整体漂移。

原因是在转换过程中又改写了寄存器。有些ADC在转换状态机运行中不允许写配置寄存器,尤其是采样率、通道序列这类与模拟前端相关的项。如果配置引擎在采集启动后又被外部信号触发重配,就可能打断正在进行的转换,造成一两个采样点错乱。

解决方式:把配置动作限定在“没有转换请求”的窗口里。一般做法是用ADC的转换完成信号(DRDY/EOC)做门控,配置引擎只在转换结束后的空闲期响应trig信号。另一种做法是参考手册里的“寄存器更新窗口”约束,比如有的ADC要求CS拉高后至少等待t_HOLD才能开始下一次转换,把这个时间在采集状态机里也预留出来。

6. 进阶技巧:回读校验与转换间隙动态重配

配置链路跑通后,还可以往上加一层保障:把“写完就拉倒”变成“写完读回来核对”。同时掌握在转换间隙做动态重配的方法,能让板卡在运行中切换量程或通道而不丢数据。

6.1 用全双工把配置读回来

SPI主机本质是全双工,发读指令的同时MISO上就会返回寄存器内容。所以回读校验不用改硬件,只需要在配置表里加一条读指令:R/W位置1,数据段占位全0,发送完成后检查rx_data的低8位是否等于期望值。

// 回读寄存器0x01,期望值是0x03 assign rd_cmd = {1'b1, 7'h01, 8'h00}; ... // 发送完成后,在spi_master的rx_data里取低8位 if (rx_data[7:0] != 8'h03) begin cfg_err <= 1'b1; // 配置失败,可触发重配 end

回读校验的价值在于,它能暴露芯片没起来、SPI引脚虚焊、配置期间被毛刺打断等问题。如果板卡上有多颗ADC,我习惯在初始化流程的最后对每颗都做一次回读,全部通过才拉高“系统就绪”信号。

6.2 动态重配的安全窗口怎么找

动态重配的要点是找到安全窗口:转换正在进行时不要碰寄存器,转换结束到下次触发之间的空闲期才是重配窗口。我的做法是把配置引擎的触发信号接到ADC的转换完成脉冲上,用这个脉冲的后沿去启动重配状态机,并保证重配所需的总时间小于两次转换之间的间隔。如果间隔不足,就先把新配置缓存住,等下一个空闲窗口再执行。这个机制对切换增益、切换输入通道、改变采样率都适用,也是把“配置”从一次性初始化升级为运行期能力的关键。

6.3 一次把SCLK拉到上限的教训

之前调一块采集板,手册写着SCLK上限50MHz,我直接把CLK_DIV设成2,跑到50MHz,结果配置时好时坏。查了一整天,最后用片内逻辑分析仪看到MISO在采样沿附近还在边沿变化——PCB走线太长,建立时间不够。把SCLK降到12.5MHz后一切正常。那之后我养成了一个习惯:配置类SPI永远不顶上限跑,功能跑通后如果想提速,再一点一点往上压。这个习惯帮我避开了很多“看似芯片有问题,其实是时序裕量不足”的坑。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询