☰
Vivado中DDS信号发生器完整验证:从仿真到上板ILA实测
2026/10/6 10:24:19 网站建设 项目流程

以前我调DDS信号发生器,习惯先开Matlab,把相位累加器的数学模型敲一遍、看几眼正弦波、再回去写Verilog。后来发现这条路在项目节奏快的时候真不划算:Matlab里的模型和FPGA里的定点实现毕竟是两套东西,仿真和工程之间隔着一道"翻译"的工序。现在我基本是在Vivado里直接用Verilog做DDS的完整验证——行为仿真、综合实现、上板ILA抓波形,一步到位。

这篇文章全程记录我用Vivado给一个DDS信号发生器做"全身体检"的过程,包括DDS核心里相位累加器和查找表怎么搭、testbench怎么写得能看、ILA上板实测怎么抓数据、以及波形质量体检报告里最容易被忽略的杂散与相位截断问题。适合正在学FPGA、想在Vivado里把DDS从仿真做到板级验证的同学,也适合想放下Matlab、纯靠FPGA工具链走完整条验证链路的工程师参考。

1. 为什么我决定在Vivado里直接验证DDS,而不是先跑Matlab

1.1 DDS到底在验证什么:三层问题

DDS信号发生器,核心功能就是用参考时钟去合成一个用户想要的频率和波形。但"验证一个DDS"这件事,远比"看看能不能出正弦波"复杂。我们在实际项目里验证DDS,其实是在回答三个层面的问题:

第一层是功能对错:输出频率对不对?波形是不是标准正弦?跳频的时候相位能不能保持连续?

第二层是性能好坏:谐波多大?杂散多大?频率准确度和频率稳定度如何?信噪比能不能满足后级电路的要求?

第三层是资源与实时性:在FPGA上占多少LUT和BRAM?任意跳频指令下发后,需要多少个时钟周期才能稳定输出?

Matlab在回答第一层问题时特别顺手,因为它的DDS模型天然是数学化的,浮点运算、连续相位、理想正弦。但到了第二层和第三层,Matlab模型如果想真正反映FPGA行为,就得把定点化、截位、查找表量化、时序延迟全部建进去。这个工作量加起来,往往比重写一份Verilog还大。

1.2 Vivado仿真与Matlab仿真的本质差异

Matlab里的DDS模型,相位累加器是用double类型跑的,正弦函数调用的是数学库。而FPGA里跑的是固定字长的二进制运算:相位累加器会截位、查找表有量化误差、输出位宽也有限。这两者之间天然存在一个鸿沟。

举个例子,Matlab里你写sin(2pif*t),不管t是多少,结果都精确到浮点精度。但Verilog里相位累加器的低十几位一旦被截断,输出波形上就会出现细小的相位抖动,这个抖动在时域里可能看不太出来,在频域里却会表现为一对明显的杂散边带。

更关键的差异在于,Vivado的行为仿真直接运行你的RTL代码,每个寄存器、每根连线都是真实存在的。你可以精确观察某个中间节点在哪个时钟沿翻转,可以在时序仿真阶段加入布线延迟,可以看到组合逻辑毛刺。这套验证体系虽然上手门槛比Matlab高一点,但它的结论最终能直接回答"这块FPGA跑起来是不是这么回事",而不是"数学上模型是不是正确"。

1.3 一条脱离Matlab的完整验证路径

我现在做DDS项目,验证流程固定如下:

  • 在Vivado里用Verilog写好DDS RTL,包含相位累加器和查找表;
  • 编写testbench,施加时钟复位和给定的频率控制字;
  • 行为仿真确认地址跳转、波形数据输出正确;
  • 综合实现后,在板上用ILA接出DDS输出,在真实时钟下采样原始数据;
  • 结合ILA导出的数据做频域分析,判断SFDR是否满足需求。

整套流程不打开Matlab。如果确实需要生成正弦查找表或做复杂频谱分析,我用Python脚本生成coe和hex文件,用ILA导出的CSV配Python做FFT。Matlab在我这里彻底退出了DDS调试主链路。Vivado这套工具链已经覆盖了DDS验证所需的大部分环节,省掉Matlab转Verilog、Verilog转板上实测的反复翻译过程,效率反而更高。

