用Vitis HLS在FPGA上实现自定义DDS正弦波发生器
2026/9/9 14:41:24 网站建设 项目流程

简介:这是一份基于Vivado HLS生成DDS IP核的完整工程资料,面向FPGA开发与数字信号处理学习者,帮助理解DDS查表原理及HLS高层次综合流程,并附C仿真验证与集成思路。压缩包共254个文件,包含cpp/h源码、工程配置tcl/xml/v文件、仿真波形wcfg等,同时有大量log/rpt报告便于复盘综合过程,包体约32.55MB,结构完整可直接按目录逐层研习。资源内针对相位累加器、相位-幅度转换等关键模块给出了可运行示例,并展现了从项目初始化、C代码编写到IP核生成仿真的完整闭环,读者可借此掌握用高级语言快速设计FPGA IP核的实用方法。当前已有497人学习下载,适合具备基础C语言与FPGA常识、希望提升DDS实现效率的开发者参考。 最近有个项目里需要在FPGA上做一路可动态调频的正弦波发生器,第一反应是直接用Xilinx官方DDS Compiler IP,但需求比较特殊:不仅要输出正弦,后面还要叠加自定义的调相和幅度调制逻辑。折腾了一圈之后,我干脆绕过DDS Compiler,直接用Vivado里的HLS(Vitis HLS)自己写了一个DDS内核,导出成IP核再用到Vivado工程里。这篇文章就把这条完整的路走一遍,讲讲DDS的原理、HLS工程的搭建、C代码的写法、仿真验证和Vivado集成,最后整理一些我在实际使用中踩过的坑。不管你是刚接触HLS,还是想在工程里自己定制DDS,这篇应该都能给你一个能直接上手的参考。

1. DDS的原理与方案选型

1.1 相位累加与频率计算公式

DDS全称Direct Digital Synthesis,直接数字频率合成。在不纠结学术定义的前提下,DDS的实质就是“以固定时钟步进查表”,每一步累加一个频率控制字FCW(Frequency Control Word),然后把累加结果的高若干位作为查找表地址,输出对应的波形采样值。

这里有个核心公式尽量记牢:

[ f_{out} = \frac{FCW \cdot f_{clk}}{2^N} ]

其中N是相位累加器的位宽,(f_{clk})是系统时钟,FCW是频率控制字。反过来,已知目标频率,求FCW:

[ FCW = \frac{f_{out} \cdot 2^N}{f_{clk}} ]

比如我的工程里时钟用100MHz,相位累加器取32位,想输出1kHz的正弦波:

[ FCW = \frac{1000 \times 2^{32}}{100 \times 10^6} \approx 42949.67 ]

FCW必须是整数,取42950,实际输出频率就是约1000.008Hz,误差不到0.01Hz,完全够用。如果系统时钟是100MHz,32位累加器,频率分辨率理论上是 (100 \times 10^6 / 2^{32} \approx 0.0233)Hz,也就是说,频率调节的最小步进能做到0.02Hz级别,这是DDS相比传统模拟振荡器很大的优势,调的频率足够精细。

相位累加器的原理也简单:每个时钟周期,累加器寄存器加上FCW,累加结果的高10位作为1024点查找表的地址。查找表里存的是一个完整正弦周期的采样值,这样地址循环一圈,输出端就得到一个正弦波。整个结构没有反馈环路,不涉及稳定性问题,很符合FPGA这种并行流水的工作方式。

1.2 官方DDS Compiler、手写RTL和HLS怎么选

做DDS至少有三种路可走,各有各的适用场景,我做了个简单的对比:

实现方式优点缺点适用场景
官方DDS Compiler IP参数化配置,资源优化好,有相位抖动抑制等高级功能接口固定,定制逻辑还得外接;想加调相调幅逻辑不方便标准正弦/余弦输出,项目周期紧
手写RTL(Verilog/VHDL)完全可控,时序可精细调优,资源最直观开发周期长,调相位、加控制逻辑要写很多代码对时序要求极高、资源极其紧张的项目
HLS实现C/C++描述算法效率高,接口用AXI或自定义都可,迭代快综合后资源可能比手写RTL略多,需额外学习HLS约束算法较复杂,需要快速迭代和软件思维建模

