☰
FPGA信号发生器中的DDS实现:从相位累加器到Verilog波形仿真
2026/10/20 23:08:19 网站建设 项目流程

做FPGA信号发生器绕不开DDS,基于DDS技术的FPGA信号发生器波形仿真这个题目看起来不难,但真正把Verilog代码写完、把仿真波形跑通、把频谱做干净,中间确实有不少门道。这篇文章我想按照自己实际做项目的顺序,从DDS的原理讲起,拆到系统架构,再落到Verilog实现和仿真验证,最后把相位截断、杂散抑制这些工程坑一并说清楚。适合同样在做FPGA信号源、想用Verilog实现DDS并做波形仿真的同学参考,不管你是刚接触数字信号处理,还是已经写过几个模块,都能找到能直接用的东西。

1. 频率合成这件事,为什么大家最终都走向了DDS

1.1 从"直接查表"到"相位累加",差的不只是节省资源

很多人一开始理解DDS,会把它简化成"ROM里存一张正弦表,按照地址循环读出来"。这个理解方向没错,但如果真按这个思路去做信号发生器,很快就会发现两个尴尬的问题:换频率怎么办?频率分辨率怎么保证?

传统直接查表的做法,是在ROM里存够一个周期的采样点,然后用计数器加1去循环地址。假如ROM深度是D,系统时钟是f_clk,那么输出频率就是f_clk / D,想换频率就得改ROM内容或者改时钟。这种方案的频率分辨率受限于ROM深度,而且频率切换的实时性很差,每次都要重新加载数据,更重要的是相位完全不连续,波形在切换瞬间会出现肉眼可见的跳变点。做静态输出还能凑合,一旦要扫频、调频、调相,直接查表方案几乎是不可用的。

DDS把这个问题彻底换了个思路:不再用计数器加1,而是用一个N位累加器加一个频率控制字F_word。累加器的值就是当前相位,只是这个相位是"离散化的角度"。每来一个时钟,累加器加上F_word,相当于在相位圆上走了一步,走完2^N步就输出一个完整周期的波形。输出频率变成了:

f_out = f_clk × F_word / 2^N

同样的ROM深度,频率分辨率从f_clk / D提升到了f_clk / 2^N。N做到32位甚至48位,在几百MHz的系统时钟下,频率分辨率轻松达到mHz级别。这才是DDS在工程上替代传统查表方案的根本原因,它把"频率精细度"从存储深度问题,变成了加法器位宽问题,而后者的成本低得多。

1.2 DDS的三个"连续"特性,决定了它能用在哪里

我实际做完几个DDS项目后,对它的三个特性特别有感触,这三个特性几乎决定了后续所有应用场景。

第一是相位连续。修改频率控制字之后,累加器在新旧频率之间是自动衔接的,下一拍输出的波形不会出现硬生生的跳变。这一点在做扫频信号源、通信调制器的时候非常关键。第二是频率切换即时响应,频率控制字更新后的下一个时钟周期,输出就是新频率,不用经过漫长的重载过程。第三是相位可编程,因为累加器里的值本身就是相位,想加一个相位偏置直接在查找表地址上加个偏移就行,想实现PSK、FSK调制,本质上就是改变相位偏移和频率控制字。

这三个特性一叠加,同一个DDS核就能用在仪器信号源、雷达波形发生器、跳频通信里。我自己做多波形信号发生器的时候,也是冲着这三点才敢把架构定成"固定DDS内核+可替换波形查找表",后续换波形完全不用动累加器部分,只换ROM内容就够了。

2. 系统架构拆分:一个DDS模块,哪些细节决定成败

2.1 顶层模块划分与各自职责

把一个完整的基于DDS的FPGA信号发生器拆开,通常能看到四个子模块:时钟管理、相位累加器、波形查找表、输出接口。

模块职责关键参数
时钟管理产生稳定、低抖动的系统时钟,通常用PLL倍频/分频确定DDS核心工作频率f_clk
相位累加器每个时钟周期累加一次频率控制字,输出N位相位N位累加位宽,决定频率分辨率
波形查找表用相位的高M位作为地址,输出对应的波形采样值存储深度2^M,数据位宽D
输出接口把数字波形数据送出FPGA,接DAC或观测端数据位宽、时序对齐方式

