☰
FPGA实战:基于DDS技术的多波形信号发生器设计与实现
2026/10/5 4:28:50 网站建设 项目流程

搞FPGA设计的朋友,十有八九都绕不开DDS(Direct Digital Synthesis,直接数字频率合成)这个经典项目。我最早接触DDS是在做毕业设计那会儿,当时导师丢给我一块老掉牙的Cyclone II开发板,让我做一个能输出1Hz到1MHz正弦波的发生器。那时候对DDS的理解就是“查表”,后来真正啃完相位累加器、频谱纯度、DAC毛刺这些问题,才明白这个看似简单的项目能把FPGA一半的家底都串起来。这篇博文就基于我最近重新整理的一套DDS信号发生器工程,完整复盘一下从原理到RTL代码、再到板级调试的整个流程,把那些容易踩坑的地方一次说清楚。

该项目适合三类人学习:正在准备FPGA课程设计或电赛的在校学生,刚入门Verilog想找小项目练手的开发者,以及想把DDS模块集成到测控系统里的工程师。整个设计不依赖任何IP核,全手写Verilog,逻辑资源占用极小,却能覆盖“相位累加-波形查表-DAC输出-按键调频”的完整链路,可以说是最有性价比的FPGA练手项目之一。

1. 项目概述与设计目标

1.1 为什么选FPGA做DDS而不是单片机

很多初学者第一反应是用STM32做信号发生器,因为STM32的DAC外设配上DMA,似乎也能输出波形。但从实际工程角度对比,FPGA方案的优势非常明显。

STM32实现DDS有个绕不开的痛点:CPU要不停地把采样点写到DAC寄存器里。假设输出1kHz正弦波,每个周期取256个点,那一秒就要执行256,000次DAC写入。虽然用DMA可以减轻CPU负担,但当你想同时调整频率、叠加调制、切换波形时,代码复杂度会急剧上升。更关键的是,SPI或并行总线写DAC存在天然的延迟抖动,输出波形在示波器上会有可见的相位抖动。

FPGA则完全不同。DDS的核心是相位累加器,它是纯组合逻辑加时序逻辑的产物,每个时钟周期稳定递增一次,没有任何中断和总线竞争。当你需要输出更高频率信号时,只要提高系统时钟或扩大查找表吞吐即可。现在的FPGA轻松跑上百MHz时钟,输出10MHz以上的正弦波毫无压力,这已经摸到了射频信号源的边。

从开发效率角度看,FPGA的并行架构天然适合多通道同步输出。同一份DDS代码例化两份,就能得到相位严格同步的双通道输出,这在STM32方案里要做大量同步处理才能约摸达到。所以我的结论是:数字信号发生器的“底座”用FPGA是王道,STM32更适合做上位机通信和人机交互。

1.2 整体系统架构

这套DDS信号发生器采用模块化设计,顶层例化了四个子系统:

  • 按键输入模块:负责读取物理按键状态,做消抖处理,输出频率调节指令。
  • DDS核心模块:由相位累加器和波形ROM组成,输出12位波形幅度数据。
  • 波形选择模块:根据按键输入或拨码开关选择正弦波、方波、三角波、锯齿波。
  • DAC驱动与输出模块:将数字波形数据送入高速DAC,生成模拟信号。

开发平台我用的Xilinx Artix-7系列开发板,具体型号无所谓,只要是主流FPGA都能跑这套代码。DAC选用了AD9708,8位、125MSPS并行接口,对入门来说性能完全够用,而且芯片便宜、资料多。如果你手头只有内置DAC的板卡(比如正点原子达芬奇Pro),也可以把AD9708相关逻辑替换为板上DAC驱动,思路是一致的。

1.3 设计指标设定

做项目不能没目标,我给自己定了几个可以量化的指标:

  • 输出频率范围:100Hz ~ 1MHz,步进100Hz。
  • 可输出波形:正弦波、方波、三角波、锯齿波。
  • 频率切换方式:按键递增/递减,数码管显示当前频率。
  • 时钟方案:开发板50MHz有源晶振,FPGA内部PLL倍频到100MHz作为DDS工作时钟。

这里提一个后来反思过的点:100MHz时钟对应输出1MHz正弦波时,每个周期只有100个采样点,波形勉强能看;如果输出10MHz,每个周期只剩10个点,阶梯感会非常明显。所以要想干净输出更高频率,要么提高主时钟、要么减小查找表间隔。这个权衡我们在2.3节做参数分析时再细聊。