2. DDS核心里最关键的两个模块:相位累加器和查找表

2.1 相位累加器写法与频率控制字计算

DDS的结构可以拆成两半:一半是相位累加器,也叫数控振荡器;另一半是相位到幅度的映射,也就是查找表。相位累加器的原理不复杂,它就是一个在每个时钟周期累加频率控制字(FCW)的寄存器,溢出即完成了2π的循环。

下面这段代码是DDS核心模块的典型写法:

module dds_core #( parameter PHASE_WIDTH = 28, parameter DATA_WIDTH = 16, parameter ADDR_WIDTH = 10 )( input wire clk, input wire rst_n, input wire [PHASE_WIDTH-1:0] fcw, output wire [DATA_WIDTH-1:0] sine_out, output wire [PHASE_WIDTH-1:0] phase ); reg [PHASE_WIDTH-1:0] phase_r; always @(posedge clk or negedge rst_n) begin if (!rst_n) phase_r <= {PHASE_WIDTH{1'b0}}; else phase_r <= phase_r + fcw; end assign phase = phase_r; // 取相位累加器的高10位作为查找表地址 wire [ADDR_WIDTH-1:0] addr; assign addr = phase_r[PHASE_WIDTH-1 -: ADDR_WIDTH]; // 查找表例化 dds_lut #( .DATA_WIDTH(DATA_WIDTH), .ADDR_WIDTH(ADDR_WIDTH) ) u_lut ( .clk (clk), .addr (addr), .data (sine_out) ); endmodule

频率控制字的计算是整个DDS设计中最基础也最容易出错的环节。输出频率和FCW的关系是:

f_out = fcw × f_clk / 2^PHASE_WIDTH

反过来就是:

fcw = f_out × 2^PHASE_WIDTH / f_clk

假设系统时钟50MHz,相位累加器28位,想输出1MHz正弦:

fcw = 1e6 × 2^28 / 50e6 = 5,368,709.12

在Verilog里fcw只能是整数,所以取5368709,实际输出频率是5368709 × 50e6 / 268435456 ≈ 999999.98Hz,误差小到可以忽略。

如果换成1kHz信号,fcw = 1e3 × 2^28 / 50e6 = 5368.709,取5369。这种低频率、整数的约束下,频率误差会被放大,但仍在可接受范围内。关键是设计者要有这个概念:fcw是整数,相位累加器溢出时刻并不是严格等间隔的,这种"时间量化误差"也是DDS杂散的一个来源。

2.2 正弦查找表怎么生成才靠谱

查找表本质上是把相位信息映射成幅度信息。DDS里最常用的做法是存一个完整正弦周期的定点采样值,用相位累加器的高位去寻址。

查找表的大小和位宽直接决定波形的质量。假设地址宽度是10位,就有1024个采样点;输出数据宽度16位,每个值用16比特带符号数表示。那么存储容量是1024 × 16bit = 2KB,对FPGA的BRAM或分布式RAM来说都非常小。

在Verilog里加载查找表,比较干净的方式是$readmemh:

module dds_lut #( parameter DATA_WIDTH = 16, parameter ADDR_WIDTH = 10 )( input wire clk, input wire [ADDR_WIDTH-1:0] addr, output reg [DATA_WIDTH-1:0] data ); reg [DATA_WIDTH-1:0] mem [0:(1<<ADDR_WIDTH)-1]; initial begin $readmemh("sine_1024_16bit.hex", mem); end always @(posedge clk) begin data <= mem[addr]; end endmodule

这个sine_1024_16bit.hex文件需要提前生成。我习惯用Python生成,因为写起来最直接:

import math n = 1024 with open("sine_1024_16bit.hex", "w") as f: for i in range(n): v = int(round(32767 * math.sin(2 * math.pi * i / n))) f.write(f"{v & 0xFFFF:04x}\n")

注意这里生成的是十六进制补码形式。32767对应0x7FFF,-32768对应0x8000。用正弦幅度32767是为了避开-32768这个不对称点,减少量化误差的边界效应。

$readmemh在Vivado仿真和综合时都会读取这个hex文件,所以路径一定要放对。更稳妥的做法是可以把hex文件放到和RTL文件同一目录,或者使用工程的绝对路径。仿真时报readmem错误,十有八九是路径问题。

