资讯详情

FPGA实战:基于DDS的任意波形发生器设计与Verilog实现

📅 2026/9/11 15:12:36 | 华诺云谱 👁 阅读
FPGA实战:基于DDS的任意波形发生器设计与Verilog实现
简介面向FPGA开发的DDS任意波形输出完整工程套件适合数字信号处理初学者和有一定基础的设计人员可帮助解决从数字频率合成原理到波形生成落地的关键问题。工程共包含633个文件压缩包大小8.5MB主要文件类型既有VHDL与Verilog两种风格的硬件源码也有仿真数据、MIF波形存储文件以及工程配置文件便于对照学习和迁移。内容分成四讲先剖析DDS数学模型与相位累加器、频率控制字、波形查找表等核心部件再讲解如何通过改变控制字和查找表来动态调节频率、波形形状及幅值并针对实际项目中常出现的精度不足、线性度差等问题从硬件资源和查表误差两方面提出优化手段。资源中还随附了仿真数据、说明文档和测试结果可支撑从代码阅读、功能仿真到板级调试的完整实践流程。目前已有146人学习下载对理解DDS架构和提升FPGA设计能力有实用价值也可为课程设计或工程排障提供直接参考。1. 从相位累加器说起为什么 DDS 是 FPGA 最合适的波形合成方案直接数字频率合成DDS和传统模拟振荡器最大的区别在于它不依赖电容充放电或锁相环反馈而是用一个固定频率的时钟去“数”相位再通过查表把相位翻译成幅度。这个思路在 FPGA 上几乎是天然的——你不需要额外的高精度 DAC 控制逻辑只需要一个累加器、一块 ROM 和一个乘法器就能输出频率分辨率高达 2^-N 倍系统时钟的任意波形。很多初学者第一次跑通 DDS 时会惊讶于频率控制字FTW改一个数输出频率就能精确跳变这种“按公式走”的确定性正是数字电路的优势。本项目给出了一套完整的 Verilog 工程覆盖了正弦波、方波、三角波的生成、仿真数据以及硬件验证流程适合正在学习 FPGA 信号处理、或者需要在毕业设计中快速落地任意波形发生器的开发者。整篇我会按照“数学模型 → RTL 编码 → 仿真验证 → 精度优化”的顺序拆开讲。2. DDS 数学模型与核心参数设计2.1 相位累加器以固定时钟步进相位DDS 的核心是相位累加器它是一个 N 位宽的寄存器每个系统时钟上升沿累加一次频率控制字 FTW。累加器溢出即完成一个 2π 周期的相位循环这个“溢出即回绕”的机制是 DDS 频率合成的基础。假设系统时钟为 fclk累加器位宽为 N频率控制字为 FTW则输出频率 fout 的公式为fout FTW × fclk / 2^N这个公式要理解透FTW 相当于每个时钟周期相位前进的步长2^N 是相位总量程。当 FTW 2^(N-1) 时输出频率等于 fclk/2也就是 Nyquist 极限。实际设计中为了留出抗混叠滤波器的过渡带最高输出频率一般取 fclk 的 40% 以下。N 的取值决定了频率分辨率。以 50MHz 系统时钟、N32 为例分辨率是 50e6 / 2^32 ≈ 0.0116Hz这意味着频率控制字每加 1输出频率只变化约 0.012Hz。如果改用 16 位累加器分辨率约为 762.9Hz这在许多低速场景下完全不够用。所以工程上普遍采用 32 位甚至 48 位累加器再截取高 8~14 位作为查表地址。2.1.1 为什么要做相位截断如果直接把 32 位累加器全部作为 ROM 地址意味着查找表要有 2^32 个存储单元这在任何 FPGA 上都是不可接受的。因此实际做法是取累加器的高 M 位作为查找表地址低 N-M 位自然丢弃。这种相位截断会带来杂散幅度大约在 -6.02×M dBc 的水平。比如取高 12 位查表理论杂散抑制约 72dB这个指标对多数教学级波形发生器足够了。若追求更高 SFDR可以后续在查找表输出端加抖动dithering这个我放到最后一章细说。2.2 频率控制字的反向计算工程中最常见的需求是根据目标频率反推 FTW。把公式变形得到FTW fout × 2^N / fclk写代码时要注意这里的除法结果需要四舍五入而不是直接截断否则会引入最多 1 个 LSB 的频率误差。在 Verilog 里做这个除法通常用定点数表示法将 2^N / fclk 预先算成一个常数再与 fout 相乘。下表给出了几种典型配置下的参数对应关系N32fclk50MHz目标频率 (Hz)FTW (十进制)FTW (十六进制)实际频率 (Hz)误差 (Hz)1k85,899.350x00014F8B999.99990.000110k858,993.460x000D1B9610000.00010.0001100k8,589,934.590x00831C4E100000.00120.00121M85,899,345.920x051EB860999999.98960.01045M429,496,729.600x199999995000000.00000.0000计算时我一般先用 Python 或 MATLAB 把 FTW 算好再以常量形式写进 Verilog 源码而不是在 RTL 里做浮点除法。这样既省资源又避免综合结果不确定。2.3 查找表的相位-幅度映射设计查找表存储的是相位到幅度的映射关系。对于正弦波一个周期内幅度对称因此可以只存 1/4 周期0 到 π/2利用正弦函数的对称性还原完整波形也可以用完整周期表逻辑更简单但 ROM 占用翻四倍。查找表的深度和位宽直接影响输出波形的质量。深度地址位宽决定相位分辨率位宽决定幅度量化噪声。一个典型的配置是 12 位地址、12 位数据对应 4096 点 × 4096 级幅度SFDR 约 72dB。生成查找表系数我通常用 Python 脚本import math DEPTH 4096 # 查找表深度 WIDTH 12 # 输出数据位宽 with open(sine_rom.mem, w) as f: for i in range(DEPTH): # 正弦波映射到 [0, 2^WIDTH - 1] val int((math.sin(2 * math.pi * i / DEPTH) 1) * (2**WIDTH - 1) / 2) f.write(f{val:04X}\n)这段脚本生成的是一个 12 位十六进制数的 .mem 文件每一行对应一个相位点的幅度量化值。(sin 1) / 2将 [-1, 1] 映射到 [0, 1]再乘以2^WIDTH - 1得到无符号整数表示。这样做的原因是 FPGA 内部处理无符号数更方便后续做幅值控制或直流偏移时只需一次性统一处理。3. Verilog 工程结构与 RTL 实现3.1 顶层模块接口定义项目工程包含顶层模块dds_top它将相位累加器、波形查找表和幅值控制逻辑封装在一起。接口定义如下表信号名方向位宽说明clkinput1系统时钟rst_ninput1异步复位低有效ftwinput32频率控制字wave_selinput2波形选择00 正弦01 方波10 三角波amp_ctrlinput12幅值控制字doutoutput12波形数据输出选择 12 位输出位宽是一个折中DAC 常见位宽为 12 位或 14 位12 位在 Artix-7 上可以用一个 BRAM 实现查找表无需额外逻辑资源。若用 16 位BRAM 消耗翻倍除非后级接的是高精度 DAC否则收益有限。3.2 相位累加器 RTL 代码module phase_accumulator ( input wire clk, input wire rst_n, input wire [31:0] ftw, output reg [31:0] phase_accum ); always (posedge clk or negedge rst_n) begin if (!rst_n) phase_accum 32d0; else phase_accum phase_accum ftw; end endmodule这个模块只有一个加法器和一个寄存器但它决定了整个 DDS 系统的频率精度。phase_accum每次累加ftw当累加结果超过 2^32 时自然溢出回绕不需要显式判断溢出条件。这里有个细节加法器的进位输出其实就代表了正弦波周期的“帧同步”信号可以用它来触发外围电路做周期对齐。3.3 波形查找表与多波形切换查找表在 Verilog 里通常以case语句或 ROM IP 核实现。项目里为了支持正弦、方波、三角波三种输出我倾向于用独立的三个 ROM通过wave_sel多路选择输出。这样做的好处是三种波形可以有不同的数据位宽和深度配置后期扩展任意波形时不影响已有逻辑。// 取相位累加器高 12 位作为查表地址 wire [11:0] addr phase_accum[31:20]; reg [11:0] sine_data; reg [11:0] square_data; reg [11:0] triangle_data; always (posedge clk) begin case (addr) 12h000: sine_data 12h800; 12h001: sine_data 12h803; // 其余 4094 行由脚本生成 default: sine_data 12h800; endcase end // 方波相位最高位决定输出电平 assign square_data addr[11] ? 12hFFF : 12h000; // 三角波相位最高位决定上升/下降方向 assign triangle_data addr[11] ? ~addr[10:0] : addr[10:0];方波和三角波的实现不需要 ROM用组合逻辑直接算。方波取相位最高位作为符号位输出满幅度的正或负电平三角波则利用相位低 11 位做线性映射最高位为 1 时反向。注意square_data和triangle_data是 wire 类型用assign连续赋值而sine_data是 reg 类型在always块中赋值这种混合写法在实际工程中非常普遍。3.3.1 ROM 初始化与 $readmemh对于正弦波查找表与其在 case 语句里写几千行不如直接调用$readmemh从 .mem 文件加载reg [11:0] sine_rom [0:4095]; initial begin $readmemh(sine_rom.mem, sine_rom); end always (posedge clk) begin sine_data sine_rom[addr]; end这种方式让数据和逻辑分离修改波形只需重新生成 .mem 文件不用动 RTL 代码。综合时 Vivado 会自动把sine_rom推断为 BRAM前提是数组大小超过分布式 RAM 的阈值通常 64 或 128 位以上。如果希望强制使用 BRAM可以在属性中声明(* ram_style block *)。3.4 幅值控制与输出级处理幅值控制常见有两种实现一种是在查找表输出后接乘法器用amp_ctrl作为乘数另一种是直接在查找表里存多组不同幅度的数据。前者灵活后者节省乘法器资源。项目中采用前者因为可以动态调节而不需要重新加载 ROM。wire [23:0] mult_result; wire [11:0] wave_data; assign mult_result wave_data * amp_ctrl; // 取乘法结果高 12 位等效于除以 2^12 assign dout mult_result[23:12];这里wave_data是经过wave_sel选择后的原始波形数据amp_ctrl是 12 位无符号数。乘法结果 24 位取高 12 位相当于右移 12 位即除以 4096。这个操作的精度损失是 1 个 LSB对 12 位系统可接受。注意乘法器在 FPGA 中用 DSP48 实现如果波形输出频率很高需要检查 DSP 的流水线级数是否满足时序要求必要时在乘法器中间插入寄存器打拍。4. 仿真数据生成与 Vivado 验证流程4.1 Testbench 设计与激励文件编写仿真验证是 DDS 项目中最容易出问题也最花时间的一环。初学者常常写完 RTL 代码就急着上板结果调试半天找不出问题。实际上先用 testbench 验证波形正确性再把工程烧到板卡上能节省大量排错时间。testbench 的核心任务是生成时钟和复位信号给定一组 FTW 值采集dout输出并验证其频率和幅度是否符合预期。module tb_dds_top; reg clk; reg rst_n; reg [31:0] ftw; reg [1:0] wave_sel; reg [11:0] amp_ctrl; wire [11:0] dout; // 50MHz 时钟 initial clk 0; always #10 clk ~clk; // 测试流程 initial begin rst_n 0; ftw 32d0; wave_sel 2b00; amp_ctrl 12h800; #100; rst_n 1; // 设置 1MHz 输出FTW 1e6 × 2^32 / 50e6 ≈ 85899346 ftw 32d85899346; // 运行 20 个周期 #20000; // 切换到三角波 wave_sel 2b10; #20000; $finish; end // 实例化被测模块 dds_top u_dut ( .clk (clk), .rst_n (rst_n), .ftw (ftw), .wave_sel (wave_sel), .amp_ctrl (amp_ctrl), .dout (dout) ); endmodule这段 testbench 在复位释放后先输出 1MHz 正弦波运行 20000ns约 1000 个时钟周期后切换到三角波。#10的时钟周期对应 50MHz这是通过时间尺度模拟时钟的标准做法。仿真时长要足够观察到至少一个完整波形周期1MHz 对应周期 1000ns20000ns 可以看见 20 个周期足够判断频率是否准确。4.2 仿真波形分析与数据导出Vivado 中完成行为仿真后重点观察三个信号phase_accum是否线性递增、sine_data是否为周期性正弦包络、dout经过幅值控制后是否正确缩放。一个常见错误是波形频率恰好是预期值的一半或两倍。这通常有两种原因一是 FTW 计算时 N 的取值错误比如用 31 代替 32二是查找表地址取错了位段取了累加器的低 12 位而不是高 12 位。低 12 位变化速率远高于高 12 位但周期完全相同只是波形点数被压缩容易被误判为“波形乱码”。仿真通过后可以将dout数据导出为文本文件做进一步频谱分析。在 testbench 中加入以下代码integer file_handle; initial begin file_handle $fopen(dout_samples.txt, w); end always (posedge clk) begin if (rst_n) $fwrite(file_handle, %d\n, dout); end导出的数据可以直接导入 Python 做 FFT 频谱分析验证 SFDR 是否达到预期。这一步我强烈建议做因为时域波形“看起来像正弦波”和频域“杂散抑制达标”是两码事。4.3 硬件测试步骤与常见坑上板测试前确认 FPGA 开发板的时钟频率和工程的时钟约束一致。项目默认是 50MHz如果你的板子是 100MHz 系统时钟需要重新计算所有 FTW 参数。硬件测试步骤如下在 Vivado 中添加 XDC 约束文件将clk绑定到板载差分或单端时钟引脚rst_n绑定到按键按下为低电平。综合、实现并生成比特流注意查看时序报告中 setup 和 hold 是否有 violation。将比特流下载到 FPGA用逻辑分析仪ILA观察dout信号。ILA 的采样时钟使用系统时钟触发条件设为rst_n上升沿。如果板载有 DAC 和示波器将dout接到 DAC 的数据端口观察示波器上的波形。比较常见的硬件问题是 DAC 的输出没有接低通滤波器导致波形呈阶梯状。DDS 输出的本质是 DAC 保持的台阶波必须经过截止频率略高于最高输出频率的低通滤波器才能恢复平滑波形。项目说明文档中如果提到了滤波器设计注意检查运放的带宽和 Q 值。5. SFDR 优化、相位抖动注入与工程化收尾5.1 相位截断杂散与抖动注入相位截断是 DDS 杂散的最大来源。假设累加器 32 位、查找表地址 12 位低 20 位被丢弃这会导致波形产生周期性误差在频谱上表现为特定频率的杂散峰。一个有效的优化方法是在查表地址中加入随机抖动。具体做法是在累加器的高位截断前将低位的若干位异或随机数再做截断wire [31:0] phase_dithered; wire [11:0] addr; // 伪随机数发生器LFSR reg [15:0] lfsr; always (posedge clk) begin if (!rst_n) lfsr 16hACE1; else lfsr {lfsr[14:0], lfsr[15] ^ lfsr[13] ^ lfsr[12] ^ lfsr[10]}; end // 将低 8 位与随机异或后再截断 assign phase_dithered phase_accum {20d0, lfsr[7:0]}; assign addr phase_dithered[31:20];这段代码将 8 位随机数叠加到相位值上等效于破坏了相位截断误差的周期性。代价是输出信号的信噪比略有下降但杂散被展平成底噪SFDR 通常能提升 6~12dB。LFSR 的反馈多项式采用了 x^16 x^14 x^13 x^11 1这是一个本原多项式能产生 65535 个周期的伪随机序列。5.2 查找表深度与输出位宽的权衡在资源允许的前提下增大查找表深度对 SFDR 的提升是有上限的。当深度超过 14 位时相位截断杂散已经低于幅度量化噪声此时继续增加深度收益很小反而消耗更多 BRAM。输出位宽的提升则直接改善量化噪声基底。从 12 位提升到 14 位理论上 SFDR 提升约 12dB但 DAC 也需要 14 位分辨率。实际工程中建议先用 12 位验证完整流程再根据频谱分析结果决定是否升级位宽。以下是一组实测参考数据配置SFDR仿真资源消耗地址 10 位 / 数据 12 位58 dBc1 个 BRAM地址 12 位 / 数据 12 位72 dBc1 个 BRAM地址 14 位 / 数据 14 位84 dBc4 个 BRAM地址 14 位 / 数据 14 位 抖动88 dBc4 个 BRAM LFSR从表中可以看出10 位地址到 12 位地址的提升非常明显而 12 位到 14 位的提升相对温和。如果你使用的是小容量 FPGA如 Cyclone IV 或 Artix-7 35T优先采用 12 位配置把 BRAM 留给其他逻辑。5.3 方波和三角波的相位噪声特性方波由相位最高位直接生成跳变沿与系统时钟对齐因此抖动等于时钟抖动相位噪声特性很好。但方波包含大量奇次谐波如果后级电路对 EMI 敏感建议在输出端加 RC 滤波或使用差分输出。三角波的线性度取决于相位累加器的单调性没有截断误差的影响但谐波成分比正弦丰富。若需要高质量三角波一种做法是用查找表存三角波数据而不是用组合逻辑直接算这样可以通过修改 ROM 内容补偿 DAC 的非线性。5.4 项目文件的组织与复用技巧拿到解压后的工程包先别急着打开 Vivado。按以下步骤整理# 查看文件结构 find . -type f | sort # 统计代码行数 wc -l *.v *.vhd 2/dev/null # 检查仿真数据文件格式 head -20 *.mem 2/dev/null我习惯把工程分为rtl/、sim/、constraints/、doc/四个目录。RTL 目录放所有 Verilog 源文件sim 目录放 testbench 和仿真脚本constraints 放 XDC 文件doc 放说明文档和波形截图。这样结构清晰后续复用到其他项目时只需复制 rtl 目录并调整顶层模块例化方式即可。修改顶层模块接口时要特别注意比如将ftw从 32 位改成 24 位所有依赖该信号的模块和 testbench 都会受影响。我一般会在修改前用 grep 搜索所有出现ftw的位置确认没有遗漏grep -rn ftw rtl/ sim/工程说明文档中如果有引脚分配表或寄存器映射表建议先对照原理图核实避免上板时发现引脚锁定冲突。最后在 Vivado 中跑一遍完整流程从综合到生成比特流确保没有警告级别的错误残留。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。