☰
FPGA查表法NCO设计:相位累加、SFDR优化与工程实战
2026/10/3 3:43:33 网站建设 项目流程

前阵子做一套中频信号源,要求输出频率能从几百kHz连续切到几十MHz,步进还要小。一开始想用FPGA内部的PLL搞定,做了两天就放弃了——PLL本质是分频/倍频,不是用来做任意连续频率合成的。后来老老实实写了一个NCO(数字控制振荡器),用查表法直接把波形拉了出来,剩下的工作重心全变成了“杂散还能压几个dB、ROM还能省多少”。NCO本身不复杂,核心就三个词:相位累加器、查找表、优化。Verilog HDL和VHDL都能实现,工程里最常用、最好理解的就是查表法。这篇文章我从原理讲到代码,再讲三种优化思路和实测数据,适合三类人:刚接触FPGA想做信号发生器的,用着DDS IP但想搞明白内部到底怎么工作的,以及准备面试想系统补一遍DDS/NCO知识点的。

1. 先搞清楚NCO在干什么:相位累加器、频率字和SFDR

1.1 相位累加器就是一个“会溢出的加数器”

NCO输出的本质是一串离散正弦采样值,y[n] = sin(2π·phase[n])。这里phase[n]不是直接给一个角度值,而是通过一个固定位宽的累加器,每个时钟周期叠加一个频率控制字(FCW,Frequency Control Word)。

累加器位宽是N位,溢出后自动回绕,正好对应相位从0走到2π再回到0。这是NCO最精妙的地方:二进制无符号数溢出就是天然的取模运算,不需要任何额外判断。你可以把它理解成一个每步走固定格数的“走格子”游戏,格子总数是2^N,每步走FCW格,走满一圈的时间就是输出信号的周期。

比如N=32,累加器从0开始每个时钟加429496730,当加到溢出时,正好输出了一个周期的正弦波。这个溢出行为永远存在,但恰恰是它构成了完整的相位映射。

1.2 频率字怎么算,频率分辨率由谁决定

输出频率公式是:

f_out = (FCW × F_clk) / 2^N

反过来:

FCW = f_out × 2^N / F_clk

举两个实际例子,假设F_clk = 100MHz,N = 32:

  • 想要10.7MHz中频:FCW = 10.7e6 × 2^32 / 100e6 ≈ 459561501
  • 想要100kHz低频:FCW = 100e3 × 2^32 / 100e6 ≈ 4294967

注意FCW必须是整数,算出来有小数直接取整。因为2^32很大,一次取整带来的误差只有不到0.03Hz,工程上完全忽略不计。

频率分辨率由累加器位宽N决定,公式是:

Δf = F_clk / 2^N

100MHz时钟下,N=32时Δf约为0.0233Hz。如果做信号发生器想要更细腻的步进,可以把N提到48,分辨率直接到10的负10次方Hz量级。累加器位宽只影响频率精度,不直接决定波形质量,这是很多初学者容易混的地方。

1.3 指标上常说的SFDR到底在看什么

评判NCO输出质量的指标主要看SFDR(无杂散动态范围),单位是dBc,表示主信号幅度与最大杂散谱线幅度之差。理想正弦波信号只有一根谱线,但实际DDS输出因为相位截断、幅度量化、ROM误差等原因,会在某些频点上冒出杂散谱线,这些谱线如果落在有用信号附近,后续处理会非常难受。

工程里通常期望SFDR至少60dB以上,通信系统则可能要求80〜90dB。后面几章讲的优化,都是围绕“把SFDR推高,同时不浪费太多资源”来展开的。

2. 从一张正弦表开始:Verilog与VHDL双版本基础实现

2.1 先用脚本把正弦表生成出来

查表法第一步是准备ROM表。最稳妥的方式是用Python或Matlab生成十六进制文件,再让Verilog/VHDL读进来。下面是一个同事都直接复用的Python脚本,生成P=14位地址、W=16位幅度的正弦表:

