1. DDS Compiler v6.0 到底解决了什么问题
两年前做一版便携超声激励源,需求清单里有这么一条:输出 2.5MHz 和 5MHz 两种频率的正弦波,上位机随时切频、切增益,还要保证双通道相位严格同步。那时候年轻,第一反应是自己用 Verilog 写查找表。写了三天,发现事情没有那么简单——频率字改了之后输出频率确实会变,但杂散也上来了;想做到 0.1Hz 量级的频率步进,查找表深度直接爆炸;两个通道各跑各的相位累加器,同步全靠运气。
后来换成 Xilinx 官方 LogiCORE IP DDS Compiler v6.0,一个下午把困扰一周的问题全解决了。这个 IP 核本质上是把相位累加器、相位截断、正弦查找表、抖动补偿这些细节全部封装好,你只需要告诉它“我要什么输出”,它会自己算好资源、位宽、时延,并用 AXI4-Stream 接口把波形数据送出来。
这篇文章要讲的,就是我从配置界面一路走到仿真验证的完整流程:每个参数到底在定什么、目标频率怎么换算成配置值、testbench 怎么写才不容易踩坑、波形出来之后怎么验证它真的对。全程基于 Vivado 里的 DDS Compiler v6.0,但里面的思路换到其他厂商的 DDS IP 或开源实现也同样成立。
先说一个反直觉的结论:这款 IP 核配置界面里,你输入 SFDR、频率分辨率这些“指标性参数”,比直接输入输出位宽、查找表深度这些“实现性参数”要重要得多。因为工具会根据前者自动倒推后者,你自己乱填后者反而容易让资源翻倍、时序还过不了。理解了这一点,整篇的配置逻辑就顺了。
2. 为什么不用 LUT 查找表自己造轮子
自己写 DDS 的思路其实很直接:把正弦波形离散成 2^M 个点,存进 ROM,每个时钟加一个步进值,ROM 地址跳着读就行了。这个方案在大学课设里屡试不爽,但放到真实项目里会遇到几个连轴转的问题。
第一,频率分辨率卡死了。步进值每次加 1,输出频率是 Fclk / 2^M。如果你查表深度是 1024,主频 100MHz,能做到的最小步进是 97.6kHz。很多通信或者信号处理场景要求的是 1Hz 以内,那查找表深度至少要 2^27,这 ROM 谁扛得住。
第二,相位累加器位宽和查找表位宽应该是分开的。一个合格的 DDS 结构是:相位累加器做 32 位甚至 48 位保证频率分辨率,高十几位去查表,剩下的低位截断。自己写的时候得算截断位数引入的杂散,还要决定要不要加 dither(抖动),这些信号处理细节很容易翻车。
第三,多通道相位同步是个大坑。两路 DDS 各自维护相位累加器,上电时刻稍微错几个周期,输出相位差就完全不可控。要是做波束成形这类应用,相位差错 1 个时钟周期都受不了。
DDS Compiler v6.0 把这些细节全部收敛了。你不需要知道内部查找表到底多深,不需要纠结相位截断位数,更不用自己设计 dither 加法器。它输出的是一对符合 AXI4-Stream 协议的 TDATA/TVALID/TREADY 信号,接到下游的 FIFO 或者 DAC 接口,干净利落。
2.1 DDS 的核心原理:一个公式吃透相位累加器
理解 DDS Compiler 还是要把原理理顺。DDS 全称 Direct Digital Synthesizer,直接数字频率合成器。核心结构就三块:相位累加器、相位到幅度转换、数模转换。
相位累加器是一个 N 位的累加器,每个时钟周期加上一个频率字 Δθ。N 位满了就自然溢出,溢出的过程相当于相位走了 360 度。输出频率由这个公式决定:
Fout = Fclk * Δθ / 2^N
变换一下,给定目标频率反求频率字:
Δθ = Fout * 2^N / Fclk
从公式能读出三层意思。第一,频率分辨率完全由 N 决定,Fclk / 2^N 就是你能分辨的最小频率间隔,跟查找表深度没有直接关系。第二,Δθ 是整数,所以实际输出频率和目标频率之间会存在一个量化误差,误差上限是 Fclk / 2^N 的一半。第三,相位累加器溢出产生的频率理论上非常纯净,因为累加器是全精度的,没有截断误差,杂散主要来自后面查表的相位截断和幅度量化,这正好对应配置界面里的 SFDR 参数。
记住这个公式,下面所有配置就不再是填数字,而是有物理意义的计算了。
3. 配置界面逐项拆解:每个参数背后都在定什么
打开 Vivado 的 IP Catalog,搜 dds,双击 DDS Compiler,v6.0 的配置界面分三个选项卡:Configuration、Implementation、Detailed Implementation。很多人一上来就懵,觉得选项太多。我按影响结果的程度排个优先级,逐个说清楚。
3.1 Configuration 页:工作模式决定接口长什么样
第一个要选的是 Operating Mode,三个选项:Phase Generator、Sin and Cos、Sin/Cos LUT only。
- Phase Generator:只输出相位,不输出波形幅度。适用于你自己外面再接一个任意波形查表,或者做相位调制的场景。
- Sin and Cos:同时输出正弦和余弦两路,这是最常用的模式,做正交混频、IQ 解调全靠它。
- Sin/Cos LUT only:只输出正弦或者只有余弦,资源最省。
我做超声激励源用的是 Sin and Cos,因为后面要同时驱动两路正交信号做相位旋转。如果只是单音正弦,选 Sin/Cos LUT only 就够了。
接着说 Parameter Selection,两个选项:System Parameters 和 Hardware Parameters。
System Parameters 模式下,你输入的是 SFDR(无杂散动态范围)、Frequency Resolution(频率分辨率)这些系统级指标,工具自动决定累加器位宽、查找表深度、输出位宽。Hardware Parameters 则让你手动指定相位累加器位宽、输出位宽这些实现参数。
我的建议很明确:没有特殊原因就用 System Parameters。因为工具自动推导出的组合在资源和性能上往往是最优的,你手动指定容易行为资源不够或者无法满足 SFDR 的问题。只在需要精确控制 DSP48 消耗、LUT 消耗时才切换到 Hardware Parameters。
还有两个下拉框:Phase Increment Programmability 和 Phase Offset Programmability,分别有 Fixed、Programmable、Streaming 三个选项。Fixed 代表频率字在配置时写死,运行过程中不能改;Programmable 代表通过一个 AXI 配置接口在运行中改;Streaming 代表每个时钟周期都可以通过 S_AXIS_PHASE 端口输入新的频率字,适合跳频场景。
注意,Phase Increment 选 Streaming 之后,相位累加器的频率字就不再是内部寄存器,而是由外部输入。这会导致接口上多出一组 s_axis_phase_tvalid / s_axis_phase_tdata / s_axis_phase_tready 信号。如果你不打算每个周期都变频率,只是偶尔切一次频点,选 Programmable 更省事,AXI4-Lite 配置接口在低速场景下完全够用。
3.2 Implementation 页:SFDR、输出位宽和资源的三角关系
切到 Implementation 选项卡,这一页才是整个配置的灵魂。
如果你选了 System Parameters,页面上会有几个灰色的自动推导值,下面几个参数值得仔细研究:
Output Width:输出波形数据的位宽,单位 bit。这个值和 SFDR 有直接关系。一个满幅正弦波理论上每 bit 大约贡献 6.02dB 的量化信噪比,12 bit 输出大概对应 72dB 左右的无杂散动态范围,16 bit 则能到 96dB 级别。但这只是个近似,因为 SFDR 同时受相位截断杂散限制,不是你输出位宽无限加,SFDR 就无限高。
Noise Shaping:有两个选项,None 和 Dither。Dither 是在相位截断前注入一个伪随机小扰动,把截断杂散从尖峰打散成底噪,从而提升无杂散动态范围。代价是底噪整体抬高。做窄带信号处理的时候强烈建议打开 Dither,测量结果里你会发现那些刺眼的旁瓣会明显变矮。
Output Type:Sine、Cosine、Sine and Cosine。刚才说过,匹配 Configuration 页的选择即可。
LUT Depth:工具自动决定的查找表深度,一般几百到几千不等,和 SFDR 强相关。你也可以手动改,但改低会直接降低 SFDR,改高则 LUT 占用成倍增加。实测下来,SFDR 要求 80dBc 时,LUT 深度 1024 量级就够了;SFDR 提到 120dBc,深度可能要 4096 往上。注意这不是线性的。
3.3 Detailed Implementation 页:最终输出频率和相位偏移的确认
这个选项卡是很多人忽略的地方。它会把前面所有配置综合成一个明确的输出规格表,包括:System Clock(主时钟频率)、Output Frequency(实际输出频率)、Frequency per Channel(多通道时每个通道的频率)、Phase Offset(相位偏移)、Latency(从输入到输出的时钟周期数)。
Output Frequency 这一栏特别要盯紧。因为我们前面算出来 Δθ 是整数,最终输出频率和目标频率之间必然存在量化误差。Detailed Implementation 页会把这个误差直接列出来,多数情况下是一个极小的数,比如目标 4.5MHz,实际 4.499999977MHz,对应 Hz 级别以下的误差,完全不影响系统。但如果你的频率分辨率设置不够或者主频选得别扭,误差会大到肉眼可见,这时候就要回头改 Frequency Resolution 或者换主频。
还有 No. of Channels 这个参数,默认是 1。多通道配置下面会专门讲,这里先提一句:通道数增加了之后,Detailed Implementation 页面的频率列表会变成多行,每个通道可以单独指定频率和相位偏移,这是做多路同步信号源的利器。
Latency 这一栏的值最终会体现在仿真波形上——你给 S_AXIS_PHASE 打了 TVALID,输出不会立刻出现有效数据,而是要等 Latency 个周期。调试定位波形对不上的时候,第一反应应该是查这个值,而不是怀疑 IP 没配好。
4. 目标频率到配置参数的完整换算过程
配置界面试过几遍之后,我发现一个规律:把配置界面里的数字和计算器算出来的数字对上之后,后续仿真基本一遍过。这里给一个完整的换算实例,我从头到尾算一遍,后面你遇到任何频率需求都能套。
4.1 频率字、相位累加器位宽与输出频率的关系
场景设定:系统主频 100MHz,需要输出 4.5MHz 正弦波,频率分辨率要求优于 0.1Hz。
第一步,确定相位累加器位宽 N。根据频率分辨率公式:
频率分辨率 = Fclk / 2^N
要求 0.1Hz 以下,所以:
2^N > Fclk / 0.1 = 100e6 / 0.1 = 1e9
2^30 约等于 1.07e9,刚好超过,所以 N = 30 能满足;如果留余量,建议直接上 32 位,一来资源差别不大,二来频率分辨率做到 0.023Hz,更舒服。
第二步,计算频率字 Δθ:
Δθ = Fout * 2^N / Fclk = 4.5e6 * 2^32 / 100e6 = 193273528.32
四舍五入为 193273528。
第三步,反推实际输出频率:
Fout_real = 100e6 * 193273528 / 2^32 = 4499999.977Hz
和目标 4.5MHz 差了 0.023Hz,完全在可接受范围内。
DAQ、通信系统里如果对频偏有硬指标,这套换算必须做一遍,至少确认误差量级在预算内。音频或者超声波驱动这种容忍度高的场景,其实只要频率字四舍五入后误差不明显,就不用操心。
4.2 多通道时的频率字与相位偏移设置
如果开双通道,Detailed Implementation 页里频率列表会有两行,假设 ch0 要 4.5MHz,ch1 要 4.5MHz 但相位滞后 90 度。
注意这里有个常用陷阱:相位偏移的单位不是弧度,也不是角度数,而是相位累加器的量化单位,也就是 2π / 2^N 的倍数。90 度对应 2^N 的四分之一:
phase_offset = 2^N / 4 = 4294967296 / 4 = 1073741824
在界面里直接填 1073741824,或者用支持表达式的地方填 2**30 之类的。填 90 的话相位就完全错了。这个坑我见不止一次,画 PCB 之前不验证,上板子才发现波形相位不对。
如果 Phase Offset Programmability 选的是 Programmable,那么这类值会在运行时通过 AXI4-Lite 接口写入,需要对照寄存器映射表确认偏移寄存器的编码方式,通常也是同样的 2π / 2^N 量化格式。
4.3 主频选择和 Nyquist 限制
DDS Compiler 理论上输出频率可以接近 Fclk / 2,但实际工程中没人会这么干。原因有两个:第一,接近 Nyquist 时镜像频率会落在通带边缘,重构滤波器设计难度骤增;第二,DDS 内部查找表的有效精度在输出接近 Fclk/2 时下降,因为每周期采样点数太少。
经验法则:DDS 输出频率不要超过 Fclk / 4,最好控制在 Fclk / 8 以下。比如你主频 100MHz,DDS 输出 25MHz 以上就要慎重,输出 12.5MHz 以下比较从容。做数字下变频的中频信号生成时,很多人因此宁可选一个更高的系统主时钟,也不愿意让 DDS 硬顶上限。
这一点也影响到 Detailed Implementation 页里 Output Frequency 的显示值——如果你配置目标频率超过 Fclk/2,界面直接报错或者给出镜像警告,那就是提示你换主频或者降频率目标。
5. 仿真环境搭建与波形验证方法
配置完 IP,下一步是仿真。很多人把 IP 拖进 Block Design,连线,编译,然后就在那等波形。我对这种做法不太推荐,尤其是第一次用 DDS Compiler,一定要单独建一个工程、单独写一个 testbench,把 IP 孤立出来看波形,确认行为符合预期再接进系统。不然到时候连错误都很难定界。
5.1 testbench 编写的关键细节
Vivado 自带 xsim 仿真器,写一个最小 testbench 就够了。核心逻辑三步:
- 生成时钟;
- 拉高复位;
- 向 s_axis_phase_tdata 灌入频率字并拉高 tvalid。
下面是一个可以直接跑的模板,基于 Streaming 模式的相位输入:
`timescale 1ns / 1ps module tb_dds_top; reg aclk; reg aresetn; reg s_axis_phase_tvalid; reg [31:0] s_axis_phase_tdata; wire s_axis_phase_tready; wire [15:0] m_axis_data_tdata; wire m_axis_data_tvalid; wire m_axis_data_tready; // 100MHz clock initial aclk = 0; always #5 aclk = ~aclk; // DDS output wire [15:0] sine_wave = m_axis_data_tdata; initial begin aresetn = 0; s_axis_phase_tvalid = 0; s_axis_phase_tdata = 32'd0; #100; aresetn = 1; #20; @(posedge aclk); s_axis_phase_tvalid = 1; s_axis_phase_tdata = 32'd193273528; // 4.5MHz @ 100MHz Fclk, N=32 #2000; s_axis_phase_tvalid = 0; #1000; $finish; end // keep tready high assign m_axis_data_tready = 1'b1; dds_compiler_0 dut ( .aclk(aclk), .aresetn(aresetn), .s_axis_phase_tvalid(s_axis_phase_tvalid), .s_axis_phase_tdata(s_axis_phase_tdata), .s_axis_phase_tready(s_axis_phase_tready), .m_axis_data_tvalid(m_axis_data_tvalid), .m_axis_data_tdata(m_axis_data_tdata), .m_axis_data_tready(m_axis_data_tready) ); endmodule注意复位信号一般叫 aresetn,低有效。如果 IP 配置界面里选了 Has ARESEtn 选项,端口才存在;没选就往下看。这个在 IP 例化模板里都有体现,直接照抄就是。
5.2 AXI 握手信号的正确处理方式
DDS Compiler 的输出接口是标准的 AXI4-Stream。m_axis_data_tvalid 拉高时表示输出数据有效,m_axis_data_tready 拉高表示下游准备好接收数据。两者同时为高,数据才算被正确传递了。
在上面的 testbench 里我直接绑定了 tready = 1'b1,这是最省事的做法,仿真也最快。但真实系统里,如果下游接的是 FIFO,FIFO 满了会把 tready 拉低,这时候 DDS 输出就会暂停。很多人在仿真里不模拟这种反压场景,结果接进系统后数据经常丢。
建议至少做一个带反压的 testbench 版本:每隔一段时间把 tready 拉低若干个周期,确认 DDS 数据不丢失、不重复。DDS Compiler 的 AXI 接口设计是支持反压的,但你要实测验证,不要默认它一定没问题。
输入侧的握手同样关键。如果你用 Streaming 模式,而且上位机不是每个周期都更新相位,需要 tvalid 和 tready 一起判断是否写入成功。有一种很隐蔽的 bug:tvalid 一直拉高,但没看 tready,导致频率字重复写入多次,相位累加器瞬间跳好几步,输出频率偏移。
5.3 从波形反推输出频率是否准确
仿真结束后,怎么验证 DDS 输出确实是 4.5MHz?
最直觉的方法:数两个正弦波峰值之间的周期数。假设仿真里 1us 能看到 4.5 个周期,100MHz 时钟 10ns 一个周期,那么一个正弦周期大约是 22.2 个时钟周期。你在波形上数 22 个时钟出现一个峰,基本能确认频率是对的。
更精确的方法是在 Vivado 里用一条简单的 Tcl 或者直接在波形窗口里做游标测量。把两个相邻峰值之间的时间间隔读出来,然后 1 / Δt 就是频率。比如测到 Δt = 222.22ns,对应频率就是 4.5000045MHz,非常接近预期值和理论值。
如果你的工程接了 FFT 或者用 ChipScope 抓了数据导到 MATLAB 做频谱分析,那更直观:峰值的横坐标位置就是输出频率,旁边旁瓣的高度就是 SFDR。我第一次把 DDS 输出做 FFT 分析的时候,看到杂散比预期低很多,那种满足感还是很真实的。
6. 调试过程中最容易踩的几个坑
配置和仿真流程走通之后,真正决定项目进度的往往是调试阶段的坑。这里把我见过的、自己踩过的问题集中写出来,都是常规文档里不会专门提醒的东西。
6.1 输出频率总差一点点:相位累加器位宽没对齐
第一类问题:仿真波形看起来是正弦,但频率比目标值差了不少,不是那种 0.023Hz 的小误差,而是差了百分之几甚至十几。
排查思路先回到频率字公式。如果配置界面里的 Phase Width(相位累加器位宽)不是 32 位,而是 16 位,那么你用 32 位算出来的频率字灌进去,实际上是高位被截断了,实际输出频率自然不对。
所以第一步是去 IP 配置界面确认 Phase Width 到底是多少,再按实际的 N 重算频率字。还有一种容易被忽略的情况:如果配置界面的相位累加器位宽是 32 位,但输入接口的 tdata 位宽是 16 位,那输入频率字要低 16 位对齐,高位填充 0 或者符号扩展,取决于 IP 配置。这在 Detailed Implementation 页的 Phase Width 和 Phase Data Width 两栏里能看得很清楚,仿真前一定瞄一眼。
6.2 多通道 TDATA 拼接顺序不要搞反
多通道 DDS 的输出 m_axis_data_tdata 位宽会变成通道数乘以单通道位宽。比如双通道、每通道 16bit,那 tdata 就是 32bit。问题是:通道 0 的数据在低 16 位还是高 16 位?
按 AXI4-Stream 的通用约定,多通道数据是低位通道优先,也就是通道 0 占用低位段,通道 1 占用高位段。实际验证了下,DDS Compiler v6.0 也是这个顺序。但用它拼接 DAC 数据时,如果 DAC 接口端口朝向你习惯的顺序,一不留神就把通道 0 和 1 的数据弄反了,表现为两路波形对调,相位也能看出偏差。
这类问题上板再查非常费劲,因为线已经连好了。最好的办法是在 testbench 里就把两路波形分开赋值给两个 wire,分别测频率和相位,确认无误再往下走。
6.3 Latency 到底是多少:别在时序上想当然
DDS Compiler 的 Latency 由配置自动决定,界面会显示一个具体数字。低频输出和高频输出的延迟其实不一样,但同一个配置里所有通道的延迟是一致的。
在仿真里,如果你把 tvalid 拉高的时刻记下来,再找到第一个有效输出数据的时钟周期,两者之差应该正好等于配置界面显示的 Latency 值。验证一遍会发现工具给的数字精确到个位。
工程中常见的问题是:接在 DDS 后面的模块为了对齐其他通路数据,需要补偿 Latency 个周期,于是写了一个移位寄存器。但如果后来改过配置(比如从单通道改成双通道),Latency 会跟着变,你忘了更新补偿值,数据错位几个周期,在帧同步系统里就会表现为整帧错位。这里记录一个小习惯:每次修改 DDS 配置后,第一件事就是把 Detailed Implementation 页的 Latency 抄到 HDL 注释里,再对应修改补偿逻辑。
7. 从单音信号源到系统的扩展经验
最后聊聊 DDS 在真实系统里的扩展玩法,这些场景都可以用 v6.0 直接实现,不用自己搭多余的外围逻辑。
7.1 跳频信号源的 Streaming 实现
跳频通信或者扫频测量里,要求每个周期甚至每隔几个周期改变一次输出频率。把 Phase Increment Programmability 设为 Streaming,然后在每个时钟周期向 s_axis_phase_tdata 送入不同的频率字,即可实现逐周期跳频。
注意跳频瞬间的相位连续性。DDS Compiler 内部相位累加器是连续运行的,如果你只是改了频率字,相位不会跳变,输出波形是连续扫过去的,这正是很多系统需要的“相位连续”特性。如果你需要在跳频同时重置相位,就要同时操作相位偏移或者拉低复位信号,但那样会牺牲连续性,具体看需求取舍。
7.2 输出幅度调整的方案选择
DDS Compiler 的输出幅度是固定的满幅正弦波,如果你需要幅度可调,通常有三种做法:
- 方法一:DDS 输出接乘法器,乘一个可变增益系数。最灵活,适合增益需要任意调节的场景。
- 方法二:DDS 输出接 Xilinx 的 AXI GPIO 控制的模拟衰减器。适合射频前端需要模拟衰减的场景。
- 方法三:直接把幅度信息编码进相位偏移或者查表权重。这招只有特殊场景才用,日常不建议,因为会牺牲 SFDR。
我在超声激励源里用的是方法一,配置了一个 16bit 的有符号乘法器,把 DDS 的 16bit 输出和一个 16bit 增益字相乘,再接一个简单的移位截断,就实现了 0 到满幅的增益控制,实测效果不错。
7.3 资源占用实测参考
给一个我这边实际工程里的资源数据,供大家估算时参考。配置:输出 16bit 正弦+余弦,SFDR 100dBc,相位累加器 32 位,单通道,100MHz 主频,开 Dither。
资源开销大致如下表:
| 资源类型 | 占用数量 | 备注 |
|---|---|---|
| LUT | 约 150-200 个 | 查找表 + 截断逻辑 |
| FF | 约 120-160 个 | 流水线寄存器 |
| DSP48E1 | 0 个 | DDS 不用 DSP |
| Block RAM | 0 个 | LUT 实现查找表,未用 BRAM |
如果关掉 Dither 或者把 SFDR 降到 80dBc,LUT 占用能再降三分之一。如果输出位宽上到 32bit,LUT 可能会到 500 个以上。这个量级在 Artix-7 上完全不叫事,但在资源紧张的 CPLD 或者小规模 FPGA 上就要仔细权衡了。
还有一点建议:如果系统里对相位噪声或者杂散有硬指标,不要只看 DDS 配置,后面的抗混叠滤波器设计和 PCB 布局对最终指标的贡献同样巨大。DDS 本身做得再好,输出端一个设计不当的 RC 滤波或者 DAC 走线串扰,都能让 SFDR 掉二十个 dB。这个坑属于“配置界面里永远看不到”的那类问题,只能靠实测整改。
用了 DDS Compiler v6.0 小两年,最大的体会是:IP 核真正帮你省掉的是“把信号处理理论变成硬件行为”的那一大段弯路,但你在电路层面该操的心一点都不少。频率字公式要自己算,时序验证要自己写 testbench,接进系统后还要处理 AXI 握手、Latency 补偿、多通道对齐这些外围问题。把上面这些环节走通一遍,这个 IP 就算是真正吃透了。