基于DDS原理的正弦波信号发生器设计与实现

📅 2026/7/31 5:39:34 👁️ 阅读次数 📝 编程学习
基于DDS原理的正弦波信号发生器设计与实现

1. 项目缘起:为什么从DDS开始做信号源?

做硬件开发或者电子设计的朋友,手里总得有个信号源。无论是调试模拟电路、测试ADC性能,还是验证通信算法,一个稳定、参数可调的正弦波信号都是刚需。市面上的成品信号发生器功能强大,但价格不菲,对于学生、爱好者或者初创团队来说,往往是一笔不小的开销。更重要的是,成品仪器是一个“黑盒”,你只知道它能输出什么,却很难深入理解它“为什么”能这样输出,以及其性能的边界在哪里。

于是,自己动手做一个信号发生器的想法就冒出来了。在众多技术方案中,直接数字频率合成(DDS)以其高频率分辨率、快速频率切换和相位连续可调等优点,成为了数字信号源设计的首选核心技术。你可能在各类电赛题目、FPGA学习项目或者开源硬件社区里频繁看到它的身影。DDS听起来高大上,但其核心思想却非常直观:它就像一个在数字域预先存储好波形数据的“播放器”,通过控制“播放速度”和“起始位置”来改变输出波形的频率和相位。

这个系列文章,我就打算从头开始,手把手实现一个基于DDS原理的正弦波形发生器,并且要实现幅值、频率、相位三个关键参数的可调。这是第一部分,我们将聚焦于理解DDS的核心原理,并搭建一个最基础的、固定参数的DDS信号发生器模型。把地基打牢了,后续的调参、优化、性能提升才能顺理成章。

2. DDS核心原理拆解:数字域的“波形播放器”

要动手做,先得弄明白DDS到底是怎么工作的。我们可以用一个非常生活化的类比来理解:想象你要播放一段音乐。

  1. 波形存储器(ROM):这相当于你的音乐文件(比如一首歌的WAV文件)。对于DDS正弦波发生器,这个“文件”里存储的是一个完整正弦周期(0到2π)的幅度采样值。我们提前以很高的精度(比如用8位、10位或12位的数字量)把这个正弦波“描”出来,存进一块只读存储器里。这块ROM就是我们的“正弦波表”。
  2. 相位累加器:这是DDS的“心脏”,决定了播放速度。它是一个位宽很宽(比如32位)的加法器。每个时钟周期,它都会累加一个固定的值,这个值叫做频率控制字(Frequency Tuning Word, FTW)。你可以把相位累加器想象成音乐播放器的“播放指针”,而FTW就是指针每次向前移动的“步长”。步长越大,指针跑得越快,遍历完整个波形表(对应相位从0到2π)所需的时间就越短,输出的波形频率自然就越高。
  3. 相位-幅度转换:相位累加器的输出是一个不断增长的相位值(比如一个32位的数)。但这个值不能直接用来输出波形,我们需要把它转换成ROM的地址。通常,我们会取相位累加器输出值的高若干位(比如高10位)作为ROM的查找地址。因为ROM里存储的是整个周期的波形,相位值的高位就对应着当前相位在波形周期中的位置。
  4. 数模转换器(DAC):ROM根据地址输出对应的幅度数字量(比如一个8位的数)。这个数字量需要经过DAC转换成模拟电压,才能得到我们最终需要的模拟正弦波。DAC的性能(分辨率、建立时间、线性度)直接决定了输出波形的质量。

这个过程可以用一个简单的公式来概括输出频率F_out与系统时钟频率F_clk、相位累加器位宽N以及频率控制字FTW之间的关系:

F_out = (FTW * F_clk) / (2^N)

从这个公式可以看出:

  • 频率分辨率:当FTW=1时,得到最小输出频率步进,即F_clk / (2^N)。N越大,分辨率越高。一个32位的相位累加器,在100MHz时钟下,分辨率可以达到惊人的0.023 Hz,这是传统模拟振荡器难以企及的。
  • 频率控制:只需改变FTW的数值,就能立即、连续地改变输出频率,且切换相位是连续的,没有传统PLL频率合成可能出现的相位突变或失锁问题。
  • 相位控制:如果在送入ROM查表前,给相位累加器的输出加上一个固定的偏移量(相位控制字, Phase Offset Word, POW),就能直接控制输出波形的初始相位。这是DDS另一个非常强大的特性。