2.3 相位截断与输出位宽的关系

我在代码里把28位的相位累加器只取了高10位给查找表,这就引入了DDS中一个躲不开的现象:相位截断(Phase Truncation)。因为地址总线只有10位,所以低18位相位信息在每个时钟周期都被丢掉了。这个动作从时域看就是给真实正弦波叠加了一个周期性的相位误差,在频域上就会表现为杂散边带。

理论上,相位截断造成的杂散抑制水平大约等于6.02 × ADDR_WIDTH dB。也就是说地址位宽10位时,理论杂散抑制大约60dB;12位大约72dB;14位大约84dB。如果你的项目要求SFDR做到80dB以上,地址位宽至少14位起步,10位的查找表在频谱仪上一定会露馅。

这引出一个设计权衡:如果把相位累加器的全部28位都作为查找表地址,存储量就是2^28 × 16bit = 512MB,这在FPGA里根本放不下。所以必须截断,但怎么截、截多少位,是性能和资源的核心博弈点。第三章后面会专门细说杂散问题。

3. 给DDS做第一次体检:testbench与Vivado仿真的完整流程

3.1 一个能用的testbench该写哪些内容

Vivado里的行为仿真,本质上就是用一段testbench给RTL施加激励,然后观察输出。这个testbench写得好不好,直接影响"体检"结论靠不靠谱。

我给上面的DDS核心模块配的testbench是这样的:

