☰
FPGA DDS信号发生器:Verilog相位累加与DAC驱动实践
2026/10/26 5:02:07 网站建设 项目流程

简介:面向FPGA数字逻辑学习者与课程设计需求的DDS信号发生器工程,基于Verilog实现直接数字频率合成,可用于理解相位累加、频率与相位调节、查表法以及DAC接口的完整链路。工程以相位累加器为核心,配合分频器、MIF波形查找表和DAC输出模块,通过修改频率控制字、分频系数或相位初值来调整输出频率与相位;内容还涉及仿真验证和板级调试,并提示观察频率准确性、相位连续性和幅度线性度等指标,模块职责划分较清晰,便于二次修改与验证。压缩包共315个文件,约5.7MB,包含Verilog/SystemVerilog源码、MIF查找表数据、Quartus工程与配置文件、编译报告及数据库文件,目录有利于按模块检索和复现实验。已有902人学习,适合作为FPGA课程实验、课程设计或DDS入门实践的参考,也可为通信、测试测量等场景中的DDS实现提供思路。

1. FPGA DDS 信号发生器:一块板子怎么自己吐出波形

手头只有一块 FPGA 开发板和一根下载线,想让它输出一个干净的 1 kHz 正弦波,最省事的路径不是外挂专用信号芯片,而是在片内搭一套 DDS 信号发生器。这份 Verilog 工程做的正是这件事:相位累加器负责“走得快还是慢”,波形查找表负责“长成什么样”,DAC 驱动负责“把数字送出去”。核心逻辑不过百来行,频率分辨率却能做到毫赫兹级别,改一个参数还能在正弦、方波、三角波、锯齿波之间来回切。

它适合两类人:刚学完 Verilog 语法、想找一个能真正上板看到波形的完整工程练手的人;以及要把简易信号源塞进自己板卡、又不愿意为固定频点增加一颗专用芯片的开发者。它不追求射频级指标,胜在结构透明——每一级你都能自己拆开改。下面按“架构怎么分、代码怎么写、怎么跑起来、哪里容易翻车、二次开发从哪下手”的顺序往下拆。

2. 架构拆解:相位累加器、波形表、DAC 接口的三段链路

DDS 的骨架其实就一个加法器。理解这一点,后面的模块划分、位宽选择、位宽之间的连锁关系才有落脚点,否则参数一改就全是玄学。

2.1 一条公式撑起整个设计:从相位增量到输出频率

DDS 的核心动作是:每个系统时钟,让一个 N 位的相位寄存器加上一个固定增量 K,寄存器自然溢出回绕,一个完整的 0 到 2^N-1 循环就是一个信号周期。由于每拍只加 K,走完一圈需要 2^N / K 个时钟,于是输出频率是:

f_out = K * f_clk / 2^N

反过来,想输出某个目标频率,频率控制字(FCW)就是:

K = round(f_out * 2^N / f_clk)

这里 N 是相位累加器位宽。N 越大,能表达的最小频率步进越细。举例说明:系统时钟 50 MHz、N 取 32 位时,频率分辨率是 50e6 / 2^32 ≈ 0.0116 Hz,也就是 11.6 毫赫兹;要输出 1 kHz,代进去 K ≈ 85899。如果偷懒把 N 降到 16 位,分辨率立刻变成 762 Hz 左右,1 kHz 根本调不准,波形会明显“跑偏”。

这条公式还决定了另一件事:输出频率的精度上限不是由 DAC 位数决定的,而是由 N 和时钟频率共同决定的。很多新手一上来纠结 DAC 是 8 位还是 12 位,其实先该确认的是相位累加器够不够宽。我一般习惯直接把 N 定成 32,反正一个 32 位加法器在主流 FPGA 里占不了多少资源,换来的是几乎不用再考虑分辨率问题的余量。

2.2 位宽的连锁关系:累加器、查表地址、数据位宽怎么配

DDS 里最容易配错的是三组位宽,它们互相牵制,改一个往往要动另外两个。我用一张表把关系固定下来,后面写代码、改参数都照着这张表走。

参数典型取值决定什么改大会怎样改小会怎样
PHASE_W(累加器位宽)32频率分辨率分辨率更细,加法器位宽变大分辨率变粗,低频段调不准
ADDR_W(查表地址位宽)10~12一个周期采多少个点波形更细腻,ROM 容量指数上升波形台阶明显,杂散变差
DATA_W(输出数据位宽)8~14幅度量化精度幅度更细腻,需匹配 DAC 位数量化噪声大,小幅度失真

