FPGA实战:DDS相位累加器原理与Verilog实现全解析
2026/9/11 7:45:22 网站建设 项目流程

简介:面向FPGA学习者的DDS信号输出实战训练资源,完整工程包含Verilog源码、Quartus工程配置、仿真波形与编译报告,适合正在练习数字信号合成、相位累加器与查找表设计的开发者。资源共88个文件,以.v/.tdf源码、.qsf/.qpf工程文件、.rpt报告、.vwf仿真波形及.sof/.pof配置文件为主,压缩包仅656KB,便于快速下载和部署。已有1029人学习下载。资料围绕DDS核心流程展开,包含相位累加、LUT查表、DAC接口控制等模块代码,并配有说明文档和综合布局报告,方便对照设计思路排查问题。通过学习可掌握FPGA实现任意频率正弦波输出的完整路径,为无线通信、信号源等场景打下基础。

1. 为什么DDS在FPGA上要先跟相位累加器和解

很多新手做fpga信号发生器,第一眼看到DDS的框图就跑去找正弦波ROM,结果被IP核配置界面里的“相位增量”“频率分辨率”“SFDR”这些词卡住半天。实际上DDS最先要解决的不是查表,而是一个N位加法器每个时钟沿累加一次频率字之后,高位什么时候溢出。只要把这个溢出行为想清楚,锁相环、正交混频、跳频扩频这些后续应用都是同一套路。

这个实战训练对应Quartus工程里的一组RTL文件:dds.v、memTable.v、ctrlDuty.v、transAddr.v、setR.v,加上dds.vwf仿真向量和完整的fit/map/asm报告,最终要实现的是一路可调频率的正弦波输出,以及一路占空比可调的方波。适合已经跑过流水灯和按键消抖、想完整走一遍“位宽计算—仿真—下载”流程的FPGA开发者。

2. 频率控制字与相位累加器:位宽决定频率分辨率而不是输出精度

2.1 相位累加器本身就是一个加法器

DDS的核心思想是“用数字积分代替模拟振荡”。一个N位寄存器,每个系统时钟沿都加上一个频率控制字freq_word,寄存器自然溢出回绕。这个回绕周期就是输出正弦波的周期。所以相位累加器不需要特判溢出,硬件加法器的进位丢失天然产生模N取余的效果。

频率控制字到输出频率的表达式为:

f_out = fword × f_clk / 2^N

这个式子里有三个变量:时钟频率、频率字、累加器位宽。实际选型时最容易被忽略的是N。有人会把N当成“DDS输出幅度精度”,其实输出信号的幅度精度由查找表数据位宽决定,N只决定频率分辨率。分辨率按同样公式最小步进为f_clk / 2^N。我一般会先定输出频率范围和解算精度,再推N,最后才考虑ROM大小。

2.2 频率控制字取值与误差边界

以50MHz系统时钟为例,分别取16、24、32、48位累加器,频率步进如下表。注意表中“最大无混叠输出频率”统一按40% f_clk估算,低于Nyquist极限以留出抗镜像滤波余量。

累加器位宽 N最小频率步进 @50MHz100kHz信号对应fword最大无混叠输出频率
16762.9 Hz131~20 MHz
242.98 Hz33554~20 MHz
320.0116 Hz8589935~20 MHz
481.78e-7 Hz5.62e8~20 MHz

从表里可以看出,N越大频率分辨率越好,但代价并不是ROM翻倍,因为查表地址只取相位高M位,累加器多出来的低位只参与进位链。FPGA里32位加法器在入门级器件上跑到100MHz没有压力,所以做信号发生器时直接上32位是性价比很高的起点。

频率字必须取整数,这就带来一个量化误差。最大频率绝对误差等于半个Lsb频率字对应的输出频率,即f_clk / 2^(N+1)。例如50MHz时钟、32位累加器时最大绝对误差约0.0058Hz。如果要输出100kHz,取fword = round(100000 × 2^32 / 50000000) = 858993,实际输出约99999.997Hz,误差不到3mHz。对绝大多数现场应用,这个误差已经低于晶振本身的偏差。

