FPGA驱动ADC128S022:SPI时序解析与Verilog控制器设计
2026/9/14 2:14:15 网站建设 项目流程

简介:这套工程示例面向TI公司的12位模数转换器ADC128S022,完整展示了在FPGA平台上使用Verilog硬件描述语言实现驱动控制的开发流程,适合电子设计竞赛、嵌入式项目以及数字信号处理相关的工程师和学习者参考。压缩包内共包含83个文件,原代码、测试平台、工程脚本和说明文档一应俱全,主要类型包括RTL源码(.v/.vhd)、Quartus工程配置(.qpf/.qsf)、ModelSim仿真任务(.do)、波形记录(.wlf)、时序报告(.rpt)以及readme说明等,整个资源包仅3.38MB,结构清晰便于按需调用。目前已有798人参与学习,可见其在实际应用中有较好的参考价值。借助顶层模块ADC128S022_top.v、仿真激励testbench以及时序序列Excel文件,读者可以深入理解启动转换、数据采集、时钟同步和错误检测等关键逻辑的实现细节,从而快速掌握基于FPGA的ADC驱动设计方法,缩短自己的项目开发周期。

1. 用 FPGA 驱动 ADC128S022,先别急着写代码

很多做 FPGA 采集的工程师第一次拿到 ADC128S022 时,会觉得它就是个 8 通道、12 位的 SPI ADC,随便搜个 Verilog 模块改改引脚就能用。等到真上了板子才发现,问题根本不在“读数据”上,而在通道切换和时序对齐上——芯片虽然叫 SAR ADC,但它的 SPI 帧里同时混着地址命令和转换结果,下发的通道地址要等下一个帧才生效。这个“一拍延迟”的规矩如果没吃透,抓回来的波形要么全部串通道,要么首帧数据直接丢掉。

这篇就围绕“ADC128S022 + FPGA + Verilog”讲一套完整驱动方案。先讲清楚芯片的 SPI 时序和关键参数,再给一个能落地的控制器状态机设计,接着用 testbench 验证 8 通道轮询逻辑,最后聊上板调试和连续采集的进阶做法。适合正在写 ADC 驱动的 FPGA 工程师,也适合用 Verilog 做数据采集又不想被时序细节卡住的人。第一部分先把芯片手册里最容易被忽略的几个参数拆开讲。

2. ADC128S022 的 SPI 帧结构与时序:读数据前先看清 8 个引脚

2.1 引脚定义与最小连接方式

ADC128S022 是 TI 出的一款 8 通道 12 位 SAR ADC,支持 SPI 接口,单电源供电,典型工作电压 2.7V 到 5.5V。既然是给 FPGA 做驱动,重点关注的就是它和主控连接的引脚:

引脚名方向功能说明
DIN输入接收 FPGA 下发的控制字,包含通道地址和掉电模式位
DOUT输出串行输出 12 位转换结果,三态输出
SCLK输入SPI 时钟,最高 16MHz(2.7V 供电时最高 13MHz)
CS_N输入片选,低有效,高电平时 DOUT 呈高阻态
VA电源模拟电源,通常与 VD 接同一路 3.3V 或 2.5V
VREF参考电压决定了输入量程,一般接 2.5V 或 3.3V 基准
IN0~IN7模拟输入8 路单端模拟输入,输入范围 0 到 VREF

实际工程项目里,FPGA 和 ADC 的连线非常干净:4 根线(SCLK、CS_N、DIN、DOUT)加上电源和参考电压,没有多余的握手信号。这也是它比并口 ADC 好用的原因。接线时注意 VREF 引脚要加 0.1uF 去耦电容,而且尽量靠近芯片引脚放置——SAR ADC 在采样瞬间会产生充电电流,电源纹波会影响转换结果。

2.2 SPI 时序:地址在下降沿被采样,数据在下降沿被输出

ADC128S022 的串行接口是标准的 16 位 SPI 帧,但不是 SPI Mode 0 那种常规读写。芯片手册明确写了:DIN 在 SCLK 的下降沿被采样,DOUT 在 SCLK 的下降沿更新输出。也就是说,FPGA 在 SCLK 下降沿同时做两件事——发送下一位地址/控制位,读取上一位转换结果。

这里有一个非常容易踩的坑:如果 FPGA 端用的是 SPI Mode 0(CPOL=0, CPHA=0),那数据是在 SCLK 上升沿被主控采样的,而 ADC128S022 恰好相反。所以必须自己产生 SCLK,并在下降沿采样 DOUT,而不是依赖硬核 SPI 控制器。这也是这个项目必须用 Verilog 自定义 SCLK 的原因。