相位累加器是DDS的灵魂,它决定了频率分辨率;查找表是资源开销大头,它决定了波形动态范围;输出接口决定了和外部世界对话的方式。做纯仿真验证的时候,输出接口通常就接一个观测总线,方便在仿真工具里看时序和抓数据。

这里有个容易被忽视的问题:时钟管理模块要不要进仿真?我的习惯是,仿真阶段不例化PLL,直接用虚拟时钟激励,因为PLL的锁定时间、抖动特性在数字仿真里本来就不真实,放进仿真只会增加跑仿真时长。真正的时钟管理验证放到板级时序分析阶段再做,数字逻辑验证阶段把注意力集中在DDS核心模块上。

2.2 相位截断:为什么不把全N位都用于查表

一个很容易误导新手的工程认知是:累加器是32位,查找表地址也应该32位。这种思路如果真落地,一张正弦表就要2^32条数据,每条就算只存1字节接近4GB,片上存储根本放不下,纯属把架构带偏。

工程设计上通用的做法是相位截断,只取N位相位的高M位作为查找表地址。比如N=32、M=12,地址深度就是4096,用片上BRAM完全放得下。代价也很明确:相位细节丢失之后,输出频谱里会出现非谐波的杂散分量spur。不过对大多数应用来说,M取10到14位,杂散已经能压到比较合理的范围。

这块我先不展开,后面专门有一节细讲相位截断和杂散的平衡,现在读者只需要记住一个基本判断:频率分辨率由N决定,波形精度和杂散水平由M决定,两者是独立的参数,不要混在一起。

2.3 波形查找表的ROM与RAM选择

查找表的数据存放方式,常见有两种:预先生成COE文件,初始化成ROM;或者系统上电后由处理器把波形数据写入BRAM,运行期间随时更新。

前者简单直接,波形固定,适合正弦波、方波、三角波这种标准波形集合;后者灵活不少,适合需要在线更新波形的设备。我自己做多波形发生器时用的是后者,上电后由一个处理器接口把波形数据写入BRAM,累加器的相位高M位作为读地址去查表。这一套结构让我既没有把逻辑写死,又能方便地扩展任意波形输出。

两种方式的Verilog写法差别其实不大,最大的区别在于初始化方式:ROM用initial块配合$readmemh,RAM则是正常的读写接口,初始化交给外部逻辑控制。开发调试期建议先用$readmemh方式快速验证,跑通后再换成RAM写入方式,一步到位反而容易被初始化时序绊住。

3. Verilog实现:从频率控制字计算到波形输出

3.1 频率控制字怎么算,算错了波形全歪

DDS的参数里,最容易出错的就是频率控制字的计算。公式本身不复杂:

F_word = round(f_out × 2^N / f_clk)

举个例子:f_clk=100MHz,N=32,想输出f_out=10kHz。代入计算:

F_word = 10,000 × 4,294,967,296 / 100,000,000 ≈ 429.5

取整到430,那么实际输出频率是:

f_actual = 430 × 100,000,000 / 4,294,967,296 ≈ 10,011.6 Hz

误差大约0.12%。这个误差来自F_word取整时的量化,想进一步减小误差,可以加大N,比如从32位提到48位,量化误差会被稀释到几乎可以忽略;也可以用数字抖动技术做分数频率逼近。不过对于绝大多数信号发生器场景,32位累加器配合整数取整已经够用。

还有一个工程细节:计算F_word时,建议先用Python或MATLAB算好,不要直接在Verilog里手算十六进制,避免进错位。算完后在Testbench里作为参数传入,可读性和可维护性都好很多。

3.2 核心代码:一个能直接用的DDS正弦发生器

下面这套Verilog代码是我平时常用的写法,Verilog-2001风格,Vivado和Quartus都能直接综合。

