最近有个做信号采集的同事跑过来问我,说他在 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 核的功能差距
下面这个表格是我实际做过的两种方案的对比,可以直接拿去说服团队里“坚持手写”的同事:
| 对比项 | 手写正弦表 ROM | Xilinx 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 (1'b1), .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 kHz,DDS 就输出 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 对你来说就不再是个黑盒子,而是一个真正能随手调用的工具。