整个帧的时序流程是这样的:

  • CS_N 拉低后,SCLK 开始翻转。
  • DIN 上先发 3 位地址(ADD2~ADD0),再发 1 位掉电模式位(PD),剩下的 12 位填充 0 或任意值。
  • 同一帧的 SCLK 下降沿,DOUT 按从高位到低位的顺序输出上一次转换的结果,前 16 个下降沿输出的是高阻或填充位,从第 4 个下降沿开始输出 12 位有效数据,前面会有 2 个无关位(通常称为“前导位”)。

这句话是理解整个驱动的关键:第 N 帧下发的通道地址,对应的是第 N+1 帧 DOUT 输出的数据。也就是说,如果你想连续采集 8 个通道,第一帧下发的地址是通道 0,但这一帧回传的数据其实是上一次(默认是通道 0)的转换值。要拿到真正的 8 通道数据,需要轮询 9 帧,丢掉第一帧,取后 8 帧。

2.3 关键参数:tACQ、SCLK 频率与吞吐率的关系

ADC128S022 的转换时间(Conversion Time)是固定的,典型值约 8 个 SCLK 周期,手册标注为 tCONV,而采样时间 tACQ 则取决于 SCLK 频率。芯片内部是 SAR 架构,采样阶段和转换阶段共用一串时钟。手册给的计算方法是:一个完整的 16 位帧周期内,前几个周期用于采样,后 12 个周期用于逐次逼近转换。

用 FPGA 驱动时,不需要严格计算 tACQ 的具体值,但必须保证 SCLK 频率在合理范围内。手册给出的 SCLK 最高 16MHz,这时吞吐率最高能到 1MSPS(每秒百万次采样)。实际项目中我一般把 SCLK 设在 10MHz 到 12MHz 之间,留出足够的时序裕量,尤其是 FPGA 和 ADC 之间走线较长或加了隔离芯片时。

三个参数要记牢:

  • 帧长:16 个 SCLK,即 DIN 发送 16 位控制字、DOUT 输出 16 位数据流。
  • 地址位:DIN 的最高 3 位(在帧的前 3 个 SCLK 下降沿发送)。
  • 数据有效段:DOUT 的第 3 到第 14 个下降沿(即采样第 4 到第 15 个下降沿的数据位),共 12 位。

理解了这三点,控制器状态机的结构就清晰了——无非是把“发地址”和“收数据”放在同一个 16 拍循环里。

3. Verilog 控制器状态机设计:把“发地址、收数据”做进同一循环

3.1 模块端口定义与功能划分

驱动模块在 FPGA 里的角色很简单:接收上层(比如状态控制模块或 FIFO 写端口)下发的通道号和启动信号,产生满足 ADC128S022 时序的 SCLK 和 CS_N,同时把 DOUT 上收到的 12 位转换结果并转成并行数据输出。

我一般把模块划分成三个子功能:分频器(产生 SCLK)、状态机主控(控制帧时序)、移位寄存器(收发数据)。端口定义如下:

module adc128s022_driver #( parameter CLK_FREQ_MHZ = 50, // 主时钟频率,单位 MHz parameter SCLK_FREQ_MHZ = 10, // 目标 SCLK 频率,单位 MHz parameter DIV_RATIO = 5, // 分频系数 = CLK_FREQ_MHZ / SCLK_FREQ_MHZ parameter N_CHANNELS = 8 // 通道数 )( input wire clk, // FPGA 主时钟 input wire rst_n, // 异步复位,低有效 input wire start, // 启动一次转换(单帧模式) input wire [2:0] ch_addr, // 要采集的通道地址 0~7 output reg [11:0] adc_data, // 转换结果 output reg data_valid, // 数据有效标志,高电平脉冲 // SPI 接口 output reg sclk, // 串行时钟 output reg cs_n, // 片选,低有效 output reg din, // 控制字输出 input wire dout // 转换结果输入 );

CLK_FREQ_MHZSCLK_FREQ_MHZ是方便综合时直接改参数的,实际运行时用DIV_RATIO做计数器分频。start信号是单脉冲,触发一个完整的 16 拍 SPI 帧。这个设计没做通道自动轮询,是因为把轮询放在上层更灵活——有的项目要固定采一路,有的要按顺序轮流采,状态机只负责“按给定地址采样一次”,两种用法都兼容。

3.2 状态机核心代码:三态状态下的 16 拍循环