import math P = 14 # 相位地址位宽 W = 16 # 幅度位宽 fname = "sine.hex" with open(fname, "w") as f: for i in range(2**P): phase = 2.0 * math.pi * i / (2**P) sample = int(round((2**(W-1) - 1) * math.sin(phase))) sample &= 0xFFFF # 转成16位无符号补码 f.write(f"{sample:04x}\n")

生成的文件格式是每行一个十六进制数,Verilog用$readmemh读入,VHDL可以用函数初始化。有一点要提醒:表格里的数据是按无符号二进制存储的,但解释成signed时它代表-32768到32767。如果你把signed和无符号搞混,后面做符号取反运算时会出现负数变正数、正数变负数的问题。

2.2 Verilog基础代码:累加器加ROM

一个最基础的查表法NCO就是这样,没有任何优化,但能跑:

module dds_lut #( parameter N = 32, // 累加器位宽 parameter P = 14, // ROM地址位宽 parameter W = 16 // 输出幅度位宽 )( input wire clk, input wire rst_n, input wire [N-1:0] fcw, output reg [W-1:0] sine_out ); reg [N-1:0] phase_acc; // 相位累加器 always @(posedge clk) begin if (!rst_n) phase_acc <= 32'd0; else phase_acc <= phase_acc + fcw; end // 取相位高P位作为ROM地址 wire [P-1:0] rom_addr = phase_acc[N-1 : N-P]; // 正弦查找表 reg [W-1:0] rom [0:(1<<P)-1]; initial $readmemh("sine.hex", rom); // ROM输出寄存器,这个寄存器非常关键 reg [W-1:0] rom_out; always @(posedge clk) rom_out <= rom[rom_addr]; // 再打一级寄存器作为输出 always @(posedge clk) sine_out <= rom_out; endmodule

注意几点:

  • 累加器采用同步复位,而不是异步复位。NCO这种流水型模块,异步复位释放时的毛刺可能导致第一拍累加错误,从而在输出端造成一个不可预期的相位跳变。
  • ROM输出必须打寄存器。如果不寄存,地址变化到ROM数据稳定之间的组合逻辑延迟会非常大,直接拖垮Fmax。
  • 这里打了两个寄存器的原因是我喜欢让所有输出都是寄存器输出,便于后级模块直接使用,也便于时序收敛。

2.3 VHDL版本哪里不一样

VHDL写NCO与Verilog大同小异,区别主要在类型处理和ROM初始化方式上。以下是一个贴合同一结构的VHDL核心框架:

library ieee; use ieee.std_logic_1164.all; use ieee.numeric_std.all; use ieee.math_real.all; entity dds_lut is generic ( N : integer := 32; P : integer := 14; W : integer := 16 ); port ( clk : in std_logic; rst_n : in std_logic; fcw : in unsigned(N-1 downto 0); sine_out : out signed(W-1 downto 0) ); end dds_lut; architecture rtl of dds_lut is type rom_t is array (0 to 2**P-1) of signed(W-1 downto 0); impure function init_rom return rom_t is variable rom_v : rom_t; variable phase_val : real; begin for i in 0 to 2**P-1 loop phase_val := 2.0 * MATH_PI * real(i) / real(2**P); rom_v(i) := integer(round(sin(phase_val) * real(2**(W-1)-1))); end loop; return rom_v; end function; constant rom : rom_t := init_rom; signal phase : unsigned(N-1 downto 0) := (others => '0'); signal addr : unsigned(P-1 downto 0); signal rd : signed(W-1 downto 0); begin process(clk) begin if rising_edge(clk) then if rst_n = '0' then phase <= (others => '0'); else phase <= phase + fcw; end if; end if; end process; addr <= phase(N-1 downto N-P); process(clk) begin if rising_edge(clk) then rd <= rom(to_integer(addr)); end if; end process; process(clk) begin if rising_edge(clk) then sine_out <= rd; end if; end process; end rtl;

