
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到81912的补码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_interfaceDAC接口。顶层模块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, endpointFalse) # 不包含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_radix10;\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_radix10; 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、PMDDS的灵活性使得集成调制功能变得非常直接。调幅将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这样的FPGAARM SoC平台上这个DDS设计可以作为一个硬件加速IP核通过AXI总线与处理器交互。处理器运行Linux或裸机程序提供用户界面如Web、串口命令接收用户设置的频率、幅度、波形、调制方式等参数通过AXI-Lite总线配置DDS核内部的寄存器。DDS核则全速运行产生波形数据通过AXI-Stream接口或直接通过DMA将数据高速输送给DAC IP核。这样就构建了一个高度灵活、性能强大的可编程信号发生设备其核心波形生成任务由硬件并行完成保证实时性控制和人机交互由软件负责保证灵活性。这种软硬协同的设计思路是现代FPGA应用开发的典型范式。