关键点在 ADDR_W 和 PHASE_W 的关系。相位累加器是 32 位,但 ROM 不可能存 2^32 个点,所以实际查表时只取相位的高 ADDR_W 位,这叫相位截断。截断会引入周期性的相位误差,在频谱上表现为杂散。经验上,ADDR_W 每增加 1 位,最坏杂散大约改善 6 dB 量级,12 位地址对应大约 -70 dBc 级别的杂散水平,做音频段信号源够用;如果只是驱动 LED 或者做个呼吸灯效果,8 位地址就够。

DATA_W 则要和 DAC 的位数对齐。DAC 是 8 位就输出 8 位,是 12 位就输出 12 位。多出来的位不会被“自动利用”,反而会因为高位对齐错误导致幅度只有一半甚至四分之一。所以我习惯让 DATA_W 参数一路从顶层透传到 ROM、到 DAC 驱动模块,只在一处改,避免多处手改漏改。

2.3 模块划分:五个文件各管一段,谁也别越界

整个工程按数据流切成五块,每块一个文件,顶层只做连线和参数传递,不写任何逻辑:

  • dds_top.v:顶层,例化下面四个模块,把参数从顶层透传下去,接板级端口(晶振、复位、DAC 引脚)。
  • clk_gen.v:时钟管理,把板载晶振转成系统时钟,输出锁定指示。用 IP 核生成,不手写。
  • dds_core.v:核心,相位累加器 + 波形选择,输出 DATA_W 位的波形数据。
  • wave_rom.v:波形查找表,地址进、数据出,同步读一个周期。
  • dac_drv.v:DAC 接口时序,把波形数据按 DAC 要求的时钟相位打出去。

这样切的理由是:dds_core里不该出现任何和 DAC 型号相关的逻辑,否则换一块 DAC 就要动核心代码;dac_drv里也不该出现相位计算,否则将来加扫频、加调制会无处下手。数据流是单向的:clk_gen → dds_core → wave_rom → dac_drv → 引脚,没有回环,没有反向依赖。

注意一点:wave_rom用同步读(时钟沿打一拍输出),不要用组合逻辑assign直接读。同步读虽然多一拍延迟,但能在综合时被正确推断成块 RAM,时序也干净;组合读在小表时看起来能用,一旦表大了或者换了器件,很容易变成一大坨组合逻辑,时序直接崩。

2.4 时钟与复位:为什么我坚持全局同步复位

板子上电时相位累加器、ROM 地址、DAC 数据寄存器都处在未知态,如果没有确定复位,DAC 可能在头几个毫秒吐出乱七八糟的电平,示波器上看着像“炸了一下”。所以复位必须覆盖所有时序逻辑。

具体做法是用一个低有效复位rst_n,在clk_gen里把外部按键复位和内部上电复位、时钟锁定信号做一次同步释放,得到一个和系统时钟同步的复位,再分发到各个模块。这样做的目的是避免异步复位释放时刻正好落在时钟沿附近,导致不同触发器退出复位的时间不一致,相位累加器一开始就错位。血泪经验:异步复位、同步释放这八个字,在 DDS 这种纯时序链路上体现得特别明显,省这一步,上板第一次抓波形就会看到输出周期忽长忽短。

3. 核心代码逐段讲:从频率控制字到 DAC 数据的完整链路

架构定完,接下来是真正要逐行看的部分。下面按数据流顺序,把相位累加、波形生成、DAC 输出三段拆开讲,每段给代码、给参数说明,也说明为什么这么写。

3.1 相位累加器:三行代码里的频率分辨率

