FPGA信号生成进阶:用DDS Compiler IP核实现任意频率正弦波
用FPGA生成正弦波早几年我是老老实实手写查找表的——先在MATLAB里把正弦值量化成16位整数生成COE文件再在项目里例化一块Block RAM写一个相位累加器去查表整套流程折腾下来快则半小时慢则一上午。后来在Vivado 2023.2里换了DDS Compiler IP核才发现事情可以简单这么多配置好参数、例化、仿真、上板前后不到五分钟。这篇笔记就把我在2023.2里用DDS IP核做任意频率信号生成的完整过程、参数计算和踩坑记录整理出来适合刚接触FPGA信号处理、或者正在纠结到底该不该用IP核的工程师朋友。1. 为什么我不再手写正弦波DDS核的选型逻辑手写正弦波这件事本质上就是在FPGA里做两件事第一把正弦波一个周期的幅值按固定点数离散化存进ROM第二按输出频率的要求每隔固定周期从ROM里取一个点。看起来不复杂但实际工程里一旦涉及频率切换、多路输出、相位调制手写方案就开始变得非常啰嗦。1.1 手写查找表方案的几个典型痛点首先是ROM资源的浪费。输出16位精度的正弦波如果一周期采样1024个点那就是1024乘16比特约16Kbit的存储这在资源紧张的小芯片上可不是小数目。更麻烦的是如果换了采样率或者换了输出频率整个COE文件都要重新生成一遍在MATLAB里改脚本、跑仿真、重新生成、再导入工程这个流程每改一次都要走一圈极其消磨耐心。其次是频率切换不灵活。手写相位累加器虽然可以实现但实现“可动态变频”时需要自己管理频率字的更新时序还要处理相位跳变带来的毛刺。我在一个项目里做过带跳频功能的信号源光是为了让切频时不出现明显相位断裂就折腾了很久。为了这些需求去造轮子性价比很低。第三是杂散和量化问题。手写方案里ROM地址宽度、数据宽度、相位累加器位宽全都要自己权衡。刚开始默认用全精度结果面积变大、时序变差一压缩位宽频谱上的杂散又抬起来。这些细节不是不能调但每项都要花时间验证尤其在项目工期紧张的时候真的很不划算。1.2 DDS IP核到底帮我搞定了什么DDS Compiler IP核的核心是直接数字频率合成器它内部替我把相位累加器、ROM查找表、抖动抑制这些模块都做好了。从使用者的角度看我只需要给它一个时钟告诉它“输出多少频率”它就能持续不断地吐出正确的波形数据而且频率精度做得很高。这里不得不提DDS的工作原理。DDS由相位累加器和查找表组成相位累加器在每个时钟周期累加一个频率控制字累加结果的低位地址去查ROMROM里存的是正弦一个周期的幅值。输出频率和频率控制字的关系如下FTW f_out × 2^N / f_clk其中N是相位累加器位宽f_clk是系统时钟频率FTW就是频率控制字也叫PINC。反过来实际输出频率等于f_out FTW × f_clk / 2^N这个公式非常关键后面配置IP核时还会用到。DDS IP核把这些逻辑全部封装起来之后我只需要关注几个配置项相位累加器位宽、输出数据位宽、目标频率、系统时钟。它甚至帮我算了FTW的实际整数值这就省掉了我每次改参数都要按计算器的步骤。1.3 哪些场景我依然会考虑手写话说回来DDS核也不是万能。在极高速率场景下比如单个DDS核跑在吉赫兹以上采样率、输出多路并行数据做数字上变频时IP核的底层实现不一定比手写优化得好因为IP核为了通用性往往会在面积和时序上做折中手写可以针对具体需求做更激进的优化。还有一类场景是纯逻辑功能验证比如板上只是临时输出一个固定频率的方波、测个IO延时这时候手写一个很短的相位累加器比例化IP核更快。但从可维护性和扩展性来看只要是长期项目、要求可靠输出、后期可能要调频率我都会优先用DDS核。毕竟跑通一次之后后期改动只是改配置重新生成一下IP。2. 开工准备Vivado 2023.2环境与DDS Compiler入口工欲善其事必先利其器。在2023.2版本里DDS Compiler迭代到了6.0版本界面逻辑和之前版本大体一致但一些细节选项的位置略有变化。本节先把环境准备和入口说清楚免得中途才发现装错了License类型。2.1 环境安装与License注意点Vivado 2023.2属于较新的版本安装包体积不小安装时要注意磁盘空间至少预留50GB以上这一步踩过坑的人应该很多。安装组件时如果只做数字逻辑和IP集成选择Vivado ML Standard版即可如果涉及部分收费IP需要确认license功能是否覆盖。License这一块我要多说两句。DDS Compiler属于Xilinx基础IP不依赖特殊Vivado激活授权正常情况下在安装了完整工具后就能使用。但如果你在生成IP时报License相关错误多数情况是安装时没选择对应产品授权可以到Xilinx官网申请评估授权用的MAC地址是网卡地址要填对否则激活不成功。有位朋友就曾因为电脑有虚拟网卡提交的MAC地址不对导致License一直识别不了白白耽误了半天。2.2 新建工程的小细节创建工程时需要注意的是目标芯片型号。不同芯片对DDS核的资源利用方式有影响例如7系列器件和UltraScale系列在Block RAM和DSP48的数量上差异很大但DDS核的配置界面基本是通用的。工程默认语言建议选Verilog后面例化IP时生成的仿真模板也默认是Verilog减少转换成本。还有一点工程创建好后最好先在Settings里打开Project Settings的Simulation选项把仿真器设为Vivado Simulator这样才能直接跑IP核自带的示例仿真。2.3 在IP Catalog里找到DDS Compiler在Flow Navigator里点开IP Catalog搜索框输入DDS很快就能看到DDS Compiler条目双击就能打开配置界面。这里提醒一下如果之前安装过破解版或旧版IPIP Catalog里可能会同时出现多个版本的DDS核尽量选用当前工具链原生版本也就是6.0避免后续生成比特流时出现不兼容问题。准备好这些之后我们就进入正经的配置环节。DDS核的配置界面有三页逐页搞定它。3. DDS Compiler配置详解一项一项填对参数DDS Compiler的配置界面很直观但它那三页选项第一次看还是有点晕。下面我按照页面顺序结合一个具体需求来逐项解释。这个需求是系统时钟100MHz输出1MHz正弦波输出数据位宽16位后续方便接DAC或给ADC模块做测试源。3.1 第一页Configuration选对工作模式打开配置界面第一项是Configuration Options下拉列表里有三个选项Phase Generator Only、SIN/COS LUT Only、Phase Generator and SIN/COS LUT。我们一般选第三个它表示内部同时有相位累加器和查找表既能通过频率控制字设定输出频率也能直接输出正弦和余弦数据。接下来是Number of Channels这个项在多路输出时才需要关心做单路信号源选1。后面还有Mode of Operation选Standard即可。如果项目里对输出时延有严格要求的连续流场景可以选Streaming模式它会让IP核以更固定的流水线间隔输出数据代价是占用资源略多。再往下是System Clock选项输入100单位默认MHz。这个一定要填准因为IP核计算FTW时依赖这个参数填错了实际输出频率就会整体偏差。填完这些第一页基本就完成了。3.2 Implementation参数精确算出频率控制字第二页是Implementation这里决定了IP核的硬件行为。六个核心项必须仔细看Phase Increment Programmability决定频率控制字是固定值还是可以在运行时动态修改。固定频率场景选FixedIP核内部会生成一个常量PINC接口更简单。要做跳频或扫频选Programmable这时候会多出一组s_axis_config接口用来在运行时写入PINC。Parameter Selection选System Parameters然后下面会出现Target Output Frequency、Sample Rate等输入框。我把目标频率填1MHz系统采样率也就是输入时钟频率填100MHzIP核会自动计算出PINC的整数值。也可以选Hardware Parameters直接填PINC但我建议工程里优先用System Parameters方式因为可读性更强别人看工程时能直接看到设计意图是“100MHz时钟输出1MHz”。Phase Offset Programmability选None就好除非需要相位偏移控制。Output Selection里这个例子里只输出正弦选Sine这样只会有一路输出数据接口更简洁。需要同时输出正弦和余弦时选Sin and Cosine。最后的Output Width填16这是输出数据位宽。位宽越大波形阶梯误差越小但ROM资源占用也会增加。实测下来16位对多数DAC接口是够用的。到这里FTW的计算逻辑我用开头提到的公式算一下FTW 1M × 2^32 / 100M ≈ 42949673。IP核内部会自动取整这个值并把实际输出频率计算为42949673 × 100M / 2^32结果为1.0000009MHz左右误差可以忽略。3.3 Detailed Implementation内存、优化和抖动第三页是Detailed Implementation包含噪声整形、内存类型、优化目标等选项。Noise Shaping这一项通常选Auto。DDS核内部在相位累加器位宽和实际ROM地址位宽不一致时会产生相位截断杂散Auto会在这种情况下自动插入抖动来打散杂散能量。如果选None频谱上可能会出现明显的高次杂散峰这对信号源应用来说很致命。Memory Type选Auto。DDS核会自动判断是使用分布式RAM还是Block RAM通常小规模查找表用分布式RAM大规模用Block RAM。我们直接用Auto让它自己决定。 Optimization Goal里我一般选Speed因为信号源场景对时序要求更高面积多花一点无所谓。如果芯片资源紧张可以改成Area但输出频率较高时容易出现时序收敛困难。配置完成后点OK这时会生成DDS核的综合文件和仿真模型稍等片刻IP配置就完成了。里面包括例化模板后续直接把模板里的接口复制到顶层模块。3.4 生成IP后的产物结构Generate Output Products完成之后在Sources面板能看到dds_compiler_0的完整文件结构。这个IP核会生成一个例化模板路径通常在dds_compiler_0.veo中里面每个端口的注释都非常清楚。还会生成IP核的仿真模型文件仿真时Vivado Simulator会自动识别。有个小习惯我都会打开生成的例化模板把接口定义复制到自己的代码里确保端口名和官方模板完全一致避免手打时拼错。这个习惯帮我省掉了大量低级错误。4. 从例化到仿真再到上板完整实操记录IP配置好了接下来就是把DDS核接进工程并验证它真的能输出正确波形。这一节把整个过程串起来从例化代码、testbench写起一直讲到ILA上板实测。4.1 例化代码与接口说明本例中因为选择了固定频率、无相位偏移、只输出Sine所以实际接口非常简洁只需要时钟和输出数据接口wire clk_100m; wire dds_axis_tvalid; wire [15:0] dds_axis_tdata; dds_compiler_0 u_dds ( .aclk (clk_100m ), .m_axis_data_tvalid (dds_axis_tvalid ), .m_axis_data_tdata (dds_axis_tdata ) );这里面的m_axis_data_tvalid是AXI4-Stream接口的握手指令表示当前输出数据有效后续数据在经过固定延迟后连续有效。dds_axis_tdata是16位补码数据MSB是符号位。需要注意DDS IP核本身没有复位接口这是正常现象。Xilinx的DDS核设计为无复位接口因为复位信号反而可能造成输出毛刺和额外的时序开销不用因为缺少复位输入而怀疑自己漏接了信号。4.2 写一个testbench把数据落盘为了验证输出频率和波形我通常会写一个简单testbench把DDS输出数据写入文本文件方便后续在MATLAB里做FFT频谱分析。下面是完整的仿真代码timescale 1ns / 1ps module tb_dds; reg clk 0; wire dds_tvalid; wire [15:0] dds_tdata; dds_compiler_0 u_dds ( .aclk (clk ), .m_axis_data_tvalid (dds_tvalid), .m_axis_data_tdata (dds_tdata ) ); integer fd; initial begin fd $fopen(dds_out.txt, w); #1000; repeat(10000) begin (posedge clk); if (dds_tvalid) $fdisplay(fd, %0d, $signed(dds_tdata)); end $fclose(fd); $finish; end always #5 clk ~clk; endmodule这里用always #5生成100MHz时钟因为10ns是一个周期。在1000ns后开始采样跳过IP核初始化的前几个周期。repeat循环里每次等时钟上升沿看到tvalid有效就把数据写入文件$signed取符号后的十进制数方便MATLAB直接读取。仿真跑完后dds_out.txt里会有一串十进制整数数值范围应该在-32768到32767之间正负交替变化说明DDS确实输出了一个正弦波序列。4.3 仿真波形怎么分析和换算频率直接把数据读出来数波形是最直观的验证方法。100MHz时钟输出1MHz正弦波意味着每100个时钟周期输出一个完整正弦周期。在Vivado Simulator里打开wave窗口添加dds_axis_tdata信号设置进制为Unsigned Decimal然后看它从最低值到最高值再到最低值的周期时标两端相减得到的周期就是精确的时间。如果两峰之间刚好约1us就能确认输出是1MHz。用文本落盘的好处是可以做定量分析。我把dds_out.txt导入MATLAB后跑一段FFT脚本看频谱data load(dds_out.txt); fs 100e6; N 8192; data data(1:N); X fft(data - mean(data), N); f (0:N-1) * fs / N; mag 20 * log10(abs(X) / N eps); plot(f(1:N/2), mag(1:N/2)); xlabel(Frequency (Hz)); ylabel(Magnitude (dBFS));跑完能清晰地看到在1MHz附近有一个明显的主峰频谱上基波上方的杂散峰在合适大小的位置说明IP核内部已经帮我们处理好了相位截断问题。第一次看到这个结果时对比之前手写方案频率精度和杂散指标确实省心不少。4.4 上板用ILA实测波形仿真通过后就是上板实测。我一般会在工程里添加ILA调试核把DDS输出数据和tvalid信号接入ILA。触发条件设成dds_axis_tvalid上升沿采样深度设成65536确保能抓取完整波形。比特流生成后烧进FPGA通过JTAG连接Vivado Hardware Manager打开ILA运行触发后就能看到真实的波形数据。抓到的数据可以直接导出成CSV文件用相同脚本做FFT分析验证板上真实输出是否与仿真一致。实测中频率误差通常只有0.001Hz级别这在仪器仪表场景已经非常好用了。上板时注意一点如果DDS输出要外接到DACDAC的采样时钟必须和DDS核的时钟同源或经过同步处理否则会产生周期性采样抖动表现为频谱上出现额外杂散。这个问题在本项目中不明显但在实际系统和高速场景下会很严重。5. 常见问题与排查技巧速查DDS核虽然好用但用久了总会遇到各种奇怪问题。这里列几个有代表性的问题都是我实际调试过程中踩过的整理成速查表。5.1 输出异常类问题现象是仿真时输出全为0或者数据一直不变。先别怀疑IP核配置优先检查aclk时钟是否真正产生了仿真里是否有initial语句初始化时钟。我在第一次使用DDS核时曾因为testbench里只写了always #5 clk ~clk忘了给初始值导致前一段仿真时钟是未知态输出自然全是X或0。建议仿真的时钟生成语句固定写reg clk 0;从0初始化开始翻转。还有一个常见问题使用Programmable PINC时没有在s_axis_config接口上给有效数据。IP核配置成动态频率后如果主机不主动发送配置数据输出频率保持为0或默认值看起来就像是DDS没工作。这时候要检查config接口的tvalid信号是否拉高以及写入的PINC值是否合法。5.2 频率和频谱类问题输出频率偏差时优先看PINC计算是否基于“系统时钟频率”而不是“DAC采样率”。有的人配置时钟填了200MHz但实际DDS核接的时钟是100MHz导致输出频率翻倍。所以每次配置IP核前一定要确认System Clock和实际驱动时钟一致。频谱杂散明显抬升时通常和噪声整形选项有关。如果选None相位截断造成的杂散会直接体现在频谱上选Auto之后抖动把杂散能量扩散成底噪主峰周围会更干净。有人担心抖动会抬高噪声底确实有这个代价但信号源应用里杂散峰比噪声底更讨厌所以优先选Auto。5.3 时序、布线和比特流问题Implement Design变红、比特流生成失败是群里问得最多的问题。DDS核本身逻辑并不复杂很多情况下问题出在时钟约束上顶层入口时钟没有绑定物理引脚、没有创建生成时钟约束导致工具无法分析IP核内部的时序路径。解决办法是检查综合后的Timing Summary看是否有未约束时钟的WARNING然后根据实际板卡时钟芯片的输出引脚添加XDC约束。还有一种情况是报类似DRC RTSTAT-2的错误。第一次遇到时我也一脸懵后来发现这类报错在布线阶段出现多数原因是局部拥塞或约束冲突。排查思路是先看DRC Summary报告定位到报错的规则名和线网再顺着报错位置往回追是哪一个信号。通常情况下把跨时钟域的异步信号用同步器或FIFO隔离就能解决大部分布线和DRC问题。如果工程里同时用了多个IP核尤其是有DDR、MIG之类的大IP时DDS核的布局位置可能不理想时序收敛会更困难。可以考虑给DDS核加Pblock约束或者把DDS核单独放到一个面积充足的区域实测对时序改善很明显。5.4 和外部DAC、ADC对接的坑DDS输出数据是有符号补码格式而很多DAC接口希望输入无符号二进制数或者带直流偏置的信号。对接时最简单的办法是把输出数据加上32768偏置转成无符号格式再送给DAC。FPGA里做这个只需要一个加法器注意溢出处理就行。另一个坑是通道宽度不匹配。DDS输出16位DAC输入12位直接截掉低4位即可但截位会引入少量量化噪声如果想要更平滑的频谱可以用一个低通滤波器IC或者数字滤波器做平滑。数字滤波器的复杂度和输出位宽直接相关工程初期就要算好位数否则后期扩展很麻烦。如果后续要把波形送给STM32的ADC采集对比那就需要注意共地问题和电平范围匹配FPGA端输出不要超过STM32 ADC的输入电压范围否则采集到的波形顶部会被削掉。6. 一点个人体会和后续扩展用过DDS核之后我已经很少再手写查找表了。但我的体会是用IP核不等于可以不懂原理。FTW公式、相位截断、杂散来源这些概念在配置界面时未必能直接看到但一旦输出异常排查方向还是要靠这些原理知识。熟练使用工具同时保持对底层机制的理解这两件事不矛盾。这个项目后续做扩展也很自然。比如把PINC改成Programmable模式通过AXI接口动态控制输出频率就能做成一个简易扫频仪开启Phase Offset功能可以输出两路有固定相位差的正弦波用来做正交信号源配合DAC和运放电路还能直接变成模拟信号发生器。DDS核只是一个起点它把最繁琐的波形生成部分解决掉了留给你的是更有价值的系统设计部分。