用Vivado和Verilog在FPGA上做一个DDS信号发生器,这个组合应该是每个学数字逻辑设计的人迟早要碰到的经典工程。DDS全称Direct Digital Synthesizer,直接数字频率合成,说人话就是靠纯数字逻辑“算”出正弦波、方波、三角波,再配一个DAC就能变成真正能输出模拟信号的信号发生器。这篇我会把DDS原理、参数计算、Vivado工程搭建、完整Verilog代码、仿真验证和常见坑位全部拆开讲,代码不依赖任何IP核,逻辑透明,方便你后面自己改造。我自己调DDS的时候踩过不少坑,比如仿真波形出不来、频率算错一位、上板后波形全是毛刺,这次一并整理出来,适合刚学FPGA、想从零跑通DDS信号发生器的同学直接照做。
1. DDS原理与整体设计思路
1.1 DDS为什么能“无级变频”
DDS的核心就两样东西:一个相位累加器,一张正弦查找表。相位累加器可以理解成一个很长的计数器,每个时钟沿到来时,它会加上一个“频率控制字”。这个频率控制字就是每步走的相位增量,累加器加满溢出后回到0重新开始,因为相位是连续的,所以输出波形天然相位连续,不会出现频率跳变时波形断掉的情况。
举个例子:你把一周期的正弦波相位切成2的32次方份,频率控制字就是每次时钟走的份数。走256份和走1024份,走完一整圈的时间完全不同,输出频率自然不同。这就实现了“无级变频”——只需要修改频率控制字,不需要像PLL那样重新锁定,DDS的频率切换可以做到瞬间完成,非常适合跳频通信、扫频源、FM调制这类需要快速切换频率的场景。
相位累加器输出的是“当前相位”,把这串数字映射成正弦幅度就是查找表要做的事。查找表本质上是一块ROM,里面按顺序存了一个周期正弦波形的量化幅值,相位累加器的高位作为地址去查表,查出来的数据就是正弦波的数字表示。
1.2 频率控制字与相位位宽的数学关系
DDS最核心的公式只有一个,必须先吃透。
[ f_{out} = \frac{FTW \times f_{clk}}{2^{N}} ]
其中FTW是频率控制字,f_clk是系统时钟频率,N是相位累加器位宽。反过来,给定目标频率,FTW的计算公式是:
[ FTW = \frac{f_{out} \times 2^{N}}{f_{clk}} ]
我这次用的是50MHz系统时钟,相位累加器32位。50MHz除以2的32次方,得到频率分辨率大约是0.0116Hz,意思就是理论上最低能出0.0116Hz的信号,调频最小步进也是这个量级。对于绝大多数实验来说,这个分辨率已经远远够用了。
32位相位累加器如果把全部32位都拿去查表,需要4G个存储单元的ROM,这显然不合理。实际做法是只取高12位或者高16位作为查找表地址,低20位直接截断,这叫相位截断。我这次取高12位,也就是4096点正弦表,每个周期用4096个相位点去逼近一个正弦波,理论上的相位截断杂散大概在73dBc左右,对入门实验完全够用。
| 目标频率 | FTW取整 | 实际输出频率 | 相对误差 |
|---|---|---|---|
| 1kHz | 85899 | 约999.99Hz | 0.001% |
| 10kHz | 858993 | 约9999.98Hz | 0.0002% |
| 100kHz | 8589934 | 约100000.0Hz | <0.001% |
| 1MHz | 85899346 | 约1000000Hz | <0.001% |
误差来源很简单,FTW是整数,取整后总有舍入误差。但因为N足够大,频率分辨率足够高,这个误差通常可以忽略。我习惯在代码里把这些参数写成parameter,后面改频率只改一个数字,比直接改逻辑方便得多。
1.3 为什么用FPGA而不是单片机或DDS芯片
用单片机生成正弦波,最常用DAC加查表的方式,但MCU的DAC速率有限,还要靠中断或定时器不停送数,频率高了以后CPU占用率非常难看。DDS芯片比如AD9951、AD9910这类,性能确实强,频率能到几百MHz甚至上GHz,但我们自己做实验往往用不到那么高,而且DDS芯片外围电路、寄存器配置、参考时钟设计都不算简单,灵活性也没有FPGA高。
FPGA做DDS最大的优势是并行性和灵活性。一个DDS核心可以轻松复制成8个甚至16个通道,每个通道独立频率、独立相位,这在DDS芯片里实现起来就麻烦多了。另外FPGA里可以随意改波形表,把正弦表换成任意自定义波形,甚至运行时动态切换波形类型,这在教学实验、仪器仪表原型验证里非常实用。做信号发生器,FPGA普遍能输出几十MHz以内的波形,覆盖日常测试需求,作为入门项目性价比很高。
2. 工程搭建与查找表生成
2.1 Vivado版本与工程结构
我这边用的是Vivado 2020.2,后面版本比如2022.2也完全兼容,只要支持Verilog和XSim仿真就行。新建工程的步骤是:Create Project -> RTL Project -> 添加源文件,先把顶层模块和查找表放进去,testbench留到仿真阶段再添加。这个工程不需要用到IP核,也不需要约束文件,仿真阶段纯靠testbench就够。
工程的文件结构建议保持简单,根目录下放三个文件:
- dds_top.v,顶层模块,包含相位累加器和例化查找表
- sine_lut.v,查找表模块,从外部hex文件加载正弦数据
- sin_4096.hex,由Python脚本生成的查找表初始化文件
如果你不习惯把hex文件放在工程根目录,也可以单独建一个data文件夹,但要注意$readmemh的路径必须对应。
2.2 正弦表设计:为什么要偏移半个量程
正弦波在数学上范围是-1到+1,但FPGA内部处理的一般是无符号定点数。如果输出数据位宽16位,取值范围是0到65535,那么正弦波就应该向左偏移半个量程,也就是让中心值落在32768附近。这样输出的数字波形就是0到65535之间来回摆动的正弦形状,直接给外部DAC送数完全没问题。
4096点正弦表的存储量是4096乘以16bit,等于64Kbit,用Xilinx 7系列FPGA的BRAM来存,两块36Kb的BRAM就装下了。对FPGA来说这个开销很小,未来如果要输出更高精度的波形,把位宽提到24位、点数提到16384点,也只是换一个查找表数据文件的事,代码框架不用动。
查找表的数据生成方式有很多,可以用MATLAB,可以用Python,甚至可以用Excel。我常用的Python脚本在主流程里直接生成,逻辑简单、可复现、改参数也方便。
2.3 用Python生成4096点查找表
下面是生成sin_4096.hex文件的Python脚本,作用是生成一个16位无符号偏移二进制的正弦表。每一个ROM地址对应一个周期的均匀相位,把这个文件放到工程根目录,仿真和综合加载的都是它。
import math N = 4096 # 查找表点数,对应ADDR_WIDTH=12 W = 16 # 输出位宽 MID = 1 << (W-1) # 偏移量 32768 with open("sin_4096.hex", "w") as f: for i in range(N): # 正弦值映射到 [-1, 1],乘上偏移量再加偏移,得[0, 65536] v = int(round(math.sin(2 * math.pi * i / N) * MID) + MID) if v >= (1 << W): v = (1 << W) - 1 f.write(f"{v:04X}\n") print("done, total lines:", N)注意生成的是十六进制文本,每行一个值,不带0x前缀。Vivado的$readmemh要求就是这种格式。脚本运行完,你可以打开文件看一眼,第一行应该接近0x8000,也就是32768,因为起始相位是0,sin(0)=0,加上偏移正好在中点。往后逐渐增大到接近0xFFFF,再慢慢回到0x8000附近,底半周继续往下走到接近0x0000,最后回到起点中点附近。确认这个趋势就说明表生成对了。
3. Verilog核心代码:DDS主模块、查找表、扩展接口
3.1 顶层模块dds_top.v
顶层模块的核心就是相位累加器。每来一个时钟上升沿,累加器加上频率控制字freq_word。复位时清零。把累加器的高12位送给查找表,查出来的sine_out就是正弦波的数字输出。
module dds_top #( parameter PHASE_WIDTH = 32, parameter ADDR_WIDTH = 12, parameter DATA_WIDTH = 16 )( input wire clk, input wire rst_n, input wire [PHASE_WIDTH-1:0] freq_word, output wire [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{1'b0}}; else phase_acc <= phase_acc + freq_word; end // 取高12位作为查找表地址 sine_lut #( .ADDR_WIDTH(ADDR_WIDTH), .DATA_WIDTH(DATA_WIDTH) ) u_sine_lut ( .clk (clk), .addr(phase_acc[PHASE_WIDTH-1 -: ADDR_WIDTH]), .dout(sine_out) ); endmodule高位截取的语法phase_acc[PHASE_WIDTH-1 -: ADDR_WIDTH]是Verilog里的部分选择,意思是从第31位开始向下取12位。这个写法比手动写phase_acc[31:20]优雅得多,参数改了之后也不用跟着改数字。
为什么只取高12位?因为前面已经算过,32位全部查表不现实,地址位宽决定了ROM大小。12位地址对应4096个点,在50MHz采样下,输出10kHz正弦波时每个周期大概5000个采样点,查表的地址平均每1.25个时钟跳一个数,波形很平滑。输出频率更高时,每个周期采样点变少,波形阶梯感会变强,但那是采样率决定的原理性现象,不是代码写错了。
3.2 正弦查找表sine_lut.v
查找表模块我用了一块真双端口RAM风格的存储数组,配合$readmemh在初始化阶段加载hex文件,然后每个时钟周期按地址输出一个值。这样实现的好处是代码极短,不依赖IP核,仿真起来也直观。
module sine_lut #( parameter ADDR_WIDTH = 12, parameter DATA_WIDTH = 16 )( input wire clk, input wire [ADDR_WIDTH-1:0] addr, output reg [DATA_WIDTH-1:0] dout ); reg [DATA_WIDTH-1:0] mem [0:(1<<ADDR_WIDTH)-1]; initial begin $readmemh("sin_4096.hex", mem); end always @(posedge clk) begin dout <= mem[addr]; end endmodule注意这里的输出用了寄存器,也就是输出会比地址变化晚一个时钟周期,这在DDS链路里会引入一拍延迟,但不会影响波形连续性。如果你对相位对齐有要求,可以在下游模块里对同一路径做等延迟补偿。对于普通信号发生器项目,这一拍延迟完全可以忽略。
$readmemh在仿真时是直接可用的。如果要上板,不同FPGA综合器对$readmemh的支持程度不一样,稳妥做法是把查找表数据做成coe文件用Block Memory Generator初始化,或者直接生成一个用case语句硬编码的Verilog文件。我在工程演示阶段用$readmemh最方便,真要上板实测的时候再换IP核,两者波形数据完全一致,不影响验证结果。
3.3 留一个后门:随便换表就是任意波形发生器
DDS最舒服的一点是,查找表里存什么波形,输出就是什么波形。存正弦表就是正弦发生器,存任意自定义波表就变成任意波形发生器。这个思路在仪器仪表领域被大量使用,很多信号发生器的DDS部分其实就是一块波表在不同索引下循环回放。
如果你还想顺便输出方波,其实不需要额外查表,直接看相位累加器的最高位就行。相位累加器最高位为0表示现在处在相位的前半周期,为1表示后半周期,天然就是方波。在顶层加上下面这行逻辑,就能得到一个和正弦波严格同频的方波输出。
assign square_out = phase_acc[PHASE_WIDTH-1] ? {DATA_WIDTH{1'b1}} : {DATA_WIDTH{1'b0}};三角波同理,可以把相位高位的值做翻转得到。我自己实验时喜欢让一个DDS核心同时输出正弦、方波、三角波,这样给后级模拟电路调试的时候特别方便,不用再单独写几个波形模块。
4. 仿真验证:从testbench到波形分析
4.1 仿真测试平台设计思路
写testbench的核心思路是:给DDS提供一个50MHz时钟,先让它输出10kHz正弦波,跑一段时间后切换到100kHz,观察频率切换是否正常。同时统计正弦波跨过中点上升沿的次数,用来粗略验证输出频率对不对。
下面这个testbench是完整的,直接添加到工程里仿真就能跑。
`timescale 1ns/1ps module tb_dds_top; reg clk = 0; reg rst_n = 0; reg [31:0] freq_word = 32'd0; wire [15:0] sine_out; // 10kHz和100kHz对应的频率控制字 localparam [31:0] FTW_10K = 32'd858993; localparam [31:0] FTW_100K = 32'd8589934; integer cnt_10k = 0; reg [15:0] sine_prev = 16'd0; always #10 clk = ~clk; // 20ns周期,50MHz initial begin #100 rst_n = 1; freq_word = FTW_10K; #500_000; $display("10kHz zero-cross count = %0d", cnt_10k); $display("10kHz measured freq = %f Hz", cnt_10k / 0.5e-3); freq_word = FTW_100K; #200_000; $finish; end // 统计正弦输出跨过中点(0x8000)上升沿的次数 always @(posedge clk) begin sine_prev <= sine_out; if (sine_out >= 16'h8000 && sine_prev < 16'h8000) cnt_10k = cnt_10k + 1; end dds_top #( .PHASE_WIDTH(32), .ADDR_WIDTH(12), .DATA_WIDTH(16) ) u_dds ( .clk (clk), .rst_n (rst_n), .freq_word(freq_word), .sine_out (sine_out) ); endmodule有人会问,统计过零次数是不是多此一举?其实这个动作非常实用。人为观察波形只能看出“像不像正弦”,看不出频率准不准。用计数器统计0.5毫秒内的周期数,如果10kHz,理论上应该数到5个完整的正弦周期。数出来是5,说明FTW算对了;数出来差很多,那就是时钟频率、累加器位宽、FTW三者对不上,能快速定位问题。
4.2 在Vivado里跑仿真
在Vivado的Sources窗口里,右键模拟源文件,Add Sources,把testbench文件添加进去,然后点击Flow Navigator里的Run Simulation -> Run Behavioral Simulation。第一次进入仿真会打开波形窗口,默认可能只运行1微秒,这点时间在10kHz下只能看到不到1个波形周期。你需要点击工具栏上的Run All,或者直接按快捷键,让它跑完testbench里的所有时间。
波形窗口打开后,默认显示的是整个testbench的信号,可能只显示顶层信号。把u_dds里面的phase_acc信号拖进来,就能看到相位累加器在一个斜坡上持续累加的过程。sine_out信号右键修改Radix,选Unsigned Decimal,波形会变成0到65535之间的正弦形状。
如果你看到的是杂乱无章的中间值,或者一条不动的直线,先别急着怀疑代码,先检查仿真时间是否跑够。波形生成需要时间,50MHz时钟下10kHz信号一个周期要5000ns,仿真至少要几万个ns才能看到完整效果,很多新手觉得波形出不来,其实只是跑的时间太短。
4.3 如何验证频率:我们算得准不准
仿真结束后,Transcript窗口会打印出统计结果。如果一切正常,你会在里面看到:
- 10kHz zero-cross count = 5
- 10kHz measured freq约等于9999.98Hz
这个结果说明FTW计算无误,DDS工作正常。接着看100kHz段的波形,你可以把光标放在任意相邻两个波峰之间测量间隔,100kHz对应10微秒一个周期,如果测量出来是10.000us,说明频率切换也没有问题。
有一点值得注意,观察频率切换时不要只盯着波形是否出现“阶梯跳变”,DDS的相位是连续的,波形不应该有突然断裂。如果你在切换频率的瞬间看到波形有明显的毛刺、跳变或者交流耦合偏移,那大概率是freq_word在运行时被非同步赋值,或者查找表输出有未复位的初始值。我这个testbench是在initial里改freq_word,复位之后才赋值,时序上是安全的。
5. 调试实录:常见问题与排查方法
5.1 波形一直不出来的几个原因
DDS仿真波形不出来的原因,我总结下来90%是下面几个:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 波形一直为0 | rst_n始终拉低,复位没释放 | 检查testbench里复位时序 |
| 波形是一条直线 | freq_word为0,相位不累加 | 确认FTW值不为0 |
| 波形只出现一半 | 仿真时间太短 | 多跑几个毫秒再看 |
| 波形是毛刺密布 | 时钟频率和PC端时间刻度不匹配 | 确认timescale是否正确 |
| $readmemh报错文件找不到 | hex文件路径不对 | 使用绝对路径或放到工程根目录 |
其中$readmemh路径问题是Vivado仿真的常见坑。Vivado运行行为仿真时,默认当前目录是工程目录,如果你的文件放在别的地方,路径就找不到。我推荐直接把sin_4096.hex放到工程根目录,然后用相对路径。如果还报错,就改成绝对路径,比如/home/user/project/sin_4096.hex,这也是一种快速验证方案。
5.2 输出频率不对:从公式验算到测试向量
频率不对这件事,多数时候不是代码问题,而是参数算错了。最典型的是把系统时钟频率当作100MHz算FTW,但实际板上时钟是50MHz;或者反过来,用50MHz算FTW,但代码里用了100MHz的PLL输出。
定位方法很简单:反推一遍公式。比如你想出10kHz,仿真里看实际过零统计,发现有9个周期而不是5个,立刻就能看出实际频率比预期高了近一倍。这时候检查时钟频率、累加器位宽、FTW三者的配合关系,基本一眼就能查出问题。
还有一种情况是FTW被覆盖。比如顶层模块里既有parameter初始值,又在某个always块里被赋值,这种多驱动问题在Vivado里不一定报错,但仿真结果一定会诡异。我的习惯是频率控制字只在顶层保留输入端口,内部绝不另加赋值,这样天然避免多驱动。
5.3 上板实测:这几件事最容易翻车
仿真通过之后,上板是另一个世界。首当其冲的是引脚约束,即使只是想把数字波形引到LED或者PMOD接口看,也必须写XDC约束文件。忘了约束引脚的话,综合会报错或者把信号随便分配到某个引脚上。
如果接了外部DAC,最容易翻车的点是输出极性。查找表生成时我用了偏移二进制,也就是无符号0到65535,那么DAC输入也应该是无符号格式。如果你的DAC芯片是补码输入,波形会整体偏移一半量程,看起来像个直流叠加在正弦上,这时候在DAC输入端减去0x8000就行。
输出毛刺也是上板常见问题。FPGA引脚输出数字波形时,高速翻转会产生振铃,如果后端DAC没有做好滤波,模拟端就会看到毛刺。最简单的处理是在DAC输出后面加一个RC低通滤波器,截止频率放在目标最高频率和采样频率之间,比如采样率50MHz、目标输出10kHz,RC截止频率设到1MHz左右就够去毛刺了。这个方案对普通测试完全够用。
6. 让DDS更接近“产品”
6.1 用DDS做扫频信号源
做完固定频率输出,我强烈建议你顺手做一个扫频功能。逻辑很简单:每隔几个时钟周期让频率控制字增加一个固定步长,DDS就会从f_start一直扫到f_stop。扫频在音频测试、滤波器频率响应测试里非常常见。
实现方式也直白,在原来的always块里多加一个累加逻辑。注意扫频时FTW每次增加的值要远小于累加器位宽,不然频率会跳变明显。用50MHz时钟输出1kHz到100kHz扫频,FTW从85899增加到8589934,每次加几百几十比较合适,具体步长和扫描时间可以按需求调整。
6.2 改善波形质量的几个方向
第一个方向是提高查找表分辨率。把地址位宽从12位提到16位,查找表变成65536点,存储量变成1Mbit,两块BRAM变成更多块,但相位截断杂散会明显改善,波形也更细腻。第二个方向是加数字低通滤波。DDS输出的数字波形在频域上有镜像频谱,理论上需要模拟低通滤波器,但如果在FPGA内部先用CIC滤波器或者FIR滤波器做一次插值滤波,下游模拟滤波器的压力会小很多。第三个方向是加相位抖动,也就是dither,随机化相位截断误差,把尖锐杂散打散成底噪,很多商用DDS芯片都会用这个技巧。
6.3 我调DDS时的一些体会
我调这个DDS工程最深的体会是,看起来复杂的东西,拆到最后其实就一个累加器和一张表。刚开始接触DDS的同学容易把精力放在“怎么把正弦表生成得更漂亮”上,但其实对入门来说,先跑通流程、理解频率控制字和相位累加器的关系,才是最重要的事。我甚至建议你把查找表换成随便一个自定义波形,比如锯齿、三角、双正弦叠加,跑一遍看波形变化,这个过程对理解DDS的帮助比多抄几遍代码大得多。做实验的时候不要怕改参数,大胆把N从32改成16,把地址位宽从12改成8,看看波形会变成什么样,这样踩出来的坑,印象比看十篇教程都深。