// 32 位相位累加器,每拍自增 fcw;溢出即完成一个信号周期 module dds_core #( parameter PHASE_W = 32, // 相位累加器位宽,决定频率分辨率 parameter ADDR_W = 12, // 查表地址位宽,取相位高 ADDR_W 位 parameter DATA_W = 10 // 输出数据位宽,需与 DAC 位数一致 )( input wire clk, input wire rst_n, input wire [PHASE_W-1:0] fcw, // 频率控制字 K input wire [1:0] wave_sel, // 00 正弦 01 方波 10 三角 11 锯齿 output reg [DATA_W-1:0] wave_out ); reg [PHASE_W-1:0] phase_acc; // 相位累加:唯一的状态寄存器,溢出回绕天然形成周期 always @(posedge clk or negedge rst_n) begin if (!rst_n) phase_acc <= {PHASE_W{1'b0}}; else phase_acc <= phase_acc + fcw; end // 取高 ADDR_W 位作为查表地址,低 (PHASE_W-ADDR_W) 位被截断 wire [ADDR_W-1:0] rom_addr = phase_acc[PHASE_W-1 -: ADDR_W]; ... endmodule

这段是整个 DDS 的心脏。phase_acc只有它一个状态寄存器,fcw是纯输入,加法器每拍做一次加法,溢出自然回绕,不需要任何取模运算——这正是二进制累加器的好处。phase_acc[PHASE_W-1 -: ADDR_W]是 Verilog-2001 的位选语法,表示从最高位开始往下取 ADDR_W 位,等价于右移PHASE_W-ADDR_W位,但写起来不受位宽变化影响。

参数上,PHASE_W决定分辨率,前面算过 32 位配 50 MHz 是 11.6 毫赫兹;ADDR_W决定查表精度,12 位意味着一个周期 4096 个采样点;DATA_W决定幅度量化,必须和 DAC 对齐。这三个参数从顶层一路透传,改的时候只在顶层改一处。

一个常见误区是把fcw做成可变的reg在内部赋值,比如内部写死fcw = 32'd85899。这样确实能看到波形,但频率就永远改不动了。正确做法是fcw作为输入端口,由上层按键、串口或者状态机来给,核心模块只负责“加”。

3.2 波形查找表:正弦表用脚本生成,别手敲

正弦表是 DDS 里唯一需要“数据”的地方。4096 个 10 位数据,手敲不现实,标准做法是用脚本生成十六进制文件,综合时用$readmemh初始化。

# gen_sin_rom.py:生成 ADDR_W 深、DATA_W 宽的正弦表 import math ADDR_W = 12 DATA_W = 10 DEPTH = 1 << ADDR_W AMP = (1 << (DATA_W - 1)) - 1 # 有符号幅度的最大正值 OFFSET = 1 << (DATA_W - 1) # 抬到无符号区间,直流偏置为半量程 with open("sin_rom.hex", "w") as f: for i in range(DEPTH): s = math.sin(2 * math.pi * i / DEPTH) v = int(round(AMP * s + OFFSET)) v = max(0, min((1 << DATA_W) - 1, v)) # 四舍五入后裁剪,防越界 f.write("{:0{}x}\n".format(v, (DATA_W + 3) // 4))

这段脚本干三件事:按2πi/DEPTH算出每个相位点的正弦值;乘上幅度上限并加上半量程偏置,把有符号正弦搬进无符号区间,因为绝大多数 DAC 只接受无符号二进制码;最后四舍五入并做上下限裁剪,防止round在峰值处多出 1 导致溢出。AMP取2^(DATA_W-1)-1而不是2^(DATA_W-1),就是为了给偏置留出对称空间。

对应的 Verilog ROM 写法:

// 同步读 ROM,地址打一拍后数据有效,综合时推断为块 RAM module wave_rom #( parameter ADDR_W = 12, parameter DATA_W = 10 )( input wire clk, input wire [ADDR_W-1:0] addr, output reg [DATA_W-1:0] dout ); reg [DATA_W-1:0] rom [0:(1<<ADDR_W)-1]; initial begin $readmemh("sin_rom.hex", rom); // 绝对路径或相对仿真目录 end always @(posedge clk) dout <= rom[addr]; // 同步读,多一拍延迟但时序干净 endmodule

$readmemh的路径是翻车高发区。综合工具和仿真器的工作目录往往不一样,仿真时能找到、综合时报 “file not found” 是家常便饭。稳妥做法是把 hex 文件放在工程根目录,同时在综合脚本里显式设置搜索路径;或者在仿真时用绝对路径,综合时用相对路径。我一般会在仿真目录放一份、综合目录放一份,宁可冗余也不让工具去找。