VHDL里用函数初始化ROM,综合器一样能正确推断为块RAM,不需要依赖外部文件路径。这一点在某些没有把.hex文件放进工程目录的场合特别省心。唯一要留意的是端口类型尽量用signed/unsigned而不是std_logic_vector,否则在计算、符号扩展时容易写出很啰嗦的代码。

2.4 仿真验证容易踩的两个小坑

跑上板之前,先在ModelSim或Vivado Simulator里验证频率是否正确。

验证方法:给固定FCW,比如F_clk=100MHz、FCW=429496730,理论上输出10MHz。仿真一段时间,在输出波形上数上升沿,用仿真时间除以周期数,得到实际频率。因为FCW是整数,实际频率会是10MHz加减0.023Hz级别的偏差,肉眼看不见。

常见两个坑:

第一个坑是ROM初始化文件路径问题。ModelSim里$readmemh用的是相对工作目录的路径,如果工程目录结构与仿真脚本不一致,文件找不到,ROM全是X态,输出直接变“毛线”。我的做法是把.hex文件放在仿真脚本目录下,或者在Testbench里用绝对路径先保证能跑通。

第二个坑是ROM输出忘记打寄存器。很多初学者喜欢用组合逻辑直接输出ROM数据,仿真波形看起来也对,但综合后时序报告非常难看。这不算功能问题,但属于一上板就随机出怪的隐患。

3. 第一刀优化:利用正弦对称性把ROM砍到1/4

3.1 正弦波只需要看四分之一

前面代码存了完整周期0〜2π的正弦表,地址深度2^P。但正弦波有明确的对称性:

  • sin(π − θ) = sin(θ)
  • sin(π + θ) = −sin(θ)
  • sin(2π − θ) = −sin(θ)

也就是说,只要知道0〜π/2区间的值,其余三个象限都能通过“地址镜像+符号取反”恢复出来。ROM表一下从2^P深度变成2^(P−2)深度,存储量降为原来的25%。

以P=14为例,原表16384个点,每个点16bit,需要256Kbit;优化后只要4096个点,64Kbit。这对资源紧张的FPGA非常有价值。

3.2 地址反转和符号取反的寄存器写法

具体实现时,把相位的高两位当作象限标识,低P−2位当作表内偏移:

  • 第一象限(00):直接用偏移,符号为正
  • 第二象限(01):偏移做镜像,符号为正
  • 第三象限(10):直接用偏移,符号为负
  • 第四象限(11):偏移做镜像,符号为负

镜像操作“2^(P−2) − 1 − offset”有一个硬件上很便宜的技巧:当减数恰好是全1时,减法就等于按位异或。因为表深度是2的幂,所以镜像等于把低P−2位全部取反。