module dds_sine #( parameter N = 32, // 相位累加器位宽 parameter M = 12, // 查找表地址位宽 parameter D = 16 // 输出数据位宽 )( input wire clk, input wire rst_n, input wire [N-1:0] fword, // 频率控制字 input wire [M-1:0] pword, // 相位偏移,可选 output reg [D-1:0] sine_out ); reg [N-1:0] phase_acc; always @(posedge clk or negedge rst_n) begin if (!rst_n) phase_acc <= 'd0; else phase_acc <= phase_acc + fword; end // 相位截断,取高M位作为查找表地址,并叠加相位偏移 wire [M-1:0] addr = phase_acc[N-1:N-M] + pword; // 查找表实例化 sine_lut #(.M(M), .D(D)) u_lut ( .clk(clk), .addr(addr), .data(sine_out) ); endmodule

这里有两个细节值得说明。第一,查找表输出通常比地址滞后一个时钟周期,因为mem读取需要一拍;所以如果发现输出波形相位比预期晚了一拍,不需要怀疑代码有问题,这是正常的流水线延迟。第二,pword和截断后的地址相加时,如果发生进位溢出,地址会自然回绕,正好对应相位超过360度后的行为,不需要额外处理边界。

查找表模块的写法如下,仿真时用$readmemh加载波形文件:

module sine_lut #( parameter M = 12, parameter D = 16 )( input wire clk, input wire [M-1:0] addr, output reg [D-1:0] data ); reg [D-1:0] mem [0:(1<<M)-1]; initial begin $readmemh("sine.mem", mem); end always @(posedge clk) data <= mem[addr]; endmodule

对应的sine.mem文件,不建议手写,用一个小脚本生成最方便。下面这个Python脚本生成的是12位地址、16位数据的无符号十六进制文本:

import math M = 12 D = 16 with open("sine.mem", "w") as f: for i in range(1 << M): v = int(round((2**(D-1)-1) * math.sin(2*math.pi * i / (1 << M)))) f.write(f"{v & 0xFFFF:04X}\n")

16位输出用无符号形式存放,配合reg [D-1:0]读取,写出来的数据在仿真波形里会呈现正弦形态,值从0到65535之间变化,观察起来很直观。

3.3 为什么示例代码里没有写PLL和DAC接口

上面这套代码有意省略了PLL和DAC接口,不是偷懒,而是分阶段验证的思路。仿真阶段最重要的是验证DDS数字核心本身:累加器有没有正确累加,地址对不对,查找表读出来的波形对不对。如果一开始就把PLL例化和DAC芯片接口时序全塞进仿真里,跑一次要处理一堆和DDS无关的时序细节,反而掩盖了核心逻辑的问题。

我的习惯是分两阶段:第一阶段仿真只验证DDS数字核心,用虚拟时钟驱动;第二阶段再把真实DAC接口挂进来,做板级时序验证。这样才能让每一步的问题边界清晰,排查效率高很多。

顺带提一个容易踩的坑:如果顶层把sine_out声明成wire,而查找表的data输出是reg类型,实例化连接本身没问题,但如果想在顶层再对输出做一级寄存器,记得先声明reg再在always里赋值,不要在wire上直接赋值,这种低级语法错误会浪费不少排查时间。

4. 仿真验证:Testbench设计到波形分析一气呵成

4.1 一个能直接跑的Testbench

DDS仿真Testbench的核心任务有四件:产生时钟、产生复位、设置频率控制字、抓取输出数据。下面是一套可以直接跑的写法:

module tb_dds; parameter N = 32; parameter M = 12; parameter D = 16; reg clk = 0; reg rst_n = 0; reg [N-1:0] fword = 0; wire [D-1:0] sine_out; always #5 clk = ~clk; // 产生100MHz时钟 initial begin $dumpfile("dds_sine.vcd"); $dumpvars(0, tb_dds); rst_n = 0; #100 rst_n = 1; fword = 32'd430; // 期望10kHz @ 100MHz #200_000; $finish; end dds_sine #(.N(N), .M(M), .D(D)) u_dut ( .clk(clk), .rst_n(rst_n), .fword(fword), .pword(0), .sine_out(sine_out) ); endmodule