`timescale 1ns / 1ps module dds_core_tb; reg clk; reg rst_n; reg [27:0] fcw; wire [15:0] sine_out; wire [27:0] phase; initial clk = 0; always #10 clk = ~clk; // 50MHz initial begin rst_n = 0; fcw = 28'd0; #100; rst_n = 1; fcw = 28'd5368709; // 1MHz @ 50MHz #500_000; // 仿真500us $finish; end dds_core #( .PHASE_WIDTH(28), .DATA_WIDTH (16), .ADDR_WIDTH (10) ) u_dds ( .clk (clk), .rst_n (rst_n), .fcw (fcw), .sine_out (sine_out), .phase (phase) ); endmodule

这段testbench的核心逻辑是把时钟设为20ns周期,复位拉低100ns后释放,再给一个1MHz对应的频率控制字,跑500us。为什么要跑500us?因为要看到完整周期的正弦波。1MHz的周期是1us,500us能看到500个完整周期,足够判断频率是否准确、波形是否连续。

如果你只想快速确认功能,也可以把fcw设得大一点,比如2MHz或5MHz,波形周期更短,仿真时间可以缩短。但注意fcw改变后要同步改频率期望值,不然数周期时会数错。

3.2 仿真波形怎么看:频率、幅度、相位连续性

打开Vivado的Simulation后,把sine_out和phase都添加到波形窗口,有几个检查点必须逐一确认:

  • 幅度范围:sine_out应在-32767到+32767之间摆动,最大振幅接近32767。如果峰值明显偏小,或者不对称,说明查找表生成或读取有问题。
  • 周期数量:500us内如果输出1MHz信号,应当看到约500个完整正弦周期。目测或数周期都行,这能粗略判断频率准确度。
  • 相位连续性:如果中途改变fcw,sine_out的波形不应出现断崖式跳变。DDS的核心优势之一就是切换频率时相位连续,这会直接体现在波形上。
  • 相位累加器:phase波形呈现锯齿状,从0增长到2^28-1后溢出归零。它的斜率等于fcw,可以辅助验证频率控制字的计算。

3.3 仿真中容易出现的假结果

行为仿真阶段有一个很典型的"假正常"现象:波形看起来完全没问题,但不是你要的频率。

原因通常是查找表地址和相位累加器的时序没对齐。我在前面的dds_lut模块输出加了寄存器,目的是流水线化。但代价是sine_out比phase晚一个时钟周期。如果你把sine_out和phase连到同一个采样点去观察,会发现幅度输出落后相位一拍。这在单看sine_out时完全看不出来,波形依然光滑,只有当你做更复杂的基于相位做解调、或者用ILA抓数据做FFT时,这个延迟才会暴露。

另一个常见问题是复位信号没拉高,输出全是0。有些同学写完testbench后忘记把rst_n从0置成1,仿真出来的波形就是一条直线,还以为是DDS没有输出。

还有一个坑是$readmemh的路径问题。Vivado仿真的工作目录和项目目录不一样,如果hex文件路径写错,查找表里全是不定态,sine_out波形就是一团噪声,而且不会报错,只在日志里给warning。排查这类问题的方法是直接查看mem数组的内容,或者把addr和data连到一起看——如果addr变化正常而data是X态,基本就是readmem失败。

4. 上板实测:用ILA捕捉真实波形,把"仿真结论"拉出来遛遛

4.1 在Vivado里添加ILA的两种方式

行为仿真通过后,DDS只是"纸面健康"。真正要确认的是它在真实电路上的表现。这个环节我用的是Vivado的ILA(Integrated Logic Analyzer),也就是集成逻辑分析仪核,相当于把示波器探头做进FPGA里。

在Vivado里加ILA有两条路:

路线一:通过综合后的网表加探针。先做综合,打开Synthesized Design,选中要观察的sine_out或phase信号,右键选择Set Up Debug,按向导流程操作,选择采样时钟和触发信号,重新实现工程。这种做法的好处是不改动RTL,缺点是后续如果改了代码,探针经常要重新设置。

路线二:在RTL里直接例化ILA IP。在IP Catalog里搜ILA,配置好探针数量和数据宽度后,生成IP,然后在顶层模块里例化:

ila_0 u_ila ( .clk (clk), .probe0 (sine_out), // 16位DDS输出 .probe1 (phase[27:18]) // 可选:观察高10位地址 );

我习惯用路线二,因为探针和设计逻辑绑定在一起,代码版本管理也更清晰。换一台机器、换一个工程,重新打开就能看到同样的观察点。

4.2 ILA参数设置和触发条件的选择

ILA的采样深度和数据位宽是相乘的关系。资源够用的话,建议采样深度至少4096,这是做FFT的最低可用样本数。如果采样深度只有1024,FFT后频率分辨率会非常差,很难分辨杂散边带。

采样时钟的选择比很多人想象的更重要。ILA的时钟必须和DDS输出信号同源,否则你会看到数据在采样窗口里到处抖。如果DDS的时钟是PLL输出的,ILA也用这个PLL时钟。这里的关键不是"用系统时钟能不能看"的问题,而是跨时钟域采样会产生亚稳态,导致时序分析中的建立保持时间违例,抓回来的波形边缘会出现毛刺。

触发条件方面,最常见的做法是用fcw变化触发。当频率控制字从一个值跳变到另一个值时,ILA窗口里既能看到跳变前的输出,也能看到跳变后的输出,直接评估跳频稳定时间。如果想聚焦观察稳态波形,可以设置phase到达某个值作为触发条件,这样每次抓到的都是波形同一相位点的前后数据,方便做多次平均。

4.3 实测波形与仿真对比:哪些差异说明了什么

把ILA抓到的sine_out用波形窗口展开,你会看到一个真实的数字正弦序列。这个序列应该和仿真波形高度一致,但仔细观察,差异恰恰是体检最有价值的部分。

有一次我在某款7系列FPGA上跑50MHz时钟DDS,ILA抓到的正弦波和仿真波形肉眼几乎无法分辨,但导出数据后做FFT,发现离主谱线约40kHz处有一个明显的杂散边带,幅度只比主频低50多dB。这个杂散在时域波形里完全看不出来,这就是为什么DDS验证不能只看时域。

后来定位到原因是查找表地址位宽只有10位,相位截断引入了周期性杂散。把地址位宽从10位提到12位之后,同样条件下杂散降到68dB附近,问题解决。这个经历让我养成了固定习惯:凡是验证DDS,时域波形只能算"初筛",频域分析才是"确诊"。

5. 体检报告怎么看:SFDR、杂散和相位截断的真相

5.1 从时域到频域的一次认识升级

做DDS验证,如果只看示波器或ILA里的时域波形,你八成会得出"波形挺干净"的结论。但真正的体检报告必须看频域。DDS信号发生器的核心性能指标不是波形长得多好看,而是它的SFDR,也就是无杂散动态范围(Spurious-Free Dynamic Range)。

SFDR的定义是:在指定频率范围内,最大输出信号幅度与最大杂散分量幅度之比,单位是dBc。DAC的SFDR通常能达到80dB以上,但很多FPGA直接生成的DDS信号,SFDR只有60dB上下。差距往往不在DAC,而在FPGA内部DDS的相位截断和查找表量化上。

5.2 相位截断杂散的计算和抑制方法

相位截断杂散的机理是这样的:相位累加器以28位步进,但查找表只有10位地址,所以相位值被量化为1024个离散档位。每个时钟周期,真实相位和量化相位之间的差值构成一个周期性误差序列。这个误差序列虽然不是纯正弦,但它的能量集中在某些离散频率点上,反映在频谱上就是杂散。

理论上,相位截断造成的杂散抑制大约是6.02 × ADDR_WIDTH dB,所以:

查找表地址位宽理论杂散抑制常见实测SFDR
10位约60dBc55~65dBc
12位约72dBc65~75dBc
14位约84dBc75~85dBc

实测值比理论值差,原因是叠加了幅度量化噪声和时钟抖动。

抑制相位截断杂散有几种常用招数。最直接的是增加地址位宽,把10位提到12位或14位,效果立竿见影。缺点是查找表容量翻倍,但1024个点变4096个点也就是8KB和32KB的区别,现代FPGA完全能承受。

第二种是加抖动(dithering)。在相位进入查找表之前,给相位低几位加一个伪随机扰动,打破相位误差序列的周期性,把离散杂散的能量摊平成底噪。这个做法不增加存储量,但会抬高噪底,适合对SFDR要求中等、对噪底要求不苛刻的场景。实现方式是用一个LFSR生成随机数,叠加到相位累加器输出上:

// 伪随机数发生器 reg [15:0] lfsr; always @(posedge clk or negedge rst_n) begin if (!rst_n) lfsr <= 16'hACE1; else lfsr <= {lfsr[14:0], lfsr[15] ^ lfsr[13] ^ lfsr[12] ^ lfsr[10]}; end wire [PHASE_WIDTH-1:0] phase_d = phase_r + {16'd0, lfsr[9:0]}; assign addr = phase_d[PHASE_WIDTH-1 -: ADDR_WIDTH];

第三种是误差反馈(Error Feedback),把相位截断误差寄存起来,在下一个周期补偿回去。这个方案不会抬高噪底,但逻辑复杂度比抖动高,工程上用得不如前两种多。

5.3 查找表幅度量化给SFDR带来的限制

除了相位截断,查找表的幅度量化也会限制SFDR。查找表输出是16位定点数,幅值被量化到2^16个离散电平。理想DAC的量化信噪比SNR约等于6.02 × B + 1.76dB,B是输出位宽。16位输出理论SNR约98dB,这个数量级远高于10位相位截断造成的60dB杂散,所以当地址位宽只有10位时,SFDR瓶颈在相位截断而非幅度量化。

但如果你的地址位宽已经拉到14位、16位,相位截断杂散被压到84dB以上,这时16位的幅度量化就会成为新的瓶颈。想要SFDR超过90dB,查找表输出位宽必须做到18位以上,并且后级DAC也不能拖后腿。

还需要注意查找表生成时的量化方式。如果用round取整而不是truncation截断,量化误差是零均值且不相关的。用Python生成正弦表时,round()函数的行为需要确认是四舍五入还是银行家舍入,不同语言对负数round的规则不一样。我都是把正弦值放大到32767后,用int(v + 0.5)这样的正数偏移再截断,确保量化误差可控。

5.4 实测工程中提高DDS性能的几个做法

综合多个项目的工程经验,我整理了提高DDS性能的六条实操建议:

  • 查找表地址位宽最少12位,不要为了省BRAM把地址压到10位以下;
  • 查找表读出的data要打一拍寄存,让输出是纯时序逻辑,避免组合逻辑毛刺直接进DAC;
  • 相位累加器保持足够宽度,建议24位以上,太窄会导致频率分辨率不足;
  • 如果输出经过DAC,DAC前一级加一个插值FIR或至少一个低通滤波器,衰减带外镜像;
  • 对SFDR要求高的场合,用抖动破坏相位截断误差的周期性;
  • 跳频应用里,fcw更新要做同步处理,不要异步改累加器输入,否则瞬间可能出现一个错误的相位增量。

这些做法单独拎出来都不复杂,但它们叠加起来,决定了一块板子上DDS方案的最终性能上限。

6. 调试DDS时我最常遇到的三个坑

6.1 现象:输出完全不对,先查时钟和复位

DDS上板后最常见的现象是sine_out全0,或者只是某个固定值。遇到这种问题,不要急着怀疑DDS逻辑写错,先检查两个最基本的信号:时钟和复位。

我在一个项目里遇到过PLL未锁定就把DDS使能的情况。PLL输出时钟在锁定前是不稳定的,DDS在这种时钟下跑了一小段,累加器状态全部乱掉。PLL锁定后时钟虽然正常了,但累加器已经在一个随机状态,输出波形不是期望的正弦。

排查手法很简单:在ILA里把PLL的locked信号和DDS的复位信号都接进来,观察locked释放和rst_n拉高的先后关系。正确做法是把PLL的locked接到DDS的复位控制逻辑上,locked为高后才释放DDS的复位。

还有一个隐蔽的坑:异步复位释放时如果刚好撞上时钟上升沿,会导致触发器进入亚稳态,部分寄存器复位成功、部分没有复位成功。稳妥的做法是在DDS内部用同步复位,或者在外部先对异步复位做两级同步。

6.2 现象:波形有毛刺,先查位宽和组合逻辑

有次我把查找表输出直接综合成组合逻辑,也就是data = mem[addr]没有打寄存器,out在ILA里看起来问题不大,但接到高速DAC后,输出正弦波上出现很多随机毛刺,尤其当addr有多个bit同时翻转时特别明显。

原因并不复杂:查找表是分布式RAM或BRAM,地址跳变时,输出数据会有短暂的不稳定区间。波形仿真阶段由于没有加时序延迟,组合逻辑毛刺不会体现在行为仿真里,但综合实现后,布线延时让毛刺变成真实可见的尖峰。

解决方式就是把查找表输出加一级流水线寄存器,用时钟沿把数据打一拍。代价是输出延迟一个周期,但换来的是干净的数字输出,这笔交易非常划算。需要留意的是,查找表输出打拍之后,整个DDS的流水线延迟会变成两个周期,如果后级有同步逻辑,要统一调整。

6.3 现象:上板波形和仿真不符合,先查ILA采样率

还有一个我能记一辈子的坑:用系统25MHz时钟去抓DDS的125MHz时钟域信号,抓到一屏波形像是被狗啃过的正弦。当时第一反应是DDS代码在综合后出了问题,反复查了半天,最后发现是ILA采样时钟和DDS时钟不同源。

跨时钟域采样时,如果采样时钟频率低于信号时钟频率,或者采样时钟和信号时钟相位关系不稳定,触发器的建立保持时间不断被违反,采样回来的数据就是乱的。这不是DDS的问题,是"体检设备"本身没校准。

这个问题的排查顺序很关键。先确认ILA的采样时钟是否和DDS时钟完全同源。其次确认采样时钟频率是否满足奈奎斯特条件,至少要大于被观察信号最高频率的两倍。DDS输出频率一般远低于它的系统时钟,所以只要ILA采样时钟等于DDS系统时钟,通常都满足。最后确认探针的数据宽度和实际信号位宽一致,宽度不匹配也会导致波形显示异常。

在实际项目中,把ILA的采样时钟独立出来、和被测DDS系统时钟用同一个PLL输出,是最省心的做法。跨时钟域的问题留给正式的数据交互逻辑去处理,调试探针不要自己制造跨时钟域。


最后再分享一个我自己用下来的习惯:DDS接入DAC之前,先用ILA把原始数字波形抓下来,导出成CSV之后做一次FFT确认SFDR达标,再往板上接DAC和模拟滤波电路。这一步能省掉很多后级滤波电路调参的时间。Vivado的ILA加Verilog定点仿真,已经完全覆盖了Matlab能做的大部分DDS验证工作,至少对我手里的项目来说是这样。如果你想从Matlab仿真过渡到纯FPGA验证,从DDS这块开始试最合适,因为它功能经典、结构清晰、问题可观测,是练手"数字化体检"流程最好的试验田。

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

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

立即咨询