2. DDS原理与核心参数拆解

2.1 相位累加器:DDS的“心”

DDS的原理可以类比成“一个在圆周上反复奔跑的指针,每到一个角度就查表读出该角度对应的波形幅度值”。这个“奔跑的指针”就是相位累加器,它是DDS的灵魂。

相位累加器本质上是一个N位加法器加N位寄存器。每个时钟上升沿,寄存器里的当前相位值增加一个频率控制字FTW(Frequency Tuning Word),然后回写寄存器。当累加值溢出(超过2^N)时,自动回卷,这个溢出速度就决定了输出信号的频率。

用公式表达就是:

f_out = FTW × f_clk / 2^N

其中f_clk是DDS工作时钟,N是相位累加器位宽。这公式是整个DDS设计的“第一性原理”,后面所有参数选择都从它出发。

举个例子:f_clk = 100MHz,N = 32,想要输出f_out = 1MHz,那么FTW = 1MHz × 2^32 / 100MHz ≈ 42,949,672(十六进制0x028F5C28)。这个数在Verilog里就是一条参数赋值语句的事。

2.2 波形存储表:把相位翻译成幅度

相位累加器的输出是32位线性递增的相位值,可波形存储器RAM/ROM只有一个有限深度的地址总线,不可能把全部32位都送进去查表。最常用的做法是取相位累加器的高M位作为ROM地址。

假设ROM深度为1024(即2^10),那我们就截取32位相位累加器的高10位作为地址。这意味着ROM里存了1024个点的正弦波幅度值,每个点对应2π/1024的相位间隔。查表后得到的M位幅度数据再送给DAC。

从数学角度看,相位截断引入了相位噪声,但其影响通常不大——因为ROM深度1024已经能把SFDR(无杂散动态范围)做到60dBc以上,对大多数测试场景绰绰有余。真正影响波形质量的反而是DAC的位数和后续滤波电路的设计,这点后面详聊。

2.3 关键参数计算与权衡逻辑

这一节把DDS设计中最核心的参数们串起来,逐个给出计算方法和工程建议。

频率分辨率

从公式f_out = FTW × f_clk / 2^N可以看出,当FTW取最小值1时,输出频率就是DDS的极限分辨能力:

Δf = f_clk / 2^N

以100MHz时钟、32位累加器为例,Δf = 100MHz / 4,294,967,296 ≈ 0.0233Hz。这意味着在理论上,这套系统连0.02Hz的亚赫兹级频率步进都能实现。实际电路里受到DAC位数、时钟精度限制,可能达不到理论值,但ppt级别的频率精度对绝大多数应用已严重冗余。

最高输出频率与采样定律

DDS输出频率的上限受奈奎斯特采样定理约束,理论上不能超过f_clk/2。但工程上有个更严格的经验法则:为保证波形至少有20个采样点,实际最高输出频率通常控制在f_clk/10以内,塞到f_clk/4就是极限操作了。

以100MHz时钟为例:

  • 理论极限:50MHz。
  • 经验推荐:≤10MHz,确保每周期20个采样点。
  • 极限可用:≤25MHz,每周期4个采样点,勉强能看正弦波。

如果你要输出几十MHz频率的射频信号,就需要用GHz级别的DAC配合超高速时钟,这就属于高端仪器仪表范畴了。

ROM深度的选择

ROM深度M决定相位量化步长。深度越大,相位量化误差越小,SFDR越好,但消耗的存储资源也越多。

ROM深度相位分辨率SFDR约值存储资源(8bit数据)
2561.4°48dBc256B
10240.35°60dBc1KB
40960.088°72dBc4KB
163840.022°84dBc16KB

我看到不少初学者一上来就上16384深度,觉得“波形越细越好”。但实际上,8位DAC的量化噪声本底就摆在那里,ROM深度超过4096后对实际输出的波形质量提升微乎其微,白白浪费Block RAM。对入门项目,1024深度是性价比最高的选择;做到4096已经是“豪华配置”。

DAC位数选择

DAC位数决定幅度量化精度。8位DAC动态范围约48dB,10位约60dB,12位约72dB。对于100Hz~1MHz的输出频段,8位DAC已经能输出相当干净的波形,配合后级运放做低通滤波,肉眼几乎看不出量化噪声。

