资讯动态

基于FPGA与Verilog的DDS信号发生器:从相位累加到扫频上板实践

发布时间:2026/10/26 5:09:37 来源:尧图企业网站定制
简介一套面向FPGA数字逻辑学习者的DDS信号发生器工程包围绕直接数字频率合成的Verilog实现展开可用于理解相位累加、频率控制字、分频调频、MIF查找表与DAC接口等关键环节。压缩包共315个文件约5.7MB包含Verilog与SystemVerilog源码、MIF波形数据、Quartus工程文件qpf/qsf/qip以及综合布局布线产生的cdb、hdb数据库和rpt报告另附说明文档工程链条相对完整。已有902人学习下载。读者可据此查看相位累加器、分频器、波形查表和DAC输出模块的组织方式结合正弦查找表理解频率与相位调整思路并借助现有工程进行仿真验证和板级调试适合作为课程设计、实验项目或DDS入门实践的参考。1. 手头缺一台能扫频的信号源时我用一块 FPGA 板子跑通了 Verilog 版 DDS去年调一个传感器激励电路需要一路频率可调、步进细、波形能切的信号。桌面信号发生器倒是有但改频率靠旋钮自动扫频还要另接上位机脚本来回折腾。后来把一块闲置的 FPGA 开发板翻出来写了一版 Verilog 的 DDS 信号发生器频率字一个寄存器直接控制1 Hz 步进都稳得住扫频逻辑塞进同一个工程里配合一个 14 位并行 DAC输出正弦、方波、三角波都能切换。这件事让我更确信DDS 这种靠相位累加加查找表出波形的结构是 FPGA 练手和实战之间最划算的一类小工程——原理一页纸能写完写起来却能把时钟、存储、时序、DAC 接口这些坑一个一个踩出来。这篇文章把我从参数计算到上板调试的完整路径摊开适合刚碰 Verilog 不久、想拿一个真实可跑的小系统练手的人也适合手边需要一个灵活激励源、又不想被专用仪器绑住的从业者。2. DDS 的两笔账频率字怎么算、查找表怎么选DDS 的核心并不神秘一个相位累加器加一张波形查找表就能把一个固定时钟变成任意频率的周期波形。但真正动手前有两笔账必须先算清楚频率分辨率由累加器位宽和系统时钟共同决定输出频谱的干净程度则由查找表深度和幅度位宽把守。这两笔账算错了后面代码写得再漂亮波形也是歪的。2.1 从相位累加器到输出频率一条公式决定分辨率DDS 的数学骨架是每个时钟周期把相位值加上一个固定的步进量累加器自然溢出取模 2^N输出的高位作为查表地址。设系统时钟为 f_clk累加器位宽为 N频率控制字为 K则输出频率为f_out K × f_clk / 2^N反过来要输出目标频率 f_out频率字这样算K round(f_out × 2^N / f_clk)以 100 MHz 时钟、32 位累加器、目标 1 kHz 为例K 1000 × 2^32 / 100,000,000 ≈ 42949.67取 42950实际输出频率为 42950 × 100e6 / 2^32 ≈ 1000.0005 Hz误差在 0.5 mHz 量级。这个误差来源不是代码写错而是频率字只能取整数属于 DDS 固有的量化误差工程上一般可以忽略。频率分辨率等于 f_clk / 2^N100 MHz 配 32 位时约 0.023 Hz。这就是为什么我倾向于把累加器位宽开到 32 位分辨率够细改频率时不会出现“想设 1 kHz 结果只能跳到 1.02 kHz”的尴尬。但位宽越大后面查表截断时丢掉的相位位也越多杂散指标会受影响取舍放在下一节说。有一个细节容易被忽略累加器寄存器本身承担了 2^N 取模不需要额外写取模运算。Verilog 里phase phase freq_word这一句溢出位直接丢弃正好就是相位回绕。硬要写成if (phase 2**N) phase 0反而是错的还多占了资源。2.2 查找表位宽与相位截断SFDR 为什么上不去理想 DDS 的输出频谱只有一根主谱线实际输出会有杂散主要来源有三个相位截断、幅度量化、DAC 非线性。其中前两个是设计阶段就能控制的。相位截断杂散来自“累加器 32 位但 ROM 只有 2^A 个地址”。如果 A 取 12相当于只用了相位的高 12 位低 20 位被直接扔掉。粗略估算截断 D 位相位时无杂散动态范围 SFDR 大约在 6.02D dB 附近12 位地址对应约 72 dB 的理论上限实测通常还要低一些。想再往上抬要么加 ROM 深度要么在截断前加抖动dithering把周期性杂散打散成噪声。幅度量化杂散由 ROM 输出位宽决定。B 位幅度数据下理论信噪比约 6.02B 1.76 dB。12 位幅度大约 74 dB14 位约 86 dB。很多入门开发板上的 DAC 是 10 位或 12 位并行接口ROM 输出位宽做到 12 位到 14 位就够用再往上加瓶颈会转移到 DAC 芯片本身。参数怎么组合我一般按这张表先框定范围再动手写代码系统时钟累加器位宽频率分辨率查找表深度地址位宽输出频率上限工程建议50 MHz243 Hz1024105 MHz100 MHz320.023 Hz40961210 MHz125 MHz320.029 Hz40961212 MHz输出频率上限为什么远低于奈奎斯特频率 f_clk/2因为 DDS 输出是阶梯状采样序列高频段每周期采样点太少DAC 后面的重建滤波器很难把镜像压干净。经验值是输出频率不超过时钟的 1/10 到 1/8波形失真才在可接受范围内。2.3 选型理由为什么不用直接计数分频有人会问输出方波直接拿计数器分频不就行了何必上 DDS。分频法的问题是频率只能取 f_clk 除以整数分辨率在低频段很差而且换波形、扫频、调相位都不方便。DDS 的相位是连续累加的改频率字立即生效且相位不跳变扫频时波形连续这是分频法给不了的。另一个常见方案是查表加插值用低深度 ROM 配线性插值器换精度代价是乘法器资源和时序压力除非对 SFDR 有很高考量一般小系统用不着。3. Verilog 落地三个模块把纸面公式变成波形参数算完接下来是把公式翻译成可综合的 Verilog。我习惯拆成三块相位累加器、波形查找表、顶层与 DAC 接口。拆开的好处是每一块都能单独仿真、单独换实现后面调参时改动面小。3.1 相位累加器模块频率字的加载与流水线处理累加器是最简单也最容易出问题的一块。频率字如果直接从外部端口接进来参与累加一旦输入在累加过程中变化那一拍的相位增量就不确定输出会出现相位毛刺。稳妥的做法是加一个更新脉冲只在脉冲有效时锁存频率字module phase_acc #( parameter integer PHASE_W 32, // 累加器位宽决定频率分辨率 parameter integer FREQ_W 32 // 频率字位宽通常与累加器一致 )( input wire clk, input wire rst_n, input wire [FREQ_W-1:0] freq_word, // 频率控制字 K input wire freq_upd, // 单拍更新脉冲 output reg [PHASE_W-1:0] phase // 当前相位值 ); reg [FREQ_W-1:0] freq_word_r; always (posedge clk or negedge rst_n) begin if (!rst_n) begin freq_word_r {FREQ_W{1b0}}; phase {PHASE_W{1b0}}; end else begin // 只在新频率字到来时锁存避免异步输入在累加中途变化 if (freq_upd) freq_word_r freq_word; // 溢出位自动丢弃等价于对 2^PHASE_W 取模 phase phase freq_word_r; end end endmodule逻辑说明freq_upd起到了一个写使能的作用把频率字和累加动作隔离开。如果工程里频率字由另一个时钟域产生这个脉冲还要先做跨时钟域同步不能直接把异步信号当使能。参数说明PHASE_W决定分辨率和查找表地址来源FREQ_W一般与PHASE_W保持一致若频率字位宽更小综合工具会自动补零但建议显式对齐避免位宽不匹配的警告淹没其他真正的问题。3.2 查找表模块ROM IP 核与纯逻辑写法的取舍查找表有两种实现路径调用厂商提供的 ROM IP 核挂初始化文件或者用case语句在逻辑里展开。前者的优点是深度大、时序好、改数据只换初始化文件后者只适合小深度示意深度一过几百行综合时间和可读性都会崩。实际工程我一律走 IP 核初始化文件用脚本生成。下面是查找表的接口示意数据源换成 IP 核后接口保持不变module sin_rom #( parameter integer ADDR_W 12, // 地址位宽深度 2^ADDR_W parameter integer DATA_W 12 // 输出位宽决定幅度量化精度 )( input wire clk, input wire [ADDR_W-1:0] addr, output reg [DATA_W-1:0] dout ); // 实际工程由厂商 ROM IP 核替换本段并加载 .coe/.mif 初始化文件 always (posedge clk) begin case (addr) 12d0 : dout 12d2048; // 中点对应相位 0 12d1024 : dout 12d2048; // 半周期处 12d2048 : dout 12d2048; // 溢出边界 12d3072 : dout 12d2048; default : dout 12d2048; endcase end endmodule需要强调的是上面case只是接口示例真实数据由生成脚本产出。以 12 位幅度、4096 点正弦表为例数据生成用几行 MATLAB 就够了N 4096; % 查找表深度 B 12; % 幅度位宽 addr 0:N-1; data round((2^(B-1)-1) * sin(2*pi*addr/N) 2^(B-1)); % 生成 .coe 文件格式按综合工具厂商的要求调整 fid fopen(sin_rom.coe, w); fprintf(fid, memory_initialization_radix10;\n); fprintf(fid, memory_initialization_vector\n); fprintf(fid, %d,\n, data(1:end-1)); fprintf(fid, %d;\n, data(end)); fclose(fid);参数说明2^(B-1)的偏移是为了把有符号正弦搬到无符号区间方便直接驱动单极性 DAC。如果 DAC 是差分或双极性接法这个偏移要不要保留要看后级电路不能照搬。生成时务必保证首尾点连续第 N 个点不写进表里否则查表到边界会多跳一格波形上出现一个固定位置的毛刺。3.3 顶层与 DAC 接口地址映射和时序收敛顶层负责把相位高位接到 ROM 地址、把 ROM 输出打到 DAC 数据口同时产生 DAC 时钟。相位到地址的映射写法是addr phase[PHASE_W-1 -: ADDR_W]取高位、丢低位这正是相位截断动作。如果开发板上 DAC 是源同步接口还要注意数据与时钟的对齐关系常见做法是把 DAC 时钟反相输出让数据在时钟上升沿稳定。module dds_top #( parameter integer PHASE_W 32, parameter integer ADDR_W 12, parameter integer DATA_W 12 )( input wire clk, input wire rst_n, input wire [PHASE_W-1:0] freq_word, input wire freq_upd, output wire [DATA_W-1:0] dac_data, output wire dac_clk ); wire [PHASE_W-1:0] phase; phase_acc #( .PHASE_W (PHASE_W), .FREQ_W (PHASE_W) ) u_acc ( .clk (clk), .rst_n (rst_n), .freq_word (freq_word), .freq_upd (freq_upd), .phase (phase) ); sin_rom #( .ADDR_W (ADDR_W), .DATA_W (DATA_W) ) u_rom ( .clk (clk), .addr (phase[PHASE_W-1 -: ADDR_W]), // 取高位做地址低位截断 .dout (dac_data) ); // DAC 时钟反相输出给外部器件留出建立保持余量 assign dac_clk ~clk; endmodule逻辑说明相位高位直接进 ROM 地址ROM 读延迟一拍输出数据相对相位晚一个时钟周期对连续波形没有影响只是整体相位平移做相位同步的系统里要记得把这个延迟算进去。参数说明ADDR_W和DATA_W必须与 ROM IP 核配置一致改了一处忘了另一处综合会报位宽不匹配或者悄悄截断是最隐蔽的一类错误。4. 仿真不是走流程用 Testbench 提前暴露频谱问题代码写完直接上板示波器上看到波形就以为成了这是新手最常见的心态。DDS 的很多问题——频率偏差、波形不连续、幅度台阶——在仿真阶段就能抓出来前提是 Testbench 写到位并且把数据导出来做频域分析。4.1 行为级仿真确认频率与幅度Testbench 的核心是给一个确定的频率字跑够时长观察输出的周期数是否与理论一致。下面这版可以直接用timescale 1ns / 1ps module tb_dds_top; reg clk; reg rst_n; reg [31:0] freq_word; reg freq_upd; wire [11:0] dac_data; wire dac_clk; // 100 MHz 系统时钟周期 10 ns initial clk 0; always #5 clk ~clk; dds_top #( .PHASE_W (32), .ADDR_W (12), .DATA_W (12) ) u_dds ( .clk (clk), .rst_n (rst_n), .freq_word (freq_word), .freq_upd (freq_upd), .dac_data (dac_data), .dac_clk (dac_clk) ); initial begin rst_n 1b0; freq_word 32d42950; // 对应 1 kHz freq_upd 1b0; #100; rst_n 1b1; #20; freq_upd 1b1; #10; freq_upd 1b0; #2000000; // 仿真 2 ms覆盖两个完整周期 $stop; end // 每个时钟把 DAC 数据写入文本供后续频谱分析 integer fd; initial fd $fopen(dds_out.txt, w); always (posedge clk) begin if (rst_n) $fdisplay(fd, %d, dac_data); end endmodule逻辑说明#2000000对应 2 ms 仿真时长1 kHz 信号刚好走两个周期肉眼在波形窗口里能数出峰峰值。freq_upd只拉高一拍模拟真实控制逻辑的写入动作。参数说明仿真时长和时钟频率要匹配如果改到 50 MHz 时钟#5要改成#10否则频率字算出来的结果和仿真显示对不上这类错配在实际项目里并不少见。4.2 把仿真数据导出来算 SFDR时域波形只能看个大概频谱干不干净要把数据导出来做 FFT。用 Octave 或 MATLAB 都行下面这段脚本读文本文件、去直流、加窗、找基波和最大杂散% 读取仿真导出的 DAC 数据并计算 SFDR x load(dds_out.txt); x x(:) - mean(x); % 去掉直流分量 N length(x); w hann(N); X abs(fft(x .* w)); X X(1:floor(N/2)); X_db 20*log10(X / max(X) eps); [~, fund_bin] max(X_db); % 基波左右各 3 个 bin 视为同一根谱线其余区域找最大杂散 mask true(size(X_db)); mask(max(1, fund_bin-3):min(end, fund_bin3)) false; sfdr X_db(fund_bin) - max(X_db(mask)); fprintf(SFDR %.2f dB\n, sfdr);参数说明加窗是为了压住频谱泄漏矩形窗在非整周期采样时会把主谱线能量摊到邻近 binSFDR 读数会偏低hann 窗是通用选择。掩码范围 3 个 bin 取决于频率字是否落在整数 bin 上如果频率字按整周期设置可以收窄到 1 个 bin。脚本跑出来的值不代表 DAC 实测指标它只反映数字部分的截断和量化杂散上板后还要叠加 DAC 非线性和时钟抖动。4.3 时序仿真查找表读延迟的影响行为级仿真通过不代表上板一定通过。ROM IP 核通常有一到两拍读延迟综合后的实际延迟取决于器件原语。如果系统里还有其他模块依赖 DAC 输出做反馈这个延迟必须算进环路。我一般在综合后跑一次门级时序仿真重点看两件事ROM 输出在 DAC 时钟沿前是否稳定以及freq_upd脉冲有没有因为布线延迟出现毛刺。前者决定波形是否失真后者决定频率切换瞬间是否出现跳变。时序仿真里如果发现 DAC 数据在时钟沿附近变化解决办法不是改代码逻辑而是在 ROM 输出和 DAC 端口之间插一级输出寄存器把路径约束在一个时钟周期内。这一级寄存器的代价是再增加一拍延迟但换来的是建立时间余量上板稳定性提升明显。5. 上板排查四类翻车现场和对应的后悔药仿真再漂亮第一次上板能一次点亮的概率也不高。DDS 这类系统的问题往往不是代码语法错误而是初始化、时钟、接口这些“外围”环节。下面几条是我自己踩过、也见过别人踩的坑按现象、原因、解决三段写方便对照。5.1 输出恒定不动ROM 初始化和 DAC 配置的连环问题现象上电后示波器测 DAC 输出一直是中间电平波形不动。原因多半是 ROM 没有正确加载初始化文件。综合工具找不到.coe文件时默认把存储内容填零查表地址怎么变输出都是零再经 DAC 偏移后就是固定电平。解决先翻综合日志里关于 ROM 初始化的 warning确认文件路径是相对工程路径还是绝对路径再打开生成的比特流报告看 ROM 是否被推断成分布式 RAM 或块 RAM有没有初始化内容。另一个可能是 DAC 配置没完成有些并行 DAC 需要先写控制寄存器进入正常模式如果配置状态机卡住数据口输入什么都不会体现在输出上。我一般会在配置完成信号上挂一个 LED先确认 DAC 已经退出复位再看波形。5.2 频率偏差频率字按错时钟算或者位宽搞混现象目标 1 kHz实测频率是 1.07 kHz 左右偏差固定。原因通常是把频率字按 50 MHz 时钟算好了工程实际跑在 100 MHz或者反过来。另一种情况是累加器位宽在顶层参数和子模块参数里不一致综合工具按其中一处生效频率字算出来的结果和实际累加宽度对不上。解决先拿示波器或逻辑分析仪测系统时钟的实际频率不要信晶振标称值再把频率字公式在纸上重算一遍确认时钟频率、位宽、频率字三个数都对上。改参数时顶层和子模块的参数必须同步修改用parameter传递而不是各自写死能从源头避免这类问题。5.3 波形毛刺与台阶数据格式、时序余量、滤波器三处嫌疑现象低频段波形看起来正常频率一升高波形上出现规律性台阶或毛刺幅度也开始塌。原因分三层第一层是 ROM 输出到 DAC 的路径没有输出寄存器建立时间不够第二层是 DAC 建立时间本身不够芯片选型时没核对数据手册里的 settling time第三层是后级重建滤波器截止频率设得太低把有用信号的高频分量也削掉了。解决先加输出寄存器排除时序问题再核对 DAC 建立时间与采样时钟的关系最后用扫频方式测一遍滤波器幅频响应截止频率一般取输出最高频率的 1.2 到 1.5 倍留够过渡带又不过度衰减。提示幅度塌陷不一定是 DDS 的错先断开滤波器直接测 DAC 输出能把问题范围缩小一半。5.4 换波形数据后输出异常初始化文件格式和首尾连续性问题现象把正弦表换成三角波表重新综合后输出波形周期性跳变或者整体幅度只剩一半。原因多半是初始化文件的进制格式和工具要求不一致比如工具要十进制而文件里写了十六进制读进去的数据全错另一种是波形数据首尾不连续三角波生成时第 N 个点写成了和第 1 个点相同的值查表回绕时多走一格输出上就出现固定位置的尖刺。解决生成脚本里固定进制声明生成后人工抽查前几个点和最后几个点首尾连续性用数值方式验证确认data(N) - data(1)的差值等于一个正常步进而不是零或跳变。5.5 频率切换瞬间相位跳变跨时钟域与更新时序现象静态输出时波形干净一旦通过控制接口改频率切换瞬间波形相位跳一下。原因是频率字来自另一个时钟域freq_upd没有同步就直接当使能用锁存时刻不确定或者频率字和更新脉冲的先后关系不固定有时旧字配新脉冲有时新字配旧脉冲。解决频率字先过两级触发器同步到 DDS 时钟域更新脉冲用脉冲同步器传递控制端保证先稳定频率字、再发更新脉冲中间留至少两个时钟周期的间隔。这类问题在仿真里不容易复现因为仿真器的跨时钟域行为和实际布线延迟不同只能靠设计规范提前规避。6. 从单频到扫频把 DDS 做成可调信号源的三个扩展单频输出只是起点真正让这块板子变得好用是在同一个工程里加上扫频、多波形切换和幅度调制。这三个扩展共用同一套相位累加和查找表增加的主要是控制逻辑代码量不大但能让一个练习工程变成手边常备的工具。6.1 扫频频率字的步进更新与换向边界扫频的本质是按固定时间间隔改频率字。下面的片段把更新周期设为 1 秒频率字线性递增到上限后回绕localparam integer SWEEP_PERIOD 100_000_000; // 100 MHz 下约 1 秒 localparam [31:0] FREQ_START 32d42950; // 1 kHz localparam [31:0] FREQ_STOP 32d429496; // 约 10 kHz localparam [31:0] FREQ_STEP 32d4295; // 每步约 100 Hz reg [31:0] cnt; reg [31:0] freq_cur; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 32d0; freq_cur FREQ_START; end else begin cnt cnt 1b1; if (cnt SWEEP_PERIOD) begin cnt 32d0; // 到达上限后回到起点形成循环扫频 if (freq_cur FREQ_STEP FREQ_STOP) freq_cur FREQ_START; else freq_cur freq_cur FREQ_STEP; end end end这段逻辑的输出freq_cur接到相位累加器的频率字端口配合前面说的更新脉冲使用。参数说明SWEEP_PERIOD决定扫频速度想扫得更慢就加大计数上限注意位宽要能装下这个值32 位在 100 MHz 下最多约 43 秒更长周期需要扩位。FREQ_STEP决定步进间隔和SWEEP_PERIOD一起决定扫频斜率调的时候两个参数要一起看别只改一个。6.2 多波形切换一张 ROM 分块存多张表把正弦、三角、方波各存一段用高地址位选波形是资源最省的多波形方案。地址映射改成addr {wave_sel, phase[PHASE_W-1 -: ADDR_W-1]}wave_sel两位就能选四种波形。代价是每张表的深度变成原来的四分之一频率分辨率不变但相位截断变多杂散会略微上升。对激励源这类应用足够用对频谱纯度要求高的场合还是建议一种波形一张 ROM用片选切换。波形每周期点数幅度位宽主要杂散来源适用场景正弦102412相位截断本振、传感器激励三角102412拐点量化扫描源、线性测试方波21边沿抖动时钟测试、逻辑激励6.3 用 DDS 做 AM 调制乘法器资源的取舍幅度调制无非是把基带信号和载波乘起来。FPGA 里可以直接用乘法运算符综合工具会推断出 DSP 块或逻辑乘法器// 载波与调制信号各 12 位乘积截高 12 位输出 wire [11:0] carrier; wire [11:0] mod_sig; wire [23:0] am_mix carrier * mod_sig; assign dac_data am_mix[23:12]; // 截位时直接取高位注意舍入误差参数说明截位直接取高 12 位等价于向下取整会引入一个小的直流偏移对直流精度敏感的应用要在截位前加半个最低位做舍入。乘法器资源在低端器件上可能紧张如果只做单频载波加低频调制也可以把调制信号量化成几位用移位加法替代乘法器。写到这里我最想说的一个习惯是每次改完频率字参数或者换 ROM 数据先跑一遍行为级仿真导数据算 SFDR再上板。有一次偷懒跳过这步上板后波形看着正常扫频到高频段才发现幅度塌了一半回头查是滤波器截止频率设低了白搭进去一个下午。从那以后我每次动查找表或者改时钟参数都强制走一遍仿真导出加频谱检查这个习惯替我省下的时间远多于它花掉的时间。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