如果说点亮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,其框图如下紫色框所示,黄色框为相位累加器,红色为存储正弦波形数据。
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 文件,你会看到类似这样的内容,这就是我们需要的初始化数据。
运行结果:
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 文件。
配置截图如下:
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)仿真波形窗口如下:
|