注意:这里存在一个常见的理解误区。有人会觉得DDS的输出频率可以无限高。实际上,根据奈奎斯特采样定理,DDS能无失真产生的最高理论频率是F_clk / 2。在实际工程中,由于DAC和滤波器性能的限制,通常建议输出频率不超过F_clk / 4F_clk / 3,以保证输出波形有较好的纯度。

3. 基础架构设计与关键模块实现

理解了原理,我们就可以开始设计一个最基础的DDS系统了。为了便于理解和仿真,我们首先在算法层面(例如使用Python或MATLAB)进行建模,然后再考虑硬件描述语言(如Verilog/VHDL)的实现。

3.1 系统参数定义

首先,我们需要确定几个核心的系统参数,这些参数将直接影响最终的性能和资源消耗。

  1. 系统时钟频率F_clk:假设我们使用一个100MHz的时钟源。这个频率越高,理论上能输出的信号频率上限也越高。
  2. 相位累加器位宽N:设为32位。这是一个非常常见的选择,能在高时钟频率下提供极高的频率分辨率(100MHz / 2^32 ≈ 0.0233 Hz)。
  3. 波形查找表(ROM)地址位宽A:设为10位。这意味着我们的正弦波表有 2^10 = 1024 个采样点。地址位宽决定了波形的相位精度。10位地址对应360°/1024 ≈ 0.35°的相位分辨率,对于大多数应用已经足够。
  4. 波形幅度数据位宽D:设为8位。即ROM输出的幅度值是0到255(无符号)或-128到127(有符号)。这决定了输出幅度的量化精度,也直接影响后续DAC的选择。
  5. 频率控制字FTW:这是我们的主要调节参数。根据目标输出频率F_out计算:FTW = round(F_out * 2^N / F_clk)。例如,要输出1MHz的正弦波,FTW = round(1e6 * 2^32 / 100e6) = 42949673

3.2 正弦波表(ROM)的生成

ROM的内容是项目的基石。生成波表时,有几个细节需要注意:

  • 量化方式:对于8位DAC,如果它接受的是无符号数据(0-255对应0-Vref电压),那么我们的波表数据应该映射到[0, 255]区间。公式为:data = round(127.5 * sin(phase) + 127.5)。如果DAC接受有符号补码数据,则应映射到[-128, 127]。
  • 存储格式:在硬件描述语言中,我们通常用一个数组或case语句来初始化ROM。在Python建模时,我们可以直接用列表或数组存储。
  • 优化技巧:得益于正弦波的对称性,我们实际上只需要存储1/4周期(0到π/2)的数据,然后通过地址映射来还原整个周期,这样可以节省大量的存储资源。这对于FPGA等资源受限的平台尤其重要。不过在第一版中,为了逻辑清晰,我们可以先实现全周期存储。

下面是一个用Python生成全周期正弦波表的示例代码,它清晰地展示了从相位到幅度的映射过程:

import numpy as np # 系统参数 ROM_ADDR_WIDTH = 10 # 地址位宽 ROM_DATA_WIDTH = 8 # 数据位宽 ROM_DEPTH = 2**ROM_ADDR_WIDTH # 1024 # 生成相位序列,从0到2π(不包含2π),共ROM_DEPTH个点 phases = np.linspace(0, 2*np.pi, ROM_DEPTH, endpoint=False) # 生成正弦波,幅度范围[-1, 1] sine_wave = np.sin(phases) # 量化到无符号8位整数范围 [0, 255] # 先归一化到[0, 1]: (sine_wave + 1) / 2 # 再乘以255并取整 sine_wave_quantized = np.round((sine_wave + 1) / 2 * (2**ROM_DATA_WIDTH - 1)).astype(np.uint8) # 查看前10个数据点 print("相位(弧度):", phases[:5]) print("正弦值:", sine_wave[:5]) print("量化值(0-255):", sine_wave_quantized[:5]) # 可以将其保存为文件,供后续Verilog或C代码初始化使用 # np.savetxt('sine_rom_1024x8.txt', sine_wave_quantized, fmt='%d')

3.3 相位累加器与查表逻辑

这是DDS的数字核心部分。在每个时钟上升沿,相位累加器执行一次累加操作。然后,取累加结果的高位作为ROM的读取地址。