2.2.1 频率字计算容易翻车的细节

计算fword时,常见误用是直接写fword = f_out / f_clk × 2^N,这在浮点除法后取整没问题,但如果在C语言或Python里先整形除法就会变成0。更隐蔽的问题在Verilog里:如果直接把计算出的十进制常量写进代码,综合器按有符号数处理,大于2^31的值在高位赋值时可能出现符号扩展。所以频率字我建议用无符号reg类型,并在parameter定义时显式写32'd858993这种形式。

2.3 可综合的相位累加器Verilog

DDS工程里dds.v内部的相位累加器,写成独立模块可以这样组织:

module phase_acc #( parameter N = 32 )( input wire clk, input wire rst_n, input wire [N-1:0] freq_word, output wire [N-1:0] phase ); reg [N-1:0] acc_reg; wire [N-1:0] acc_next = acc_reg + freq_word; always @(posedge clk or negedge rst_n) begin if (!rst_n) acc_reg <= {N{1'b0}}; else acc_reg <= acc_next; end assign phase = acc_reg; endmodule

acc_next先做组合求和,时序逻辑里只触发寄存器更新,这样综合工具能清楚看到加法器的输入输出路径,便于做Fmax优化。异步复位用negedge rst_n,确保上电后相位从0开始。如果希望支持扫频,freq_word也可以定义为有符号数,低位累加,高位带符号输出,但本工程做固定频率输出时保持无符号最省资源。

代码里的N是parameter的上游开关,不是localparam。这样顶层例化时可以一次把N从32改成48,而不需要改这个模块内部任何一行。综合后相位累加器的资源占用大约只是N个逻辑单元加进位链,在Quartus的dds.map.rpt里会看到整个DDS的Total logic elements主要耗在查找表地址译码和输出寄存器上,加法器本身占比很小。

3. 从相位到波形:memTable查找表和相位截断的取舍

3.1 正弦LUT该存多少样点

累加器输出的是锯齿相位,要变成正弦波需要把相位映射到幅度。最常见方案是全周期查找表,地址M位,深度2^M,存储一个完整的正弦周期。这一版memTable.v实现的就是全周期表,代码直观、不易出错。但做高分辨率输出时,全周期表相当浪费BRAM,比如M=14,数据宽度16位时占用256Kbit,很多低成本FPGA一个M9K模块整块被占掉。

工程做法有三类:全周期表、半周期表、四分之一周期表。三者的核心取舍如下表:

存储方式地址位宽所需样点数幅度恢复逻辑
全周期M2^M直接查表
半周期M-12^(M-1)高1位做符号翻转
四分之一周期M-22^(M-2)高2位做象限映射,地址和符号分别翻转

我一般倾向于存四分之一周期,因为正弦波在[0, π/2]区间内满足幅值对称和相位互补,通过地址翻转和符号翻转可以还原整个周期。以M=12为例,全周期需要4096点,四分之一周期只需1024点,BRAM用量减少到原来的四分之一,代价只是多了两三个异或门和补码运算。资源足够或者赶进度时,先用全周期表跑通,再改成半周期表,这样排错更容易。

3.2 用initial块代替mif文件的memTable实现

很多教程让读者用Quartus IP核生成ROM并加载.mif文件,但实战工程里memTable.v用的是Verilog initial块初始化ROM,这类写法更适合代码review和版本管理,不需要额外维护mif文件。

module memTable #( parameter ADDR_W = 12, parameter DATA_W = 12 )( input wire clk, input wire [ADDR_W-1:0] addr, output reg [DATA_W-1:0] q ); reg [DATA_W-1:0] rom [0:(1<<ADDR_W)-1]; integer i; real amp; initial begin for (i = 0; i < (1<<ADDR_W); i = i + 1) begin amp = $sin(2.0 * 3.14159265 * i / (1<<ADDR_W)); rom[i] = $rtoi(amp * ((1<<(DATA_W-1))-1)); end end always @(posedge clk) begin q <= rom[addr]; end endmodule