另外,如果表很小(比如 ADDR_W 取 8,只有 256 个点),也可以用case语句硬编码,省掉外部文件依赖。但表一旦过千点,case会写成一个巨大的查找逻辑,不仅难维护,综合时间也会明显变长,这时候必须回到$readmemh。

3.3 多波形切换:用相位做文章,而不是写四张表

很多人第一反应是给每种波形都做一张 ROM,然后用选择器切换。这能跑,但资源占用翻四倍,而且方波、锯齿波这种根本不需要查表——它们的值可以直接从相位累加器的高位算出来。

// 四种波形并行生成,最后按 wave_sel 选择,共用同一个相位源 wire [DATA_W-1:0] sin_val; // 方波:相位最高位,占空比固定 50% wire [DATA_W-1:0] square_val = {DATA_W{phase_acc[PHASE_W-1]}}; // 锯齿:直接取相位高 DATA_W 位,天然线性上升 wire [DATA_W-1:0] saw_val = phase_acc[PHASE_W-1 -: DATA_W]; // 三角:高 DATA_W 位,相位前半周上升、后半周取反下降 wire [DATA_W-1:0] tri_val = phase_acc[PHASE_W-1] ? ~phase_acc[PHASE_W-2 -: DATA_W] : phase_acc[PHASE_W-2 -: DATA_W]; always @(*) begin case (wave_sel) 2'b00: wave_out = sin_val; 2'b01: wave_out = square_val; 2'b10: wave_out = tri_val; 2'b11: wave_out = saw_val; default: wave_out = sin_val; endcase end

这里三角波的写法值得多说两句。它取的是相位累加器第PHASE_W-2位往下DATA_W位,比锯齿波低一位,这样前半周(最高位为 0)线性上升,后半周(最高位为 1)按位取反,正好构成对称的三角。方波更简单,直接拿最高位复制 DATA_W 份,占空比严格 50%,不需要任何比较器。

这种做法的好处是:只有正弦需要 ROM,其余三种波形零存储开销;四种波形共享同一个相位累加器,切换时相位是连续的,不会出现跳变毛刺。要注意的是case放在组合逻辑里,wave_sel是由按键产生的,如果按键有抖动,切换瞬间会有短暂的波形混叠,实际工程里我会在wave_sel前面加两级寄存同步加消抖。

3.4 DAC 接口:数据和时钟谁先到,决定波形干不干净

DAC 驱动看着简单,其实是整套设计里最容易出问题的地方。核心就一句话:数据必须在 DAC 采样时钟沿之前稳定。

// 并口 DAC 驱动:输出时钟与数据,数据在时钟上升沿前建立 module dac_drv #( parameter DATA_W = 10 )( input wire clk, // 系统时钟,与 dds_core 同源 input wire rst_n, input wire [DATA_W-1:0] din, // 来自波形选择 output reg [DATA_W-1:0] dac_data, output wire dac_clk ); reg dac_clk_r; // 数据先寄存一拍,保证与 dac_clk 同沿发出、沿间稳定 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin dac_data <= {DATA_W{1'b0}}; dac_clk_r <= 1'b0; end else begin dac_data <= din; dac_clk_r <= ~dac_clk_r; // 二分频输出,DAC 在上升沿采样 end end assign dac_clk = dac_clk_r; endmodule

这里的关键是dac_data和dac_clk_r在同一个always块里、由同一个时钟沿驱动。这样数据变化和时钟翻转是同时发生的,DAC 在dac_clk的上升沿采样时,数据已经稳定了半个系统时钟周期。如果偷懒写成assign dac_clk = clk;,数据也是在clk上升沿变化,时钟和数据同相到达 DAC,建立时间几乎为零,示波器上就会看到每个跳变点附近有一小段振铃或者毛刺,频谱上是一片抬起的底噪。

参数上,DATA_W必须等于 DAC 的位数,高位对齐。如果 DAC 是 12 位而波形只有 10 位,要把 10 位左移 2 位再送出去,而不是直接接低 10 位——接低位等于幅度只剩四分之一,示波器上看到的正弦会明显“矮一截”。如果 DAC 是 SPI 接口,dac_drv内部要换成移位寄存器加分频状态机,把din拆成若干字节按位送出,采样率会受 SPI 时钟限制,这时系统时钟和 SPI 时钟的比例要重新算一遍,否则输出频率会跟着变。

4. 工程结构与仿真验证:目录怎么放,波形怎么量