这里有一个关键点:相位累加器是N位(如32位)的,但ROM地址是A位(如10位)。我们通常取相位累加器输出的最高A位(phase_acc[N-1:N-A])作为ROM地址。这是因为最高位代表了相位周期中最大跨度的部分,用它们做地址,相当于把整个32位的相位空间均匀地映射到了1024个点的波表上。这种截断操作会引入微小的相位截断误差,这是DDS输出频谱中产生杂散信号的主要来源之一,在基础版本中我们可以先接受它。

在Python中,我们可以这样模拟一个持续运行的DDS核心:

class SimpleDDS: def __init__(self, f_clk=100e6, n_bits=32, rom_addr_bits=10, rom_data): self.f_clk = f_clk self.n_bits = n_bits self.rom_addr_bits = rom_addr_bits self.rom = rom_data # 之前生成的正弦波表 self.phase_acc = 0 self.ftw = 0 # 频率控制字 def set_frequency(self, f_out): """设置输出频率,计算并更新FTW""" self.ftw = int(round(f_out * (2**self.n_bits) / self.f_clk)) print(f"目标频率 {f_out/1e6:.3f} MHz, 设置 FTW = {self.ftw}") def step(self): """执行一个时钟周期的操作:累加相位,查表输出""" # 相位累加(模拟32位无符号溢出) self.phase_acc = (self.phase_acc + self.ftw) & ((1 << self.n_bits) - 1) # 取高ROM_ADDR_BITS位作为地址 rom_addr = (self.phase_acc >> (self.n_bits - self.rom_addr_bits)) & ((1 << self.rom_addr_bits) - 1) # 查表输出幅度 amplitude = self.rom[rom_addr] return amplitude # 使用示例 dds = SimpleDDS(rom_data=sine_wave_quantized) dds.set_frequency(1e6) # 设置输出1MHz # 模拟运行10个时钟周期 output_samples = [] for i in range(10): output_samples.append(dds.step()) print("前10个输出幅度值:", output_samples)

3.4 从数字到模拟:DAC的选择与考量

DDS的数字部分输出的是离散的数字序列,必须经过DAC才能变为连续的模拟信号。DAC的选择至关重要,它直接决定了输出信号的动态范围、信噪比(SNR)和无杂散动态范围(SFDR)

  • 分辨率:与我们波表的数据位宽匹配,这里至少需要8位DAC。分辨率越高,输出波形的量化噪声越低,波形越光滑。
  • 建立时间:DAC从收到数字输入到输出稳定模拟电压所需的时间。这个时间必须远小于系统时钟周期,否则会严重限制DDS的最高输出频率。例如,100MHz时钟对应10ns周期,DAC的建立时间最好在1-2ns以内。
  • 接口类型:并行接口速度最快,但占用引脚多;串行接口(如SPI)节省引脚,但更新速率可能受限于串行时钟。对于100MHz时钟的8位DDS,并行接口是更稳妥的选择。
  • 输出类型:电流输出型DAC通常需要外接运放构成I-V转换电路;电压输出型DAC则更简单。根据后续电路需求选择。

实操心得:在项目初期,可以选用常见的、资料丰富的DAC芯片,如ADI的AD9708(8位并行)、TI的DAC082S085(8位SPI)。对于高速应用,要特别关注DAC的数据手册中关于“更新速率”和“建立时间”的参数。一个常见的坑是,只看了DAC的分辨率,却忽略了其建立时间无法跟上系统时钟,导致波形严重失真。

4. 系统仿真与性能分析

在真正烧录到FPGA或制作PCB之前,进行充分的仿真是必不可少的。仿真能帮助我们验证逻辑正确性,并初步评估系统性能。

4.1 功能仿真:验证波形正确性

我们可以用Python或MATLAB模拟运行足够多的时钟周期,将输出的数字序列绘制成波形图,并做FFT分析。