我这次选择HLS,主要理由是:DDS后续要叠加调相、幅度调制甚至多路波形,用C++描述这些逻辑比写RTL快得多,而且HLS可以直接用hls::sinf或查找表初始化生成波形数据,省去手工生成ROM初始化文件的步骤。代价是资源稍微多一点,但对目前主流FPGA来说,这点开销可以接受。官方DDS Compiler虽然方便,但要把调相、自定义控制总线都塞进去,反而绕远路。

有一点得提醒:HLS并不是万金油,如果追求极限时序性能,或者做非常简单的逻辑,直接写RTL更合适。HLS擅长的是“算法密集、控制较复杂”的场景,DDS其实偏简单,但配合调相、多路输出和寄存器配置,HLS的价值就出来了。

2. HLS工程搭建与核心代码编写

2.1 工具版本与工程创建

我用的是Vivado 2020.1以后的版本,里面集成的是Vitis HLS。如果你用的是Vivado 2019.2及以前的版本,操作路径叫Vivado HLS,界面长得差不多,但细节有差异。Vitis HLS可以直接从Vivado的Tools菜单下启动,也可以独立打开。

创建工程的步骤也不复杂:

  1. 打开Vitis HLS,选择Create New Project。
  2. 填写工程名和路径。
  3. 设置顶层函数(Top Function),这里我填hls_dds
  4. 选择FPGA器件型号,要和你Vivado工程里的目标器件一致,否则导出的IP到Vivado那边可能不匹配。
  5. 加源文件和测试文件。

时钟周期我直接设为10ns,对应100MHz。HLS综合时以这个时钟约束为目标,最后生成的IP也是带这个频率约束的。

工程创建好以后,第一件事是把C语言的源文件和Testbench准备好。我习惯把公共常量放到头文件里,方便统一修改。

2.2 顶层C++代码:累加器、查找表、接口一气呵成

先给出完整的顶层代码,这是我调试通过的版本,参数都在头文件中定义。

// hls_dds.h #ifndef HLS_DDS_H #define HLS_DDS_H #include <ap_int.h> #include <hls_math.h> #define DDS_PHASE_WIDTH 32 #define DDS_LUT_DEPTH 1024 #define DDS_OUT_WIDTH 16 void hls_dds( ap_uint<DDS_PHASE_WIDTH> fcw, ap_int<DDS_OUT_WIDTH> &sine_out ); #endif
// hls_dds.cpp #include "hls_dds.h" void hls_dds( ap_uint<DDS_PHASE_WIDTH> fcw, ap_int<DDS_OUT_WIDTH> &sine_out ) { #pragma HLS INTERFACE ap_none port=fcw #pragma HLS INTERFACE ap_none port=sine_out #pragma HLS INTERFACE ap_ctrl_none port=return #pragma HLS PIPELINE II=1 static ap_uint<DDS_PHASE_WIDTH> phase_acc = 0; static ap_int<DDS_OUT_WIDTH> sine_lut[DDS_LUT_DEPTH]; static bool lut_init_done = false; if (!lut_init_done) { for (int i = 0; i < DDS_LUT_DEPTH; i++) { sine_lut[i] = (ap_int<DDS_OUT_WIDTH>)(hls::sinf(2 * M_PI * i / DDS_LUT_DEPTH) * 32767); } lut_init_done = true; } phase_acc += fcw; ap_uint<10> lut_addr = (ap_uint<10>)(phase_acc >> (DDS_PHASE_WIDTH - 10)); sine_out = sine_lut[lut_addr.to_uint()]; }

代码逻辑分三块。第一块是查找表初始化,用hls::sinf生成1024点的正弦表,量化到16位有符号数。这个初始化在综合时HLS会把循环内数据提前算好,固化成ROM内容,不会在硬件里真的去跑浮点正弦,这点可以放心。

