问答 店铺
热搜: ZYNQ FPGA discuz

QQ登录

只需一步,快速开始

微信登录

微信扫码,快速开始

微信扫一扫 分享朋友圈

已有 24 人浏览分享

开启左侧

第四章:数字信号处理的"Hello World" - 数控振荡器 (NCO)

[复制链接]
24 0
       如果说点亮LED是FPGA世界的“Hello World”,那么数控振荡器(Numerically Controlled Oscillator, NCO)就是FPGA数字信号处理领域的“Hello World”。
       项目目标: 在FPGA内部,纯数字地生成一个频率、相位都可精确控制的高质量正弦波。
       NCO是许多复杂通信和信号处理系统(如软件无线电、信号发生器)的核心模块。通过这个项目,你将第一次完整地体验从算法建模到硬件实现的全过程,并将上一章学到的定点数知识付诸实践。
1 原理剖析:相位累加器与正弦查找表 (LUT)
我们如何在数字世界里“画”出一个完美的正-弦波?答案出奇地简单,只需两步:
       匀速地转圈: 想象一个在单位圆上以恒定角速度旋转的点。它在Y轴上的投影就是一个完美的正弦波。在数字世界里,这个“旋转”的动作由相位累加器 (Phase Accumulator) 完成。
       投影计算: 将每个时刻的角度(相位)通过 sin() 函数计算出其投影值。由于在FPGA中实时计算 sin() 函数非常消耗资源,我们采用一种更聪明的方法:查找表 (Look-Up Table, LUT)。
工作流程如下:
相位累加器:
  • 它本质上是一个N位的加法器和一个N位的寄存器。
  • 在每个时钟周期,它将一个固定的频率控制字 (Frequency Control Word, FCW) 加到当前寄存器的值上。
  • 寄存器的值就代表了当前时刻的相位,范围从 0 到 2^N - 1,正好对应 0 到 2π 的相位。
  • FCW越大,每个时钟周期相位增加得越快,输出正弦波的频率就越高。
查找表 (Look-Up Table, LUT):
  • 这是一块预先存储了正弦函数一个周期采样值的存储器(在FPGA中通常是Block RAM)。
  • 我们将相位累加器输出值的高位部分作为地址,去查找表中“查询”对应的正弦幅值。
  • 为什么只用高位?因为我们不需要对 2^N 这么多个相位点都存储幅值,这太浪费存储空间了。通常用高M位(例如10位,即1024个点)就足以获得很高精度的正弦波。
频率计算公式:
f_out = (FCW * f_clk) / 2^N
其中:
  • f_out 是输出正弦波的频率。
  • FCW 是频率控制字。
  • f_clk 是系统时钟频率。
  • N 是相位累加器的位宽。
图示:关于NCO的结构主要包括相位累加器和正弦波存储ROM,其框图如下紫色框所示,黄色框为相位累加器,红色为存储正弦波形数据。
image.jpg

2 MATLAB建模:生成高精度正弦波.coe文件
       在动手写Verilog之前,我们先用MATLAB来完成查找表的设计工作。我们的任务是生成一个包含正弦波采样值的文件,这个文件将用来初始化FPGA中的Block RAM。
       目标: 创建一个深度为1024(10位地址),数据位宽为16位的正弦查找表。
MATLAB脚本 (generate_sine_coe.m)
% NCO Sine LUT Generation Script

% 1. 参数定义
ADDR_WIDTH = 10; % 地址位宽 (Address Width)
DATA_WIDTH = 16; % 数据位宽 (Sample Width)
LUT_DEPTH = 2^ADDR_WIDTH; % 查找表深度 (LUT Depth)
FILENAME = 'sine_lut.coe'; % 输出文件名

% 2. 生成理想的浮点正弦波
% 生成 0 到 2*pi 之间的 LUT_DEPTH 个相位点
phase = linspace(0, 2*pi, LUT_DEPTH + 1);
phase = phase(1:end-1); % 去掉最后一个点,保持周期性

% 计算对应的正弦值 (-1.0 to 1.0)
sine_wave_float = sin(phase);

% 3. 量化为定点数 (Q1.15格式)
% 我们希望将幅值映射到16位有符号整数范围 [-32768, 32767]
% 最大幅值 (接近1.0) 应该映射到 32767
scaling_factor = 2^(DATA_WIDTH - 1) - 1;
sine_wave_fixed = round(sine_wave_float * scaling_factor);

% 确保数据在范围内
sine_wave_fixed(sine_wave_fixed > 32767) = 32767;
sine_wave_fixed(sine_wave_fixed < -32768) = -32768;

% 4. 绘图检查
figure;
plot(sine_wave_fixed);
title(sprintf('%d-Point, %d-Bit Sine Wave LUT Data', LUT_DEPTH, DATA_WIDTH));
xlabel('Address');
ylabel('Quantized Amplitude');
grid on;

% 5. 生成 Vivado .coe 文件
% .coe 文件格式要求
fid = fopen(FILENAME, 'w');
fprintf(fid, 'memory_initialization_radix=10;\n'); % 数据为十进制
fprintf(fid, 'memory_initialization_vector=\n');

% 写入数据,用逗号分隔,最后一个数据后用分号
for i = 1:LUT_DEPTH-1
fprintf(fid, '%d,\n', sine_wave_fixed(i));
end
fprintf(fid, '%d;\n', sine_wave_fixed(LUT_DEPTH)); % 最后一个数据