这里#200_000对应200微秒仿真时间,足以统计出多个完整周期的10kHz波形。如果是给DAC用的输出数据,建议在仿真脚本里用$fwrite按周期把sine_out写成CSV文件,后面做FFT和画图分析会非常方便。

我个人不太建议用可视化波形工具肉眼判断波形质量,肉眼根本看不出杂散和失真。正确做法是把数据导出来做数值分析,看频谱才能看出问题。

4.2 把"波形好"这件事量化:FFT频谱怎么看

判断DDS做得好不好,最有效的办法是看FFT,不是看时域波形。把仿真导出的CSV导入Python,用一段标准流程就能看到频谱:

import numpy as np import matplotlib.pyplot as plt data = np.loadtxt("sine_out.csv") N_fft = len(data) win = np.hanning(N_fft) spec = np.fft.rfft(data * win) freqs = np.fft.rfftfreq(N_fft, d=1e-8) # 仿真步长10ns plt.semilogy(freqs, np.abs(spec)) plt.xlabel("Frequency (Hz)") plt.ylabel("Magnitude") plt.show()

拿到频谱之后,重点看三处:主峰是否落在设定频率附近,测量值和设定值差多少;主峰之外有没有突出的杂散峰,尤其是非谐波位置的尖峰,这些通常是相位截断造成的;底部噪声平台是否平滑,有没有异常的孤立尖峰。

一次合格的DDS仿真,至少应该看到主峰清晰、杂散水平比主峰低50dB以上。如果杂散过高,优先检查查找表地址位宽M是否太小,再检查ROM数据有没有截断错误。频率偏差如果偏高,则回头检查F_word计算是否准确。

4.3 仿真与实测的常见差异,提前打好预防针

仿真里走得很顺,到了真板上大概率还会遇到几个问题,这里先做个预判。

第一个是输出毛刺。DAC数据总线切换瞬间出现竞争态,通常是由FPGA内部布局布线的延迟差导致的,时域波形上表现为很窄的尖峰。解决方案是加输出寄存器、约束输出延迟,或者干脆用专用DDR输出接口。

第二个是噪声地板偏高。理想仿真环境里噪声地板很低,但真板上电源不干净、FPGA内部开关噪声耦合都会抬高地板,尤其是高频段更明显。这个属于模拟端和电源完整性问题,不是DDS数字逻辑的错误。

第三个是高频段幅度塌陷。DAC输出的SINC包络效应会导致高频信号幅度下降,仿真端完全看不出来。这是模拟物理特性,解决办法要么在模拟端加SINC补偿滤波器,要么直接限制DDS输出频率不超过奈奎斯特频率的约70%。

这三个坑我都在不同项目里踩过,提前知道能省下不少排查时间。

5. 相位截断、杂散抑制与频率精度的工程平衡

5.1 相位截断到底带来了什么

为什么查表地址只取累加器的高M位,就会产生杂散?这个问题值得说透。

累加器全N位对应2^N个离散相位,截断到M位之后,地址只能区分2^M个相位值。当频率控制字不是2^(N-M)的整数倍时,每次累加落在地址边界上的"舍入"就带上了一个周期性误差序列。这个误差序列会以某个频率去调制输出幅度,在频谱上表现为离散的杂散峰。

M越大,这个相位误差越小,杂散水平越低。经验上,M每增加一位,相位截断杂散大约改善6dB:

查找表地址位宽M相位截断杂散水平(经验值)片上存储用量(16bit输出)
M=10约-60 dBc16 Kbit
M=12约-72 dBc64 Kbit
M=14约-84 dBc256 Kbit

但这里要注意,查找表输出的幅度量化误差同样会限制动态范围。如果M推到16,但D只有12位,整体动态范围也就-70dB左右。M和D要匹配,常见的组合是M=12配D=12到16,再往上提M,效果就不明显了,瓶颈转移到了输出位宽和DAC本身。

5.2 数字抖动:给杂散"散掉"的工程手段

