ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用HLS在Vivado中实现自定义DDS:从原理到IP核集成

用HLS在Vivado中实现自定义DDS:从原理到IP核集成 简介面向FPGA开发者和数字信号处理工程师这份资料围绕用Vivado HLS生成DDS IP核的完整流程结合相位累加器、查表法、数字调制三大核心模块详细讲解了从C/C算法建模、HLS优化注释、C仿真到IP封装、再导入Vivado完成系统集成的实践路径适合需要快速实现高精度波形发生器并在通信、测试测量、雷达等场景落地的中高级开发者。资料包共254个文件、约32.55MB以cpp/h源码、tcl工程脚本、v/vhd硬件封装、dat波形数据、log仿真日志等类型为主同时保留HLS工程目录、basicwave结果文件及多轮仿真记录便于对照工程结构验证输出波形与排查问题。已有497人学习下载借助其中可编辑的HLS工程和完整仿真流程读者能直接上手复现dds_gen掌握DDS IP核生成时的频率控制字、相位累加器位宽、查表深度等关键参数配置与排错思路。 如果你在Vivado里做过信号产生相关的东西大概率见过DDS Compiler这个IP核的配置界面——一个窗口里挤满了通道数、相位宽度、频率分辨率、SFDR、输出位宽……选项多得让人头皮发麻。我之前有个项目需要在Zynq上产生一路可实时变频的I/Q正弦信号终于在第三次被DDS Compiler的配置界面劝退后决定换一条路用HLS直接写一个DDS再导出成IP核。这篇文章就是这次实践的完整记录内容包括DDS原理、HLS代码、仿真验证、IP核集成和实测资源数据适合在Vivado里做信号处理、又不想被传统IP核配置流程捆住手脚的工程师和学生参考。1. 为什么我会用HLS去折腾DDS从“调IP核界面”到“写几行C代码”1.1 传统DDS Compiler的配置痛点提到DDS大多数人的第一反应是打开Vivado找到DDS Compiler IP核。这个IP核确实成熟Xilinx在它上面下了很多功夫支持多通道、支持相位抖动、支持可编程频率和相位SFDR可以做到很高。但对实际项目来说它有个不太友好的地方——配置项太多太细。我记得第一次用DDS Compiler时光搞明白“Phase Increment Programmability”和“Phase Offset Programmability”的区别就花了不少时间。如果选择Fixed模式生成之后要想改频率就非常痛苦要么重新生成IP核要么把这个IP核配置成可编程模式再接一堆AXI-Lite寄存器。这个过程中你还要手动去算频率控制字FTW和相位偏移量一旦算错波形频率跟预期差得十万八千里。更麻烦的是DDS Compiler生成的RTL是一个黑盒除了在界面里配置参数你几乎没法对内部逻辑做定制。比如我想在输出正弦波的同时输出一个可变的直流偏置或者做扫频输出用DDS Compiler就很难优雅地实现。HLS则完全不同——DDS的核心逻辑就是几十行C代码想加什么功能直接在代码里改然后再综合成IP核。1.2 什么场景下HLS方案真正值得用并不是所有DDS应用都适合用HLS。如果你是做高速信号处理比如DAC工作在1GSPS以上对时序和资源极度敏感那还是乖乖用DDS Compiler毕竟那是经过手工优化过的RTL。HLS DDS真正适合的场景有几个特征系统时钟在100~200MHz量级DDS输出速率没到GHz级别需要灵活的定制功能比如扫频、调幅、多波形、自定义查找表项目里既有熟悉RTL的工程师也有熟悉C/C的工程师维护C代码比维护RTL黑盒更容易希望快速迭代参数比如把查找表地址宽度从10位改成14位C代码里改一个宏就行而DDS Compiler可能要把整个IP核重新配置一遍。我这次的需求就是在Zynq-7020上生成一对I/Q正弦波频率需要在1MHz~50MHz之间可调输出位宽16位系统时钟100MHz。用HLS来写从零到导出IP核只花了半天。1.3 整体工作流程顺带说一下HLS DDS的完整流程让没接触过HLS的读者有个全局概念在Vivado HLS里新建工程编写C/C源码和testbench运行C仿真验证算法逻辑综合Synthesis查看资源报告和时序报告运行C/RTL协同仿真确认RTL行为和C模型一致导出RTL为IP核Export RTL在Vivado里把IP核加入Block Design连接时钟和AXI接口综合、实现、上板调试用ILA观察波形。后面几节我会按这个流程把每个阶段的关键点拆开讲尤其是那些文档里不会写的坑。2. DDS原理与HLS代码映射相位累加器、截断和查找表2.1 DDS核心公式与参数设计DDS的全称是直接数字频率合成Direct Digital Synthesis。它的核心思想很简单用数字方式产生一个相位随时间线性增长的序列然后通过查找表把相位映射成幅度。一个典型的DDS包含N位相位累加器每个时钟周期累加一次频率控制字FTW相位截断从N位相位中取高P位作为查找表地址波形查找表存储一个完整周期的正弦波采样值DAC或直接输出数字样本。输出频率公式是f_out FTW × f_clk / 2^N频率分辨率为Δf f_clk / 2^N举个例子。假设f_clk 100MHzN 32位要产生10MHz正弦波那么FTW 10e6 × 2^32 / 100e6 ≈ 429496730换算成十六进制就是0x1999999A。这个值用C语言、Python或者Matlab随手就能算出来不用像DDS Compiler那样在界面里小心翼翼地填。查找表深度由相位截断宽度P决定表深度 2^P。P越大表的点数越多ROM资源消耗越大但相位截断引入的杂散越小。经验上相位截断P位时最坏情况的SFDR大约有6.02P dBc。比如P12时理论SFDR约72dBc对于大多数中频信号产生场景已经够用。输出数据位宽B为16位时幅度量化本身带来的极限SFDR约为6.02B 1.76 ≈ 98dB所以12位地址的相位截断通常是主要的杂散来源。这里有个权衡点如果你很在意SFDR可以把查找表地址宽度加到14甚至16位。4096点的正弦表在BRAM里也就占一点点资源但如果P超过14往往就要用两个BRAM性价比会下降。具体怎么选取决于你的系统链路对杂散的要求。2.2 查找表的工程化实现别手写4096个常量HLS里最直接的查找表做法是在函数里定义一个static数组比如static ap_int16 sin_lut[LUT_DEPTH] { /* 谁手写4096个数 */ };先不说手写这些常量值有多痛苦就算你从某处复制了一个4096点的正弦表想要改成2048点或者换成一个带相位调制的表又要重新生成一遍。我的做法是在工程里用一个小脚本生成查找表头文件然后在HLS源码里引用。用Python生成查找表import math depth 1 12 # 4096点 bits 16 # 输出位宽 with open(sin_lut.h, w) as f: f.write(#ifndef SIN_LUT_H\n#define SIN_LUT_H\n\n) f.write(#include ap_int.h\n\n) f.write(f#define LUT_DEPTH {depth}\n\n) f.write(const ap_int16 SIN_LUT[LUT_DEPTH] {\n) for i in range(depth): val int(round(32767.0 * math.sin(2.0 * math.pi * i / depth))) f.write(f {val},\n) f.write(};\n#endif\n)注意这里输出用16位有符号数最大值32767最小值-32768。生成的头文件放到HLS工程的source目录下然后在DDS源码里#include sin_lut.h。以后要改查找表点数、位宽、波形形状只需要改脚本重新生成一次就行。这个习惯我在做任意波形发生器时帮了大忙——想生成三角波、锯齿波只是改一下val那行的公式而已。在HLS综合时const ap_int16 SIN_LUT[LUT_DEPTH]会被推断成只读ROM。这一点很重要如果数组不是constHLS可能会综合成带读写端口的RAM白白增加资源。所以查找表务必声明为const。2.3 完整HLS代码与接口设计下面是我实际用的DDS HLS代码删掉了项目相关的一些业务逻辑保留核心。#include hls_stream.h #include ap_int.h #include sin_lut.h #define PHASE_WIDTH 32 #define ADDR_WIDTH 12 void dds_hls( ap_uintPHASE_WIDTH ftw, // 频率控制字 hls::streamap_int16 out_sin, // 正弦输出AXI-Stream hls::streamap_int16 out_cos // 余弦输出AXI-Stream ) { #pragma HLS INTERFACE s_axilite portftw #pragma HLS INTERFACE axis portout_sin #pragma HLS INTERFACE axis portout_cos #pragma HLS INTERFACE ap_ctrl_none portreturn #pragma HLS PIPELINE II1 static ap_uintPHASE_WIDTH phase_acc 0; // 相位累加每个时钟周期累加一次FTW phase_acc ftw; // 相位截断取高12位作为查找表地址 ap_uintADDR_WIDTH addr phase_acc.range(PHASE_WIDTH - 1, PHASE_WIDTH - ADDR_WIDTH); // 正弦查表输出 out_sin.write(SIN_LUT[addr]); // 余弦输出正弦值相位偏移90度表长的四分之一 ap_uintADDR_WIDTH cos_addr addr (LUT_DEPTH 2); out_cos.write(SIN_LUT[cos_addr.range(ADDR_WIDTH - 1, 0)]); }这段代码有几个关键设计点我一个个说。第一相位累加器用static变量表示这是一个跨时钟周期保持的寄存器。HLS综合时它会变成一个32位寄存器每个时钟上升沿执行一次累加。注意#pragma HLS PIPELINE II1保证了累加和查表合并在一个时钟周期内完成也就是每个周期都能输出一个样本。第二FTW接口使用s_axilite也就是挂到AXI-Lite总线上。这样在Zynq里PS端只要往对应寄存器写一个数就能改变输出频率不用重新生成IP核。相比DDS Compiler的固定模式这种方式在系统联调时太方便了。如果你用的是纯FPGA没有ARM也可以把s_axilite换成ap_vld握手输入或者直接用#pragma HLS INTERFACE ap_none portftw把FTW变成普通输入。第三余弦输出不需要单独存一份余弦表只要在正弦表地址上加上表长的四分之一就是相位偏移90度的余弦值。注意这里做了一次range截位防止地址越界。这个技巧能让查找表ROM用量减半。提示在ap_ctrl_none模式下这个IP核上电后就开始自由运行不需要任何启动信号。如果你在Block Design里发现输出一直为0先检查AXI-Stream的tready信号是否一直被拉高。ILA里看到tvalid拉高但没有tready说明下游消费能力不足。3. 从C仿真到RTL协同仿真验证链路里最容易翻车的几个环节3.1 C仿真先确认算法和定点精度在写HLS代码时每改一次逻辑我通常都会先跑一遍C仿真。C仿真的目的不是验证时序而是验证行为逻辑。对于DDS来说C仿真要确认这么几件事给定FTW输出的正弦波频率是否符合预期相位累加是否连续没出现跳变或回绕错误16位定点输出是否有削顶、溢出余弦和正弦是否严格正交相位差90度。C仿真的testbench很简单初始化一个FTW调用dds_hls循环跑个几千次把输出写到文件里然后用Python读出来看波形和频谱。一个典型的testbench结构#include dds_hls.h #include fstream int main() { ap_uint32 ftw 0x1999999A; // 100MHz时钟下约10MHz hls::streamap_int16 out_sin; hls::streamap_int16 out_cos; std::ofstream sin_file(sin_out.dat); std::ofstream cos_file(cos_out.dat); for (int i 0; i 4096; i) { dds_hls(ftw, out_sin, out_cos); sin_file out_sin.read() std::endl; cos_file out_cos.read() std::endl; } return 0; }这里有个容易踩的坑hls::stream在C仿真里是有深度的默认深度是2。如果函数内部往stream里write但testbench不及时read写满之后函数就会阻塞。好在这个testbench每个周期都read一次不会触发阻塞。但如果实际HLS代码里一个周期往同一个stream写了两次而我只read一次C仿真就会卡死这个现象我最初排查了很久后来才意识到是stream阻塞了。C仿真跑完后用Python做FFTimport numpy as np import matplotlib.pyplot as plt data np.loadtxt(sin_out.dat, dtypenp.int16) N 4096 win np.hanning(N) spectrum np.fft.rfft(data * win) mag_db 20 * np.log10(np.abs(spectrum) / N) plt.plot(mag_db) plt.ylim(-120, 0) plt.show()这一步能直观地看到SFDR是否和理论估算一致。之前我在C仿真阶段发现输出频谱在某个频点出现了一个明显的杂散反复检查发现是查找表头文件里生成的最后一个采样点带了舍入误差修正生成脚本后频谱就干净了。这种问题如果在RTL阶段才发现排查成本会高很多。3.2 C/RTL协同仿真接口时序与II1才是重头戏C仿真通过之后接下来是C/RTL协同仿真。在Vivado HLS的Solution菜单下选择“Run C/RTL Co-simulation”会弹出配置窗口需要选择RTL仿真器Vivado自带Simulator即可、设置时钟周期我设为10ns以及Testbench里的dut函数名。协同仿真本身不难真正容易翻车的往往是接口时序问题。比如我最初把FTW接口设计成了ap_vld握手输入然后在C仿真里每周期都给FTW赋同一个值C仿真正常但协同仿真时RTL模型在tvalid/tready握手过程中会插入等待周期导致输出样本并不是每周期一个下游FFT处理就乱了。后来我改用s_axilite寄存器方式配置FTWFTW只在配置时写入一次DDS内部自由运行握手的不确定性就完全消失了。这也是我把接口定为s_axilite的原因之一。写代码时多想想接口握手的时序比出了问题再去抓波形省事得多。另一个协同仿真常踩的坑是HLS综合时如果用了#pragma HLS PIPELINE II1协同仿真工具会自动生成一个循环驱动的testbenchII1约束不满足时仿真会直接报“Unable to schedule loop”或“II constraint violated”。如果遇到这个报错先降低II约束或者把相位累加和查找表拆到两个流水级里。对于100MHz时钟的Zynq-702012位地址的查找表加上32位累加II1通常是可以满足的。3.3 仿真数据导入Python做频谱分析协同仿真结束后HLS会把RTL仿真产生的波形数据写出来。我习惯在testbench里用fprintf把数据导出到文本文件然后复用上面的Python脚本做频谱分析。这一步是为了确认RTL输出和C仿真的频谱一致性。如果两者频谱差异很大基本可以断定接口握手或数据位宽映射出了问题。我还遇到过一种情况C仿真频谱正常协同仿真频谱正常但上板用ILA抓出来的数据却不对。最后发现是Block Design里AXI-Stream数据用ap_int16而ILA的probe宽度设成了16位但高位和低位的字节序没有对上导致看起来波形像噪声。ILA里看到的数据字节序问题是很多人容易忽略的后面我会专门提。4. 导出IP核并接入Vivado块设计AXI-Stream与AXI-Lite的配合4.1 导出配置接口选项和控制协议怎么选在综合和协同仿真都通过后选择“Export RTL”将HLS工程导出为IP核。导出时有两个选项值得注意一是输出格式选“Vivado IP (.zip)”还是“System Generator for DSP”我们选前者二是“Display SystemC”之类的不需要动保持默认即可。导出后的IP核会出现在Vivado的IP Catalog里路径以你HLS工程名命名。这里有一个小经验如果你的HLS工程用了hls::stream作为AXI-Stream接口导出时HLS会为每个stream生成一个对应的AXI-Stream master接口tdata宽度由ap_int16决定正好是16位。在Vivado里例化这个IP时接口名字可能会带上一串自动生成的后缀比如m_axis_out_sin之类的记下这些接口名后面连Block Design时要用。关于控制协议我在代码里用了ap_ctrl_none导出后IP核不会出现ap_start、ap_done这些控制端口上电就运行。如果你在代码里没有设ap_ctrl_noneHLS默认会生成ap_ctrl_hs也就是需要外部拉高ap_start才开始工作。在Block Design里如果你懒得接ap_start可以把它用Constant IP强制拉高。但更干净的做法是一开始就在代码里指定ap_ctrl_none。4.2 Block Design集成与ILA调试在Vivado里新建Block Design添加这个DDS IP核。因为FTW接口是s_axiliteBlock Design会自动要求你连接一个AXI-Lite主接口。在Zynq里通常是PS的M_AXI_GP0在纯FPGA里可以用AXI GPIO或者MicroBlaze来配置。AXI-Stream输出则直接接ILAIntegrated Logic Analyzer就可以抓波形。ILA的probe位宽要设置成16位时钟接DDS的aclk也就是100MHz系统时钟。上板之后用ILA抓取out_sin和out_cos应该能看到相位正交的两路正弦波。如果看不到波形按这个顺序排查确认复位释放aclk引脚上有时钟确认FTW寄存器不是0。Zynq里PS写寄存器时要对照HLS导出的地址映射。HLS的s_axilite默认从0x00开始分配寄存器ftw通常在偏移0x10的位置具体以导出时生成的地址映射文档为准确认AXI-Stream的tready为高。HLS的AXI-Stream master会等待tready如果下游没准备好tvalid可能周期性地拉高拉低看起来像有数据但不像连续正弦波。这里要特别强调一下地址偏移这个问题。我最初在PS里往地址0x00写FTW结果波形纹丝不动。后来翻ip_name_s_axilite.c的驱动代码才发现寄存器偏移并不是我想当然的0x00。不同版本的HLS对AXI-Lite寄存器的分配策略略有差异最快的确认方法就是找到导出的驱动文件一个个对应好再写PS代码。4.3 资源占用与信号质量实测对比最后列一下我这版的资源占用情况器件是Zynq-7020时钟约束100MHz查找表4096点×16位资源HLS DDS双输出Xilinx DDS Compiler双输出LUT186142FF214173BRAM_18K22DSP00数据是通过Vivado综合报告统计的HLS版本2020.1。可以看到HLS版本比DDS Compiler多消耗一些LUT和FF但差异不大。BRAM都是2个18K块因为4096×16位的ROM正好塞不满一个36K BRAM用了两个18K。如果你把查找表点数改成2048点BRAM通常能降到1个。信号质量方面我在100MHz时钟下用FTW对应10MHz输出做了FFT分析实测SFDR约68dBc比理论估算的72dBc略低主要是PCB噪声和ILA量化造成的。对于这个量级的信号产生需求完全够用。顺便提一下HLS DDS吃不吃DSP资源取决于你怎么写查找表。如果查找表用const数组直接用ROM不吃DSP如果你用hls::sin库函数HLS会把它综合成CORDIC算法LUT消耗会明显上升但BRAM会省下来。我的建议是固定波形优先用ROM查找表需要动态计算任意波形的场合才考虑CORDIC。5. 几点经验和后续可以继续玩的方向5.1 踩坑清单整理把这次实践里最值得记住的几个点集中整理一下查找表一定要用const数组否则HLS可能推断出RAM浪费资源大规模正弦表用脚本生成别手工填常量改参数时你会感谢自己FTW这种运行时要改的配置用s_axilite寄存器比ap_vld握手更稳定连续流输出用hls::streamap_int16如果需要TLAST带帧边界要改成hls::axisap_axiu...类型上板前先用C仿真加Python做频谱一致性检查别直接抓ILAPS写FTW前先查导出的寄存器地址映射别想当然用偏移0x00。5.2 后续可以扩展的方向这次的DDS只是HLS在信号处理里的一个起点。如果你想继续深挖可以试着在DDS外面包一层扫频逻辑用代码实现线性调频信号chirp或者把查找表换成任意波形表做AWG也可以在输出链路加一个半带滤波器改善带外杂散。我个人下一步打算把相位累加器宽度从32位改成48位提高频率分辨率同时在查找表输出后级联一个CIC滤波器做插值把DDS的输出速率提升到400MHz。这些改动在RTL里想想就头大但在HLS里就是改几行代码的事。本文还有配套的精品资源点击获取
返回列表