ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

基于FPGA的DDS信号发生器:Verilog实现相位累加器与正弦查找表

基于FPGA的DDS信号发生器:Verilog实现相位累加器与正弦查找表 简介面向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 MHz321 kHz429500x0000A7C6100 MHz3210 kHz4294970x00068DB9100 MHz321 MHz429496730x028F5C29100 MHz3210 MHz4294967300x1999999A50 MHz321 kHz858990x00014F8B125 MHz3210 MHz3435973840x147AE147这里面最容易犯的错误是直接用浮点数算完往寄存器里面写写完发现频谱上有杂散。原因不是 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{1b0}}; 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适用场景2568 bit48~55 dBc简易音频信号、蜂鸣器驱动102410 bit60~66 dBc触摸屏激励、中频信号模拟409612 bit72~80 dBc实验室信号源、低速通信调制1638414 bit84~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 32d0; #20 rst_n 1; // 10 kHz 输出对应系统时钟 100 MHz fword 32h0000A7C6; 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], 6b000000};这行代码把 8 位正弦波数据放在 14 位总线的最高 8 位低 6 位补零配合 DAC 工作在左对齐模式。如果 DAC 要求右对齐则改成{6b000000, 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 32d0; sweep_cnt 24d0; end else if (sweep_cnt SWEEP_LEN) begin sweep_cnt 24d0; fword_sweep fword_base; // 扫频结束回到起始频率 end else begin sweep_cnt sweep_cnt 1b1; 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 数据通路。本文还有配套的精品资源点击获取
返回列表