1. 项目概述
在数字信号处理(DSP)的硬件实现领域,FIR(有限脉冲响应)滤波器因其绝对稳定性和线性相位特性,成为工程师们手中的一把利器。无论是通信系统的信道均衡,还是音频处理中的噪声抑制,FIR滤波器的身影无处不在。然而,当我们将算法从MATLAB的仿真环境搬到FPGA或ASIC的硅基世界里时,一个核心的矛盾就摆在了面前:性能与资源的博弈。并行FIR结构吞吐量高,但需要消耗大量的乘法器和加法器资源;而串行FIR结构则反其道而行之,它通过“时间换空间”的策略,用更高的时钟频率来换取极致的逻辑资源节省。今天,我就结合一个具体的低通滤波器设计案例,来和大家深入聊聊如何用Verilog实现一个串行FIR滤波器,并分享我在这个过程中踩过的坑和总结的经验。
这个项目的目标很明确:设计一个低通FIR滤波器,其采样频率为50MHz,需要滤除输入信号中7.5MHz的高频分量,只保留250KHz的低频分量。滤波器阶数定为15(即16个抽头)。与并行实现不同,串行方案的核心思想是在多个时钟周期内,分时复用单一的乘法器和加法器来完成所有抽头的乘累加运算。这意味着,为了在50MHz的采样率下持续处理数据,我们的系统工作时钟需要高达400MHz(8倍于采样率)。这听起来有点挑战,但正是这种设计,能在资源紧张的FPGA上实现复杂的滤波功能。接下来,我将从设计思路、代码实现、仿真验证到实战技巧,为你完整拆解这个串行FIR滤波器的设计过程。
2. 串行FIR滤波器核心架构解析
2.1 为何选择串行结构:资源与速度的权衡
在动手写代码之前,我们必须先理解串行结构的本质。一个N阶(N+1个抽头)的FIR滤波器,其输出是输入信号与滤波器系数卷积的结果。对于并行结构,每个时钟周期都需要完成N+1次乘法和N次加法。以一个16抽头的滤波器为例,这需要16个乘法器和15个加法器同时工作。在FPGA上,这直接对应着大量的DSP Slice消耗,在一些低端或资源受限的器件上可能根本无法实现。
串行结构的智慧在于,它承认“在同一时刻完成所有计算”并非唯一解。既然硬件资源有限,那么我们可以将计算任务平摊到多个时钟周期中去。对于具有线性相位特性的FIR滤波器(其系数通常对称),我们可以利用对称性将乘法次数减半。例如,16个对称系数可以两两配对先相加,然后再与对应的单个系数相乘。这样,原本16次乘法就变成了8次。串行结构更进一步,它只使用一个乘法器,在8个时钟周期内,依次完成这8次乘法运算,并将结果累加起来。简而言之,串行结构用8倍的时间(时钟周期)和1/16的乘法器资源,完成了同样的一次滤波计算。
这种设计的代价就是工作时钟频率必须提高。因为你要在8个时钟周期内处理完一个采样点对应的所有运算,才能迎接下一个采样点。如果采样率是Fs,那么系统时钟至少需要是 N/2 * Fs(对于对称结构)。在本例中,Fs=50MHz,阶数15(有效对称计算次数为8),因此系统时钟需要400MHz。这是一个典型的速度换面积(Speed-Area Trade-off)的案例。
注意:选择400MHz时钟需要评估目标FPGA器件的性能。并非所有FPGA的普通逻辑区域都能稳定运行在这个频率下。通常需要查看器件的数据手册,确认其全局时钟网络和特定Bank的时钟性能,并可能在布局布线时添加时序约束。
2.2 系统时序与数据流设计
串行FIR的时序是设计的灵魂。整个数据流就像一个精心编排的流水线,每个节拍都不能出错。下图勾勒了核心的时序和数据路径:
时钟周期 (400MHz clk): 0 1 2 3 4 5 6 7 0 1 2 ... 采样使能 (50MHz en): |___| |___| 输入数据 (xin): D0 D1 移位寄存器: D0->...->D15 D1->...->D16 计算索引 (xin_index): 0 1 2 3 4 5 6 7 0 1 2 ... 对称加法 (add_s): D0+D15 D1+D14 ... D7+D8 D1+D16 D2+D15 ... 系数选择 (coe_s): C0 C1 ... C7 C0 C1 ... 乘法结果 (mout): (D0+D15)*C0 ... (D7+D8)*C7 (D1+D16)*C0 ... 累加器 (sum): Σ(mout over 8 cycles) Σ(mout over next 8 cycles) 有效输出 (valid): |___________| 输出数据 (yout): Y0 Y1关键时序节点解析:
- 输入采样 (
en信号):这是一个频率为50MHz的脉冲信号,每8个系统时钟周期(400MHz)出现一次高电平。它标志着新的输入数据xin有效。 - 数据移位与缓存:当
en有效时,新数据D0被移入一个深度为16的移位寄存器。同时,旧数据依次向后移位。这个寄存器阵列保存了当前计算所需的全部16个历史数据。 - 对称加法与系数选择:利用系数的对称性,在每个时钟周期,根据一个循环计数器
xin_index(0~7),从移位寄存器中取出对称位置的两个数据(如D0和D15)进行相加。同时,选择对应的滤波器系数C0。 - 分时乘法:对称相加的结果
add_s与选中的系数coe_s被送入一个单一的乘法器进行运算。这个乘法器在每个时钟周期完成一次乘法,连续工作8个周期,依次计算出8个部分乘积。 - 周期累加:一个8位的累加器在每个乘法结果有效的时钟周期将其累加。经过8个周期后,累加器的和即为当前采样点对应的一个完整的滤波输出结果。
- 输出对齐与有效信号:由于FIR滤波器有群延迟,前15个输出是不完整的(滤波器未填满),需要丢弃。从第16个输出开始,数据才有效。因此,需要生成一个
valid信号来指示输出端口yout上的数据是有效的滤波结果。
整个设计就像一个旋转的陀螺,en信号每拍打一次(50MHz),内部的8相位计算引擎(400MHz)就高速旋转一周,产出一个结果。理解这个时序流,是编写和调试代码的基础。
3. Verilog代码实现与关键模块详解
有了清晰的设计思路,我们就可以开始动手编写Verilog代码了。我将按照数据流的顺序,逐一拆解核心模块的代码,并解释其中的设计考量。
3.1 顶层模块与参数定义
首先,我们定义顶层模块的接口和关键参数。这里我增加了一些注释和参数化设计,以增强代码的可读性和可重用性。
`timescale 1ns / 1ps // 定义是否使用安全的流水线乘法器,用于仿真速度与综合可靠性的权衡 `define SAFE_DESIGN module fir_serial_low #( parameter INPUT_WIDTH = 12, // 输入数据位宽 parameter COEFF_WIDTH = 12, // 系数位宽 parameter ORDER = 15, // 滤波器阶数 (N=15, 抽头数=16) parameter SYS_CLK_MULT = 8 // 系统时钟与采样时钟的倍数关系 )( input wire rstn, // 低电平复位 input wire clk, // 系统高速时钟 (Fs * SYS_CLK_MULT = 400MHz) input wire en, // 输入数据有效脉冲,频率为 Fs (50MHz) input wire [INPUT_WIDTH-1:0] xin, // 输入数据,有符号数补码格式 output wire valid, // 输出数据有效信号 output wire [INPUT_WIDTH+COEFF_WIDTH+$clog2(ORDER/2+1)-1:0] yout // 输出数据 ); localparam TAP_NUM = ORDER + 1; // 抽头数 = 16 localparam SYM_ADD_CYCLES = TAP_NUM / 2; // 对称加法计算周期数 = 8 localparam ACC_WIDTH = INPUT_WIDTH + COEFF_WIDTH + $clog2(SYM_ADD_CYCLES); // 累加器位宽估算 // 实际位宽计算:输入12位,系数12位,对称加法后数据扩展1位至13位。 // 13位 * 12位乘法结果为25位。8个25位数累加,最大位宽增长log2(8)=3位,故输出最多28位。 // 我们分配29位(28:0)以提供一位保护位。 localparam OUTPUT_WIDTH = 29; // 内部寄存器与连线声明 reg [2:0] cnt; // 0-7循环计数器,控制8相位计算 reg [INPUT_WIDTH-1:0] xin_reg [0:TAP_NUM-1]; // 16级输入数据移位寄存器 reg [INPUT_WIDTH:0] add_a, add_b; // 对称相加的两个操作数,扩展1位防溢出 reg [COEFF_WIDTH-1:0] coe_s; // 当前时钟周期选中的系数 wire [INPUT_WIDTH+1:0] add_s; // 对称加法结果,位宽再扩展1位 wire [INPUT_WIDTH+COEFF_WIDTH:0] mout; // 乘法器输出 reg [OUTPUT_WIDTH-1:0] sum; // 累加器 reg valid_r; // 原始有效信号 reg [OUTPUT_WIDTH-1:0] yout_r; // 输出数据寄存器 reg [4:0] cnt_valid; // 有效输出延迟计数器设计要点:
- 参数化:使用
parameter和localparam定义关键参数,使得模块可以方便地适配不同阶数、位宽的滤波器设计。 - 位宽计算:精确计算中间信号和最终输出的位宽至关重要,可以防止溢出并优化资源。这里详细推导了从12位输入、12位系数到29位输出的位宽增长过程。
- 有符号数处理:虽然示例代码中数据看似为无符号,但在实际通信或音频处理中,数据通常为有符号的补码形式。设计时需要统一考虑加法、乘法的有符号数运算规则,确保符号位正确处理。本例为简化,按无符号数处理,但结构完全兼容有符号数。
3.2 输入数据缓存与使能同步
输入数据xin以50MHz的速率到来,我们需要在400MHz的时钟域下处理它。这里涉及到一个慢速使能信号到快速时钟域的同步与数据缓存问题。
// 使能信号同步链,用于产生后续计算各阶段的使能 reg [TAP_NUM-1:0] en_r; // 使能移位寄存器,深度为16 always @(posedge clk or negedge rstn) begin if (!rstn) begin en_r <= {TAP_NUM{1'b0}}; end else begin en_r <= {en_r[TAP_NUM-2:0], en}; // 将en信号在高速时钟下移位 end end // 8相位循环计数器:核心状态机 always @(posedge clk or negedge rstn) begin if (!rstn) begin cnt <= 3'b0; end else if (en || (cnt != 3'b0)) begin // 关键!只要en有效或计数器未归零,就持续计数 cnt <= cnt + 1'b1; // 从0计数到7,然后自动翻转为0 end // 注意:当cnt==7时,加1后变为0。这构成了一个模8计数器。 end // 16级输入数据移位寄存器 integer i, j; always @(posedge clk or negedge rstn) begin if (!rstn) begin for (i=0; i<TAP_NUM; i=i+1) begin xin_reg[i] <= {INPUT_WIDTH{1'b0}}; end end else if ((cnt == 3'd0) && en) begin // 仅在每个计算周期的起始点(cnt==0)且en有效时,采样新数据 xin_reg[0] <= xin; for (j=0; j<TAP_NUM-1; j=j+1) begin xin_reg[j+1] <= xin_reg[j]; // 数据向后移位 end end end关键逻辑与避坑指南:
- 使能同步链 (
en_r):en信号是50MHz的脉冲,在400MHz时钟下看,它只持续一个周期。我们通过一个移位寄存器将其延迟多个周期。en_r[0]对应en延迟1拍,en_r[1]延迟2拍,以此类推。这个延迟链的作用是为后续不同计算步骤(如对称加法、乘法、累加)提供精确的时序控制信号。例如,当cnt==1时进行对称加法,此时需要用en_r[1]来锁存操作数,确保数据已经稳定。 - 循环计数器 (
cnt) 的使能条件:cnt的计数条件是(en || (cnt != 3‘b0))。这是串行调度器的核心。当en有效时,启动一个新的8周期计算循环。一旦计数器启动(cnt != 0),它就会在每个时钟周期自增,直到完成一个循环(从0到7)后自动归零。这个逻辑确保了计算引擎可以连续不断地工作,只要en信号周期性到来。 - 数据移位时机:数据移位操作
xin_reg仅在cnt == 0且en有效时进行。这意味着每8个高速时钟周期,才有一组新数据被移入寄存器堆。这保证了在接下来的8个周期内,用于计算的16个历史数据是静止不变的,为分时计算提供了稳定的操作数。这是一个常见的错误点:如果移位时机不对,会导致计算使用的数据错位,滤波结果完全错误。
3.3 系数对称性利用与分时计算
FIR滤波器的线性相位特性意味着其系数是对称的:h[n] = h[N-n]。我们可以利用这一点减少一半的乘法运算。
// FIR滤波器系数,由MATLAB fdatool生成并量化。这里为16个对称系数,只存储前8个。 wire [COEFF_WIDTH-1:0] coe [0:SYM_ADD_CYCLES-1]; assign coe[0] = 12'd11; assign coe[1] = 12'd31; assign coe[2] = 12'd63; assign coe[3] = 12'd104; assign coe[4] = 12'd152; assign coe[5] = 12'd198; assign coe[6] = 12'd235; assign coe[7] = 12'd255; // 中心系数最大 // 根据当前计数器的值,选择对称的数据对和对应的系数 // xin_index 用于寻址:当cnt>=1时,索引为cnt-1;当cnt==0时,索引应为7(上一个循环的最后一次计算)。 // 这里用一个条件运算符简洁实现。注意en_r的延迟要与之匹配。 wire [2:0] xin_index = (cnt >= 3'd1) ? (cnt - 3'd1) : 3'd7; // 对称加法操作数选择与锁存 always @(posedge clk or negedge rstn) begin if (!rstn) begin add_a <= {(INPUT_WIDTH+1){1'b0}}; add_b <= {(INPUT_WIDTH+1){1'b0}}; coe_s <= {COEFF_WIDTH{1'b0}}; end else if (en_r[xin_index]) begin // 使用同步后的使能信号精确控制锁存时机 add_a <= {xin_reg[xin_index][INPUT_WIDTH-1], xin_reg[xin_index]}; // 符号位扩展(若有符号) add_b <= {xin_reg[TAP_NUM-1-xin_index][INPUT_WIDTH-1], xin_reg[TAP_NUM-1-xin_index]}; coe_s <= coe[xin_index]; end end // 对称加法器 assign add_s = add_a + add_b;设计细节与优化:
- 系数存储:由于对称性,我们只需要存储一半的系数(前8个)。这节省了存储资源。
- 索引计算 (
xin_index):这是一个关键的计算。在cnt从0到7的循环中,我们需要按顺序计算对称对(0,15),(1,14), ...,(7,8)。当cnt=1时,应计算索引0的对。当cnt=0时,它实际上是一个新循环的开始,但此时应该计算上一个循环未完成的索引吗?不,在cnt=0的周期,我们主要处理新数据的移入和累加器的复位/输出。因此,xin_index的设计确保了在有效的计算周期(cnt=1到cnt=7,以及为了流水线对齐可能需要的cnt=0的某个特殊阶段)能正确索引。示例代码中的三目运算符逻辑是合理的:cnt>=1时索引为cnt-1;cnt==0时索引为7。但必须与后续乘法器的使能严格对齐,这是调试的重点。 - 位宽扩展:在进行加法
add_a + add_b前,我们将12位的数据扩展了1位({符号位, 数据})。这是为了防止两个最大值的数相加时产生溢出。例如,两个12位有符号数范围是 -2048 到 2047,相加的结果范围是 -4096 到 4094,需要13位来表示。 - 使能信号对齐 (
en_r[xin_index]):这里用en_r移位寄存器的某一位作为锁存使能。因为xin_index是变化的,en_r[xin_index]就相当于一个动态选择的、与当前计算相位精确对齐的使能脉冲。这确保了add_a,add_b,coe_s只在数据准备就绪的时钟边沿被更新,避免了毛刺和亚稳态。
3.4 单乘法器分时复用与累加
这是串行FIR最核心的资源节省部分:只实例化一个乘法器。
// 乘法器使能生成,需要根据计算流水线深度调整 wire en_mult; // 假设对称加法消耗1个周期,乘法器输入寄存器消耗1个周期。 // 因此,乘法操作比对称加法晚2个周期。我们需要从en_r中选择合适的延迟位。 // 例如,如果xin_index对应的使能是en_r[k],那么乘法使能可能是en_r[k+2]。 // 这里用一个简单的加法来模拟这种延迟关系。具体延迟取决于乘法器模块本身的流水线级数。 wire [3:0] index_mult = (cnt >= 3'd2) ? (cnt - 3'd2) : (4'd7 + cnt[0]); // 一种可能的对齐方式 // 更稳健的做法是:根据仿真波形精确调整这个索引,确保当add_s和coe_s稳定后,en_mult才有效。 `ifdef SAFE_DESIGN // 使用流水线乘法器模块,通常有2-3级流水线,时序性能好。 mult_man #( .WIDTH_M (INPUT_WIDTH+2), // add_s位宽,13位 .WIDTH_N (COEFF_WIDTH) // coe_s位宽,12位 ) u_mult_single ( .clk (clk), .rstn (rstn), .data_rdy (en_r[index_mult]), // 必须仔细调整这个使能信号! .mult1 (add_s), .mult2 (coe_s), .res_rdy (en_mult), // 乘法结果有效的信号,比data_rdy晚若干拍 .res (mout) // 25位输出 ); `else // 直接使用组合逻辑乘法运算符“*”,仿真快,但时序差,不适合高速或大型设计。 reg [INPUT_WIDTH+COEFF_WIDTH:0] mout_reg; // 25位 always @(posedge clk or negedge rstn) begin if (!rstn) begin mout_reg <= {(INPUT_WIDTH+COEFF_WIDTH+1){1'b0}}; end else if (|en_r[8:1]) begin // 一个较宽的有效窗口 mout_reg <= coe_s * add_s; // 直接乘 end end assign mout = mout_reg; assign en_mult = en_r[2]; // 假设组合乘法结果在下一拍稳定 `endif // 累加器:在8个周期内累加乘法结果 reg [4:0] cnt_acc_r; // 累加计数器,0-7 always @(posedge clk or negedge rstn) begin if (!rstn) begin cnt_acc_r <= 5'b0; end else if (cnt_acc_r == 5'd7) begin cnt_acc_r <= 5'b0; // 计满8次归零 end else if (en_mult || (cnt_acc_r != 5'b0)) begin // 只要乘法结果有效信号到来或计数器已启动,就继续计数 cnt_acc_r <= cnt_acc_r + 1'b1; end end always @(posedge clk or negedge rstn) begin if (!rstn) begin sum <= {OUTPUT_WIDTH{1'b0}}; valid_r <= 1'b0; end else if (cnt_acc_r == 5'd7) begin // 第8次累加完成,输出有效,并保持当前和(实际上是下一个周期的初始值?这里逻辑需斟酌) // 更常见的做法:在下一个周期(cnt_acc_r==0)锁存sum并清零,开始新的累加。 sum <= sum + mout; // 完成最后一次累加 valid_r <= 1'b1; // 标记累加完成 end else if (en_mult && (cnt_acc_r == 5'b0)) begin // 新一轮累加的开始,初始化累加器为第一个乘法结果 sum <= mout; valid_r <= 1'b0; end else if (cnt_acc_r != 5'b0) begin // 累加过程中间周期 sum <= sum + mout; valid_r <= 1'b0; end // 注意:当en_mult无效时,sum应保持不变。上述逻辑已覆盖。 end核心难点与调试经验:
乘法器选型与使能对齐:这是串行设计中最容易出错的地方。
- 流水线乘法器 (
SAFE_DESIGN):为了满足400MHz的高时序要求,强烈建议使用FPGA供应商提供的IP核或自己设计的流水线乘法器。这类乘法器通常有2-3级甚至更多级流水线寄存器。这意味着从输入data_rdy有效,到输出res_rdy有效,会有固定的延迟(例如2个时钟周期)。你必须根据乘法器的流水线深度,重新调整整个数据通路的使能时序。index_mult和en_r的选择必须保证当add_s和coe_s稳定出现在乘法器输入端时,data_rdy恰好有效。而en_mult(即res_rdy)则用于控制累加器的动作。 - 组合乘法器 (
else):使用*运算符,在综合时可能被推断为组合逻辑。在400MHz下,这几乎肯定会导致建立/保持时间违例,时序无法收敛。仅适用于低速仿真或验证概念。如果使用,需要手动插入输出寄存器,并相应调整en_mult的生成(通常比输入使能晚一个周期)。 - 我的踩坑记录:在一次项目中,我使用了3级流水线的乘法器IP,但错误地将
en_r[1]作为data_rdy,导致乘法器总是使用错误的数据对进行计算。通过仔细绘制时序图,我发现需要将en_r[3]作为data_rdy,才能对齐。务必用仿真工具(如ModelSim/QuestaSim)绘制详细的波形图,逐个周期核对每个使能信号和数据路径!
- 流水线乘法器 (
累加器逻辑:累加器需要在8个周期内完成8次加法。
cnt_acc_r计数器与en_mult同步。逻辑上,当第一个en_mult有效时,cnt_acc_r从0开始计数,并将sum初始化为第一个乘法结果mout。随后每个en_mult有效的周期,cnt_acc_r递增,并将新的mout累加到sum中。当cnt_acc_r计到7时,意味着第8个乘积已被累加,此时sum就是完整的滤波输出,可以置位valid_r。注意:示例代码中在cnt_acc_r==7时执行sum <= sum + mout并置位valid_r。这意味着valid_r有效时,sum已经是最终结果。但在下一个时钟沿(cnt_acc_r变为0),sum会被重新初始化为新的第一个乘积。这个逻辑是连贯的。
3.5 输出对齐与有效信号生成
FIR滤波器有初始的瞬态响应,前N(阶数)个输出对应于滤波器初始填充过程,不是有效的稳态输出。我们需要丢弃它们。
// 输出寄存器,用于时钟对齐,使输出信号变化频率降低,更稳定 always @(posedge clk or negedge rstn) begin if (!rstn) begin yout_r <= {OUTPUT_WIDTH{1'b0}}; end else if (valid_r) begin yout_r <= sum; // 在valid_r有效时锁存累加结果 end end assign yout = yout_r; // 有效输出延迟计数器:滤除前15个无效输出 always @(posedge clk or negedge rstn) begin if (!rstn) begin cnt_valid <= 5'b0; end else if (valid_r && (cnt_valid != 5'd16)) begin // 每产生一个有效的累加结果,计数器加1,直到16 cnt_valid <= cnt_valid + 1'b1; end // 当计数器达到16后,保持,表示之后的所有输出都是有效的 end // 最终的输出有效信号:只有当计数器表明已过初始阶段,且当前累加结果有效时,才置位 assign valid = (cnt_valid == 5'd16) & valid_r;设计考量:
- 输出寄存器 (
yout_r):这是一个好的设计习惯。sum在valid_r有效的那个周期变化,直接将其输出可能会在非valid周期也看到数据变化(尽管外部可能不关心)。用一个寄存器在valid_r有效时锁存一下,可以使输出数据总线只在真正有效的时刻变化,减少不必要的开关活动,有利于降低功耗和电磁干扰(EMI)。 - 有效信号生成 (
valid):valid信号是下游模块读取yout的依据。cnt_valid计数器记录了已经产生了多少个完整的累加结果。前15个结果(对应滤波器初始填充)被标记为无效。从第16个结果开始,valid信号才跟随valid_r一起变高。注意:valid_r的频率是50MHz(每8个高速时钟一个脉冲),valid信号在初始延迟后,频率也是50MHz,但相位比原始输入en延迟了多个周期(取决于滤波器的群延迟和流水线深度)。
4. Testbench设计与仿真验证
设计完成后,必须通过仿真来验证功能的正确性。一个完善的Testbench不仅能验证功能,还能帮助调试时序。
4.1 测试平台搭建
`timescale 1ns / 1ps module tb_fir_serial(); // 参数定义 parameter CLK_400M_HALF_PERIOD = 1.25; // 400MHz时钟半周期=1.25ns parameter CLK_50M_CYCLE = 20; // 50MHz周期=20ns parameter SIMU_CYCLE_NUM = 1000; // 仿真周期数(以50MHz周期计) parameter SIN_DATA_DEPTH = 200; // 预存正弦波数据点数 // 接口信号 reg clk_400m; reg rst_n; reg en_50m; reg [11:0] xin; wire [28:0] yout; wire valid; // 时钟生成:400MHz initial begin clk_400m = 1'b0; forever #CLK_400M_HALF_PERIOD clk_400m = ~clk_400m; end // 复位与仿真结束控制 initial begin rst_n = 1'b0; #100 rst_n = 1'b1; // 复位100ns后释放 #(CLK_50M_CYCLE * SIMU_CYCLE_NUM); // 仿真运行足够多的50MHz周期 $display("Simulation finished at time %0t ns", $time); $finish; end // 从文件读取MATLAB生成的激励数据 reg [11:0] stimulus [0:SIN_DATA_DEPTH-1]; integer data_index; initial begin $readmemh("cosx0p25m7p5m12bit.txt", stimulus); // 读取16进制文本文件 en_50m = 1'b0; data_index = 0; xin = 12'b0; #200; // 等待复位完成 forever begin // 模拟50MHz的采样使能:每8个400MHz周期(即20ns)产生一个脉冲 repeat(7) @(negedge clk_400m); // 等待7个周期 en_50m = 1'b1; xin = stimulus[data_index]; @(negedge clk_400m); // 在下一个时钟下降沿后撤销使能 en_50m = 1'b0; // 更新数据索引,循环播放 if (data_index == SIN_DATA_DEPTH-1) begin data_index = 0; end else begin data_index = data_index + 1; end end end // 实例化被测设计 fir_serial_low u_fir_serial ( .clk (clk_400m), .rstn (rst_n), .en (en_50m), .xin (xin), .valid (valid), .yout (yout) ); // 可选:将输出数据写入文件,供MATLAB分析 integer f_out; initial begin f_out = $fopen("fir_output.txt", "w"); forever begin @(posedge clk_400m); if (valid) begin $fwrite(f_out, "%d\n", yout); // 写入有符号十进制数 end end end initial begin #(CLK_50M_CYCLE * SIMU_CYCLE_NUM); $fclose(f_out); end endmoduleTestbench设计要点:
- 时钟与使能生成:精确生成400MHz的系统时钟
clk_400m和50MHz的采样使能脉冲en_50m。en_50m每8个系统时钟周期产生一个高电平脉冲,模拟ADC的采样节奏。 - 激励数据加载:使用
$readmemh系统任务从文本文件加载MATLAB生成的混合正弦波数据。这种方式可以方便地使用复杂的真实或仿真数据作为输入。 - 数据循环:Testbench中的
forever循环会持续不断地从数组中读取数据并施加到输入端,模拟一个连续的信号流。当数组数据用完后,索引归零,重新开始,形成循环。 - 输出捕获:将
valid信号有效的输出数据写入文件fir_output.txt,便于后续用MATLAB或其他工具进行频谱分析,定量评估滤波效果。
4.2 MATLAB辅助设计与验证
在硬件仿真之前,我们通常先用MATLAB完成算法设计和验证。
生成滤波器系数:
% 设计一个低通FIR滤波器 Fs = 50e6; % 采样频率 50MHz Fpass = 250e3; % 通带截止频率 250kHz Fstop = 1e6; % 阻带起始频率 1MHz Apass = 1; % 通带纹波 (dB) Astop = 60; % 阻带衰减 (dB) N = 15; % 滤波器阶数 % 使用fdesign函数 filtSpec = fdesign.lowpass('N,Fp,Fst', N, Fpass, Fstop, Fs); firFilter = design(filtSpec, 'equiripple', 'SystemObject', true); % 或者使用 firpm 函数直接计算系数 % h = firpm(N, [0 Fpass/(Fs/2) Fstop/(Fs/2) 1], [1 1 0 0], [1 100]); coef_float = firFilter.Numerator; % 获取浮点系数 % 系数对称性检查 assert(isequal(coef_float, fliplr(coef_float)), 'Coefficients are not symmetric!'); % 定点量化 (例如 Q1.11格式,放大2048倍后取整) coef_scale = 2048; coef_fixed = round(coef_float * coef_scale); % 取前一半系数(因为对称) coef_half = coef_fixed(1:(N+1)/2); % 将系数写入Verilog可用的格式 fprintf('Coefficients for Verilog:\n'); for i = 1:length(coef_half) fprintf('assign coe[%d] = 12''d%d;\n', i-1, coef_half(i)); end生成测试输入信号:
clear; close all; clc; Fs = 50e6; fc = 0.25e6; % 250kHz 期望信号 fn = 7.5e6; % 7.5MHz 干扰信号 Nsamples = 200; % 生成点数 t = (0:Nsamples-1)/Fs; signal_clean = cos(2*pi*fc*t); signal_noise = 0.5 * cos(2*pi*fn*t); % 干扰幅度可调 signal_mixed = signal_clean + signal_noise; % 归一化并量化为12位无符号数 (0-4095) signal_mixed_normalized = (signal_mixed - min(signal_mixed)) / (max(signal_mixed)-min(signal_mixed)); signal_12bit = floor(signal_mixed_normalized * 4095); % 绘制时域和频域图 figure; subplot(2,1,1); plot(t*1e6, signal_mixed); xlabel('Time (us)'); ylabel('Amplitude'); title('Mixed Signal Time Domain'); subplot(2,1,2); freq_axis = (-Nsamples/2:Nsamples/2-1)*(Fs/Nsamples); fft_mixed = fftshift(fft(signal_mixed)); plot(freq_axis/1e6, abs(fft_mixed)/Nsamples); xlabel('Frequency (MHz)'); ylabel('Magnitude'); title('Mixed Signal Frequency Domain'); xlim([0, Fs/2/1e6]); % 写入16进制文本文件,供Verilog读取 fid = fopen('cosx0p25m7p5m12bit.txt', 'w'); for i = 1:Nsamples fprintf(fid, '%x\n', signal_12bit(i)); end fclose(fid); disp('Test data file generated.');验证仿真结果:将仿真工具(如ModelSim)输出的fir_output.txt读回MATLAB,与理论结果对比。
% 读取Verilog仿真输出 fid = fopen('fir_output.txt', 'r'); verilog_out = fscanf(fid, '%d'); fclose(fid); % 将输出数据转换为有符号数(假设输出是二进制补码) % 需要根据实际输出位宽进行调整,例如29位有符号数 verilog_out_signed = verilog_out; verilog_out_signed(verilog_out_signed >= 2^28) = verilog_out_signed(verilog_out_signed >= 2^28) - 2^29; % 在MATLAB中用相同系数进行滤波,作为黄金参考 load('coef_fixed.mat'); % 载入之前量化的系数 matlab_out = filter(coef_fixed, 1, signal_mixed); % 注意:MATLAB filter函数有初始瞬态 matlab_out = matlab_out * coef_scale; % 乘以相同的缩放因子 % 比较结果(忽略前N个瞬态点) start_idx = N+1; figure; subplot(2,1,1); plot(verilog_out_signed(start_idx:end)); hold on; plot(matlab_out(start_idx:end), 'r--'); legend('Verilog Output', 'MATLAB Reference'); title('Time Domain Comparison'); xlabel('Sample Index'); ylabel('Amplitude'); subplot(2,1,2); fft_verilog = fftshift(fft(verilog_out_signed(start_idx:end))); fft_matlab = fftshift(fft(matlab_out(start_idx:end))); f_axis = (-length(fft_verilog)/2:length(fft_verilog)/2-1)*(Fs/length(fft_verilog)); plot(f_axis/1e6, 20*log10(abs(fft_verilog)/max(abs(fft_verilog)))); hold on; plot(f_axis/1e6, 20*log10(abs(fft_matlab)/max(abs(fft_matlab))), 'r--'); xlabel('Frequency (MHz)'); ylabel('Magnitude (dB)'); title('Frequency Domain Comparison'); xlim([0, Fs/2/1e6]); grid on; legend('Verilog', 'MATLAB');通过对比时域波形和频域频谱,可以直观地验证Verilog设计的滤波器是否正确地滤除了7.5MHz的高频分量,并且输出波形与MATLAB的理论结果是否匹配。频谱图上应该只留下250KHz的单峰。
5. 常见问题、调试技巧与实战优化
5.1 时序收敛与时钟约束
在400MHz下工作,时序约束(SDC文件)至关重要。
# 时钟定义 create_clock -name clk_400m -period 2.5 [get_ports clk] # 400MHz, 周期2.5ns # 生成时钟或衍生时钟约束(如果en是由内部PLL产生) # create_generated_clock -name clk_50m -source [get_ports clk] -divide_by 8 [get_pins {pll_inst|clkout[0]}] # 输入延迟约束 set_input_delay -clock clk_400m -max 1.0 [get_ports xin] set_input_delay -clock clk_400m -min 0.5 [get_ports xin] # en信号可能来自另一个时钟域,需要设置set_false_path或set_clock_groups # set_clock_groups -asynchronous -group {clk_400m} -group {clk_50m_source} # 输出延迟约束 set_output_delay -clock clk_400m -max 1.0 [get_ports {yout valid}] set_output_delay -clock clk_400m -min 0.5 [get_ports {yout valid}]综合实现后,必须仔细查看时序报告,确保所有路径的建立时间(Setup)和保持时间(Hold)都满足要求。特别是从en到第一级寄存器,以及乘法器内部的多级流水线路径。
5.2 关键问题排查清单
输出全是零或不变:
- 检查复位:确认
rstn信号在仿真初期被正确释放。 - 检查使能链:用仿真波形查看
en,en_r,cnt是否按预期跳变。en_r是否随着cnt的变化,有对应的位被激活? - 检查数据通路:从
xin输入开始,跟踪xin_reg是否在cnt==0 && en==1时正确移位。检查add_a,add_b,add_s的值是否正确(对称相加)。 - 检查乘法器使能:确认
en_mult或data_rdy信号是否在正确的周期有效。这是最容易出错的地方。
- 检查复位:确认
输出波形幅度异常或失真:
- 检查系数:确认Verilog代码中的系数与MATLAB生成的、经过量化后的系数完全一致。系数量化可能会引入误差,但不应导致完全错误的频率响应。
- 检查位宽和溢出:仔细检查所有加法、乘法操作的位宽。中间结果是否因为位宽不够而被截断?累加器
sum的位宽是否足够容纳8个最大乘积的和?建议在仿真中设置断言(assertion)或监控中间变量的最大值。 - 检查有符号数处理:如果输入和系数是有符号数,确保在加法和乘法前都进行了符号位扩展。Verilog中,有符号数运算最好使用
signed关键字声明,或者手动进行符号扩展。
valid信号永不拉高或拉高时机不对:- 检查
cnt_valid计数器:它是否在valid_r有效时递增?是否在达到16后停止? - 检查
valid_r生成逻辑:valid_r是否在cnt_acc_r==7时正确拉高?cnt_acc_r的计数是否与en_mult同步? - 理解延迟:从输入
en有效,到第一个有效valid输出,中间有固定的流水线延迟。这个延迟包括:输入移位寄存器延迟、对称加法延迟、乘法器流水线延迟、累加周期(8个时钟)、输出丢弃延迟(15个输出)。总延迟可能在几十个时钟周期。计算清楚这个延迟,并在Testbench中验证。
- 检查
5.3 性能与资源优化技巧
- 使用FPGA DSP Slice:现代的FPGA(如Xilinx 7系列、UltraScale)都包含专用的DSP48单元,非常适合实现高速乘法累加操作。在综合工具中,确保你的乘法器(
*操作符或实例化的乘法器模块)被映射到了DSP48上,而不是用查找表(LUT)和寄存器拼凑,后者速度慢且资源消耗大。 - 流水线深度优化:为了达到400MHz,可能需要在数据通路上插入更多的流水线寄存器。例如,在对称加法器
add_s之后、乘法器之前插入寄存器;在累加器sum的反馈路径上插入寄存器(但这会改变累加时序,需要重新设计控制逻辑)。这称为“超流水线”设计。 - 资源共享的高级形式:本文展示的是最基础的串行结构。还可以探索“半并行”结构,例如使用2个或4个乘法器,将工作频率降低到200MHz或100MHz,在资源和速度之间取得更好的平衡。
- 系统级时钟方案:400MHz的全局时钟可能带来较大的时钟网络功耗和抖动。可以考虑使用FPGA内部的PLL或MMCM生成一个相位对齐的、频率为50MHz的时钟,专门用于采样使能
en和输出接口,而核心计算仍用400MHz时钟。这需要对跨时钟域信号(如en)进行妥善处理。
5.4 扩展与变体
- 多通道滤波:利用串行结构资源少的优势,可以时分复用同一套计算单元来处理多个通道的信号。只需要为每个通道配备独立的输入移位寄存器组和累加器,而乘法器和控制逻辑可以共享。这非常适合多通道采集系统。
- 可重配置系数:将系数
coe数组改为由寄存器或RAM存储,并通过APB、AXI-Lite等总线接口进行配置,可以实现一个通用的、系数可变的FIR滤波器IP核。 - 抽取与插值:将串行FIR与多速率信号处理结合。例如,在滤波器后加入抽取器,可以降低输出数据率;或者在滤波器前加入插值器,可以提高输入信号速率。串行结构因其固有的分时处理特性,与多速率系统有天然的契合点。
设计一个高速串行FIR滤波器,就像在时间的钢丝上跳舞,需要对时序有精准的把握。从架构设计、代码实现、仿真验证到时序收敛,每一步都充满了挑战。但当你看到仿真波形中杂乱的高频信号被干净地滤除,只剩下平滑的低频波形时,那种成就感是无与伦比的。希望这篇详细的拆解和分享,能帮助你少走弯路,更顺利地完成自己的滤波器设计。记住,多画时序图,多看仿真波形,耐心调试,你一定能驾驭这“时间换空间”的艺术。