fclose(fid);
disp(['Successfully generated ' FILENAME]);

操作指南:
1.在MATLAB中新建一个脚本,将上述代码粘贴进去。
2.运行脚本。你会看到一张正弦波的图,并且在当前目录下会生成一个名为 sine_lut.coe 的文件。
3.打开 sine_lut.coe 文件,你会看到类似这样的内容,这就是我们需要的初始化数据。
运行结果:
image.jpg

3 FPGA实现:设计与实现一个NCO模块
现在,我们拥有了正弦波的“菜谱”(.coe文件),是时候在FPGA这个“厨房”里把它做出来了。
1.在Vivado中创建新工程或添加新源文件。
2.添加Block Memory IP核:
  • 在 "IP Catalog" 中搜索 "Block Memory Generator"。
  • 配置IP核:
       Interface Type: Native
       Memory Type: Single Port ROM
       Port A Options:
             Port A Width: 16 (我们的数据位宽)
             Port A Depth: 1024 (我们的查找表深度)
       Other Options 标签页:
             勾选 "Load Init File"。
             Browse... 选择我们刚刚生成的 sine_lut.coe 文件。
生成IP核。Vivado会自动创建一个 .xci 文件。
配置截图如下:
image.jpg
image.jpg
image.jpg
3.编写NCO顶层模块 (nco.v)
module nco #(
    parameter PHASE_WIDTH = 32, // 相位累加器位宽
    parameter ADDR_WIDTH  = 10  // LUT地址位宽
)(
    input  wire                 I_clk,
    input  wire                 I_rst,
    input  wire [PHASE_WIDTH-1:0] I_fcw,       // 频率控制字
    output wire signed [15:0]   O_sine_out   // 16位正弦输出
);

// 内部信号
reg [PHASE_WIDTH-1:0] phase_accumulator;

// 1. 相位累加器
always @(posedge I_clk) begin
    if (I_rst) begin
        phase_accumulator <= 0;
    end else begin
        phase_accumulator <= phase_accumulator + I_fcw;
    end
end

// 2. 例化 Block Memory (Sine LUT)
// 模块名和端口名需要根据你生成的IP核的instantiation template来修改
blk_mem_gen_0 sine_lut_inst (
  .clka(I_clk),                  // input wire clka
  .addra(phase_accumulator[PHASE_WIDTH-1 : PHASE_WIDTH-ADDR_WIDTH]), // input wire [9 : 0] addra
  .douta(O_sine_out)             // output wire [15 : 0] douta
);

endmodule

代码讲解:
(1)我们使用了参数化设计,使得相位累加器位宽可配置。
(2)相位累加器是一个简单的加法器+寄存器结构。
(3)关键点: 我们将 phase_accumulator 的最高 ADDR_WIDTH 位(即[31:22])连接到ROM的地址端口 addra。这就是所谓的“相位截位”。
(4)sine_out 直接从ROM的数据输出端口 douta 引出。

4 仿真验证:在Vivado中观察生成的正弦波
       口说无凭,仿真为证。我们需要编写一个Testbench来测试我们的NCO模块。
       Testbench代码 (tb_nco.v)
`timescale 1ns / 1ps

module tb_nco;

    // 参数
    localparam CLK_PERIOD = 10; // 时钟周期 10ns -> 100MHz
    localparam PHASE_WIDTH = 32;

    // 信号
    reg I_clk;
    reg I_rst;
    reg [PHASE_WIDTH-1:0] I_fcw;
    wire signed [15:0] O_sine_out;

    // 实例化DUT
    nco #(
        .PHASE_WIDTH(PHASE_WIDTH)
    ) uut (
        .I_clk(I_clk),
        .I_rst(I_rst),
        .I_fcw(I_fcw),
        .O_sine_out(O_sine_out)
    );

    // 时钟生成
    always #(CLK_PERIOD/2) I_clk = ~I_clk;

    // 测试激励
    initial begin
        // 初始化
        I_clk = 0;
        I_rst = 1;
        I_fcw = 0;
        # (CLK_PERIOD * 10); // 等待10个时钟周期
        I_rst = 0;

        // 设置一个I_fcw来生成一个特定频率的正弦波
        // f_out = (I_fcw * f_clk) / 2^32
        // 假设我们想生成 1MHz 的波形 @ 100MHz 时钟
        // I_fcw = (1e6 * 2^32) / 100e6 = 42949672.96
        // 我们取整数 42949673
        I_fcw = 32'd42949673;

        // 运行一段时间后停止仿真
        # (CLK_PERIOD * 2000);
        $stop;
    end

endmodule

操作指南:
(1)在Vivado中添加上述Testbench作为仿真源文件。
(2)在 "Simulation Settings" 中设置 tb_nco 为顶层模块。
(3)运行 "Run Simulation"。
(4)在波形窗口中,将 sine_out 信号拖拽出来。
(5)关键一步: 右键点击 sine_out 信号,选择 "Waveform Style" -> "Analog",并设置Radix为 "Signed Decimal"。
(6)结果: 你将看到一条非常平滑、漂亮的数字正弦波形!这直观地证明了你的NCO设计是正确的。
(7)仿真波形窗口如下:
image.jpg
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

0

关注

10

粉丝

150

主题
精彩推荐
热门资讯
    网友晒图
      图文推荐
        
        • 微信公众平台

        • 扫描访问手机版