
做FPGA的兄弟应该都有体会信号发生器这东西在调试电路的时候太常用了——电机控制要PWM、音频要正弦波、通信要调频调相没有个能灵活出波形的模块很多时候就只能用开发板上的LED闪灯凑合着看。但真要买台信号发生器便宜的几百块带任意波形的直接上千而且拿到手也不一定能满足你项目里那些定制化的调制需求。所以这次干脆在FPGA上自己做一套DDS信号发生器不依赖任何DDS专用芯片纯靠Vivado和Verilog把核心逻辑搭出来。DDS全称Direct Digital Synthesis直接数字频率合成本质就是用一块FPGA内部的数字电路来“算”出你要的波形。它最大的特点就是频率切换快、精度高、相位连续而且输出频率可以做到非常精细的步进这些特性正好是模拟信号源很难做到的。我这篇文章不搞虚的从DDS工作原理一步步拆开讲然后给你一套能直接跑起来的完整Verilog代码、Testbench仿真脚本以及Vivado里的完整操作流程。不管你是刚入门FPGA的新手还是有一定底子想系统做一遍信号源的兄弟跟着走完这一遍你会对数字频率合成这件事有非常直观的理解代码拿走就能在自己的板子上用。1. DDS到底是怎么工作的——先搞懂原理再写代码很多朋友拿到DDS的第一反应是去看datasheet里那些参数表然后被相位累加器、查找表这些名词劝退。实际上DDS的原理没有想象中复杂它本质上就是“以数字方式控制一个相位随时间线性增加再用这个相位去查一张预先存好的波形表”。我尽量用大白话把这条链路讲透。1.1 相位累加器DDS的“心脏”是怎么跳动的相位累加器是整个DDS的核心没有之一。你可以把它想象成一个数字秒表每一拍时钟就自动加一个数。这个“加多少”不是固定的而是由你设置的频率控制字Frequency Control Word简称FCW决定。比如累加器位宽是N位那么它从0加到2^N-1再归零就完成一个完整的计数周期。这个计数值就代表波形的相位从0到360度被分成了2^N份。输出的信号频率就等于f_out f_clk × FCW / 2^N这个公式是DDS设计的灵魂所有参数计算都绕不开它。举个例子假设系统时钟是50MHz累加器位宽是32位想输出1kHz的正弦波那FCW算出来就是FCW 1000 × 2^32 / 50_000_000 ≈ 85899看到没85899这个数字看起来不大但它能通过32位累加器控制出1kHz这么低频率的信号这说明DDS做低频信号特别拿手。反过来如果你想要10MHz的高频信号50MHz时钟下直接输出10MHz已经到极限了要满足奈奎斯特定理输出频率不能超过时钟的一半但用DDS可以轻松推上去。1.2 相位截断与波形查找表让资源消耗大幅下降如果相位累加器输出32位你直接拿这32位去查波形表那就得建一个2^32深度的存储器这在FPGA里完全不现实成本高得离谱。实际工程中普遍采用“相位截断”的做法只取相位累加器的高位去查表低位直接扔掉。比如32位累加器只取高12位波形表深度就降到了4096个点和2^32相比暴跌了100万倍但引起的频率误差非常小相位误差低于1/4096个周期对绝大多数应用来说完全够用。这就是工程上的“用精度换资源”属于DDS设计中最典型的取舍。我一开始自己写DDS的时候也纠结过要不要保留完整精度后来实测发现截断后的波形在频谱上的杂散分量确实存在但幅度很低如果你后面再接一级低通滤波器这些杂散基本就看不见了。所以新手阶段完全不用担心相位截断带来的负面影响先把核心电路跑通更重要。1.3 波形存储的两种方式ROM IP核和组合逻辑查找波形存储是DDS的另一半核心。存波形有两种常用办法第一种是用Xilinx提供的Block Memory Generator IP核把波形数据初始化到ROM里。这种方式最灵活波形想存什么就存什么正弦波、三角波、任意波都可以。需要先用Matlab或者Python生成coe文件然后在Vivado里配置ROM IP核把coe文件加载进去。我后面会详细演示这条路。第二种方式是用组合逻辑直接计算波形值比如利用Cordic算法算正弦函数。这样省掉了一块存储资源但会消耗更多逻辑单元而且代码复杂度明显增加。对于新手来说我强烈建议先把ROM方案吃透Cordic算法可以作为后续进阶方向。另外说一句波形表深度和输出数据位宽的设定也有讲究。深度决定相位分辨率宽度决定幅度分辨率。一般来说深度4096、宽度12位或者16位就是很均衡的配置人眼看波形已经很平滑了。2. 系统架构与模块化设计——先把框架立起来写FPGA代码和写软件有点像最忌讳一上来就堆代码。我先说模块怎么划分再说Verilog怎么写。2.1 顶层模块划分每个模块干一件明确的事我推荐把DDS信号发生器拆成这几个模块dds_top.v顶层模块负责例化所有子模块对外提供时钟、复位、频率控制字输入和波形输出接口。phase_accumulator.v相位累加器模块输入FCW输出累加相位值。波形ROM用Vivado的IP核生成不需要手写存储逻辑。testbench仿真激励文件用来验证DDS输出是否满足预期。这个拆分方式有个好处每个模块非常独立后期如果要加幅度控制、频率调制、扫频功能都是加模块或改接口的事不会影响到已有的核心电路。我见过不少同学把所有功能硬塞到一个module里最后综合不过或者仿真一改全崩根子就在于一开始没做好模块划分。2.2 接口设计用参数化方法让模块复用性更强为了让代码好维护我没有把累加器位宽写死而是用了parameter参数化设计。这样同一个模块既能用在50MHz系统下也能搬到100MHz系统下只需要重新算一下FCW就好了。顶层接口设计如下module dds_top #( parameter PHASE_WIDTH 32, // 相位累加器位宽 parameter LUT_WIDTH 12, // 查找表地址位宽 parameter DATA_WIDTH 12 // 输出数据位宽 )( input wire clk, input wire rst_n, input wire [PHASE_WIDTH-1:0] fword, // 频率控制字 output wire [DATA_WIDTH-1:0] wave_out // 波形输出 );在这里加粗强调一点fword的位宽必须和PHASE_WIDTH一致否则仿真时会出现位宽不匹配的warning严重的直接导致频率算不对。这个坑我当年踩过写Testbench的时候为了方便直接给了个常量结果因为位宽不够高频输出完全不对。2.3 为什么选择“时钟-复位-控制-输出”这种标准接口可能有人觉得顶层接口多定义几个信号不是更灵活吗比如加一个波形选择信号、加一个幅度控制信号。我的建议是第一版先把最核心的跑通接口越简单越不容易错。加了波形选择就得在ROM那边加多路复用逻辑加了幅度控制就得挂乘法器这对于还没验证核心DDS功能的新手来说平白多了几个出错点。先做成一个“最小可行系统”跑通了再加功能是我做FPGA项目一以贯之的节奏。这套DDS也是一样第一版就是纯正弦波固定输出验证无误之后再扩展。3. Verilog代码实现与关键细节剖析——逐行写给你看理论聊完框架也搭定了现在直接上代码。我会把每一段代码都拆开讲清楚确保你能看懂每一个always块在干什么而不是光把代码贴出来就完事。3.1 相位累加器Verilog代码详解相位累加器的Verilog实现非常简洁就是一个带复位的加法器module phase_accumulator #( parameter WIDTH 32 )( input wire clk, input wire rst_n, input wire [WIDTH-1:0] fword, output reg [WIDTH-1:0] phase ); always (posedge clk or negedge rst_n) begin if (!rst_n) phase {WIDTH{1b0}}; else phase phase fword; end endmodule这段代码的逻辑核心就一句话phase phase fword。每个时钟上升沿相位累加器就自动加上一个频率控制字。注意这里用的是非阻塞赋值这在时序逻辑里必须遵守如果你写成综合结果会完全变样仿真也许碰巧能过但上板必出问题。有人会问累加器溢出怎么办答案是让它自然溢出就好。Verilog里位宽固定的加法器在溢出时会自动回到0再继续加这正好就是DDS需要的“相位回绕”——相位走到360度就该归零重新开始。不要试图去“处理”溢出这是DDS算法本身的一部分。3.2 使用Block Memory Generator生成波形ROM接下来是建立波形查找表。我在Vivado里推荐用IP核方式步骤如下先准备好波形coe文件。这一步可以用Matlab或Python生成。我习惯用Pythonimport math DEPTH 4096 WIDTH 12 with open(sine_4096x12.coe, w) as f: f.write(memory_initialization_radix10;\n) f.write(memory_initialization_vector\n) for i in range(DEPTH): val int((math.sin(2 * math.pi * i / DEPTH) 1) / 2 * (2**WIDTH - 1)) if i DEPTH - 1: f.write(f{val};\n) else: f.write(f{val},\n)这段代码生成的是单极性波形0到4095好处是后面接DAC或者直接观察都比较方便。如果你想要双极性波形比如-2048到2047需要多一步二进制补码的转换。在Vivado里添加IP核搜索“Block Memory Generator”配置为Single Port ROM宽度12位深度4096加载刚才生成的coe文件。在顶层代码里例化这个IP核把相位累加器的高12位接到ROM的地址上ROM的输出就是波形的采样值。3.3 顶层模块连接代码顶层模块把前面的phase_accumulator和ROM例化到一起module dds_top #( parameter PHASE_WIDTH 32, parameter LUT_WIDTH 12, parameter DATA_WIDTH 12 )( input wire clk, input wire rst_n, input wire [PHASE_WIDTH-1:0] fword, output wire [DATA_WIDTH-1:0] wave_out ); wire [PHASE_WIDTH-1:0] phase; wire [LUT_WIDTH-1:0] addr; assign addr phase[PHASE_WIDTH-1 -: LUT_WIDTH]; phase_accumulator #( .WIDTH (PHASE_WIDTH) ) u_phase_accumulator ( .clk (clk), .rst_n (rst_n), .fword (fword), .phase (phase) ); blk_mem_gen_0 u_wave_rom ( .clka (clk), .addra (addr), .douta (wave_out) ); endmodule注意这段代码里的关键一行assign addr phase[PHASE_WIDTH-1 -: LUT_WIDTH];这行用了Verilog的“部分选择”语法意思是取phase的最高12位作为ROM地址。这种写法比phase[31:20]更安全因为改成参数化之后位宽变了它也能自动适配。3.4 ROM输出的位宽匹配问题ROM例化时输出位宽要和DDS顶层输出的DATA_WIDTH保持一致这里都是12位。如果你在IP核配置里选了16位输出而顶层接口的wave_out只有12位Vivado综合时不会报错只会给一个warning但波形数据会被截断这在很多时候会造成输出异常。所以遇到“仿真正常、上板输出不对”的问题时一定要回头核对IP核的位宽配置。4. Testbench仿真验证——让波形“眼见为实”代码写完必须仿真这一步不能省。仿真不只是看输出对不对更是理解DDS内部工作机制的最好方式。4.1 写一份能看懂的Testbench我给出的Testbench会根据不同频率控制字输出不同频率的正弦波并且用系统任务打印关键信息timescale 1ns / 1ps module tb_dds_top; reg clk; reg rst_n; reg [31:0] fword; wire [11:0] wave_out; dds_top #( .PHASE_WIDTH (32), .LUT_WIDTH (12), .DATA_WIDTH (12) ) u_dds_top ( .clk (clk), .rst_n (rst_n), .fword (fword), .wave_out(wave_out) ); initial clk 0; always #10 clk ~clk; // 50MHz时钟 initial begin rst_n 0; fword 32d0; #100; rst_n 1; // 先输出1kHz正弦波 fword 32d85899; // 通过公式计算得到 #200_000; // 切换为10kHz正弦波 fword 32d858993; #200_000; // 切换为100kHz正弦波 fword 32d8589935; #200_000; $finish; end initial begin $dumpfile(dds_top.vcd); $dumpvars(0, tb_dds_top); end endmodule有的同学可能觉得FCW随便填一个值就行反正波形能出来。但我的建议是每一次仿真都应该用公式验算过期望频率再通过仿真波形数周期去反推实际频率这样才能真正验证DDS设计的正确性。我把常用频率对应的FCW计算放在了下文表格里方便你验证。4.2 仿真结果怎么看波形频率与幅度的判断标准仿真跑完在Vivado的波形窗口里应该能看到wave_out是一串正弦波。这时不要只看“长得像正弦”就完事还需要做两个关键检查先看频率对不对。把光标放在波形的一个上升沿和下一个上升沿之间看时间差。比如50MHz时钟下1kHz信号的周期应该是1ms。如果你设了1kHz波形周期却不是1ms说明FCW计算或者位宽匹配有问题这时回头检查相位累加器位宽、截断地址是否配置一致。再看波形平滑度。12位正弦波在波形窗口里应该是比较平滑的曲线如果出现明显阶梯状说明地址位太短比如只有8位这时候需要调大LUT_WIDTH。这段仿真的步骤里有个常见坑有些人直接在Vivado里点了“Run Simulation”发现波形窗口空空的以为代码有问题其实往往是因为仿真时间设得太短正弦波一个周期还没走完。我在Testbench里写了200_000ns的持续时间这个时间足够看几十个周期你如果自己修改频率也要记得同步调整仿真时间。4.3 频率控制字参考速查表为了方便验证我列一个常用FCW参考表基于50MHz时钟、32位累加器目标频率FCW十进制FCW十六进制1kHz858990x00014FBB10kHz8589930x001D1B31100kHz85899350x00831D0F1MHz858993460x051EB8515MHz4294967300x1999999A这个表的作用不只是让你抄答案更重要的是你可以用这张表来验证你的公式是不是对的。比如表中100kHz对应的FCW是8589935你代入前面那个公式算一下应该能得到接近99.998kHz的数值。有小偏差是因为FCW四舍五入了实际误差在万分之几完全可接受。5. Vivado工程搭建实操——从新建工程到仿真跑通全流程上一节的代码如果能直接在文本编辑器里写完那这节就是把它变成一个能跑的Vivado工程。操作步骤看着琐碎但每一步都有它存在的意义我边操作边解释原因。5.1 新建工程与添加源文件打开Vivado选择Create Project选好芯片型号我用的是XC7A35T系列但代码是纯通用的任意型号都能跑。工程建好后先把三个文件加进去dds_top.vphase_accumulator.vtb_dds_top.v添加的时候注意testbench文件在Vivado里最好是放到Simulation Sources下而不是Design Sources下。如果你放错了综合的时候Vivado会把testbench当作可综合模块去跑然后给你报一堆莫名其妙的错误比如$finish不可综合之类的。这种错误让人头大其实根因就是文件分类搞错了。5.2 配置Block Memory Generator IP核接下来在IP Catalog里搜索“Block Memory Generator”双击打开配置界面。关键设置项Component Name起个有意义的名字比如blk_mem_gen_0。Memory Type选Single Port ROM。注意不是RAM我们只要读不要写。Write Width和Write Depth填12和4096。这里的Write Width和Write Depth管的是存储容量不是所有位宽都要开满。Load Init File勾上选择刚才Python生成的coe文件。配置完成后点OKVivado会自动生成一个IP核的例化模板。你可以右键IP核选择Open IP Example Design但更方便的做法是直接在顶层模块里照着Instantiation Template复制例化代码。每个IP核都会生成一份例化模板路径通常在Sources窗口的IP核下方。5.3 综合、仿真一条龙跑通所有源文件添加完毕IP核配置完成点击Run Synthesis。综合通过后再点Run Simulation → Run Behavioral Simulation。Vivado会自动打开仿真波形窗口这时候你应该能看到正弦波了。一个提醒behavioral simulation跑的是RTL级仿真不包含任何门延迟所以波形是比较“理想”的。对于DDS这种纯数字电路RTL仿真结果基本和实际一致但如果你接了DAC、运放这些模拟器件仿真阶段就无法覆盖了必须上板实测。如果你发现综合时间很长可以在设置里关掉不用的综合选项比如LUT Combining之类的优化项在初期并不重要。我一般会保留默认设置毕竟这个工程规模很小综合慢主要慢在启动Vivado本身。6. 常见问题与排查技巧实录——避坑清单这部分是我最想分享的很多经验是文档里找不到的全是我一次次踩坑换来的。6.1 综合报错DRC RTSTAT-2热搜里有网友提到vivado报错DRC RTSTAT-2这个错误我遇到过好多次翻译过来就是综合后的网表里某些信号处于输出悬挂状态Dangling Output。常见原因有两个一是顶层模块的输出端口没有在约束文件里定义引脚。Vivado对未分配引脚的输出一般只给warning但在某些严格设置下会升级为error。二是IP核的某个输出端口没接线。比如Block Memory Generator的douta如果你没连保留空悬综合器就会根据设置决定是否报错。解决办法打开Elaborated Design在I/O Planning视图里为所有外部端口分配引脚。如果你只是仿真不打算马上上板可以直接在约束文件里注释掉引脚分配或者设置set_property BITSTREAM.GENERAL.COMPRESS TRUE绕过但这不是正解。更好的习惯是始终为顶层输出分配好引脚哪怕你暂时不烧写。6.2 Implement Design变红的常见原因实现阶段报错变红是FPGA开发最让人崩溃的时刻之一。排查经验告诉我大部分情况无非这三类第一类是时序约束缺失。DDS本身是同步设计但如果你没有添加时钟约束某些版本的Vivado会报TIMING-1错误。解决办法使用create_clock为你的时钟引脚添加约束。第二类是资源不够。如果你的芯片本身太小ROM又占了大量Block RAM在Packing阶段就可能失败。解决办法调低LUT_WIDTH比如把4096点降到1024点或者换成更大的芯片。第三类是IP核配置和顶层代码不一致。比如你在IP核里设置了两个输出端口但代码里只连了一个。这个属于低级失误但挺常见的建议报错后第一步先去查所有IP核的例化代码。6.3 仿真能过、上板无输出的排查思路这个坑我当年踩得最深。仿真波形明明是漂亮的正弦波下载到板子上就是什么都不出。仔细查下来问题往往不在DDS逻辑本身而在外围。先检查时钟是否真的过来了。很多开发板的时钟是差分时钟或者经过MMCM/PLL分频后的时钟你不能假设板上的oscillator直接就是50MHz。用ILA集成逻辑分析仪抓一下时钟引脚或者简单用一个LED翻转程序验证时钟路径是否正常。再检查复位极性。这次代码里用的是异步复位低有效但有些开发板的按键按下是高电平那样你如果直接把按键接到rst_n上板子一上电就是一直处于复位状态。解决方法是要么在代码里取反要么在约束里设置按键反转。我在很多项目里看到新手在这里卡了整整一个下午。最后检查DAC或示波器探头。如果你是直接拿IO引脚出来看波形注意FPGA的IO输出是数字方波不是真正的模拟正弦波。你需要外接DAC模块或者RC低通滤波才能看到连续的正弦曲线。FPGA引脚上直接测量到的是PWM式的多bit数字信号这一点务必搞清楚。6.4 降低输出噪声的小技巧如果输出波形上叠了很多毛刺试着在顶层模块加一级D触发器打拍同步。DDS内部是纯同步电路但输出引脚上的翻转噪声有时候会被示波器捕获。在输出前加几级寄存器链能有效减少引脚反弹和串扰。另外FPGA的IO扇出也会影响信号质量你可以给wave_out加上set_property DRIVE 8之类的约束试试。7. 这套DDS还能怎么玩——扩展思路分享一个基础DDS做出来后能扩展的方向其实非常多。我觉得这才是做项目最有意思的地方核心部件搭好了想象力就有了载体。频率调制很容易只要把fword从一个固定值换成另一个动态变化的信号源就实现了FSK频移键控。你可以接一个按键每按一次换一个频率这就变成了一个简易的信号发生器。幅度控制也不难在ROM输出后面加一个乘法器乘数就是幅度控制字输出除以2^N就完成了调幅。加上调制信号就成了AM调制器。扫频功能更酷只要让fword随时间线性增加比如每N个时钟周期加一个固定步进就能输出一个从低到高的扫频信号。这在滤波器测试、音频扫频应用里非常实用。甚至我可以把相位累加器的某几个中间位引出来做成任意波形发生器。ROM里换成方波、三角波、锯齿波的数据那就是一台多功能信号源了。对于电机控制场景还能用DDS生成SVPWM的参考波。我个人实际做完这个DDS项目后的体会是越是基础的模块越值得花时间吃透因为它是无数复杂应用的地基。DDS这个项目工程量不大但它串起了Verilog语法、时序逻辑、IP核使用、仿真验证方法、板上调试技巧几乎涵盖了一个FPGA工程师的基础技能树。你把这条链路完整走一遍后面再做高速接口、复杂协议心态和方法论会稳很多。最后再分享一个小技巧在Vivado里仿真时不要只盯着波形窗口看。多打开Console窗口里的打印信息配合$display语句把关键中间变量打出来比如设置频率时的FCW值、复位释放的时刻、预期输出频率和实际输出频率对比。这种“打印大法”在调试复杂状态机时尤其管用整个数字逻辑调试点就藏在这些细节里。希望大家用这套代码做出自己的信号源有什么好玩的应用场景随时交流。