前几天有个做 FPGA 信号处理的朋友问我:“IIR 滤波器在 MATLAB 里仿真效果很好,但一用 Verilog 实现就出问题,要么不稳定,要么资源占用太高,到底该怎么写才能既稳定又高效?” 这个问题其实挺典型的——很多从算法转到硬件的工程师都会卡在 IIR 数字滤波器的 Verilog 实现上。IIR 滤波器因为反馈结构的存在,在硬件实现时需要考虑量化误差、稳定性、流水线冲突等一系列在软件仿真中不会暴露的问题。
如果你也在用 Verilog 写 IIR 滤波器,可能会发现单看理论公式很简单,但真正把差分方程转化成可综合、可稳定工作的硬件电路时,处处是坑。这篇文章不会只给你一段“看起来能工作”的代码,而是会从 IIR 滤波器的核心特性出发,解释为什么直接实现会出问题,以及如何通过结构选择、量化策略和流水线设计来避开这些坑。
1. 先搞清楚 IIR 滤波器在硬件里真正难在哪里
IIR(Infinite Impulse Response)滤波器和 FIR(Finite Impulse Response)最大的区别就是有反馈回路。这个反馈让 IIR 可以用较低的阶数实现尖锐的频响,但同时也带来了硬件实现上的三个核心挑战。
1.1 量化误差会被反馈回路放大
在 MATLAB 里,你用的都是双精度浮点数,系数可以精确到小数点后十几位。但在 FPGA 里,你需要把系数量化成有限的位数(比如 12bit、16bit)。这个量化误差在 FIR 滤波器里影响相对较小,因为 FIR 没有反馈。
但在 IIR 里,每次计算的误差都会通过反馈回路累积起来。如果滤波器的极点靠近单位圆,这种累积可能导致输出饱和或极限环振荡。这就是为什么在 MATLAB 里完美的设计,到硬件上可能完全变样。
// 直接型结构的危险示例(不推荐实际使用) always @(posedge clk) begin if (rst) begin y <= 0; end else begin // y[n] = b0*x[n] + b1*x[n-1] + ... - a1*y[n-1] - a2*y[n-2] - ... y <= b0 * x + b1 * x_d1 + b2 * x_d2 - a1 * y_d1 - a2 * y_d2; end end这种直接I型实现看起来直观,但对量化误差极其敏感。
1.2 反馈回路限制了时序性能
在 FIR 滤波器中,你可以通过插入流水线寄存器来提升工作频率,因为 FIR 是纯粹的前馈结构。但 IIR 的反馈回路创造了一个组合逻辑的闭环:当前的输出依赖于之前的输出。
这意味着你不能在反馈路径上随意插入流水线寄存器,否则会破坏时序关系。如果滤波器的采样率要求很高,这个反馈回路可能成为时序瓶颈。
1.3 稳定性判断在硬件中更复杂
软件中的稳定性可以通过计算极点位置来判断,所有极点都在单位圆内就稳定。但在硬件中,由于量化效应,原本在单位圆内的极点可能会被"推"到单位圆外。
更麻烦的是,某些情况下滤波器会在量化误差的影响下产生极限环振荡——即使输入为零,输出也会在小范围内持续振荡。这种现象在理论分析中很难预测,只有在实际硬件测试中才会暴露。
2. 选择适合硬件的滤波器结构比优化代码更重要
面对上述挑战,直接照搬教科书上的直接型结构通常不是好选择。你需要根据具体需求选择更适合硬件实现的结构。
2.1 二阶节级联:最实用的折中方案
将高阶滤波器分解为多个二阶节(Biquad)级联,是硬件实现中最常用的方法。每个二阶节可以独立处理,降低了设计复杂度,也减少了量化误差的敏感性。
// 单个二阶节的直接II型转置结构 module iir_biquad #( parameter WIDTH = 16, parameter COEF_WIDTH = 12 )( input clk, input rst, input signed [WIDTH-1:0] x_in, output signed [WIDTH-1:0] y_out ); // 系数定义(需要根据具体滤波器设计确定) wire signed [COEF_WIDTH-1:0] b0, b1, b2, a1, a2; // 状态寄存器 reg signed [WIDTH-1:0] s1, s2; reg signed [WIDTH-1:0] x_d1, x_d2; // 中间计算结果(需要足够的位宽防止溢出) wire signed [WIDTH+COEF_WIDTH:0] sum1, sum2; always @(posedge clk) begin if (rst) begin s1 <= 0; s2 <= 0; x_d1 <= 0; x_d2 <= 0; end else begin // 更新状态寄存器 s1 <= sum1[WIDTH+COEF_WIDTH:COEF_WIDTH]; // 截断到合适位宽 s2 <= sum2[WIDTH+COEF_WIDTH:COEF_WIDTH]; x_d1 <= x_in; x_d2 <= x_d1; end end // 组合逻辑计算 assign sum1 = x_in * b0 + s2; assign sum2 = x_d1 * b1 - s1 * a1 + s2; assign y_out = s1; endmodule这种结构的优点是每个二阶节相对独立,便于测试和调试。如果某个二阶节出现问题,可以单独调整而不影响整个滤波器。
2.2 一阶节并联:更好的数值稳定性
对于高阶滤波器,并联结构(将滤波器分解为一阶或二阶节的并联组合)通常有更好的数值特性。因为每个节的误差不会传播到其他节,减少了误差累积。
不过并联结构的缺点是设计稍微复杂,需要完成部分分式分解。在实际工程中,除非对数值稳定性有极高要求,否则级联结构通常足够用。
2.3 直接型与级联型的对比选择
为了更直观地展示不同结构的优劣,这里有一个实用对比表:
| 特性 | 直接型 | 级联型 | 并联型 |
|---|---|---|---|
| 硬件资源 | 最省 | 中等 | 较多 |
| 数值稳定性 | 差 | 好 | 最好 |
| 设计复杂度 | 简单 | 中等 | 复杂 |
| 可调试性 | 差 | 好 | 好 |
| 适合场景 | 低阶滤波器(1-3阶) | 通用选择 | 高精度要求 |
对于大多数应用,我从经验上建议:4阶及以下可以考虑直接型,4阶以上强烈推荐级联型,只有在对数值误差极其敏感的应用中才考虑并联型。
3. 量化策略:决定滤波器实际性能的关键细节
系数和数据的量化方式直接影响滤波器的频率响应和稳定性。这里有几个容易忽略但至关重要的细节。
3.1 系数量化不能简单截断
MATLAB 生成的系数通常需要量化到硬件支持的位宽。最简单的办法是乘以 2^n 后取整,但这样可能改变滤波器的特性。
更好的做法是使用频率响应验证:量化前后在 MATLAB 中对比频响曲线,确保通带、阻带特性没有明显恶化。
% MATLAB 中的系数量化验证示例 [b_float, a_float] = butter(4, 0.2); % 原始浮点系数 scale = 2^11; % 11位小数位 b_fixed = round(b_float * scale) / scale; a_fixed = round(a_float * scale) / scale; % 比较频响 freqz(b_float, a_float); hold on; freqz(b_fixed, a_fixed); legend('浮点', '定点');如果量化后频响变化太大,可能需要增加位数或选择对量化不敏感的滤波器结构。
3.2 中间计算结果需要足够的位宽
在定点运算中,乘法结果的位宽是操作数位宽之和。如果每一步都直接截断,会引入大量误差。
// 错误的做法:过早截断 wire signed [15:0] result = (a * b) >> 8; // 立即截断 // 正确的做法:保持足够位宽,最后统一截断 wire signed [31:0] temp = a * b; // 保持全精度 // ... 经过多个计算步骤 ... wire signed [15:0] final_result = accumulated_temp >>> 16; // 最后截断一般规则是:内部计算位宽应该是输入位宽的 2 倍加上系数位宽,最后根据输出需求截断。
3.3 饱和处理与溢出保护
IIR 滤波器可能因为特定输入而产生很大的中间值,即使最终输出在正常范围内。需要在关键位置添加饱和处理:
// 饱和处理函数 function signed [15:0] saturate; input signed [31:0] value; begin if (value > 32767) saturate = 32767; else if (value < -32768) saturate = -32768; else saturate = value[15:0]; end endfunction // 在关键计算点应用饱和处理 wire signed [31:0] raw_result = a * b + c * d; wire signed [15:0] safe_result = saturate(raw_result);4. 时序与流水线:在速度与稳定性间找到平衡
反馈回路的存在让 IIR 滤波器的流水线设计变得棘手,但并非完全没有优化空间。
4.1 仔细分析关键路径
使用综合工具的时序报告识别关键路径。如果乘法器是瓶颈,可以考虑:
- 使用 FPGA 内置的 DSP 块
- 将大位宽乘法拆分为多个小乘法
- 使用 Booth 编码等优化算法
4.2 有限度的流水线优化
虽然不能在反馈回路中插入流水线,但可以在前馈路径中优化:
// 有限流水线优化示例 module pipelined_biquad #( parameter WIDTH = 16 )( input clk, rst, input signed [WIDTH-1:0] x_in, output signed [WIDTH-1:0] y_out ); // 第一级流水:输入寄存器 reg signed [WIDTH-1:0] x_reg; reg signed [WIDTH-1:0] s1_reg, s2_reg; // 第二级流水:乘法器输出 wire signed [2*WIDTH:0] prod1, prod2, prod3, prod4; reg signed [2*WIDTH:0] prod1_reg, prod2_reg, prod3_reg, prod4_reg; // 第三级流水:加法器树输出 wire signed [2*WIDTH+2:0] sum; reg signed [WIDTH-1:0] y_reg; always @(posedge clk) begin if (rst) begin // 复位所有寄存器 x_reg <= 0; s1_reg <= 0; s2_reg <= 0; prod1_reg <= 0; // ... 其他寄存器复位 end else begin // 流水线推进 x_reg <= x_in; prod1_reg <= prod1; // ... 其他寄存器更新 // 反馈状态更新(注意这里不能流水!) s1_reg <= sum[2*WIDTH+2:WIDTH+2]; // 适当截断 s2_reg <= s1_reg; end end // 组合逻辑 assign prod1 = x_reg * b0; assign prod2 = s2_reg * b2; // ... 其他乘法 assign sum = prod1_reg + prod2_reg + prod3_reg - prod4_reg; // 注意减法对应反馈项 assign y_out = y_reg; endmodule这种设计在反馈路径上保持单周期延迟,但在前馈路径上使用流水线,可以在不破坏稳定性的前提下提升频率。
4.3 时序约束与时钟域考虑
如果滤波器需要处理跨时钟域的数据,需要添加适当的同步电路:
// 输入数据同步 reg [WIDTH-1:0] sync1, sync2, sync3; always @(posedge clk) begin sync1 <= async_input; sync2 <= sync1; sync3 <= sync2; end // 使用 sync3 作为稳定的输入同时,在综合约束文件中添加适当的时序约束,确保工具能够优化关键路径。
5. 测试与调试:确保滤波器在实际环境中可靠工作
写完 Verilog 代码只是第一步,充分的测试才能保证滤波器在实际应用中稳定工作。
5.1 分层测试策略
模块级测试:每个二阶节单独测试,验证基本功能
- 输入单位脉冲,检查冲击响应
- 输入直流信号,验证增益
- 输入正弦波,验证频率响应
系统级测试:整个滤波器链测试
- 扫频测试,测量实际频响
- 噪声输入测试,检查稳定性
- 边界值测试,检查溢出处理
5.2 实际信号测试用例
不要只满足于仿真测试,在 FPGA 上验证实际信号:
// 简单的测试信号生成 reg [15:0] test_counter; reg signed [15:0] test_signal; always @(posedge clk) begin test_counter <= test_counter + 1; // 生成正弦测试信号 test_signal = amplitude * $sin(2 * 3.14159 * frequency * test_counter / sampling_rate); end5.3 性能监控与调试接口
在实际系统中添加调试接口,便于问题诊断:
// 可选的调试输出 output reg [31:0] debug_overflow_count; output reg [15:0] debug_max_value; always @(posedge clk) begin if (y_out == 16'h7FFF || y_out == 16'h8000) debug_overflow_count <= debug_overflow_count + 1; if ($abs(y_out) > debug_max_value) debug_max_value <= $abs(y_out); end6. 从单滤波器到可配置滤波器的演进
在实际项目中,经常需要支持多种滤波器配置。这时候就需要设计参数化的滤波器架构。
6.1 系数可重载设计
module configurable_iir #( parameter NUM_STAGES = 2, parameter WIDTH = 16, parameter COEF_WIDTH = 12 )( input clk, rst, input config_enable, input [3:0] config_stage, // 配置哪个二阶节 input [COEF_WIDTH-1:0] config_b0, config_b1, config_b2, config_a1, config_a2, input signed [WIDTH-1:0] x_in, output signed [WIDTH-1:0] y_out ); // 系数存储器 reg [COEF_WIDTH-1:0] b0[0:NUM_STAGES-1], b1[0:NUM_STAGES-1], b2[0:NUM_STAGES-1]; reg [COEF_WIDTH-1:0] a1[0:NUM_STAGES-1], a2[0:NUM_STAGES-1]; // 系数更新逻辑 always @(posedge clk) begin if (config_enable) begin b0[config_stage] <= config_b0; b1[config_stage] <= config_b1; // ... 其他系数更新 end end // 实例化多个二阶节 generate genvar i; for (i = 0; i < NUM_STAGES; i = i + 1) begin: stage iir_biquad #(.WIDTH(WIDTH), .COEF_WIDTH(COEF_WIDTH)) biquad_inst ( .clk(clk), .rst(rst), .x_in(i == 0 ? x_in : stage[i-1].y_out), .y_out(stage[i].y_out), .b0(b0[i]), .b1(b1[i]), .b2(b2[i]), .a1(a1[i]), .a2(a2[i]) ); end endgenerate assign y_out = stage[NUM_STAGES-1].y_out; endmodule这种设计允许在运行时改变滤波器特性,适合需要动态调整的应用场景。
6.2 自适应滤波器初步思路
对于更高级的应用,可以考虑自适应 IIR 滤波器。虽然实现复杂,但基本思路是在原有结构上添加系数更新逻辑:
// 简化的 LMS 自适应算法实现片段 always @(posedge clk) begin // 计算误差:期望信号与实际输出之差 error <= desired_signal - y_out; // 根据误差更新系数(需要适当的步长控制) b0 <= b0 + mu * error * x_in; b1 <= b1 + mu * error * x_delayed_1; // ... 其他系数更新 end自适应滤波器对数值精度和稳定性要求更高,需要更仔细的设计和测试。
真正可靠的 IIR 滤波器实现不是一蹴而就的,需要经过仿真、板级测试、实际信号验证等多个阶段的迭代优化。关键是要理解算法与硬件之间的差距,并在设计初期就考虑数值稳定性、时序约束和测试方案。从简单的定点仿真开始,逐步推进到硬件实现,每一步都验证正确性,这样才能构建出既满足性能要求又稳定可靠的数字滤波器。