工程上有个原则叫“DAC位数和ROM深度要匹配”:DAC是8位,你用16位ROM数据去查,多出来的低8位纯属浪费;反过来ROM只有8位,DAC用12位,高4位只能填0,白白损失动态范围。最合理的搭配是ROM数据位宽等于DAC位宽,误差不超过1~2位。

3. FPGA实现与代码剖析

3.1 顶层模块与端口设计

先给出顶层模块的端口定义,方便后续代码对照。

module dds_top( input wire clk_50m, // 板上50MHz晶振 input wire rst_n, // 复位,低有效 input wire [1:0] wave_sel, // 波形选择 input wire key_inc, // 频率增加按键 input wire key_dec, // 频率减少按键 output wire [7:0] dac_data, // 8位DAC数据 output wire dac_clk, // DAC时钟(可选) output wire [3:0] seg_sel, // 数码管位选 output wire [7:0] seg_data // 数码管段码 );

这里用了四个按键和一位拨码输入,但其实可以简化为两个按键加一个波形切换按键。核心是DDS模块,界面交互可以根据手上板卡资源灵活调整。

3.2 相位累加器实现

相位累加器是DDS的引擎,实现起来极其精炼:

// 参数定义 parameter N = 32; // 相位累加器位宽 parameter M = 10; // ROM地址位宽,即查找表深度2^M parameter F_CLK = 100_000_000; // 工作时钟频率 // 频率控制字计算:FTW = f_out * 2^N / f_clk // 这里以1kHz为例,实际值由按键增减实时调整 reg [N-1:0] freq_word; // 相位累加器核心 reg [N-1:0] phase_acc; always @(posedge clk or negedge rst_n) begin if (!rst_n) phase_acc <= 32'd0; else phase_acc <= phase_acc + freq_word; end // 截取高位作为ROM地址 wire [M-1:0] rom_addr = phase_acc[N-1:N-M];

注意这段代码里有个关键细节:相位累加器没有清零逻辑,只在溢出时自然回卷。这是DDS能保持相位连续的根本原因——无论频率如何切换,输出波形都不会出现相位跳变。很多新手做频率切换时会习惯性复位累加器,结果示波器上一片毛刺,问题根源就在这里。

3.3 波形查找表实现

ROM部分有两种实现方式:一是使用$readmemh系统任务加载初始化文件,二是直接用case语句做组合逻辑查表。对于1024深度的正弦表,用$readmemh更规范,代码也更干净。

// 声明ROM存储器 reg [7:0] sine_rom [0:1023]; // 加载初始化文件,sine_rom.mem里存1024个十六进制数 initial begin $readmemh("sine_rom.mem", sine_rom); end // 异步读ROM wire [7:0] sine_out = sine_rom[rom_addr];

正弦ROM表的生成不需要手写1024个值,用Python或Matlab生成即可。下面是用Python生成.mem文件的脚本:

import math DEPTH = 1024 WIDTH = 8 with open("sine_rom.mem", "w") as f: for i in range(DEPTH): # 正弦值映射到0~255,中心128 val = int(127.5 + 127.5 * math.sin(2 * math.pi * i / DEPTH)) f.write(f"{val:02x}\n")

生成时注意几点:ROM深度1024对应地址0~1023,数据用十六进制书写,每行一个值,$readmemh要求每行数据以换行分隔。如果你用ModelSim或Vivado仿真,初始化文件路径问题可以这样解决:要么把文件放到工程根目录下,要么用绝对路径。后者在换电脑时容易失效,建议放在工程目录。

3.4 波形选择与按键调频

波形选择逻辑比较简单,用case语句根据wave_sel选出不同波形ROM的输出:

reg [7:0] wave_data; always @(*) begin case (wave_sel) 2'b00: wave_data = sine_out; // 正弦波 2'b01: wave_data = triangle_out; // 三角波 2'b10: wave_data = square_out; // 方波 2'b11: wave_data = sawtooth_out; // 锯齿波 default: wave_data = sine_out; endcase end

方波不需要ROM,直接用相位累加器最高位取反即可得到0和255的跳变;三角波用相位累加器高9位做对称处理;锯齿波直接取相位累加器高8位。这里用少量组合逻辑替代RAM资源,是DDS扩展波形的常见技巧。

按键调频部分需要处理消抖。我之前用过一个很皮实的“计数器延时消抖”方案:

reg [19:0] deb_cnt; reg key_inc_d, key_inc_rise; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin deb_cnt <= 0; key_inc_d <= 1'b1; // 上拉默认高电平 end else begin // 边沿检测,按键按下时key_inc为低 key_inc_d <= key_inc; if (key_inc_d && !key_inc) begin if (deb_cnt == 20'd999999) begin // 延时约10ms deb_cnt <= 0; freq_word <= freq_word + 32'd4295; // 对应100Hz步进 end else deb_cnt <= deb_cnt + 1'b1; end else deb_cnt <= 0; end end