第二块是相位累加,每个时钟周期执行phase_acc += fcw,这也是DDS最核心的语句。第三块是查表输出,取相位累加器的高10位做地址。

用高10位而不是低10位,这是个细节。因为相位累加器的高位对应的是相位的大致位置,取高位做地址可以理解为“先粗粒度查表”,低位自然被截断,等效于相位量化。如果取低10位,输出的波形顺序会完全错乱,因为低位的每次步进并不等于相同的相位增量。

2.3 接口约束与流水优化说明

HLS里接口约束直接决定IP核的外部信号长什么样。上面代码接口定义如下:

  • fcwap_none,意思是这个输入端口不带任何握手信号,纯数据输入,系统自动生成一个32位宽的输入端口。
  • sine_out同样用ap_none,输出端不带valid信号,每个时钟周期实时更新。
  • ap_ctrl_none port=return表示禁用块级握手,IP在上电后始终处于运行状态,不需要额外启动信号。

这种接口方式最简洁,非常适合在Block Design里接VIO或者ILA观察。

#pragma HLS PIPELINE II=1非常关键,它告诉HLS让主循环单周期流水化,目标间隔II(Initiation Interval)为1,也就是每个时钟周期都能处理一次累加和查表。如果不加这条指令,综合结果可能是多个周期才完成一次输出,DDS的输出更新率就会降下来,波形质量大打折扣。

实际综合完成后,我习惯先看资源报告。我这个设计在Zynq-7020上综合,查找表维度和输出位宽固定后,LUT消耗大概在200个以内,BRAM用一个或者零个(查表数组太小时HLS会直接用分布式RAM/LUT实现,不占BRAM),这个资源开销对大多数场景来说很友好。

接下来是Testbench。

// hls_dds_tb.cpp #include "hls_dds.h" #include <cstdio> #include <cmath> #include <fstream> int main() { ap_int<DDS_OUT_WIDTH> out_val; const ap_uint<DDS_PHASE_WIDTH> FCW = 42950; // 约1kHz @ 100MHz printf("FCW = %u\n", (unsigned int)FCW); std::ofstream outfile("dds_out.txt"); for (int i = 0; i < 2048; i++) { hls_dds(FCW, out_val); outfile << (int)out_val << "\n"; if (i < 10) { printf("sample %d: %d\n", i, (int)out_val); } } outfile.close(); printf("Simulation passed, waveform data written to dds_out.txt\n"); return 0; }

这个Testbench做了两件事:一是连续调用2048个时钟周期的hls_dds,把输出写入文件;二是在终端打印前10个采样点,方便快速确认输出量级。跑完C仿真后在工程目录下会生成dds_out.txt,我习惯直接用Python把这些数据画成波形图,确认正弦形状和周期数。

3. 仿真验证、IP导出与Vivado集成

3.1 C仿真与波形数据分析

在Vitis HLS的Flow Navigator里点C Simulation,选择C Testbench文件后直接运行。如果代码没有语法问题,仿真会很快结束,控制台打印出每个采样点的值。

仿真完成后,我在Python脚本里简单读取dds_out.txt,画一下输出波形。用2048个点,频率约为1kHz,时钟100MHz,理论上应该能看两个完整的正弦周期。实际跑下来波形很干净,峰值在±32766附近,没有溢出和削顶。

这里有个值得养成的习惯:别只看波形像个正弦就完了,要数一下周期数。频率对不对,一数周期就清楚。比如2048个点对应20.48微秒,1kHz信号应该有约0.02048个周期,也就是20个完整周期,如果周期数对不上,FCW计算就有问题。

3.2 C/RTL联合仿真与导出IP