这个ROM是两个周期后才出数据:地址在时钟沿进入BRAM地址寄存器,再过一个时钟沿q寄存器才更新为对应数据。也就是说从phase地址到sin_out会引入两级流水延迟。这样做的原因是BRAM本身的读取时序就是同步读,q寄存器再打一拍可以将IO延时和下一级组合逻辑隔开,规避毛刺。

$sin$rtoi是仿真系统函数,Quartus对initial块里这种有限循环的处理是当作ROM初始化内容来综合,但不同版本对浮点函数的支持程度不同,遇到综合错误时可以改用Python生成mif文件:

import math depth = 4096 width = 12 with open("sine.mif", "w") as f: f.write(f"WIDTH={width};\nDEPTH={depth};\n") f.write("ADDRESS_RADIX=DEC;\nDATA_RADIX=DEC;\nCONTENT BEGIN\n") for i in range(depth): v = int(round((2**width - 1) / 2 * (1 + math.sin(2 * math.pi * i / depth)))) v = max(0, min(v, 2**width - 1)) f.write(f"{i} : {v};\n") f.write("END;\n")

然后在memTable.v里用$readmemh("sine.mif", rom)替代initial循环。注意mif里的数据是无符号偏移格式,0对应满幅负值,2^DATA_W-1对应满幅正值。如果后级DAC需要二进制补码,可以在memTable.v输出端做一次减偏移。

3.2.1 幅度位宽与输出接口

DATA_W决定DDS输出的信噪比。幅度每增加1位,量化噪声功率约改善6dB。12位输出适合驱动AD9708这类通用DAC,8位输出在逻辑分析仪上看着更直观,但正弦波台阶感明显。参数ADDR_W每增加1位,ROM深度翻倍,SFDR理论上改善约6dBc。实际工程里ADDR_W取12以上更稳妥,低于10时波形失真肉眼可见。

3.3 相位截断与杂散

相位累加器位宽N通常远大于查表地址位宽M,所以phase的高M位进ROM,低N-M位被丢弃,这就是相位截断。相位截断产生的周期误差会在输出频谱上表现为离散杂散,而不是均匀噪声。最坏情况下杂散幅度与M的关系大致是SFDR ≈ 6M dBc。M=12时约72dBc,对一般信号源足够,但用在雷达或宽带跳频系统里就需要更高M或加抖动。

加抖动是常见做法:把累加器低N-M位中截取的若干位通过LFSR异或后,叠加到ROM地址的低1~2位上,破坏相位截断误差的周期性。代价是相位噪声抬高,但离散spur被抹平成底噪。这个工程没做抖动,因为目标场景是实验室信号发生器,72dBc杂散在示波器和频谱仪上已经看不到明显毛刺。如果你要用于DDS IP核级联,再考虑加dither处理。

4. 把DDS放上FPGA:dds.v、ctrlDuty.v到Quartus引脚约束

4.1 顶层dds.v的模块组织与流水线

拿到Quartus工程后,建议先打开dds.qpf和dds.v看整体结构。我的习惯是顶层模块按“相位累加器—地址转换—查表—输出寄存”四级流水组织。地址转换在这里由transAddr.v负责,它把相位累加器的高位映射成memTable的ROM地址,同时给出符号位。示例顶层如下:

module dds_top #( parameter N = 32, parameter M = 12, parameter D = 12 )( input wire clk, input wire rst_n, input wire [N-1:0] fword, output wire [D-1:0] sin_out, output wire sign ); wire [N-1:0] phase; wire [M-1:0] rom_addr; wire [D-1:0] sin_quarter; phase_acc #(.N(N)) u_phase ( .clk (clk), .rst_n (rst_n), .freq_word (fword), .phase (phase) ); transAddr #(.N(N), .M(M)) u_trans_addr ( .phase (phase), .addr (rom_addr), .sign (sign) ); memTable #(.ADDR_W(M), .DATA_W(D)) u_mem ( .clk (clk), .addr (rom_addr), .q (sin_quarter) ); assign sin_out = sign ? (~sin_quarter + 1'b1) : sin_quarter; endmodule

