
简介面向 FPGA 数字逻辑设计与 DDS 初学者的工程实践包围绕直接数字频率合成信号发生器用 Verilog 与少量 SystemVerilog、VHDL 实现相位累加、时钟分频、波形查表与 DAC 接口等模块覆盖从原理理解到上板验证的链路。压缩包共 315 个文件、约 5.7MB以 Quartus 工程文件qpf、qsf、qip与编译数据库cdb、hdb、ddb为主另含 15 个 v、3 个 sv 与 1 个 vhd 源码、6 个 mif 波形查找表数据、sof 下载码流及若干 rpt、summary 报告与 readme 说明覆盖设计、综合、布局布线与下载环节。已有 902 人学习下载适合课程设计、自学与实验参考。读者可对照相位累加器位宽与频率控制字的关系调整分频系数与 MIF 初始相位验证输出频率准确度与相位连续性。1. 从一块开发板说起FPGA DDS 信号发生器为什么值得当作完整课题很多同学做 FPGA 课程设计第一反应是流水灯、数码管、交通灯交上去能跑答辩时被问一句你这个设计的指标是什么就哑了。FPGA DDS 信号发生器不一样它天然带着一条完整的信号链相位累加器算相位、波形表存样点、DAC 把数字量变成模拟电压、上位机把频率控制字送进来最后还要用示波器和频谱数据证明输出干净。这套方案要解决的就是从零搭一台能测、能调、能讲清楚指标的数字信号源适合做毕设、课程设计也适合刚转 FPGA 方向、想找一个能写进简历的完整项目。下面按需求、选型、架构、实现、验收的顺序一路推下去。2. 需求与选型先把指标钉死再谈用什么架构2.1 三张指标表决定后面所有设计DDS 这东西最怕上来就写代码。相位累加器位宽、ROM 深度、DAC 位数、滤波器截止频率这四个参数互相咬死改一个就得回头重算一串。我一般的做法是先写一张指标表把能做到和想做到分开再反推每个参数。指标项本方案取值决定它的设计参数输出频率范围1 Hz ~ 1 MHz控制字下限、DAC 更新率、重建滤波器带宽频率分辨率约 0.023 Hz相位累加器 32 位、系统时钟 100 MHz波形种类正弦、方波、三角波、锯齿波波形表深度、波形选择逻辑输出幅度0.1 ~ 2 Vpp 可调DAC 满量程、后级运放增益无杂散动态范围≥ 60 dBc1 kHz 附近相位截断位数、DAC 位数、PCB 布线控制接口按键步进 串口绝对下发参数寄存器与更新机制为什么把上限定在 1 MHz100 MHz 的 DAC 更新率理论上能合成到 40 MHz但那是理想情况。输出频率越高每个周期内的采样点越少重建滤波器越难做DAC 建立时间的余量也越紧。1 MHz 以内每个周期还有 100 个点输出波形干净示波器上看得清楚答辩时也好讲。想往上冲得换高速 DAC 和射频级的布线那是另一个课题的工程量。分辨率这一项经常被忽略。0.023 Hz 听起来夸张实际意义是你下发任意一个频率控制字输出频率和设定值的偏差都在 0.023 Hz 以内。做频率扫描、做锁相环参考源的时候这个精度是硬指标。2.2 相位累加器位宽32 位累加配 12 位 ROM 地址DDS 的核心公式只有一条频率控制字 FTW 等于目标频率乘以 2 的 N 次方再除以系统时钟。FTW f_out × 2^N / f_clk f_res f_clk / 2^NN 取 32f_clk 取 100 MHz分辨率就是 100e6 / 2^32 ≈ 0.0233 Hz。取 24 位也能用分辨率降到 6 Hz 左右做一般信号源够但做低频扫描时就看出差别了。32 位是现在的主流选择累加器本身只占 32 个触发器加一个 32 位加法器资源上完全不是瓶颈。真正需要权衡的是 ROM 地址位宽。相位累加器 32 位ROM 不可能存 2^32 个点必须截断。截断多少位直接决定杂散水平。经验公式是每截断一位最坏情况下的杂散大约恶化 6 dB。截断 20 位取高 12 位理论杂散在 -70 dBc 量级配合 10 位 DAC 的量化噪声整体能做到 60 dBc 以上足够应付课设和大部分毕设的验收。提示截断带来的是确定性杂散不是随机噪声。它会在频谱上表现为固定位置的离散谱线示波器 FFT 功能就能看到答辩时最好提前测出来并准备解释。取高 12 位还有个好处4096 个点的正弦表刚好塞进一个块 RAM不占逻辑资源。如果 FPGA 的块 RAM 紧张可以退到 10 位地址、1024 个点杂散会再差 12 dB 左右看你的验收标准能不能接受。2.3 DAC 位宽与波形表深度10 位数据、4096 点的性价比幅度量化位数决定信噪比理想情况下 SNR 约等于 6.02N 1.76 dB。8 位 DAC 约 50 dB10 位约 62 dB12 位约 74 dB。对一台桌面信号源来说8 位能看出明显的阶梯感12 位成本又上去了10 位并行 DAC 是个折中点市面上的模块也多。波形表的数据宽度跟 DAC 位数对齐10 位数据存 0 到 1023 的无符号偏移码中点 512 对应零电平。这样省掉有符号数转偏移码的运算DAC 直接吃。表深度和地址位宽绑定4096 点、12 位地址。有人会问既然最后只取 12 位地址为什么不干脆用 12 位累加器因为频率分辨率会崩掉。累加器和 ROM 地址是两件事前者管频率精度后者管波形精度混在一起是新手最容易踩的坑之一。2.4 波形种类怎么扩查表与解析式各管一半正弦波必须查表没有解析式能直接算。方波、三角波、锯齿波反而不用表直接拿相位的高位做逻辑就行。方波取相位最高位最高位为 1 输出满量程为 0 输出零。三角波用相位的高 12 位做对称折叠低 11 位从 0 涨到 2047 再落回来。锯齿波最简单高 12 位直接当幅度。这样做的代价是四种波形的幅度刻度不一致切换时会有跳变。解决办法是在波形选择之后加一级数字增益每种波形配一个独立的缩放系数存在参数寄存器里切换时一起加载。这一级乘法用 FPGA 自带的 DSP 块实现一个 10×10 的乘法器绰绰有余。3. 总体架构设计四个模块的职责与数据流3.1 数据通路从相位累加器到 DAC 输出的四级流水整条链路拆成四级相位计算、波形查表、幅度缩放、DAC 接口。四级之间都用同步寄存器隔开时钟统一这样时序收敛容易后期加流水线也不破坏结构。第一级是 32 位相位累加器每个时钟加一次 FTW同时叠加一个相位偏置字用于相位调制或者多通道之间的相位差。第二级把相位和高 12 位送去查表ROM 配成同步读读出来带一拍延迟。第三级做幅度缩放和波形选择。第四级把数据送进 DAC 接口模块转成 DAC 需要的时序。每一级都有固定的延迟加起来是 3 到 4 个时钟周期。这个延迟在做单次触发或者和外部设备同步时需要考虑普通输出场景直接忽略。我一般会在代码里用参数把各级延迟显式记下来注释写清楚后面做相位对齐时省事。数据流的完整路径是控制字寄存器 → 相位累加器 → 相位偏置加法 → 截断取高 12 位 → 波形 ROM → 波形选择与幅度缩放 → DAC 接口 → 外部重建滤波器 → 模拟输出。控制通路另行一套按键消抖、串口接收、参数寄存器最后生成一个 load 脉冲把新参数一次性打进工作寄存器。3.2 时钟方案单一时钟源PLL 只做一件事DDS 对时钟抖动非常敏感。相位累加器每加一次输出相位就前进一格时钟上任何一点抖动都会直接转换成输出信号的相位噪声。所以整套设计只能有一个时钟源PLL 只负责把外部晶振倍频到 100 MHz不再做其他分频。PLL 输出分两路一路给内部逻辑一路给 DAC 采样时钟。两路同源相位关系固定这是保证输出干净的前提。如果 DAC 时钟另外从一个引脚灌进来两个时钟不同源输出频谱上会出现一堆解释不了的杂散查起来非常痛苦。复位也要跟时钟对齐。外部按键复位是异步的直接送进 PLL 和计数器会引入亚稳态。通常的做法是加一个复位同步器异步置位、同步释放两拍就打干净了。PLL 的锁定信号也要参与复位锁定之前不让 DDS 核开始工作否则上电瞬间会输出一段乱七八糟的波形。3.3 控制通路按键管步进串口管绝对值两种控制方式各有用途。按键和旋转编码器适合现场调试按一下频率加一档看着示波器调最直观。串口适合做扫频和自动化测试上位机一次下发一串频率点程序自动跑完。按键侧要做消抖机械按键的抖动时间在 5 到 20 ms 之间用 20 ms 的计数器做电平确认比较稳。旋转编码器还要判方向A 相上升沿时读 B 相电平高就是正转低就是反转。这两块逻辑不复杂但细节多写不好就是按一下跳三下。串口侧用 115200 波特率帧格式定成 5 字节1 字节帧头 0xAA4 字节频率控制字高位在前。加帧头是为了防止上位机中途重连导致字节错位收到帧头才开始组帧对不上就丢掉重新找。两路控制最后都写进同一组参数寄存器用一个 load 脉冲统一更新。这里有个细节更新必须原子化。如果 4 个字节分 4 次写进工作寄存器中间那几拍输出频率是乱的示波器上能看到一个短暂的跳变。做法是先把数据写进影子寄存器等一帧收完再用一个脉冲把影子寄存器的值整体搬到工作寄存器。3.4 波形选择与幅度控制的实现位置波形选择和幅度缩放放在查表之后不要放在查表之前。放前面意味着每种波形都要一张表ROM 资源翻四倍放后面只需要一张正弦表加几个组合逻辑方波三角波锯齿波都用相位直接生成。幅度控制用乘法实现波形数据乘以一个 10 位的增益系数取乘积的高 10 位。增益系数由按键或者串口下发存在寄存器里。这样幅度调节是数字域的线性度好不受运放温漂影响。模拟端的后级运放只负责固定增益和阻抗匹配不再调幅度。4. 关键模块实现Verilog 代码逐段落地4.1 相位累加器与 ROM 寻址这是整套设计的心脏代码不长但每个参数都不能拍脑袋。module dds_core #( parameter integer PHASE_W 32, // 相位累加器位宽决定频率分辨率 parameter integer ADDR_W 12, // ROM 地址位宽决定波形精度 parameter integer DATA_W 10 // 波形数据位宽与 DAC 位数对齐 )( input wire clk, input wire rst_n, input wire [PHASE_W-1:0] ftw, // 频率控制字 input wire [PHASE_W-1:0] pinc, // 相位偏置字用于相位调制 output reg [DATA_W-1:0] wave_out ); reg [PHASE_W-1:0] phase_acc; // 第一级相位累加每个时钟前进一个 FTW always (posedge clk or negedge rst_n) begin if (!rst_n) phase_acc {PHASE_W{1b0}}; else phase_acc phase_acc ftw; end // 叠加相位偏置支持多通道相位差 wire [PHASE_W-1:0] phase_sum phase_acc pinc; // 第二级截断取高位送 ROM低位丢弃会引入确定性杂散 wire [ADDR_W-1:0] rom_addr phase_sum[PHASE_W-1 -: ADDR_W]; // 第三级同步读 ROM输出带一拍延迟 sine_rom #(.ADDR_W(ADDR_W), .DATA_W(DATA_W)) u_rom ( .clk (clk), .addr (rom_addr), .dout (wave_out) ); endmodule代码里有三个要点。ftw和pinc都是 32 位无符号数累加器的溢出是设计的一部分不需要做饱和处理溢出恰好对应相位绕回 2π。phase_sum[PHASE_W-1 -: ADDR_W]是 Verilog-2001 的位选语法等价于取最高 12 位写起来比[31:20]更耐改参数位宽一调整不用回头改索引。ROM 用同步读输出比地址晚一拍这拍延迟在幅度缩放和 DAC 接口里会累加做相位对齐时要一起算进去。参数怎么改系统时钟降到 50 MHz其他不变分辨率变成 0.0116 Hz输出上限也跟着降一半ROM 地址从 12 位降到 10 位表深度变 1024杂散恶化约 12 dB但块 RAM 占用降到四分之一。这两个参数是最常动的改之前先把指标表翻出来对一遍。4.2 波形表生成脚本出 hex$readmemh 进 ROM正弦表不要手写用脚本生成改深度改位宽只改两个常数。import math ADDR_W, DATA_W 12, 10 # 地址 12 位、数据 10 位 DEPTH 1 ADDR_W # 4096 个样点 AMPL (1 (DATA_W - 1)) - 1 # 幅度峰值 511无符号偏移码 with open(sine_4096x10.hex, w) as f: for i in range(DEPTH): # 相位 0~2π加偏移后落在 1~1022中点接近 512 v int(round((1 (DATA_W - 1)) AMPL * math.sin(2 * math.pi * i / DEPTH))) v min(max(v, 0), (1 DATA_W) - 1) # 兜底钳位防止舍入越界 f.write(f{v:03x}\n) # 10 位数据用 3 个十六进制字符生成的 hex 文件每个样点一行ROM 模块用$readmemh读进去。module sine_rom #( parameter integer ADDR_W 12, parameter integer 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] mem [0:(1ADDR_W)-1]; initial begin $readmemh(sine_4096x10.hex, mem); // 综合工具据此初始化块 RAM end always (posedge clk) begin dout mem[addr]; // 同步读一拍延迟 end endmodule$readmemh的路径是相对综合工程的路径不是相对源文件这一点坑过很多人综合日志里会报cannot open file但工程照样能编过只是 ROM 全零输出一条直线。稳妥的做法是把 hex 文件放在工程根目录或者用绝对路径先跑通再改相对路径。10 位数据用 3 个十六进制字符表示最大值 0x3FF正好占满。如果数据位宽改成 12 位格式要改成04x同时 ROM 的DATA_W参数一起改漏一个就会出现数据错位。4.3 串口下发频率控制字串口接收用状态机写比计数器堆叠清晰得多出问题也好定位。module uart_cmd_rx #( parameter integer CLK_FREQ 100_000_000, parameter integer BAUD 115_200 )( input wire clk, input wire rst_n, input wire rx, output reg [31:0] ftw_out, // 解析出的频率控制字 output reg ftw_vld // 单周期有效通知下游加载 ); localparam integer DIV CLK_FREQ / BAUD; // 每位占用的系统时钟数 localparam [1:0] S_IDLE 2d0, S_START 2d1, S_DATA 2d2, S_STOP 2d3; reg [1:0] rx_sync; // 两级同步消除亚稳态 reg [1:0] state; reg [15:0] div_cnt; reg [2:0] bit_cnt; // 0~7 共 8 个数据位 reg [7:0] shreg; reg [39:0] frame; // 收到的 5 字节帧帧头 0xAA reg [2:0] byte_cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin rx_sync 2b11; state S_IDLE; div_cnt 16d0; bit_cnt 3d0; shreg 8d0; frame 40d0; byte_cnt 3d0; ftw_out 32d0; ftw_vld 1b0; end else begin rx_sync {rx_sync[0], rx}; // 异步输入先打两拍 ftw_vld 1b0; case (state) S_IDLE: begin if (rx_sync 2b01) begin // 检测起始位下降沿 div_cnt DIV / 2; // 半个位周期后采样对准中点 state S_START; end end S_START: begin if (div_cnt 0) begin if (rx_sync[1] 1b0) begin div_cnt DIV - 1; bit_cnt 3d0; state S_DATA; end else state S_IDLE; // 毛刺丢弃重新等 end else div_cnt div_cnt - 16d1; end S_DATA: begin if (div_cnt 0) begin div_cnt DIV - 1; shreg {rx_sync[1], shreg[7:1]}; // LSB 先到 if (bit_cnt 3d7) state S_STOP; else bit_cnt bit_cnt 3d1; end else div_cnt div_cnt - 16d1; end S_STOP: begin if (div_cnt 0) begin if (byte_cnt 3d4) begin // 第 5 字节到齐frame 高字节里是帧头 if (frame[31:24] 8hAA) begin ftw_out {frame[23:0], shreg}; ftw_vld 1b1; end byte_cnt 3d0; frame 40d0; end else begin frame {frame[31:0], shreg}; byte_cnt byte_cnt 3d1; end state S_IDLE; end else div_cnt div_cnt - 16d1; end endcase end end endmoduleDIV是波特率分频系数115200 波特率在 100 MHz 下等于 868实际取整是 868累积误差 0.03% 左右收 8 位数据没问题。如果系统时钟换成 50 MHzDIV变成 434误差稍大但仍在容限内。波特率越低误差越小9600 波特率下几乎可以忽略。两级同步rx_sync是必须的rx是异步输入不打拍直接进状态机偶尔会出现起始位误判。状态机采样点在位周期的正中间S_START里先等半个周期之后每位再等一个完整周期这是标准的 UART 收法。组帧逻辑要特别注意下标。frame是 40 位移位寄存器每收一个字节往左推收满 4 个字节后帧头落在frame[31:24]后 3 个字节落在低 24 位第 5 个字节在shreg里拼起来就是完整的 32 位控制字。这个下标写错一位下发下去的频率就完全是另一个值示波器上一眼能看出来。4.4 DAC 时序与输出调理DAC 接口时序跟着器件手册走这里给一个通用的 SPI 版本并行 DAC 把移位部分换成直接赋值即可。module dac_spi_drv #( parameter integer CLK_FREQ 100_000_000, parameter integer SCLK_HZ 20_000_000, parameter integer DATA_W 16 )( input wire clk, input wire rst_n, input wire [DATA_W-1:0] din, input wire load, // 单周期脉冲装载一帧 output reg sclk, output reg sdin, output reg cs_n, output reg done // 一帧发完单周期脉冲 ); localparam integer HALF CLK_FREQ / (SCLK_HZ * 2); // 半周期计数值 reg [15:0] div_cnt; reg [DATA_W-1:0] shreg; reg [7:0] bit_cnt; reg busy; always (posedge clk or negedge rst_n) begin if (!rst_n) begin div_cnt 16d0; shreg {DATA_W{1b0}}; bit_cnt 8d0; busy 1b0; sclk 1b0; sdin 1b0; cs_n 1b1; done 1b0; end else begin done 1b0; if (!busy) begin if (load) begin shreg din; bit_cnt 8d0; busy 1b1; cs_n 1b0; div_cnt 16d0; end end else if (div_cnt HALF - 1) begin div_cnt 16d0; sclk ~sclk; // 翻转产生 SCLK if (sclk 1b0) begin // 上升沿前摆好数据 sdin shreg[DATA_W-1]; shreg {shreg[DATA_W-2:0], 1b0}; bit_cnt bit_cnt 8d1; end end else begin div_cnt div_cnt 16d1; end if (busy bit_cnt DATA_W sclk 1b1) begin busy 1b0; cs_n 1b1; done 1b1; end end end endmoduleHALF决定 SCLK 频率100 MHz 系统时钟下取 20 MHz 的 SCLK半周期是 2.5 个时钟取整成 2实际略快仍在 DAC 的时序容限内。如果 DAC 手册要求 SCLK 不超过 10 MHz把这个参数调到 4 就行。bit_cnt计数到DATA_W的那一拍表示最后一位已经移出此时收cs_n拉高done。下游可以用done作为下一帧的触发也可以用固定的采样时钟驱动load前者适合按需更新后者适合连续输出。模拟输出端必须加重建滤波器。DAC 输出的是阶梯波频谱里除了基波还有一堆采样镜像集中在 f_clk ± f_out 附近。一个截止频率略高于最高输出频率的低通滤波器就能把这些镜像压下去通常用二阶或三阶的有源滤波截止频率取 1.5 到 2 倍最高输出频率。滤波器的运放要选带宽足够的型号压摆率不够会把高频波形削成三角波。5. 避坑与排查五类上板翻车的典型现场5.1 现象仿真波形漂亮上板输出全是噪声原因最常见的是 PLL 没锁就放 DDS 核出去跑或者复位是异步的PLL 失锁瞬间状态机跑飞。另一种情况是 DAC 时钟和系统时钟不同源两路时钟相位关系随机输出频谱一团糟。解决把 PLL 的锁定信号接进复位逻辑锁定之前 DDS 核和 DAC 接口都保持在复位态。复位统一用异步置位、同步释放的同步器两拍打干净。时钟源严格走同一路DAC 时钟从 PLL 的另一个输出口出不要从外部引脚单独灌。上电后先用示波器看 PLL 锁定引脚确认锁定稳定了再测输出。5.2 现象设定 1 kHz实测 997 Hz偏差还不固定原因频率控制字计算时用了浮点或者整数除法精度丢了。常见写法是FTW 1000 * 100_000_000 / (132)整数除法直接截断误差被放大。还有一种是把 32 位 FTW 赋给 24 位累加器高位被截频率自然不对。解决控制字计算放在上位机或脚本里用 64 位中间变量最后四舍五入取整再下发。Verilog 里永远不做除法FTW 是外部算好直接写寄存器。位宽对齐要检查一遍累加器位宽、FTW 位宽、串口帧长度三处必须一致。实测偏差还有一个来源是晶振本身的精度普通晶振 50 ppm1 kHz 下偏差 0.05 Hz这个属于正常范围报告里要单独说明不要和计算误差混在一起。5.3 现象综合通过下载后输出一条水平直线原因ROM 初始化没生效。$readmemh的路径不对综合工具找不到文件不报错直接给 ROM 填零。另一种是综合工具把 ROM 优化掉了因为判断它只读不写、可以常量传播。解决先看综合日志里有没有 cannot open file 或者 memory initialization 相关的警告有就改路径。再打开综合后的网表或者资源报告看块 RAM 是不是真的被例化了占用为 0 说明被优化。实在不行就把 ROM 换成case语句实现的分布式 ROM牺牲一点资源换取确定性。还有一种土办法在 ROM 输出后加一个握手信号上电后读一次已知地址比对预期值不对就拉一个错误标志到 LED 上调试时一眼定位。5.4 现象频谱上出现一串等间隔的杂散位置不随频率变化原因相位截断引起的确定性杂散。累加器 32 位、地址 12 位低 20 位被丢掉截断误差是周期性的频谱上就是固定间隔的谱线。另一种是 ROM 数据和地址位宽不匹配比如表是按 12 位生成的模块参数写的 10 位读出来的数据错位杂散更严重。解决先确认位宽一致ADDR_W、DATA_W、hex 文件的格式三处对齐。确认无误后杂散还在那就是截断误差。工程上的做法是在截断前加一点抖动dither把一个大的确定性杂散摊成一堆小的随机噪声听感和观感都好一些。抖动源可以用一个线性反馈移位寄存器产生伪随机序列加到相位低位上。代价是本底噪声抬高几 dB换取杂散指标改善十几 dB看验收标准更看重哪一项。5.5 现象串口调参偶发失效重发一次又好了原因波特率分频误差累积收满 8 位后采样点偏出位周期或者没有做两级同步起始位被亚稳态吃掉或者帧没有校验上位机重连时字节错位帧头对不上就一直丢帧。解决先算分频误差DIV CLK_FREQ / BAUD取整后的相对误差要小于 2%超过就换时钟频率或者降波特率。再加两级同步寄存器这是硬性要求。帧格式里保留帧头收到帧头才开始组帧对不上直接丢弃重新找比任何校验都管用。如果现场干扰大再补一个简单的累加和校验最后一位是前面四位的和对不上就丢。调试阶段可以在状态机每个状态上挂一个 LED 或者计数器的输出看看到底卡在哪一步。6. 验收与答辩怎么用示波器和频谱数据证明方案站得住验收这件事指标表写得好不如测法写得清楚。答辩老师最常问的一句话是你这个数据是怎么测的答不上来前面的架构讲得再漂亮也打折扣。我一般把验收分成六项每项都写清楚测量条件、方法和判据。| 测试项 | 测量方法本文还有配套的精品资源点击获取