C仿真只能验证算法逻辑,要确认综合出来的RTL行为和C一致,必须跑C/RTL联合仿真。在Vitis HLS里操作路径是:

  1. 在Flow Navigator中先执行Synthesis(C Synthesis),综合成功后会生成RTL。
  2. 再点击C/RTL Cosimulation,选择Verilog或VHDL,设置“Dump Trace”为all,方便看波形。
  3. 跑完后会生成协同仿真的波形文件,可以在Vitis HLS的Wave Viewer中查看。

联合仿真如果在时序上和C仿真有差异,常见原因一般是接口握手不匹配,或者初始化循环没有在预期周期内完成。我这次用ap_ctrl_none接口,没有块级握手,联合仿真直接就过了。

联合仿真通过后,接下来导出IP。在Vitis HLS里选Export RTL,或者Solution菜单下Export RTL,会弹出导出选项框:

  • 格式选Vivado IP (.zip),这是最通用的格式,Vivado直接认。
  • 或者在Output Format里选Vivado IP Catalog,它会自动把IP加入IP Catalog。

导出完成后,工程目录下会生成一个与你工程名同名的zip包,这个zip就是Vivado可识别的IP核。

3.3 在Vivado中创建块设计并接入VIO与ILA

到了这一步,HLS部分的使命基本完成了,接下来把IP核拿到Vivado里验证硬件行为。

我新建Vivado工程,器件选和HLS工程一致的型号。然后添加IP仓库:在Tools -> Settings -> IP -> Repository Manager里,把HLS导出zip所在目录加进去,或者直接点Add IP from Repository,选择导出的zip文件。

之后新建Block Design:

  1. 在IP Catalog里搜索hls_dds,双击添加。
  2. 添加一个VIOIP核,用来给fcw端口赋值。
  3. 添加一个ILAIP核,用来观察sine_out波形。

连线很简单:VIO的probe_out[31:0]接到hls_dds的fcw[31:0],hls_dds的sine_out[15:0]接到ILA的probe_in[15:0]。时钟统一连到Vivado自带的时钟模块输出100MHz,复位按常规接法。

这里有一步容易搞混:VIO的probe_out端口位宽要设置成32位,和fcw一致;ILA的probe_in位宽设置成16位,和sine_out一致。如果位宽对不上,连线会报错,或者数据采出来是乱的。

Block Design连完后,右键生成输出产品(Generate Output Products),然后综合、布局布线、生成比特流。下载到板子后,在硬件管理器里打开VIO面板,把fcw设为42950、429497、4294967这样几组值,ILA里就能看到正弦波频率明显变化。

我实测在100MHz时钟下,fcw=42950时输出约1kHz,fcw=429497时大约10kHz,观察到的波形干净、无明显毛刺,说明HLS综合出来的时序和预期一致。如果发现波形有较大的失真,优先怀疑查找表位宽不够,或者ILA采样时钟和数据时钟不同源。

4. 常见问题与排查技巧

4.1 查找表初始化与sinf综合问题

HLS最常见的一个坑就是初始化循环里用了普通C库的sin,而不是hls::sinf。如果用sin,在C仿真时一切正常,但综合时可能报错,或者生成一堆莫名其妙的浮点运算逻辑,资源占用暴涨。解决方法是统一使用hls::sinf,或者干脆预先生成一个.h文件,把1024个ROM初始化数据写成常量数组,这样既不依赖HLS的数学库,也能加快综合速度。

如果你实在不想在代码里用sinf,还有一种更稳妥的生成查找表方式:在自己的电脑上用Python/Matlab把正弦表算好,生成一个头文件或者.mif文件,然后在HLS里直接定义常量数组。我后来在另一个项目中就采用了这种方式,因为那一版波形是用户自定义的非标准曲线,用公式生成不如直接用采样点数组。

4.2 频率字位数和相位截断的影响

DDS相位累加器位宽N决定了频率分辨率,N越大越好。查找表地址位宽M决定了相位量化噪声,M越大,波形越平滑,但查找表深度也越大。