这里把transAddr.v实现了相位到象限的转换,输出补码形式的正弦波。assign sin_out = sign ? (~sin_quarter + 1'b1) : sin_quarter放在memTable后面做补码翻转,没有额外打拍,如果时序余量不足,可以在顶层再加一级输出寄存器。

注意这版memTable仍然按全周期表设计,所以transAddr.v在这里实际只做了相位截断。如果你要改成四分之一周期表,只需要把transAddr.v的addr位宽从M改成M-2,并把sin_out用象限逻辑翻转,memTable深度参数同步减小。改动边界清晰,这是模块化带来的好处。

4.2 ctrlDuty.v与占空比可调的方波输出

DDS不全是为了正弦波,很多测试场景需要同频方波。ctrlDuty.v在工程里承担的就是方波/脉冲输出。给它一个自由运行的计数器和一个占空比寄存器duty,比较结果直接产生PWM:

module ctrlDuty #( parameter CNT_W = 12 )( input wire clk, input wire rst_n, input wire [CNT_W-1:0] duty, output reg pwm_out ); reg [CNT_W-1:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 0; pwm_out <= 0; end else begin cnt <= cnt + 1'b1; pwm_out <= (cnt < duty); end end endmodule

这个PWM的基频是f_clk / 2^CNT_W,与DDS正弦波频率没有直接关系。如果想让方波频率跟随DDS,更合理的做法是直接取相位累加器的最高位作为方波,再用ctrlDuty在里面做脉宽微调。工程里ctrlDuty.v存在的原因应该是在同一个信号源里提供独立的可调占空比输出。用计数器比较输出PWM时,比较器建议放在always块内部生成寄存器型输出,而不是用assign pwm_out = (cnt < duty)的组合逻辑,否则cnt变化瞬间duty没有同步改变时会产生毛刺。

4.3 qsf/qpf引脚约束与DAC直连注意点

Quartus工程通过dds.qsf和dds_assignment_defaults.qdf保存引脚和时序约束。这类文件核心内容是把RTL端口映射到FPGA引脚:

set_global_assignment -name FAMILY "<FPGA_FAMILY>" set_global_assignment -name DEVICE "<FPGA_PART>" set_location_assignment PIN_<PIN> -to clk_50m set_location_assignment PIN_<PIN> -to rst_n set_location_assignment PIN_<PIN> -to dac_out[0] set_location_assignment PIN_<PIN> -to dac_out[1] # 展开到 dac_out[11] set_instance_assignment -name IO_STANDARD "3.3-V LVCMOS" -to dac_out[*]

dac_out[11:0]是memTable的输出,直接连外部DAC时要注意三点。第一,FPGA引脚IO标准必须是3.3V LVCMOS,否则DAC参考电压不匹配;第二,高速翻转的12位并行总线需要做引脚分组约束,把同组引脚分到同一IO Bank,减少skew;第三,如果DAC输入没有内部锁存,建议在顶层输出前加一组寄存器让所有数据同时变化,否则中间毛刺会被DAC采样成尖峰。

工程里如果只用R-2R电阻网络做简易DAC,还要注意FPGA引脚拉电流有限,直接驱动低阻网络会让高电平跌落。正确的做法是电阻网络到地再接运放跟随器,运放输出再接示波器或ADC。fpga信号发生器ego1这类板载DArr的厂商库一般已经处理了这个问题,但自己搭面包板时几乎都会踩这个坑。

4.4 从综合报告看资源占用:ASM、MAP、FIT报告

工程文件里有一串后缀为rpt的报告,分别是map(映射)、fit(布局布线)、asm(汇编)、tan(时序分析)、flow(整体流程)。拿到这些报告第一件事不是打开asm汇编报告,而是看dds.map.rpt和dds.fit.rpt里的“Fitter Resource Usage Summary”。DDS工程通常关心三个数字:

资源项关注点
Total logic elements用途:加法器和BRAM地址译码
Total memory bits用途:memTable正弦表
Fmax限制:决定了最高系统时钟

如果Fmax低于目标时钟,先看关键的组合路径是不是memTable的地址到q输出链路。这条路径包含ROM读延迟,再叠加sign的补码翻转,容易成为关键路径。解决方法是在transAddr.v和memTable.v之间加一级流水寄存器,或者把补码翻转改成纯符号复制扩展。

dds.vwf是Quartus内置仿真器的波形文件,打开后在Processing里启动Simulation Tool,可以像Modelsim一样观察phase、addr、sin_out信号。VWF适合快速确认寄存器复位和时钟沿关系,但跑完整个DDS需要很长时间,因为要看到完整正弦波至少得等2^N/fword个时钟周期。这类耗时仿真还是建议用ModelSim脚本跑,这也是下一章要讲的验证方法。

5. 仿真与动态频率切换:从.vwf到无毛刺更新

5.1 在ModelSim里验证输出频率

拿到DDS工程后,我习惯直接写一个testbench,绕过VWF限制,用$rtoi自动算频率字。以32位累加器、50MHz时钟、目标频率10kHz为例:

`timescale 1ns/1ps module tb_dds; reg clk = 0; reg rst_n = 0; reg [31:0] fword; wire [11:0] sin_out; localparam real F_CLK = 50.0e6; localparam real F_OUT = 10.0e3; localparam real TWO_32 = 4294967296.0; initial begin #100 rst_n = 1; fword = $rtoi($floor(F_OUT * TWO_32 / F_CLK + 0.5)); #2_000_000 $finish; end always #10 clk = ~clk; dds_top #(.N(32), .M(12), .D(12)) dut ( .clk (clk), .rst_n (rst_n), .fword (fword), .sin_out(sin_out) ); endmodule

testbench里用localparam real类型做浮点计算,避免整数除法截断。2ms仿真在ModelSim里大约十几秒跑完,能看到100个完整正弦周期。测量输出周期时,把sin_out从负值变成正值的穿越点作为标记,用光标量两个穿越点间隔,再换算频率。实测值与10000Hz的差异应该小于0.02Hz,这个误差来自频率字量化,不是逻辑错误。

5.2 setR.v与transAddr.v的组合使用:频率字同步切换技巧

如果只是上电固定频率,dds.v里直接给fword常量就够了。但工程里有setR.v,说明这里预留了动态改频的寄存器接口。动态改频最容易出问题的是32位频率字被拆成两次16位写操作时,中间一个时钟周期里相位累加器会拿到一个高半字是旧值、低半字是新值的混合数,输出频率瞬间跳到一个不在计划内的值,在时域上表现为一个明显毛刺。

我常用的做法是加一级影子寄存器。setR.v里维护一个shadow,写接口先更新shadow,等两半字都写完后,用一个load脉冲把shadow一次性锁存到fword:

module setR #( parameter N = 32 )( input wire clk, input wire rst_n, input wire [N-1:0] new_freq, input wire load, output reg [N-1:0] fword ); reg [N-1:0] shadow; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin shadow <= {N{1'b0}}; fword <= {N{1'b0}}; end else begin shadow <= new_freq; if (load) fword <= shadow; end end endmodule

load信号本身如果来自另一个时钟域,必须先打两拍同步到DDS的clk域,否则fword在异步时序下可能采样到半个周期的shadow,产生同样的毛刺。我现在做跳频源时,还会加一个状态位busy,写高16位后拉高busy,写低16位后拉低,host只有看到busy为0才能发起下一次改频,从根源上避免地址线上的撕裂写。transAddr.v这个模块名在不同工程里指代不一致,如果你拿到的工程里它做的是总线地址译码,那就把它和setR.v配合起来用,作为寄存器组解码器。这是DDS人机接口最常见的组合方式。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询