简介:面向FPGA学习者和数字信号处理工程师,这份实战训练资料围绕DDS信号输出展开,以FPGA工程为载体,完整演示了从相位累加器、查找表(LUT)到DAC接口的实现流程,并涉及频率精度、相位噪声、实时性等关键设计考量因素。压缩包共88个文件,包含Verilog源码、仿真波形文件、Quartus工程配置、编译报告及说明文档,整体仅656KB,结构清晰,便于分模块查阅。已有1029人学习下载,适合需要掌握DDS数字频率合成技术、信号源设计或数字信号处理实战技巧的中高级开发者。通过实际工程可快速理解DDS工作原理,学习相位控制字设置、LUT构建与仿真分析方法,同时可参考工程报告与配置文件排查综合、布局布线问题,为设计高频、高精度的定制化信号源提供切实可落地的技术参考。
1. 从一个工程问题说起,DDS 为什么值得用 FPGA 做
做信号源、雷达回波模拟、或者通信基带测试的时候,最讨厌的就是去买一台固定频率的 DDS 芯片,发现输出频点不够用,想扫频还得改寄存器。换个思路,直接在 FPGA 里做 DDS,输出的频率其实是一个 32 位频率控制字决定的,理论上能做到 MHz 级步进,这在很多场景下比模拟锁相环来得干脆。所谓 DDS,全称 Direct Digital Synthesis,直接数字频率合成,核心思想是“用相位累加器代替振荡器”,不靠压控振荡器,也不靠锁相环,而是用查表和累加的方式在数字域把波形造出来。这一讲我们先把最基础的正弦波输出链路打通,用 Verilog 在 Vivado 里实现一个可复用的 DDS 模块,配合仿真验证扫频能力。适合刚接触 FPGA 或者想把信号发生器搬进逻辑里的工程师,后续还可以把这个模块接进射频前端或者音频系统,直接变成一个自定义信号源。
2. 相位累加器是 DDS 的心脏,频率字和位宽先把数学定死
2.1 相位累加器的本质是“数字角度轮盘”
DDS 的输出频率不靠分频,靠的是每过一个时钟周期,往相位值上加一个增量。这个增量就是频率控制字,我们通常叫 Fword 或者 FTW。相位值本身是 N 位宽,累加到溢出时自然回到 0,相当于一个 N bit 的相位轮盘转了一圈。每转一圈,就是正弦波的一个完整周期,所以输出频率等于:
Fout = FTW * Fclk / 2^N这里 Fclk 是 FPGA 内部驱动累加器的时钟,N 是相位累加器位宽,FTW 是频率控制字。这个公式决定了所有设计参数:想要频率分辨率高,就把 N 拉大;想要细调频率,就得控制好 FTW 的变化步长。
举个例子,系统时钟 100 MHz,相位累加器用 32 位,那频率分辨率就是 100e6 / 2^32,约等于 0.0233 Hz。意味着只要改 FTW 的值,输出频率可以以 0.0233 Hz 为最小步进调整。这对传统模拟锁相环来说几乎是不可想象的精度。
累加器本身就是一个加法器加一个 D 触发器,没有任何神秘的地方。麻烦的地方在后续,相位累加出来的值是 0 到 2^N-1 之间的整数,要把它映射成正弦波的幅度值。
2.2 频率字怎么算,直接给出可复用的参数表
实际设计里,我们用到的不是数学公式,而是固化到寄存器里的值。频率字的计算就是把上面的式子反过来:
FTW = round(Fout * 2^N / Fclk)注意这是 round 不是 floor,因为取整误差会直接影响实际输出频率,四舍五入可以把误差控制在半个分辨率之内。
我一般习惯把 N 固定为 32,然后在顶层模块里通过参数传入 Fclk 和目标频率。这看起来多花了几个寄存器,但换来的是工程上不用频繁改位宽。如果用 16 位,频率分辨率在 100 MHz 时钟下是 1525 Hz,做普通音频都嫌糙。
下面是几个常用配置对应的频率字,方便快速估算:
| 系统时钟 Fclk | 累加器位宽 N | 目标频率 Fout | 频率字 FTW(十进制) | 频率字 FTW(十六进制) |
|---|---|---|---|---|
| 100 MHz | 32 | 1 kHz | 42950 | 0x0000A7C6 |
| 100 MHz | 32 | 10 kHz | 429497 | 0x00068DB9 |
| 100 MHz | 32 | 1 MHz | 42949673 | 0x028F5C29 |
| 100 MHz | 32 | 10 MHz | 429496730 | 0x1999999A |
| 50 MHz | 32 | 1 kHz | 85899 | 0x00014F8B |
| 125 MHz | 32 | 10 MHz | 343597384 | 0x147AE147 |
这里面最容易犯的错误是直接用浮点数算完往寄存器里面写,写完发现频谱上有杂散。原因不是 FPGA 的问题,而是 FTW 的小数部分被截断了,会产生相位截断误差。规避方式很简单,要么提高 N,要么在底层把频率字计算用定点数处理好。
2.3 可综合相位累加器 Verilog 实现
理解了数学关系,剩下的逻辑其实非常少。相位累加器只需要一个加法器,一个寄存器,以及一个可选的同步复位。代码结构清晰,而且这段逻辑完全可综合,不需要任何 IP 核。
module dds_phase_accumulator #( parameter N = 32 )( input wire clk, input wire rst_n, input wire [N-1:0] fword, // 频率控制字 output reg [N-1:0] phase // 当前相位值 ); always @(posedge clk or negedge rst_n) begin if (!rst_n) phase <= {N{1'b0}}; else phase <= phase + fword; end endmodule累加器的物理含义是相位随时间线性增长,当相位值越过 2^N 边界时自然溢出。溢出时不会报错,而是自动模 2^N 回卷,这个特性正是 DDS 能连续输出波形的基础,设计时不需要额外处理溢出标志。
来看参数。N 默认给到 32,意味着相位分辨率足够细,后续查表时取高几位做地址即可。fword 位宽和 N 保持一至。rst_n 是异步复位,这里低有效,符合多数 FPGA 开发板的按键和上电复位习惯。
phase <= phase + fword这行是整个 DDS 里唯一的数学运算。你会发现 DDS 为什么省资源,因为核心计算就这一条加法。相比之下,直接算三角函数需要大量 DSP Slice,而查表法占用的只是 BRAM 或者分布式 RAM。
2.4 相位截断的代价,以及什么时候可以忽略
相位累加器输出 32 位,但我们不可能用一个 2^32 深度的查找表,那会直接耗尽 BRAM。通常的做法是从相位整形值中取高 M 位作为查找表地址。如果 M 小于 N,就发生了相位截断。
相位截断会在输出频谱里引入杂散,这些杂散幅度和位置取决于 FTW 与 2^(N-M) 的关系。工程上,我们用一个经验值控制它:查找表地址位宽 M 取 12~16 位时,无杂散动态范围(SFDR)大约在 72~96 dB 之间,对于基础信号发生器完全够用。
如果你做的是通信系统本振,对杂散要求苛刻,可以考虑两个补强方案。第一,在相位累加器之后加一个随机抖动 dither,把相位截断的周期性误差白噪声化,这样杂散会被摊平到噪声底。第二,从查找表改成 CORDIC 算法实时计算正余弦,但这个会吃 DSP 资源,不适合花在单路 DDS 上。
// 相位取高 12 位作为查表地址示例 wire [11:0] phase_addr; assign phase_addr = phase[N-1 -: 12];这行代码的意思是把 32 位相位值的高 12 位截出来,作为后续正弦查找表的读地址。这样做不是因为低 20 位没用,而是因为波形精度在 12 位地址下已经足够平滑,再增加地址位数对模拟输出端影响微弱,却会浪费 BRAM。
3. 正弦查找表的生成与 Verilog 建模,波形质量看这里
3.1 查找表的三种来源,直接给脚本和工具链
相位到幅度的转换最直接的手段就是用 ROM 存一个周期的正弦采样值。这个 ROM 的生成有三种常见路径。
第一种是用 MATLAB 的定点工具箱生成 .coe 文件,然后导入 Vivado 的 Block Memory Generator。适合习惯图形化操作的人。
第二种是用 Python 脚本直接生成 Verilog 可读的 .hex 文件或 .mem 文件,然后用$readmemh在仿真和综合时加载。这种方式的好处是整个流程可脚本化,改参数重新生成只需要执行一条命令。
第三种更极端,直接在 Verilog 里用case语句硬编码表项。只适合表深度很小的情况(比如 64 点以内),表项一多代码会膨胀到没法维护。
我推荐的路径是第二种,零依赖,跨平台,也方便做参数扫描。下面给出一个生成 12 位地址、8 位幅度量化、256 点正弦表的 Python 脚本。
import math import sys depth = 256 # 查找表深度 width = 8 # 输出幅度位宽 amplitude = 2**(width-1) - 1 offset = 2**(width-1) with open("sine_lut.mem", "w") as f: for i in range(depth): phase = 2 * math.pi * i / depth sample = int(round(amplitude * math.sin(phase) + offset)) if sample >= 2**width: sample = 2**width - 1 elif sample < 0: sample = 0 f.write(f"{sample:02x}\n")写入的每一个字节都是无符号定点数,取值范围 0~255,对应的模拟幅度范围是 0 到 Vref,中点 0x80 表示 0 V。这里刻意用无符号数而不是有符号数,是为了后面接低速 DAC 时不用额外做二进制补码转换。
当然如果你用的是有符号 DAC 或者打算直接接数字下变频,那应该把offset设为 0,让输出范围落在 -128 到 127。两种方案各有取舍,无符号在单电源单端输出的场景更省事,有符号在差分输出和调制场景更直接。
3.2 Verilog 侧例化 RAM,读操作是纯组合输出
有了正弦表,接下来写一个只读存储器模块。因为 DDS 只需要按顺序读地址,不需要写端口,所以直接在模块内部声明一个reg数组,用$readmemh在初始化阶段加载表内容。
module sine_lut #( parameter ADDR_WIDTH = 12, parameter DATA_WIDTH = 8, parameter LUT_DEPTH = 1 << ADDR_WIDTH )( input wire clk, input wire [ADDR_WIDTH-1:0] addr, output reg [DATA_WIDTH-1:0] dout ); reg [DATA_WIDTH-1:0] mem [0:LUT_DEPTH-1]; initial begin $readmemh("sine_lut.mem", mem); end always @(posedge clk) begin dout <= mem[addr]; end endmodule注意这里用了寄存输出,也就是读操作有 1 个时钟周期的延迟。从相位累加器给出地址到波形数据稳定,一共经过两级打拍,在 RTL 仿真和实际综合中行为一致,不会出现仿真通过上板失败的问题。
$readmemh是 Verilog 的系统任务,会在仿真开始时把文件内容加载进mem。综合时,Vivado 和 Quartus 都支持把它映射到 BRAM 的初始化文件中,也就是说这段代码综合后真的会变成一块 ROM。如果你用的是纯开源工具链,比如 Yosys 配合 IceStorm,同样支持$readmemh初始化,只是要注意文件路径的写法。
3.3 为什么选择 256 点还是 4096 点,怎么量化误差
查找表深度直接决定了两件事:频率分辨率能否体现出来,以及谐波失真能做到什么程度。用 256 点表时,相位量化误差带来的杂散大约在 -48 dB 左右,这个水平听感已经不错,但如果要做一个实验室信号发生器给 ADC 测试用,通常会用到 4096 点表。
下表给出不同配置下的工程经验值,单位是 dBc(相对载波):
| 查找表深度 | 地址位宽 | 典型 SFDR | 适用场景 |
|---|---|---|---|
| 256 | 8 bit | 48~55 dBc | 简易音频信号、蜂鸣器驱动 |
| 1024 | 10 bit | 60~66 dBc | 触摸屏激励、中频信号模拟 |
| 4096 | 12 bit | 72~80 dBc | 实验室信号源、低速通信调制 |
| 16384 | 14 bit | 84~90 dBc | 高要求射频本振模拟 |
这个表只是经验值,实际 SFDR 还受幅度量化位数影响。幅度量化 8 bit 时,即便查表深度做得再大,量化噪声底也会限制整体性能。一般规律是查表深度增加 4 倍,SFDR 改善约 12 dB;幅度位宽增加 1 bit,改善约 6 dB。两者都值得加,但优先加幅度位宽,因为 BRAM 占用增加得不明显。
综合在 100 MHz 时钟下,12 位地址、8 位数据深度 4096 的 ROM 只占用一个 BRAM 18K 块,这在绝大多数 FPGA 上都不是负担。因此在这个设计里,我们直接用 12 位地址位宽和 8 位数据位宽,将来做 NCO 升级时只需要换 .mem 文件,不需要改任何 Verilog 代码。
4. 顶层例化和仿真验证,扫频与频谱的直观确认
4.1 把累加器和 ROM 拼装成完整 DDS
前面讲的两块逻辑,敲定了相位累加器和正弦查找表,最后一步是例化成完整的 DDS 模块。顶层除了把两个子模块接起来,还需要提供一个频率控制字输入端口和可选的输出寄存器。
module dds_top #( parameter N = 32, parameter ADDR_WIDTH = 12, parameter DATA_WIDTH = 8 )( input wire clk, input wire rst_n, input wire [N-1:0] fword, output wire [DATA_WIDTH-1:0] sine_out ); wire [N-1:0] phase; dds_phase_accumulator #( .N(N) ) u_accum ( .clk (clk), .rst_n (rst_n), .fword (fword), .phase (phase) ); sine_lut #( .ADDR_WIDTH(ADDR_WIDTH), .DATA_WIDTH(DATA_WIDTH) ) u_lut ( .clk (clk), .addr (phase[N-1 -: ADDR_WIDTH]), .dout (sine_out) ); endmodule注意顶层里直接把相位值的高 12 位接给了查找表地址,中间没有任何缓冲。这样做的好处是节省一个周期延迟,缺点是累加器输出的组合逻辑直接驱动 ROM 地址,时序上要保证这个路径的延迟满足一个时钟周期。在 100 MHz 下没问题,如果跑到 250 MHz 以上,建议在查找表地址前再插一级寄存器。
4.2 tesebench 怎么写,仿真结果怎么确认
验证 DDS 模块用不着复杂的 UVM 环境,一个简单的 testbench 把频率控制字换成几个典型值,跑一段时间,用$fwrite把输出数据落盘,再用别的手段看波形就足够了。
下面给出一段可用的 testbench 模板,支持仿真结束前自动停止。
module tb_dds_top; localparam N = 32; localparam ADDR_WIDTH = 12; localparam DATA_WIDTH = 8; reg clk; reg rst_n; reg [N-1:0] fword; wire [DATA_WIDTH-1:0] sine_out; dds_top #( .N(N), .ADDR_WIDTH(ADDR_WIDTH), .DATA_WIDTH(DATA_WIDTH) ) u_dut ( .clk (clk), .rst_n (rst_n), .fword (fword), .sine_out (sine_out) ); initial clk = 0; always #5 clk = ~clk; // 100 MHz 时钟 integer fp; initial begin rst_n = 0; fword = 32'd0; #20 rst_n = 1; // 10 kHz 输出,对应系统时钟 100 MHz fword = 32'h0000A7C6; fp = $fopen("dds_output.txt", "w"); repeat (10000) begin @(posedge clk); $fwrite(fp, "%d\n", sine_out); end $fclose(fp); $finish; end endmodule跑完仿真后,用 Python 读取dds_output.txt,做一次 FFT 估测输出频率。如果峰值不在 10 kHz,回查频率字的计算过程。常见的偏差有两个来源:一个是表格初始化文件路径不对,导致查表输出全零;另一个是时钟周期#5写成了#5ns但timescale不是 1ns/1ps,导致频率整体偏移。
4.3 上板验证的最小流转,逻辑分析仪还是示波器
仿真只能验证数字部分行为正确,最终波形好看不好看还得接 DAC 看模拟输出。上板时最省事的办法是把 DDS 输出通过开发板上的 Pmod 引脚直接送进示波器,如果示波器有足够的采样率,能看到阶梯状的正弦波,这是正常的,外加一个 RC 低通滤波器就能平滑成连续正弦。
如果你用的是带有 DAC 的开发板,比如常见的 ZYNQ 系列开发板带的高速 DAC,那就得额外处理一下 DDS 输出的位宽和 DAC 的接口时序。DAC 通常在特定的位宽模式(比如 14 bit 左对齐对齐),需要把 8 位输出扩展到对应位宽,并在数据时钟下同步输出。
// 接 14 位 DAC 时的数据对齐示例 wire [13:0] dac_data; assign dac_data = {sine_out[7:0], 6'b000000};这行代码把 8 位正弦波数据放在 14 位总线的最高 8 位,低 6 位补零,配合 DAC 工作在左对齐模式。如果 DAC 要求右对齐,则改成{6'b000000, sine_out[7:0]}。对齐方式错了,示波器上看到的就是波形严重失真或者只有半波。
4.4 用 ILA 观测数字域信号,替代示波器抓数据
在没有 DAC 或者示波器带宽不够的情况下,可以直接用 Vivado 的集成逻辑分析仪 ILA 抓 sine_out 的数据,看是不是正弦轮廓。ILA 的采样时钟就是 DDS 的驱动时钟,抓到的数据导入波形窗口后,肉眼就能判断波形是否干净。
ILA 的配置有一点要特别注意,采样深度至少得跨越一个完整输出周期。输出频率很低(比如 100 Hz)时,在 100 MHz 采样率下意味着要采 1M 个点才能看到完整周期,ILA 深度不够就看不出来。所以低频率验证时建议先输出一个 1 kHz 以上的信号,抓到波形后再调回低频率。
5. 一个实用的进阶技巧:在线改频率字实现线性扫频
DDS 模块架构稳定之后,最值得玩的就是动态改变频率控制字。只要在逻辑里加一个计数器,周期性地把频率字从起始值偏移到终止值,就能实现线性扫频,这在雷达和声呐领域叫做 chirp 信号。实现不需要任何额外 DSP,只需要一个减法器和一个加法器。
reg [N-1:0] fword_base; reg [N-1:0] fword_sweep; reg [23:0] sweep_cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin fword_sweep <= 32'd0; sweep_cnt <= 24'd0; end else if (sweep_cnt >= SWEEP_LEN) begin sweep_cnt <= 24'd0; fword_sweep <= fword_base; // 扫频结束回到起始频率 end else begin sweep_cnt <= sweep_cnt + 1'b1; fword_sweep <= fword_sweep + STEP; // 频率字线性递增 end end这个扫频控制的代码里,SWEEP_LEN决定扫频时间,STEP决定频率变化斜率。把fword_sweep接到 DDS 顶层的fword输入端,就能在示波器上看到频率随时间线性上升的正弦波。想扫频的时候需要确认输出频率的计算关系:频率增量等于STEP * Fclk / 2^N,反过来,给定目标扫频带宽和扫频时间,就能算出 STEP 的取值。
扫频有一个隐藏问题,相位累加器中的相位值是连续的。这意味着扫频频率变化时波形是相位连续过渡的,不会像手动切换频率字那样出现相位跳变。这是 DDS 方案相比直接切换 PLL 频率的巨大优势。若频率字跳变时相位不连续,频谱上会产生大量杂散能量,实际系统中表现为“咔哒”声或者频谱泄漏。
也可以用 NCO IP 核快速对比这个 DIY 方案的性能,Xilinx 的 DDS IP 支持线性扫频和相位抖动选项,但核心原理跟咱们这个 DIY 模块一模一样。IP 的优势在于优化过资源和时序,劣势是参数配置界面复杂,不直观。自己写的模块拿来做学习和基础产品完全够用,跑高速时才需要换成 IP 配合 AXI4-Stream 接口对接 DMA 数据通路。
本文还有配套的精品资源,点击获取