1. 从差分方程到硬件结构:FIR在FPGA里的三种打开方式
1.1 为什么滑动加权和能滤掉指定频率
提到FIR滤波器,最容易被忽略的一件事是:它本质上就是一个带系数的滑动平均。你在数字信号处理课上学到的那个公式:
y[n] = b[0]·x[n] + b[1]·x[n-1] + ... + b[N-1]·x[n-N+1]
翻译成大白话就是:当前输出等于最近N个输入的加权和,权重就是滤波器系数。系数长什么样,决定了这个滤波器是低通、高通、带通还是带阻。Verilog实现FIR滤波器,说到底就是把这串乘加运算用硬件搭出来,并且保证每一个时钟节拍都能稳定地算完一遍。
很多初学者一上来就盯着代码看,我觉得顺序反了。硬件描述语言和软件语言最大的区别在于:你写的每一行Verilog最终都会变成电路,所以在动手之前必须先把"电路长什么样"想清楚。FIR在FPGA里有三种经典骨架——直接型、转置型、脉动型。这三种结构数学上完全等价,但硬件资源、时序表现、可维护性差异巨大。
1.2 直接型、转置型、脉动型:先选骨架再写代码
直接型结构最符合公式直觉:一串移位寄存器存输入历史,每个寄存器的输出和对应系数相乘,再把所有乘积加起来。它的优点是代码直观,调试方便;缺点是当抽头数多的时候,那个"把所有乘积加起来"的加法树会成为时序瓶颈。16抽头还好,128抽头的时候一个时钟周期内完成128个乘法加一个128输入的加法树,布线延迟会非常难看。
转置型结构把加法拆散到每一级。每个乘法器的结果就地累加,再向后传递,本质上形成了一个流水线。它的最大优势是:关键路径只包含一个乘法器和一个加法器之和,不随抽头数增加而变长,时序收敛容易得多。代价是代码理解成本高一点,而且每个抽头都要有一个独立的加法寄存器。
脉动型更激进,数据和系数都像流水线一样在寄存器阵列里流动,每个处理单元只做一次乘加。这种结构在高阶数、高吞吐的场景下非常漂亮,但在普通FPGA项目里属于"知道有这回事"就够了,不是默认首选。
我在实际项目中最常用的组合是:前端信号处理用转置型,测试模型用直接型。两者结果应该完全一致,正好互相验证。
1.3 位宽从哪里来:输入、系数、累加器的宽度推演
位宽设计是FIR实现的第一个坑。很多人的代码写好了,仿真波形也看过了,一到上板就发现输出有直流偏置,或者信号幅度不对,多半是位宽处理出了问题。
计算逻辑很简单:如果输入数据是16比特有符号数,系数也是16比特有符号数,那么一次乘法结果是32比特。如果有N个这样的乘积要累加,累加器位宽还需要额外增加ceil(log2(N))比特来防止溢出。16抽头就是ceil(log2(16))=4比特,所以累加器至少要用36比特。实际工程里我通常会再加1比特裕量,凑成偶数位宽方便处理,比如直接上38比特,反正FPGA的DSP Slice内部累加器位宽一般都有48比特,不用白不用。
输出的位宽则要看后级模块的需求。如果后级是一个16比特的DA转换器,那就需要从36比特累加结果里截取合适的高位,并且要做饱和处理,不能简单地从最高位砍掉。这个问题我放到第3章的截位部分详细说,因为它直接决定信号的动态范围和失真程度。
2. 先别急着写RTL:以16抽头低通为例的指标与系数准备
2.1 采样率、截止频率、抽头数怎么定
设计FIR的第一步不是打开Vivado,而是打开计算器,把指标定下来。假设我们要做一个音频信号处理里的低通滤波器,采样率48kHz,想滤掉8kHz以上的分量。设计参数就是:Fs=48000Hz,Fc=8000Hz,抽头数N=16。
抽头数的选择本身是一个工程权衡。理论上抽头越多,过渡带越窄,阻带衰减越大,但延迟、资源、功耗都跟着涨。对于16抽头这种小规模验证,过渡带会比较宽,阻带衰减大概在40dB左右,拿来验证算法和RTL流程绰绰有余。如果你要做一个真正能用的音频滤波器,一般推荐至少32到64抽头,配合合适的窗函数。
这组参数在Python里用一行代码就能算出系数:
from scipy.signal import firwin import numpy as np fs = 48000 fc = 8000 numtaps = 16 coefs = firwin(numtaps, fc, fs=fs, window='hamming') print(coefs)Hamming窗是经典选择,主瓣宽度和旁瓣衰减的平衡比较适合通用场景。想更陡峭可以用Kaiser窗,但那是另一个话题了。
2.2 用Python算系数并做16位量化
算出来的浮点系数不能直接烧进FPGA。硬件里的乘法器处理的是定点数,所以要把浮点系数转成定点数。最常见的做法是转成16比特有符号数,也就是把系数映射到[-1, 1)范围,再乘上2^15取整。
coef_q = np.round(coefs * 32767).astype(np.int16) print(coef_q)这一步有几个细节值得注意。第一,量化之后系数就不再是最优的了,滤波器响应会有微小偏差,但只要量化位数够,这个偏差完全在工程允许范围内。第二,系数的对称性在量化后仍然要保持,也就是coef_q[k]必须等于coef_q[N-1-k]。因为FIR的线性相位特性完全依赖于系数对称性,如果量化时出现舍入误差让两边不一致,滤波器相位响应就会变丑。
为了确保对称,我习惯先量化前一半系数,然后直接复制给后一半,而不是分别量化每一半。这样即使舍入有误差,误差也是对称的。
2.3 量化后的系数表与对称性自检
下面给出这组示例系数的量化结果。需要说明的是,这是一组根据Hamming窗计算并量化到16比特的近似值,不同工具算出来的末位可能有差异,实际工程中以你自己的量化结果为准:
| 抽头索引 | 浮点系数 | 16比特量化值 |
|---|---|---|
| h[0] = h[15] | 0.0021 | 69 |
| h[1] = h[14] | 0.0062 | 203 |
| h[2] = h[13] | -0.0144 | -472 |
| h[3] = h[12] | -0.0338 | -1108 |
| h[4] = h[11] | -0.0170 | -557 |
| h[5] = h[10] | 0.0440 | 1442 |
| h[6] = h[9] | 0.1303 | 4270 |
| h[7] = h[8] | 0.2563 | 8398 |
看到这个表你会意识到一个关键点:很多系数是负数。这意味着在Verilog里,无论输入数据还是乘法器,都必须用有符号数来处理,否则负数系数在补码运算里会算出一堆莫名其妙的错误结果。这个提醒虽然简单,但我在各种论坛和答疑群里见过太多次了——reg signed忘写,或者把$signed()用错,结果整个滤波器的频谱完全不对。
3. RTL逐段拆解:移位链、加法先行与状态机控制
3.1 模块接口与可配置参数设计
写RTL之前,先把模块接口定清楚。我见过很多人把FIR模块写成纯组合逻辑——输入进去立刻出结果,看起来很美,但实际工程中几乎不可用。真正可复用的FIR必须有时钟、有复位、有输入有效信号和输出有效信号,这样才能挂到更大的数据通路上。
下面是一份可以直接使用的16抽头FIR模块骨架:
module fir_16tap #( parameter DATA_WIDTH = 16, parameter COEF_WIDTH = 16, parameter ACC_WIDTH = 38 )( input wire clk, input wire rst_n, input wire signed [DATA_WIDTH-1:0] x_in, input wire x_valid, output reg signed [DATA_WIDTH-1:0] y_out, output reg y_valid );这个接口背后的设计逻辑是:x_valid拉高时,x_in上的数据才是有效的,模块在此时刻采样。如果x_valid一直拉高,那就是每个时钟都输入一个数据;如果x_valid是脉冲式的,那就不能简单地每个时钟都移位,否则会漏采或错采。这一点在接ADC数据、接上游FIFO时都特别容易出问题。
3.2 数据通路:利用线性相位先加后乘
16抽头的系数是对称的,h[0]=h[15]、h[1]=h[14]……利用这个性质可以把乘法器数量砍半。具体做法是:先把对称位置的两个数据相加,再乘系数。以h[0]为例:
wire signed [DATA_WIDTH:0] sum0; assign sum0 = x_d[0] + x_d[15];注意这里加法结果位宽是DATA_WIDTH+1=17比特,这是因为两个16比特有符号数相加可能产生17比特的结果。如果不留这个位宽,就可能发生溢出。
完整的移位寄存器链是FIR的"存储体"。这里有一个设计选择:用寄存器数组还是用移位寄存器IP。小规模直接用寄存器数组最灵活:
reg signed [DATA_WIDTH-1:0] x_d [0:15]; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin for (int i = 0; i < 16; i = i + 1) x_d[i] <= 'd0; end else if (x_valid) begin x_d[0] <= x_in; for (int i = 1; i < 16; i = i + 1) x_d[i] <= x_d[i-1]; end end当x_valid拉高时,新数据进入x_d[0],原有数据依次后移,相当于一个滑窗。当x_valid为低时,寄存器保持不动——这个行为必须和接口约定一致,这是从UART、SPI这类串行接口接到FIR模块时最容易踩的坑。
利用对称性加法先行之后,原来16个乘法变成了8个乘法加8个加法。这种"加法先行"的技巧在硬件上是完全赚到的,因为我们加了8个17比特加法器,但省掉了8个16x16乘法器——乘法器的面积和延迟远大于加法器。
3.3 乘加调度:单乘法器状态机与全流水两种写法
接下来是乘法累加部分的实现思路。这里有两种典型写法,各有利弊。
第一种是时间复用单乘法器方案:只用1个乘法器,靠状态机把8对数据的乘加运算串行做完,再加一个D触发器把结果打一拍同步输出。它的优点是资源极省,适合那种数据速率远低于时钟速率的场景。比如音频48kHz采样率,主时钟跑100MHz,一个采样周期有2000多个时钟周期,做8次乘法绰绰有余。状态机通常包含IDLE、ACC、DONE三个主要状态。
第二种是全并行流水线方案:例化8个乘法器同时计算,再用一个加法树求和。它的优点是吞吐率高,每个时钟都能出一个结果;缺点是资源占用大。对于16抽头这种规模,现代FPGA比如Artix-7或者Cyclone V,DSP Slice数量完全够用,全并行是最省心的选择。
我的建议很明确:如果是教学验证或者资源不敏感的场合,直接上全并行;如果是要往大规模信号处理上走,必须学会状态机时间复用。因为128抽头的FIR全并行会吃掉100多个DSP Slice,在很多项目里是不可接受的。
全并行加法树的一段关键代码长这样:
// 对称加法先行之后,sum_out[0]对应x_d[0]+x_d[15] wire signed [COEF_WIDTH-1:0] coef0 = 16'sd69; // ... wire signed [DATA_WIDTH+COEF_WIDTH-1:0] mout0; assign mout0 = sum0 * coef0; // 其余7个乘法结果类似 // 最后用组合逻辑求和(注意这里需要评估时序) wire signed [ACC_WIDTH-1:0] y_temp; assign y_temp = mout0 + mout1 + ... + mout7;实际工程中,这种组合逻辑求和是时序杀手,尤其是抽头数超过32之后。更稳妥的做法是在乘法器输出之后每一级插入流水线寄存器,把加法树拆成多层。这也是转置型结构为什么时序更好的原因——它天然地把加法分散到了每一级寄存器之间。
3.4 输出截位与符号处理
累加器算出来的结果是38比特,直接输出给外部是不可能的,必须截位。截位策略直接决定滤波后的信号质量。
最简单的做法是直接截掉低比特:assign y_temp_sliced = y_temp[ACC_WIDTH-1: ACC_WIDTH-DATA_WIDTH];。这种truncation的问题在于,它相当于无条件向下取整,会给信号引入直流偏置。尤其在信号非常小的时候,误差听感上是"嗡嗡"的背景噪声。
更推荐的做法是四舍五入截位:截位之前,给累加结果加上截位位宽的半个LSB。假设要从38比特截到16比特,就要截掉低22比特:
wire signed [ACC_WIDTH-1:0] y_rounded; assign y_rounded = y_temp + {1'b0, {(ACC_WIDTH-DATA_WIDTH-1){1'b0}}, 1'b1};这行代码的原理是:在低22比特的最高位上加1。如果被截掉的低位部分大于等于一半,进位就会让高位结果加1,实现四舍五入。这是我在实际项目里反复验证过的方法,几乎没有额外硬件代价,但能明显改善输出信号的信噪比。
另外还有一点:如果输出的动态范围可能超过16比特,在上述截位之后还需要判断溢出,做饱和截位。Verilog里有符号数的比较和截位有一个老生常谈但值得再强调一遍的坑——一定要保证参与运算的所有变量都有signed修饰,并且在做位拼接、扩展位宽时用{{}}符号扩展,而不是{0, ...}零扩展。
4. Testbench写不好等于没做:仿真激励与结果判定
4.1 用叠加高频的正弦波验证滤波效果
FIR模块写完,最激动人心的时刻就是仿真跑出波形。但很多人随便给一个随机数或者阶跃信号就算"功能仿真通过"了,这样验证的覆盖度远远不够。
我的标准做法是:构造两个正弦波的叠加信号,一个在通带内,一个在阻带内。还是以48kHz采样率、8kHz截止频率为例,我用200Hz的正弦波作为"想保留的信号",用15kHz的正弦波作为"想滤掉的信号"。如果滤波器工作正常,输出波形应该是一条光滑的200Hz正弦,15kHz成分被明显压下去。
简化版testbench如下:
`timescale 1ns / 1ps module tb_fir_16tap(); parameter DATA_WIDTH = 16; parameter CLK_PERIOD = 10; // 100MHz reg clk, rst_n; reg signed [DATA_WIDTH-1:0] x_in; reg x_valid; wire signed [DATA_WIDTH-1:0] y_out; wire y_valid; fir_16tap dut( .clk(clk), .rst_n(rst_n), .x_in(x_in), .x_valid(x_valid), .y_out(y_out), .y_valid(y_valid) ); initial clk = 0; always #(CLK_PERIOD/2) clk = ~clk; initial begin rst_n = 0; x_valid = 0; x_in = 0; #100; rst_n = 1; #20; x_valid = 1; // 每个时钟产生一个采样点 // 用系统任务 $sin 生成正弦波,需要仿真器支持 // 若工具不支持,可用查找表方式产生激励 repeat (4096) begin @(posedge clk); x_in = $shortint(12000 * $sin(2 * 3.1415926 * 200.0 * $realtime / 1000.0)); end #200; $finish; end endmodule这个testbench里用了$sin系统任务。注意不是所有仿真器都支持这个函数,Icarus Verilog和Vivado XSim是支持的,ModelSim的某些版本支持得不好。如果不支持,最稳妥的方案是先用Python生成一个正弦波查找表文件,然后用$readmemh读入做激励。这个方法最通用,也最不容易出幺蛾子。
4.2 把仿真结果落盘,用脚本看频谱
光看时域波形还不能完全说明滤波器性能。FIR滤波器的核心指标在频域——通带波动多少,阻带衰减多少,有没有镜像频率。所以仿真的时候要把输出数据写入文本文件,然后拉出来做FFT。
在testbench里加一段文件输出:
integer fout; initial begin fout = $fopen("output_data.txt", "w"); end always @(posedge clk) begin if (y_valid) $fwrite(fout, "%d\n", y_out); end跑完仿真之后,用Python读取这个文件,把时间序列做FFT。如果一切正常,你应该能看到两个明显的现象:200Hz处有一条很高的谱线,15kHz处几乎看不到东西,或者幅度比输入低了40dB以上。如果15kHz分量依然很强,那就要回头检查系数表的符号位和位宽处理了。
4.3 仿真中容易被忽略的初始无效输出与延迟对齐
FIR是因果系统,输出天然有延迟。16抽头的直接型结构,延迟就是16个时钟周期。在仿真波形的前16个周期,输出并不是真正有效的滤波结果,因为移位寄存器里还没有装满真实数据。这个"瞬态过程"是正常的,但不能当成有效输出送到下游。
所以y_valid信号必须在x_valid拉高之后的固定延迟之后再拉高,或者简单一点处理:x_valid连续拉高16拍之后,y_valid才跟随x_valid拉高。这个对齐问题看着小,但是在多模块级联的时候会非常头疼——下游模块如果不知道这个延迟,会把前面十几个垃圾数据当成有效数据一起处理掉。
另外,如果你在仿真里看到了类似"# Fatal: failure to obtain a verilog simulation license."的报错,那不是你代码的问题,是仿真工具的许可证没配置好。我曾经被这个报错卡了两个小时,反复检查testbench,最后发现纯粹是license环境变量没设对。这种"假报错"遇到一次记住就行,不用慌。
5. 工程落地的量级考量:资源占用、时序收敛与后续扩展
5.1 抽头数增长后该怎么改结构
16抽头只是入门。实际项目里做音频降噪,32到64抽头是刚起步;做通信系统的成形滤波,128抽头很常见;做雷达脉冲压缩,上千抽头的FIR都有人用。抽头数一涨,单纯堆并行乘法器的思路就走不通了。
当抽头数超过64的时候,我建议做三件事:第一,把FIR从"数据移位、系数固定"的直接型改成半并行结构——一次只算M个抽头,分时复用乘法器;第二,把对称系数加法先行保留,这永远不会亏;第三,考虑利用FPGA厂商的FIR IP核,Xilinx的FIR Compiler和Intel的FIR IP都经过了充分的时序和资源优化,比自己手写的转置结构要靠谱得多。
有人会觉得用IP核丢人,其实大可不必。IP核帮你解决了乘法器调度、流水线深度、资源复制策略这些脏活累活,你只需要关心接口时序。这就像写Python不用自己实现list一样,没有错。
5.2 从FIR到抽取滤波、半带与多相分解
FIR滤波器经常会和多速率信号处理绑定出现。比如你的ADC以48kHz采样,但后级算法只需要24kHz的带宽,这时候可以直接用一个FIR做低通滤波,然后每两个输出取一个,这就是2倍抽取滤波器。聪明的工程师会发现,既然要扔掉一半的输出,那另一半本来就不该算——多相滤波器结构就是把标准FIR的系数按照抽取倍数分成多个子滤波器,每相只算原来抽头数的1/M,计算量直接除以M。
再进一步,半带滤波器是抽取滤波器的特殊优化形态,一半系数为0,乘法器数量直接减半,特别适合做2的整数次幂抽取。如果你想做音频重采样,从48kHz转96kHz或者反向操作,插值滤波器加多相分解是标准赛道。这些思路的共同点是:FIR系数设计是核心,RTL只是载体。我见过太多人在RTL上抠周期,却忽略了用算法层的优化减掉一个数量级的乘法量,这是本末倒置。
5.3 我的几条实操建议和高频踩坑记录
最后整理几条真正来自实战的注意事项,都是我在这类项目里踩过或者帮别人排查过的。
系数转成十进制补码放进Verilog时,注意用16'sd69这种有符号十进制常量写法,不要直接用16'h45。十六进制常量在拼接和仿真时容易被当成无符号数,引出一些极其隐蔽的bug。
移位寄存器链的使能信号不要乱打。如果你用x_valid作为移位使能,那就所有抽头统一用一个使能;如果你用"每个时钟都移位"的简化做法,那就必须保证上游数据每个时钟都是有效的。两种约定都可行,混用必然出错。
乘法器的输出寄存器是否保留,对时序影响很大。FPGA里的DSP48 Slice自带流水寄存器,例化乘法器时最好把输出寄存器打开。现代综合工具比如Vivado会自动推断,但如果你手写组合乘法再想在后级统一打拍,有些情况下综合器不一定会把两个寄存器吸收进DSP Slice,时序就输在起跑线上了。
截位之后务必做溢出饱和。如果截位后的数值超过输出16比特范围,直接输出会出现严重削波失真。饱和逻辑写起来很啰嗦但必须写,否则滤波输出的峰峰值稍微大一点,后级就会看到完全扭曲的信号。
5.4 从仿真到上板:还有一件容易被忽略的事
仿真跑通了,板子还没调通,这中间最大的变量是输入数据本身的时序质量。我曾经做过一个FIR验证工程,仿真波形完美,上板后输出始终带噪声,排查了整整一天。最后用逻辑分析仪抓输入信号才发现,问题根本不在FIR模块,而在ADC的输出时序——数据在时钟上升沿附近发生了跳变,导致每个采样周期大约有六分之一的概率采到了边沿数据。
这类问题和RTL没有关系,但验证FIR时你必须心里有数:FIR对输入存在"延迟敏感"特性,输入数据的建立保持时间不达标,再好的滤波器也白搭。如果官方的ADC例程里已经做了跨时钟域处理,FIR的输入就应该接在跨时钟域处理之后,不要图省事直接接原始ADC引脚。