在第四章中,我们成功地生成了纯净的数字信号。然而,在真实世界中,信号总是伴随着各种噪声。滤波器的核心任务就是去伪存真,从含噪的信号中提取出我们感兴趣的部分。
本章,我们将从最简单、最直观的滤波器——移动平均滤波器(Moving Average Filter)入手,学习如何设计一个基本的低通滤波器,并亲眼见证它平滑噪声、让信号“恢复平静”的神奇效果。
项目目标: 设计一个N点移动平均滤波器,并用它来处理一个被高频噪声污染的正弦波信号。
1 项目目标
欢迎来到你的第一个数字信号处理(DSP)项目!在本章中,我们将从零开始,在FPGA上设计、实现并验证一个基础但极为重要的滤波器——移动平均滤波器 (Moving Average Filter)。
这个项目的目标不仅仅是实现功能,更是要学习:
硬件思维:如何将一个数学算法,从“软件思维”转化为适合FPGA流水线结构的“硬件思维”。
高效实现:学习一种工业界常用的、高性能的滤波器实现结构,避免初学者常犯的时序错误。
专业验证:建立一个从MATLAB生成测试数据,到Verilog Testbench进行自动化比对的完整、可靠的验证流程。
2 移动平均滤波器:从理论到硬件的思考
移动平均滤波器的原理很简单:将最近N个采样点的值相加,然后除以N,得到当前点的输出。其数学公式为:
y[n] = (x[n] + x[n-1] + ... + x[n-N+1]) / N
其中,y[n]是当前时刻的输出,x[n]是当前时刻的输入,N是滤波器的“阶数”或“窗口大小”,我们称之为TAPS。
初学者的陷阱:直接翻译公式
如果直接将公式翻译成Verilog,我们可能会写出这样的伪代码:
// 这是一个不好的设计,请勿使用!
always @(posedge clk) begin
sum = x[n] + x[n-1] + ... + x[n-N+1]; // N输入加法器
output = sum / N;
end
| 这个设计的致命缺陷在于,N个输入的加法器是一个巨大的组合逻辑。综合工具会将其展开成一棵庞大的加法树。这个加法树的延迟非常长,它必须在一个时钟周期内计算完成,这会成为设计的关键路径,严重限制你的时钟频率。当N稍大时(例如16或32),几乎必然会导致时序违例。
专业的实现:利用递归思想
一个高效的FPGA设计需要我们对算法进行转换。观察求和部分:
Sum[n] = x[n] + x[n-1] + ... + x[n-N+1]
Sum[n-1] = x[n-1] + x[n-2] + ... + x[n-N]
将这两个式子相减,我们得到一个美妙的递归关系:
Sum[n] - Sum[n-1] = x[n] - x[n-N]
Sum[n] = Sum[n-1] + x[n] - x[n-N]
这个公式是FPGA实现的黄金准则!它告诉我们,在每个时钟周期,我们不需要做N次加法,而只需要:
1.拿到上一周期的和 (Sum[n-1])
2.加上当前的新输入 (x[n])
3.减去N个周期前最老的那个输入 (x[n-N])
这个计算路径非常短(一次加法和一次减法),与N的大小无关,可以运行在极高的时钟频率下,并且资源占用极少。这正是我们要在硬件中实现的结构。
3 步骤一:Verilog硬件设计
根据上面的递归公式,我们来编写Verilog代码。我们需要三个主要部分:
1.一个移位寄存器,用于存储历史数据,以便在需要时能提供最老的x[n-N]。
2.一个累加器寄存器,用于实现 Sum[n] = Sum[n-1] + x[n] - x[n-N]。
3.输出逻辑,将累加和除以N(通过右移实现)。
moving_average_filter_optimized.v
module moving_average_filter_optimized #(
parameter DATA_WIDTH = 16,
parameter TAPS = 8
)(
input wire I_clk,
input wire I_rst,
input wire signed [DATA_WIDTH-1:0] I_data_in,
output wire signed [DATA_WIDTH-1:0] O_data_out
);
localparam SHIFT_BITS = $clog2(TAPS);
// 累加器的位宽需要足够大以防止溢出
localparam ACCUM_WIDTH = DATA_WIDTH + SHIFT_BITS;
// 内部信号
reg signed [ACCUM_WIDTH-1:0] sum_reg;
reg signed [DATA_WIDTH-1:0] data_history [0:TAPS-1];
wire signed [DATA_WIDTH-1:0] data_to_subtract;
integer i;
// 1. 移位寄存器链,用于得到 x[n-N]
// 注意,我们需要一个完整的N级延迟链
always @(posedge I_clk) begin
if (I_rst) begin
for (i = 0; i < TAPS; i = i + 1) begin
data_history <= 0;
end
end else begin
data_history[0] <= I_data_in;
for (i = 1; i < TAPS; i = i + 1) begin
data_history <= data_history[i-1];
end
end
end
// 要减去的最老的数据 x[n-N]
assign data_to_subtract = data_history[TAPS-1];
// 2. 累加器(核心部分)
// Sum[n] = Sum[n-1] + x[n] - x[n-N]
always @(posedge I_clk) begin
if (I_rst) begin
sum_reg <= 0;
end else begin
// 这是一个非常短的组合逻辑路径!
sum_reg <= sum_reg + I_data_in - data_to_subtract;
end
end
// 3. 除法 (通过算术右移实现)
assign O_data_out = sum_reg >>> SHIFT_BITS;
endmodule
|
4 步骤二:MATLAB黄金参考模型
为了验证我们的Verilog代码是否正确,我们需要一个“标准答案”,也就是黄金参考模型。MATLAB是完成这项任务的完美工具。
下面的脚本将:
1.生成一个混合了低频信号和高频噪声的测试输入。
2.使用MATLAB内置的filter函数计算理想的滤波输出。
3.将输入和输出数据转换为16位定点数,并以十六进制格式保存到.txt文件中。使用十六进制是FPGA仿真的最佳实践,因为它兼容性最好,且直接反映了硬件中的数据表示。
generate_stimulus_hex.m
% =========================================================================
% generate_stimulus_hex.m
%
% 功能:
% 1. 生成一个包含两个正弦波的复合信号作为滤波器输入。
% 2. 使用MATLAB的filter函数计算移动平均滤波的"黄金参考"输出。
% 3. 将输入信号和参考输出转换为16位定点数。
% 4. 将定点数结果以十六进制格式保存到 .txt 文件中,
% 用于Verilog Testbench的仿真。
% =========================================================================
clear; clc; close all;
% --- 参数定义 (必须与Verilog设计保持一致) ---
DATA_WIDTH = 16; % 数据位宽
TAPS = 8; % 滤波器阶数 (移动平均的点数)
NUM_POINTS = 1024; % 仿真数据点数
% --- 1. 生成输入信号 ---
% 创建一个复合信号:一个低频主信号 +一个高频噪声
fs = 1000; % 假设采样频率
t = (0:NUM_POINTS-1) / fs;
f1 = 10; % 低频信号 10Hz
f2 = 150; % 高频噪声 150Hz
% 生成浮点信号,并调整幅度使其在定点数范围内
signal_float = 0.6 * sin(2*pi*f1*t) + 0.2 * sin(2*pi*f2*t);
% --- 2. 转换为定点数 ---
% Q1.15 格式 (1个符号位, 15个小数位)
% 缩放因子为 2^(DATA_WIDTH-1)
scaling_factor = 2^(DATA_WIDTH-1);
input_fixed = round(signal_float * scaling_factor);
% 确保数据不会超出16位有符号数的范围
input_fixed(input_fixed > 32767) = 32767;
input_fixed(input_fixed < -32768) = -32768;
% --- 3. 计算黄金参考输出 ---
% 移动平均滤波器的系数是一个长度为TAPS,值为1/TAPS的数组
b = (1/TAPS) * ones(1, TAPS);
a = 1;
% 使用MATLAB的filter函数计算浮点输出
output_float_golden = filter(b, a, signal_float);
% 将黄金参考输出也转换为定点数
output_golden_fixed = round(output_float_golden * scaling_factor);
output_golden_fixed(output_golden_fixed > 32767) = 32767;
output_golden_fixed(output_golden_fixed < -32768) = -32768;
% --- 4. 将数据以十六进制格式写入文件 ---
fprintf('Writing stimulus and golden reference files...\n');
% -- 写入输入激励文件 --
fileID_in = fopen('input_stimulus.txt', 'w');
if fileID_in == -1
error('Cannot open input_stimulus.txt for writing.');
end
% 将有符号整数类型转换为无符号整数,以获得正确的二进制补码十六进制表示
input_unsigned = typecast(int16(input_fixed), 'uint16');
fprintf(fileID_in, '%x\n', input_unsigned);
fclose(fileID_in);
% -- 写入黄金参考文件 --
fileID_out = fopen('golden_reference.txt', 'w');
if fileID_out == -1
error('Cannot open golden_reference.txt for writing.');
end
output_golden_unsigned = typecast(int16(output_golden_fixed), 'uint16');
fprintf(fileID_out, '%x\n', output_golden_unsigned);
fclose(fileID_out);
fprintf('Files generated successfully!\n');
% --- 5. (可选) 绘图验证 ---
figure;
subplot(2,1,1);
plot(t, input_fixed);
title('Input Signal (Fixed-Point)');
xlabel('Time (s)');
ylabel('Amplitude');
grid on;
subplot(2,1,2);
plot(t, output_golden_fixed);
title('Golden Reference Output (Fixed-Point)');
xlabel('Time (s)');
ylabel('Amplitude');
grid on;
| 图示:
5 步骤三:编写专业的Testbench
Testbench是连接我们的设计和MATLAB数据的桥梁。一个好的Testbench需要处理两个关键问题:延迟和精度。
1.处理延迟:我们的硬件设计是流水线结构,存在延迟。当我们在时钟周期i给入stimulus时,data_out上出现的值是基于上一个输入stimulus[i-1]计算得到的。因此,在比对时,我们必须将当前的data_out与上一个黄金参考值golden_mem[i-1]进行比较。
2.处理精度:MATLAB的浮点运算和Verilog的定点运算在舍入(Rounding)或截位(Truncation)上可能存在微小的差异,这可能导致结果有1个最低有效位(LSB)的误差。严格的 A === B 比较可能会因此失败。专业的做法是加入一个容差(Tolerance),允许±1的误差。
tb_moving_average_optimized.v
module tb_moving_average_optimized;
// --- 参数定义 ---
localparam DATA_WIDTH = 16;
localparam TAPS = 8;
localparam SIM_POINTS = 1024;
localparam CLK_PERIOD = 10;
// --- 信号和变量 ---
reg I_clk;
reg I_rst;
reg signed [DATA_WIDTH-1:0] I_data_in;
wire signed [DATA_WIDTH-1:0] O_data_out;
integer i = 0;
integer error_count = 0;
integer comparison_start_index = TAPS;
reg signed [DATA_WIDTH-1:0] stimulus_mem [0:SIM_POINTS-1];
reg signed [DATA_WIDTH-1:0] golden_mem [0:SIM_POINTS-1];
// --- 实例化 DUT ---
moving_average_filter_optimized #(
.DATA_WIDTH(DATA_WIDTH),
.TAPS(TAPS)
) uut (
.I_clk(I_clk),
.I_rst(I_rst),
.I_data_in(I_data_in),
.O_data_out(O_data_out)
);
// --- 时钟生成 ---
always # (CLK_PERIOD / 2) I_clk = ~I_clk;
// --- 主测试流程 ---
initial begin
$display("--- Starting Final Simulation (Latency Aligned & LSB Tolerant) ---");
// 使用 $readmemh 读取十六进制文件
$readmemh("input_stimulus.txt", stimulus_mem);
$readmemh("golden_reference.txt", golden_mem);
I_clk = 0;
I_rst = 1;
I_data_in = 0;
# (CLK_PERIOD * 5);
I_rst = 0;
for (i = 0; i < SIM_POINTS; i = i + 1) begin
@(posedge I_clk);
I_data_in <= stimulus_mem;
if (i >= comparison_start_index) begin
#1; // 等待组合逻辑稳定
// **核心验证逻辑**
// 比较当前输出 O_data_out (由 i-1 的输入产生)
// 和 golden_mem 中对应 i-1 的值,并允许 +/-1 的容差。
if ( (O_data_out > golden_mem[i-1] + 1) || (O_data_out < golden_mem[i-1] - 1) ) begin
$display("ERROR @ input index %0d: DUT_output = %h, Mismatched Golden_ref(i-1) = %h",
i, O_data_out, golden_mem[i-1]);
error_count = error_count + 1;
end
end
end
# (CLK_PERIOD * (TAPS + 5)); // 等待所有数据流过
if (error_count == 0) begin
$display("--- [SUCCESS] Simulation passed! No errors found. ---");
end else begin
$display("--- [FAILURE] Simulation failed with %d errors. ---", error_count);
end
$stop;
end
endmodule
|
6 步骤四:在Vivado中进行仿真
1.创建工程:在Vivado中创建一个新工程,并将 moving_average_filter_optimized.v 添加为设计源(Design Source),将 tb_moving_average_optimized.v 添加为仿真源(Simulation Source)。
2.添加数据文件:
在 "Sources" 窗口,右键点击 "Simulation Sources"。
选择 "Add Sources..." -> "Add or create simulation sources"。
点击 "Add Files",找到你用MATLAB生成的 input_stimulus.txt 和 golden_reference.txt。
关键:在文件选择对话框的右下角,将文件类型过滤器从 "Verilog" 改为 "All Files (.)",这样 .txt 文件才会显示出来。
选中这两个文件,并确保 "Copy sources into project" 已勾选,然后完成添加。
3.运行仿真:
在左侧的 "Flow Navigator" 中,点击 "Run Simulation" -> "Run Behavioral Simulation"。
仿真启动后,在Tcl Console窗口中,你应该会看到我们精心设计的打印信息,并最终显示 [SUCCESS] Simulation passed!。
在波形窗口中,将 data_in 和 data_out 拖入。你可以将它们的显示格式改为 "Analog" 来直观地看到高频噪声是如何被滤除,波形是如何变得平滑的。
|