Vivado开发圈里聊到信号发生,DDS(Direct Digital Synthesis,直接数字频率合成)这个词绕不开。很多人第一反应是:不就是相位累加器加查找表吗,自己写个ROM查表不就行了?但真到了工程里,尤其是要在Vivado里快速出一版低杂散、相位连续、还能动态调频的正弦波时,DDS IP核几乎是绕不开的选择。
这篇文章我就从实测角度,把Vivado里DDS IP核的配置、调用、调频控制、仿真验证和踩坑经验完整过一遍。目标是让手里有开发板、想快速上手信号生成的朋友,照着做就能跑起来,并且真正搞懂每个参数背后的物理含义,而不是只会点“Generate”。
1. 先理解DDS,再点开IP核配置界面
1.1 DDS的工作原理,一句话版本
DDS的核心不复杂:一个相位累加器不停地累加,每次累加一个频率控制字,累加结果的高位去ROM查找表里查正弦值,然后输出。相位累加器什么状态下溢出一次,就意味着正弦波走完一个完整周期。
举个例子,累加器位宽是32位,那么它从0累加到2^32再溢出,正好是2^32个时钟周期。如果每个时钟周期累加值不是1,而是M,那么溢出一次就需要2^32 / M个时钟周期,也就是输出频率等于时钟频率除以这个数字:
f_out = f_clk × M / 2^N
这个公式是整套DDS体系的基石。N是相位累加器位宽,M就是频率控制字(Phase Increment)。N越大,相同时钟和频率下M的精细程度越高,频率分辨率也就越高。IP核界面上让你填的“Phase Increment Programmable”相关参数,本质就是给你留出动态改M的空间。
1.2 为什么用IP核,而不是自己写个查找表
自己写DDS最直接的坑是查找表资源。一个标准的1MHz输出、50MHz采样率、精度还看得过去的方案,要存的点是采样点数,几百个点起步,波形边缘微微带台阶。如果要求再高一点,点数上千,分布式RAM可能就紧张了。IP核在实现上会帮你做资源权衡,用Block RAM存正弦表,能自动优化ROM寻址逻辑,还能顺手处理好相位截断、输出量化这些细节。
自己做还有一关很难过:时序。相位累加器本身是个纯组合逻辑加寄存器,好写,但查找表的地址线宽了以后,ROM的访问路径可能就是一长串组合逻辑,时钟一高,时序立马给脸色看。IP核内部会按FPGA的底层结构去排布,保证查找表路径短,时序更好收敛。
另外,IP核提供了标准的AXI接口。后续如果要做自动控制、上位机调速,直接用AXI-Lite或者AXI4-Stream协议去灌配置数据,比自己写一堆自定义握手逻辑规范得多。这也是我在工程里改用IP核的根本原因——不是为了省那几行RTL,而是为了接口统一、时序可靠、资源可控。
当然,不是说动手能力不重要。理解DDS原理、自己写一个简单的模型用来核对IP核的输出,是很有价值的。IP核的datasheet再详细,也不如自己手里有一个“参考实现”来得安心。我建议至少用Python或者Verilog仿真写一个简单的32位累加器模型,验证一下IP核给的频率字与你计算值之间的对应关系。
2. Vivado里配置DDS IP核,每个参数都帮你过一遍
2.1 新建DDS Compiler,选择合适配置模式
在Vivado的IP Catalog里搜索“DDS”,会出现DDS Compiler,双击进入配置界面。如果IP Catalog里找不到,先确认License状态。很多新手在这卡住,是因为Vivado License不完整,DDS Compiler显示为灰色的锁状态。
配置界面左侧有“Configuration”和“Implementation”两个Tab。Configuration页面里需要重点关注的选项有:
- Configuration Mode:有“Phase Generator and SIN/COS LUT”“Phase Generator”“SIN/COS LUT”三种。我们要做可调频正弦波,选第一项“Phase Generator and SIN/COS LUT”就对了,它把“相位累加”和“正弦查找”两个环节都包含了。
- Channels:单通道选1。多通道相参信号场景选多,比如雷达里的I/Q双通道,可以选2,但要注意后续number of channels会影响资源。
- Mode of Operation:有“Standard”和“RASTERIZED”两种。用得最多的是Standard,它的相位增量和相位偏移自由度更高。RASTERIZED模式适合做高性能、特定频率栅格的场景,参数较少,自由度受限。
- System Clock:填你实际的时钟频率,比如100MHz。这里必须填准,因为IP核输出频率参数是相对于这个时钟的。
- Number of Output Channels:同上。
- Parameter Selection:可选“System Parameters”和“Hardware Parameters”。System Parameters模式下直接填输出频率、频率分辨率等物理量,IP核自动反推参数,适合快速配置;Hardware Parameters模式下直接填相位位宽、频率字位宽等硬件参数,控制粒度更细。初次使用我建议用Hardware Parameters,因为“调频”的本质就是控制硬件参数,你早晚要理解它。
选Hardware Parameters之后,可以看到下面会列出:
- Phase Width(相位宽度):累加器的位宽N,常见值是16到32。建议32位,频率分辨率足够精细,也没多耗多少资源。
- Output Width(输出位宽):正弦波数据位宽,常见是8、10、12、16位。位数越高,输出波形越细腻,但后续处理比如DAC转换要考虑实际支持的位数。测试用12位比较合适。
- Phase Increment(频率控制字):如果选Fixed,频率写死;如果选Programmable,则在后续“Phase Increment Programming”选项里选择可编程方式。我们要做动态调频,必须选Programmable。
2.2 关键参数之间的约束关系
系统时钟、相位宽度、频率字位宽这三者互相咬合。相位宽度决定频率分辨率,频率字位宽决定你最小能加的“步长”,两者并不一定相等。
比如,System Clock = 100MHz,Phase Width = 32位,那么理论频率分辨率大约是100MHz / 2^32 ≈ 0.0233Hz,极细。但实际并不需要这么细,因为输出频率到MHz级别时,用32位字长去表达MHz分辨率,频率字的跳动可能只有非常低的几个比特变化,这也无所谓,频率分辨率高不会带来麻烦。
反过来说,如果Phase Width只有16位,频率分辨率就是100MHz / 65536 ≈ 1525.9Hz,想调一个1kHz级别的频点就很难受。所以在能承受资源的情况下,推荐Phase Width取24位以上,我一般是32位直接上。
再往下有一个“Output Selection”,可以选Sine、Cosine、Sine and Cosine。如果只需要单路正弦,选Sine即可。选Sine and Cosine会多占一个输出通道的资源,除非后续要做正交混频,否则别浪费。
旁边的“Latency”参数要注意一下。它表示从输入到输出经过多少个时钟周期。IP核会根据配置自动计算一个最小延迟,你可以在“Auto”和“Manual”之间选。一般用自动即可,但如果你的系统对延迟敏感,比如要做闭环反馈,最好先确认这个latency数值,在其它信号路径上做对齐。
2.3 实现方式的选型
Implementation(实现)Tab里有一个很容易被忽略的选项:硬件资源类型。可选“Block ROM”“Distributed RAM”“DSP48”“Auto”。
- 查找表部分通常用Block ROM。Block RAM容量充足,查表速度快,不占逻辑单元。
- Distributed RAM会用LUT来实现,适合小容量查找表,优势是不占用BRAM。
- DSP48实现则适合输出数据需要进一步滤波、补偿的场景,DSP48可以内置乘法器,如果后续要级联混频或者FIR滤波,选DSP48能把DDS和后续处理塞得更紧凑。
实际操作中,如果不想动脑子,选Auto让工具自己决定。但有个前提:你的工程里BRAM资源别太紧张。我曾在一个小容量芯片上同时例化了DDS和FFT,BRAM吃紧,后来把DDS切成Distributed RAM模式才解决。
还有一点值得关注,“Output Fourier Series”相关的选项在界面里叫“Output Type”,可选“Sine”“Cosine”或者“Sine and Cosine”,如果选Sine,下游可能还需要自己做90度相移,就别在IP核里浪费通道。
配置完成后点击“Generate”生成IP核。此时工程里会出现.veo文件(例化模板文件)和.pdf文档。我强烈建议打开PDF文档看一眼前几页的端口定义和时序图,省得后面连线出错又得回头翻。
3. 可调频的关键:频率控制字与配置接口
3.1 频率控制字的计算与精度取舍
硬核来了。真正做可调频,核心就是“改频率控制字”。使用DDS IP核默认的可编程配置,数据要送到s_axis_config接口。
频率控制字M的计算公式在上面说过:M = f_out × 2^N / f_clk。这里N通常就是相位宽度。举个例子:
- 系统时钟f_clk = 100MHz
- 相位宽度N = 32位
- 需要输出f_out = 5MHz
M = 5000000 × 4294967296 / 100000000
这个数除不尽,M = 214748364.8。由于M必须是整数,实际输出频率就是 214748364 × 100000000 / 4294967296 ≈ 4999999.9994Hz,差了不到1Hz。这种误差在实际系统中完全可以忽略。
但要注意,如果系统时钟较低、频率字位宽不够,这种计算误差会明显变大。比如N=16,想从100MHz时钟出5MHz信号,M = 5000000 × 65536 / 100000000 = 3276.8,取整后实际输出约4999389Hz,偏差600多Hz。这就说明为什么我前面推荐相位宽度至少24位,工程上传真一点就直接32位,频率精度上不吃亏。
实操中,我习惯把M的计算用64位定点数先算好,避免32位溢出。M一旦超过2^N就会发生相位累加器溢出,意思是一个周期内累加超过一圈,输出的频率本来就已经超过奈奎斯特限制,所以超出部分没有任何物理意义,必须保证M < 2^N / 2,也就是输出频率不能超过f_clk/2。实际工程建议最大输出频率不超过f_clk × 0.4,留出抗混叠滤波器的过渡带余量。
3.2 s_axis_config接口的时序
s_axis_config是AXI4-Stream类接口,用来配置DDS IP核的参数。端口上有:
- s_axis_config_tvalid:配置数据有效
- s_axis_config_tready:IP核准备好接收
- s_axis_config_tdata:配置数据总线
注意,这个tdata的位宽和配置内容有关。如果配置了Phase Increment可编程和Phase Offset可编程,tdata里前几个bit是Phase Increment,后几个bit是Phase Offset。具体位宽在Summary页会有明确提示。
时序上,你需要在s_axis_config_tvalid拉高的同时把tdata数据准备好,然后等待tready为高,表示一次握手完成。这里有个经验坑:很多人测试时tvalid只拉高一个周期,结果发现频率没变。仔细看文档会发现,部分配置下IP核要求tvalid至少保持一个时钟周期,并且完成握手后需要间隔一段时间再发下一次配置,否则可能丢失。稳妥做法是:拉高tvalid = 1,等待tready拉高后再拉低tvalid,然后空几个时钟周期再做下一次配置。
配置完成后,输出并不会立刻变成新频率,中间会有几个周期的延迟,这个延迟和IP核的Latency相关。不要期望配置完下一个时钟周期就看到新波形。
3.3 让频率“平滑”切换的实操建议
DDS最大的优势之一是相位连续调频。因为频率控制字可以直接改变相位累加器的步长,而不是去切换两个独立的振荡器,所以频率变化瞬间波形不会发生相位跳变,这在调频连续波雷达、扫频信号源等场景里极其重要。
但“相位连续”也有前提:你改的是PINC(Phase Increment),而不是直接改累加器的当前值。如果你在IP核配置里不小心使能了相位偏移的实时改写,然后又动态去改Phase Offset,那就可能造成瞬间的相位跳变。所以,单纯做调频时,把Phase Offset Mode设成Fixed或者干脆设为0,只在运行中改PINC。
另外,如果你需要做一个扫频源,频率按时间线性变化,用外部控制器每个周期去写新的频率字会产生离散频率台阶。这时候有两种处理方式:
- 提高配置更新的频率。如果系统时钟100MHz,每100个时钟周期更新一次频率字,就能获得1MHz步进、100MHz/s的扫频速率。频率字在FPGA内部自己生成,用计数器触发,这样扫频逻辑完全可控。
- 用IP核自带多通道预置波形能力。DDS IP核支持把多组频率字和相位字存储在内部寄存器中,然后通过外部触发去选择不同的配置群,适合做跳频。这一项需要在配置界面里把“Number of Channels”或者“Programmable Configuration”相关项打开,之后s_axis_config的tdata可以携带配置序号,按索引切换预置参数。
我在实际雷达信号源项目里,就是用第二种方式做了16个频点的快速跳频,切换时间可以做到几十纳秒级,远远快于外部控制器重新下发配置字。
4. 顶层模块与仿真验证
4.1 带AXI配置接口的顶层例化
下面给出一个我在工程里常用的顶层模块,例化了DDS IP核,同时把s_axis_config接口引出来,方便外部控制。这个模块做的是100MHz时钟下,输出频率可配置的12位正弦波。
module dds_top ( input wire clk, input wire rst_n, input wire [31:0] freq_word, // 频率控制字 input wire freq_update, // 频率更新脉冲(单周期高有效) output wire [11:0] sine_out, output wire sine_valid ); // 配置接口信号 wire s_axis_config_tvalid; wire s_axis_config_tready; wire [31:0] s_axis_config_tdata; // 控制逻辑:把外部频率字写入配置接口 reg [31:0] cfg_word_reg; reg cfg_valid_reg; reg cfg_busy; assign s_axis_config_tdata = cfg_word_reg; assign s_axis_config_tvalid = cfg_valid_reg && !cfg_busy; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cfg_word_reg <= 32'd0; cfg_valid_reg <= 1'b0; cfg_busy <= 1'b0; end else if (freq_update && !cfg_busy) begin cfg_word_reg <= freq_word; cfg_valid_reg <= 1'b1; cfg_busy <= 1'b1; end else if (s_axis_config_tvalid && s_axis_config_tready) begin cfg_valid_reg <= 1'b0; cfg_busy <= 1'b0; end end // DDS IP 核例化 dds_compiler_0 u_dds ( .aclk (clk), .aresetn (rst_n), .s_axis_config_tvalid (s_axis_config_tvalid), .s_axis_config_tready (s_axis_config_tready), .s_axis_config_tdata (s_axis_config_tdata), .m_axis_data_tvalid (sine_valid), .m_axis_data_tdata (sine_out) ); endmodule这段代码里我做了一个简单的握手状态机。freq_update来一个脉冲后,把新的频率字锁存进去,等待tready回应后再清掉valid标志。这里要注意,tdata位宽不一定刚好是32位,要根据你IP核Summary页显示的位宽调整,我这里的配置刚好是32位。
如果IP核还配置了tdata里同时携带Phase Offset,那就需要把tdata拆成两个域。举个例子,配置界面里PINC位宽24位,POFF位宽16位,那么tdata总位宽40位,其中低24位是PINC,高16位是POFF。例化时千万要对应好,否则你会发现频率对不上,却怎么都查不出原因。
4.2 testbench设计
写testbench的核心目标有两个:验证配置时序正确,验证输出频率符合预期。我习惯配合手工计算频率字,在testbench里做一次“期望值对比”。
`timescale 1ns / 1ps module tb_dds_top; reg clk; reg rst_n; reg [31:0] freq_word; reg freq_update; wire [11:0] sine_out; wire sine_valid; // 生成100MHz时钟 initial clk = 0; always #5 clk = ~clk; // 测试流程 initial begin rst_n = 1'b0; freq_word = 32'd0; freq_update = 1'b0; #100; rst_n = 1'b1; #100; // 配置输出频率 5MHz,100MHz时钟,32位相位宽度 // M = 5e6 * 2^32 / 100e6 = 214748365 (四舍五入) freq_word = 32'd214748365; freq_update = 1'b1; #20; freq_update = 1'b0; // 等待一段较长的时间,观察输出 #200_000; // 切换到 10MHz freq_word = 32'd429496730; freq_update = 1'b1; #20; freq_update = 1'b0; #200_000; $finish; end dds_top u_dds_top ( .clk (clk), .rst_n (rst_n), .freq_word (freq_word), .freq_update(freq_update), .sine_out (sine_out), .sine_valid (sine_valid) ); endmodule仿真时注意,DDS IP核输出前几个周期的数据可能不是合法的正弦波,要等Latency过去之后再看。另外,sine_out默认是无符号数格式,波形图里看起来可能是一个带直流偏置的正弦波,这是正常的。如果想让波形看起来以零为中心,可以在后续处理中减去2^(Output_Width-1)。
4.3 仿真结果分析,对比实际输出频率
仿真跑完之后,怎么看频率对不对?最直接的办法是放大波形数周期,量两个峰值之间的时间间隔。对5MHz正弦波来说,周期是200ns,在100MHz时钟下就是20个时钟周期。如果你看到峰峰间隔不是20个时钟周期,说明频率字算错了,或者tdata位宽不对。
另外一个更可靠的验证方式:用testbench里的计数器数一下固定时间内过零点的次数。这个我在工程里验证I/Q输出时特别有用,因为I/Q两路的相位差只有波形对比看不出来,用计数法能精确验证。
还可以把正弦输出数据dump成文本,然后导入Matlab或者Python做FFT,看频谱主峰位置是否落在预期频率上。这一步能同时检查杂散水平、SFDR等指标,比看波形图严谨得多。
我实测过5MHz配置下的输出,IP核输出的tdata直接导入Python做2048点FFT,主峰落在第102个频点附近,对应5MHz,旁边杂散低至-74dBc左右,完全满足大多数测试设备的激励需求。
5. 常见问题与排查实录
5.1 输出全零,或者一直是固定值
这是DDS IP核最容易碰到的问题。排查顺序我一般是这样:
先看s_axis_config_tvalid有没有成功握手。如果配置没有完成,IP核可能一直输出0相位对应的正弦值,看起来就是一个固定值。重点检查tready信号在仿真里有没有拉高。如果tready一直不拉高,说明tdata位宽或配置Mode和界面设置不匹配,比如你把PINC设成了24位却实际发送了32位。
再看reset。DDS Compiler的aresetn是低电平有效,很多新手按AXI的惯例以为是高有效,结果一直复位着,输出当然出不来。仿真波形里先确认aresetn确实为高。
最后确认tdata每个bit的含义。如果同时配置了PINC和POFF,而你的tdata直接把PINC放在了高bit,IP核解析出来的频率字就完全错了,表现为输出频率不对但波形形状正常。
5.2 换频后波形跳变,或者有毛刺
波形跳变最大的嫌疑是Phase Offset被动态改了,或者配置数据在握手期间发生了变化。我用过一段时间的经验是,配置数据要在tvalid拉高之前就稳定下来,不要tvalid和tdata同时变化,因为DDS Compiler对tdata的采样发生在握手成功的时钟沿,数据不稳定极容易采到中间态。
还有一个隐蔽问题:配置完成后立刻读输出数据,读到了配置前的老数据。这是Latency造成的。换频后需要等待IP核内部流水线冲刷完毕,再做数据采集。否则你看到的“毛刺”其实是新旧频率的过渡数据,不是真正的毛刺。
如果需要严格无毛刺切换,建议在DDS输出后面加一个gating逻辑,配置完成后延迟固定的Latency再加一个安全裕量,再让数据流入后级。比如Latency=10,你就等20个周期再放数据。
5.3 时序违例,Implement Design 报红
DDS IP核本身时序通常不是瓶颈,瓶颈一般在系统时钟选择和配置路径过长。如果Implement之后出现时序违例,先查你的时钟约束。
很多开发板上的PL时钟默认source是用向导生成的,时钟频率记得和实际一致。如果系统时钟写100MHz但约束文件里没加create_clock,或者时钟周期约束和实际不一致,工具在布线时的努力方向就是错的,最后可能乱优化导致路径变差。
如果确认时钟约束没问题,再看看s_axis_config这条路径是不是跨时钟域了。外部控制器给频率字时如果用的是异步时钟,这里的同步处理没做,时序违例几乎必然出现。我给客户的工程里,配置路径一律用系统时钟域打两拍再进入握手状态机,宁可多两拍延迟,也要保证跨时钟域安全。
还有一个小技巧:如果只差一丢丢时序不满足,可以在综合设置里把DDS IP核的keep_hierarchy设为YES,防止综合器过度展平压坏了关键路径。这个操作在Vivado里是右键IP核 -> Properties -> Synthesis Options,有时候能救回来。
5.4 仿真速度慢,怎么加速
DDS仿真的时间单位是ns级,验证一个完整的频率字切换和输出稳定,仿真时间常常要到几百微秒,跑起来确实让人等待。
几个提速技巧:
- 仿真精度如果能放宽,把timescale改成
timescale 1ns / 100ps,有些场景能提速。但DDS内部有周期精确的时序逻辑,精度太粗可能导致采样丢失,建议还是保持1ps精度。 - 不要开全波形存储。把不需要观察的内部信号加入“log”里会导致仿真器IO开销巨大,尽量只留关键输出和配置接口信号。
- 用增量编译和并行仿真的选项。Vivado自带的xsim在run阶段可以用-multicore选项。更直接的办法是编译后只跑testbench,不重新综合,配合绝对路径的仿真库,速度能快不少。
- 如果只是验证逻辑,可以把IP核配置里的Latency和输出位宽临时调低,跑通后再恢复回正式参数。
5.5 生成比特流失败,License和资源问题
Generate Bitstream失败在网络求助里很常见。先看错误log,大部分情况可以分成三类:第一类是License缺失,DDS Compiler或者配套的仿真库需要license,界面上会有明确提示;第二类是资源超限,BRAM或者DSP48超出芯片容量,把DDS实现方式从Block ROM改成Distributed RAM往往能省出空间;第三类是SDK或硬件平台相关的版本问题,检查Vivado版本和板子支持包是否匹配。
我自己碰到过一次非常奇怪的失败:IP核单独综合没有问题,但全工程跑bitstream时在路由阶段报错,后来发现是工程里有个旧版本的DDS IP缓存文件没更新,删掉生成的IP相关缓存文件,重新Generate所有IP后就恢复正常了。
6. 再往深处走一点
DDS IP核如果只是用来输出正弦波,其实是大材小用了。实际上,DDS的相位输出可以当成一个“相位参考源”来用,比如配合CORDIC做正交信号发生,配合混频器做数字上变频,或者把多路DDS级联起来做相控阵波束形成。
拿我做过的一个项目举例,四路DDS共用一个时钟,每路配置不同的初始相位,配合外部移相器校准,实现了波束指向可调。这种场景下,DDS IP核的“初始相位可编程”能力极其重要,而且IP核支持多通道相参输出,通道间的相位差是严格确定的,比自己做四个独立DDS再用同步逻辑校准方便一百倍。
还有一点容易被忽略的是SFDR(无杂散动态范围)。DDS IP核在配置界面里会给出无杂散动态范围指标,这个指标通常取决于相位截断位数和输出量化位数。如果你做的是通信系统本振,SFDR低于70dBc可能会对相邻信道产生干扰,这时要把相位宽度适当加大,或者开启抖动(Dither)选项。抖动能显著提升SFDR,代价是底噪略微抬高,在窄带应用里是很划算的交换。
回到开头那句话:理解DDS原理再谈IP核配置,顺序不能反。IP核把底层细节包装得很干净,但最终决定你能不能高效调频、能不能避开那些隐藏坑的,还是对N、M、相位截断、握手时序这些底层概念的理解是否扎实。我希望这篇文章能把“原理”和“工具”衔接起来,让你拿到IP核后不是点完Generate就完事,而是心里有一笔清清楚楚的账。