当相位杂散集中在少数几个频点上,而且尖峰幅度比本底高很多时,工程上常用加抖动的方式来处理。具体做法是在查表地址上叠加一个小范围的伪随机数,让周期性的相位误差变成随机误差。频谱上的离散杂散会被"抹平",均匀扩散到底部噪声里。

代价是整体噪声底会抬升几个dB。不过在通信系统的频谱掩膜测试场景里,通常宁可要一个平坦的噪声地板,也不能容忍高耸杂散峰;而在追求极低噪声的测试测量仪器领域,反而更在意杂散峰是否可控。要不要加抖动,取决于你的应用对频谱掩膜的要求。

我自己在通信类项目里倾向于加抖动,在仪器类项目里不加,因为仪器用户更关心相位噪声和杂散的绝对值。

5.3 频率分辨率与频率精度,别混为一谈

很多初学者问:N=32、f_clk=100MHz,频率分辨率不是0.023Hz吗,为什么实际输出频率和目标频率差了0.1%?

这里需要把两个概念拆开。频率分辨率是最小可调的频率步进,由N决定;频率精度是实际输出频率和目标频率之间的偏差,由F_word取整误差、参考时钟精度、累加器位宽共同决定。分辨率可以做得很细,但如果晶振本身有50ppm的偏差,输出频率就会带同样量级的偏差,这是分辨率无法补偿的。

仿真里参考时钟是理想源,所以F_word取整误差是主要偏差来源;到了真板上,晶振误差会直接乘到输出频率上。设计阶段的建议是:N尽量取32位以上,F_word取整时尽量选择接近真实频率的值;测试计划里把参考时钟的长期稳定度作为一个独立测量项,不要把所有偏差都归罪到DDS逻辑上。

6. 从仿真到板卡:能立刻动手的检查清单与扩展思路

6.1 小规模系统搭建清单

把前面讲的工程经验整理成一份可执行的检查清单,按顺序过一遍,基本能覆盖从设计到验证的主流程:

  • 确定目标频率范围,反推系统时钟和N位宽。
  • 确定杂散指标,反推查找表地址位宽M与输出位宽D。
  • 生成波形文件sine.mem,并提前用Python做一版预期输出比对。
  • 建立Testbench,先跑10kHz左右的低频,检查时域波形连续性和频率正确性。
  • 导出CSV做FFT,确认主频位置、杂散水平、噪声地板数值。
  • 挂DAC前做时序约束检查,确认数据建立保持时间满足要求。

这份清单我几乎每次做DDS都会沿用,顺序也尽量不要打乱。先参数后代码,先低频后高频,先频谱后时域,能避免大量无效返工。

6.2 两个值得一试的扩展:正交输出和扫频模式

DDS的价值远不止输出一个正弦波。同一个架构下,把查找表内容换成三角波、锯齿波、任意波,就实现了对应波形输出;把pword接口充分利用起来,就是PSK调制器;在正确时机切换fword,就是FSK调制器。

我建议基础DDS跑通之后,优先做两个扩展。

第一个是双DDS正交输出,一个输出sin、一个输出cos,共用一个累加器的相位。这个架构是I/Q信号生成的基础,后续做QAM调制、数字上变频都用得上。实现上的改动很小,但验证时要注意两个查找表的初始化和时序对齐。

第二个是扫频模式,让fword按线性增量变化,输出线性调频波chirp,这是雷达模拟信号最常用的波形。这里最容易出现的坑是扫频过程中的相位连续性,如果fword切换时机没处理好,波形拼接处会出现相位跳变。

把这两个扩展调通,对DDS的理解就从"会仿真"提升到了"会设计"。

做这类FPGA DDS项目,我的个人排序一直是:先把相位累加器的参数量化清楚再写代码,先看FFT再看时域,先跑低频仿真再往高频推。很多初学者一上来就想仿真几十MHz的信号,结果一个周期只采几个点,FFT全是假频谱,越看越糊涂。DDS这套设计其实很有意思,跑通低频、把频谱结构分析明白,再把频率推上去,整个过程会非常顺,希望这篇实战拆解能帮你少走一段弯路。

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

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

立即咨询