import matplotlib.pyplot as plt from scipy.fft import fft, fftfreq # 模拟参数 f_clk = 100e6 sim_cycles = 5000 # 仿真5000个时钟周期 dds.set_frequency(1e6) # 设置1MHz输出 # 运行仿真 samples = [] for _ in range(sim_cycles): samples.append(dds.step()) # 将数字量转换为模拟电压值(假设DAC参考电压Vref=3.3V) vref = 3.3 voltage = [sample / 255.0 * vref for sample in samples] # 绘制时域波形(前200个点) plt.figure(figsize=(12, 4)) plt.subplot(1, 2, 1) plt.plot(voltage[:200]) plt.title('DDS Output Waveform (First 200 samples)') plt.xlabel('Clock Cycles') plt.ylabel('Voltage (V)') plt.grid(True) # 绘制频域频谱 plt.subplot(1, 2, 2) N = len(voltage) T = 1.0 / f_clk # 采样间隔 yf = fft(voltage) xf = fftfreq(N, T)[:N//2] # 取单边频谱 plt.plot(xf[:N//100], 2.0/N * np.abs(yf[0:N//100])) # 只看低频部分 plt.title('Single-Sided Amplitude Spectrum') plt.xlabel('Frequency (Hz)') plt.ylabel('Amplitude') plt.grid(True) plt.tight_layout() plt.show()

通过时域图,我们可以看到生成的正弦波形是否连续、光滑。通过频谱图,我们可以清晰地看到在1MHz处有一个主峰,这就是我们想要的信号。同时,我们也能看到频谱底部的噪声基底以及其他频率的杂散信号。这些杂散主要来源于:

  1. 相位截断误差:因为我们只用了相位累加器的高10位去寻址,丢弃了低22位,这引入了周期性误差。
  2. 幅度量化误差:波表数据只有8位精度,对正弦波幅度进行了量化。
  3. DAC非线性误差:这是实际DAC器件固有的缺陷。

4.2 关键指标初探:SFDR与频率调谐

在仿真中,我们可以初步估算系统的无杂散动态范围(SFDR),即主信号幅度与最大杂散信号幅度的比值,用dB表示。SFDR是衡量信号发生器纯度的一个核心指标。

# 简单的SFDR估算(找到主峰后,寻找次高峰) spectrum = 2.0/N * np.abs(yf[0:N//2]) main_freq_idx = np.argmax(spectrum) # 主信号索引 main_amp = spectrum[main_freq_idx] # 将主信号附近的区域置零,避免主信号影响寻找杂散 mask_width = 10 # 屏蔽主频左右10个频点 spectrum[main_freq_idx-mask_width:main_freq_idx+mask_width] = 0 spur_amp = np.max(spectrum) # 最大杂散幅度 sfdr_db = 20 * np.log10(main_amp / spur_amp) print(f"主信号幅度: {main_amp:.4f} V") print(f"最大杂散幅度: {spur_amp:.4f} V") print(f"估算SFDR: {sfdr_db:.2f} dB")

对于一个8位DDS,其理论SFDR主要由量化噪声决定,大约在6.02 * N + 1.76 ≈ 50 dB附近(N=8)。我们的仿真结果应该与此量级相符。如果远低于此值,可能需要检查波表生成或仿真代码是否有误。

此外,我们可以通过改变FTW来验证频率调谐功能。分别设置FTW对应100kHz、1MHz、10MHz,观察输出波形的周期变化以及频谱主峰的移动,确认频率控制是否准确、连续。

5. 从模型到硬件:FPGA实现要点

软件模型验证无误后,就可以着手用硬件描述语言(以Verilog为例)进行实现了。这是将想法落地的关键一步。

5.1 Verilog模块划分

一个典型的DDS顶层模块可能包含以下子模块:

  • phase_accumulator.v:相位累加器模块。
  • sine_rom.v:存储正弦波表的ROM模块。
  • dds_top.v:顶层模块,例化并连接上述模块,可能还包含控制接口(如AXI-Lite)用于配置FTW。

相位累加器模块示例:

module phase_accumulator #( parameter PHASE_WIDTH = 32 )( input wire clk, input wire rst_n, input wire [PHASE_WIDTH-1:0] ftw_i, // 频率控制字输入 output reg [PHASE_WIDTH-1:0] phase_acc_o // 当前相位输出 ); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin phase_acc_o <= {PHASE_WIDTH{1'b0}}; end else begin phase_acc_o <= phase_acc_o + ftw_i; // 自动溢出,实现周期累加 end end endmodule

正弦ROM模块示例(使用$readmemh初始化):

