ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于FPGA的DDS信号发生器设计:Verilog实现与仿真验证

基于FPGA的DDS信号发生器设计:Verilog实现与仿真验证

简介:基于FPGA的DDS信号发生器设计工程包,面向FPGA入门学习者与数字信号处理方向的学生,解决直接数字频率合成(DDS)中频率、幅度与波形可调的实现问题。工程以Verilog为核心,配套Quartus工程文件,覆盖相位累加器、波形查找表、幅度控制等关键模块,也体现了奈奎斯特采样定理对波形重建的影响,适合课程设计、毕业设计与自主实验参考。包内共254个文件,核心包括.v源码、.tdf设计文件、.mif波形数据文件、.qpf/.qsf工程文件,辅以.png原理框图、.txt与.readme说明、.m仿真脚本及大量工程记录、备份文件,整体约22.08MB,目录层次清晰,便于按模块检索与复现。已有10018人学习下载。压缩包内不仅提供可直接综合的RTL代码,还保留完整辅助脚本与工具记录,可帮助读者理解DDS工作原理、参数可调的设计思路和工程组织方式,快速搭建并验证自己的信号发生器。

1. 基于 FPGA 的 DDS 信号发生器:频率、幅度、波形三参数可调

FPGA、Verilog、DDS 这三个词放到一起,通常意味着你正在找一个能随时改变频率、幅度、波形的信号源。这个工程的思路很直接:用 FPGA 内部逻辑搭一个相位累加器,再用查找表把相位映射成正弦波数据,最后通过输出级做波形选择和幅度缩放。它不依赖外部专用 DDS 芯片,时钟一跑起来就能出波形,适合需要把信号源嵌进自动化测试系统、或者做变频/移频实验的开发者。新手可以照代码抄一遍跑通仿真,老手能直接用这个框架去改扫频逻辑或接入外部 DAC。

2. DDS 核心原理与关键参数设计:相位累加器、查找表和频率控制字

2.1 DDS 为什么能精确控制频率:相位累加器的工作方式

DDS 的基础是相位累加器,它本质上是一个每来一个时钟就累加一次频率控制字的寄存器。假设累加器位宽是 N 位,每周期加上的数值是 freq,那么累加器溢出一次所需要的时间就是 2^N / (freq * fclk)。输出波形的频率就是溢出率本身,所以频率控制公式可以写成:

fin = freq * fclk / 2^N

反推频率控制字就是:freq = fin * 2^N / fclk。这个公式是整套代码的出发点,后续所有频率参数都要靠它换算。选位宽 N 时,我一般会把系统时钟频率、目标频率范围和最低步进一起放进公式里算,而不是先拍脑袋定一个数。

例如 50MHz 系统时钟下,想做到 1Hz 级调节,累加器位宽至少要 26 位,因为 50e6 / 2^26 约等于 0.745Hz。工程里做成 32 位,频率分辨率能到 0.011Hz 左右,覆盖从 0.1Hz 到几十 MHz 的常见范围都没问题。累加器取了高位做查找表地址,这意味着低位只参与进位累积,并不直接进表。

2.2 顶层模块代码:可用的 Verilog 实现

下面给出一个可直接仿真的顶层设计,参数全部放在 module 头部,方便适配不同开发板。

module dds_top #( parameter N = 32, // 相位累加器位宽 parameter M = 10, // 查找表地址位宽,深度 2^M parameter LUT_WIDTH = 8 // 波形数据位宽 )( input wire clk, // 系统时钟 input wire rst_n, // 低电平复位 input wire [N-1:0] freq, // 频率控制字 input wire [1:0] wave_sel, // 波形选择:0正弦 1方波 2三角波 input wire [LUT_WIDTH-1:0] amp, // 幅度控制字 output reg [LUT_WIDTH-1:0] dout // 波形输出 ); reg [N-1:0] phase_acc; // 相位累加器 always @(posedge clk or negedge rst_n) begin if (!rst_n) phase_acc <= {N{1'b0}}; else phase_acc <= phase_acc + freq; end // 取累加器高位作为查找表地址 wire [M-1:0] addr = phase_acc[N-1:N-M]; // 波形存储 reg [LUT_WIDTH-1:0] sin_lut [0:(1<<M)-1]; reg [LUT_WIDTH-1:0] tri_lut [0:(1<<M)-1]; initial begin $readmemh("sin_lut.hex", sin_lut); // 正弦表 $readmemh("tri_lut.hex", tri_lut); // 三角波表 end reg [LUT_WIDTH-1:0] sin_val; reg [LUT_WIDTH-1:0] tri_val; reg [LUT_WIDTH-1:0] squ_val; always @(posedge clk) begin sin_val <= sin_lut[addr]; tri_val <= tri_lut[addr]; squ_val <= phase_acc[N-1] ? {LUT_WIDTH{1'b1}} : {LUT_WIDTH{1'b0}}; end // 波形选择 reg [LUT_WIDTH-1:0] wave_val; always @(*) begin case (wave_sel) 2'b00: wave_val = sin_val; 2'b01: wave_val = squ_val; 2'b10: wave_val = tri_val; default: wave_val = sin_val; endcase end // 幅度控制:乘法加移位,注意有效位 reg [LUT_WIDTH+LUT_WIDTH-1:0] amp_result; always @(posedge clk) begin amp_result <= wave_val * amp; dout <= amp_result[LUT_WIDTH+LUT_WIDTH-1 : LUT_WIDTH]; // 右移LUT_WIDTH位 end endmodule

