尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Verilog实现DDS正弦波信号发生器:从原理到仿真实战

Verilog实现DDS正弦波信号发生器:从原理到仿真实战 简介面向数字电路与FPGA学习者的Verilog正弦波信号发生器工程代码基于查找表方法生成正弦波形可帮助理解数字波形产生、硬件描述语言设计与仿真验证的完整流程。压缩包共689个文件大小约4.04MB主要包含Verilog与VHDL源文件、Quartus工程文件、存储器初始化文件.mif以及仿真数据文件等工程目录结构较完整可在Quartus II中直接打开查看与运行。已有3236人学习非常适合作为数字系统设计课程项目或FPGA入门练习。代码清晰展示了查表法正弦波发生器的实现细节包括通过计数器实现相位累加、利用二进制角度索引查找表、输出量化正弦值并配有必要的时序控制逻辑同时给出了仿真通过的结果与综合实现的相关配置读者可以据此快速复现波形生成效果深入学习硬件描述语言的综合、布局布线以及板级调试思路是一份高质量的FPGA波形生成参考工程。 写Verilog的人多多少少都会碰一次“正弦波信号发生器”面试题里有、课程设计里有、实际项目里做信号源和基带测试也绕不开。网上一搜“正弦波信号发生器verilog代码”能找到一堆demo但很多代码拿下来直接用就是不行要么仿出来波形不对要么上板后频率差得离谱要么根本不知道原理只能瞎调参数。这篇文章我会从DDS的原理讲起把相位累加器、查找表、位宽参数这些核心细节一次说明白再给出一份可以直接跑通的Verilog代码、完整的testbench和Modelsim仿真流程。不管你是刚入门的FPGA新手还是想把手头的信号源做实、做准的工程师这篇都能提供一个少踩坑的参考方案。1. 方案选型为什么一定用DDS 查找表1.1 三种常见实现思路对比用Verilog生成正弦波通常有三条路可走直接查表、CORDIC实时计算、DDS合成。每条路都有各自的适用场景选错方案后面容易越调越痛苦。纯查表法把正弦波一个周期的采样值存在ROM或寄存器数组里用计数器从0到N-1循环读出来。实现简单输出频率和主时钟的关系非常固定但每次换频率都要改代码或者重新配置地址累加步进灵活性很差。CORDIC算法靠迭代旋转实时计算正弦值。优势是不需要大存储相位精度高适合需要高精度、宽频率范围且不占用太多Block RAM的场景。缺点是迭代周期多、时序收敛麻烦入门门槛偏高。DDS直接数字频率合成用一个相位累加器不停累加“频率控制字”再取一部分相位寻址查找表。频率切换只需要改一个寄存器的值频率分辨率由累加器位宽决定可高可低是当前FPGA和MCU信号发生器最主流的方案也是我推荐的首选。实际项目里除非你的正弦信号要求相位连续可调且精度极高或者片上存储极度紧张否则DDS查找表几乎没有短板。这也是市面上几乎所有DDS芯片和FPGA IP核的内核结构。1.2 DDS核心原理与关键公式DDS的核心思想可以概括成一句话用数字相位随时间线性增长再把这个相位映射成正弦幅值。相位累加器每个时钟周期累加一个频率控制字Fword累加器溢出一次就代表正弦波走完了一个完整的360度周期。输出频率、主时钟频率、累加器位宽和频率控制字四者的关系是Fout Fclk × Fword / 2^N其中N是相位累加器的位宽。反过来想要一个固定频率频率控制字的计算方式就是Fword Fout × 2^N / Fclk举个例子如果主时钟是100MHz累加器位宽32位想输出1kHz正弦波那么Fword 1000 × 2^32 / 100_000_000 ≈ 42949.67296Fword取整为42950此时实际输出频率是Fout 100_000_000 × 42950 / 2^32 ≈ 1000.0000076 Hz误差几乎可以忽略。这就是DDS牛的地方——位宽做够了频率分辨率能做到微赫兹级别而且频率切换的时候相位连续不会像直接切换分频系数那样出现毛刺。2. 关键参数设计与Verilog实现细节2.1 相位累加器位宽决定频率精度上限相位累加器的位宽直接决定了频率分辨率不考虑小数点的情况下频率分辨率 Fclk / 2^N还是以100MHz主频为例16位累加器分辨率是1525.9Hz32位累加器分辨率是0.0233Hz。这个差异是非常直接的。我的建议是累加器位宽至少做到24位常规做法直接32位。32位累加器只占用很少的逻辑资源但频率分辨率能提升好几个数量级后面调频率时会非常舒服尤其是需要输出非整数频率或扫频的时候。阅读代码时要注意累加器不会全部用来查表。查找表地址位宽如果和累加器一样存储资源会爆炸。通常做法是只截取累加器的高位作为查找表地址也就是所谓“相位截断”。2.2 相位截断与查找表深度如何在资源和杂散之间取舍查找表深度和相位截断是一对互相牵制的参数。深度越大存储越精确占用的BRAM或寄存器越多截断越多资源越省但相位量化误差会变大输出信号的SFDR无杂散动态范围会变差。一个工程上常用的经验值是查找表深度256也就是地址8位数据位宽8位。这个配置配合100MHz时钟输出信号能覆盖绝大多数音频、基带测试和简单信号源需求资源开销几乎可以忽略。如果你对纯度有更高要求可以把LUT深度提到1024、数据位宽提到12位或16位。但要注意提升数据位宽时后续接DAC时要做截位处理避免输出端噪声被无情放大。这个问题我在第4章会细说。2.3 核心Verilog代码参数化DDS模块直接给出一份参数化DDS代码这部分我已经在Vivado和Quartus II下跑过Modelsim仿真也验证过module dds_sine #( parameter PHASE_WIDTH 32, parameter LUT_ADDR_WIDTH 8, parameter LUT_DATA_WIDTH 8 )( input wire clk, input wire rst_n, input wire [PHASE_WIDTH-1:0] freq_ctrl_word, output reg [LUT_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_ctrl_word; end // 相位截断取高 LUT_ADDR_WIDTH 位作为查找表地址 wire [LUT_ADDR_WIDTH-1:0] lut_addr; assign lut_addr phase_acc[PHASE_WIDTH-1 -: LUT_ADDR_WIDTH]; // ---------------------------------------------- // 正弦查找表初始化文件 sine_lut.hex // ---------------------------------------------- reg [LUT_DATA_WIDTH-1:0] sine_lut [0:(1LUT_ADDR_WIDTH)-1]; initial begin $readmemh(sine_lut.hex, sine_lut); end always (posedge clk) begin sine_out sine_lut[lut_addr]; end endmodule这份代码有三点值得注意。第一相位累加器在复位时清0这样可以保证输出正弦波从0相位开始方便仿真时观察和抓起始点。第二地址截位用的是phase_acc[PHASE_WIDTH-1 -: LUT_ADDR_WIDTH]这表示从最高位往下取8位。因为phase是累加的高位变化最慢相当于把整个2^N的相位圆均匀切成了256个扇区每个扇区对应相位步进360/256度。第三查找表用$readmemh从外部文件加载比在代码里写256个case干净得多。替换波形数据时完全不用改RTL这个习惯在项目后期调波形时能省下大量时间。2.4 用Python生成查找表文件既然要从外部文件加载就得先造一个sine_lut.hex。我用Python生成过很多次脚本很简单顺手分享出来import math DEPTH 256 WIDTH 8 with open(sine_lut.hex, w) as f: for i in range(DEPTH): val int(round((2**(WIDTH-1)-1) * math.sin(2 * math.pi * i / DEPTH))) val val 0xFF # 转成8bit无符号存储 f.write(f{val:02X}\n)这里注意一个容易掉坑的点DDS输出的是有符号正弦值范围在-128到127之间而$readmemh加载进reg数组的数值默认按无符号数理解。如果直接把数据喂给DAC必须把二进制补码转成对应的无符号偏移码常见做法是加上128的直流偏置。代码里生成的0x80正好对应零电平方便接入8位的视频DAC或高速DA。3. testbench编写与Modelsim仿真实战3.1 testbench结构驱动信号、任务函数与数据落盘设计完RTL接下来是仿真验证。很多初学者喜欢直接在Modelsim里看波形波形确实直观但量一大就很难精确定位问题。我习惯在生成波形的同时把仿真数据以文本格式写出来再用Python做频谱分析效率高且能验证频率精度。一个标准testbench框架如下timescale 1ns/1ps module tb_dds_sine; reg clk; reg rst_n; reg [31:0] freq_ctrl_word; wire [7:0] sine_out; // 生成100MHz时钟 initial clk 0; always #5 clk ~clk; // 实例化DDS dds_sine #( .PHASE_WIDTH (32), .LUT_ADDR_WIDTH (8), .LUT_DATA_WIDTH (8) ) u_dds ( .clk (clk), .rst_n (rst_n), .freq_ctrl_word (freq_ctrl_word), .sine_out (sine_out) ); integer file_handle; initial begin file_handle $fopen(sine_out.txt, w); rst_n 0; freq_ctrl_word 0; #100; rst_n 1; // 1kHz对应100MHz主频、32位累加器 freq_ctrl_word 42950; // 仿真相位数采集65536个点 repeat (65536) begin #10; // 每10ns采样一次即每个时钟沿采样 $fwrite(file_handle, %0d\n, $signed(sine_out)); end $fclose(file_handle); $stop; end endmodule$signed(sine_out)这一步很多人会漏掉。如果你直接$fwrite一个reg类型写出去的是无符号整数范围是0到255分析时还得手动减128。用$signed写出来就是-128到127后续Python处理时不需要再转换更省事。3.2 Modelsim仿真verilog文件的完整流程用Modelsim跑Verilog和testbench其实就三步建库、编译、仿真。命令行最顺滑的方式如下vlib work vlog dds_sine.v tb_dds_sine.v vsim -c work.tb_dds_sine run -all如果就想看波形加一句vsim work.tb_dds_sine add wave sim:/tb_dds_sine/clk add wave sim:/tb_dds_sine/sine_out run -all波形出来以后sine_out应该是一串连续变化的8位数值。如果你用了有符号数的显示方式max是0x80附近min是0x00左右整体是平滑的正弦形状。3.3 仿真数据后处理验证频率和纯度拿到sine_out.txt之后我习惯用Python快速验证一下频率是否正确import numpy as np data np.loadtxt(sine_out.txt) fs 100e6 N len(data) t np.arange(N) / fs # 做FFT看峰值频率 spectrum np.fft.rfft(data) freqs np.fft.rfftfreq(N, 1/fs) peak_idx np.argmax(np.abs(spectrum[1:])) 1 print(f输出频率估计: {freqs[peak_idx]:.3f} Hz)如果参数没算错FFT峰值应该落在1000Hz附近。如果偏差明显先查频率控制字计算再查testbench里的参数是否和RTL里一致。这种验证方式比肉眼看波形可靠得多。4. 工程落地从仿真到板级部署常见坑与经验4.1 输出频率精度与整数/小数分频的日子很多人在仿真完开始上板时会对“为什么实际频率和理论值有偏差”感到困惑。DDS的频率精度主要受三个因素影响主时钟频率的精确度如果你的开发板晶振是100MHz实际可能在100MHz加减几十ppm之间。对大部分信号源需求来说这个误差完全可接受但对精密测试源就不可接受了这时可能需要外接高精度时钟或锁相环校正。频率控制字的取整误差2^32除以100MHz再乘以1000结果是42949.67296取整为42950后理论偏差只有7.6e-6Hz基本可以忽略。如果N位宽选小了比如16位取整误差就会大大增加。查找表量化误差8位数据位宽理论上量化信噪比约49.9dB作为普通信号源够用但如果你要做高纯度信号源量化噪声会显著影响底噪。这时位宽提到12位或16位能明显改善。实际项目中还有一个容易踩的坑是FPGA主时钟频率并不是整数时DDS输出频率会出现非整数例如12MHz系统时钟想输出1kHz频率控制字计算出来是个无限循环小数无法精确取整。调试方法也很简单尽量把累加器位宽做大然后根据FFT结果朝需要的方向微调控制字不要死磕理论精确值。4.2 DAC接口扩展把正弦波变成真实模拟信号FPGA内部是数字世界正弦波要以模拟形式输出到示波器或后端电路通常要接DAC芯片。DAC接口一般有SPI、I2C或并行接口而DDS模块本身输出的是并行8位或16位数据所以两者之间一般需要一个接口适配层。举个例子如果DAC是8位并行接口、上升沿锁存你可以在DDS模块之后加一个简单的寄存器打拍reg [LUT_DATA_WIDTH-1:0] dac_data_reg; always (posedge clk) begin dac_data_reg sine_out; end然后直接把dac_data_reg送到DAC的并行数据口由DAC的转换时钟或SPI帧驱动。需要注意的有两点一是DAC的建立时间和保持时间输出数据务必用时钟打一拍再出去避免组合路径的毛刺二是数据格式一定要对上DAC如果是二进制偏移码0x00对应最低电瓶、0xFF对应最高电瓶那就直接用无符号输出如果是二进制补码要做符号扩展或加直流偏置。4.3 与FIR滤波器、基带调制等热门方向的衔接做信号源从来不只是“出一个正弦波”就完事。很多同事问我在正弦波DDS基础上还能往下做些什么比较常见的扩展方向有三个DDS作为可变载波源并与FIR数字滤波器级联当DDS输出的正弦波带外杂散比较大或者波形阶梯感明显时后端加一个FIR低通滤波器做平滑非常有效。这也是“基于FPGA的FIR数字滤波器”这个热搜词经常和DDS出现在同一篇文章里的原因。FIR系数计算在MATLAB的Filter Designer里做Verilog集成方式我一般用分布式算法或直接乘加有兴趣的可以看我的FIR专题。多路DDS构成正交本振I/Q输出通信基带里常要I/Q两路正交正弦信号来模拟载波或变频。实现上不算复杂第一个DDS相位累加器值作为公共相位第二个DDS查找表地址在相位累加器基础上增加N/4个查找表地址对应90度相位差即可。DDS做扫频信号源只需要用一个线性下降或上升的寄存器来实时改变freq_ctrl_word就能产生Chirp信号。很多雷达测试系统就是这么干的。如果后面想在板级验证滤波效果建议把DDS的输出先经过DAC变成模拟信号再用ADC采回FPGA和滤波结果做对比这样既验证了算法也验证了模拟前端整体会比较有说服力。4.4 常见问题与排查速查表最后把我在这个项目里亲手踩过的坑和排查经验整理成表格大家在遇到问题时可以直接对照。现象可能原因解决方法仿真波形是一条恒定的直线或锯齿相位累加器没复位或Fword为0检查复位逻辑确认freq_ctrl_word不等于0波形输出频率是预期值的一半或两倍查找表数据只取了半周期或地址截位出错确认生成的正弦数据覆盖完整0~360度周期检查截位方式输出波形有明显台阶不平滑查找表深度太浅或者数据位宽不足将LUT深度提升到1024以上数据位宽提到12位以上上板后无波形输出时钟没起来、复位被拉死、DAC数据格式不对用在线逻辑分析仪抓clk、rst_n、sine_out核对DAC手册的数据格式输出频谱底噪偏高相位截断和量化带来的杂散且没有滤波尽量截断低位数保留高位数后端加FIR滤波优化DAC布线testbench里$readmemh报告文件打不开hex文件路径不对或格式有误确保hex文件和仿真工作目录在同一目录文件里每行只能有一个十六进制数频率控制字改变后输出有毛刺控制字寄存器在组合逻辑通路中被异步修改添加同步寄存器控制字只在时钟沿更新这些坑里最隐蔽的是“波形是半周期重复”那个当时我排查了很久才发现是因为生成LUT时只取了正弦函数的前半段补全后半段之后波形立刻正常。反过来也说明能用FFT做频率验证就多做一步肉眼从波形上很难看出半周期重复的问题。写在最后正弦波信号发生器用Verilog实现本质是一个DDS问题掌握了相位累加器和查找表这两个核心点后面想扩展频率、加调制、接DAC都不难。从我个人的经验看做这类模块时不要急着写代码先把主时钟、累加位宽、输出频率这三者关系算清楚很多调试时间都能省下来。建议先配合testbench在Modelsim里把频率和波形验证透再上板去接DAC分步验证才是最快的方式。另外查找表生成脚本建议保留好后续调位数、调位宽都非常有用。本文还有配套的精品资源点击获取
返回列表