这里有个容易踩的坑:按键消抖的“延时”不是简单让系统等待10ms再响应,而是利用计数器确认电平稳定。如果写成阻塞式延时(比如用#10ms),整个FPGA都会被卡住,信号发生器的其他模块全部停摆,必须避免。

3.5 DAC接口与输出级

AD9708的数据接口非常简单:上升沿锁存8位并行输入,然后转换输出电流信号。FPGA端只需要在时钟上升沿把波形数据锁存到输出引脚上即可:

reg [7:0] dac_data_r; always @(posedge clk or negedge rst_n) begin if (!rst_n) dac_data_r <= 8'd128; // 中点电平 else dac_data_r <= wave_data; end assign dac_data = dac_data_r; assign dac_clk = clk; // 直接用系统时钟驱动DAC

DAC的输出是电流信号,需要经过一个运放转电压信号。我用的是AD8042双运放,做成I/V转换加二阶低通滤波。截止频率设在5MHz左右,可以有效滤除DAC输出的阶梯状谐波。

这里记录一个教训:DAC和运放之间的模拟地与数字地必须单点连接,铺铜时用磁珠或0欧电阻隔开。我第一次画PCB时图省事直接整块铺地,结果输出波形在20MHz附近出现严重的开关噪声毛刺,后来改成单点接地才解决。

4. 仿真与板级调试全记录

4.1 RTL仿真环境搭建

无论代码写得多自信,仿真一步都不能省。我用Vivado自带的xsim做仿真,testbench里例化顶层模块,模拟按键输入并监测DAC输出数据:

module tb_dds_top(); reg clk_50m; reg rst_n; reg [1:0] wave_sel; reg key_inc, key_dec; wire [7:0] dac_data; dds_top uut( .clk_50m(clk_50m), .rst_n(rst_n), .wave_sel(wave_sel), .key_inc(key_inc), .key_dec(key_dec), .dac_data(dac_data) ); initial begin clk_50m = 0; rst_n = 0; wave_sel = 0; key_inc = 1; key_dec = 1; #100 rst_n = 1; #1000 key_inc = 0; // 模拟按键按下 #50 key_inc = 1; // 松开 #10000 $finish; end always #10 clk_50m = ~clk_50m; // 50MHz时钟 endmodule

仿真中最重要的验证点是:观察dac_data是否形成周期性的正弦阶梯波。跑完仿真后把数据导出到文本,用Matlab或Python画波形,能直观看到频率是否正确。比如设FTW对应1kHz,在100MHz时钟下每个周期应输出100,000个采样点,检查数据长度就能验证频率计算对不对。

4.2 波形失真的三类典型案例

把项目过程中实测遇到的波形问题整理成表格,方便读者对照排查:

现象可能原因解决方法
正弦波顶部明显削平ROM数据映射错误,超出DAC满量程检查正弦ROM生成公式,确保最大值不超过255
波形不稳、相邻周期幅度不一致时钟复位异常或相位累加器被误清确认不复位累加器,检查复位信号释放时间
存在高次毛刺脉冲DAC输出数字信号过冲或振铃在DAC数据线上串联33欧姆电阻,抑制反射
频率切换时有短促噪声按键消抖或频率字写入过程产生毛刺FTW更新与累加器时钟域对齐,必要时打一拍
用示波器看高频段波形稀疏输出频率超过时钟1/10经验线提高PLL倍频或限制输出上限频率

第一类问题我最有发言权。第一次生成ROM时写的是int(128 + 128 * sin(...)),最大值被截断到255,结果顶部出现平顶。后来改成int(127.5 + 127.5 * sin(...))才正常。这个“127.5的中点、127.5的振幅”的小细节,直接决定波形是否对称,马虎不得。

4.3 实测数据与调试验证

调试阶段的验证方式分三步走:

先用示波器DC耦合挡观察DAC输出电压范围,确认中点约等于电源电压一半。AD9708输出经I/V转换后,理想中点大约是1.65V(3.3V单电源供电)。如果偏离超过±50mV,就要检查DAC基准电压和运放偏置。

再用AC耦合挡观察波形纯净度。100Hz时示波器上应该是一条完美平滑的正弦曲线;1MHz时波形仍清晰可辨,但每个周期约100个采样点,阶梯效应肉眼可见,这是正常现象。5MHz以上阶梯感明显,波形开始“棱角分明”。

最后测试频率精度:把示波器打到频率测量模式,与设定的FTW理论值对比。我实测过100kHz档,输出显示100.01kHz,偏差不到0.01%,主要来源是板上晶振本身的精度,这个精度已经超过普通模拟信号发生器了。

调试中还发现一个非常容易忽略的问题:如果波形ROM初始化失败(比如.mem文件路径不对),$readmemh会静默失败,输出全是零数据,表现为DAC输出恒定为1.65V直流电平。这时不要怀疑DAC坏了,优先用仿真或在线逻辑分析仪抓一下ROM读取数据,八成是初始化文件没加载成功。

5. 进阶扩展方向

5.1 从单通道到双通道相位可调输出

DDS的并行架构让多路输出扩展极其简单。例化两个DDS核心模块,共享同一个系统时钟,但各自使用独立的相位累加器,两边就能输出完全相同频率的正弦波。如果在此基础上加一个相位控制字,就能实现相位差精确可调的双通道输出。

相位控制字的实现思路:对相位累加器输出做一次“相位偏置加法”,在查表前给相位值加上一个固定的偏移量:

wire [N-1:0] phase_offset = phase_acc + phase_word; wire [M-1:0] rom_addr = phase_offset[N-1:N-M];

这个功能在电机驱动(两相正弦波)、正交混频器(I/Q信号)里非常实用。我后来做的超声波激励电源就用了双通道DDS,相位差可调0~360度,步进0.1度,效果很理想。

5.2 扫频与调制功能扩展

在DDS基础上叠加扫频功能只需让频率控制字随时间线性变化。例如做一个从100Hz扫到10kHz、耗时1秒的对数扫频:每过约97.6微秒把FTW增大一个固定值。FPGA里的方式是用计数器计时,每到一定阈值就累加FTW的增量。注意扫频时相位累加器要保持连续,否则扫频过程会出现相位跳变导致的咔哒声。

幅度调制同样不复杂:在DDS输出级后面级联一个乘法器,把波形数据和调制信号相乘。Xilinx FPGA里有免费的DSP48乘法器切片,直接用*乘法运算符即可综合出来。

5.3 高精度指标升级路径

如果你的目标不止于入门项目,而是要做一台能拿得出手的实验室信号源,需要从以下方向升级:

  • 换用更高位数的DAC:从8位AD9708升级到14位的AD9764或16位的DAC8812,动态范围会大幅提升。
  • 加入DDS芯片配套的模拟调理电路:AD9708输出后加椭圆低通滤波器,带外抑制优于60dB。
  • 采用更高性能的时钟方案:用温补晶振(TCXO)或恒温晶振(OCXO)替代普通晶振,频率稳定度提高一两个数量级。
  • 加入数字增益控制:通过乘法器对振幅进行精细调节,替代模拟电位器。

其中性价比最高的一项升级是换TCXO晶振。普通晶振频率稳定度约±50ppm,TCXO能做到±1ppm以内,成本只差几块钱,而信号发生器的频率精度直接取决于参考时钟。

最后再分享一个从这几块板子上总结的经验:DDS这种小项目,最容易翻车的不是代码,而是电源和地。FPGA内核电压的纹波、DAC电源的干净程度,都会直接反映到输出波形上。如果示波器看到100kHz以上有规律性毛刺,先别急着调代码,用LDO单独给DAC和模拟电路供电,问题往往迎刃而解。我自己第一次调试时折腾了三天,最后发现是开发板USB供电纹波太大,换成外部线性电源立刻干净了。信号链路的“最后一厘米”,永远是最值得花时间的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询