wire [1:0] quadrant = phase_acc[N-1 : N-2]; wire [P-3:0] offset = phase_acc[N-3 : N-P]; wire [P-3:0] mirror = { (P-2){1'b1} } ^ offset; // 镜像地址 wire [P-3:0] rom_addr = quadrant[1] ? mirror : offset; // 第二、四象限镜像 wire neg = quadrant[0]; // 第三、四象限取负

由于ROM深度降为2^(P−2) = 4096,定义ROM时数组大小要改成4096。ROM init数据也要改成只存0〜π/2区间的正弦值,别再把整表读进去了。

符号取负有符号处理上有个小陷阱:如果ROM中出现-32768这个值,取反会得到+32768,超出16位有符号范围。解决方法是生成表时把正弦峰值限定在±(2^(W−1)−1),也就是±32767,不要让极端值出现。上一节的Python脚本已经这么处理了,所以你直接生成的表是安全的。

3.3 从1/4到1/8:0〜π/4插值思路

继续压资源的话,可以只存0〜π/4区间,那ROM又减半。代价是逻辑复杂度上了一个台阶,因为0〜π/4到π/2的映射不再只是简单镜像,需要做一次角度变换,再加一段线性或抛物线插值来补偿误差。

我自己的经验是,0〜π/4查表配合插值适合对SFDR要求不是极高、但BRAM占用非常紧张的场合。它能把4096深度降到2048,ROM再省一半,但组合逻辑增加,时序未必更好。多数项目里做到1/4对称已经够用,1/8属于锦上添花,不是必须。

4. 第二刀优化:相位截断杂散的来源与抖动压制

4.1 相位截断是怎么把好频谱搞坏的

很多人明明ROM表已经做好,加抖动也试了,频谱却还是有很多尖峰,问题大概率出在相位截断上。

累加器是32位,ROM地址只有14位,这意味着每个时钟周期有18个低位的相位信息被直接丢弃。被丢弃的相位就构成了一个误差信号,而这个误差信号是周期性的,周期性与FCW的取值强相关,反映到频域就是一根根离散的杂散谱线。

理论上,ROM地址位宽P每增加1位,相位截断引入的杂散上限大约能改善6dB。粗略经验公式:

SFDR(相位截断) ≈ 6.02 × P dBc

P=14时理论上限约84dBc。但这是理想情况,实际还要叠加上幅度量化误差、ROM误差、时钟抖动等因素,所以实测一般落在60〜75dBc之间。如果只在特定频率点(坏点)工作,有些FCW的相位误差序列特别规律,杂散可能更低,比如50多dBc,这就是某些帖子里“NCO在某个频率处频谱特别差”的原因。

4.2 抖动为什么有用

抖动的核心思想是在被截断的低位相位上叠加一个小的伪随机数,破坏相位误差的周期性。周期性是杂散谱线产生的前提,破坏了这个周期,离散杂散能量就会被打散成宽带噪声。代价是整体噪声本底会抬升几dB,但换来了“没有一根特别突出的杂散尖峰”的频谱形态。

这个trade-off在通信系统里非常划算。因为很多接收机更怕带内单音干扰,而不太怕平整的噪声基底。抖动把一个集中的干扰能量摊开到整个频段,杂散峰值下降,系统的抗干扰裕量反而变大。

4.3 代码里怎么加LFSR抖动

工程上最常用LFSR生成伪随机序列,因为不需要真随机数,只要序列足够长、相关性低就行。下面是一个16位LFSR的Verilog片段:

reg [15:0] lfsr; always @(posedge clk) begin if (!rst_n) lfsr <= 16'hACE1; // 非零种子 else lfsr <= {lfsr[14:0], lfsr[15] ^ lfsr[14] ^ lfsr[12] ^ lfsr[3]}; end // 把抖动展开到N位并叠加在相位累加器输出上 wire [N-1:0] phase_dith = phase_acc + { {(N-16){1'b0}}, lfsr }; // 高P位仍然作为ROM地址 wire [P-1:0] rom_addr = phase_dith[N-1 : N-P];

注意抖动要加在相位累加器输出之后、截断之前,不能直接改累加器。直接改累加器会影响频率字积分,导致输出频率抖动。叠加在截断前,只是把被丢弃的低位“搅浑”,对主频率几乎没影响。

我做过的对比实验中,某一组FCW参数下,不抖动时SFDR只有59dBc,加入16位LFSR抖动后SFDR升到76dBc,但噪声本底抬高了约5dB。频谱从一片尖峰变成平缓的噪声平台,后续滤波器压力小很多。

5. 第三刀优化:块RAM映射、流水线与Fmax

5.1 大ROM一定要想尽办法映射到块RAM

如果ROM容量很小(几十个字),用LUT实现没问题。但查表法NCO动辄几千上万深度,用LUT会消耗大量逻辑资源,组合延迟也线性增加,Fmax很难看。