代码写完只是第一步,能不能复现、能不能自证正确,靠的是目录组织和验证手段。这一章讲工程怎么摆、仿真怎么搭、上板怎么抓。

4.1 目录结构:源文件、约束、脚本、仿真四层分开

工程目录我一般按下面这样分,好处是换器件、换仿真器时只动一两处:

dds_gen/ ├── rtl/ │ ├── dds_top.v 顶层,只做例化和端口映射 │ ├── dds_core.v 相位累加器 + 多波形生成 │ ├── wave_rom.v 正弦查找表 │ └── dac_drv.v DAC 接口时序 ├── ip/ │ └── clk_gen/ 锁相环 IP,由工具生成,不手改 ├── constr/ │ └── dds_top.sdc 时钟定义与输出延时约束 ├── sim/ │ ├── tb_dds_core.v Testbench │ └── sin_rom.hex 仿真用查找表(与综合用同一份内容) ├── script/ │ ├── gen_sin_rom.py 正弦表生成脚本 │ └── build.tcl 综合与实现脚本 └── doc/ └── pin_map.md 引脚分配说明

rtl里只放可综合代码,不放任何initial以外的仿真语句;sim里放 Testbench 和仿真专用文件;script里放可重复执行的脚本。这样做的直接好处是:接手的人不需要问“哪个文件是仿真的、哪个是综合的”,目录名已经说清楚了。引脚分配单独写在doc/pin_map.md里,而不是只留在约束文件里,因为约束文件经常被工具重新生成,注释容易丢。

4.2 Testbench:在仿真里把频率和幅度量出来

光看波形图判断“像不像正弦”不靠谱,要用数值验证。Testbench 的思路是给两个不同频点,把输出打到文件,再用脚本量周期。

