简介:面向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 系统时钟、N=32 为例,分辨率是 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 相乘。
下表给出了几种典型配置下的参数对应关系(N=32,fclk=50MHz):
| 目标频率 (Hz) | FTW (十进制) | FTW (十六进制) | 实际频率 (Hz) | 误差 (Hz) |
|---|---|---|---|---|
| 1k | 85,899.35 | 0x00014F8B | 999.9999 | 0.0001 |
| 10k | 858,993.46 | 0x000D1B96 | 10000.0001 | 0.0001 |
| 100k | 8,589,934.59 | 0x00831C4E | 100000.0012 | 0.0012 |
| 1M | 85,899,345.92 | 0x051EB860 | 999999.9896 | 0.0104 |
| 5M | 429,496,729.60 | 0x19999999 | 5000000.0000 | 0.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,它将相位累加器、波形查找表和幅值控制逻辑封装在一起。接口定义如下表:
| 信号名 | 方向 | 位宽 | 说明 |
|---|---|---|---|
| clk | input | 1 | 系统时钟 |
| rst_n | input | 1 | 异步复位,低有效 |
| ftw | input | 32 | 频率控制字 |
| wave_sel | input | 2 | 波形选择:00 正弦,01 方波,10 三角波 |
| amp_ctrl | input | 12 | 幅值控制字 |
| dout | output | 12 | 波形数据输出 |
选择 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 <= 32'd0; 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) 12'h000: sine_data <= 12'h800; 12'h001: sine_data <= 12'h803; // 其余 4094 行由脚本生成 default: sine_data <= 12'h800; endcase end // 方波:相位最高位决定输出电平 assign square_data = addr[11] ? 12'hFFF : 12'h000; // 三角波:相位最高位决定上升/下降方向 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 = 32'd0; wave_sel = 2'b00; amp_ctrl = 12'h800; #100; rst_n = 1; // 设置 1MHz 输出:FTW = 1e6 × 2^32 / 50e6 ≈ 85899346 ftw = 32'd85899346; // 运行 20 个周期 #20000; // 切换到三角波 wave_sel = 2'b10; #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 对应周期 1000ns,20000ns 可以看见 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 <= 16'hACE1; else lfsr <= {lfsr[14:0], lfsr[15] ^ lfsr[13] ^ lfsr[12] ^ lfsr[10]}; end // 将低 8 位与随机异或后再截断 assign phase_dithered = phase_accum + {20'd0, 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 dBc | 1 个 BRAM |
| 地址 12 位 / 数据 12 位 | 72 dBc | 1 个 BRAM |
| 地址 14 位 / 数据 14 位 | 84 dBc | 4 个 BRAM |
| 地址 14 位 / 数据 14 位 + 抖动 | 88 dBc | 4 个 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 中跑一遍完整流程,从综合到生成比特流,确保没有警告级别的错误残留。
本文还有配套的精品资源,点击获取