news 2026/9/11 8:27:02

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于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 kHz429500x0000A7C6
100 MHz3210 kHz4294970x00068DB9
100 MHz321 MHz429496730x028F5C29
100 MHz3210 MHz4294967300x1999999A
50 MHz321 kHz858990x00014F8B
125 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{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适用场景
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 = 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写成了#5nstimescale不是 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 数据通路。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 8:25:39

智能汽车软件可控感设计与工程实践

1. 汽车软件品牌升级的核心挑战与破局点在智能汽车快速迭代的今天&#xff0c;软件定义汽车已成为行业共识。去年某头部车企的OTA升级事故导致大规模车辆停摆&#xff0c;直接暴露出软件可控性的致命短板——当车机系统崩溃时&#xff0c;连最基本的车窗控制都失效。这个典型案…

作者头像 李华
网站建设 2026/9/11 8:21:45

ARM嵌入式AI实战:ML-KWS-for-MCU源码深度解析与内存优化

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/11 8:20:08

昇思MindSpore大模型对齐实战:从RLHF到DPO的偏好优化指南

做昇思MindSpore大模型训练的朋友&#xff0c;可能都有过这种时刻&#xff1a;基座模型辛辛苦苦预训练完&#xff0c;loss低得漂亮&#xff0c;给它一句提示词也能生成一大段通顺的文字&#xff0c;可一旦让它"按给定格式输出"或者"只回答要点"&#xff0c…

作者头像 李华
网站建设 2026/9/11 8:19:31

无电无网的野外监控怎么搭?太阳能4G云台半年使用实测

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/11 8:16:47

HarmonyOS养老APP开发:三层架构与分布式实践

1. 项目背景与核心价值"益康养老"HarmonyOS APP的开发源于当前老龄化社会对智能化养老服务的迫切需求。作为一款面向老年群体的健康管理应用&#xff0c;它需要解决三个核心问题&#xff1a;操作简易性、数据安全性以及跨设备协同能力。选择HarmonyOS作为开发平台&am…

作者头像 李华