ARTICLE DETAIL

资讯详情

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

用Vivado HLS手写DDS IP核:从原理到集成实战

用Vivado HLS手写DDS IP核:从原理到集成实战 简介面向数字信号处理与FPGA开发者的DDS IP核实现资料对应Vivado HLS开发流程解决从C语言算法到可复用硬件IP的转换需求适合通信、测试测量与雷达等需要灵活生成正弦波、方波的场景。资源共254个文件压缩包约32.55MB具体包括24个dat数据、22个tcl脚本、19个cpp源文件、18个v文件、17个xml配置以及h、vhd、log、txt等工程辅助文件其中basicwave相关数据可用于波形验证整体目录结构清晰完整反映HLS工程从源码到仿真、封装的流程。已有497人学习。跟着该资料可掌握DDS相位累加器、查表与调制器设计学会编写dds_gen函数、配置HLS优化指令、完成C仿真并封装为IP核再集成到Vivado顶层设计包内文件命名规范适合初次接触高层次综合的开发者边看源码边核验波形减少环境配置与排错时间也可作为快速生成自定义DDS核的参考。 搞FPGA的兄弟应该都遇到过这个需求板子上需要一个可动态变频的正弦信号源第一反应是拖一个Xilinx官方的DDS IP核进来。但实际用下来你会发现官方DDS配置界面虽然友好可一旦你要把变频、调制、滤波跟业务逻辑耦合在一起或者想多通道相参输出它就不够灵活了。我最近一个项目干脆用Vivado HLS手写了一个DDS IP核效果还不错这篇直接说说我的做法和踩过的坑。先说结论用HLS生成DDS不是炫技而是为了把信号生成算法跟后续处理放在同一个C/C模型里快速迭代。整个过程从原理推导、HLS工程搭建、C综合到Vivado集成一套流程走完也就半天时间。适合有一定HLS基础、想提高算法迭代效率的FPGA开发者也适合被官方DDS IP的接口和时序搞得头大的朋友。1. 为什么我不用Xilinx自带DDS IP偏要自己用HLS生成1.1 官方DDS IP核的痛点Xilinx官方DDS Compiler确实是好东西相位累加器、查找表、SFDR优化这些都帮你处理好了GUI里选一下参数就能出RTL。但用多了你会发现几个别扭的地方一是它本质上是个“黑盒”。你只能通过配置界面设定频率字、相位偏移这些参数没法在信号生成的同时把幅度调制、频率跳变逻辑、多路相位同步这些东西直接揉进去。二是当系统里同时需要多个DDS且每个DDS的输出要跟后续FIR滤波器、混频器联动时官方IP一个个例化、一个个接握手信号工程会变得很啰嗦。1.2 HLS生成DDS的实际优势HLS全称High-Level Synthesis允许用C/C描述算法然后自动生成RTL。放到DDS这个场景里最直观的好处是算法代码可控DDS输出之后可以直接在同一个函数里加FIR、加AM/FM调制生成一个“多功能信号处理IP”不用在Block Design里把好几个IP串起来。频率字、相位字、波形查找表全都变成了C代码里的变量和数组改起来就是改个参数重新综合一次就行。接口风格统一导出后就是一个带AXI-Stream或者AXI-Lite接口的标准IP跟Xilinx官方IP一样可以拖进Block Design。当然代价也有比如资源占用通常比手写RTL高一些时序也要靠pragma去优化。但如果你的目标是快速做成一个能用的信号处理IPHLS的性价比非常高。1.3 什么样的项目适合用HLS做DDS我个人的判断标准很简单如果需求是“一个频率固定的正弦波”直接用官方DDS如果需求是“很多路信号频率要动态变、波形要能换、还要跟业务逻辑联动”那就老老实实用HLS。尤其是做雷达信号模拟、通信基带原型验证这类项目算法迭代快、需求变动频繁HLS这套流程能帮你省掉大量改RTL重仿真的时间。2. DDS核心参数计算频率字、相位位宽和SFDR2.1 相位累加器原理和频率字推导DDS最核心的结构就两部分相位累加器和波形映射。每个时钟周期相位累加器加上一个频率控制字FTWFrequency Tuning Word累加结果的高位去索引正弦表输出对应的波形幅度。输出频率的公式就一行f_out (FTW × f_clk) / 2^N其中N是相位累加器位宽f_clk是系统时钟频率。这个公式要刻在脑子里后面所有参数都是从它推出来的。举个例子假设系统时钟125MHz想产生1MHz正弦波相位累加器位宽N32那么FTW (1e6 × 2^32) / 125e6 ≈ 34359738计算过程不复杂2^32 4294967296乘以1e6得到4.294967296e15再除以125e6结果34359738.368取整34359738。频率分辨率则是f_clk / 2^N也就是大约0.0291Hz足够精细。2.2 相位位宽和查找表深度怎么取舍工程上不可能把32位相位全部拿去做查找表索引那样需要2^32个存储单元显然不现实。通常只取高10到14位去查表这就是“相位截断”。相位截断会引入杂散直接影响无杂散动态范围也就是SFDR。这里有个实用经验查找表深度M决定了SFDR的理论下限粗略估算SFDR ≈ 6.02 × M dB。比如取12位地址、4096点正弦表理论上SFDR能到72dB左右。如果你的系统对杂散要求高可以加大表深或者用“相位抖动”技术摊平杂散。我实际做的时候考虑到HLS里数组会被综合成BRAM4096点的查找表大概占用1到2块BRAM比较划算。如果你把整个32位相位全部喂给查询逻辑不仅BRAM爆炸时序也很难收敛完全没有必要。2.3 用查表还是用CORDICHLS里实现正弦映射有两种常见方案直接在C代码里放一个const数组做查找表或者调用hls::sin。查表法BRAM占用大但延迟低hls::sin在综合时会变成CORDIC迭代结构用LUT/DSP换BRAM延迟稍大但资源更均衡。我的做法是先期验证用hls::sin让流程快速跑通等到系统指标稳定后如果BRAM余量充足再考虑改成查表法。CORDIC的好处是相位精度和幅度精度可以放宽到32位不会出现相位截断杂散因为每一拍都在迭代计算不依赖存储深度。3. Vivado HLS工程搭建与C代码实现3.1 工程版本和新建工程操作我这里使用的是Vivado HLS 2019.2后来的Vitis HLS流程也基本一致。新建工程的步骤很简单File - New Project名字随意顶层函数填dds_hls添加源文件时钟周期按实际系统时钟填目标器件选你板子对应的型号。这里有个小提醒如果目标平台是Zynq器件型号一定要选对否则导出的IP在Vivado里综合时可能出现器件不匹配的问题。另外工程创建后第一件事是把HLS的solution设成你的主方案避免默认的solution名字造成后续导出混淆。3.2 顶层函数和接口设计DDS IP核最关键的是两个接口频率控制字输入我希望它可配置和波形数据输出要走AXI-Stream方便跟后续处理对接。我给出的顶层函数是这样的#include ap_fixed.h #include ap_int.h #include hls_math.h #include hls_stream.h #define PHASE_WIDTH 32 void dds_hls( ap_uintPHASE_WIDTH phase_inc, hls::streamap_fixed16, 1 out ) { #pragma HLS INTERFACE modeap_none portphase_inc #pragma HLS INTERFACE modeaxis portout #pragma HLS INTERFACE modeap_ctrl_none portreturn #pragma HLS PIPELINE II1 static ap_uintPHASE_WIDTH phase_acc 0; phase_acc phase_inc; float phase_float (float)phase_acc * 6.28318530718f / 4294967296.0f; float sin_float hls::sin(phase_float); ap_fixed16, 1 sin_val (ap_fixed16, 1)sin_float; out.write(sin_val); }几个关键点说一下phase_inc我用ap_uint32无符号定点数正好对应DDS的频率控制字。这里我故意用ap_none接口而不是s_axilite是因为在Block Design里固定频率配置时直接拿Constant IP驱动这个端口就行省去挂AXI-Lite总线的麻烦。如果你要动态配置频率后面我会说怎么改成s_axilite。相位累加器phase_acc是static变量HLS会把它综合成一个寄存器每个时钟周期加一次phase_inc这就是DDS最核心的相位累加逻辑。输出数据用ap_fixed16,116位总位宽整数位1位取值范围[-1,1)覆盖正弦波的幅值范围绰绰有余。之所以不用float直接输出是因为AXI-Stream上跑浮点数据在Vivado里要额外处理定点数据更实用而且综合资源省不少。最后一行out.write(sin_val)收到数据端的tready之后才会真正写入HLS会自动处理好AXI-Stream的tvalid/tready时序。3.3 为什么这里要用CORDIC而不是查表hls::sin在综合时会生成一个CORDIC运算单元好处是相位精度可以吃满32位不丢失低位信息理论上没有相位截断杂散。坏处是每次计算sin需要若干级流水延迟比查表高而且会消耗不少LUT。当你加了#pragma HLS PIPELINE II1之后HLS会尽量让每个时钟周期都能进入一个新的数据也就是吞吐率做到1拍1个点。第一次综合时如果发现II不是1通常是因为CORDIC内部迭代链太长HLS找不到足够的资源并行展开这时候可以适当放宽流水线目标或者考虑查表法。我在实际项目里先用的CORDIC数据率不高所以没遇到瓶颈。3.4 写一个简单的C仿真测试平台写完顶层函数后一定要先在HLS里做C仿真别急着综合。测试平台很简单调用1024次把输出打出来看是不是正弦波#include hls_stream.h #include ap_fixed.h #include cstdio int main() { hls::streamap_fixed16, 1 out; ap_uint32 ftw 34359738; // 125MHz时钟下产生1MHz for (int i 0; i 1024; i) { dds_hls(ftw, out); ap_fixed16, 1 sample out.read(); printf(%f\n, (float)sample); } return 0; }跑完之后把打印出来的数据复制到Python或者Matlab里画个图确认是正弦且周期正确。如果输出恒定、频率不对多半是FTW算错了或者相位累加器位宽没对上。C仿真发现问题的成本远比综合后RTL仿真低这一步真别省。4. 综合报告解读与IP导出注意这几个坑4.1 综合报告看什么在HLS里点击C Synthesis跑综合完成后重点看两样东西Estimated Resources和Latency/Interval。我第一次用这段代码综合时资源占用大概是LUT两千多、FF一千多、DSP几个BRAM为0。CORDIC方案确实不用BRAM但LUT消耗比查表法高。Interval如果是1说明每拍都能输出一个点这个性能用来做普通信号源完全够用。如果资源超出预期优先检查是不是hls::sin被展开了很多份。HLS有时候会为了并行度复制CORDIC核心这时候可以通过降低流水线目标或者手动实例化一个sin模块来限制单元数量。另外如果发现BRAM占用很高八成是你代码里写了很大的数组并且没有加partition pragma这个在查表法方案里尤其明显。4.2 导出IP的正确姿势综合通过后点击Export RTLFormat选IP CatalogHLS会生成一个Vivado可以直接识别的IP目录。导出时有个选项叫“Evaluation”生产使用记得取消勾选否则IP会在硬件上定时锁死。我头一回导出手贱勾了结果板子跑几分钟输出就停了排查了半天才发现是这个评估模式搞的。导出完成后在Vivado的IP Catalog里搜你的IP名字就能直接加进Block Design。注意版本匹配Vivado HLS 2019.2导出的IP在Vivado 2019.2里用肯定没问题拿到新版Vivado里一般也兼容但极少数时候会提示需要rebuild IP直接照做就行。4.3 关于快速验证的另一个小技巧如果你只是想在Vivado里快速看波形不想开Block Design可以直接把导出的IP实例化在顶层RTL里端口手动连一个计数器或者开关产生FTW再把输出抓到ILA里看。这样能最快验证HLS生成的RTL是否正确。Block Design适合后面要跟MicroBlaze或Zynq PS联动时再上。5. Vivado中集成HLS DDS IP从固定频率到动态配置5.1 Block Design里固定频率怎么接由于我顶层接口把phase_inc设成了ap_none硬件端口在Block Design里的连接就非常直接把一个Constant IP的dout连到dds_hls的phase_incConstant值填34359738代表FTW。这样DDS开机就以1MHz输出不需要任何CPU参与。输出端out是AXI-Stream后面可以直接接DAC的DMA接口、ILA探针或者再接一个自定义的AXI-Stream数据处理模块。如果你只是想在Vivado仿真里看结果加一个ILA把tdata连进去tready用Constant IP拉高即可。注意AXI-Stream的tready必须处理HLS生成的IP只有tready有效时才输出数据悬空的话仿真里会看到tvalid永远不拉高。5.2 动态配置频率改成AXI-Lite接口如果频率要由MicroBlaze或者Zynq ARM核动态修改就需要把phase_inc挂到AXI-Lite总线上。改动很小把接口pragma改成#pragma HLS INTERFACE modes_axilite portphase_inc这样HLS会生成一组s_axi相关的寄存器端口在Block Design里连到zynq的M_AXI_GP0或者MicroBlaze的AXI总线上就行。在软件里往寄存器写FTW即可实时变频寄存器偏移量可以从HLS导出的地址映射文档里查通常是0x00是控制寄存器0x10开始是第一个参数。这块有个容易踩的坑动态改频率时相位累加器并不会清零频率突变瞬间波形会有瞬态这在有些场景下可接受如果要求相位连续就得在软件里先把相位清零增加一个复位寄存器端口或者修改HLS代码增加相位复位逻辑。5.3 多通道和相参输出的扩展思路如果系统需要多路相参信号比如雷达模拟里需要I/Q两路正交正弦波其实不用例化两个IP。最简单的做法是HLS顶层函数里加两个输出stream一路输出正弦、一路输出余弦这在C代码里就是多调一次hls::cosII最多变成2输出速率减半。如果要求两路信号严格同步务必让两路共用同一个phase_acc累加器这样相位天然一致没有对齐问题。当时我做相参I/Q源就是这么干的省掉了外部同步逻辑效果很理想。如果两路要独立变频那就单独各自维护phase_acc代价是资源翻倍。6. 常见问题速查与排查实录我在做这个DDS IP的过程中遇到了几个典型问题整理成表格方便大家对号入座现象可能原因排查方法输出是一条直线FTW为0phase_acc没累加查看Constant是否配置正确检查接口是否悬空输出频率差一倍FTW计算时把2^N算成了2^(N1)检查相位累加器位宽公式代入重新算一遍波形有台阶或毛刺printf输出精度不够C仿真采样点数太少增加仿真点数用Python画图对比综合后Interval大于1CORDIC链路流水太长资源不够并行放宽流水线目标或改用查表法FPGA上输出正常但DAC没波形AXI-Stream的tready没拉高检查下游模块是否解除背压动态改频率后波形跳变相位累加器未清零频率突变导致相位不连续增加相位复位逻辑或软件先清零导出IP后Vivado综合报错HLS版本和Vivado版本不匹配确认Vivado里的IP版本支持情况重新导出6.1 排查实例输出恒定不变我第一次在板子上抓ILA发现输出恒定在0第一反应以为是hls::sin写错了。后来查了好久才发现问题根本不在算法而是Block Design里Constant IP的位宽默认是8位我填的34359738被截断了导致phase_inc实际是0。这个小问题浪费了我大半天大家接Constant IP时务必检查输出位宽或者在HLS里就把phase_inc定义成32位。6.2 排查实例频率差一倍还有一次仿真里输出频率怎么都对不上算出来应该1MHz实测居然是2MHz。后来逐项核对发现我在计算FTW时误把相位累加器位宽按31位算了那分子分母自然差了2倍。DDS的公式真的不能凭感觉套每一步都用笔算一遍比较稳妥。6.3 关于时序约束和上板验证的建议HLS导出的IP时序一般比较干净但整个设计里还有其他逻辑时最好在综合后先看一眼时序报告确认时钟能跑在预期频率。上板验证时有条件就直接接DAC加示波器看波形没条件就用ILA抓数据然后导出来在电脑上还原波形。ILA采样深度尽量设大一点至少4096这样能抓到十几个完整周期的波形方便观察频率和幅度是否正常。我自己习惯同时抓phase_inc和输出数据方便定位问题出在配置还是算法侧。最后再分享一点个人体会这套用HLS生成DDS IP核的流程我陆陆续续在好几个项目里用过最大的感受是它把“算法设计”和“硬件集成”之间的切换成本降到了很低。以前改一个变频策略要在RTL里改状态机、重新跑仿真现在就是把C代码里的参数和逻辑改一改重新综合导出就行。如果后续你想扩展可以在这个基础上给IP增加相位累加器复位端口、多种波形切换寄存器甚至把DDS和FIR滤波器放在同一个HLS函数里一起综合形成真正意义上的软件化信号处理链。搭配ILA和Python脚本做联合调试开发效率比纯手写RTL高出一大截。本文还有配套的精品资源点击获取
返回列表