FPGA里的BRAM天然适合这种“地址进、数据出”的只读表。Verilog代码里只要写成一个always块根据地址读数组,综合器通常能自动推断为块RAM。不确定的时候,显式加综合属性:

(* ram_style = "block" *) reg [W-1:0] rom [0:(1<<(P-2))-1];

Altera/Intel Quartus下的写法是:

(* ramstyle = "M9K" *) reg [W-1:0] rom [0:(1<<(P-2))-1];

用块RAM还有个额外好处:块RAM本身自带可选的输出寄存器,开启后能进一步压缩时序路径。这一点对高速设计太重要了。

5.2 流水线打拍:让NCO跑上高频

未加输出寄存器的NCO时序路径是“累加器输出 → 地址解码 → ROM读数据 → 输出”,累计延迟主要由ROM访问时间决定。加上输出寄存器后,Fmax往往能翻倍。

实际工程里我会做两级流水:

  • 第一级:相位累加器输出(本身是寄存器,天然一级)
  • 第二级:块RAM输出打一拍
  • 如果需要做象限镜像和符号取反,把这两步组合逻辑也放在第二级和第三级之间,再打一拍

以我在Artix-7(速度等级-1)上的实测为例,未加块的组合输出NCO大概只能跑到150MHz左右,加上一级ROM输出寄存器,Fmax直接到260MHz,再把符号处理打一拍,可以稳定跑在320MHz以上。输出频率并不受影响,因为流水线对连续流数据没有任何间隔,只是增加固定延迟。

5.3 多通道共享ROM和IQ双输出

如果系统里有多个独立通道都需要正弦波,没必要每个通道复制一份ROM。块RAM是双端口的,可以同时读两个地址;更进一步,可以用更高时钟频分复用,比如系统需要4个NCO,但BRAM只有一个,用4倍时钟周期轮流查询4个通道的相位地址,ROM就共享了。

IQ双输出是通信里最常见的需求:一路sin一路cos。做法也非常简单,不用额外存一张cos表,因为cos(θ) = sin(θ + π/2),只要在查表时给地址加一个π/2对应的偏移量即可。在0〜π/2压缩表中,这个偏移量正好是表深度的一半,也就是offset加上2^(P−2),再取模。配合块RAM双口,一个周期同时输出sin和cos。

6. 实测对比:不同参数的资源、速度与频谱质量

6.1 本次实测的配置与测试方法

为了写这篇文章,我在一块Artix-7 XC7A35T上用三个配置做了对比:

  • 配置A:N=32,P=14,W=16,完整正弦表,无优化
  • 配置B:N=32,P=14,W=16,1/4对称表
  • 配置C:配置B基础上加16位LFSR抖动和两级流水线

测试时钟100MHz,用固定FCW输出约3.1MHz信号。仿真波形导出后,用Python做加窗FFT,统计SFDR。

6.2 资源占用实测数据

三组配置的资源、时序实测如下表:

配置36Kb BRAMLUTFF最高Fmax(实测)实测SFDR
配置A 全表无优化8120左右45150MHz68dBc
配置B 1/4对称295左右50210MHz65dBc
配置C 抖动+流水线2110左右80320MHz76dBc

几个有意思的点:

  • 配置A的SFDR理论上应该最好,因为没有任何截断映射逻辑,但实际测出来只有68dBc,说明在P=14这个层面,幅度量化和ROM误差已经掩盖了相位截断的影响。
  • 配置B的SFDR反而比配置A低了一点,这个差异主要来自象限映射带来的额外相位误差,但在可接受范围内。
  • 配置C加入抖动后,SFDR明显改善,从65dBc跳到76dBc,代价是多消耗了一些FF和LUT。

6.3 按需求选参数的三条原则

第一,累加器位宽N只决定频率分辨率,不影响SFDR和资源。普通信号发生器和通信混频器N=32够用,计量级设备可以上48。

