基于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^N,N是相位位宽)上向前移动固定的“步长”。这个步长,专业术语叫“频率控制字”(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 Options:
- Configuration 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”模式用于产生特定模式的波形,我们不涉及。
Parameters:
- Phase 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)的引脚就不会生成,可以节省资源。
- Phase Width:相位位宽。这里默认是
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的信号,根据公式计算频率控制字FTW:FTW = (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)。
- 此时计算FTW:
FTW = (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 <= 10'd0; clk_100K <= 1'b0; end else if (cnt == 10'd499) begin // 计数到499,完成一个半周期,翻转时钟,计数器归零 cnt <= 10'd0; clk_100K <= ~clk_100K; end else begin // 计数器加1 cnt <= cnt + 1'b1; 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 <= 16'd0; end // 判断是否累加到最大值附近。FTW=655,当累加值超过65535(16‘hFFFF)时,回绕到0。 // 这里使用小于判断,避免在临界值处出现比较错误。 else if (phase_tdata < 16'hFFFF) begin // 每个100kHz时钟周期,相位增加655(即FTW) phase_tdata <= phase_tdata + 16'd655; end else begin // 当相位值达到或超过最大值时,归零,实现周期性累加。 // 注意:由于FTW不一定能被2^N整除,直接加可能导致溢出,这里用条件判断更安全。 phase_tdata <= 16'd0; end end // --- 第三部分:实例化DDS Compiler IP核 --- // dds_compiler_0 是我们在IP Catalog中配置并生成的模块名 dds_compiler_0 dds_inst ( .aclk (clk_100K), // DDS工作时钟,接我们分频得到的100kHz .s_axis_phase_tvalid (1'b1), // 相位数据有效信号,恒为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^9=512>500)。这里用了10位
[9:0],更宽裕。计数比较值是499,因为从0开始计数。 - 相位累加器溢出处理:这是最容易出错的地方。
phase_tdata是16位,最大值是65535(16‘hFFFF)。FTW=655,累加若干次后必然会超过65535。代码中通过if (phase_tdata < 16'hFFFF)进行判断,当将要溢出时,直接归零。这种处理方式简单有效,能保证相位连续循环。也可以利用Verilog的位自动溢出特性(phase_tdata <= phase_tdata + 16'd655;),但显式判断逻辑更清晰。 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 = 1'b0; // 开始时复位有效 clk_100M = 1'b0; // 等待10ns后,释放复位 #10; rst_n = 1'b1; // 运行足够长时间的仿真,以观察多个周期的正弦波 // 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的数据格式要求。
- 没有波形:检查DAC模块是否供电、配置是否正确;检查FPGA引脚约束是否正确;用示波器测量一下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],然后用它来替代代码中固定的16'd655。
always @(posedge clk_100K or negedge rst_n) begin if (!rst_n) begin phase_tdata <= 16'd0; end else if (phase_tdata < 16'hFFFF) begin phase_tdata <= phase_tdata + freq_word; // 动态频率控制字 end else begin phase_tdata <= 16'd0; 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核配置的一个选项,到代码里一个计数器的比较值,再到仿真时的一个显示设置,任何一个细节的忽略都可能让你卡上半天。把原理吃透,把步骤理清,再动手去实践和验证,这个过程本身带来的成就感,远比最后屏幕上那个跳动的正弦波要大得多。下次如果你需要产生更复杂的调制信号,或者多路不同频率的波形,不妨试试在这个框架上继续搭建。
