FPGA驱动ADC128S022:SPI时序解析与Verilog控制器设计

发布时间:2026/9/14 2:14:18
FPGA驱动ADC128S022:SPI时序解析与Verilog控制器设计 简介这套工程示例面向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 时钟最高 16MHz2.7V 供电时最高 13MHzCS_N输入片选低有效高电平时 DOUT 呈高阻态VA电源模拟电源通常与 VD 接同一路 3.3V 或 2.5VVREF参考电压决定了输入量程一般接 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 0CPOL0, CPHA0那数据是在 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 帧下发的通道地址对应的是第 N1 帧 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_MHZ和SCLK_FREQ_MHZ是方便综合时直接改参数的实际运行时用DIV_RATIO做计数器分频。start信号是单脉冲触发一个完整的 16 拍 SPI 帧。这个设计没做通道自动轮询是因为把轮询放在上层更灵活——有的项目要固定采一路有的要按顺序轮流采状态机只负责“按给定地址采样一次”两种用法都兼容。3.2 状态机核心代码三态状态下的 16 拍循环状态机设计为 4 个状态IDLE、START、SHIFT、DONE。核心逻辑是IDLE 等待 startSTART 拉低 CS_N 并准备发送第一位数SHIFT 循环 16 拍每一拍的下降沿发送/接收数据第 16 拍结束后回到 DONEdout 产生一个正脉冲。localparam IDLE 2d0; localparam START 2d1; localparam SHIFT 2d2; localparam DONE 2d3; 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 5d16) ? DONE : SHIFT; DONE: next_state IDLE; default: next_state IDLE; endcase endSHIFT 状态里bit_cnt 每两个主时钟周期加 1一个 SCLK 周期加一次在 SCLK 下降沿时完成两件事第一是把 shift_reg 的最高位送到 din第二是把 dout 采进来放进 shift_reg 的最低端。这里用了一个技巧——收发共用一个移位寄存器发送向左移接收向左移发送的填充位正好被接收的数据不断覆盖。这里有参数说明shift_reg初始值为{ch_addr, 1b1, 11b0_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 1b0; clk_div_cnt 4d0; end else if (state SHIFT) begin if (clk_div_cnt (DIV_RATIO/2 - 1)) begin clk_div_cnt 4d0; sclk_en 1b1; // SCLK 翻转 end else begin clk_div_cnt clk_div_cnt 1b1; sclk_en 1b0; end end else begin sclk_en 1b0; clk_div_cnt 4d0; end end always (posedge clk or negedge rst_n) begin if (!rst_n) sclk 1b0; 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 12d0; data_valid 1b0; end else if (state SHIFT bit_cnt 5d16) begin adc_data shift_reg[11:0]; data_valid 1b1; end else begin data_valid 1b0; end end注意这里state SHIFT bit_cnt 5d16是一个“最后一拍”的判定条件。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] 12d1000; adc_result[1] 12d1500; adc_result[2] 12d2000; adc_result[3] 12d2500; adc_result[4] 12d3000; adc_result[5] 12d3500; adc_result[6] 12d4000; adc_result[7] 12d2048; 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 1b1) 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], 1b0}; end else begin dout 1bz; end end endmodule上面这个模型简化了不少实际用的时候还要加addr_from_host的移位寄存器和使能信号控制。最关键的是要模拟“CS_N 拉低后第 4 个下降沿 DOUT 开始输出前导位”的行为。仿真时看波形重点不是看 DOUT 是否翻转而是看data_valid和adc_data是否与期望通道对应。4.2 轮询测试9 帧数据怎么验写一个轮询 testbench让驱动模块连续采样 8 个通道。我一般会做 9 次转换输出结果打印成表格帧序号下发通道地址DOUT 实际数据表示哪个通道期望数据00旧值任意丢弃110 通道1000221 通道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如果打印结果里 ch1 的那一行数据是 1500说明驱动时序没问题。如果 ch1 那行出现的是 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_data和data_valid引到 ILA集成逻辑分析仪的探针上。FPGA 和 ADC 之间用杜邦线或 PCB 走线连好ILA 触发条件设置为data_valid 1b1抓一拍看数据。用 ILA 抓信号时要同时观察cs_n、sclk、din、dout、adc_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.6us8 通道轮询一次是 12.8us理论吞吐率约 78kSPS每秒每通道采样率。用定时器统计 1 秒内收到的完整轮询周期数除以 8 得到单通道有效采样率再和理论值对比。如果差得远优先检查 SCLK 分频是否受到阻塞——比如状态机里用了多余的等待周期或者 data_valid 脉冲宽度超出了预期。本文还有配套的精品资源点击获取