最近在调FPGA输出正弦波的项目,卡了好几天的问题居然是被ILA一抓一个准,这让我下定决心把这块内容整理出来。正弦波生成在信号发生器、电机驱动、音频合成这些场景里都是基本功,但很多朋友上手时直接在DDS IP核里配个相位增量就完事,波形出来不对也找不到原因。这篇我打算把纯RTL代码写DDS和调用Xilinx DDS IP核两条路线都走一遍,同时把ILA逻辑分析仪的正确用法讲透——毕竟调波形这件事,没有ILA辅助就像蒙着眼睛调琴弦。
整篇内容我会按这个思路组织:先从DDS的原理说起,让不明白相位累加器是什么的朋友也能跟上;然后分别给出纯代码方案和IP核方案的完整工程代码和配置过程;最后重点讲ILA怎么用,包括触发条件怎么设、数据位宽怎么匹配、采样深度怎么选。过程中会穿插我实际调试板子时的经验,比如采样率不够导致正弦波看起来像阶梯波、IP核输出位宽和DAC不匹配导致波形顶部削平这类坑。
先说清楚这篇东西适合谁看。如果你是刚开始接触FPGA数字信号处理,想在开发板上真实看到正弦波输出;或者你已经能用IP核出波形,但想搞明白内部相位累加器到底怎么工作;再或者你正被"波形对但毛刺多""频率调不准"这类问题折磨,那这篇内容应该能帮你省下几个晚上。如果对Verilog基础语法还不太熟,建议先补一下always块和wire/reg数据类型,下面涉及RTL代码的部分会比较密集。
我用的板子是某厂商的Artix-7系列开发板,时钟是100MHz,DAC芯片是8位并行接口,这些参数后面代码里都会体现。大家移植到自己板子时只需要改改时钟频率和输出位宽就行。
1. 前置数值关系:20MHz时钟下每个周期该累加多少
在写代码之前,先把DDS最核心的数值关系捋清楚。DDS(直接数字频率合成)本质上就是一个数字分频器,它不像PLL那样靠反馈环路锁定频率,而是通过每个时钟周期累加一个固定数值来产生频率可变的数字波形。
相位累加器的宽度(比如32位)决定了频率分辨率。举个具体例子:时钟100MHz,相位累加器32位,那么频率分辨率就是100MHz除以2的32次方,算下来约0.023Hz。这个精度意味着你几乎可以连续调节输出频率,而不是像分频器那样只有几个离散档位。
频率控制字(相位增量)的计算公式是:
[math]FreqWord = \frac{f_{out} \times 2^{N}}{f_{clk}}[/math]
其中f_out是目标输出频率,f_clk是系统时钟频率,N是相位累加器的位宽。这个公式我自己推过好几遍,网上有些帖子直接给结论,但不知道为什么是这个形式,这里解释一下:相位累加器每来一个时钟沿就加上一次频率控制字,累加器溢出时对应一个完整的正弦周期。所以控制字除以2的N次方就是每个时钟周期走过的相位占整个周期的比例,再乘以时钟频率就得到输出频率。
做个实际计算:目标是输出1kHz正弦波,系统时钟100MHz,N=32位长。
[math]FreWord = \frac{1000 \times 2^{32}}{100000000} \approx 42949.67[/math]
取整为42950,写入寄存器后实际输出频率为:
[math]f_{out_real} = \frac{42950 \times 100000000}{2^{32}} \approx 999.99Hz[/math]
误差仅0.01Hz,完全满足工程需求。如果频率控制字算出来不是整数,直接四舍五入取整即可,累加器的性质会自动把舍入误差分散到多个周期里,不会积累成可见的频率漂移。这一点我在频谱仪上实测过,取整带来的误差是随机的,不像某些分频方案存在固定累积误差。
再来验证标题里那个"20MHz时钟"的场景。有些入门级开发板或低速DAC电路使用20MHz系统时钟,此时1kHz正弦波对应的频率控制字是:
[math]FreqWord = frac{1000 \times 2^{32}}{20000000} \approx 214748.36[/math]
取整为214748。这里要特别注意:20MHz时钟下如果你用同样的32位累加器,频率分辨率会变差到约0.0047Hz,仍然够用,但如果你的时钟只有1MHz,那频率分辨率就变成0.00023Hz,对大多数低频波形生成仍然是友好的。关键在于记住N越大分辨率越高,而N受限于FPGA的DSP切片和LUT资源。
还有一个容易忽略的点:相位累加器输出的高几位才是真正用来查表的地址。比如累加器32位,你只取高12位作为ROM地址,那么相位截断会带来少量杂散,但这种杂散在8位DAC输出场景下远低于量化噪声,不用过度担心。
2. 方案一:纯Verilog代码手写DDS,不吃任何IP资源
我最早实现DDS的时候也没直接上IP核,而是纯用Verilog写了相位累加器加查找表,这样做的好处是彻底理解了原理,而且代码可以无缝移植到任何FPGA平台,不限厂商。这里给出完整代码和每段逻辑的解释。
2.1 相位累加器实现,一个时钟周期一次的定点累加
module dds_phase_accumulator #( parameter PHASE_WIDTH = 32, parameter OUTPUT_WIDTH = 12 )( input wire clk, input wire rst_n, input wire [PHASE_WIDTH-1:0] freq_word, output wire [OUTPUT_WIDTH-1:0] phase_out ); reg [PHASE_WIDTH-1:0] phase_acc; always @(posedge clk or negedge rst_n) begin if (!rst_n) phase_acc <= 32'd0; else phase_acc <= phase_acc + freq_word; end assign phase_out = phase_acc[PHASE_WIDTH-1 -: OUTPUT_WIDTH]; endmodule代码逻辑很直白:每个时钟上升沿把频率控制字加到累加器上。相位累加器位宽32位,但输出只取最高12位作为后续查找表的地址。之所以取高位而不是低位,是因为高位对应的是相位变化的主要部分,低位只是精度余量。如果拿低位去查表,波形会乱跳,这点务必记住。
有人会问,为什么不在一个周期内多次累加?实际上DDS的累加就是每个时钟加一次,等效的连续时间相位是线性增长的,最高输出频率受奈奎斯特限制,必须小于时钟频率的一半。实际工程中我一般把最大输出频率限制在时钟的40%以内,否则重建滤波器很难把镜像频率压下去。
2.2 正弦波查找表的生成方法,别再手动敲256个数据了
查找表是DDS的另一半。如果手敲256个正弦采样值,费时不说还容易敲错。正确做法是用Python或者MATLAB生成coe文件,再通过Xilinx ROM IP核加载。这里给出一段Python生成脚本:
import math import os DEPTH = 4096 WIDTH = 12 with open('sine_rom.coe', 'w') as f: f.write('memory_initialization_radix=10;\n') f.write('memory_initialization_vector=\n') for i in range(DEPTH): val = int(round((2**WIDTH - 1) * (math.sin(2 * math.pi * i / DEPTH) + 1) / 2))) f.write(str(val)) if i != DEPTH - 1: f.write(',\n') else: f.write(';\n') print('sine_rom.coe generated.')这段脚本生成4096点、12位的单极性正弦波查找表。为什么用单极性(0到4095)而不是双极性(-2048到2047)?因为后续如果接的是单极性输入的DAC,直接用这个值就行;如果DAC支持双极性,可在代码里减去2048再输出。我习惯一次性把存储器深度做大一点,4096点的相位分辨率在低频输出时波形平滑度明显优于256点,FFT测出来的谐波失真也低不少。
另一个常见选择是直接用$readmemh加载hex文件,完全不依赖IP核。如果你不想用Xilinx Block Memory Generator,可以把上面的脚本改成输出hex格式,然后在RTL里写:
reg [11:0] sine_rom [0:4095]; initial $readmemh("sine_rom.hex", sine_rom);这种方法在仿真里特别好用,Vivado综合时也能正确把ROM综合成Block RAM或分布式RAM。需要注意initial块里的文件路径要用绝对路径或在XST/综合设置里指定好,否则实现时会报找不到文件的错误。我个人实际项目里两种方式都混合用过:仿真用$readmemh快速验证算法,综合时改成Block Memory Generator保证时序收敛。
2.3 顶层模块把累加器和ROM串起来,输出带流水线寄存器
有了相位累加器和ROM,接下来就是例化拼接。这里有一个工程上很容易忽略的点:ROM读操作有延迟,特别是用Block RAM时输出会晚一拍,所以顶层逻辑需要考虑相位和数据的对齐。我的做法是在累加器输出和ROM间不做额外处理,让ROM输出直接作为DAC数据,自然接受这一拍延迟,反正正弦波是周期性的,固定延迟不影响波形形状。
module dds_top #( parameter PHASE_WIDTH = 32, parameter ADDR_WIDTH = 12, parameter DATA_WIDTH = 12 )( input wire clk, input wire rst_n, input wire [PHASE_WIDTH-1:0] freq_word, output wire [DATA_WIDTH-1:0] dac_out ); wire [ADDR_WIDTH-1:0] phase_addr; dds_phase_accumulator #( .PHASE_WIDTH(PHASE_WIDTH), .OUTPUT_WIDTH(ADDR_WIDTH) ) u_accumulator ( .clk(clk), .rst_n(rst_n), .freq_word(freq_word), .phase_out(phase_addr) ); sine_rom u_rom ( .clk(clk), .addr(phase_addr), .dout(dac_out) ); endmodule在实际的Block Memory Generator配置中,我给ROM设置了Always Enabled、Primitive Output Register打一拍,输出寄存器能明显改善时序,代价是多一拍延迟,这在DDS应用中可以接受。需要注意如果后面所接DAC有建立时间要求,在FPGA的IO约束里把输出delay约束设好,我会在最后一章详细讲。
2.4 把波形抓出来:纯代码方案的仿真Verification
代码写完先别上板,仿真这步能省大量时间。下面这个testbench给频率控制字赋了一个对应1kHz的值,跑了足够长时间能看到好几个完整周期的正弦波。
module tb_dds_top; reg clk, rst_n; reg [31:0] freq_word; wire [11:0] dac_out; initial begin clk = 0; rst_n = 0; freq_word = 32'd42950; // 对应1kHz @ 100MHz #100; rst_n = 1; #200_000; $finish; end always #5 clk = ~clk; // 100MHz dds_top u_dut ( .clk(clk), .rst_n(rst_n), .freq_word(freq_word), .dac_out(dac_out) ); endmodule仿真跑完后把dac_out以模拟格式显示,理想情况下是平滑的12位正弦波。如果你的仿真里波形出现台阶感,那不是代码问题,而是数字量化特性和查看器显示颗粒度的问题。真正上板时ILA抓到的数据也是这样一个个离散点,视觉上的平滑度取决于采样率和DAC重建滤波器的设置。
我仿真时还习惯检查一下频率是否准确。办法是测两个波峰之间的时间差,然后换算成频率。因为代码里相位累加器的工作机制是确定的,仿真结果直接反映真实运行情况。如果仿真频率都不对,那就别上板了,先查频率控制字算没算对。
3. 方案二:直接调用DDS IP核,配置项逐项拆解
如果你不想自己维护ROM和累加器,或者说项目进度紧,Xilinx的DDS IP核是最省事的选择。这个IP核内部已经把相位累加器、抖动消除、象限压缩等细节都做好了,性能比我手写的简化版更优。但IP核配置选项多,我见过很多人配完波形不对,问题出在Phase Increment和Output Width没理解透。
3.1 参数配置界面的关键选项解读
创建DDS Compiler IP核时,主要设置这几项:
Configuration选项卡:
- 系统时钟频率填实际板载时钟,比如100MHz;这里填错会导致后续所有频率参数的计算全错。
- 通道数填1,如果你要多通道正交输出,可以填2然后配置相位偏移,但这不是本文重点。
- 模式选Standard或Sin-Cos LUT都可以,区别在于后者多输出一路余弦,资源消耗略多。
Implementation选项卡:
- Phase Increment Programmability选Programmable,这样运行时可以通过AXI接口动态改频,实测调频非常方便。
- Output Width建议12位或16位,取决于你的DAC位宽。8位DAC选8位也无妨,但波形精度会受限。
- Noise Shaping选None或Taylor Series Correction,前者资源省,后者杂散低。我一般选Taylor Series Correction,对输出精度有要求的场景效果明显。
Detailed Implementation里有个Phase Dithering选项,建议开启。相位抖动会略微抬高本底噪声来换取杂散抑制,在音频或测量场景里听感/观感都会好很多。
3.2 AXI接口动态配置频率控制字的时序
Programmable模式下,运行时改频需要往IP核的AXI-Lite寄存器写数据。每个通道有独立的Phase Increment寄存器,地址是0x00。重点是AXI握手时序,必须在aclk上升沿满足S_AXIS_TVALID为高且S_AXIS_TREADY为高时,数据才会被锁存。
下面给出一段简单的写频率控制字的逻辑,用状态机实现一次写操作:
module dds_freq_config #( parameter DATA_WIDTH = 32 )( input wire clk, input wire rst_n, input wire start, input wire [DATA_WIDTH-1:0] freq_data, output reg s_axis_tvalid, output wire s_axis_tready, output reg [DATA_WIDTH-1:0] s_axis_tdata ); localparam IDLE = 2'd0, SEND = 2'd1, WAIT = 2'd2; reg [1:0] state; assign s_axis_tready = (state == IDLE); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; s_axis_tvalid <= 1'b0; s_axis_tdata <= 32'd0; end else begin case (state) IDLE: begin if (start) begin s_axis_tdata <= freq_data; s_axis_tvalid <= 1'b1; state <= SEND; end end SEND: begin if (s_axis_tready) state <= WAIT; end WAIT: begin s_axis_tvalid <= 1'b0; state <= IDLE; end endcase end end endmodule这里写的是一次性的单次写入状态机。实际调试时我在ILA里观察过AXI握手信号,发现最容易犯的错是tvalid拉高的同时tready还没拉高,导致数据丢失。IP核的tready空闲时是高,但要确认它真的已经进入接收状态再给数据才稳。上面状态机把判断放在SEND状态,没有在IDLE时就直接发数据,这个细节很关键。
3.3 M_AXIS_DATA输出端的位宽匹配与符号问题
DDS IP核输出的数据格式与配置有关。若输出设置为12位无符号整数,那么波形范围是0到4095,正弦波中心在2048;如果设置为有符号数,范围是-2048到2047,正弦波中心在0。你的DAC如果是有符号输入,要选有符号输出;如果DAC是无符号输入但IP核配成了有符号,波形送到DAC会整体被抬高或截断,顶部出现削顶失真。这个问题我在ILA上抓到的波形非常明显,正弦波的负半周整个平了。
常用的匹配关系我用表格整理一下,方便对照:
| DAC接口类型 | DDS输出格式 | 建议操作 |
|---|---|---|
| 8位无符号并行 | 8位无符号 | 直接连接,无需转换 |
| 12位无符号并行 | 12位无符号 | 直接连接,无需转换 |
| 12位无符号并行 | 12位有符号 | 将输出加2048转成无符号 |
| 16位差分输出 | 16位有符号 | 直接连接,匹配差分输入范围 |
| 8位无符号并行 | 16位有符号 | 将高8位取出并做偏置调整 |
如果你的DAC要求双极性输出(比如-1V到+1V),而DDS输出是无符号数,可以直接在代码里减掉中点值。反过来,DDS输出有符号数但DAC只能接受无符号,就需要加上中点偏置。这个小转换不加的话,波形看起来就像被切掉一半,很多新手在这里卡住。
资源方面,DDS IP核由于内部做了CORDIC优化,LUT消耗比我手动写的ROM版本少。具体数字记不太清了,但同一片Artix-7上,IP核方案大约省了30%的LUT,BRAM基本不用。如果你的设计里BRAM非常紧张,用IP核方案的确更划算。
4. ILA逻辑分析仪:抓内部信号的正确姿势和常见抓狂原因
ILA是Xilinx集成逻辑分析仪,它本质上是把FPGA内部信号实时采样后存入BRAM,再通过JTAG回传到电脑显示。用它的核心问题有三个:触发条件设什么、采样深度设多大、信号位宽怎么选。下面逐个讲。
4.1 集成ILA到工程里的具体步骤
Vivado里加ILA有两种途径,我都用过。第一种是在Netlist界面右键Mark Debug,再重新综合实现,最后在硬件管理器里连接调试核。第二种是直接在RTL里例化ILA IP核,我一直用第二种,因为可控性更强,而且代码里能明确看出监控哪些信号。
RTL里例化方式示例:
ila_0 u_ila ( .clk(clk), .probe0(phase_addr), .probe1(dac_out), .probe2(freq_word) );每个probe对应一组要观测的信号。上面的例子同时抓了相位地址、DAC输出和频率控制字。建议probe数量不要太多,我最多一次抓4组信号,再多了BRAM消耗很大,而且波形窗口密密麻麻反而不好分析。
生成ILA IP核时,有三个参数直接影响调试体验:
- 采样深度:默认1024,我一般设4096或8192。深度越大,能看到的波形周期越多,但消耗BRAM越多。4096对看正弦波绰绰有余。
- 触发条件:可以设等于、不等于、上升沿等。我常用上升沿触发来定位某个事件,比如频率控制字跳变的瞬间。
- 数据位宽:自动和probe信号宽度一致即可,不用手动改。
配置好之后,综合、实现、生成比特流,然后打开Hardware Manager,选择目标芯片,Program Device,就能在ILA窗口看到实时波形了。如果窗口里只看到一条平线,最常见的原因是触发条件没满足,多试几个触发条件或直接设成一直触发。
4.2 为什么有时候ILA里看到的是楼梯状而不是平滑正弦波
很多人在ILA里看到正弦波形呈明显的阶梯状,第一反应是DDS代码写错了,其实很可能不是。ILA采样的是离散时刻的数据点,如果采样深度内的点都集中在正弦波的一个或几个周期,显示出来自然就是一个个台阶。采样点和波形周期的相对密度决定了视觉平滑度。
要看到更平滑的正弦波,建议增加采样深度,并且把触发位置设到波形前沿。在ILA设置里有一项Trigger Position,可以选择触发点在采样窗口的开头、中间或结尾。想要观察稳态波形,选中间最合适,能看到触发前后各一半数据。
另外,ILA的采样时钟用的是你probe信号的同源时钟。如果你把clk设为100MHz,但正弦波输出频率是1kHz,一个周期里有10万个采样点,ILA只存4096点,那显示出来的只是这个周期里很小的一段。想让ILA窗口显示多个完整周期,需要把采样时钟改慢或增加采样深度。由于ILA采样时钟就是系统时钟,没法直接改,只能靠增大采样深度来实现。实测8192深度能显示大约两个1kHz完整周期,前提是采样时钟100MHz,如果换成1MHz正弦波就能抓几十个周期,视需求而定。
还有一个思路:把DDS的频率控制字临时调高,让输出频率暂时变成10kHz或100kHz,再抓ILA就能看到很多周期,确认波形形状没问题后,再把频率调回去。这种"临时变频率"的调试手法我用了很多次,非常实用。
4.3 用ILA验证频率准确性和毛刺排查
ILA不只是看波形形状,还能定量测量频率。直接在波形窗口里用光标测量两个同相位点的时间差,反推频率,和设置的目标频率对比。比如设了1kHz,测量周期如果是1.0002ms,那误差在0.02%以内,说明配置正确。误差如果到了百分之几,优先检查系统时钟频率真值,别相信标称值;很多开发板上实际时钟和标称有个小的偏差,直接把实际频率代入频率控制字公式重算即可。
毛刺排查是ILA的另一大用途。如果正弦波上有个别点明显偏离理想曲线,单独看很难判断是触发问题还是大电流干扰。我的做法是把probe宽度扩展到超过实际数据位宽,比如把dac_out[11:0]和freq_word[31:0]一起抓进来,如果毛刺同时出现在多个信号上,多半是异步路径或跨时钟域导致的亚稳态;如果毛刺仅出现在DAC数据上,优先查IO约束和数据建立时间。
我在实际调试某个1MHz输出时遇到过一次奇怪问题:ILA显示正弦波每个周期有规律地出现两次小突刺,频率恰好是正弦波的两倍。排查了很久,最后发现是DAC的时钟输入和数据总线没有同步,数据变化时DAC正好在采样。解决办法是把DAC的时钟反相或延迟半个周期,让它在数据稳定后再采样。ILA在这个排查过程中提供了关键证据——突刺出现的位置总在数据变化沿附近,这才引导我去查建立时间问题。
5. DAC输出级联:并口时序匹配和模拟前端滤波
FPGA里数字波形出来了,但要变成真正的模拟正弦波,还差DAC和低通滤波器这一步。这一节讲DAC接口时序怎么配合,不然数字域正确的东西到了模拟端还是容易出问题。
5.1 并口DAC的建立时间和数据对齐,一个被忽略的细节
市面常见的8位或12位并口DAC,一般都有数据建立时间要求,比如20ns到100ns不等。FPGA输出的数据是在时钟上升沿之后变化的,如果DAC也在同一时钟沿采样,那么数据变化和采样时刻重合,极容易采到毛刺或中间态。解决方法是让DAC时钟相对于数据延迟半个周期。
代码里实现延迟半个周期,可以通过输出时钟反相实现,或者在数据路径上加一级寄存器。下面是我常用的一种输出时序控制写法:
reg dac_clk_reg; always @(posedge clk) begin dac_clk_reg <= ~clk; end assign dac_clk = dac_clk_reg;这个写法让DAC时钟在系统时钟的下降沿翻转,等效于DAC在数据稳定后约5ns处采样(100MHz时钟下)。虽然不是精确的相位控制,但对低速DAC足够可靠。如果你用的是高速DAC或者时钟频率很高,建议去看一下DAC数据手册里的建立时间和保持时间,用ODDR原语做精细的时钟输出控制,这种粗暴反相就不太适用了。
除了时钟相位,数据总线上的信号完整性也值得注意。我的经验是数据线尽量等长布线,尽量远离时钟线和复位线,可以减少串扰引起的偶发干扰。板级调试时如果发现正弦波有随机毛刺,而又排除ILA显示问题,拿示波器量一下DAC输出脚的信号边沿,比较一下不同走线长度的毛刺幅度差异。
5.2 一阶RC低通滤波器的截止频率选择,如何压掉时钟噪声
DAC输出的信号除了正弦波基频,还有大量采样时钟的镜像频率和量化噪声,必须靠低通滤波器压掉。一阶RC是最简单的方案,但截止频率不能乱选。
滤波器截止频率一般取输出正弦波最高频率的3到5倍。比如你要输出的最高频率是100kHz,那截止频率设在300kHz到500kHz比较合适。如果截止频率太低,输出波形的幅度在高频段会明显下降,这时需要补偿增益;如果太高,镜像频率压制不够,波形还是不够干净。
一阶RC的截止频率公式是:
[math]f_c = \frac{1}{2\pi RC}[/math]
假设取R=1kΩ,目标截止频率400kHz,那么:
[math]C = \frac{1}{2\pi \times 1000 \times 400000} \approx 398pF[/math]
取标准值390pF或470pF都行。实际焊接时我用贴片电容,效果差别不大。如果要求更高的频谱纯度,可以加二阶有源滤波器(比如Sallen-Key结构)甚至椭圆滤波器,但普通测试用途一阶RC已经够用。
还有一点:DAC的输出带载能力有限,RC滤波后面接示波器探头时,探头电容会影响截止频率。示波器探头一般10MΩ输入阻抗,并联约10pF电容,对几百pF的滤波电容影响不到10%,基本可控。如果后面直接接功放或其他高阻负载,问题也不大;但如果是低阻负载,建议加一级运放缓冲再输出。
5.3 实测波形:ILA数据对比示波器模拟输出
实际操作中,我习惯用ILA和示波器同时看信号。ILA看数字域波形,示波器看DAC输出的模拟波形,两者互相对照,能快速区分问题在FPGA里还是在模拟前端。
如果ILA波形很完美,但示波器看到的有明显变形,问题大概率在DAC后端,优先检查滤波电路、输出负载、电源纹波。如果ILA和示波器看到的都不正常,那问题在FPGA内部逻辑或IP核配置,回到前面几节排查。
举个例子,我在输出3MHz正弦波时,ILA上的数字波形很好,但示波器显示的波形顶部有轻微圆角失真。排查后发现是DAC后面的运放带宽不够,增益在3MHz处已经开始滚降。换成更高带宽的运放后问题解决。如果当时不借助ILA把数字域验证为正常,我可能会在FPGA逻辑里白找半天问题。
这种双域对照的调试方式,强烈建议大家在项目里养成习惯。它不仅能定位问题,还帮你建立数字到模拟的映射直觉,时间久了看一眼波形就能判断大概问题在哪一层。
6. 实测踩坑:三个调试过程的完整复盘
理论与实践之间永远隔着一道道坑。下面三个案例都是我在实际调试某个多频段信号源项目时遇到的,每个都花了不少时间才定位,写出来供大家参考。
6.1 频率控制字写入后没反应,AXI时序问题全过程
现象:我通过MicroBlaze软核向DDS IP核写频率控制字,无论写什么值,输出正弦波频率始终是默认值。短信上配置寄存器的地址是0x00,数据类型也对,为什么写不进去。
排查过程:先在ILA里同时抓S_AXIS_TVALID、S_AXIS_TREADY、S_AXIS_TDATA三个信号,发现tvalid根本没拉高。顺藤摸瓜查MicroBlaze侧的GPIO输出,软核里对应外设地址映射没有使能,状态寄存器一直处于复位值。修正外设地址映射后,tvalid正常拉高,但tready在写入瞬间是低的,数据没被接收。
真正的问题在于:MicroBlaze写GPIO的时序和DDS IP核的AXI接口握手之间需要一个中间缓冲。GPIO输出变化后要给tvalid留出足够的建立时间,不能期望下一拍就能握手成功。我在软核里加了延时,在置高tvalid之前等一个固定周期数,问题解决。
这个坑其实很大程度是MicroBlaze侧不熟悉AXI时序造成的。如果你用纯逻辑控制DDS IP核,照前面第3.2节的状态机写法一般不会遇到这种问题。
6.2 波形出来了,但幅度只有预期一半,数据位宽和符号的锅
现象:DDS IP核输出16位有符号正弦波,DAC是12位无符号输入,我直接把高12位接了过去,结果输出波形幅度只有理论值的一半。
原因分析:16位有符号数的范围是-32768到32767,取高12位时,正数部分最大约2047,负数部分最小约-2048,看上去是对称的。但DAC是无符号输入,范围是0到4095,负半周全部被截断了——准确说,负数部分的二进制表示在无符号解释下变成了大正数,结果波形变成一个半波整流形状,幅度自然不对。
解决:先在有符号输出上加2048偏置,使波形范围从-32768~32767映射到0~4095,再取高12位输出给DAC。这个加法用12位还是16位做要仔细:我起初用16位加法再做截断,浪费了不少LUT;后来直接在12位域里做偏置调整,效果一样,资源省了三分之一。
这类问题用ILA非常好查:如果看到负半周缺失、波形像整流器输出,十有八九是符号和无符号的匹配问题,直接查位宽转换逻辑就行。
6.3 采样深度不够导致波形锯齿化,别急着改代码
现象:ILA抓到的正弦波形看着像锯齿波,高频分量明显。
一开始我怀疑是不是查找表数据有问题,又怀疑IP核配错了,折腾了好一阵。后来看到波形其实是完整的单周期正弦,只是点数太少才觉得像锯齿。ILA采样深度4096、采样时钟100MHz,当输出频率是50kHz时,一个正弦周期内只有约2000个采样点,看起来当然不够光滑。这不是代码问题,是观测窗口的问题。
解决:我把输出频率临时调到1kHz,再抓ILA,波形显示为非常平滑的正弦曲线。确认波形形状没问题后,把频率改回去。如果需要观测高频波形,用示波器看效果更好,ILA毕竟不是实时频谱分析仪。
所以说,ILA抓波形不能光看形状,还要想清楚采样点数相对于波形的密度。见到锯齿状先估算一下每周期采样点数,如果只有几十个点,那这个锯齿是非常正常的数字现象。
7. 两个方案怎么选:纯RTL还是DDS IP核,实测对比
文章最后把两种实现从多个维度做个总结对比,方便大家根据实际场景选择。以下是我在同板子、同输出条件下的实测经验。
| 对比项 | 纯RTL实现 | DDS IP核实现 |
|---|---|---|
| 资源消耗 | BRAM占用明显(4096x12bit约1块) | LUT较少,大多数配置不用BRAM |
| 最高时钟频率 | 取决于你的代码质量和约束 | 官方IP优化过,时序更好 |
| 杂散性能 | 取决于相位截断和查表精度 | 内置抖动和校正,杂散更低 |
| 可实现性 | 代码透明,可完全自定义 | 面向标准场景,定制能力有限 |
| 调频便捷度 | 自己写寄存器接口 | AXI标准接口,软件控制方便 |
| 可移植性 | 跨厂商完全可移植 | 仅限Xilinx平台 |
| 设计周期 | 中等,需自己写ROM生成器 | 短,配置界面直观 |
我的个人建议很明确:如果是学习原理,或者需要跨平台移植,或者想完全掌控DDS的行为细节,用纯RTL方案。如果是项目开发、进度紧、性能要求高、只用Xilinx平台,直接用IP核,把省下来的时间去调DAC后端更值得。
两个方案我都实际跑过,纯RTL方案做1kHz输出时波形和IP核方案肉眼几乎看不出差别。频率提高到1MHz以上时,IP核方案的杂散表现确实更好,毕竟相位抖动和误差校正这些细节不是手写代码能轻易复刻的。
纯RTL方案的另外一个优点是:当你需要非正弦波形时(比如三角波、任意波形),改一下ROM内容就行,IP核就没这么灵活了。如果你的波形需求比较特殊,纯RTL可能是唯一选择。
最后说一句关于ILA的体会。逻辑分析仪这个东西,当你没有它的时候觉得示波器就够了,一旦用惯了就再也回不去了。尤其是FPGA内部信号,外面量不到,ILA几乎是唯一能直接观测几百MHz内部节点的手段。把这个工具用熟,调试效率能提升一个量级。希望这篇对正在跟正弦波较劲的朋友有帮助——如果你们在调试中也踩过什么奇怪的坑,欢迎在评论区分享,互相学习。