基于FPGA与DDS IP核实现1kHz正弦波信号生成:原理、配置与工程实践

发布时间:2026/6/7 12:09:16

基于FPGA与DDS IP核实现1kHz正弦波信号生成:原理、配置与工程实践 1. 项目概述与核心思路最近在实验室接手一个信号源相关的项目需要生成一个1kHz的正弦波。考虑到FPGA在数字信号处理上的灵活性和实时性我决定用Xilinx的Vivado工具调用DDS Compiler IP核来实现。这个想法听起来挺直接的不就是配置个IP、写点代码嘛但实际动手才发现从系统时钟分频到相位累加器的计算再到仿真波形的正确显示每一步都有不少细节需要注意。折腾了差不多四天踩了好几个坑才终于看到示波器上那个漂亮的正弦波。这篇文章我就把整个实现过程、背后的原理以及那些容易出错的地方详细拆解一遍希望能给正在入门FPGA数字信号处理的你省点时间。简单来说这个项目的目标就是在Xilinx的Zedboard开发板上用Vivado 2015.4其他版本思路类似生成一个频率为1kHz、幅度量化为8位有符号数的正弦波信号。整个流程会涉及新建工程、配置DDS IP核、编写包含分频和相位控制的顶层模块、编写测试文件进行仿真验证这几个核心环节。无论你是刚开始接触FPGA的学生还是需要快速实现一个DDS功能的工程师这套方案都可以直接拿来参考。2. DDS核心原理与IP核选型解析2.1 DDS是如何“合成”波形的在动手之前我们得先搞清楚DDS直接数字频率合成到底是怎么工作的。你可以把它想象成一个非常聪明且高速的“查表播放器”。它的核心是一个相位累加器。这个累加器就像一个不断前进的指针每个时钟周期它都会在预先定义好的一个“圆周”通常是2^NN是相位位宽上向前移动固定的“步长”。这个步长专业术语叫“频率控制字”Frequency Tuning Word, FTW。步长越大指针跑完一圈的速度就越快输出的波形频率也就越高。它们之间的关系就是那个核心公式Fout (FTW * Fclk) / 2^N。相位累加器输出的值是一个代表当前相位角度的数字。但这个数字本身不是我们想要的波形幅度。怎么办呢这时候就需要一张“波形表”Sine/Cosine Look-Up Table, LUT。这张表里预先存储好了一个完整周期正弦波或余弦波在不同相位点对应的幅度值。DDS的工作就是根据相位累加器给出的“地址”相位值去这张表里“查找”Look-Up出对应的幅度值然后输出。所以DDS输出的信号在时域上是离散的幅度上也是量化的但它能非常精确、快速地产生我们想要的频率。注意DDS的输出频率分辨率由系统时钟和相位位宽共同决定。分辨率 Fclk / 2^N。这意味着在时钟和位宽固定的情况下我们无法产生任意频率的信号只能产生其整数倍分辨率的频率。但这对于大多数应用来说精度已经足够高。2.2 为什么选择Xilinx的DDS Compiler IP核在Vivado里实现DDS功能主要有两种路子一是自己用Verilog从头写相位累加器和ROM查表模块二是调用官方提供的DDS Compiler IP核。我强烈推荐后者尤其是对于初学者和追求开发效率的项目。自己写代码固然能加深理解但你需要处理很多细节ROM表的生成确保没有相位误差、有符号数的处理、流水线时序优化、以及可能需要的抖动Dithering技术来改善无杂散动态范围SFDR。这些对于新手来说挑战不小。而Xilinx的DDS Compiler IP核是一个经过高度优化和验证的软核它帮你封装了所有复杂逻辑。你只需要通过图形界面配置几个参数它就能生成一个高性能、可综合的DDS模块。它支持正弦、余弦输出可配置输出位宽、相位增量类型甚至内置了泰勒级数校正Taylor Series Corrected模式来提供更高的SFDR。对于我们生成1kHz正弦波这个需求它是最快、最稳的选择。3. Vivado工程创建与DDS IP核详细配置3.1 工程创建与环境准备首先打开Vivado 2015.4点击“Create Project”开始。这里步骤比较常规但有几个关键点项目名称与路径取个有意义的英文名比如dds_sine_1khz路径不要有中文和空格。项目类型选择“RTL Project”因为我们是从RTL设计开始。添加源文件这一步可以先跳过我们稍后再创建。选择开发板这是最重要的一步在“Default Part”页面不要直接在器件列表里漫无目的地找。最好点击“Boards”选项卡然后在搜索框里输入“Zedboard”。如果能找到并选中它Vivado会自动为你设置好对应的芯片型号XC7Z020-CLG484-1和所有相关的板级约束后续会省很多事。如果“Boards”列表里没有你就需要手动在Parts里根据芯片型号选择。后续的页面都保持默认直到完成工程创建。3.2 DDS Compiler IP核的深度配置工程创建好后在左侧的“Flow Navigator”中找到并点击“IP Catalog”。在搜索框输入“DDS”就能看到“DDS Compiler (6.0)”这个IP核双击它。弹出的配置窗口有很多选项卡我们逐一来看关键配置Component Name可以保持默认dds_compiler_0或者改成你喜欢的名字比如dds_sine_1k。Configuration OptionsConfiguration Mode选择“Sin and Cos LUT only”。这是最常用的模式只使用查找表来产生正余弦波资源消耗相对较少。其他模式如“Phase Generator and SINCOS LUT”会单独输出相位我们用不上。“Taylor Series Corrected”能提供更好的性能但消耗更多资源1kHz信号不需要这么高的要求。System Clock先填100单位选MHz。这是我们分频前的系统主时钟。注意IP核内部会使用这个频率进行一些计算但实际的输入时钟aclk端口接什么由我们后续的代码决定。Number of Channels填1。我们只需要一个通道的正弦波。Mode of Operation选择“Standard”即可。“Rasterized”模式用于产生特定模式的波形我们不涉及。ParametersPhase Width相位位宽。这里默认是16。这个值非常重要请务必记下。它决定了频率分辨率分辨率 Fclk/2^16和相位累加器的最大计数值。16位对于很多应用是平衡了精度和资源的一个常用值。Output Width输出数据位宽。根据项目要求AD输入需要8位所以这里设为8。这表示输出的正弦波幅度将被量化为256个等级-128 到 127。Phase Increment相位增量编程性。选择“Streaming”。这意味着我们通过一个AXI-Stream接口s_axis_phase_tdata来动态地、每个时钟周期提供相位增量值。这给了我们最大的灵活性可以在运行时改变频率。如果选择“Fixed”则需要在配置时写死一个增量值频率就不可变了。Output Selection因为我们只需要正弦波所以勾选“Sine”即可。余弦输出Cosine的引脚就不会生成可以节省资源。Detailed Implementation和Summary选项卡可以浏览一下确认配置信息然后点击“OK”。Vivado会生成IP核并提示你“Generate Output Products”和“Create a HDL Wrapper”通常都选择默认或“Generate”即可。这样IP核的源文件就添加到你的工程里了。4. 顶层模块设计分频与相位控制逻辑配置好IP核只是准备好了“播放器”我们还需要为它提供正确的“转速”时钟和“乐谱翻页指令”相位增量。这就是顶层模块dds_top要做的事。4.1 系统时钟分频的必要性与计算Zedboard的系统时钟是100MHz。如果我们直接把100MHz接到DDS IP核的aclk上想要产生1kHz的信号根据公式计算频率控制字FTWFTW (Fout * 2^N) / Fclk (1000 * 65536) / 100,000,000 ≈ 0.65536这是一个小于1的小数而我们的相位增量接口phase_tdata是16位整数。如果我们直接给0或1要么输出直流频率为0要么输出一个频率为100MHz/65536 ≈ 1525Hz的信号无法精确得到1kHz。实操心得直接使用过高系统时钟的DDS为了产生低频信号FTW会非常小量化误差会很大导致输出频率误差大甚至因为FTW被截断为0而无法输出信号。因此对系统时钟进行分频降低DDS的工作时钟是产生精确低频信号的常用技巧。我选择分频到100kHz。为什么是100kHz它远高于我们需要的1kHz满足奈奎斯特采样定理理论上大于2kHz即可但实际要高很多以保证波形质量。它是一个整数分频容易实现100MHz / 100kHz 1000分频系数为1000。此时计算FTWFTW (1000 * 65536) / 100,000 655.36。取整后为655。产生的实际频率为Fout_real (655 * 100,000) / 65536 ≈ 999.7 Hz误差仅为0.03%完全满足一般需求。4.2 Verilog代码实现详解下面是dds_top.v的完整代码我加了详细注释timescale 1ns / 1ps // 时间单位和精度 module dds_top( input rst_n, // 低电平有效的全局复位信号 input clk_100M, // 系统输入时钟100MHz output data_tvalid, // DDS输出数据有效信号IP核产生通常恒为高 output [7:0] data_tdata // DDS输出的8位有符号正弦波数据 ); // --- 第一部分100MHz 到 100kHz 时钟分频 --- // 分频系数 100MHz / 100kHz / 2 500 // 因为我们要产生占空比50%的时钟所以计数器数到499后翻转 reg [9:0] cnt; // 10位计数器最大可数到1023足够计500次 reg clk_100K; // 分频产生的100kHz时钟 always (posedge clk_100M or negedge rst_n) begin if (!rst_n) begin // 复位时计数器清零时钟信号置低 cnt 10d0; clk_100K 1b0; end else if (cnt 10d499) begin // 计数到499完成一个半周期翻转时钟计数器归零 cnt 10d0; clk_100K ~clk_100K; end else begin // 计数器加1 cnt cnt 1b1; end end // --- 第二部分相位累加器生成送给DDS的相位增量 --- // 相位累加器位宽必须与DDS IP核配置的Phase Width一致这里是16位 reg [15:0] phase_tdata; always (posedge clk_100K or negedge rst_n) begin if (!rst_n) begin // 复位时相位累加器清零 phase_tdata 16d0; end // 判断是否累加到最大值附近。FTW655当累加值超过6553516‘hFFFF时回绕到0。 // 这里使用小于判断避免在临界值处出现比较错误。 else if (phase_tdata 16hFFFF) begin // 每个100kHz时钟周期相位增加655即FTW phase_tdata phase_tdata 16d655; end else begin // 当相位值达到或超过最大值时归零实现周期性累加。 // 注意由于FTW不一定能被2^N整除直接加可能导致溢出这里用条件判断更安全。 phase_tdata 16d0; end end // --- 第三部分实例化DDS Compiler IP核 --- // dds_compiler_0 是我们在IP Catalog中配置并生成的模块名 dds_compiler_0 dds_inst ( .aclk (clk_100K), // DDS工作时钟接我们分频得到的100kHz .s_axis_phase_tvalid (1b1), // 相位数据有效信号恒为1表示数据一直有效 .s_axis_phase_tdata (phase_tdata[15:0]), // 输入的16位相位增量值 .m_axis_data_tvalid (data_tvalid), // 输出数据有效信号 .m_axis_data_tdata (data_tdata[7:0]) // 输出的8位正弦波数据 ); endmodule关键点解析与避坑指南分频计数器位宽计算分频系数为500需要至少9位计数器2^9512500。这里用了10位[9:0]更宽裕。计数比较值是499因为从0开始计数。相位累加器溢出处理这是最容易出错的地方。phase_tdata是16位最大值是6553516‘hFFFF。FTW655累加若干次后必然会超过65535。代码中通过if (phase_tdata 16hFFFF)进行判断当将要溢出时直接归零。这种处理方式简单有效能保证相位连续循环。也可以利用Verilog的位自动溢出特性phase_tdata phase_tdata 16d655;但显式判断逻辑更清晰。s_axis_phase_tvalid信号这个AXI-Stream信号必须有效DDS才会处理输入的数据。因为我们每个时钟周期都提供有效的相位数据所以直接接高电平1‘b1。信号连接确保将分频后的clk_100K连接到IP核的aclk。千万不能接错否则DDS将以错误的频率工作。5. 测试仿真与结果分析设计完成之后必须通过仿真来验证功能是否正确然后再上板测试。5.1 测试平台Testbench编写创建一个名为sim_dds_tb.v的仿真文件。timescale 1ns / 1ps module sim_dds_tb(); // 声明与被测模块dds_top连接的信号 reg rst_n; reg clk_100M; wire data_tvalid; wire [7:0] data_tdata; // 实例化被测模块 dds_top u_dds_top ( .rst_n (rst_n), .clk_100M (clk_100M), .data_tvalid(data_tvalid), .data_tdata (data_tdata) ); // 生成100MHz时钟周期10ns占空比50% always #5 clk_100M ~clk_100M; // #5表示延迟5个时间单位ns // 初始化与测试流程 initial begin // 初始化信号 rst_n 1b0; // 开始时复位有效 clk_100M 1b0; // 等待10ns后释放复位 #10; rst_n 1b1; // 运行足够长时间的仿真以观察多个周期的正弦波 // 1kHz波形周期是1ms。我们仿真10ms可以看到10个完整周期。 #10_000_000; // 10,000,000 ns 10 ms // 停止仿真 $stop; end endmodule5.2 Vivado仿真设置与波形查看技巧运行仿真在Vivado左侧“Flow Navigator”中点击“Simulation” - “Run Simulation” - “Run Behavioral Simulation”。添加波形仿真启动后在仿真窗口的“Scope”面板找到测试平台下的实例u_dds_top将其中的信号特别是clk_100M,clk_100K,data_tdata拖到波形窗口。关键设置——将data_tdata显示为模拟波形在波形窗口中右键点击data_tdata信号。选择“Waveform Style” - “Analog”。在弹出的“Analog Settings”对话框中通常保持默认设置即可。这一步会将数字总线值转换成连续的模拟波形显示这样才能看到正弦波形状。重新运行与测量点击工具栏的“Restart”和“Run All”重新仿真。仿真结束后使用波形窗口的测量工具如光标测量data_tdata波形的周期。如何测量周期找到两个相邻的、完全相同的相位点比如都是从负到正的过零点测量它们之间的时间差。这个时间差应该接近1ms对应1kHz。由于我们使用了取整的FTW实际周期可能是1.0003ms左右与理论计算吻合。仿真避坑大总结仿真时间不够长这是新手最常见的问题。1kHz信号周期是1ms仿真1us只能看到千分之一不到的波形看起来就是一条线。一定要把仿真时间设置得足够长比如10ms或更长。没有设置为模拟显示data_tdata是8位数字总线默认以二进制或十六进制显示你看不到正弦波形状。必须将其设置为“Analog”显示模式。数据格式误解DDS IP核输出的data_tdata默认是二进制补码形式的有符号数。在波形窗口查看其数值时它会以十进制有符号数如-128, 0, 127显示。这也是为什么它能正确显示正负幅度的原因。复位信号处理确保测试平台中复位信号rst_n有足够长的低电平时间比如至少一个时钟周期让所有寄存器都能正确初始化。6. 上板验证与调试心得仿真通过后就可以生成比特流文件下载到Zedboard上进行实际测试了。这一步需要用到约束文件XDC。6.1 引脚约束与时钟约束创建一个约束文件如zedboard.xdc主要包含两部分时钟引脚约束将clk_100M信号分配到Zedboard的板载100MHz时钟晶振对应的FPGA引脚上。# 系统时钟 100MHz set_property PACKAGE_PIN Y9 [get_ports clk_100M] set_property IOSTANDARD LVCMOS33 [get_ports clk_100M] create_clock -period 10.000 -name sys_clk -waveform {0.000 5.000} [get_ports clk_100M]复位引脚约束将rst_n信号分配到一个拨码开关或按钮上方便控制。# 复位信号连接至SW0开关低电平复位 set_property PACKAGE_PIN G15 [get_ports rst_n] set_property IOSTANDARD LVCMOS33 [get_ports rst_n]输出信号约束将data_tdata的8位信号分配到PMOD接口或者LED上用于简单观察。如果要接高速DAC或ADC需要根据具体模块的引脚分配。这里为了简单可以先分配到LED观察其变化虽然频率太快人眼看不到但可以验证信号是否活跃。# 将低4位数据分配到LED上仅用于观察活动非实际波形输出 set_property PACKAGE_PIN T22 [get_ports {data_tdata[0]}] set_property IOSTANDARD LVCMOS33 [get_ports {data_tdata[0]}] # ... 类似地约束 data_tdata[1] 到 data_tdata[3]6.2 实际测量与工具使用真正的波形输出需要借助数模转换器DAC。Zedboard板载的音频编解码器Audio CODEC或者通过PMOD接口外接一个高速DAC模块如ADI的PMOD DA2都可以。使用Audio CODEC需要编写额外的I2C配置代码和I2S数据发送代码将data_tdata按照I2S协议发送给音频芯片。这涉及另一个复杂模块初期验证可以先用DAC模块。使用PMOD DAC相对简单。将data_tdata和data_tvalid以及时钟clk_100K连接到DAC模块的对应引脚并编写一个简单的并行数据发送模块。DAC模块会直接将数字量转换为模拟电压输出。上板调试心得先静态测试下载比特流后先不接示波器。通过拨动复位开关观察分配了data_tdata的LED是否在快速闪烁尽管肉眼分辨不出1kHz但能看到亮暗变化这至少说明逻辑在运行。示波器测量将DAC的输出接到示波器探头。设置示波器为直流耦合垂直档位合适比如500mV/div水平时基调到1ms/div左右。可能遇到的问题没有波形检查DAC模块是否供电、配置是否正确检查FPGA引脚约束是否正确用示波器测量一下DAC的输入时钟clk_100K是否存在频率是否为100kHz。波形频率不对如果频率偏差很大回头检查分频计数器逻辑和相位累加器FTW计算是否正确。可以用示波器测量clk_100K的实际频率进行反推。波形畸变可能是DAC的参考电压或输出负载不匹配。确保DAC工作在正确的电压范围内。对于8位DAC输出范围通常是0-Vref而我们的data_tdata是有符号数-128~127可能需要一个偏移处理加128将其转换为0-255的无符号数具体要看DAC的数据格式要求。7. 性能优化与扩展思考基本的1kHz正弦波产生后我们可以从这个项目出发思考更多可能。7.1 如何提高输出频率精度我们之前因为FTW取整655产生了约0.03%的频率误差。如果对精度要求极高怎么办增加相位累加器位宽N将DDS IP核的“Phase Width”从16位增加到24位或32位。这样频率分辨率会急剧提高Fclk/2^24。在同样的100kHz时钟下FTW (1000 * 2^24) / 100,000 167772.16取整误差的影响微乎其微。代价是消耗更多的FPGA资源查找表更大。使用更高精度的FTW我们的相位增量phase_tdata是整数。如果DDS IP核支持“Fixed”模式下的浮点或高精度定点数配置可以直接输入更精确的值。但在“Streaming”模式下我们可以通过相位累加器位宽扩展来实现。例如使用一个32位的寄存器phase_acc[31:0]进行累加每次加FTW_extended (Fout * 2^32) / Fclk。然后将phase_acc的高16位phase_acc[31:16]作为phase_tdata送给16位接口的DDS IP核。这相当于在内部使用了更高精度的累加器。7.2 如何动态改变输出频率这是我们选择“Streaming”模式相位增量的优势所在。我们不需要修改代码重新综合只需要在运行时改变输入给DDS的phase_tdata值即可。可以在顶层模块增加一个输入端口freq_word[15:0]然后用它来替代代码中固定的16d655。always (posedge clk_100K or negedge rst_n) begin if (!rst_n) begin phase_tdata 16d0; end else if (phase_tdata 16hFFFF) begin phase_tdata phase_tdata freq_word; // 动态频率控制字 end else begin phase_tdata 16d0; end end这样通过外部逻辑如处理器通过AXI总线或另一个模块改变freq_word的值就能实时调整输出正弦波的频率。这就是DDS广泛应用于通信和信号调制的基础。7.3 资源消耗与时钟考量在Vivado实现后打开“Project Summary”或运行“Report Utilization”可以查看这个设计消耗的FPGA资源LUT、FF、DSP、BRAM。一个基本的16位相位、8位输出的DDS IP核加上分频器在Artix-7上资源占用通常很小。关于时钟我们采用了寄存器分频产生的clk_100K。在高速或对时钟质量要求高的系统中更推荐使用时钟管理单元如MMCM或PLL来产生精确、低抖动的分频时钟。但对于100kHz这样的低频时钟寄存器分频简单可靠完全可行。折腾完这个项目我最深的体会是FPGA开发就像搭积木但每块积木的接口和特性都必须摸得门儿清。从IP核配置的一个选项到代码里一个计数器的比较值再到仿真时的一个显示设置任何一个细节的忽略都可能让你卡上半天。把原理吃透把步骤理清再动手去实践和验证这个过程本身带来的成就感远比最后屏幕上那个跳动的正弦波要大得多。下次如果你需要产生更复杂的调制信号或者多路不同频率的波形不妨试试在这个框架上继续搭建。

相关新闻