状态机设计为 4 个状态:IDLE、START、SHIFT、DONE。核心逻辑是:IDLE 等待 start,START 拉低 CS_N 并准备发送第一位数,SHIFT 循环 16 拍,每一拍的下降沿发送/接收数据,第 16 拍结束后回到 DONE,dout 产生一个正脉冲。

localparam IDLE = 2'd0; localparam START = 2'd1; localparam SHIFT = 2'd2; localparam DONE = 2'd3; reg [1:0] state, next_state; reg [4:0] bit_cnt; // 0~16 位计数器 reg [15:0] shift_reg; // 发送寄存器:{3-bit addr, 1-bit PD, 12-bit dummy} reg [11:0] dout_reg; // 接收寄存器 // 状态跳转逻辑 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; end else begin state <= next_state; end end // 次态逻辑 always @(*) begin case (state) IDLE: next_state = start ? START : IDLE; START: next_state = SHIFT; SHIFT: next_state = (bit_cnt == 5'd16) ? DONE : SHIFT; DONE: next_state = IDLE; default: next_state = IDLE; endcase end

SHIFT 状态里,bit_cnt 每两个主时钟周期加 1(一个 SCLK 周期加一次),在 SCLK 下降沿时完成两件事:第一是把 shift_reg 的最高位送到 din,第二是把 dout 采进来放进 shift_reg 的最低端。这里用了一个技巧——收发共用一个移位寄存器,发送向左移,接收向左移,发送的填充位正好被接收的数据不断覆盖。

这里有参数说明:shift_reg初始值为{ch_addr, 1'b1, 11'b0_0000_0000_0000},bit 15~13 是通道地址,bit 12 是 PD 位,置 1 表示正常工作模式,后 11 位填充 0。每次 SCLK 下降沿,shift_reg 左移一位,最高位送给 ADC,同时 DOUT 数据从最低位移入。16 拍结束后,shift_reg 的 bit 11~0 就是我们要的 12 位转换结果。整个过程用的是一种“边发送边接收”的回环思路,把 16 个 SCLK 周期内的输入输出天然地对齐了。

3.3 SCLK 分频与边沿对齐

SCLK 由主时钟分频产生,最简单的方式是用一个计数器按DIV_RATIO翻转。为了保证下降沿采样可靠,SCLK 的低电平宽度和高电平宽度要相等,DIV_RATIO 最好设为偶数。

reg clk_div_cnt; reg sclk_en; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sclk_en <= 1'b0; clk_div_cnt <= 4'd0; end else if (state == SHIFT) begin if (clk_div_cnt == (DIV_RATIO/2 - 1)) begin clk_div_cnt <= 4'd0; sclk_en <= 1'b1; // SCLK 翻转 end else begin clk_div_cnt <= clk_div_cnt + 1'b1; sclk_en <= 1'b0; end end else begin sclk_en <= 1'b0; clk_div_cnt <= 4'd0; end end always @(posedge clk or negedge rst_n) begin if (!rst_n) sclk <= 1'b0; else if (sclk_en) sclk <= ~sclk; end

这段代码的坑在于:sclk_en只是翻转使能,真正产生 SCLK 是sclk <= ~sclk。如果直接写sclk <= ~sclk而不加使能,SCLK 就会变成一个自由振荡的时钟,CS_N 拉低前它就在跑,时序就乱了。另外,DIN 和 DOUT 的采样点最好避开 SCLK 翻转沿——我用的是“在 SCLK 低电平期间更新 din,高电平期间锁存 dout”,这样就不会因为建立时间不足采到毛刺。

3.4 帧结束后数据锁存与 valid 信号生成

16 拍结束后,状态机进入 DONE。这时 shift_reg 的 bit 11~0 已经是有效数据。但要注意,这个数据对应的是上一帧下发的通道地址,而不是本帧ch_addr对应的结果。所以data_valid信号要晚一个周期再拉高,并且在拉高前先把数据存到寄存器里。

