ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

FPGA上的DDS信号发生器:原理、Verilog代码与仿真调试全解析

FPGA上的DDS信号发生器:原理、Verilog代码与仿真调试全解析 用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取整实际输出频率相对误差1kHz85899约999.99Hz0.001%10kHz858993约9999.98Hz0.0002%100kHz8589934约100000.0Hz0.001%1MHz85899346约1000000Hz0.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_WIDTH12 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因为起始相位是0sin(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{1b0}}; 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:(1ADDR_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{1b1}} : {DATA_WIDTH{1b0}};三角波同理可以把相位高位的值做翻转得到。我自己实验时喜欢让一个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 32d0; wire [15:0] sine_out; // 10kHz和100kHz对应的频率控制字 localparam [31:0] FTW_10K 32d858993; localparam [31:0] FTW_100K 32d8589934; integer cnt_10k 0; reg [15:0] sine_prev 16d0; 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 16h8000 sine_prev 16h8000) 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 510kHz measured freq约等于9999.98Hz这个结果说明FTW计算无误DDS工作正常。接着看100kHz段的波形你可以把光标放在任意相邻两个波峰之间测量间隔100kHz对应10微秒一个周期如果测量出来是10.000us说明频率切换也没有问题。有一点值得注意观察频率切换时不要只盯着波形是否出现“阶梯跳变”DDS的相位是连续的波形不应该有突然断裂。如果你在切换频率的瞬间看到波形有明显的毛刺、跳变或者交流耦合偏移那大概率是freq_word在运行时被非同步赋值或者查找表输出有未复位的初始值。我这个testbench是在initial里改freq_word复位之后才赋值时序上是安全的。5. 调试实录常见问题与排查方法5.1 波形一直不出来的几个原因DDS仿真波形不出来的原因我总结下来90%是下面几个现象可能原因排查方法波形一直为0rst_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、目标输出10kHzRC截止频率设到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看看波形会变成什么样这样踩出来的坑印象比看十篇教程都深。
返回列表