分享

如何在FPGA中构建数控振荡器 (NCO)

 比特波特 2022-07-25 发布于浙江

许多信号处理应用在某些特殊项目时需要正弦波。如果这个正弦波的相位或频率在设计中能被控制,那么它通常被称为数控振荡器 (NCO)。今天让我们花一些时间研究如何在FPGA中构建一个NCO。最后我们还将介绍一个 C++ 实现方案,它可以用于嵌入式应用程序。

我们是在可以生成正弦波(查找表方法、四分之一波查表方法、CORDIC的余弦波)情况下,如何通过正弦波发生器变成 NCO。

不过,在我们深入细节之前,让我们花点时间思考一下如何使用这样的 NCO。我今天提出这个的原因是双重的。首先,我知道有一个学生在努力理解如何构建这样的东西作为数字通信解调器的一部分。第二部分是构建一个比这篇 stackoverflow 文章(https:///questions/13466623/how-to-look-up-sine-of-different-frequencies-from-a-fixed-sized-lookup-table)推荐的更好的NCO。

但这几乎没有可能触及你想做一个NCO的想法。考虑一个例子......

  • 任何线性信号处理系统都完全以其频率响应为特征。因此,我过去曾使用 NCO 以及某种类型的示波器来评估信号处理算法的数字输入是否得到正确处理。

  • 我们之前使用 NCO作为演示的一部分, 证明改进的 PWM 发生器(http:///dsp/2017/09/04/pwm-reinvention.html)在音频信号生成方面比传统 PWM工作得更好。虽然我们当时没有讨论音调发生器的细节,但我们今天将解释其中的许多细节。

  • 还可以使用 NCO 在频率上移动信号——或者将其从某个中频 (IF)降低到可以在接收器中处理它的基带频率,或者作为传输的一部分在另一个方向上相同算法。

  • 还可以将 NCO 用作数字通信调制器或解调器的一部分。

  • 可以使用 NCO创建调幅 (AM) 信号甚至是调频 (FM)信号。

  • 另一个常见用途是通过加法合成器生成音符。事实上,我们将在下面开发的NCO的相位累加器部分甚至可以用于减法合成——它非常通用。

  • 最后,由于作为设计人员可以完全控制 NCO的频率输出,因此 甚至可以做一些更奇特的事情——例如构建跳频扩频信号——这应该是你想要做的。

事实上,NCO是数字信号处理 (DSP) 算法的基本组成部分,因此很难在此处列举它们可用于的所有内容。

什么是 NCO?

图 1. NCO

NCO 将频率输入转换为正弦波,就我们今天的目的而言,数控振荡器只是一个由数字逻辑创建的振荡器,可以完全控制数字逻辑。名义上,这种振荡器将接收希望产生的频率作为输入,并在该频率下产生数字采样的正弦波。如果选择在PLL中使用 NCO,那么还将调整此正弦波发生器的相位。然而,现在,将 NCO视为 一个简单的数字逻辑电路,它接受频率输入并产生采样的 正弦波作为输出。

图 2. 框图

基本 NCO 的组成部分.在内部, NCO 跟踪它产生的正弦波的相位,并在每个采样点增加该相位。

让我们通过三角函数来看看它是如何工作的。

我们将从我们想要产生的正弦波开始 ,如图 3 所示的正弦波:

图 3. 正弦波

并且由等式给出:

由于数字实现只能处理采样信号,因此我们需要每秒钟采样一次这个正弦波Ts。为了使我们的符号保持直截了当,我们现在将按样本编号索引这个正弦波输出,而不是按时间索引。

我个人发现使用数字化的采样率比使用采样之间的时间更容易。它们互为倒数,所以,我们可以将这个相同的方程表示为,fsTsfs = 1/Ts

并绘制图 4 中的采样函数。

图 4. 采样的正弦波

在该图中,采样点以圆圈显示。它们各自被一个2pi f/fs相隔开。

然而,我们在这个算法中的全部重点将放在这个表达式的相位上——正弦波的参数。在上面的表达式中,这个相位由频率比n给出。我们称之为变化的相位值。f/fs 2pi phi[n]

要构建 NCO,我们需要将此相位值phi[n]转换为我们的正弦波发生器可以处理的输入。

我们将首先使用这个相位值phi[n]重写我们的正弦波,以便它捕获这个正弦波的内部——除了部分之外。

具体来说,该相位值由下式定义:

它代表我们的正弦波绕单位圆转的次数——如果愿意的话,就是旋转的次数。

图 5. 单位圆旋转

例如,phi[n]为 1.0,在内部应用于我们的正弦波,将导致正弦函数的相位参数——2pi,表明我们绕单位圆转了一圈 。2.0 的Aphi[n]将产生相同的值,但表示相位已围绕单位圆行进两次。然后分数将表示从 x 轴围绕单位圆的部分角度,因此 phi[n]为0.5将表示绕该圆的一半, 而0.25将代表绕圈的四分之一。

让我们继续使用这个值。我们可以根据前一个阶段递归地定义这个阶段。

这个简单的修改实现了两个目的。首先,它允许我们避免乘以n,将下一个阶段的计算从过去阶段转变为仅需要加法的运算。其次,因为这个较新的NCO版本不再与的距离相关,这种细微的变化使我们能够在n=0中保持累积的相位偏移 ——不仅仅是在零时间相位为零的频率。

到目前为止,这听起来相当简单。有什么诀窍?

建立 NCO 的“诀窍”

建立 NCO的“诀窍” 在于phi[n]. 上面介绍的的单位是围绕单位圆phi[n]的循环数(或旋转)。因此,phi[n] 为 1.0 表示绕单位圆1 次,phi[n]为 2.0 表示绕单位圆2 次,依此类推。

但是,在大多数信号处理逻辑(不是全部)中,并不关心正弦波绕单位圆多少整数次 ,只关心角分数。因此,让我们从整数和小数部分的角度来检查这个数字。

具体来说,让我们将它分成一个整数部分,以及 W小数点后的第一位,如下所示。

phi[n] 分为整数分量和 W 小数分量

从图形上看,删除整数部分可能如下图 6 所示。

图 6. 相位函数

请注意图 6 中相位如何在正弦波开始重复的同一点跳回零。当然,不需要将此值恢复为零,但这样做会创建一个有限的范围,然后我们可以将其拆分为固定数量的位-W.

由于我们不关心绕单位圆的整数次,我们可以从它的整数部分中减去phi[n],它来单独恢复分数——就像我们在上面的图 6 中所做的那样。然后,我们可以将结果乘以2^W,从而得到一个适合位长字的定点相位,表示为:

为了完成这个,我们只保留这个值的整数部分,代表我们的小数相位W的最高位,我们将忽略小数点以外的任何其他位。

因此PHI[n],现在是一个介于0和之间的数字,2^W-1表示围绕单位圆0的分数旋转,介于0和1之间的值。

这是我们“把戏”的第一部分。

对于这个“技巧”的第二部分,让我们使用P作为PHI[n]的高位 , 作为正弦波发生器的输入,无论是 查表正弦波发生器,还是 CORDIC算法的相位输入相位值 。

但是我们怎么用定点相位表示的其余W位呢?

这些可以用于两个目的之一。首先,它们可以用作分数表索引,随着时间的推移累积以调整我们的表索引。下面的图 7 显示了这种情况的一个示例。

图 7 分数相累积

如果仔细查看此图,可以看到相位指针一次移动多于一个表位置。最终,这个额外的累积分数会导致表索引完全跳过表位置(位置 6)。

这将允许以不是通过表格的整数步长形成的频率来表示和创建正弦波。这样的频率可能涉及跳过表格条目,如上图 7 所示,或者在必要时甚至重复条目。是的,跳跃条目可能会导致输出失真,但它也将帮助保持 比仅第一位所允许的更好的频率分辨率。

底部位的第二个用途W-P是作为内插方案的一部分,以减少与任何表格表示相关的相位噪声。这是一个非常重要的可能性,我们可能不得不回到它并在以后的文章中更多地写它。

但是,溢出呢?

这是一个重要的问题,所以让我们通过一个例子来看看会发生什么。考虑一下如果我们在一个 8 位字中跟踪相位,并且我们的一个加法溢出会发生什么。例如,假设您想从PHI[n]=8'h20(45 度)开始绕一个圆圈走四步 。然后,将在每个时钟上添加8'h40(90 度)。然后,结果序列将是8'h20(45 度)、 8'h60(135 度)、8'ha0(225 度)、8'he0(315 度)、 8'h20(45 度)。

你有没有发现刚刚发生的事情?相位累加器刚刚在 315 度和 45 度之间溢出,但相位表示只是“做对了”!这意味着可以忽略相位中的任何溢出——无论如何它只会环绕单位圆,而正弦波发生器只对相位分数感兴趣。

示例源代码

那么,这在实践中会如何呢?让我们看一个例子,看看这在 C++ 和 Verilog 中会是什么样子。

我们将从一个 C++ 示例开始。我们将制作一个包含这些原则的 C++ NCO类。本课程包含三个基本部分。首先是类声明和表生成。

class NCO {
public:
 unsigned m_lglen, m_len, m_mask, m_phase, m_dphase;
 float  *m_table;

 NCO(const int lgtblsize) {
  // We'll use a table 2^(lgtblize) in length.  This is
  // non-negotiable, as the rest of this algorithm depends upon
  // this property.
  m_lglen = lgtblsize;
  m_len = (1<<lgtblsize);

  // m_mask is 1 for any bit used in the index, zero otherwise
  m_mask = m_len - 1;

我们将使用任何间隔左边缘的正弦波值来构建表格本身。这不是最优的,因为它会在左边缘强制误差为零,并可能在间隔的右侧使其成为最大值,但它会很快为我们提供不错的能力。

  m_table = new float[m_len];
  for(k=0; k<m_len; k++)
   m_table[k] = sin(2.*M_PI*k / (double)m_len);

我们可能会在稍后的帖子中回到这一点,以最小化此查找中的最大错误。

此初始化的最后一部分是为我们的 相位 累加器 ( PHI[n]) 提供初始值以及创建已知频率输出所需的相位步长。在这种情况下,我们将初始化这一步,使其产生零频率——这不是很令人兴奋,但下一步将是修复它。

  // m_phase is the variable holding our PHI[n] function from
  // above.
  // We'll initialize our initial phase and frequency to zero
  m_phase = 0;
  m_dphase = 0;
 }

 // On any object deletion, make sure we delete the table as well
 ~NCO(void) {
  delete[] m_table;
 }

这个实现的第二部分是设置频率的函数。

 // Adjust the sample rate for your implementation as necessary
 const float SAMPLE_RATE= 1.0;
 const float ONE_ROTATION= 2.0 * (1u << (sizeof(unsigned)*8-1));

 float frequency(float f) {
  // Convert the frequency to a fractional difference in phase
  m_dphase = (int)(f * ONE_ROTATION / SAMPLE_RATE);
 }

作为个人实践,我从不SAMPLE_RATE参与我的NCO 实施。这样,一个 NCO 实施就可以跨多个项目工作。

你可能会发现上面逻辑中最令人困惑的部分是 ONE_ROTATION值。这是代表围绕位圆一周的相位值。它由2^W 给出 。但是,我们必须通过一些环来设置这个值,因为该值不适合我们用来保存ONE_ROTATION 的整数。或者,我们可能已经设置为 unsigned PHI[n]m_phase,但是上面的方法使编译器更容易识别这个值是一个常量,而不是需要调用数学库函数。

这个类的最后一部分将索引向前一步插入到表中,然后返回表中由m_phase 单词P中的最高位给出的索引处的值。

 float operator ()(void) {
  unsigned index;

  // Increment the phase by an amount dictated by our frequency
  // m_phase was our PHI[n] value above
  m_phase += m_dphase; // PHI[n] = PHI[n-1] + (2^32 * f/fs)

  // Grab the top m_lglen bits of this phase word
  index = m_phase >> (sizeof(unsigned)*8)-m_lglen);

  // Insist that this index be found within 0... (m_len-1)
  index &= m_mask;

  // Finally return the table lookup value
  return m_table[index];
 }

可能会注意到我选择使用单精度floats,而不是double精度浮点数。我这样做有两个原因。

首先,我想鼓励你提出一个问题,即实际需要多少精度?

其次,我想指出单精度float表示只有 24 位尾数。今天的大多数 CPU都允许 32 位的整数。结果,整数相位累加器比相位累加器具有更高的精度float。可以在图 8 中以图形方式看到这一点。

图 8. 浮点与定点相位

比较固定和浮点相位表示

如果不熟悉单精度 IEEE 浮点数,第一位 S, 是符号位,接下来的七位E, 是指数位。最后的 24 位M, 是尾数位。放在一起,这些项目代表了一种类似的数字(-1)^S * 2^E * M。(是的,我在这里跳过了一些细节。)

与 IEEE 浮点数不同,我们的定点相位表示只是32尾数位,值范围从0到1。

你认为哪一个会更精确?

以类似的方式,如果unsigned long对累加器使用 an 而不是unsignedvalue,则 相位 累加器的精度将高于double精度浮点所允许的精度。

在这两种情况下,这个相位累加器优于浮点相位累加器的原因很简单,因为我们的表示具有固定的小数位置,而不是浮点小数点。这允许将每个字的更多位分配给尾数,因为浮点表示需要为符号位和指数分配额外的位。

如果您在 Verilog 中构建 NCO 实现,代码几乎相同。最大的区别首先是我们要求给定的频率已经转换为适当的单位,其次是位选择比以前更简单。

module nco(i_clk, i_ld, i_dphase, o_val);
 parameter LGTBL = 9, // Log, base two, of the table size
   W = 32, // Word-size
   OW = 8; // Output width
 localparam P = LGTBL;
 //
 input wire  i_clk;
 //
 input wire  i_ld;
 input wire [W-1:0] i_dphase;
 //
 input wire  i_ce
 output wire [OW-1:0] o_val;

任何时候请求新频率i_ld时,都会将信号设置为高电平并将新频率置于i_dphase. 此 频率值以m_dphase上面 C++ 代码中的单位为单位。

 reg [W-1:0] r_step;

 initial r_step = 0;
 always @(posedge i_clk)
 if (i_ld)
  r_step <= i_dphase; // = 2^W * f/fs

同样,在任何i_ce为 high 的时钟上,我们将相位向前步进相同的频率相关量。

 reg [W-1:0] r_phase;

 initial r_phase = 0;
 always @(posedge i_clk)
 if (i_ce)
  // PHI[n] = PHI[n-1] + 2^W * f / fs
  r_phase <= r_phase + r_step;

最后,在我们的查表r_phase中使用的最高位P。

 sintable // #(.PW(P), .OW(OW))
  stbl(i_clk, 1'b0, i_ce, 1'b0, r_phase[(W-1):(W-P)],
  o_val, ignored);
endmodule

可能会注意到 C++ 和 Verilog 的实现非常相似。它们都是低逻辑实现,展示了创建NCO所需的基础知识 。

未来的规划

我们刚刚介绍了构建基本 NCO背后的逻辑。虽然这种方法生成的正弦波并不完美,但对于项目来说可能已经足够了。如果需要更高质量的正弦波,可能希望知道除了我们之前讨论过的简单查表方法之外,还有其他更好的正弦波发生器。

参考

http:///dsp/2017/12/09/nco.html

    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多