这里有几个参数值得单独解释。M决定查找表深度:10 位地址对应 1024 个采样点,对普通正弦波来说相噪和杂散已经能压到可接受范围,如果后续要接高精度 DAC 可以改成 12 位。LUT_WIDTH决定波形数据位宽,8 位对应 256 级量化幅度,配合 8 位 DAC 恰好一一对应。幅度控制放在查表输出之后做乘法,这会导致额外一个时钟周期的延迟,所以我在乘法器前后都用了寄存器打拍,避免波形切换时出现中间态。

2.3 查找表生成方法:用 Python 生成初始化文件

上面代码用$readmemh加载查找表,所以需要准备sin_lut.hex和tri_lut.hex。这两个文件用下面的 Python 脚本几秒钟就能生成,注意数值必须是无符号整数格式。

import math depth = 1024 # 查找表深度,对应 Verilog 参数 M=10 width = 8 # 数据位宽,对应 LUT_WIDTH=8 max_val = 2**width - 1 with open("sin_lut.hex", "w") as f: for i in range(depth): sin_v = (math.sin(2 * math.pi * i / depth) + 1) / 2 f.write(f"{int(round(sin_v * max_val)):02x}\n") with open("tri_lut.hex", "w") as f: for i in range(depth): # 三角波:前半段上升,后半段下降 tri_v = i / depth * 2 if i < depth // 2 else (depth - i) / depth * 2 f.write(f"{int(round(tri_v * max_val)):02x}\n")

脚本把正弦波归一化到 0~255,这样 8 位查找表可以直接用无符号数存储和参与乘法,不用处理负数偏移。三角波表生成逻辑是前半周期从 0 升到最大值,后半周期从最大值回落到 0。频率控制字的换算这里再强调一次:假设系统时钟是 50MHz,要输出 10kHz 正弦波,freq = 10000 * 2^32 / 50000000,算出来约等于 858993。写在 Verilog 里用十六进制就是32'h0D1B717,仿真和上板都用这个值验证。

3. 仿真验证:Testbench 写法与波形分析方法

3.1 搭建最小仿真环境

拿到代码先别急着上板,用 Modelsim 或者 Vivado 自带的仿真器跑一遍,确认相位累加器和乘法器行为正确。Testbench 的关键是给足够的仿真时间,因为 DDS 输出一个完整周期需要2^N / (freq * fclk)秒,频率越低仿真时间越长。

