1. 项目缘起:从需求到FPGA选型
最近在做一个无线通信模块的前期验证,需要生成一个频率和相位都可灵活调整的高质量正弦波信号。一开始图省事,直接用实验室的现成信号发生器,但很快就遇到了瓶颈:一是需要频繁手动调整参数,自动化测试流程卡壳;二是想做一些复杂的频率捷变或相位调制测试时,那台老仪器的响应速度和编程接口实在让人捉急。琢磨了一下,这种对实时性、灵活性和集成度有要求的场景,不正是FPGA的用武之地吗?用FPGA自己做一个数字控制的正弦波发生器,核心算法就是DDS,不仅能把整个信号源集成到目标系统里,还能为后续更复杂的数字上变频、调制解调算法铺路。
为什么是FPGA,而不是单片机或专用DDS芯片?这里面的考量其实很实际。单片机靠软件循环计算正弦值,速度慢,波形频率和精度上限低,做做几kHz的音频还行,上MHz就力不从心了。专用DDS芯片(比如AD9850/51)性能不错,但它是“黑盒”,内部参数和调制方式固定,想深度定制或者把整个DDS逻辑作为更大系统的一个子模块来调用,就不太灵活了。FPGA的优势在于,它是一片“数字乐高”,你可以用硬件描述语言(比如VHDL或Verilog)从门电路级别开始,亲手搭建出DDS的每一个部件——相位累加器、波形查找表、DAC接口控制器。所有的逻辑都是并行执行的,时钟驱动下,每个周期都能输出一个新的数据点,速度只受限于你设计的流水线深度和FPGA本身的时钟频率,轻松跑到百兆赫兹以上。更重要的是,这个“发生器”的架构、精度、调制方式完全由你定义,之后可以无缝嵌入到你的通信链路、雷达信号处理或者音频算法中,实现真正的片上系统。
基于这个目标,我决定用最经典的DDS架构,在FPGA上实现一个参数可实时配置的正弦波发生器。我会选用VHDL进行设计,一方面它语法严谨,在描述寄存器传输级电路时非常清晰;另一方面,很多军工、航天领域的项目仍以VHDL为主,掌握它没坏处。硬件平台我手头有一块搭载Xilinx Artix-7芯片的开发板,上面的片上RAM足够存放一个高精度的正弦查找表,板载的DAC芯片也能满足中频信号输出的需求。整个设计过程,我会从DDS的核心数学原理开始,一步步推导出硬件架构,然后编写VHDL代码,最后在Vivado里进行仿真、综合、上板测试。过程中遇到的时序约束、资源优化、DAC数据接口同步这些坑,也会详细说说。
2. DDS核心原理:从概念到硬件映射
直接数字频率合成技术,听起来高大上,但它的核心思想非常直观。我们可以把它想象成一个在圆形跑道上匀速跑步的运动员和一张巨大的正弦波高度查询表。这个圆形跑道就是相位圆,周长是2π。运动员每秒钟跑的圈数,就是我们想要的输出正弦波的频率。那张查询表,记录了在跑道每一个位置(相位)上,对应的正弦波幅度值。
在数字域,我们用二进制数来表示这一切。首先,我们把一个完整的2π相位圆,等分成2^N份,这里的N就是相位累加器的位宽,比如32位。这样,相位圆上的每一个点,就对应一个32位的二进制数。那个“运动员”就是相位累加器,它是一个32位的寄存器。在每个时钟周期,它都会自动加上一个固定的值,这个值叫做频率控制字。假设系统时钟是100MHz,如果我们想让输出正弦波的频率是1MHz,那么运动员需要每秒跑1M圈。由于每秒钟有100M个时钟节拍,所以每个节拍他需要跑1M/100M = 0.01圈。把0.01圈映射到32位的相位空间里,这个值就是频率控制字。计算方法是:频率控制字 = (期望输出频率 / 系统时钟频率) * 2^N。对于1MHz输出,F_tuning_word = (1e6 / 100e6) * 2^32 ≈ 42949673。相位累加器每个时钟加一次这个数,其输出就是一个线性增长的相位序列。
但是,我们不可能真的做一个有2^32个条目的正弦表,那太庞大了。实际上,我们只取相位累加器输出的高M位(比如高12位)来作为查找表的地址。这相当于对相位进行了截断或取整,只关心跑道的大致区域,忽略细微的位置差别。这个操作会引入相位截断误差,它是DDS输出频谱中杂散噪声的主要来源之一。用这M位地址,去查询一个预先计算好并存入ROM中的正弦波形表,这个表有2^M个条目,每个条目存储了对应相位的正弦幅度值,通常用有符号的二进制补码表示。
最后,ROM输出的幅度数据,经过一个数模转换器,就变成了模拟的连续正弦波。通过改变频率控制字,我们就能瞬间改变“运动员”的跑步速度,从而改变输出频率,这就是DDS频率捷变速度快的原因。通过给相位累加器的输出加上一个固定的偏移量(相位控制字),就能实现相位的移动。
注意:频率分辨率是DDS的一个重要指标,它由相位累加器的位宽N和系统时钟F_clk决定:Δf = F_clk / 2^N。对于100MHz时钟和32位累加器,分辨率高达0.023 Hz,这意味着你可以极其精细地控制频率。
2.1 关键参数设计与权衡
在动手写代码前,必须把几个关键参数定下来,这直接决定了系统的性能和资源消耗。
- 系统时钟频率:这取决于你FPGA开发板的主晶振和你能稳定运行的逻辑速度。我的板子有100MHz的晶振,我就以此作为设计时钟。更高的时钟意味着在同样的相位累加器位宽下,能输出更高的频率(奈奎斯特定理限制,最高输出频率理论上小于F_clk/2)。
- 相位累加器位宽:我选择32位。这是一个常见的折中值。位宽越大,频率分辨率越高(如上所述,可达0.023Hz),但也会让累加器占用更多的寄存器资源。32位对于绝大多数高精度应用已经绰绰有余。
- 查找表地址位宽:这是最需要权衡的地方。地址位宽决定了查找表的深度,也就是ROM的大小。如果地址位宽太小(比如8位),那么查找表只有256个点,用一个周期的正弦波采样256次,精度太低,还原出的波形阶梯感明显,谐波失真大。如果地址位宽太大(比如用上累加器全部的32位),那么表项数是2^32,约43亿,没有任何一块FPGA的BRAM能装下。我选择取相位累加器输出的高12位作为查找表地址。这样,查找表深度为4096,对一个正弦周期采样4096次,在大多数情况下已经能提供非常光滑的波形了。ROM的输出数据位宽(即幅度精度)我定为14位,与我将要使用的DAC芯片分辨率匹配。
- 幅度量化与DAC匹配:查找表里存储的幅度值,需要量化为整数。对于14位有符号DAC,其输入范围通常是-8192到+8191(2的补码,0x2000到0x1FFF)。因此,我们需要将sin函数的值(-1到+1)映射到这个范围。公式是:
DAC_Code = round( sin(phase) * 8191 )。这里用8191而不是8192,是为了避免+1.0被映射到0x2000(这实际代表-8192,如果DAC是偏移二进制码则另议),防止溢出。
这些参数不是孤立的。例如,输出信号的频谱纯度受限于相位截断误差和幅度量化误差。用12位地址,相位截断误差相当于把32位相位精度中的低20位丢弃了。这个误差会表现为周期性的相位抖动,在频谱上产生杂散。通过合理选择累加器位宽和表地址位宽,可以将主要杂散推到带外或降低其功率。在实际中,如果对杂散要求极高,还可以采用相位抖动注入或正弦值压缩算法等技术,但这会显著增加设计复杂度。
3. VHDL硬件设计与模块拆解
有了清晰的数学模型和参数,就可以开始用VHDL搭建硬件电路了。整个系统我将划分为三个主要模块:phase_accumulator(相位累加器)、sine_lookup_rom(正弦查找表ROM)和dac_interface(DAC接口)。顶层模块dds_sine_generator负责实例化并连接它们。
3.1 相位累加器模块
这是DDS的心脏,一个带同步复位和使能的累加器。其VHDL实体定义大致如下:
library IEEE; use IEEE.STD_LOGIC_1164.ALL; use IEEE.NUMERIC_STD.ALL; -- 必须使用这个库进行算术运算 entity phase_accumulator is Port ( clk : in STD_LOGIC; rst : in STD_LOGIC; en : in STD_LOGIC; -- 使能信号,可用于门控输出 freq_word : in STD_LOGIC_VECTOR (31 downto 0); -- 32位频率控制字 phase_out : out STD_LOGIC_VECTOR (31 downto 0) -- 32位相位输出 ); end phase_accumulator; architecture Behavioral of phase_accumulator is signal acc_reg : unsigned(31 downto 0) := (others => '0'); begin process(clk) begin if rising_edge(clk) then if rst = '1' then acc_reg <= (others => '0'); elsif en = '1' then acc_reg <= acc_reg + unsigned(freq_word); end if; end if; end process; phase_out <= std_logic_vector(acc_reg); end Behavioral;这里有几个细节:
- 使用
unsigned类型:freq_word输入是std_logic_vector,但在做加法前必须转换为unsigned类型(来自NUMERIC_STD库)。直接对std_logic_vector做+操作是非法的。 - 无溢出处理:32位无符号数
acc_reg在累加超过2^32-1时会自动回绕到0,这正好对应相位从2π回到0,符合圆周循环的特性,所以不需要额外的溢出处理逻辑。 - 使能信号
en:这个信号非常有用。你可以通过控制它来暂停波形输出,或者在多通道DDS中分时复用累加器。
3.2 正弦查找表ROM的生成与例化
在FPGA中,查找表通常用Block RAM来实现。Xilinx Vivado工具提供了非常方便的方式:使用COE文件初始化ROM。首先,我们需要用Python或MATLAB生成一个包含4096个14位有符号数的系数文件。
import numpy as np # 参数 LUT_DEPTH = 4096 AMPLITUDE = 8191 # 14位有符号数最大值(正) # 生成相位和正弦值 phases = np.linspace(0, 2*np.pi, LUT_DEPTH, endpoint=False) # 不包含2π点 sine_values = np.sin(phases) # 量化为14位有符号整数(二进制补码) dac_codes = np.round(sine_values * AMPLITUDE).astype(np.int16) # 写入COE文件 with open('sine_lut_4096x14.coe', 'w') as f: f.write('memory_initialization_radix=10;\n') f.write('memory_initialization_vector=\n') for i, code in enumerate(dac_codes): f.write(str(code)) if i != LUT_DEPTH - 1: f.write(',\n') else: f.write(';')生成的.coe文件,前几行是这样的:
memory_initialization_radix=10; memory_initialization_vector= 0, 201, 402, ...在Vivado中,你可以通过IP Catalog -> Memories & Storage Elements -> ROMs & ROMs选择Distributed Memory Generator或Block Memory Generator来创建一个ROM IP核。在配置时,选择单端口ROM,设置深度为4096,位宽为14,然后加载刚才生成的.coe文件。Vivado会生成一个封装好的ROM模块(例如blk_mem_gen_0)。
在你的VHDL顶层设计中,直接例化这个IP核即可。注意,ROM的输入地址我们取相位累加器输出的高12位(phase_out(31 downto 20))。
-- 在结构体中例化ROM sine_rom_inst : entity work.blk_mem_gen_0 PORT MAP ( clka => clk, addra => phase_high_bits, -- 12位地址,连接 phase_out(31 downto 20) douta => sine_data_raw -- 14位数据输出 );3.3 DAC接口模块与时序对齐
ROM输出的sine_data_raw是14位有符号补码,但你的DAC芯片可能需要特定的数据格式和时序。我板子上用的是一款SPI接口的14位DAC。因此,我需要一个dac_interface模块,将数据转换为SPI协议帧,并控制片选和时钟。
更复杂但常见的情况是使用并行接口的高速DAC。这时,接口模块的核心任务就是满足DAC数据建立时间和保持时间的要求。假设DAC在时钟dac_clk的上升沿锁存数据,那么我们必须确保数据在上升沿到来之前已经稳定了一段时间(建立时间),并在之后继续保持一段时间(保持时间)。
entity dac_interface is Port ( fpga_clk : in STD_LOGIC; -- FPGA主时钟 dac_data_in : in STD_LOGIC_VECTOR (13 downto 0); -- 来自ROM的14位数据 dac_clk_out : out STD_LOGIC; -- 输出给DAC的时钟 dac_data_out : out STD_LOGIC_VECTOR (13 downto 0) -- 输出给DAC的并行数据 ); end dac_interface; architecture Behavioral of dac_interface is signal data_reg : std_logic_vector(13 downto 0); begin -- 用FPGA主时钟寄存一次数据,保证数据稳定 process(fpga_clk) begin if rising_edge(fpga_clk) then data_reg <= dac_data_in; end if; end process; -- 将寄存后的数据直接输出 dac_data_out <= data_reg; -- 关键:DAC时钟生成。这里采用时钟反相法来满足时序。 -- 假设fpga_clk和dac_clk_out同源同频。 process(fpga_clk) begin -- 在fpga_clk的下降沿更新dac_clk_out,这样当dac_clk_out上升沿到来时, -- data_reg已经在fpga_clk上升沿被稳定更新,并保持了半个周期。 dac_clk_out <= not fpga_clk; end process; end Behavioral;这种“时钟反相”是FPGA驱动高速并行DAC的经典技巧。它利用FPGA时钟和数据路径的延迟,人为地让数据提前于DAC采样时钟变化,从而更容易满足建立时间的要求。当然,最严谨的做法是使用Vivado的时序约束工具,对dac_clk_out和dac_data_out的输出延迟进行精确约束。
注意:对于不同的DAC芯片,务必仔细阅读数据手册的时序图。有些DAC可能需要你在时钟上升沿发送数据,有些则是在下降沿。有些还需要额外的控制信号,如
SYNC、PD(掉电)等。接口模块必须严格按照手册实现。
4. 系统集成、仿真与上板调试
将三个子模块在顶层连接起来,并添加必要的时钟管理单元。我的顶层设计还包括一个AXI-Lite从接口,这样我就可以通过FPGA上的软核处理器(如MicroBlaze)或者外部单片机,动态地配置频率控制字和相位控制字,实现可编程信号发生器。
4.1 功能仿真与Modelsim验证
在生成比特流之前,必须进行充分的仿真。我用Vivado自带的仿真器,或者Modelsim,写一个简单的测试平台。
-- 测试平台片段 library IEEE; use IEEE.STD_LOGIC_1164.ALL; use IEEE.NUMERIC_STD.ALL; entity tb_dds is end tb_dds; architecture Behavioral of tb_dds is component dds_sine_generator is ... end component; signal clk_100m : std_logic := '0'; signal rst : std_logic := '1'; signal freq_word: std_logic_vector(31 downto 0) := (others => '0'); signal dac_data : std_logic_vector(13 downto 0); begin uut: dds_sine_generator port map (...); -- 生成100MHz时钟 clk_100m <= not clk_100m after 5 ns; process begin wait for 100 ns; rst <= '0'; -- 释放复位 freq_word <= std_logic_vector(to_unsigned(42949673, 32)); -- 对应1MHz wait for 200 us; -- 观察一段时间波形 freq_word <= std_logic_vector(to_unsigned(85899346, 32)); -- 切换到2MHz wait; end process; end Behavioral;在仿真波形中,我需要重点观察:
- 相位累加器输出:是否在每个时钟周期累加正确的值?溢出是否正常?
- ROM地址:是否正确地取到了相位累加器的高12位?
- ROM输出数据:是否是一个完整的、量化的正弦波序列?
- DAC数据输出:时序是否符合预期?当频率控制字改变时,输出频率是否立刻改变?(DDS的频率捷变性)
通过仿真,可以提前发现数据位宽不匹配、符号错误、时序错位等逻辑问题。
4.2 综合、实现与时序约束
仿真通过后,在Vivado中进行综合和实现。这一步的关键在于时序约束。你需要创建一个.xdc文件,告诉Vivado你的时钟频率和端口时序要求。
# 主时钟约束 create_clock -period 10.000 -name clk_100m [get_ports clk_100m] # 输出端口约束(以并行DAC为例) # 假设DAC要求数据在时钟上升沿前2ns稳定(建立时间),之后保持1ns(保持时间) set_output_delay -clock [get_clocks clk_100m] -max 2.000 [get_ports dac_data_out[*]] set_output_delay -clock [get_clocks clk_100m] -min -1.000 [get_ports dac_data_out[*]] # 约束DAC时钟输出 create_generated_clock -name dac_clk -source [get_ports clk_100m] -divide_by 1 [get_ports dac_clk_out]运行Implementation后,务必查看时序报告,确保没有建立时间或保持时间的违例。如果有违例,可能需要优化代码(如增加输出寄存器级数)、调整约束、或者降低时钟频率。
4.3 上板实测与频谱分析
生成比特流并下载到FPGA开发板后,真正的考验才开始。我用示波器观察DAC输出的模拟波形,首先看时域波形是否光滑,幅值是否正确。然后,使用频谱分析仪(或示波器的FFT功能)观察输出频谱。
在1MHz输出时,我期望在频谱上看到一个干净的单频信号。但实际可能会发现:
- 底噪过高:可能是电源噪声、DAC本身的量化噪声、或PCB布局布线问题。
- 明显的杂散峰:这很可能来自DDS本身的缺陷。在基频附近出现的杂散,通常是相位截断误差引起的。可以通过增加查找表深度(地址位宽)或采用前面提到的相位抖动技术来抑制。在频率为
F_clk / 2^M的整数倍附近出现的杂散,可能是幅度量化误差的非线性效应。 - 时钟馈通:在系统时钟频率(100MHz)及其谐波处看到尖峰,这是数字时钟信号串扰到了模拟输出。需要检查电源去耦、模拟和数字地分割是否良好,DAC的输出滤波器是否合适。
我个人的一个实测经验是,最初发现输出正弦波在过零点处有轻微的“毛刺”。排查后发现,是因为ROM的读取延迟是1个时钟周期,而相位累加器每个周期都在输出新地址。当频率控制字较大时,相位累加器的高位(地址位)在某些累加周期会发生连续变化,但ROM的读取需要时间,导致地址变化和数据输出之间出现短暂错位。解决方法是在相位累加器后也插入一级寄存器,让地址也同步一拍,确保地址和读取数据的时序严格对齐。这个“流水线寄存器”的插入,是高速数字设计中平衡时序的常用手段。
5. 性能优化与高级应用拓展
一个基础的DDS做出来之后,可以从多个维度进行优化和扩展,让它更实用、更强大。
5.1 资源优化技巧
- 压缩正弦查找表:利用正弦波的对称性,只存储0到π/2(第一象限)的采样值。当相位落在其他象限时,通过简单的地址映射和符号取反逻辑来还原完整的正弦波。这样可以将ROM大小减少到原来的1/4。例如,对于12位地址(4096点)的全表,压缩后只需要1024个点。
process(phase_high_bits) variable addr : unsigned(9 downto 0); -- 10位地址,对应1024点 variable quad : unsigned(1 downto 0); -- 象限判断 begin quad := phase_high_bits(11 downto 10); -- 取最高两位判断象限 addr := phase_high_bits(9 downto 0); -- 低10位作为查表地址 case quad is when "00" => -- 第一象限,直接查表 rom_addr <= std_logic_vector(addr); data_sign <= '0'; when "01" => -- 第二象限, sin(π-θ) = sinθ rom_addr <= std_logic_vector(1023 - addr); -- 镜像地址 data_sign <= '0'; when "10" => -- 第三象限, sin(π+θ) = -sinθ rom_addr <= std_logic_vector(addr); data_sign <= '1'; when others => -- 第四象限, sin(2π-θ) = -sinθ rom_addr <= std_logic_vector(1023 - addr); data_sign <= '1'; end case; end process; -- ROM输出后,根据data_sign决定是否取反 - 使用Distributed RAM:对于深度不大的查找表(比如压缩后的1024点),可以不用Block RAM,而用FPGA逻辑单元中的分布式RAM实现。这可以节省宝贵的BRAM资源,用于其他更重要的数据缓冲。在Vivado中,用
distributed_memory_genIP核或在代码中用array定义并指定ram_style属性即可。
5.2 从单音到任意波形发生器
DDS的核心是相位到幅度的映射。如果我们把正弦查找表换成其他波形(方波、三角波、锯齿波,甚至自定义的任意波形)的查找表,它就变成了一个任意波形发生器。我们可以设计一个多路复用器,根据一个“波形选择”寄存器,来决定使用哪个ROM的输出。更进一步,可以将波形数据存储在更大的外部存储器(如DDR3)中,FPGA通过一个DMA控制器循环读取数据并发送给DAC,从而实现超长、复杂的任意波形播放。
5.3 集成调制功能:AM、FM、PM
DDS的灵活性使得集成调制功能变得非常直接。
- 调幅:将ROM输出的幅度数据,与一个代表调制信号的数字相乘(在数字域进行乘法运算),再将结果送给DAC。这个乘法器可以用FPGA内部的DSP Slice高效实现。
- 调频:动态改变频率控制字
freq_word。你可以将调制信号作为偏移量加到基带频率控制字上。freq_word(t) = base_freq_word + modulation_index * mod_signal(t)。注意,freq_word的变化率决定了最大频偏。 - 调相:动态改变相位累加器的输出偏移量。在将相位值送入查找表前,加上一个相位控制字
phase_word(t)。phase_to_rom = phase_accum_out + phase_word(t)。这直接实现了数字移相。
将这些调制器集成进去,你的FPGA正弦波发生器就升级为一个完整的数字调制信号源,可以直接用于通信系统的原型验证。
5.4 与处理器协同:构建可编程信号源系统
在像Zynq这样的FPGA+ARM SoC平台上,这个DDS设计可以作为一个硬件加速IP核,通过AXI总线与处理器交互。处理器运行Linux或裸机程序,提供用户界面(如Web、串口命令),接收用户设置的频率、幅度、波形、调制方式等参数,通过AXI-Lite总线配置DDS核内部的寄存器。DDS核则全速运行,产生波形数据,通过AXI-Stream接口或直接通过DMA将数据高速输送给DAC IP核。这样,就构建了一个高度灵活、性能强大的可编程信号发生设备,其核心波形生成任务由硬件并行完成,保证实时性;控制和人机交互由软件负责,保证灵活性。这种软硬协同的设计思路,是现代FPGA应用开发的典型范式。