always @(posedge clk or negedge rst_n) begin if (!rst_n) begin adc_data <= 12'd0; data_valid <= 1'b0; end else if (state == SHIFT && bit_cnt == 5'd16) begin adc_data <= shift_reg[11:0]; data_valid <= 1'b1; end else begin data_valid <= 1'b0; end end

注意这里state == SHIFT && bit_cnt == 5'd16是一个“最后一拍”的判定条件。bit_cnt 从 0 计数到 16,当它等于 16 时,第 16 个 SCLK 下降沿已经处理完毕,正好锁存。data_valid在 DONE 状态期间维持高电平,下一次 start 到来前自动拉低。这样上层模块可以直接用data_valid做 FIFO 写使能,不需要额外握手。

这版设计对“首帧数据无效”的处理方式:上层模块连续下发两帧,第一帧转发的是上一次的转换值(除非上电后第一次采集),第二帧开始才是真正对应通道地址的有效数据。如果做多通道轮询,最好先发一帧 dummy 数据(比如通道 0),让 ADC 先完成一次转换预热。

4. 用 testbench 验证 8 通道轮询:把时序仿真做扎实,少上一次板

4.1 模拟 ADC128S022 行为的 testbench 模型

写一个模拟 ADC 行为的 testbench 其实不难,难的是模拟出“本帧地址、下帧出数据”的真实行为。我通常会在 testbench 里建一个小的行为级模型:内部存一个last_addr寄存器,收到 DIN 的地址位后,把当前通道的模拟电压转换成 12 位数字量,等到下一帧才开始逐位移出。

module adc128s022_model ( input wire sclk, input wire cs_n, input wire din, output reg dout ); reg [2:0] addr_from_host; reg [11:0] sample_val; reg [11:0] shift_out; reg [3:0] bit_index; reg [2:0] current_addr; reg [11:0] adc_result [0:7]; // 存储 8 个通道的模拟量 // 模拟每个通道的输入电压(单位映射到 0~4095) initial begin adc_result[0] = 12'd1000; adc_result[1] = 12'd1500; adc_result[2] = 12'd2000; adc_result[3] = 12'd2500; adc_result[4] = 12'd3000; adc_result[5] = 12'd3500; adc_result[6] = 12'd4000; adc_result[7] = 12'd2048; end // 在 cs_n 拉低后的第 1~3 个 sclk 下降沿采样地址 always @(negedge sclk) begin if (!cs_n) begin if (bit_index < 3) begin addr_from_host = {addr_from_host[1:0], din}; // 累积地址位 bit_index = bit_index + 1; end end end // 第 16 个上升沿触发“转换”:用当前帧分配的地址去采样数据 always @(posedge sclk) begin if (!cs_n && bit_index == 3 && sclk == 1'b1) begin current_addr = addr_from_host; sample_val = adc_result[current_addr]; shift_out = sample_val; end end // DOUT 在 sclk 下降沿逐位输出 always @(negedge sclk) begin if (!cs_n) begin dout = shift_out[11]; shift_out = {shift_out[10:0], 1'b0}; end else begin dout = 1'bz; end end endmodule

上面这个模型简化了不少,实际用的时候还要加addr_from_host的移位寄存器和使能信号控制。最关键的是要模拟“CS_N 拉低后第 4 个下降沿 DOUT 开始输出前导位”的行为。仿真时看波形,重点不是看 DOUT 是否翻转,而是看data_validadc_data是否与期望通道对应。

4.2 轮询测试:9 帧数据怎么验

写一个轮询 testbench,让驱动模块连续采样 8 个通道。我一般会做 9 次转换,输出结果打印成表格:

帧序号下发通道地址DOUT 实际数据(表示哪个通道)期望数据
00旧值(任意)丢弃
110 通道1000
221 通道1500
............
80(循环)7 通道2048

这份表格在仿真波形图里对照着看,比单靠肉眼数 SCLK 脉冲要直观得多。一个常见的 testbench 写法是:在adc_data有效时用$display打印出地址和值,检查是否错位。

always @(posedge data_valid) begin $display("ch=%0d, data=%0d @ %0t", ch_addr, adc_data, $time); end

如果打印结果里 ch=1 的那一行数据是 1500,说明驱动时序没问题。如果 ch=1 那行出现的是 1000,说明地址晚了一帧——这时候大概率是 bit_cnt 或 state 跳转逻辑写错了,检查 SHIFT 状态退出条件是不是多等了一个周期。

4.3 Vivado 仿真器常见问题:X 态、位宽不匹配和 SCLK 毛刺

用 Vivado 仿真时最容易遇到两件事:一是移位寄存器出现 X 态,二是 SCLK 翻转瞬间 DOUT 刚好在跳变,采样出现亚稳态。X 态通常是因为仿真开始时shift_reg没有初值,在 IDLE 状态给它赋值即可;SCLK 毛刺则是分频逻辑里组合逻辑产生了竞态。

我处理采样稳定的办法是:在 SCLK 上升沿和下降沿中间再等一个主时钟周期,用一个“采样使能”信号延迟半拍。具体做法是产生一个sample_point信号,它在 SCLK 下降沿之后延时半个 SCLK 周期,然后才把 DOUT 采进来。这样 DOUT 的电平已经稳定,采样窗口落在数据有效区间中间,可靠性比在边沿上采样高得多。代价是每组数据多消耗一个主时钟周期,但对低速 SPI 来说完全值得。

5. 从仿真到上板:用 ILA 抓信号、丢首帧、再连 FIFO 做连续采集

5.1 最小上板验证:一个计数器来回切换通道

上板之前先在工程里例化一个最简单的测试代码:用一个 10 位计数器做通道地址,每 1ms 切换一次,把adc_datadata_valid引到 ILA(集成逻辑分析仪)的探针上。FPGA 和 ADC 之间用杜邦线或 PCB 走线连好,ILA 触发条件设置为data_valid == 1'b1,抓一拍看数据。

用 ILA 抓信号时,要同时观察cs_nsclkdindoutadc_data。重点检查三个点:第一,CS_N 低电平持续时间是否正好是 16 个 SCLK;第二,DIN 上的地址位是否在 SCLK 下降沿附近稳定发送;第三,DOUT 上第一个有效位出现的位置。如果 DOUT 一直为高或低且不翻转,先拿万用表量 ADC 的 VREF 引脚有没有电压,再确认 CS_N 是不是被拉高了——这两个问题能排除掉 80% 的上板异常。

5.2 上板后最常见的三个坑

第一个坑是模拟输入超过 VREF。ADC128S022 的输入电压范围是 0 到 VREF,如果输入接了 5V 或者负电压,DOUT 会输出全 1 或者全 0,有时候还会把参考电压拉偏。用前先用示波器确认输入电压在量程内。

第二个坑是模拟地与数字地没有正确连接。ADC 的 GND 引脚和 FPGA 的数字地如果是两个独立的有压差的地平面,转换结果会有一个固定偏移。上板之前检查 ADC 的模拟地是否通过一个 0 欧电阻或磁珠和数字地单点连接。

第三个坑是没做首帧丢弃。前面提到过,第一帧下发的地址对应的数据是上电初始值,不是真实采样。如果状态机在第一帧就输出 data_valid,那多通道扫描时通道 0 的数据永远是错位。解决办法就是在控制器外面加一个“预热完成”标志,第一次 start 后不锁存数据,第二次 start 后正常输出,或者干脆在轮询逻辑里跳过第一帧。

5.3 把 8 通道连续采样结果写进异步 FIFO

实际项目里不可能一帧一帧地读寄存器,通常是把data_valid接到 FIFO 的写使能,把adc_data接到 FIFO 的数据端口,连续跑起来。用 FPGA 自带的 FIFO IP 核是最省事的方案,但要注意写时钟域是主时钟域,读时钟域可以是任意其他时钟域(比如软核 AXI-Lite 时钟)。跨时钟域用异步 FIFO 对齐时,不需要额外逻辑,直接让data_valid的脉冲宽度保持一个主时钟周期就可以。

wire fifo_wr_en; wire [11:0] fifo_din; wire fifo_full; assign fifo_wr_en = data_valid && !fifo_full; assign fifo_din = adc_data; fifo_async #( .DATA_WIDTH(12), .DATA_DEPTH(1024) ) u_fifo ( .wr_clk (clk), .wr_en (fifo_wr_en), .din (fifo_din), .full (fifo_full), .rd_clk (rd_clk), .rd_en (rd_en), .dout (fifo_dout), .empty (fifo_empty) );

FIFO 写进的数据是 12 位,对应通道信息没有一并保存,所以上层读取时要知道当前是哪个通道。进阶做法是把通道地址也拼进 FIFO 数据里:用 16 位宽度,高 4 位存通道号,低 12 位存转换值。这样 ARM 或软核 CPU 读 FIFO 时不需要额外维护通道指针,直接按 16 位解析即可。代价是 FIFO 宽度多 4 位,但对 1024 深度来说资源开销可忽略。

上板调试的关键指标是“连续采集的吞吐率是否达到预期”。之前设计了 10MHz SCLK,对应的帧周期是 1.6us,8 通道轮询一次是 12.8us,理论吞吐率约 78kSPS(每秒每通道采样率)。用定时器统计 1 秒内收到的完整轮询周期数,除以 8 得到单通道有效采样率,再和理论值对比。如果差得远,优先检查 SCLK 分频是否受到阻塞——比如状态机里用了多余的等待周期,或者 data_valid 脉冲宽度超出了预期。

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

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

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

立即咨询