`timescale 1ns / 1ps module tb_dds_core; localparam PHASE_W = 32; localparam ADDR_W = 12; localparam DATA_W = 10; reg clk = 1'b0; reg rst_n = 1'b0; reg [PHASE_W-1:0] fcw = 0; reg [1:0] wave_sel = 2'b00; wire [DATA_W-1:0] wave_out; always #10 clk = ~clk; // 50 MHz 系统时钟 dds_core #( .PHASE_W(PHASE_W), .ADDR_W(ADDR_W), .DATA_W(DATA_W) ) u_dut ( .clk(clk), .rst_n(rst_n), .fcw(fcw), .wave_sel(wave_sel), .wave_out(wave_out) ); integer fd; initial begin fd = $fopen("dac_out.txt", "w"); #200 rst_n = 1'b1; fcw = 32'd85899; // 目标 1 kHz #2_000_000; // 跑 2 ms,覆盖 2 个周期 fcw = 32'd858993; // 目标 10 kHz #1_000_000; $fclose(fd); $finish; end // 每个时钟把输出写一行,供脚本后处理 always @(posedge clk) if (rst_n) $fdisplay(fd, "%d", wave_out); endmodule

fcw = 85899是按1000 * 2^32 / 50e6算出来的,实际输出 999.99 Hz,误差在分辨率以内。跑 2 ms 能覆盖两个完整周期,足够脚本识别上升沿。把数据写成文本而不是依赖仿真器的波形文件格式,是为了让验证脚本和仿真器解耦。

配套的量测脚本:

# measure.py:从采样序列里估算周期和幅度 import numpy as np v = np.loadtxt("dac_out.txt") mid = (v.max() + v.min()) / 2.0 # 找所有由下向上穿过中值的点,相邻间隔即一个周期 idx = np.where((v[:-1] < mid) & (v[1:] >= mid))[0] period = np.diff(idx).mean() print("平均周期(采样点) = %.2f" % period) print("相对频率 = %.4f" % (1.0 / period)) print("幅度峰峰值 = %d" % (v.max() - v.min()))

这个脚本只依赖采样序列,不关心仿真器。判断标准很直接:1 kHz 那段的平均周期应该接近 50000 个采样点(50 MHz / 1 kHz),10 kHz 那段接近 5000 个;峰峰值应该接近2^DATA_W - 2而不是明显小一截。峰峰值偏小往往意味着幅度位宽配错或者偏置没加对,这一点在下一章的排查里还会提到。

4.3 上板验证:用片上逻辑分析仪抓相位和输出

仿真过了不等于板子上对。上板第一件事是把phase_acc的高几位和wave_out一起接进片上逻辑分析仪,触发条件设成phase_acc最高位由 0 变 1,抓一小段看三件事:

第一,phase_acc是不是单调递增、每次增加量严格等于fcw。如果增量忽大忽小,说明fcw来源不稳定,多半是按键没消抖或者串口数据还没对齐。第二,wave_out在phase_acc过零附近是不是平滑地穿过中值,如果出现平台或者跳变,查 ROM 地址位选和$readmemh是否真的加载成功。第三,dac_clk和dac_data的相位关系,数据跳变应该发生在dac_clk的下降沿附近,也就是采样沿的另一侧。

示波器上量最终输出时,先测直流偏置。无符号数据直接送 DAC,输出会带一个半量程的直流分量,这是正常的;如果后级要接交流耦合或者运放,记得用电容隔直或者做偏置搬移,否则看上去波形整体“浮”在上面。再测峰峰值和频率,和仿真里的数值对一遍,误差应该在分辨率允许范围内。

5. 避坑与常见问题排查:从不出来波形到满屏毛刺

DDS 这类纯数字链路,出错的现象往往很有迷惑性:仿真完美、上板全错;或者波形出来了但频率怎么都对不上。下面五条是我在调试这类工程时反复遇到的,按“现象 → 原因 → 解决”记下来。

5.1 现象:DAC 输出恒定电平,示波器一条直线

原因通常有三个层次。最外层是复位:rst_n极性接反,板子上按键按下是低电平,代码里却写成高有效,结果相位累加器一直被按在 0,wave_out永远是正弦表第 0 个点的值。中间层是fcw恒为 0,累加器每拍加 0,相位不动,输出自然也不动。最内层是$readmemh没加载成功,ROM 全是未知态或者全 0,综合工具给了个警告但没报错。

解决顺序从外往内:先用逻辑分析仪看phase_acc有没有在动,动说明复位和fcw没问题;不动就查复位极性和fcw的来源。如果phase_acc在动但wave_out不变,去看综合日志里$readmemh那一行,确认 hex 文件路径和格式都正确,文件里不能有空行、不能有0x前缀,每行位宽要和DATA_W对齐。

5.2 现象:波形出来了,但频率和设定值差整数倍

这个坑我踩过不止一次。fcw是按 50 MHz 算的,但实际系统时钟是锁相环倍频后的 100 MHz,或者反过来,代码里以为跑 100 MHz,实际晶振就是 50 MHz 没倍频。频率差 2 倍、4 倍,基本都是这个原因。

排查方法很直接:在逻辑分析仪里数一下phase_acc最高位的翻转周期,用系统时钟周期一除,就是实际输出频率;再拿这个值和公式反推的f_clk对比。另一个常见变体是fcw计算时用了整数除法提前截断,比如先算f_out / f_clk再乘2^N,小数部分被丢光,低频段误差会非常大。正确顺序是先乘2^N再除以f_clk,并且用足够位宽的中间变量,脚本里用浮点算完再取整最稳。

5.3 现象:正弦波顶部被削平,或者只剩半周

削顶是幅度溢出。生成正弦表时用了AMP = 2^(DATA_W-1)而不是2^(DATA_W-1)-1,峰值加上偏置正好超出DATA_W位能表示的范围,综合或者运行时高位被截掉,波形顶部就平了。只剩半周则是偏置没加,有符号正弦直接当无符号送出去,负半周被当成 0 或者回绕成大数,波形看着像被砍了一半。

解决方法是回到生成脚本,检查AMP和OFFSET两个常量,并且检查裁剪那两行有没有写。v = max(0, min((1<<DATA_W)-1, v))这一句不能省,它能在表生成阶段就把越界值拦住,比上板之后再查快得多。另外如果 DAC 是 12 位、表是 10 位,记得左移对齐,别直接接低位。

5.4 现象:波形上有规律的周期性毛刺,频谱底噪抬高

这种毛刺通常有固定周期,和输出频率成整数倍关系。第一个可能是相位截断:ADDR_W取太小,比如 8 位地址配 32 位累加器,截断误差周期性重复,频谱上会出现一族离散杂散。把ADDR_W提到 12 位通常能明显改善。第二个可能是数据在时钟沿变化,DAC 采样到了跳变中的值,回到dac_drv检查数据和时钟是不是同一拍发出。

还有一种容易被忽略:ROM 用了组合读。组合读会让地址到数据的路径变成纯组合逻辑,地址一变数据立刻跟着变,中间经过若干级逻辑延迟,输出上就会出现窄脉冲。改成同步读,多打一拍,毛刺立刻消失。

5.5 现象:仿真完全正确,上板行为混乱

仿真用的是理想时钟、理想复位,上板有抖动、有上电时序、有跨时钟域。最常见的原因是复位释放没有同步,不同触发器退出复位的时刻差了半个周期,相位累加器起始值不一致,波形周期就开始抖。其次是约束没写,dac_clk和dac_data之间的输出延时没约束,工具按默认值布线,建立时间不满足。

解决办法是补两样东西:复位同步器(异步置位、同步释放),以及输出延时约束。下面这段 SDC 是最小集合,时钟周期按实际系统时钟填:

# 系统时钟定义,周期单位 ns create_clock -name sys_clk -period 20.000 [get_ports clk] # DAC 数据相对输出时钟的延时约束,数值按板级走线和 DAC 建立时间调整 set_output_delay -clock sys_clk -max 3.0 [get_ports dac_data*] set_output_delay -clock sys_clk -min 0.5 [get_ports dac_data*]

-max和-min分别约束建立和保持方向,具体数值要看 DAC 手册的建立保持时间和 PCB 走线延迟。约束写完一定要看时序报告,确认没有未约束路径,别只看到“实现通过”就以为万事大吉。

6. 二次开发:把固定频点改造成可扫频、可调制的信号源

工程跑通之后,真正有价值的部分才开始:它不是一个只能输出固定频率的玩具,而是一个可以往上叠功能的底座。改之前先想清楚改哪一层,别一上来就动dds_core的加法器。

6.1 参数化移植:换板子只改顶层和约束

PHASE_W、ADDR_W、DATA_W三个参数已经做成可透传,移植到新板子时只需要动三处:clk_gen的输入晶振频率和输出频率、顶层端口到引脚的映射、SDC 里的时钟周期。dds_core和wave_rom一行都不用改。我一般会在顶层加一组localparam把目标频率也参数化:

// 顶层参数:换板子只改这几行 localparam CLK_HZ = 50_000_000; // 系统时钟频率 localparam F_OUT = 1_000; // 目标输出频率 localparam FCW_INIT = (F_OUT * (1 << PHASE_W)) / CLK_HZ; // 综合期算好

这样综合工具会直接把FCW_INIT算成常数,不占运行期资源。要改频点,改F_OUT重新综合即可,比在代码里到处找魔数可靠得多。

6.2 加扫频:让 FCW 自己走起来

扫频的本质是让fcw随时间线性变化。做法是在dds_core外面加一个扫频控制模块,内部维护一个频率步进计数器,每隔固定时间把fcw加一个步进值,从起始频率走到终止频率再回绕。注意fcw变化时相位累加器不能复位,否则每个频点切换处都会出现相位跳变,听感或者频谱上就是“咔哒”一声。正确做法是只改增量、不动累加器当前值,相位自然连续。

加 AM 调制则更简单:在wave_out后面串一个乘法器,用低频正弦(另一个 DDS 或者一个计数器查表)去乘输出幅度。要注意位宽扩展——两个 10 位数相乘是 20 位,右移截断回 10 位时要做饱和处理,不能直接截低位,否则调制深度大的时候会溢出翻转,波形出现反向尖峰。

6.3 验证方法:每次改动都回到数值比对

改完任何一处,我都不看波形图下结论,而是回到第 4 章那套流程:跑仿真、导数据、用脚本量周期和峰峰值。加扫频就多量一个“频率随时间是否单调变化”,加调制就多量一个“包络深度是否符合设定”。数值对不上,先去查位宽和截断,八成问题都在那里。

从那以后我每次动 DDS 的位宽参数,都强制走一遍“脚本生成表 → 仿真导数据 → 脚本量周期 → 上板抓相位”这条链路,哪怕只改了一个DATA_W。这套流程看起来笨,但它是唯一能让我在改完之后还敢直接烧板子的底气。希望帮到你。

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

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

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

立即咨询