实际中这两者有个平衡:我上面的代码里N=32,M=10,也就是查找表1024点。输出16位幅度量化。最终输出端的无杂散动态范围(SFDR)大约在60dBc左右,对大多数信号发生器场景够用。如果你的项目对SFDR要求更高,比如仪器仪表级别的80dBc以上,就需要加大查找表深度,或者增加相位抖动(dithering)处理。这个在HLS里也可以做,加一个伪随机扰动到相位累加器低位再查表,能显著改善杂散,但会稍微增加一点逻辑资源。

4.3 IP集成后常见的Link错误与无波形问题

在Vivado Block Design里,有时候添加HLS导出的IP后,Validate Design会报“undriven net”之类的错,通常是ap_clkap_rst_n没有连上。HLS生成的IP默认一定有这两个端口,哪怕你的设计里没有显式使用复位,IP也带一个复位输入。连接方法很简单,把ap_clk接到时钟源,ap_rst_n接到全局复位或者常量1(如果不需要复位)。

另外,如果在ILA中一直采不到正弦波,但仿真正常,优先检查VIO里fcw是不是真的赋值了。VIO上电后的默认值可能是0,fcw=0时输出恒为0,看起来就像没有波形。这不算故障,只是初始化值没设对。我一般会在VIO面板里把fcw的默认值先设成一个实际频率对应的值,这样上电就能看到波形。

4.4 常见问题速查表

整理一份我自己常用的排查清单:

现象可能原因解决方法
C仿真输出全为0fcw输入为0检查Testbench里FCW初值
综合后LUT资源异常大初始化循环里用了浮点三角函数改用常量数组或hls::sinf
C/RTL联合仿真波形和C不一致ap_vld/ap_rdy接口时序不匹配改成ap_none;或正确等待握手信号
Vivado里找不到HLS IP仓库路径没添加对检查zip是否完整,重新Add Repository
ILA采不到波形VIO默认值0设置非零FCW初值
波形有台阶感查找表深度太小增大DDS_LUT_DEPTH
输出频率偏差大FCW四舍五入误差用更高位宽累加器,或算FCW时向上取值

4.5 关于AXI-Lite配置方案的补充

上面的方案里fcw是纯输入端口,适合在Block Design里接VIO或者上级逻辑直接赋值。如果你的系统需要用CPU或者MicroBlaze在线改频率,建议把接口改成AXI-Lite,这样fcw就变成一个寄存器,可以通过总线直接读写。

改法也不复杂,顶层函数这样写就行:

void hls_dds_axi( ap_uint<DDS_PHASE_WIDTH> fcw, ap_int<DDS_OUT_WIDTH> &sine_out ) { #pragma HLS INTERFACE s_axilite port=fcw #pragma HLS INTERFACE ap_none port=sine_out #pragma HLS INTERFACE ap_ctrl_none port=return ... }

综合后HLS会自动生成一个hls_dds_axi_s_axi接口,包含awaddrwdata等AXI-Lite信号,地址偏移4就对应fcw。在Block Design里把这个接口连到Zynq的M_AXI_GP0或者MicroBlaze的AXI总线上,就能软件写寄存器改频率了。

我当时实际项目正好是Zynq平台,后续就是这么做的,软件端写一个驱动函数,往寄存器地址写FCW,硬件端立刻生效,实测在Linux下用ioctl调用,延迟在微秒级别以内,完全满足实时扫频需求。

最后分享一点体会

把HLS做的DDS IP核真正跑在板子上之后,最大的感受是:HLS这套流程对于“带算法的信号处理模块”确实能显著缩短开发周期,把精力集中在算法本身,而不是纠结每个时钟周期的信号翻转。DDS看起来是个很简单的东西,但用HLS做一遍,等于把相位累加、查表、接口握手、仿真验证这些基本功全过了一遍,对后面用HLS做FFT、滤波、调制解调这些更复杂的模块非常有帮助。

如果你正准备上手,建议别直接抄代码,而是手动敲一遍,然后自己改一改查找表深度、位宽、输出格式,观察波形和资源的变化。这样操作一轮下来,对DDS和HLS的理解会比看十篇博文都深刻。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询