
简介面向FPGA开发者的ZYNQ7100 DDS信号发生器完整工程基于Vivado实现主控芯片为XC7Z100FFG900-2可方便移植到XC7Z100系列平台。包内共298个文件、约52.98MB既包含vh、v、vhdl等HDL设计源码也有xdc约束、xci IP核、dcp网表、coe/mif波形数据文件以及bit配置文件、tcl/bat辅助脚本和rpt报告基本覆盖工程生成、综合实现到下载验证的完整流程。目前已有169人浏览学习。整个工程目录结构清晰便于对照源码理解DDS查找表、相位累加器与DAC接口等关键模块描述特别说明代码可顺利编译运行适合正在调试ZYNQ平台信号发生器、需要可运行参考工程的开发者直接借鉴或二次移植。1. 从查表到波形ZYNQ7100 实现 DDS 信号发生器的底层逻辑DDSDirect Digital Synthesis直接数字合成是 FPGA 工程师绕不开的基础技术。和传统模拟振荡器靠 RC 充放电或晶体谐振产生频率不同DDS 的核心思路是“查表”——把正弦波一个完整周期的采样点预先存进 ROM 或 BRAM然后用一个相位累加器不停地对地址递增把查出来的幅度值送给 DAC就得到了连续波形。ZYNQ7100 这块芯片属于 Xilinx Zynq-7000 系列里的中高端型号片上同时集成了双核 ARM Cortex-A9 处理器和 Kintex-7 级别的可编程逻辑资源用它来做 DDS既能发挥 PL 侧并行查表的高吞吐优势又能借用 PS 侧 Linux 或裸机程序做频率控制、参数下发和上位机交互。对于刚接触 Vivado 的开发者来说最容易产生的误解是“DDS 就是调一个 IP 核”。实际上Vivado 里的 DDS Compiler IP 只是把相位累加、抖动消除、正弦查找表打包好了频率分辨率怎么算、无杂散动态范围SFDR受什么限制、相位截断对输出谱的影响有多大这些问题如果理解不到位调出来的信号发生器很可能在频谱仪上一看全是杂散。本文不打算只停留在“双击 IP 配置一下”的层面而是从相位累加器原理出发给出完整的 ZYNQ7100 工程搭建步骤包括纯 Verilog 手写 DDS 和调用 Xilinx DDS IP 两种方案并把频率字、相位字、输出位宽这几个核心参数一次讲透。2. DDS 频率合成原理与 ZYNQ7100 资源选型2.1 相位累加器DDS 的频率控制字是怎么工作的DDS 系统的第一级是 N 位相位累加器它本质上就是一个 N 位加法器加 N 位寄存器每个时钟上升沿执行一次phase_reg phase_reg freq_ctrl。这里的freq_ctrl就叫频率控制字Frequency Control WordFCW它和输出频率的关系是f_out (freq_ctrl * f_clk) / 2^N其中 f_clk 是 DDS 模块的系统时钟N 是相位累加器位宽。频率分辨率由f_clk / 2^N决定N 越大能分辨的频率间隔越小。以 ZYNQ7100 常用的 100MHz 系统时钟为例如果 N32那么频率分辨率约为100_000_000 / 4_294_967_296 ≈ 0.0233 Hz这在工程上已经相当精细。Vivado 的 DDS Compiler IP 里有个参数叫“Phase Width”和“Output Width”新手容易把这两个搞混。Phase Width 是送入查找表的相位地址位数Output Width 是波形数据的幅度量化位数。相位累加器全位宽 N 会远大于 Phase Width P因为如果直接把 32 位累加器全部接到查找表地址上ROM 容量需要 2^32 个字这在任何 FPGA 上都是不可接受的。实际做法是只取相位累加器的高 P 位作为查找表地址剩下的低 N-P 位被截断丢弃。相位截断会引入杂散这是 DDS 系统最主要的频谱纯度限制因素。截断后的相位误差是一次锯齿波反映在频域上就是在主频两侧产生成对的杂散分量幅值大约为-6.02 * (N-P) dBc。比如 N32、P16理论杂散抑制约 96dB但实际因为 ROM 量化误差和 DAC 非理想特性很难达到这个值。如果你对 SFDR 要求不高P14 或 P16 是功耗和性能的折中选择。2.2 ZYNQ7100 的 BRAM 和 DAC 接口资源适配ZYNQ7100 具体资源量是 555K 逻辑单元、约 2,180 个 DSP48E1 Slice 和 13.5Mb Block RAM。对于 DDS 信号发生器查找表占用的是 BRAM 而不是 LUT。一个 16 位地址、14 位输出的正弦表需要2^16 * 14 917,504 bit 896 Kbit这对 13.5Mb 的容量来说毫无压力但要注意 Vivado 会把一个 16 位地址的 ROM 推断成多个 BRAM36 块拼接实际占用比理论值略大。从 ZYNQ7100 输出模拟信号需要外接 DAC 芯片。Xilinx 官方开发板通常板载 ADV 系列高速 DAC 或 HDMI 接口的 FMC 子卡扩展。如果你手头的板子没有 DAC也可以用板上自带的音频编解码器比如 SSM2603 或 ADAU1761的 I2S 接口来输出但带宽受限只能到音频范围。更常见的做法是买一块 AD9708 或 AD9767 双通道 DAC 子卡通过 FMC-LPC 接口连接数据位宽可选 8 位或 14 位。下面这个表格列出了不同输出位宽对应的 SFDR 上限和 BRAM 消耗参考值输出位宽最小 Phase Width理论 SFDR (dBc)BRAM 估算 (Block RAM 36Kb)8 位1060210 位1272412 位1484814 位16961616 位169616这里的 BRAM 估算没有考虑 Vivado 的 ROM 揉合优化实际工程以综合结果为准。ZYNQ7100 的 BRAM 总量大约是 755 个 36Kb 块即使查表做到 16 位地址资源占用也不到总量的 3%剩下的大量 RAM 可以留给数据缓存或波形存储。3. 手写 Verilog DDS 内核与 Vivado 工程搭建3.1 最小可综合的 DDS 模块代码和引脚定义不依赖 Xilinx IP纯手写一个 DDS 模块能帮助你完全掌握相位累加器的行为。下面这段代码我建议按原样复制到一个名为dds_core.v的文件里module dds_core #( parameter PHASE_WIDTH 16, // 送入查找表的相位地址位宽 parameter DATA_WIDTH 14, // 波形采样数据输出位宽 parameter ACCUM_WIDTH 32 // 相位累加器位宽决定频率分辨率 )( input wire clk, input wire rst_n, input wire [ACCUM_WIDTH-1:0] freq_ctrl, // 频率控制字 FCW input wire [PHASE_WIDTH-1:0] phase_offset, // 可选相位偏移 output wire [DATA_WIDTH-1:0] wave_out // 正弦波采样值 ); reg [ACCUM_WIDTH-1:0] phase_accum; // 相位累加器每个时钟上升沿累加一次 FCW always (posedge clk or negedge rst_n) begin if (!rst_n) phase_accum 32d0; else phase_accum phase_accum freq_ctrl; end // 取累加器高 PHASE_WIDTH 位 相位偏移作为 ROM 地址 wire [PHASE_WIDTH-1:0] rom_addr phase_accum[ACCUM_WIDTH-1 -: PHASE_WIDTH] phase_offset; // 正弦查找表用 case 语句实现此处仅列开头部分示意 reg [DATA_WIDTH-1:0] sine_rom [0:(1PHASE_WIDTH)-1]; // 实际工程里应使用 $readmemh 加载 .coe 或 .mem 文件 // initial $readmemh(sine_table.mem, sine_rom); wire [DATA_WIDTH-1:0] rom_out; assign rom_out sine_rom[rom_addr]; // 输出寄存器打一拍消除组合逻辑毛刺 reg [DATA_WIDTH-1:0] wave_out_r; always (posedge clk or negedge rst_n) begin if (!rst_n) wave_out_r {DATA_WIDTH{1b0}}; else wave_out_r rom_out; end assign wave_out wave_out_r; endmodule代码里的phase_offset是一个可选输入把它加上之后可以控制初始相位这在生成 I/Q 正交信号或做相移键控时很管用。频率控制字freq_ctrl是 32 位宽这意味着要从 PS 侧或某个寄存器接口给这个模块写值。rom_out出来后过了一级寄存器这一步很多人会忽略但对高速 DAC 来说输出寄存器能显著降低数据线上的时序抖动。3.2 用 MATLAB 生成正弦查找表并转成 COE 文件上面的 Verilog 代码里 ROM 是空的接下来要生成 COE 文件让 Vivado 在综合时把它初始化进 BRAM。最方便的方式是先用 MATLAB 或 Python 生成整数格式的波形表再转成 Xilinx COE 格式。下面是 MATLAB 脚本% 生成 16 位地址、14 位输出的正弦查找表 depth 2^16; % 查找表深度 width 14; % 输出位宽 t (0:depth-1) / depth * 2 * pi; sine_data round((sin(t)) * (2^(width-1) - 1)); % 调整范围到无符号数适合 DAC 的二进制补码/偏移二进制 sine_data sine_data 2^(width-1); % 写入 COE 文件 fid fopen(sine_table.coe, w); fprintf(fid, memory_initialization_radix10;\n); fprintf(fid, memory_initialization_vector\n); for i 1:depth if i depth fprintf(fid, %d;\n, sine_data(i)); else fprintf(fid, %d,\n, sine_data(i)); end end fclose(fid);注意这里的sine_data被加了2^(width-1)做电平偏移因为单极性 DAC 的输入码一般是 0 到满量程的无符号整数正弦波叠加一个直流偏置后波形范围变成0 ~ 1638314 位。如果你用的是双极性 DAC那就不需要加偏移。生成完 COE 文件后在 Vivado 里创建一个 Block Memory Generator IP选择 Single Port ROM加载这个文件即可。也可以把 Verilog 里的$readmemh注释打开改成读取文本格式的 .mem 文件两者效果等价。3.3 频率控制字的计算方法和在线调节策略FCW 的计算是 DDS 调参最核心的一步。假设系统时钟 f_clk100MHz想要输出 f_out10kHz累加器位宽 N32根据公式反推freq_ctrl f_out * 2^N / f_clk 10000 * 4294967296 / 100000000 429497 (取整)对应的实际输出频率是429497 * 100000000 / 4294967296 10000.0028 Hz误差大约 2.8mHz完全可以忽略。在 FPGA 里做除法不太划算所以工程上通常先在 PS 侧 ARM 核里用浮点运算算出 FCW再通过 AXI-Lite 寄存器写到 PL 侧 DDS 模块。下面是一个典型的在线调频流程上位机发送目标频率比如通过串口或以太网ZYNQ PS 端运行的程序收到指令解析出 10kHz 这个数值PS 计算FCW round(10000 * 2^32 / 100000000)把 FCW 拆成两个 16 位半字写入 AXI-Lite 寄存器地址偏移 0x00 和 0x04PL 侧把两个 16 位半字拼接成 32 位freq_ctrl喂给 DDS 内核这里有个关键问题freq_ctrl在 DDS 运行过程中是随时可能改变的。如果 FCW 在累加器计数的中途发生变化相位累加器的输出会跳变导致波形产生尖刺。解决办法是在 DDS 模块内部对freq_ctrl做一次双缓冲——PS 先把新值写入影子寄存器等一个 pulse 信号到来再同步加载到工作寄存器。下面的代码展示了这个同步加载机制// 双缓冲频率字同步加载 reg [ACCUM_WIDTH-1:0] freq_shadow; reg [ACCUM_WIDTH-1:0] freq_work; reg update_pulse_d; always (posedge clk) begin if (update_pulse_d) freq_work freq_shadow; endupdate_pulse_d通常由 AXI 寄存器接口里的一个写使能位生成。这样一来即使 PS 侧持续更新频率DDS 内核也只会在指定时刻切换频率保证波形的连续性。4. Vivado 工程配置与 AXI-Lite 寄存器控制通路4.1 Block Design 中 DDS 内核与 Zynq PS 的互联方式ZYNQ7100 的优势在于 PS 和 PL 打通以后频率控制不用再通过 JTAG 或拨码开关手动改而是可以在 Linux 里用一句write命令完成。在 Vivado 里搭建 Block Design 时常见做法是添加ZYNQ7 Processing SystemIP配置 UART、SD、DDR 和外设添加AXI GPIOIP把它配置成两个通道输出通道宽度 32 位用来控制 FCW将AXI GPIO的 axi_gpio_0.gpio_io_o 连接到自定义 DDS 模块的 freq_ctrl 信号PS (ARM Cortex-A9) │ AXI_GP ▼ AXI GPIO IP (输出 32 位) ▼ freq_ctrl[31:0] 手写 DDS 内核 (dds_core.v) ▼ wave_out[13:0] DAC 芯片 (AD9708/AD9767) ▼ 模拟输出使用 AXI GPIO 的好处是省去自己写 AXI-Lite Slave IP 的麻烦但缺点是没有握手信号PS 写入新 FCW 的瞬间可能出现在 DDS 时钟域的不稳定期。如果你需要精确的频率切换时序建议用 Vivado 的Custom AXI-IP向导生成一个带单寄存器接口的 IP在其中加入上一小节描述的双缓冲逻辑。Vivado 使用教程里关于 AXI GPIO 的部分讲得很细但对于高频 DDS 应用自定义 IP 更合适。4.2 修 Vivado 时序约束和管脚分配DDS 模块的输出连接外部 DAC数据线时序需要根据 DAC 芯片手册的建立保持时间做约束。上板调试最怕的情况是波形出来但是毛刺很多十有八九是约束没做对。下面是一个典型的 XDC 约束片段# 系统时钟 100MHz create_clock -period 10.000 -name sys_clk [get_ports clk] # DAC 数据输出管脚 set_property PACKAGE_PIN AB12 [get_ports {dac_data[0]}] set_property PACKAGE_PIN AB13 [get_ports {dac_data[1]}] # ... 其余管脚类似 # 约束 DAC 时钟和数据的关系假设 DAC 时钟由 FPGA 输出 set_property IOB true [get_ports {dac_data[*]}] # 数据与时钟输出之间的最大延迟约束 set_output_delay -clock dac_clk_out -max 2.0 [get_ports {dac_data[*]}] set_output_delay -clock dac_clk_out -min 0.5 [get_ports {dac_data[*]}]set_property IOB true把数据寄存器放到了 IO Block 内部的输出触发器里这能保证每个数据位到 pin 脚的延迟基本一致。如果不加这条Vivado 可能把寄存器放在离管脚很远的 CLB 里导致各 bit 之间偏斜太大DAC 采样时出错。ZYNQ7100 的管脚分布可以在 Xilinx 官方引脚图里查到但开发板厂商一般会提供现成的 XDC 模板直接复制改名字就行。4.3 用 XSCT 或 SDK 在线改频率字工程生成比特流后需要写一段在 Zynq ARM 核上运行的代码来改变频率。最简单的方式是用 Vitis新版本 SDK建一个 Hello World 工程然后加入寄存器读写逻辑#include xil_io.h #include xparameters.h #include sleep.h #define AXI_GPIO_BASE XPAR_AXI_GPIO_0_BASEADDR // 计算 FCW: f_out f_clk * FCW / 2^32 // 100MHz 时钟输出 5kHz 正弦波 unsigned int calc_fcw(unsigned int f_out_hz) { unsigned long long tmp; tmp (unsigned long long)f_out_hz * (1ULL 32); tmp tmp / 100000000ULL; return (unsigned int)tmp; } int main() { unsigned int fcw_5k calc_fcw(5000); unsigned int fcw_50k calc_fcw(50000); // 写入 5kHz 频率字 Xil_Out32(AXI_GPIO_BASE 0x00000000, fcw_5k); usleep(2000000); // 维持 2 秒 // 切换到 50kHz Xil_Out32(AXI_GPIO_BASE 0x00000000, fcw_50k); while(1); }XPAR_AXI_GPIO_0_BASEADDR这个宏在 xparameters.h 里自动生成如果你改过 IP 名字对应的宏名也会变。代码里用了 64 位中间变量来算 FCW如果直接用 32 位整数计算50000 * 4294967296会溢出。5. 窄带信号性能测试与 SFDR 优化手段5.1 频谱仪实测对比和常见杂散来源信号发生器做完最直接的验证手段是用频谱仪看输出信号的频谱纯度。把 DAC 输出经过一个 SMA 连接线接到频谱仪上设置中心频率为 5kHzSPAN 50kHz观察主频两侧的杂散分布。下面这张表总结了不同实现方式下的典型测试结果实现方式输出位宽实测 SFDR主要杂散来源纯查表 ROM无抖动14 位约 62 dBc相位截断杂散、DAC 量化噪声高相位位宽 P1614 位约 70 dBcDAC 非线性和毛刺P16 抖动注入14 位约 76 dBc热噪声地板抬升杂散转为噪声P18 Taylor 近似14 位约 75 dBc幅度量化误差为主如果你实测 SFDR 低于 55dBc先别急着怀疑 ROM 表。先看 DAC 的时钟抖动jitter再看数据线上的串扰最后才是查找表的相位截断。DAC 模拟输出的滤波电路也是一个高频杂散的来源SMA 接口后最好加一级低通滤波器截止频率设置为最高工作频率的 1.2 倍。5.2 相位截断杂散的数学规律和抖动注入实现知道了杂散来源就要对症下药。相位截断杂散的数学规律是当截断位数N-P m时杂散在频域上的分布和输出频率有关会产生2^m / gcd(FCW, 2^m)根离散谱线。当 FCW 是 2 的整数次幂时杂散最少当 FCW 是 3 的倍数时杂散最复杂。避开的方法是在累加器输出到 ROM 地址之间加一个伪随机抖动源// 相位抖动注入将低位随机数与相位高 P 位相加后送入查找表 wire [PHASE_WIDTH3:0] phase_jitter_sum; wire [PHASE_WIDTH-1:0] phase_addr_dithered; // jitter 源可以用 LFSR 生成不需要真随机 reg [7:0] lfsr; always (posedge clk or negedge rst_n) begin if (!rst_n) lfsr 8d1; else lfsr {lfsr[6:0], lfsr[6] ^ lfsr[5] ^ lfsr[4] ^ lfsr[3]}; end // 取低 4 位与相位截断部分原低 N-P 位的高 4 位相加 wire [PHASE_WIDTH-1:0] p_high phase_accum[ACCUM_WIDTH-1 -: PHASE_WIDTH]; wire [3:0] injected_jitter {4b0000} ^ lfsr[3:0]; assign phase_addr_dithered p_high injected_jitter;加入抖动后原本离散的杂散能量被“打散”成宽带噪声表现在频谱仪上就是杂散被压低但噪声底抬高。在信号发生器设计里这是一个很实用的折中——人耳或大多数被测设备对噪声底的容忍度比离散杂散高得多。5.3 用 ILA 逻辑分析仪在线抓取波形数据没有频谱仪的情况下先用 Vivado ILA 核抓 PL 内部信号也能验证 DDS 是否正确工作。在 Block Design 里加入 ILA把探针连到wave_out和phase_accum[31:28]上设置采样深度 1024触发条件选freq_ctrl ! 0上板后可以在 Vivado Hardware Manager 里看到抓下来的波形。ILA 抓出来的数据可以用export_csv功能导出再导入到 MATLAB 里做 FFT 分析这样在没有示波器的环境里也能大致评估频谱纯度。不过要意识到ILA 抓到的是数字域信号不含 DAC 引入的模拟失真所以它的结果比真实频谱仪要乐观一些。6. 把 DDS 做成可调幅、扫频的工程级信号源6.1 幅度控制与乘法器的使用单独一个正弦波发生器应用面有限加上幅度控制和扫频模式后就更实用。控制幅度有两个方案一是让 DDS 输出固定满幅波形在外面接一个可编程增益放大器PGA或数字电位器调模拟幅度二是在数字域直接乘一个幅度系数。后者实现更简单在 ZYNQ7100 上只需要用一个 DSP48E1 就能完成一次乘法// 数字域幅度调节乘以 16 位幅度系数再截断回 14 位 reg [29:0] product; always (posedge clk) begin product wave_out * amp_ctrl; // amp_ctrl 是 16 位无符号数0~65535 end assign wave_amp product[29:16]; // 截取高 14 位作为输出在 Vivado 中乘法会由综合工具自动推断为 DSP48E1 Slice。ZYNQ7100 上这类资源非常充裕多路独立幅度控制也不会成为瓶颈。另外注意乘法器的流水线级数——如果wave_out和amp_ctrl都来得太急需要插两级流水寄存器否则时序可能跑不到 100MHz 以上。6.2 扫频模式的频率步进与驻留时间控制扫频信号发生器Sweep Frequency Generator在测量滤波器频率响应时非常有用。实现思路是让 PS 每隔一段时间给 DDS 模块更新一次 FCW每次增加一个步进值。下面是一段在 PS 侧运行的裸机扫频代码// 线性扫频起始 1kHz结束 100kHz步进 100Hz驻留时间 1ms #define START_FREQ 1000 #define STEP_FREQ 100 #define END_FREQ 100000 #define DWELL_US 1000 void sweep_frequency(void) { unsigned int fcw, current_freq; for (current_freq START_FREQ; current_freq END_FREQ; current_freq STEP_FREQ) { fcw calc_fcw(current_freq); Xil_Out32(AXI_GPIO_BASE, fcw); usleep(DWELL_US); } }扫频模式下相位累加器不会在每个频点处从零开始而是继续保持上一个频点的相位值继续累加。这在某些测试场景下会带来相位不连续的问题。如果你做的是网络分析仪式的扫频测量通常需要每次频率切换时把phase_accum清零这时就要在 DDS 模块里加一个phase_rst端口接在双缓冲加载信号旁。6.3 波形杂散排查顺序和最终性能验证清单工程交付时验证要形成闭环。我的排查顺序是固定的先查 FF 时序是否有违规再看 DAC 端口的 IO 时序是否满足要求然后看频谱仪的杂散分布是否符合理论值最后检查输出幅度稳定性和温度漂移。下面这几个问题可以当作自测清单Vivado 综合和实现报告里有没有 timing negative slackILA 抓到的波形和 ROM 表数据是否完全一致FCW 修改后输出频率跳变瞬间波形是否连续不同频率下的 SFDR 是否都高于初始设计目标DAC 满量程输出时模拟幅度是否达到期望的 1Vpp查完这五条DDS 信号发生器就可以稳定运行了。至于要不要加一个 FIR 滤波器来压带外噪声取决于你的下游电路对噪声底的要求——需要的时候把 FIR IP 直接挂在 DAC 数据输出和管脚之间即可。6.4 最后一招用 ChipScope 逻辑分析仪确认输出寄存器时序如果以上排查都没发现问题但 DAC 输出还是有毛刺用 Vivado 里的 Hardware Manager 连接开发板打开 DDS 输出寄存器的波形对比触发时钟边沿和输出数据翻转时刻。通常毛刺出现的原因是 DAC 时钟和数据信号的相对延迟太大这时可以在约束文件里手动把 DAC 时钟的set_output_delay微调 0.5ns 步进直到波形变干净。这个方法虽然土但在很多项目里比重新编译几十次更有效。本文还有配套的精品资源点击获取