说实话早几年做DDS信号发生器我习惯先在Matlab里把波形仿真调顺了再回Vivado写Verilog两边来回倒腾。后来项目周期越来越紧干脆直接在Vivado里用Verilog搭了一套仿真流程把DDS信号发生器的频率精度、相位连续性、幅度一致性、毛刺情况全都“体检”了一遍效果比想象中好很多而且省掉了Matlab和Vivado之间数据对接的麻烦。这篇文章就把我这套做法完整拆出来从DDS原理到Testbench搭建再到波形分析和踩坑记录一条线讲清楚。适合正在做FPGA信号源、或者刚接触Verilog仿真想拿DDS练手的朋友看完基本可以直接复现。1. DDS信号发生器的核心原理与Verilog实现思路1.1 DDS为什么能在FPGA里“无脑”生成波形直接数字频率合成DDSDirect Digital Synthesis之所以在FPGA里这么好用因为它本质上就是个“查表游戏”。你不需要让FPGA去实时计算正弦值——那会浪费大量乘法器和逻辑资源——而是先把一个周期的正弦波等间隔采样成若干个点存进ROM或RAM里然后用一个相位累加器不断累加相位步进拿累加器的高位去查表查出来的数据直接驱动DAC输出或者数字波形。这个思路跟你在纸上画很多小竖条来近似一条平滑曲线是一模一样的。竖条越密曲线越接近真实正弦波ROM深度越大相位分辨率越高输出位宽越大幅度量化噪声越小。DDS的本质就是用“时间换资源”用固定的存储空间和累加逻辑去换取任意可调的输出频率而且频率切换时相位是连续的不会像PLL那样有锁定时间这是它最大的亮点。1.2 从“Matlab画图”到“Vivado跑波形”的思维转变以前用Matlab仿真本质上是把DDS当成纯数学模型来跑最终得到的是一堆离散点然后用plot画出来。这里有个天然缺陷Matlab模型不会告诉你FPGA里位宽截断的影响、ROM初始化文件的格式、时钟到输出的延迟、仿真器的时间精度限制。你把Matlab里好看的波形拿去上板经常发现实际输出毛刺一堆或者频率隔了十万八千里。在Vivado里用Verilog做仿真是直接把RTL代码放进仿真器里跑所有信号都是真实的二进制位宽所有延迟、截断、复位行为都和实际电路一致。仿真通过上板基本就八九不离十。所以我现在做DDS项目基本流程是先手工算好关键参数然后写RTL接着写Testbench直接拉波形看结果最后再根据需要决定要不要用Matlab做频谱分析验证。1.3 整体方案选型相位累加器位宽、ROM深度、输出位宽先说结论我常用的是相位累加器32位、ROM深度256点、输出数据位宽10位时钟100MHz。这套参数做1kHz到几MHz的正弦波都很顺手。为什么相位累加器选32位因为输出频率分辨率取决于累加器位宽N频率分辨率 时钟频率 / 2^N。如果N32系统时钟100MHz分辨率 100e6 / 2^32 ≈ 0.023Hz也就是说你可以调出间隔0.023Hz的任意频率对于绝大多数信号源需求完全够用。N要是降到16位分辨率直接变成1525Hz想精确出个100kHz信号都费劲。ROM深度256点其实并不高但配合10位输出信噪比已经能到60dB左右普通示波器看看波形、简单测测频响完全够。如果你做高保真音频或者高精度测量源可以把ROM深度加到1024甚至4096点输出位宽提到14位或16位代价是FPGA查找表资源和Block RAM占用增加逻辑上没有本质区别。2. 关键参数计算与Verilog代码框架搭建2.1 频率控制字的计算从公式到实例DDS最核心的计算就一个公式频控字 (目标频率 × 2^N) / 系统时钟频率这个公式非常直观2^N代表相位累加器转一圈的总步数系统时钟频率代表每秒走多少步两者相除再乘以目标频率就是每时钟周期需要累加的步数。举个例子系统时钟100MHzN32想产生1MHz正弦波频控字 (1e6 × 2^32) / 100e6 42949673转成16进制就是0x028F5C29。这个数就是你在代码里赋给相位累加器的递进步长。累加器每个时钟周期加一次这个值溢出一次就代表完成一个周期的正弦波输出所以输出频率严格等于目标值。再比如2MHz频控字 (2e6 × 2^32) / 100e6 85899346 ≈ 0x051EB852注意频控字是整数如果算出小数只能取整这会造成微小频偏。但N32时取整带来的误差已经被压到0.023Hz以内完全不用担心。2.2 Verilog代码框架参数化设计是关键DDS的Verilog代码本身并不复杂但强烈建议做成参数化模块把位宽和ROM加载方式都变成参数方便复用。module dds #( parameter PHASE_WIDTH 32, parameter ROM_ADDR_WIDTH 8, parameter DATA_WIDTH 10 )( input wire clk, input wire rst_n, input wire [PHASE_WIDTH-1:0] freq_word, output reg [DATA_WIDTH-1:0] sine_out ); // 相位累加器 reg [PHASE_WIDTH-1:0] phase_acc; always (posedge clk or negedge rst_n) begin if (!rst_n) phase_acc {PHASE_WIDTH{1b0}}; else phase_acc phase_acc freq_word; end // 取相位累加器的高8位作为ROM地址 wire [ROM_ADDR_WIDTH-1:0] rom_addr; assign rom_addr phase_acc[PHASE_WIDTH-1 -: ROM_ADDR_WIDTH]; // 正弦查找表 reg [DATA_WIDTH-1:0] sin_table [0:(1ROM_ADDR_WIDTH)-1]; initial begin $readmemh(sine_table.hex, sin_table); end always (posedge clk) sine_out sin_table[rom_addr]; endmodule这段代码里有几个点值得展开讲。首先是相位截断。ROM地址只取了相位累加器的高8位低24位全部丢弃。这相当于把相位精度从32位降到了8位每次查表都会引入一个小的相位误差。好在这个误差只在相邻采样点之间抖动经过DAC后表现为很小的杂散分量通常可以接受。如果你想极致优化可以在相位累加器后加一个相位抖动dither电路能非常有效地把杂散摊平到噪声底不过对于多数应用没必要。然后是输出打拍。sine_out在always (posedge clk)里赋值意味着ROM输出后额外寄存一级这是为了打破组合逻辑路径、提升时序收敛能力。代价是输出延迟一个时钟周期但对DDS这种连续输出系统来说一个周期的延迟完全可以忽略。你在看波形的时候只要知道这个延迟存在就行别拿它去卡什么相位谱。2.3 正弦查找表的生成告别手动敲数据初学者最容易卡在ROM表的数据上手算256个点太痛苦也不想为了这个去开Matlab。我的建议是直接用Python一个小脚本生成HEX文件几行代码搞定还不会手抖填错数。import math depth 256 width 10 max_val (1 (width - 1)) - 1 # 10位有符号数正最大值511 with open(sine_table.hex, w) as f: for i in range(depth): val int(max_val * math.sin(2 * math.pi * i / depth)) f.write(f{val ((1 width) - 1):03x}\n)生成的sin_table是10位二进制补码形式ROM初始化时用$readmemh读进去。注意写入的值是val 0x3FF这样做是因为Verilog仿真器和FPGA综合工具对负数HEX的处理方式有时会让人抓狂直接存无符号的补码最省心。输出端口虽然声明为reg [DATA_WIDTH-1:0]但你后续接DAC时应该把它当作有符号数来理解0~511对应正半周512~1023对应负半周配合DAC的补码输入接口即可。3. 在Vivado里搭Testbench进行“全身体检”3.1 Testbench的骨架要让DUT转起来DDS模块本身是纯数字逻辑Testbench写起来非常直接。核心就三件事给时钟、给复位、给频控字。timescale 1ns / 1ps module tb_dds; localparam PHASE_WIDTH 32; localparam ROM_ADDR_WIDTH 8; localparam DATA_WIDTH 10; reg clk; reg rst_n; reg [PHASE_WIDTH-1:0] freq_word; wire [DATA_WIDTH-1:0] sine_out; // 实例化DDS模块 dds #( .PHASE_WIDTH(PHASE_WIDTH), .ROM_ADDR_WIDTH(ROM_ADDR_WIDTH), .DATA_WIDTH(DATA_WIDTH) ) uut_dds ( .clk(clk), .rst_n(rst_n), .freq_word(freq_word), .sine_out(sine_out) ); // 100MHz时钟周期10ns半周期5ns initial clk 0; always #5 clk ~clk; // 测试流程 initial begin // 初始化 rst_n 0; freq_word 32d0; // 复位20个时钟周期 repeat(20) (posedge clk); rst_n 1; // 设置频控字为1MHz输出 freq_word 32d42949673; // 运行20000个时钟周期相当于200us repeat(20000) (posedge clk); // 切换频率到2MHz观察频率变化 freq_word 32d85899346; repeat(20000) (posedge clk); $finish; end endmodule这段Testbench里有几个容易被忽视的细节。时钟生成用always #5 clk ~clk对应100MHz。如果你要改成其他频率比如50MHz就把延迟改成#10。很多人在这里卡住其实就是半周期关系没转过弯来。复位时序一定要给足。DDS模块使用异步复位复位信号至少要保持几个时钟周期确保内部相位累加器和输出寄存器都回到已知状态。我在实际调试中见过不少次复位时间太短导致输出初值不确定的情况波形一出来就是乱七八糟的。3.2 读波形频率、相位、幅度的检查方法Testbench跑完之后在Vivado的Simulation窗口里把sine_out加到波形视图接下来就是“体检”的核心环节。第一步是查频率。波形稳定后把鼠标放在一个完整周期的起始点然后拖动游标到下一个周期的同相位点看时间差。如果是1MHz周期应该是1000ns。因为timescale是1ns/1ps波形窗口里你能读到小数点后三位足够判断频率是否精准。第二步是查相位连续性。这个特别重要DDS最引以为傲的特性就是频率切换时相位连续。你在Testbench里故意在20000个周期后切换了频控字观察切换点前后的波形如果正弦波没有任何跳变或毛刺而是平滑地过渡到新频率那就说明相位累加器的设计没有问题。这里能看出DDS和PLL的本质区别PLL切换频率可能要经过几百微秒的锁定时间中间频率是乱的DDS瞬间切换相位不中断。第三步是查幅度。看波形峰峰值是否等于你设计的满幅范围。10位有符号输出理论范围是-512到511所以峰峰值应该是1023。如果看到峰值明显偏低比如只能到400多那大概率是ROM初始化文件里的数据不对或者你输出寄存器的符号处理有问题。3.3 “全身体检”检查项清单类比给人做体检我给DDS整理了一份检查清单每一项都有明确标准和判定方法检查项检查方法通过标准频率精度测量波形周期换算频率与目标频率误差小于0.1%相位连续性切换频控字瞬间观察波形无跳变、无毛刺、平滑过渡幅度一致性连续观察多个周期峰峰值各周期峰峰值误差小于1个LSB输出毛刺放大波形观察边沿无随机毛刺无亚稳态现象复位行为观察复位释放后的前几个周期输出从零或初始值正常起振频率切换响应测量切换后第一个波形周期新频率立即生效无滞后代码覆盖率Vivado仿真统计关键语句和分支全部覆盖这份清单看起来简单但每项背后都有坑。举个例子频率精度看起来最容易测但如果你只看1MHz的波形周期1000ns的读数其实只精确到0.1%测试时间太短会导致统计误差偏大。我的做法是把波形放大到20到30个完整周期用游标测量总时间再除以周期数这样等效精度能提升很多。这个方法和示波器上测频率的原理是一模一样的。4. 仿真中那些烦人的报错与排查技巧4.1 Vivado仿真里常见的几类问题先说说仿真心跳过程中必然会遇到的报错和处理方式这些我基本都踩过一遍。比较常见的是“$readmemhfile not found”或者“cannot open”这类报错。原因通常是HEX文件的路径不对或者文件没放到工程目录下。Vivado仿真器的工作目录和文件所在目录有时会不一样尤其在工程套件里建sim文件夹的时候。解决办法一是把HEX文件放到工程根目录二是在Testbench里写绝对路径我一般用$readmemh(D:/fpga_project/dds/sine_table.hex, sin_table)把它写死省得环境不同导致路径解析出问题。还有一个高频报错是“sine_outis not a constant”或“cannot assign”。这多半是你在模块内部或者Testbench里把已经声明为reg的变量又用assign赋值了。Verilog语法里reg和wire各有各的驱动方式混用就会报错。另外如果你在always块里同时用了阻塞赋值和非阻塞赋值Vivado也会警告严重时仿真结果直接不对。DDS模块内部同步逻辑统一用这个习惯要养好。4.2 波形看起来没问题但频率不对怎么查这类问题最迷惑人。仿真波形是一圈一圈的正弦波形状完全正确但用游标量周期发现和设计值差了十万八千里。我遇到过一次情况是相位累加器位宽在RTL里写死了16位但Testbench给的频控字是按32位算出来的。结果就是相位累加器每一步累加的步长虽然看起来是42,949,673但实际只有低16位参与累加相当于每步叠加了步长的低16位频率完全是另一回事。这种问题只要检查一下RTL里参数有没有真正传到实例化模块或者看看波形窗口里的低频包络就能发现。还有一种情况是timescale设置导致的时间测量偏差。如果你在Testbench文件里忘了写timescale 1ns / 1ps仿真器会用默认精度波形窗口的时间轴可能会以某个奇怪的单位显示。频率“看起来”不对实际上电路没问题。排查方法是看波形窗口左下角的时间单位再和你的时钟周期对一下确认基础单位没错。4.3 仿真通过但上板失败提前排掉的隐患虽然标题是“告别Matlab、在Vivado里做全身体检”但仿真通过绝不等于上板稳了。有几个隐患在仿真阶段就能提前检查出来。一个是亚稳态。如果你的DDS模块外部还有异步信号的交互比如按键切换频率或者外部ADC/DAC的数据总线那单纯测试DDS内部是测不出来亚稳态问题的。但你可以通过仿真给输入信号加一些随机抖动来模拟真实场景。Testbench里用#10.123这样的非整数延迟去驱动异步输入能逼出不少时序问题。另一个是毛刺。仿真默认是理想数字信号所有信号变化都发生在时钟边沿看起来干净利落。但实际FPGA里ROM地址线切换时会有不同时延组合逻辑会产生极窄的毛刺。为了在仿真里暴露这个问题可以在Testbench里给地址线加可变的路径延迟。做法是在ROM查找表的组合逻辑路径上人为插入延迟或者直接把ROM的输出级寄存器去掉看仿真波形会发现输出像“毛刺集合体”。这能帮你理解为什么ROM后面必须打一拍。4.4 我常用的几条仿真提速经验仿真跑20000个周期100MHz时钟就是200us在Vivado自带仿真器里可能要跑一会儿。如果只是想验证功能可以把时钟改快一点比如直接用10ns仿真周期但是把Testbench里的repeat次数缩小到2000先快速看个大概确认连线没问题后再跑完整仿真。这个习惯能省下大量时间。还有一个小技巧是善用Vivado的$display和$monitor在控制台直接打印关键数据不用每次都打开波形窗口去量。比如always (posedge clk) begin if (sine_out 10d511 || sine_out 10d511) // 简化示意 $display(peak detected at time %0t, value %0d, $time, sine_out); end不过要注意$display在仿真里会占用很多IO和仿真资源只在调试峰值、边界条件时用别在整体跑批的时候挂一堆打印。5. 从仿真走向板级频率字计算与硬件联调的关键补充5.1 频控字的上位机赋值和FPGA内部计算DDS模块的频控字在实际工程里往往不是写死的而是由UART、SPI或者AXI-Lite接口动态配置。这里有一个重要区别你在Testbench里给的是32位十进制数但在嵌入式软件里可能没法直接用浮点算出42949673这种大数需要把频控字拆分成两个16位寄存器来写。比如软件想配置1MHz输出分两步操作uint32_t freq_word 42949673; write_reg(0x00, freq_word 0xFFFF); // 低16位 write_reg(0x01, (freq_word 16) 0xFFFF); // 高16位FPGA内部再把两个16位寄存器拼接成32位频控字。这个过程虽然简单但我在联调时见过不少次因为寄存器地址弄反导致高16位和低16位交换输出频率完全不是预期的案例。所以建议在RTL里预留一个回读功能把拼接后的32位频控字通过总线再读出来和上位机写入值对比能在几秒钟内定位问题。5.2 DAC接口的仿真验证DDS模块的输出还只是数字信号真正变成模拟量还得靠外部DAC。如果你用的DAC是并行接口比如AD9708、AD9767这类的那仿真时需要模拟DAC输入端的建立时间和保持时间。Testbench里可以定义DAC的时序模型在时钟边沿采样数据然后把采样到的数字值转换成模拟电压打印出来。这样你能直接看到“数字DDS模拟DAC”的整体效果比只看数字波形更直观。举个例子如果DAC是12位、满量程2V那每个数字值对应的模拟电压就是2V * code / 4096。你在Testbench里写一个简单的转换函数function real dac_output; input [11:0] code; begin dac_output 2.0 * code / 4096.0; end endfunction然后在每个时钟上升沿把sine_out转成模拟值用$display输出文本波形可以配合$fwrite写到一个文本文件里之后无论你是想用脚本画图还是拿进Matlab做FFT分析都方便。这一步其实就是“数字仿真”和“模拟验证”之间的桥梁也是我偶尔还会开一下Matlab的唯一理由。5.3 资源占用与性能评估最后聊聊体检报告里的“资源占用”这一项。DDS模块本身非常省资源32位累加器只有32个触发器加上一个256×10bit的ROM在大多数主流FPGA上只占不到百分之一的逻辑资源。但如果你把DDS拓展成多通道比如4路信号输出ROM可以共享累加器和输出寄存器就要复制四份资源占用基本是线性的。时序方面100MHz时钟下ROM输出到寄存器的路径通常没压力。如果你把时钟超频到250MHz或者ROM深度加大到4096且用分布式RAM实现组合逻辑路径变长时序收敛就会开始紧张。这时候可以尝试把相位到地址的组合逻辑打一拍也就是在ROM地址生成后加一级寄存器牺牲一个周期延迟换取更高Fmax。这种方法我实测下来非常有效。如果要在Vivado里看准确的资源报告综合之后打开“Utilization Report”重点关注“FF”和“LUT”两栏。DDS这种数据通路简单、存储占比高的模块如果LUT消耗异常大通常说明ROM被推断成了逻辑实现而不是Block RAM。这时候可以按FIFO/RAM方式手动例化一个真双口ROM或者给综合工具加一条“ram_style block”的信号综合属性把存储迁移到Block RAM里能大幅降低LUT使用量。5.4 最后的调试心得仿真文档比代码更重要做DDS“全身体检”这件事技术含量其实不在写代码而在你有没有一套完整的仿真验证流程和检查记录。我现在的习惯是每跑一轮仿真就把频率、相位连续性和幅度误差记录下来保存成表格再跟上一轮结果对比。看起来麻烦但对定位问题是真有用。有一次频率从1MHz切到2MHz时出现了微小的幅度波动翻了半天代码没找到原因最后对比记录才发现是ROM表里有一个地址被初始化成了错误数值而且是上次改参数时手工改错留下的。如果没有记录这种问题可能要排查一整天。另外DDS的Verilog代码加上Testbench整体是个非常完美的入门到进阶练习项目它涵盖了参数化设计、状态初始化、ROM初始化、时序约束、仿真验证这些FPGA开发的核心话题。你如果能把这份“体检报告”里的每一项都搞明白不只是能调通一个DDS你对整个数字逻辑设计的理解都能上一个台阶。算是花一份时间拿双倍回报。
企业数字化 ERP 产品动态
相关推荐
LuatOS赋能EC618:Cat.1模组开发范式升级 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/24 12:50:44
全志T113-i嵌入式Linux启动优化实战:从6秒到2.5秒 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/24 12:50:44
VSCode 搭建 Verilog 开发环境:ctags 跳转与 iverilog 仿真 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/24 12:50:44
开关电源EMC失效的PCB布局根源与实战对策 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/24 13:26:27
时间序列滞后相关性分析:用hister精准定位变量间延迟关系 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/24 13:26:27
Modbus调试总抓狂?从协议坑到诊断工具的高效排查方法论 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/24 13:26:27
差分探头匹配电容调整指南:原理、选型与实战技巧 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/24 13:26:27
Bandgap设计中的启动电路与偏置电路:从简并偏置点到Cadence仿真实战 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/24 13:26:27
CH340/CH341驱动安装全攻略:Win10/Win11三种方案与避坑指南 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/24 13:26:20
基于YOLOv8的渔船作业监控系统:从环境搭建到边缘部署全流程 简介:这是一套面向计算机、人工智能、自动化等专业学生与教师的毕业设计级项目资源,围绕YOLOv8实现渔船作业监控系统,可用于毕设、课程设计、大作业或项目立项演示。压缩包共97个文件,约24.21MB,以70个Python源码文件为… · 2026/9/24 0:00:13
1D-CNN时间序列建模实战:从Conv1d原理到工业落地 简介:面向时间序列数据建模的一维卷积神经网络完整实现,适合深度学习入门者及需要快速验证时序模型的研究者,能够从音频、文本、传感器或股价等序列中挖掘局部特征与时间依赖。压缩包体积很小,只有3KB,内含3个Python脚… · 2026/9/24 0:00:26
柔软的L:汉语语流中被忽视的舌肌张力控制 1. 这个“L”不是字母表里的L,而是舌尖上的L最近在几个方言群和语音教学社群里,反复看到有人发一句:“也说字母L:柔软的长舌”。初看以为是英语发音课笔记,点开才发现全是方言爱好者、播音系学生、语言康复师甚至戏曲演… · 2026/9/24 0:00:44