第二,ROM地址位宽P与SFDR强相关,但收益不是无限的。P超过W之后,相位截断杂散低于幅度量化本底,再加P就没意义了,只会浪费BRAM。所以P取到和W接近即可,P=14、W=16就是常见甜点。

第三,输出位宽W必须与后续DAC/处理链路匹配。如果后面接12位DAC,NCO输出16位纯属浪费资源,不如截到14位;如果后面做高精度FFT分析,W至少16位起步。

7. 把它用进真实系统:IQ本振、工程坑和IP核选择

7.1 用作数字混频本振:IQ双输出怎么做

通信系统里NCO最经典的应用是作为数字下变频(DDC)的本振。输入信号分别乘以sin和cos,就能得到正交的I/Q两路基带信号。

实现时,NCO部分增加一个cos输出,也就是在ROM查询时给地址加上表深度的四分之一偏移。配合块RAM双口,一个模块同时输出I路本振和Q路本振,代码量增加不到十行。混频器本身就是一个乘法器,如果FPGA里有DSP48单元,直接例化乘法器,注意把NCO输出和输入信号的位宽对齐,避免截断造成额外噪声。

7.2 工程中反复踩的坑清单

这条清单里的每一个坑我都实际踩过:

  • 混叠:NCO输出频率必须小于F_clk/2。想输出30MHz,时钟只有50MHz是做不到的,这是奈奎斯特硬限制。设计前先算好频率范围,别等波形怪了才发现。
  • 跳频时不要清零相位累加器。需要切换频率时,只需要在某个时钟沿把FCW换成新值,下一拍频率自然变化,相位保持连续。如果清零累加器,输出波形会断一下,模拟域可能直接冒出一个大毛刺。
  • 补码极值溢出问题。取符号反时如果ROM里有-32768,会翻到+32768溢出。前面说过,生成表时控制峰值在±32767可以规避。
  • 同步复位比异步复位稳妥。NCO内部大量移位寄存器,异步复位释放时的亚稳态容易让输出相位错拍,这也是FPGA面试里经常被追问的点。
  • 打拍延时要统一。ROM查询输出相比输入相位有一个固定的延迟,如果NCO在同一设计里还承担“相位对齐”任务,记得把其他数据路径通过FIFO或打拍校准到同等延迟,否则解调出来的相位会偏。
  • 仿真顺手,上板不对,先查复位。很多FPGA开发板上的复位按键是高电平有效,而很多代码写的是低电平复位,接反了NCO永远停在复位状态,现象就是输出直流。

7.3 什么时候该换IP核

Xilinx的DDS Compiler和Intel的NCO IP都能直接生成参数化NCO,内部已经集成了对称表、抖动、泰勒级数校正等优化,性能和资源都很好。如果你的项目时间紧、需求标准,直接用IP核绝对没错。

但我仍建议至少手写一次NCO。原因有两个:一是面试和方案评审时,只会调用IP核和能解释相位截断、抖动原理是两回事;二是IP核毕竟是黑盒,遇到自定义需求(比如线性调频、跳频图案、BPSK相位切换)时,手写代码改动起来非常灵活。

我自己做的很多项目里,手写NCO代码不过百行,但配合一个干净的接口规范,比反复配置IP核还省事。真要上IP核,也建议先用这个手写版本做功能基准,方便对照验证IP核的配置对不对。


最后说点个人的体会。查表法NCO这几个优化点,按重要程度排序,我认为是:块RAM映射 > 输出寄存器/流水线 > 1/4对称压缩 > 抖动。前两件事决定能不能跑起来、跑多快;后两件事决定频谱好不好看、资源省多少。遇到一个新项目,我通常先用“32位累加器 + 14位表地址 + 16位幅度 + 1/4对称 + LFSR抖动 + 两级流水线”这一套固定配置,再根据实际系统需求微调。用了这么多次,还没翻过车。下一次做信号发生器或数字混频时,你也可以直接从这套配置开始。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询