刚接触Vivado里DDS Compiler的时候,我相信大多数人都会被那一串配置项搞得云里雾里。Phase Increment、Phase Offset、SFDR、Noise Shaping、Independent/Joint通道模式……一个个术语看着都眼熟,真到要配一个能用的双通道信号发生器出来,却总是差那么一口气。这篇文章就用一个实战例子,带着你把Vivado IP Catalog里的DDS Compiler从原理到配置、再到双通道信号生成的完整流程走一遍,顺便把那些文档里不会明说的坑都给你指出来。
这篇文章适合谁看?已经会用Vivado建工程、写简单Verilog,但还没怎么碰过DDS Compiler的FPGA开发者;或者是项目里需要快速生成两路正弦波、需要I/Q正交信号、需要给相控阵通道提供可调相位基准的朋友。看完之后,你应该能独立完成双通道DDS的配置、例化和仿真验证,也知道换频率、换通道关系时该动哪些参数。
1. DDS原理与需求的初步拆解
1.1 DDS的基本原理:相位累加器与查找表
DDS全称Direct Digital Synthesizer,直接数字频率合成器。FPGA里实现DDS,核心就三样东西:相位累加器(Phase Accumulator)、波形查找表(LUT/ROM)、以及后级的幅度量化输出。绝大多数情况下,你不需要自己去写相位累加器和查找表,直接用Xilinx官方IP即可,但原理必须懂,否则配置界面上的参数对你来说就是一堆随机数字。
相位累加器可以理解成一个循环计数器,每个时钟上升沿累加一次。这个累加器的位宽是N位,所以它能表示的相位范围是0到2^N-1,对应一个完整周期的360度。每个时钟沿累加的值就是频率控制字,也叫Phase Increment(PINC)。PINC越大,累加器从0跑到溢出(或者回绕)的速度越快,输出波形的频率就越高。
输出频率的计算公式是:
f_out = PINC × f_clk / 2^N
其中f_clk是DDS的系统时钟频率。从公式可以直观看到,PINC相当于每个时钟周期"走多少度",走得快,波形就密。频率分辨率由Δf = f_clk / 2^N决定。举个例子,系统时钟100MHz,累加器位宽32位,那么频率分辨率就是100e6 / 4294967296 ≈ 0.0233Hz,非常精细,足够绝大多数应用使用。
相位累加器产生的是相位值,要变成正弦波,还需要查表。把累加器的高M位作为地址,去ROM里查出对应的正弦幅度值。但这里有个问题:ROM的深度有限,不可能把2^N个相位全部映射进去,只能取高M位,低N-M位被截断。这个截断就是DDS杂散(Spurious)的主要来源。表现出来就是在频谱上,主信号旁边会出现不需要的小尖峰。
为了抑制杂散,常见的做法是加Dither(抖动),把杂散能量打散到噪声底;或者用Taylor Series Correction(泰勒级数校正)来补偿截断误差。这个后面配置的时候会具体说。
1.2 双通道信号生成的应用场景与典型需求
为什么需要双通道信号生成?应用场景太常见了。最典型的就是I/Q正交信号生成,A通道输出余弦,B通道输出正弦,两路信号频率相同、相位差90度,用于正交上变频、数字下变频、QPSK调制解调等。另一个常见场景是双音测试,同时给被测系统输入两个不同频率的信号,用来测互调失真、非线性特性。还有相控阵系统里,每个阵元可能需要独立的相位控制,多通道DDS可以方便地给不同通道设置相位偏移,实现波束指向控制。甚至音频领域做立体声效果、左右声道不同频率扫频信号,都能用上双通道DDS。
双通道的"关系"通常分两种:一是两个通道频率不同,各自独立;二是两个通道频率相同,但相位关系固定(尤其是0度/90度/180度等)。搞清楚你要哪种关系,直接决定DDS Compiler里Channel Selection选Independent还是Joint。
这里有个重要的概念要提前说:DDS Compiler的多通道输出是时分复用(TDM)的。也就是说,两个通道的数据不是两个独立的并行数据流,而是在同一个数据接口上轮流输出:第一个时钟周期出通道0的数据,第二个时钟周期出通道1的数据,第三个周期又出通道0……以此类推。因此,每个通道的有效采样率是系统时钟除以通道数。如果系统时钟100MHz、双通道,那么每个通道的实际吞吐率是50MHz。这一点在做频率规划时必须提前算好,否则输出频率超过25MHz就会违反奈奎斯特定理,波形直接失真。
2. Vivado IP Catalog中的DDS Compiler核心概念
2.1 从IP Catalog找到DDS Compiler
打开Vivado工程,在左侧Flow Navigator里找到IP Catalog,搜索框输入"dds",能看到一个叫DDS Compiler的IP。它的完整版本号一般是6.0以上,不同Vivado版本可能细节略有差异,但核心配置项是通用的。
如果你习惯用Tcl,也可以在Vivado Tcl Console里直接输入:
create_ip -name dds_compiler -vendor xilinx.com -library ip -version 6.0 -module_name dds_compiler_0这样IP会直接创建到工程里,然后双击打开配置界面。我个人建议新手用GUI方式,因为GUI里有大量的自动计算和提示,能帮你搞明白各个参数之间的联动关系。
2.2 关键配置项逐一解读:Configuration与Output Frequency
DDS Compiler的配置界面左侧有多个标签页,核心主要是Configuration和Output Frequency这两块。
Configuration页里,第一个要选的是Configuration类型,有Phase Generator和Direct Digital Synthesizer(DDS)两种。Phase Generator只输出相位值,不负责查表转换成正余弦;DDS才是完整的信号发生器。大部分情况下选DDS。
接着是Phase Increment Programmability,也就是频率控制字能不能在运行时修改。可选Fixed(固定)或Programmable(可编程)。选Fixed,频率在配置时写死,IP内部自动处理,不需要额外的输入接口;选Programmable,IP会多出一个S_AXIS_PHASE输入接口,运行时要通过这个接口把PINC值送进去。同理,Phase Offset Programmability控制相位偏移(POFF)是否可编程,有None、Fixed、Programmable三种。
Noise Shaping这里,有None、Dither、Taylor Series Corrected三个选项。None就是不管相位截断的杂散;Dither加抖动,把杂散摊平到噪声底,简单有效;Taylor是泰勒级数校正,进一步提升SFDR,代价是资源更多。对一般的信号生成,Dither是性价比最高的选择。
Output Frequency页,主要填这些内容:
- System Clock:DDS的工作时钟频率,必须和实际接入的aclk一致,否则IP算出来的频率全是错的。
- Number of Channels:通道数,这里填2。
- Channel Selection:Independent或Joint,后面单独讲。
- Frequency per Channel:每个通道的期望输出频率。
- Spurious Free Dynamic Range(SFDR):无杂散动态范围,IP会根据你的输出位宽、相位位宽和噪声整形方式估算能达到的SFDR值。
- Frequency Resolution:只读,显示当前配置下的频率分辨率。
2.3 模式选择的核心取舍:Fixed/Programmable与Independent/Joint
Phase Increment选Fixed还是Programmable,核心是"是否需要在运行时改变频率"。
如果你只是生成两路固定频率的信号,比如10MHz和5MHz,那必须选Fixed,省事、稳定、资源也少,而且代码里完全不用关心PINC怎么算。如果系统需要扫频、跳频,或者像相控阵那样动态调整通道相位,那就得选Programmable,运行时通过S_AXIS_PHASE接口喂数据。
Channel Selection的Independent和Joint是另一个关键选择。
Independent模式下,每个通道有独立的相位增量和相位偏移,通道之间完全独立。这意味着两个通道可以输出不同频率、不同相位的信号。Joint模式则恰好相反,所有通道共享相位增量,通道间只能配置各自不同的相位偏移(而且有些版本Joint模式连POFF也是共享的,具体看版本提示)。Joint模式资源更省,但灵活性差,适合所有通道输出相同频率、只需要固定相位差的场景。
这里必须提醒一个容易踩的坑:很多人以为选了Independent模式,IP内部就有两套完整的DDS硬件,资源翻倍。其实不是。DDS Compiler的多通道实现是时分复用的,两个通道共用一套相位累加器和查找表逻辑,Independent模式只是多了存储每通道PINC/POFF的寄存器。所以多通道DDS比例化两个独立DDS IP核要省资源得多,这也是推荐用多通道功能的原因。
3. 双通道DDS配置的完整步骤
3.1 配置前准备:时钟、位宽、频率规划
在动手配置之前,先把需求定清楚。我这次以一个非常典型的例子来走流程:系统时钟100MHz,需要输出两个通道的正弦波,通道0输出10MHz,通道1输出5MHz。两个通道频率不同,所以Channel Selection选Independent。输出位宽16bit,Noise Shaping选Dither,Phase Accumulator位宽取默认32bit。
先验证频率规划是否合理:双通道模式下,每个通道的有效采样率是100MHz / 2 = 50MHz。通道0的10MHz远小于25MHz的奈奎斯特上限,通道1的5MHz也没问题。如果需求是40MHz双通道,按这个配置就废了,因为40MHz超过了25MHz。这种时候要么降低通道数,要么提高系统时钟,要么改用单通道Sin/Cos同时输出两路信号(Sin和Cos的采样率都等于系统时钟,不受通道数折半影响)。设计阶段先把这些算清楚,能省后面好多事。
Output Width选16bit是比较均衡的选择。位宽越大,幅度量化噪声越低,但查找表存储和后续处理资源也会上升。16bit在绝大多数测试和通信场景下都够用。
3.2 DDS Compiler的GUI配置实操
创建好IP后,双击打开DDS Compiler配置界面,按下面顺序设置。
Configuration页面:
- Component Name改成dds_compiler_0,方便后续例化。
- Configuration选Direct Digital Synthesizer(DDS)。
- Phase Increment Programmability选Fixed。固定频率模式,不用写S_AXIS_PHASE接口,最简单。
- Phase Offset Programmability选Fixed。通道间不需要动态调相。
- Noise Shaping选Dither。
- 往下看,Output类型选Sine,因为我们只要正弦波。如果需要正交信号,可以选Sine and Cosine,这样单通道就能同时输出正余弦两路信号,那是另一种用法。
Output Frequency页面:
- System Clock填100,单位MHz。
- Number of Channels填2。
- Channel Selection选Independent。
- Frequency per Channel这里会出现两个输入框,通道0填10,通道1填5,单位MHz。
- 填完后,下面会自动显示计算出来的结果:Phase Increment值、SFDR、频率分辨率等。
这时你会看到,两个通道的PINC值会被自动算出来。通道0(10MHz)的PINC大约是0x1999999A,通道1(5MHz)的PINC大约是0x0CCCCCCD。如果频率不是整数关系,IP会显示最接近的PINC值和对应的实际输出频率,二者会有极其微小的误差,通常可以忽略。
其他标签页比如Implementation、Detailed Implementation,新手可以直接保持默认。需要注意的是Phase Accumulator的实现方式,默认Auto会让Vivado自己决定用LUT还是DSP48,通常没问题。如果后续时序收敛困难,再回来手动指定用DSP48实现累加器。
配置完成后点击OK,然后Generate Output Products,等IP生成完毕。
3.3 生成IP后的接口信号与关键端口
IP生成后,在Sources面板里找到dds_compiler_0,右键选择Open IP Example Design可以打开官方例程。但更直接的是在顶层代码里手动例化。双击IP核,在IP Sources里能看到例化模板(Instantiation Template),端口大概是这样的:
dds_compiler_0 u_dds ( .aclk (aclk), .aresetn (aresetn), .m_axis_data_tvalid (m_axis_data_tvalid), .m_axis_data_tdata (m_axis_data_tdata), .m_axis_data_tuser (m_axis_data_tuser) );aclk是系统时钟,aresetn是异步复位,低有效。m_axis_data_tvalid是输出数据有效标志,m_axis_data_tdata是波形数据,m_axis_data_tuser是通道指示信号。因为固定频率模式下没有S_AXIS_PHASE接口,所以输入侧只需要时钟和复位。
这里重点说TUSER。双通道时分复用输出下,m_axis_data_tuser用来区分当前m_axis_data_tdata属于哪个通道。对2通道来说,tuser为0时数据是通道0的,为1时是通道1的。如果不把TUSER接口引出来,后面解析数据时两个通道的波形叠在一起很难处理。所以配置IP时,TUSER选项一定要开。有些版本默认是关的,需要手动在Output Frequency或AXI4-Stream Options里勾上。
4. 双通道信号生成的代码实现与仿真验证
4.1 顶层模块例化:固定频率模式
固定频率模式下,代码极其简单。只需要一个顶层文件,例化DDS IP,把TUSER和TDATA引到需要的地方即可。下面是一段可以直接用的代码:
module top_dds_dual( input wire clk_100m, input wire rst_n, output wire [15:0] dds_data, output wire [0:0] dds_channel ); wire m_axis_data_tvalid; wire [15:0] m_axis_data_tdata; wire [0:0] m_axis_data_tuser; dds_compiler_0 u_dds ( .aclk (clk_100m), .aresetn (rst_n), .m_axis_data_tvalid (m_axis_data_tvalid), .m_axis_data_tdata (m_axis_data_tdata), .m_axis_data_tuser (m_axis_data_tuser) ); assign dds_data = m_axis_data_tdata; assign dds_channel = m_axis_data_tuser; endmoduleTUSER拉出来后,你在FPGA内部可以根据dds_channel的值把数据分别接入两个下游模块。比如dds_channel为0时,把dds_data写入通道0的FIFO;为1时写入通道1的FIFO。注意,两个通道各占50%的有效时钟周期,下游逻辑必须按这个节奏消费数据,不能假设每个周期都有某个通道的数据。
4.2 Programmable模式下的PINC计算与动态配置
如果需求是动态改变频率,那Phase Increment Programmability必须选Programmable。此时IP会多出S_AXIS_PHASE接口,你需要手动计算PINC并按时分复用的时序送进去。PINC的计算公式前面已经提过:
PINC = round(f_out × 2^N / f_clk)
N是Phase Accumulator位宽,默认32。以100MHz时钟、通道0输出10MHz为例:
PINC = round(10e6 × 2^32 / 100e6) = round(429496729.6) = 429496730
换算成十六进制就是0x1999999A。通道1输出5MHz:
PINC = round(5e6 × 2^32 / 100e6) = round(214748364.8) = 214748365
十六进制是0x0CCCCCCD。
在Programmable模式下,S_AXIS_PHASE接口也是时分复用的。双通道Independent模式,每个通道的PINC要在自己对应的时隙写进去,接口上同样有TUSER来标识当前写的是哪个通道。写配置的程序要比固定频率模式复杂一些,而且IP的S_AXIS_PHASE接口有TREADY信号,需要配合握手。我个人的建议是:如果只是生成两个固定频率的信号,就别折腾Programmable。真要动态调频,优先考虑用AXI4-Lite接口通过软件配置寄存器,而不是在硬件逻辑里手工去拼时序。
4.3 testbench设计与仿真验证
写完顶层代码就可以仿真了。下面是一个最基础的testbench:
module tb_dds_dual; reg clk_100m; reg rst_n; wire [15:0] dds_data; wire [0:0] dds_channel; top_dds_dual u_top ( .clk_100m (clk_100m), .rst_n (rst_n), .dds_data (dds_data), .dds_channel(dds_channel) ); initial begin clk_100m = 1'b0; forever #5 clk_100m = ~clk_100m; // 100MHz时钟,周期10ns end initial begin rst_n = 1'b0; #100; rst_n = 1'b1; #5000; $stop; end endmodule跑仿真后,在Vivado的波形窗口里添加m_axis_data_tdata和m_axis_data_tuser。你会看到tuser以0、1、0、1的节奏翻转,每个tuser对应的波形各自是连续的正弦波。把游标放到tuser为0时的tdata数据点上,数一下一个完整周期的采样点数。通道0输出10MHz,有效采样率50MHz,一个周期大约5个点,能看到明显的正弦轮廓;通道1输出5MHz,一个周期大约10个点,波形更平滑。如果波形看起来像一条直线或者只有几个离散值,检查一下Output Width是否是16bit,以及仿真时间是否足够长。
用tuser区分通道的好处在这里体现得很明显:一眼就能判断出两个通道的频率和幅度是否正常。如果有条件,可以把仿真数据导出成文本,用Python或MATLAB做FFT,验证频谱纯度。不过日常使用波形窗口目测加游标测量就够了。
5. 常见问题排查与实操经验
5.1 常见问题速查表
实际调试中,我遇到过不少问题,也帮别人排查过类似的坑,整理成一张速查表供参考。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 输出波形频率和配置值不符 | System Clock配置值与实际时钟不一致 | 检查IP配置里的System Clock,必须和aclk实际频率一致 |
| 两个通道的波形完全一样 | Channel Selection选成了Joint | 需要在配置里改成Independent |
| 波形看起来有毛刺或杂散大 | 相位截断噪声、Noise Shaping选成None | 配置里改选Dither或Taylor Series Corrected |
| m_axis_data_tuser一直为0或没有这个信号 | IP生成时未使能TUSER输出 | 重新配置IP,打开TUSER选项 |
| 输出数据全部为0 | aresetn一直为低,或固定频率模式下某个配置项没填完整 | 检查复位信号;重新打开IP确认PINC已填入 |
| 仿真波形中有大量X态 | 复位未正确释放,或者未加aresetn信号 | 在testbench里给复位一个延时后拉高 |
| 双通道输出频率超过25MHz时波形畸变 | 通道有效采样率只有50MHz,输出频率接近或超过奈奎斯特上限 | 提高系统时钟,或改单通道Sin/Cos方式,或降低输出频率 |
| 时序报错,DDS路径timing violation | 系统时钟过高,或相位累加器实现方式不合理 | 在Implementation里把相位累加器指定到DSP48,或降低时钟频率 |
5.2 关于有效采样率、SFDR与多通道数据解析的实操经验
多通道DDS最容易忽视的就是有效采样率。很多人配完IP,拿着100MHz的时钟,想当然以为输出能到50MHz,结果出来的波形稀烂。双通道时分复用决定了每个通道的有效采样率只有f_clk/2。所以输出频率规划时,要按奈奎斯特定理重新算一遍。同样的道理,如果用到4通道,每个通道有效采样率只有f_clk/4,能输出的最高频率更紧张。
SFDR方面,如果项目对频谱纯净度要求高,比如通信系统中要混频的本地振荡器信号,我建议Noise Shaping直接上Taylor Series Corrected,虽然资源多一点,但频谱改善效果明显。如果只是做测试信号源,Dither就足够了,没必要浪费资源。这里还要注意,Output Width会制约SFDR上限,16bit位宽就算相位处理得再好,SFDR也上不到100dB以上,那是位宽的极限决定的。
最后再强调一下TUSER。我见过不止一个人,配双通道DDS时没开TUSER,然后看着示波器上的波形一头雾水,不知道两个通道的数据到底怎么区分。时分复用输出是DDS Compiler多通道工作的核心特征,做数据解析时必须依赖TUSER信号。在代码层面,建议把TUSER引到顶层,不要藏在IP内部不管。
这套双通道DDS的配置流程,我前后用在信号源、正交调制、相控阵相位校准等好几个项目里。本质上,只要走通了双通道,后面扩展到4通道、8通道,或者切换到Joint模式、动态相位控制,都只是改配置界面里几个选项的事。希望能帮你少走点弯路。