
最近有个做信号采集的同事跑过来问我说他在 FPGA 里存了一个 4096 点的正弦查找表结果换一次频率就要重新算一次 ROM 内容连续换了三次以后整个人都麻了。我问他为什么不直接用 Vivado 里的 DDS IP 核他愣了一下DDS 不是做无线通信用的吗我还能拿它当普通信号发生器对能而且这句话其实把 DDS 的一个最典型的应用场景给说小了。DDS IP 核不是只能做载波它本质上是“用时钟去换取频率精度”的数字信号发生器。在 Vivado 2023.2 里你不需要手写相位累加器不需要自己维护查找表打开 IP Catalog把 DDS Compiler 拖进来配置几个参数接上时钟正弦波就出来了。这个过程熟练以后真的五分钟左右频率想要多少就多少分辨率能做到微赫兹级别。这篇文章就是围绕“DDS IP 核生成正弦波”这件事把这五分钟背后需要懂的原理、参数、接线和避坑点一次说清楚顺便也聊聊从正弦波到方波、再到把波形数据拿去做其他仿真测试的延伸玩法。1. 手写正弦波生成器的维护成本为什么大多数情况下是伪需求很多人刚开始接触 FPGA 信号生成的时候第一反应都是我在 Block Memory 里放一张正弦表然后按地址循环去读不就是一个正弦波发生器吗这个思路没有错甚至对于一些非常简单、频率固定的场景它反而是最高效的方案。但真正去实现过以后你会发现手写方案的性价比极其糟糕今天看起来能用下次改需求的时候就要命了。1.1 你一定会犯的符号位错误第一个必踩的坑就是输出是无符号数还是有符号数。手工用 MatLab 或者 Python 生成正弦表的时候很多人会习惯性地生成 0 到 255 之间的整数点这样看起来“标准”。但如果你后续接的是 DAC 芯片一般希望输入的是有符号数比如 -127 到 127或者 -2048 到 2047。手写 ROM 的时候你往往会忘记把无符号转成二进制补码结果示波器上看波形上半周和下半周根本不对称甚至出现一个很大的直流偏置。用 DDS IP 核就不存在这个问题。它有一个明确的输出格式选项默认就是有符号数接口文档里清清楚楚写着输出数据位宽和极性你不用自己去“猜符号位”。我自己第一次手写正弦表的时候就因为在符号位少加了 1硬是浪费了一个下午排查最后发现是查表地址对应的值被当成无符号数输出了。1.2 改频率时手写方案会让你想摔键盘手写 ROM 方案的另一个痛点在于如果你需要两个不同频率的正弦波你很可能需要准备两份不同的 ROM。如果你需要 100 个不同频率那基本上就没法收了。虽然也有一个改进方案就是只存一个周期然后通过控制“步进”来改变频率。这本质上就是在实现频控字的思路了。当你意识到这一步的时候你其实已经在一个非常初级的 DDS 结构里了问题在于手写这个结构的时候相位累加器溢出、截位处理、查找表深度和杂散性能这些细节都要自己操心。而 DDS IP 核把这些全封装好了。用 DDS IP 改频率只需要改送入频率控制字端口的数值就行实时改、运行中改都可以。你甚至可以在一次完整的输出过程中间切换频率虽然有细微的相位噪声但只要不是频率跳变极端的场合实际用起来完全够。1.3 手写实现和 DDS IP 核的功能差距下面这个表格是我实际做过的两种方案的对比可以直接拿去说服团队里“坚持手写”的同事对比项手写正弦表 ROMXilinx DDS Compiler IP频率分辨率受 ROM 深度和步进精度限制通常很粗糙相位累加器位宽动态可选频率分辨率极高频率动态切换要么重新生成 ROM要么自己写步进控制逻辑修改频率控制字即可支持实时更新占用资源大 ROM 地址生成逻辑内部优化过的查找表同样指标占用更省无杂散动态范围受量化影响大需要自己优化可配置多种 SFDR 档位自动做扰动处理多通道相位同步极难保证多通道模式下内置相位偏移和同步机制配置效率每次重新生成文件或者动 RTL图形界面点几下5 分钟搞定这套对比不是说手写完全不行而是告诉你在绝大多数信号生成场景下用成熟 IP 是投入产出比最高的选择。除非你的项目对资源占用有极致追求或者需要非标准的输出畸形波形否则没必要从零造轮子。2. 从原理到理解DDS 是“账房先生”而不是“波形生成器”有句话叫“知其然更要知其所以然”。用 DDS IP 核之前我建议你先想明白相位累加器这个概念。它真的不复杂理解它以后你配置起 IP 来就不是瞎填参数了而是清清楚楚知道每一个位宽在干什么。2.1 相位累加器的工作机制DDS 全称是 Direct Digital Synthesizer直接数字频率合成器。它的核心是一个相位累加器这个词听起来很高级其实你完全可以把它理解成一个账房先生在记账每个时钟周期账房先生在手头的账本上加一个固定数当数字加到超过账本的容量时把它顶出去再从零开始继续加。这个“固定数”就是频率控制字英文叫 Frequency Control Word简称 FCW。只要 FCW 的数值不同账本被顶满的速度就不同。账本被顶满一次就代表正弦信号走完了一个周期。所以 FCW 越大正弦波频率越高FCW 越小正弦波频率越低。账本容量就是相位累加器的位宽。比如 32 位累加器它的容量就是 2 的 32 次方。这个容量和你的系统时钟共同决定了你能输出的最小频率步进。2.2 频率控制字计算两步就能算出来很多教程会把频率控制字的公式写得非常复杂其实只需要记住下面这个式子FCW f_out × 2^N / f_clk其中f_out 是你想要的输出正弦波频率。N 是相位累加器位宽也就是 IP 配置里的 Phase Width。f_clk 是送入 DDS IP 核的系统时钟频率。举个例子如果系统时钟是 100 MHz相位累加器位宽设成 32 位我想生成一个 1 kHz 的正弦波那么FCW 1000 × 4_294_967_296 / 100_000_000算一下大概是 42949.67。因为 FCW 是整数所以实际得到的频率可能不是精确的 1000 Hz而是 1000.00000047 Hz 左右。这种误差对绝大多数应用来说完全可以忽略。你可能会问是不是相位累加器位宽越大越好从频率分辨率角度说是的。32 位累加器在 100 MHz 时钟下的频率分辨率能到 0.023 Hz 级别。但数据位更多内部查表地址位数也更多资源会略微增加。好在 DDS IP 核内部会自动做截断处理你设置的是相位累加器位宽而映射到查找表的实际地址位数可以单独控制两者解耦。2.3 相位截断和输出位宽的取舍这里有一个概念很多新手容易忽略相位累加器的位宽往往比 ROM 查找表的地址位宽大。也就是说累加器高十几位用来查表低十几位就直接舍弃了这个过程叫相位截断。相位截断会带来杂散但 IP 核文档里会给出不同配置下的 SFDR 指标你只需要在界面上选对应的档位就行。输出位宽也要自己想清楚。输出位宽决定了波形幅度方向的量化精度。如果你后面接的是 12 位 DAC那输出位宽设成 12 位就是最匹配的设多了浪费设少了会有明显台阶感。这个“输出位宽”和你“相位累加器位宽”是两回事不要搞混。3. Vivado 2023.2 里的 DDS IP 核配置实操下面进入正题在 Vivado 2023.2 里从零建立一个 DDS 信号生成模块。我用的是 2023.2 版本界面和后几个版本基本一致如果你是 2020.1 之后的版本操作路径大同小异。3.1 从 IP Catalog 创建 DDS Compiler打开 Vivado 工程在左侧 Flow Navigator 里点开 IP Catalog在搜索栏输入 dds会出现一个叫 DDS Compiler 的 IP双击它。这里有个版本环境差异提醒一下虽然 DDS Compiler 这个 IP 在 Vivado 里存在了很多年但新版本的 IP 定制界面细节变了。2023.2 里主要有两个页面Configuration 和 Summary。你要改的关键配置都在 Configuration 页面。进入配置界面后第一件事是选择 Implementation。一般有 Phase Generator 和 Phase Generator and SIN/COS Lookup Table 两个选项。我们是要输出正弦波形数据所以要选后者也就是包含查找表输出的模式。如果只选 Phase Generator那你得到的只是相位数据没有正弦幅度值那个是给闭环控制场景用的。3.2 关键参数怎么填累加器位宽、输出位宽、频率分辨率接下来是几个常用参数的填法我按实际使用优先级来说第一个参数是 Number of Channels通道数。默认是 1如果你需要 I/Q 两路正交信号或者需要多路不同频率的信号可以填 2、4 甚至更多。注意每增加一个通道输出带宽会被均分这个我后面会专门讲。第二个参数是 Mode of Operation。这里有两个选择Standard 和 Rasterized。Rasterized 模式是配合 ADC/DAC 的采样率做精细化频率规划的普通场景选 Standard 就够了。Standard 模式下你直接填输出频率和系统时钟频率它会自动帮你算出频率控制字对应的参数。第三个是 Parameter Selection 里的两个选项Hardware Parameters 和 System Parameters。如果你选 System Parameters就可以直接填希望得到的输出频率和实际采样频率工具会自动反算硬件参数这对新手特别友好。如果你选 Hardware Parameters就需要自己填累加器位宽、查找表地址位宽等适合对底层清楚的高手。我给新手的建议是第一次用 System Parameters 模式把频率、时钟填上去先跑通再去看看工具帮你换算出的累加器位宽是多少。这样你既会搭又知道底层它帮你做了什么。3.3 输出幅值格式和无符号/有符号的控制在配置页面的 Output 部分你会看到一个 Output Width 选项。这个要和你的后级数据通路匹配。常见做法接 Xilinx 自带的 AXIS 数据流或者做 FFT 分析输出位宽选 16 位足够。接外部 DAC 芯片输出位宽必须和 DAC 输入位宽一致。观察波形方便输出位宽选 8 位或 12 位都可以如果只做波形验证8 位也够用。下面还有一个 Output Selection默认就是 Sine只有正弦。如果你后续还要同相余弦信号可以选 Sine and Cosine这样会同时输出两路分别对应 sin 和 cos。这在做正交混频、锁相环仿真时特别有用。关于有符号和无符号DDS Compiler 默认输出是有符号数也就是二进制补码形式。这个不要乱改因为后续不管是接 FFT、接乘法器还是接 DAC大家都默认数据是有符号的。如果你非要无符号输出需要在 IP 外面自己加一个偏移量把一个周期的负半周抬到零以上这个操作看起来简单但很多时候会引入不必要的直流分量反而影响后级调试。3.4 最小可用的顶层接线示例配置完 IP 后在 IP Sources 里例化它。一个最简可用顶层模块大概长这样module dds_sin_gen ( input wire clk, input wire rst_n, input wire [31:0] freq_ctrl_word, output wire [15:0] sine_out, output wire m_axis_data_tvalid ); wire [15:0] dds_sine; wire dds_tvalid; dds_compiler_0 u_dds ( .aclk (clk), .aresetn (rst_n), .s_axis_phase_tvalid (1b1), .s_axis_phase_tdata (freq_ctrl_word), .m_axis_data_tvalid (dds_tvalid), .m_axis_data_tdata (dds_sine) ); assign sine_out dds_sine; assign m_axis_data_tvalid dds_tvalid; endmodule这段代码里最重要的就是s_axis_phase_tdata这个端口它接收的就是频率控制字。你只要在逻辑里改变这个端口的数值输出正弦波的频率就会跟着变。注意s_axis_phase_tvalid要拉高否则相位累加器不会更新。这个细节很多第一次用 AXI4-Stream 接口的人会漏掉导致 IP 核永远不出数据。4. 用 STM32 ADC 做采集验证DDS 可能出现的波形怪相有时候我们在 FPGA 里用 Vivado 仿真波形看起来完美无缺但接到真实硬件上就发现不是那么回事。这里我以 STM32 的 ADC 采集为例聊聊 DDS 输出波形在外部的真实表现以及你可能遇到的问题。4.1 为什么拿 STM32 采集过来验证有相当多的人是先在 FPGA 上用 DDS 生成一个正弦波然后再送给模拟前端最后用 STM32 的 ADC 去采样。这种结构在测控设备、小型信号源、振动台控制里很常见。说白了DDS 生成的是数字序列你要让物理世界“看到”正弦波中间一定要经过 DAC。很多开发板并没有板载 DAC于是大家就会用一个外置的串行 DAC 芯片比如 AD9767、AD9708 等等。DDS IP 核输出的数据总线接到 DAC 的数据总线DAC 出来的模拟信号再送到 STM32 ADC。如果你的 DDS 输出数据格式和 DAC 输入格式不匹配那一刻就是灾难现场。4.2 采集到的正弦波顶部被削平是为什么最常见的现象是STM32 的 ADC 采集到的正弦波顶部明显被削了一块看起来像一个被压扁的波峰。这个问题很坑因为它可能来自三个方向第一DDS 的输出幅度超过了 DAC 后面运放的线性范围。DDS 输出满幅值本来就接近正负满量程如果运放的电源电压不够或者增益设置过大信号在运放出口被限幅就是削顶。第二STM32ADC 的参考电压小于输入信号的峰值。比如 STM32 供电 3.3V参考电压也是 3.3V但 DAC 后级输出的信号峰值到了 3.5V那 ADC 内部采样保持电路的输入超量程就会削顶。第三如果 DDS 输出配置成了无符号数而 DAC 芯片要求有符号输入那么 DAC 输出的波形会在零电平附近出现一个奇怪的折叠或直流偏移。这时候的波形往往不仅仅是削顶还会看起来上下不对称。解决思路就是先用示波器在 DAC 输出端测原始波形如果模拟输出是完好的正弦那就重点查 STM32 ADC 的参考电压和输入范围如果模拟输出已经畸形了那问题一定出在 DDS 数据位宽、符号格式或者后级运放电路上和 STM32 无关。这种“分段定位”的思路比反复调 STM32 代码高效很多。4.3 采样率不够会看到什么样的“假波形”STM32 的 ADC 默认采样率和 DDS 输出的正弦波频率如果搭配不合理你会在采集端看到一个完全失真的波形。这其实不怪 DDS也不怪 ADC纯粹是欠采样导致的混叠。举个例子DDS 输出一个 20 kHz 的正弦波而 STM32 ADC 的采样率只有 20 kHz 附近奈奎斯特定理要求采样率至少两倍于信号频率你现在刚好踩在线上了采出来的数据点会像是低频包络或者相位来回反转的乱点。建议验证时把信号频率控制在采样率的十分之一以下。比如 ADC 实际采样率 200 kHzDDS 就输出 5 kHz 到 15 kHz 的正弦波这样采集到的波形会比较平滑调试时容易看清幅值、频率和相位是否准确。等基础验证通过后再逐渐提高信号频率到接近奈奎斯特极限这时候你会看到更多的采样点抖动和量化噪声也算是提前给系统做压力测试。5. 延伸玩法正弦波转方波以及作为车辆路面激励的思路搞 FPGA 信号生成的很少只停留在“生成一个正弦波”就完事。更多时候这个正弦波是去做进一步处理的素材。我搜了下最近大家在讨论的几个方向一个是正弦波和方波的关系一个是 Carsim 里把正弦波当路面激励这两个都有意思而且都能基于 DDS IP 核来做。5.1 正弦波和方波的底层关系很多人在“方波和正弦波关系”这个问题上绕了很久。从时域看方波就是正弦波经过一个过零比较器之后得到的。也就是说你把正弦波送给一个比较器高于零电平就输出高低于零电平就输出低于是得到方波。从频域看方波可以分解成基波加上一系列奇次谐波基波分量本身就是正弦波。所以严格说方波不是“另一个东西”它是在正弦基波上叠加了很多奇次谐波的结果。在 FPGA 里如果你已经有 DDS 生成的正弦波数据想得到一个同频方波不需要外接比较器芯片。你可以直接取 DDS 输出的最高位符号位正半周符号位为 0负半周符号位为 1。把这个符号位拉出来当逻辑电平用就是一个天然的数字方波。这种方法零额外成本频率完全和正弦波同步。如果你需要的是模拟电路上方波那就把 DDS 输出的正弦波经过板上的电压比较器比如 LM393、TLV3501输出端接上拉就能得到上升沿很陡的方波。这种方法的好处是方波的幅度和 FPGA 电平域解耦适合驱动后级功率电路。5.2 DDS 数据作为 Carsim 路面激励的转化思路再说说 Carsim 把正弦波作为路面激励这个场景。你可能会疑惑Carsim 不是车辆动力学仿真软件吗和 FPGA 有什么关系这里其实是把 DDS 这个信号生成能力作为一种通用工具来用。车辆动力学仿真中有时需要给悬架或者轮胎输入一个正弦形式的位移激励用来测试车辆的频率响应。Carsim 里可以导入路面高程数据文件而路面高程随时间的变化曲线就是一个正弦波。你可以在 FPGA 上用 DDS 生成高精度的正弦波采样点然后通过串口或以太网发给上位机最后转成 Carsim 需要的路面文件格式。这种方案的优点是可以利用 FPGA 生成非常稳定的正弦波序列不像普通函数发生器那样存在时基抖动。做转化的时候要注意两点第一Carsim 需要的路面数据通常有明确的时间步长和物理单位比如每 0.01 秒一个点单位是米。FPGA 生成的原始数据是二进制码需要在上位机软件里做一次标定换算把 ADC 码值换成实际位移。第二正弦波的频率选择要和车辆系统共振频率错开否则仿真里会出现极端的悬架行程导致仿真步长不稳定甚至发散。说到标定换算其实所有 DDS 信号生成系统都绕不开这一步除非你是纯数字域内部自测。只要涉及数模转换和物理量输出就一定要记录 DAC 满量程对应的物理量程否则生成的正弦波只能是“看起来像正弦波”实际幅度单位是错误的。5.3 导出正弦波离散点的注意事项不管你是把正弦波数据送到 Carsim还是送到 MatLab 做进一步分析导出数据的时候有几个细节建议养成习惯明确数据格式是二进制补码还是偏移二进制是 16 位还是 12 位导出时统一标注清楚。记录采样率一组正弦波数据如果没有采样率对方拿到也没法还原频率。做适当的整周期截取最好输出整数个周期避免 FFT 分析时出现频谱泄露。文件头加元信息时间戳、频率字、时钟频率、生成日期全部写上。这一点非常有利于多方联调时追溯数据来源。6. 遇到异常波形时的快速排查链路就算你完全按照上面的步骤配置好了 DDS在实际调试中也不可能一帆风顺。这里整理一套我常用的排查思路按顺序执行可以快速定位大多数问题。6.1 先确认输出频率对不对再看波形形状很多人一看波形不对就先怀疑 DDS IP 配置。其实更高效的做法是先测量输出信号频率看看它和目标频率的偏差有多大。如果频率完全对不上大概率是频率控制字或者系统时钟出了问题。频率控制字的计算要重新检查尤其是当你换了时钟频率之后原有的频率控制字没有跟着变输出频率就会成比例偏移。如果频率是对的只是波形形状不对那才需要怀疑相位截断、输出位宽、后级滤波这些问题。频率正确但波形有毛刺常见原因是数据在跨时钟域或经过逻辑门的时候出现了四舍五入导致的突变。DDS 输出本身是干净的问题通常出在你对输出数据的后续处理。6.2 用 ILA 抓内部信号时注意别“抓到假的”Xilinx 的 ILA 调试核是 FPGA 调试的神器但用它观察 DDS 输出时有一个陷阱ILA 的采样时钟频率必须高于你信号的频率否则同样会有混叠问题。如果你用 ILA 以 100 MHz 的时钟去抓一个 20 MHz 的正弦波每个周期只有 5 个采样点波形看起来就会像锯齿波甚至像一个不规则的多边形。这是正常的不代表 DDS 坏了。想要在 ILA 里看到平滑的正弦波要么降低信号频率到采样时钟的几十分之一要么加大 ILA 的采样深度让它可以连续抓到多个周期再做事后分析。另外ILA 抓出来的数据默认是无符号视角显示。DDS 输出是有符号数如果你在波形窗口里没设置好有符号显示会看到一个正半周正常、负半周变成大正数的奇怪波形。这个不是数据错了是显示格式问题别在这一点上浪费时间。6.3 频率控制字实时更新时需要留意缓存机制DDS IP 核的频率控制字输入端口是带缓存功能的。在 AXI4-Stream 接口下频率控制字并不是立刻生效而是要等到相应的握手信号出现才被捕获。如果你在代码里把s_axis_phase_tvalid长期拉高那没问题新的频率控制字几乎立即生效。如果你是在特定时刻才发一个tvalid脉冲那就要确认脉冲宽度和时序否则可能出现频率控制字更新失败波形仍然是旧频率的情况。我在做动态扫频的时候踩过这个坑。当时我在一个状态机里想让 DDS 每隔 1 ms 换一个频率结果前三个频率正常到第四个就不变了。排查后发现是我的状态机在第四个跳转时tvalid只拉高了一个时钟周期而那个时钟周期恰好和 DDS 内部的背压信号冲突导致数据没写进去。解决办法很简单s_axis_phase_tvalid在频率控制字有效期间保持拉高而不是只给一个单周期脉冲。6.4 复位信号别忽略尤其是指数复位方式DDS IP 核的aresetn是低有效的异步复位平时要拉高只有在需要强制清零相位累加器时才拉低。如果你把它直接接到了系统的低有效复位信号上而系统复位信号在上电后因为外部原因抖动了一下DDS 的相位累加器被莫名清零输出波形可能会突然出现一个相位跳变。稳妥的做法是在 DDS 的复位输入前面加一个小的延时展宽电路或者直接用 FPGA 内部寄存器把复位信号打两拍再送进去。这个细节不会影响你正常跑通 Demo但等系统集成到真实设备里复位时序问题会让你排查到怀疑人生。7. 最后分享一点个人想法DDS 这个东西第一次用的时候觉得它只是一个“查表器”用得越多越觉得它是个精密的数字控制装置。相位累加器、频率控制字、相位截断、SFDR每一个参数背后都对应着真实的物理行为。这些概念在手写正弦波的时候很难真正体会但用 DDS IP 核时你会被迫去理解这些术语因为你要在配置界面里做选择。我现在最常做的操作就是在一个简单的 AXI 寄存器组里放几个频率控制字寄存器CPU 或者上位机通过串口往寄存器里写不同的值FPGA 里的 DDS 就能实时输出不同频率的正弦波整个过程稳定且可复现。以前这种功能我可能要写上百行 RTL维护起来还提心吊胆现在改成配置 IP 以后代码量少了一大半调试周期也短得多。如果你也打算在 Vivado 2023.2 里用 DDS IP 核做信号生成我的建议是先按系统参数模式跑通一个最小工程确认接口时序然后回头去看看它自动生成的硬件参数再改成手动模式把每个参数的含义用文档记下来。这个过程走一遍DDS 对你来说就不再是个黑盒子而是一个真正能随手调用的工具。