FPGA DDS信号发生器:Verilog相位累加与DAC驱动实践 简介面向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.vDAC 接口时序把波形数据按 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{1b0}}; 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 32d85899。这样确实能看到波形但频率就永远改不动了。正确做法是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:(1ADDR_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) 2b00: wave_out sin_val; 2b01: wave_out square_val; 2b10: wave_out tri_val; 2b11: 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{1b0}}; dac_clk_r 1b0; 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 1b0; reg rst_n 1b0; reg [PHASE_W-1:0] fcw 0; reg [1:0] wave_sel 2b00; 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 1b1; fcw 32d85899; // 目标 1 kHz #2_000_000; // 跑 2 ms覆盖 2 个周期 fcw 32d858993; // 目标 10 kHz #1_000_000; $fclose(fd); $finish; end // 每个时钟把输出写一行供脚本后处理 always (posedge clk) if (rst_n) $fdisplay(fd, %d, wave_out); endmodulefcw 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 kHz10 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极性接反板子上按键按下是低电平代码里却写成高有效结果相位累加器一直被按在 0wave_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((1DATA_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。这套流程看起来笨但它是唯一能让我在改完之后还敢直接烧板子的底气。希望帮到你。本文还有配套的精品资源点击获取