简介:面向FPGA数字信号处理、DDS波形合成与ADDA闭环测试的学习者,这份工程提供从DDS IP产生正弦波、DAC模拟输出回环至ADC输入,到ILA采样与Matlab频谱验证的完整参考。实验采用50MHz DAC时钟,经MMCM分频得到25MHz ADC采样时钟,使用ILA捕获不少于2048个样点,并通过VIO修改频率字观察1MHz与3MHz信号的频谱变化;同时涉及AN108 34针插头安装方向(1脚与Zynq底板对齐)及0~20MHz低通滤波器通带注意事项。压缩包共613个文件,约46.11MB,以Verilog/VHDL源码、约束与工程脚本、xci IP配置、csv/ila采样数据、bit流和说明文档为主,工程目录还可见仿真、综合实现与板级调试相关文件,便于按模块对照复现。已有321人学习,读者可据此核对采样时钟、数据位宽、频谱峰值与频率字设置之间的关系,对课程设计、毕业设计及FPGA实验教学中的DDS/ADDA联调具有较强参考价值。
1. DDS ADDA 测试工程文件:一块板子上的自发自收,难的往往不是算法,而是细节
把 DDS 和 ADDA 放在同一块板子上做自发自收,是验证一条信号链最省钱的办法:数字域先造一段干净的正弦,DAC 把它变成模拟电压,滤波衰减后送回 ADC,最后拿回数字域做频谱比对。DDS ADDA 测试工程文件省掉的正是中间那些没人愿意重写第二遍的工作——相位累加器定宽、查找表初始化、跨时钟域收数、引脚与时序约束。它适合手上已经有一块带并行 DAC 和 ADC 的板子、想先把链路跑通再去抠指标的人,也适合要拿它当预研或教学平台的人。下面按我拆这类工程的顺序讲:先看 DDS 内核参数怎么定,再看模拟与收数链路怎么搭,最后落到上板步骤和几个血泪坑。
2. DDS 内核拆解:32 位相位累加器配 12 位查找表,参数到底怎么定
DDS 的骨架其实很朴素,一个加法器加一块 ROM,但它决定了整条链路的上限。累加器位宽定死频率分辨率,查找表地址位宽定死相位截断杂散,输出位宽定死幅度量化噪声,三者任意一个不达标,后面模拟链路再好也补不回来。这一章把这三个参数逐个拆开算一遍,再把工程目录里各文件的职责说清楚,照着填参数就能跑。
2.1 四段数据通路:累加、截断、查表、对齐
一段正弦在数字域里的产生过程分成四段。第一段是相位累加,每个采样时钟把频率控制字加到相位寄存器上,寄存器溢出一次就是一个完整周期,溢出速率就是输出频率。第二段是相位截断,累加器通常给 32 位,ROM 装不下 2^32 个点,只能取高若干位当地址,被丢掉的那部分就是相位截断误差,杂散的源头在这里。第三段是查表,用截断后的地址读出预先算好的正弦样点。第四段是位宽对齐,把样点补零或截断到 DAC 的并行位宽上,同时处理无符号偏移二进制和补码之间的转换。
// dds_core.v —— 相位累加 + 查找表,输出偏移二进制给并行 DAC module dds_core #( parameter PHASE_W = 32, // 相位累加器位宽,决定频率分辨率 parameter LUT_ADDR_W = 12, // 查找表地址位宽,决定相位截断杂散 parameter DATA_W = 14 // 输出位宽,必须和 DAC 并行位宽一致 )( input wire clk, input wire rst_n, input wire [PHASE_W-1:0] ftw, // 频率控制字 input wire [PHASE_W-1:0] poff, // 相位偏移,做相位扫描/双音用 output reg [DATA_W-1:0] dac_data, output reg dac_valid ); reg [PHASE_W-1:0] acc; wire [PHASE_W-1:0] phase = acc + poff; // 全位宽加偏移 wire [LUT_ADDR_W-1:0] addr = phase[PHASE_W-1 -: LUT_ADDR_W]; // 取高位,即截断 // sine_lut:2^LUT_ADDR_W 深、DATA_W 宽的 ROM,例化时用 $readmemh 初始化 wire [DATA_W-1:0] lut_q; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin acc <= {PHASE_W{1'b0}}; dac_data <= {DATA_W{1'b0}}; dac_valid <= 1'b0; end else begin acc <= acc + ftw; // 溢出即一个输出周期 dac_data <= lut_q; // 查表结果打一拍,与 DAC 建立时间对齐 dac_valid <= 1'b1; end end // ROM 读出用地址打一拍,保证 addr -> lut_q 与 acc 更新同步 sine_rom #(.AW(LUT_ADDR_W), .DW(DATA_W)) u_rom ( .clk (clk), .addr (addr), .dout (lut_q) ); endmodule上面这段的关键不在语法,在几个容易忽略的地方。acc + ftw用的是累加器自身位宽,Verilog 会自动回绕,这正是我们想要的溢出行为,千万不要写成带饱和的加法。phase[PHASE_W-1 -: LUT_ADDR_W]是取高位的写法,等价于右移PHASE_W - LUT_ADDR_W位,改位宽时不用手算移位量。dac_valid比dac_data晚一拍拉高,接 DAC 时用作时钟使能或输出寄存器的加载信号,很多工程干脆不引出这个信号,结果在高速下数据与时钟相位对不上。ROM 例化必须带输出寄存器,否则大容量 LUT 会被综合成组合逻辑,时序直接崩。
参数怎么定有一条经验线:PHASE_W不小于 24,做精密测量或低频输出时给到 32 或 48;LUT_ADDR_W取 10 到 14,12 是通用配置;DATA_W严格等于 DAC 并行位数,多一位少一位都会让波形出现台阶或削顶。
2.2 频率控制字与分辨率:三个数字决定输出精度
频率控制字是 DDS 里唯一需要现场算的公式:
FTW = round(f_out * 2^PHASE_W / f_clk) f_out_actual = FTW * f_clk / 2^PHASE_W 频率分辨率 = f_clk / 2^PHASE_W以 125 MHz 采样时钟为例,不同相位位宽下的分辨率和 1 MHz 输出对应的控制字如下表。这张表建议截图存下来,现场改参数时比翻公式快。
| 相位位宽 | 频率分辨率(125 MHz 时钟) | 1 MHz 对应的控制字 | 实际输出频率 |
|---|---|---|---|
| 24 | 7.4506 Hz | 134218 | 1000000.19 Hz |
| 28 | 0.4657 Hz | 2147484 | 1000000.09 Hz |
| 30 | 0.1164 Hz | 8589935 | 1000000.02 Hz |
| 32 | 0.0291 Hz | 34359738 | 1000000.00 Hz |
控制字取整带来的频率误差最大不超过分辨率的一半,做单音测试完全够用。真正需要注意的是输出频率上限:理论上可以到f_clk/2,但重建滤波器的过渡带和 DAC 的建立时间会把实用上限压到0.4 * f_clk左右。125 MHz 时钟下,单音测试建议不超过 40 MHz,超过之后 DAC 输出已经看不出正弦形状,测出来的一堆谐波其实是 DAC 的零阶保持效应,不是你的设计问题。
查找表地址位宽影响的是杂散,不是频率。相位截断产生的最大杂散大致满足SFDR ≈ 6.02 * LUT_ADDR_WdBc,这是工程上够用的估算:
| 地址位宽 | 样点数 | 相位截断杂散估算 | ROM 容量(14 位输出) |
|---|---|---|---|
| 10 | 1024 | 约 -60 dBc | 约 1.75 KB |
| 12 | 4096 | 约 -72 dBc | 约 7 KB |
| 14 | 16384 | 约 -84 dBc | 约 28 KB |
另外还有幅度量化噪声,理论 SNR 约6.02 * DATA_W + 1.76dB,14 位输出对应约 86 dB,12 位对应约 74 dB。如果你的目标 SFDR 是 80 dBc,地址位宽至少给 14;目标 70 dBc,12 位就够。不要一上来就堆 16 位地址,ROM 面积翻四倍,收益只有 12 dB。
查找表初始化用脚本生成最稳,手写 hex 迟早出错。下面这段 Python 生成偏移二进制格式的 ROM 初值文件,注意留一个 LSB 的幅度余量,满量程会削顶:
import numpy as np LUT_ADDR_W = 12 DATA_W = 14 n = 1 << LUT_ADDR_W mid = 1 << (DATA_W - 1) # 偏移二进制中点 amp = mid - 1 # 留 1 个 LSB 余量,避免 +FS 处削顶 phase = np.arange(n) / n * 2 * np.pi sine = np.round(mid + amp * np.sin(phase)).astype(np.int64) sine = np.clip(sine, 0, (1 << DATA_W) - 1) # 双保险,防浮点误差越界 digits = (DATA_W + 3) // 4 # 每行十六进制字符数 with open("sine_lut.hex", "w") as f: for v in sine: f.write(f"{v:0{digits}x}\n") print(f"points={n}, min={sine.min()}, max={sine.max()}")amp = mid - 1这一行是后面排查削顶问题的关键,很多工程直接写mid,正弦峰值刚好顶到2^DATA_W - 1,DAC 满幅输出再叠加运放失调,频谱上立刻出现一片对称的谐波。生成的 hex 用$readmemh("sine_lut.hex", rom)灌进 ROM,综合工具会自动推断成块 RAM,不占用逻辑资源。
2.3 工程文件的目录与时钟域划分
工程文件通常按功能分目录,第一次打开时先看目录结构,比直接读顶层更快建立心智模型。我拆过的这类工程大致是下面这个组成,不同工程会有增删,但职责划分基本一致。
| 目录/文件 | 作用 | 关键参数或注意点 |
|---|---|---|
rtl/dds_core.v | 相位累加与查表 | PHASE_W、LUT_ADDR_W、DATA_W |
rtl/sine_rom.v | 波形 ROM 封装 | 由综合工具推断 BRAM |
rtl/dac_if.v | DAC 接口时序与位序对齐 | 并行位序、输出寄存器 |
rtl/adc_if.v | ADC 接口捕获与位对齐 | LVDS 或并行、IDDR 处理 |
rtl/cap_wr.v | 采样缓存写侧 | 抓取长度、触发布防 |
rtl/cfg.v | 参数配置寄存器 | 频率字、幅度、触发使能 |
sim/tb_dds.v | 行为级仿真 | 用 $readmemh 灌同一份 LUT |
constr/timing.xdc | 时钟与 IO 约束 | 见第 4 章 |
script/gen_lut.py | LUT 生成脚本 | 改位宽后必须重跑 |
时钟域至少有三个:DAC 侧时钟、ADC 侧时钟、慢速配置时钟。三者可能同源也可能不同源,同源时相位关系固定,测出来的重复性好;不同源时每次上电的相位随机,做相干采样会很痛苦。配置寄存器一定要在配置时钟域里写,再把单比特使能信号打两拍同步到 DAC/ADC 域,多比特参数(比如频率字)用握手或双缓冲切换,别直接打拍,否则切换瞬间会出现半个字的错值。
仿真环节别省。写一个最简单的 testbench,把同一个 hex 文件灌进 ROM,跑 4096 个点,用$fwrite导出到文本,和 Python 生成的理想正弦逐点比对,能提前抓出位序颠倒、补码偏移二进制搞混这类问题。这类错误在板子上表现为波形形状对但幅度或偏置不对,靠示波器看很难定位。
3. ADDA 闭环:从 DAC 模拟输出到 ADC 采回数字流的搭建顺序
数字域调通之后,真正的麻烦才开始。DAC 出来的是阶梯波,ADC 采进来的是带噪声和失真的数字流,中间这段模拟链路的每一个环节都会往指标上扣分。这一章按信号流的方向走一遍:先看模拟前端怎么配,再看数字侧怎么把数据抓稳,最后说数据怎么搬出板子。
3.1 模拟链路:滤波器、衰减器和时钟质量先过关
DAC 输出的是零阶保持的阶梯波,频谱上除了基波还有fs ± f0、2fs ± f0一系列镜像。重建滤波器的作用就是把这些镜像压下去,截止频率一般设在0.4 * fs附近,阶数看你对镜像抑制的要求。用三阶巴特沃斯或 LC 椭圆滤波都行,前者相位线性好,后者过渡带陡。我一般先用示波器直接看 DAC 引脚上的波形,能明显看到台阶说明更新率不够或者时钟频率设低了;再接滤波器看输出,台阶变平滑、幅度略降是正常现象。
衰减环节最容易被忽略。DAC 满幅输出通常是 2 Vpp 或更高,而多数 ADC 的输入量程是 1 Vpp 差分或 2 Vpp 差分,直接对接就是削顶。常见做法是用 π 型电阻网络把幅度压到 ADC 量程的 70% 到 80%,留出余量应对运放失调和温漂。量程占用太低也不行,ADC 的有效位数会明显下降,实测 SNR 会比理论值低好几 dB。这个比例我一般调到 0.75 左右,兼顾动态范围和削顶风险。
时钟质量对指标的影响比多数人预期的大。采样时钟的抖动会直接调制到输出上,由此引入的 SNR 上限可以用SNR = -20 * log10(2π * f_in * t_jitter)估算。1 MHz 输入、1 ps 抖动对应约 104 dB,10 MHz 输入、10 ps 抖动就只有约 64 dB 了。所以高频测试时,时钟源和时钟走线的重要性不低于 ADC 本身。DAC 和 ADC 尽量用同源时钟,相位关系固定,抓到的数据重复性好,排查问题也容易。
还有一条血泪经验:数字地和模拟地的处理。DDS 的并行数据线翻转会在电源上产生尖峰,耦合到模拟前端就是一堆宽带噪声。频谱上表现为噪底整体抬高,而不是某个固定频率的杂散,遇到这种情况先查电源去耦和地平面分割,别急着怀疑 ADC 坏了。
| 环节 | 常见做法 | 快速判定 |
|---|---|---|
| 重建滤波 | 三阶低通,fc 约 0.4 fs | 示波器看台阶是否消失 |
| 幅度衰减 | π 型电阻,压到量程 75% | 万用表量峰峰值 |
| 阻抗匹配 | 50 Ω 端接 | 长线时看有无反射振铃 |
| 时钟 | DAC/ADC 同源 | 看相位噪声与重复性 |
| 电源 | 模拟域独立 LDO,多点去耦 | 频谱噪底是否整体抬高 |
3.2 收数通路:跨时钟域 FIFO 与触发式抓取
ADC 数据在 ADC 时钟域产生,读出去往往在另一个更慢的时钟域,这中间必须做跨时钟域处理。做单音测试时,最容易犯的错是拿在线逻辑分析仪直接抓 ADC 数据线,看着有波形就以为通了,实际上抓到的可能是分段采样拼起来的,时间轴不连续,拿去 FFT 会得到一堆假杂散。
正确做法是在 ADC 时钟域里做一次连续缓存,采满固定长度后停住,再由读侧慢速搬出。下面这个模块是写侧,只负责“布防—连续写—采满停”,读侧用双口 RAM 的另一端口按地址顺序读,读写指针用格雷码同步,这套结构在多数工程里够用。
// cap_wr.v —— ADC 时钟域连续写,采满 CAP_LEN 点后置 done module cap_wr #( parameter DW = 16, // 与 ADC 输出位宽一致 parameter CAP_LEN = 16384, // 一次抓取点数,取 2 的幂 parameter AW = 14 // log2(CAP_LEN) )( input wire adc_clk, input wire adc_rst_n, input wire [DW-1:0] adc_data, input wire adc_valid, // ADC 数据有效标志 input wire arm, // 单比特布防,配置域同步过来 output reg [AW-1:0] wr_addr, output reg [DW-1:0] wr_data, output reg wr_en, output reg cap_done // 采满拉高,由读侧握手清除 ); reg armed; always @(posedge adc_clk or negedge adc_rst_n) begin if (!adc_rst_n) begin armed <= 1'b0; wr_addr <= {AW{1'b0}}; wr_data <= {DW{1'b0}}; wr_en <= 1'b0; cap_done <= 1'b0; end else if (arm && !armed && !cap_done) begin armed <= 1'b1; // 上升沿布防,下一拍开始写 wr_addr <= {AW{1'b0}}; end else if (armed && adc_valid && !cap_done) begin wr_en <= 1'b1; wr_data <= adc_data; wr_addr <= wr_addr + 1'b1; if (wr_addr == CAP_LEN - 1) begin armed <= 1'b0; // 采够长度自动撤防 cap_done <= 1'b1; // 跨域前在读侧打两拍 end end else begin wr_en <= 1'b0; end end endmoduleCAP_LEN决定频率分辨率,做 FFT 时点数越多分辨率越高,但存储和上传时间也线性增长。16384 点配 125 MHz 采样率,频率分辨率约 7.6 kHz,用来分辨谐波和杂散够了。做双音互调测试时得加到 65536 点以上,否则两个靠得近的互调分量会落在同一个 bin 里。
arm必须是单比特,而且在配置时钟域里已经打过两拍同步。多比特的触发阈值不要直接跨域,要改成“配置域比较、只同步比较结果”的结构,否则会采到亚稳态导致的随机值。
cap_done拉高后不要立刻清除,等读侧把数据搬完再回一个应答信号,两边握手完成才允许下一次布防。我见过直接在读侧把cap_done清零的写法,结果偶尔会丢最后几个点,频谱上表现为噪底抬高,查了很久才发现是握手的问题。
3.3 数据出板:三种导出方式的取舍
数据抓稳之后要搬到上位机做分析,常见三条路:在线逻辑分析仪导出、串口上传、高速接口上传。三者不是替代关系,是不同阶段用不同工具。
| 方式 | 典型深度 | 适用阶段 | 主要坑 |
|---|---|---|---|
| 在线逻辑分析仪导出 | 1K 到 8K 点 | 首次上板看波形形状 | 分段采样导致时间轴不连续 |
| 串口上传 | 16K 到 256K 点 | 单音 FFT、慢速验证 | 波特率与实际不符导致丢字节 |
| 以太网/USB 上传 | 1M 点以上 | 双音互调、长时捕获 | 协议栈调试量大,先跑通再优化 |
我的习惯是分两步:先用在线逻辑分析仪抓 2048 点,确认波形是正弦、幅度没削顶、周期数对得上,这一步只花几分钟;确认无误后再走串口拉 64K 点做 FFT。串口上传时数据加一个帧头和一个简单校验和,上位机先从帧头对齐再解析,能省掉大量“数据错位”的排查时间。波特率别贪高,115200 在多数板子上稳定,1 Mbps 以上要看 USB 转串口芯片的支持情况,丢字节是常态。
4. 上板四步:约束、抓波形、导数据、算指标
从综合出比特流到拿到第一个可信的 SNR 数字,中间隔着四步,每一步都有明确的验收标准。跳过任何一步直接看结果,最后大概率是在一堆假数据里找规律。
4.1 引脚与时序约束写不对,后面全是玄学
约束文件是这类工程里最容易被复制粘贴带错的部分。时钟周期、IO 延迟、跨时钟域路径这三类必须写全,缺一类工具就会按默认值猜,猜错的结果是时序报告全绿但板子上跑不通。
# timing.xdc —— 只列关键几类,具体数值按器件手册和 PCB 实测修正 create_clock -name clk_dac -period 8.000 [get_ports clk_dac_p] # 125 MHz DAC 侧 create_clock -name clk_adc -period 8.000 [get_ports clk_adc_p] # 125 MHz ADC 侧 # DAC 并行数据输出:max/min 对应外部器件的建立与保持要求 set_output_delay -clock [get_clocks clk_dac] -max 1.500 [get_ports dac_data*] set_output_delay -clock [get_clocks clk_dac] -min -0.500 [get_ports dac_data*] # ADC 并行数据输入:按 ADC 的 tCO 加 PCB 走线延时估算 set_input_delay -clock [get_clocks clk_adc] -max 2.000 [get_ports adc_data*] set_input_delay -clock [get_clocks clk_adc] -min 0.800 [get_ports adc_data*] # 跨时钟域的单比特控制信号,声明为伪路径,不要按同步时序分析 set_false_path -from [get_clocks clk_dac] -to [get_clocks clk_adc] set_false_path -from [get_clocks clk_adc] -to [get_clocks clk_dac] # 异步复位同步释放后的复位网络 set_false_path -from [get_ports rst_async_n]-max和-min的单位是纳秒,含义是数据相对时钟沿的到达窗口。上表数值只是示意,实际要按 ADC 手册的tCO(时钟到输出有效)加上 PCB 走线延时(约 6 ps/mm,FR4 内层)来算。算不准也没关系,先给一个保守值,保证时序能过,再上板用在线逻辑分析仪看数据眼图,逐步收紧。
比延迟数值更容易错的是位序。DAC 的 D0 到 D13 和 FPGA 引脚的对应关系,一定按原理图逐根核对,别信“上次就是这么接的”。位序整体反了,输出频率会变成镜像频率;中间两位交换,波形上会出现不规则台阶,频谱上是一堆非谐波相关的杂散,非常难定位。
4.2 在线逻辑分析仪怎么设触发才抓得到
在线逻辑分析仪(ILA)用得好不好,一半在触发条件。采样时钟必须选被测信号所在时钟域,抓 ADC 数据就用 ADC 时钟,抓 DDS 输出就用 DAC 时钟,选错了看到的是跨域采样后的假波形。
触发条件我一般分三轮设:
第一轮,验证时钟活着。触发条件设成任意时刻,深度最小,看采样时钟计数寄存器是否在跳。这一步能排除时钟没起来、PLL 没锁的问题。
第二轮,验证数据在动。触发条件设成adc_valid && (adc_data != 0),深度 2048,触发位置放在 25% 预触发。看到数据在动说明 ADC 接口通了,但还不能判断对错。
第三轮,验证波形形状。触发条件设成cap_done上升沿,深度设成等于CAP_LEN,这样一次就能抓到完整的一段。抓完之后在工具里直接把数据导出成 CSV,别在波形窗口里数周期,肉眼数不准。
| 观测点 | 采样时钟 | 触发条件 | 看什么 |
|---|---|---|---|
| 时钟计数器 | 对应域时钟 | 任意 | 是否递增,排除时钟问题 |
| ADC 原始数据 | adc_clk | adc_valid 且非零 | 数据线是否全 0 或全 1 |
| DDS 输出 | dac_clk | dac_valid 上升沿 | 正弦形状、幅度、周期数 |
| 缓存完成 | adc_clk | cap_done 上升沿 | 一次抓满整段,供导出 |
抓 DDS 输出时有个细节:如果深度不够覆盖一个完整周期,波形窗口里会看到一段看似无规律的折线,误以为 DDS 出错了。低频输出(比如 10 kHz)在 125 MHz 时钟下一个周期有 12500 个点,深度 2048 根本盖不住,这时要么降低深度要求改成看局部斜率,要么把输出频率临时调到 1 MHz 以上再抓。
4.3 上位机 FFT:SNR、SFDR、ENOB 的算法与判读
拿到一段连续采样数据,接下来算指标。核心是三条:去掉直流、加窗抑制泄漏、把基波 bin 附近的能量单独拿出来。
import numpy as np def analyze(x, fs, guard=3): """x: 采样序列; fs: 采样率; guard: 基波两侧保护的 bin 数""" x = np.asarray(x, dtype=np.float64) n = len(x) x = x - x.mean() # 去直流,否则 DC 泄漏会淹没低噪底 w = np.blackman(n) # Blackman 窗,旁瓣低,适合看杂散 X = np.fft.rfft(x * w) P = np.abs(X) ** 2 / (w ** 2).sum() # 归一化到单边功率谱 P[1:] *= 2 k0 = int(np.argmax(P[1:]) + 1) # 基波 bin,跳过 DC sig = P[k0 - guard:k0 + guard + 1].sum() Pn = P.copy() Pn[:2] = 0 # 丢掉 DC 及其相邻 bin Pn[k0 - guard:k0 + guard + 1] = 0 # 丢掉基波 nd = Pn.sum() # 噪声 + 谐波 + 杂散 snr = 10 * np.log10(sig / nd) # SFDR:最大杂散相对基波,排除基波自身邻域 spur = Pn.max() sfdr = 10 * np.log10(sig / spur) # ENOB 用 SINAD 折算,SINAD 里的 S 是基波,N+D 是其余全部 enob = (snr - 1.76) / 6.02 f0 = k0 * fs / n return dict(f0=f0, snr=snr, sfdr=sfdr, enob=enob) data = np.loadtxt("cap.csv", delimiter=",", skiprows=1)[:, 1] # 第 2 列是 ADC 码值 print(analyze(data, fs=125e6))几个参数值得说清楚。guard是基波两侧的保护 bin 数,窗函数会把基波能量摊到相邻几个 bin 上,guard太小会把泄漏当成噪声,SNR 偏低;太大又会把真实杂散误算进基波,SNR 偏高。n是 2 的幂时guard取 3 到 5 比较合适,非 2 的幂时适当放大。w ** 2).sum()是窗函数的功率归一化因子,漏掉这一项算出来的绝对功率是错的,虽然比值指标不受影响,但噪底绝对值会偏移。
窗函数的选择看测试目的:看杂散和 SFDR 用 Blackman 或 Blackman-Harris,旁瓣压得低;看噪底和 SNR 用 Hann,主瓣窄;做相干采样(信号频率正好落在整数 bin 上)时干脆不加窗,直接用矩形窗,此时泄漏为零,指标最干净。相干采样的条件是f0 / fs = M / n,M 为整数,也就是说频率控制字得配合抓取点数反算,这也是为什么工程里常备一个“相干频率计算”小脚本。
读结果时有几个典型现象:SNR 正常但 SFDR 很差,多半是 DDS 相位截断或 DAC 非线性,先把 LUT 地址位宽加上去试试;SNR 和 SFDR 一起差,先看时钟抖动和电源噪声;测出来的 ENOB 比 ADC 手册低 2 位以上,先确认量程占用是否到 75%,再看有没有削顶。
5. 避坑排查:五个把 DDS 回环测出假结果的细节
下面五条都是我自己或身边人真实撞过的,共同特点是“板子看起来在跑,数据看起来正常,结论全错”。每条按现象、原因、解决写,遇到对得上的直接照做。
5.1 波形有但频率差一截
现象:示波器上有干净正弦,频率比设定值低或高一个固定比例,比如设 1 MHz 出来 1.25 MHz,或者出来 800 kHz。
原因:频率控制字用错了时钟。最常见的是算 FTW 时用了 ADC 时钟频率,实际 DDS 跑在 DAC 时钟域,两个时钟不同频时比例就固定偏了。次常见的是参数位宽不匹配,PHASE_W改成 30 但顶层例化的参数覆盖没跟着改,实际还是 32 位在跑,控制字按 30 位算出来的值喂进去就偏了 4 倍。
解决:先在仿真里跑一遍,把ftw和acc都拉出来看,用$display打印溢出间隔,换算出的频率和公式对得上再上板。上板后用在线逻辑分析仪抓acc的最高位,看方波周期,这是最直接的验证。工程里最好把时钟频率做成 parameter,算 FTW 的脚本直接读同一个 parameter,避免两处手写不一致。
5.2 输出削顶、频谱一片草原
现象:示波器上正弦顶部和底部被削平,FFT 里出现大量等间隔谐波,噪底整体抬高,SFDR 只有 40 dB 出头。
原因:三个可能。一是 LUT 初始化时幅度用了满量程mid,正弦峰值顶到码值边界,这个在第 2 章的脚本里已经用mid - 1规避。二是 DAC 满幅输出超过 ADC 量程,模拟链路没做衰减,ADC 自己在削顶。三是补码和偏移二进制搞混,负半周被当成大正数,波形上表现为一半正常一半翻转。
解决:先量模拟链路各点的峰峰值,从 DAC 引脚一路量到 ADC 输入,找到削顶发生的位置。数字侧用在线逻辑分析仪抓dac_data,看最大值和最小值是否贴着 0 和2^DATA_W - 1,贴着就是数字削顶。补码/偏移二进制的转换只在接口层做一次,别在 DDS 核和 DAC 接口里各做一次,两次转换等于没转。
5.3 ADC 数据恒为零或恒定值
现象:在线逻辑分析仪抓到的adc_data全是 0,或者全是一个固定值,adc_valid也不动。
原因:adc_valid没接对是最常见的,很多 ADC 接口模块把 valid 直接接成常数 1,实际数据还没稳定就开始采样。其次是采样时钟没起来,PLL 没锁或者时钟使能没打开。差分接口的 P/N 接反也会导致数据无意义,但这种情况通常表现为随机跳变而不是恒定值。还有一种比较隐蔽:输入延迟约束给错,采样点正好落在数据跳变沿上,抓到的值在相邻两个码之间抖。
解决:按“时钟—valid—数据”的顺序查。先用在线逻辑分析仪看采样时钟计数寄存器,不动就是时钟问题;动了再看adc_valid,不跳就是接口时序问题;都正常再看数据。输入延迟用保守值先跑通,再逐步收紧。差分对 P/N 接反可以在约束里交换引脚做快速验证,确认后再改硬件。
5.4 抓到的数据每次不一样,重复性差
现象:同样的配置,连续抓十次,每次 FFT 出来的噪底和杂散位置都不一样,SNR 波动好几 dB。
原因:抓取不是连续时间轴。在线逻辑分析仪在深度不够时会分段采样再拼接,拼出来的波形看着连续,实际上是若干不连续片段首尾相接,相位不连续,FFT 上就是随机杂散。另一个原因是触发点漂移,每次触发的相位不同,相干采样变成非相干,泄漏导致噪底抬高。
解决:改用第 3 章的连续缓存结构,采满固定长度再搬出,保证时间轴连续。触发条件用cap_done而不是数据比较,触发点固定。如果必须用在线逻辑分析仪,把深度设成不小于一个完整周期的整数倍,并确认工具里没有开启分段采样模式。判断方法很简单:把抓到的数据首尾各取 100 点看是否连续,不连续就说明拼接过。
5.5 综合后时序违例、上板跑飞
现象:仿真全对,综合后时序报告一堆违例,或者时序勉强过了但板子上偶尔死机、数据错乱。
原因:跨时钟域的多比特信号直接打拍。频率字、抓取长度这类多比特参数在配置域和 ADC 域之间直接两级触发器同步,切换瞬间会采到新旧值混合的中间态,比如
本文还有配套的精品资源,点击获取