module sine_rom #( parameter ADDR_WIDTH = 10, parameter DATA_WIDTH = 8, parameter ROM_FILE = "sine_rom_1024x8.hex" // 存储波表数据的文件 )( input wire [ADDR_WIDTH-1:0] addr_i, output reg [DATA_WIDTH-1:0] data_o ); reg [DATA_WIDTH-1:0] rom [0:(1<<ADDR_WIDTH)-1]; initial begin $readmemh(ROM_FILE, rom); // 从文件加载波表数据 end always @(*) begin data_o = rom[addr_i]; end endmodule

顶层连接:

module dds_top #( parameter PHASE_WIDTH = 32, parameter ROM_ADDR_WIDTH = 10, parameter ROM_DATA_WIDTH = 8 )( input wire sys_clk, input wire sys_rst_n, input wire [PHASE_WIDTH-1:0] ftw, output wire [ROM_DATA_WIDTH-1:0] dac_data ); wire [PHASE_WIDTH-1:0] phase_acc; wire [ROM_ADDR_WIDTH-1:0] rom_addr; // 实例化相位累加器 phase_accumulator #( .PHASE_WIDTH(PHASE_WIDTH) ) u_phase_accumulator ( .clk(sys_clk), .rst_n(sys_rst_n), .ftw_i(ftw), .phase_acc_o(phase_acc) ); // 取相位累加器的高位作为ROM地址 assign rom_addr = phase_acc[PHASE_WIDTH-1:PHASE_WIDTH-ROM_ADDR_WIDTH]; // 实例化正弦ROM sine_rom #( .ADDR_WIDTH(ROM_ADDR_WIDTH), .DATA_WIDTH(ROM_DATA_WIDTH) ) u_sine_rom ( .addr_i(rom_addr), .data_o(dac_data) ); endmodule

5.2 综合与实现中的注意事项

  1. 时序约束:必须为系统时钟sys_clk添加正确的时序约束,确保工具能优化关键路径(从相位累加器到ROM输出),以满足100MHz的时钟要求。
  2. ROM的实现方式:在FPGA中,ROM通常会用块存储器(Block RAM, BRAM)来实现。需要确认综合工具是否成功将你的ROM推断为BRAM,因为BRAM的性能和资源利用率远优于用逻辑单元(LUT)搭建的分布式RAM。
  3. 资源评估:一个32位加法器和一个1024x8的BRAM,在主流FPGA上只占用极少资源。这为后续添加幅值控制、相位控制、甚至多通道DDS留下了充足空间。
  4. IO规划dac_data输出端口需要分配到FPGA的引脚上,并连接到外部DAC芯片的数据线。需要根据DAC的数据手册,确认接口电平(通常是LVCMOS)和时序要求(建立/保持时间),并在约束文件中设置正确的set_output_delay

5.3 板级调试第一步:观察DAC输出

将比特流文件下载到FPGA开发板后,第一步不是直接测波形,而是先用逻辑分析仪(或者FPGA的在线逻辑分析仪工具,如Xilinx的ILA)抓取dac_data总线上的数字信号。你应该能看到一个不断变化的8位数据流。将其以模拟波形的方式显示,应该能看到一个初步的正弦波阶梯图。这一步验证了数字逻辑部分工作正常。

接下来,连接示波器到DAC的模拟输出端。你可能会看到:

  • 一个阶梯状的正弦波:这是正常的,因为8位DAC只有256个离散电平。
  • 高频毛刺:在DAC数字码值跳变时产生,这是由于各数据位开关时间不完全同步导致的“闪码”现象。可以在DAC输出后添加一个低通滤波器(也称为重构滤波器或抗镜像滤波器)来平滑波形,滤除高频分量(主要是采样时钟及其谐波附近的分量)。

一个简单的一阶或二阶RC低通滤波器,其截止频率设置为略高于你需要的最高输出频率(例如10MHz),就能显著改善波形质量。这是DDS系统不可或缺的一部分。

至此,一个最基础的、频率可调的DDS正弦波发生器就已经搭建完成了。我们实现了从原理理解、软件建模、算法仿真到硬件(FPGA)实现和初步调试的完整流程。输出频率可以通过改变输入给FPGA的ftw寄存器值来精确控制。在下一部分,我们将在这个稳定的基础上,增加幅值控制相位控制功能,并深入探讨如何优化输出频谱纯度,让这个自制的信号源真正变得实用、好用。