`timescale 1ns / 1ps module tb_dds; reg clk = 0; reg rst_n = 0; reg [31:0] freq = 32'h0D1B717; // 10kHz @ 50MHz reg [1:0] wave_sel = 2'b00; reg [7:0] amp = 8'hFF; wire [7:0] dout; // 生成 50MHz 时钟 always #10 clk = ~clk; // 实例化被测模块 dds_top #( .N(32), .M(10), .LUT_WIDTH(8) ) dut ( .clk(clk), .rst_n(rst_n), .freq(freq), .wave_sel(wave_sel), .amp(amp), .dout(dout) ); initial begin #100 rst_n = 1; #2000000 $finish; // 仿真 2ms,能覆盖约 20 个 10kHz 周期 end // 打印频率控制字对应的理论输出频率 initial $monitor("time=%0t dout=%0d", $time, dout); endmodule

这个 Testbench 里,#10翻转一次时钟产生 50MHz 信号,#100后释放复位。仿真总时长 2ms,能观察至少 20 个完整的 10kHz 波形周期。如果只跑 200us 会看到波形像没溢出完,容易被误判为代码错误。$monitor可开可不开,开了之后终端会持续刷数据,配合波形图看会更直观。

3.2 从仿真波形里读周期和幅度

打开仿真波形后,先看dout。10kHz 信号在 50MHz 采样下的周期应该是 5000 个时钟周期,也就是 100us。确认波形每两个峰值之间间隔确实是 100us,说明频率控制字换算正确。接着看幅度:amp = 8'hFF时波形峰值应该接近 255,如果把amp改成8'h80,峰值会变成约 128。这个现象验证的是乘法器的右移截断逻辑,如果峰值变成 127 或者 127.5 附近出现台阶,说明右移位宽选错了一位。

仿真时常见的问题是看不到sin_val变化。如果你把内部信号添加进波形窗口后发现一直保持未知值,十有八九是$readmemh的路径不对。Vivado 仿真默认工作目录是工程所在文件夹,文件必须放在那个路径下,或者用绝对路径。ModelSim 则是在你启动仿真的目录下找。这个坑我刚学时踩过很多次,后来养成了先把 hex 文件放到仿真目录再跑的习惯。

3.3 仿真通过后仍需关注的边界条件

仿真能跑通只是第一步,有几个边界条件仿真里不容易暴露,但上板一定会遇到。第一是复位释放的时机:如果复位释放瞬间刚好在时钟上升沿附近,累加器初始相位可能是错的,不过由于相位累加器是周期性的,这个错误会在下一个溢出周期自动消失,不致命但会干扰第一次触发。第二是波形切换瞬间的输出毛刺:wave_sel变化时,wave_val可能在一个周期内从正弦值跳到方波值,如果后续接的是 DAC,模拟输出会有一个尖峰,需要在模拟端加一级低通滤波。

这里给一个参数检查清单,方便你在仿真环境里逐项核对。

检查项预期结果判断方法
输出频率与 freq 换算值一致波形两个峰值间隔 = 1/fin
输出幅度amp=FF 时接近满幅峰值等于 LUT_WIDTH 最大值
波形切换wave_sel 变化后 1~2 周期输出新波形观察 disable/反应时间
频率字边界freq=1 时输出最低频率确认累加器不溢出报错
复位信号rst_n=0 时输出清零波形图无 X 态

4. DDS 设计常见问题排查:五条真实踩坑记录

4.1 改了频率控制字但输出频率没变

现象:在板上通过按键或者串口修改 freq,示波器观察输出频率始终不变,甚至输出完全不动。

原因:最常见的是累加器位宽选小了,频率字没有帮累加器溢出。比如 N=16 时,freq 必须大于 1 才能在一个合理时间内溢出,如果你上位机发的 freq 数值只有几,溢出周期长到看起来像没输出。另一种可能是你改了 testbench 里的 freq,但顶层代码里还有另一个 freq 端口被例化时固定死,写寄存器的地方没有连接到真正的顶层引脚。

解决:先看仿真里是不是能复现,仿真里也不变就直接查例化连接。仿真正常但板上不变,优先查 SPI/UART/拨码开关到顶层端口的映射,把freq从只读常量改成实时可写寄存器,并用逻辑分析仪抓一下写入时序。

4.2 高频输出毛刺多、波形发虚

现象:频率设到几 MHz 后,示波器上正弦波边缘出现明显毛刺,波形边缘密度不一致,用 DDS 字面意思理解就是“波形在抖”。

原因:查找表输出没有打拍,组合逻辑直接接 DAC 或者后级电路,导致在一个时钟周期内地址变化时查找表输出短暂出现错误值。尤其当 M 变大、查找表实现为分布式 RAM 时,组合读路径延迟更大。

解决:把sin_val和tri_val的赋值从阻塞改成非阻塞,并且保证查找表输出之后至少经过一级寄存器再送 DAC。我在 2.2 节代码里已经把sin_val <= sin_lut[addr]写成了时序逻辑,这就是为消除毛刺加的打拍。如果毛刺仍存在,检查 DDS 输出引脚到 DAC 的走线是不是太远。

4.3 板上调幅度没有任何效果

现象:amp 从 0xFF 改到 0x01,示波器上输出波形幅度纹丝不动。

原因:FPGA 的 GPIO 输出电平本身是固定的,CMOS 输出高电平接近 VCC、低电平接近 GND。你在 FPGA 内部改乘法器只改变了数字信号的值,但引脚上到不了模拟连续变化的电压,没有 DAC 的情况下幅度调节无从谈起。这个是我带某高校同学做课程设计时发现的经典误区,他们直接在排针上用杜邦线接示波器,以为内部乘法后引脚电压会变。

解决:要么在外部用 R-2R 电阻网络或者 DAC 芯片,把数字波形转换成模拟电压;要么用内部乘法器配合外部可控增益放大器。如果只是做逻辑验证,可以把幅度控制当成数字增益来看,接一个 LED 阵列观察亮度变化,也能验证乘法器效果。带宽要求不高时,一个便宜的 R-2R 网络即可,注意电阻精度会影响输出毛刺。

4.4 仿真正常但板级没有输出

现象:ModelSim 里波形完全正常,下载到板子后示波器什么都测不到,检查引脚绑定也确认过。

原因:大概率是复位时序问题。板上电瞬间时钟还没稳定,如果复位信号恰好是同步复位且释放时固定相位不对,累加器可能一直抱死在某一个状态。另外有些板子的复位按键是上电默认高电平,而你代码里用的是低电平复位,rst_n一直为 0 导致整个模块被关掉。

解决:用逻辑分析仪或者示波器先测rst_n引脚电平,再在代码里把复位改成默认值可用的电平。常见做法是加一个上电复位延时:用一个计数器在复位释放后继续拉低几百个时钟周期,让 PLL 锁定完再启动 DDS。我一般会写一个简单的复位模块,而不是直接把外部复位信号接进 DDS。

4.5 输出波形有台阶和量化噪声

现象:正弦波上升沿不是平滑的曲线,而是阶梯状,低频时尤其明显,换更高位宽 DAC 后依然存在。

原因:查找表数据位宽和 DAC 位宽不匹配。例如 DAC 是 12 位,你的 LUT_WIDTH 只有 8 位,量化台阶直接被放大。也可能是查找表点数太少,M=8 时只有 256 点,正弦波每个台阶对应 14° 相位变化,视觉上很粗糙。

解决:把 LUT_WIDTH 扩到 12 或 16,M 扩到 10 或 12。查找表深度增大不会消耗太多 BRAM,1024 点 12 位宽的 ROM 大约 1.5KB,FPGA 完全承受得起。理想情况下,M 位宽对应的相位分辨率要小于 DAC 的 LSB 对应角度,量化噪声才能被 DAC 自身噪声掩盖。

5. 板上调试技巧与扩展思路:从固定频率到简单扫频实现

5.1 用示波器快速验证 DDS 输出的方法

上板后先用低频信号验证,比如 1kHz 正弦波。示波器时基调到 500us/div,触发方式选上升沿。观察波形除了幅度和频率,还要看波形顶部有没有划平,顶部划平说明输出接近电源轨,需要降低 amp 或者检查外部衰减电阻。建议第一步先跑方波,方波对相位噪声不敏感,容易确认电路通路,再切到正弦波检查失真。

如果没有 DAC,直接测 FPGA 引脚输出的数字波形也是可行的,只是看到的是 0 和 1 跳变,需要用示波器的余晖模式观察占空比变化,这个方法能验证频率字是否正确,但看不出正弦幅度。要看到模拟正弦波,至少要在输出引脚上并联一个 RC 低通滤波器,截止频率设为 DDS 输出频率的 3~5 倍,否则波形会严重衰减。

5.2 扫频信号源的实现思路

很多测试场景需要扫频信号,比如测滤波器幅频特性。DDS 天然适合做扫频,因为只需周期性改变 freq 寄存器。简单做法是用一个计数器做地址扫描,每 N 个时钟把 freq 加上一个步长,循环往复。要注意扫频速度和步进的配合:步长太大,频点不够密,滤波器曲线会折线化;扫频太快,相位累加器来不及建立稳定输出,测出来的幅度值偏小。

我常用的参数是每 100us 改变一次 freq,步长对应 100Hz,这样从 1kHz 扫到 10kHz 需要 9ms,示波器用峰值检测模式就能看到完整包络。这个扫频逻辑可以直接挂在 DDS 顶层外面,不影响原有模块。如果想做更精细的扫频,可以改成线性扫频计数器,但注意相位累加器里没有相位连续控制,扫频切换瞬间波形会有跳变,这是 DDS 架构固有的特性。

5.3 波形种类扩展的接口预留

波形选择用 2bit 信号已经扩展了正弦、方波、三角波三种,如果想加锯齿波或者任意波,只需要在查找表区域增加一块 ROM 并在 case 里加一个分支。任意波形存储可以用上位机通过串口写入 FPGA 的 BRAM,这个设计做出来后,再加上外部 DAC,整个信号发生器可以当一个小型任意波形源用。整个工程里最值得反复检查的还是 freq 换算和位宽匹配,这两个点一旦出错,仿真波形再漂亮上板也白搭。

这套工程的完整 Verilog 代码、Python 查找表生成脚本和 Vivado 仿真工程都整理在下载包里,拿回去直接打开仿真就能看到 10kHz 正弦波形,改成自己的板子时钟后就能上板。从那以后我每次做 DDS 工程都会先算一遍频率字换算,再检查一次查找表深度和 DAC 位宽是否匹配,省掉不少调板时间,希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进