4、FIR滤波器设计:窗函数法、等波纹设计、在STM32上的实现
说到FIR滤波器,我脑子里第一个蹦出来的词就是「稳」。
为什么?因为FIR天生就是线性相位的,不会把信号的形状搞歪。你在做雷达信号处理时,目标回波的相位信息太重要了,IIR那种非线性相位搞不好就把多普勒频移算错了。所以,我个人习惯,只要资源够用,优先上FIR。
4.1 窗函数法:最直观的设计方式
窗函数法,说白了就是「截断」。
理想的低通滤波器频率响应是个矩形,但时域上是无限长的sinc函数。你不可能在嵌入式里跑无限长的系数,所以得截一段出来。直接截断会怎么样?会产生吉布斯现象——通带和阻带边缘会有严重的振荡。
解决办法就是加窗。用窗函数去平滑截断的边缘,抑制旁瓣。
常用的窗函数有这些:
| 窗函数 | 主瓣宽度 | 旁瓣衰减 | 适用场景 |
|---|---|---|---|
| 矩形窗 | 最窄 | -13 dB | 对旁瓣无要求,追求过渡带最陡 |
| 汉宁窗 | 较宽 | -31 dB | 通用场景,旁瓣抑制不错 |
| 海明窗 | 较宽 | -41 dB | 语音信号处理常用 |
| 布莱克曼窗 | 最宽 | -57 dB | 需要极低旁瓣,但过渡带很宽 |
| 凯泽窗 | 可调 | 可调 | 灵活,通过β参数控制 |
我在项目中遇到过一个问题:用矩形窗设计了一个带通滤波器,仿真时看着挺好,一上硬件,目标信号旁边总有个假目标。查了半天,就是矩形窗旁瓣太高,把强杂波的频谱泄漏进来了。后来换成海明窗,问题就解决了。
设计步骤其实很简单:
- 确定滤波器阶数N和截止频率fc
- 生成理想低通滤波器的单位脉冲响应hd[n]
- 选择窗函数w[n]
- 加窗:h[n] = hd[n] · w[n]
代码示例(C语言,适用于STM32的系数预计算):
// 窗函数法生成低通FIR系数
// N: 滤波器阶数(必须为偶数)
// fc: 归一化截止频率 (0~0.5)
// coeff: 输出系数数组
void fir_win_lowpass(int N, float fc, float *coeff) {
int i;
float mid = N / 2.0f;
float sum = 0.0f;
for (i = 0; i <= N; i++) {
float n = (float)i - mid;
if (n == 0.0f) {
coeff[i] = 2.0f * fc;
} else {
coeff[i] = sinf(2.0f * PI * fc * n) / (PI * n);
}
// 加海明窗
coeff[i] *= (0.54f - 0.46f * cosf(2.0f * PI * i / N));
sum += coeff[i];
}
// 归一化,保证直流增益为1
for (i = 0; i <= N; i++) {
coeff[i] /= sum;
}
}
4.2 等波纹设计:更优的折中方案
窗函数法虽然简单,但有个问题——你没法精确控制通带波纹和阻带衰减。比如你要求通带波纹0.1dB,阻带衰减60dB,窗函数法只能「大概差不多」,然后你得多取几阶才能满足。
等波纹设计(也叫Parks-McClellan算法)就不一样了。它能把误差均匀分布在通带和阻带里,说白了就是「最优」的。
为什么叫等波纹?因为它的误差曲线看起来像一串等高的波纹。这个算法基于切比雪夫逼近理论,用Remez交换算法迭代求解。
等波纹设计的优势:
- 在相同阶数下,通带波纹和阻带衰减的指标最优
- 可以精确指定通带/阻带的边界频率
- 过渡带最陡
缺点嘛,就是计算复杂,没法在STM32上实时算。我一般用MATLAB的firpm函数或者Python的scipy.signal.remez来生成系数。
举个例子,设计一个带通滤波器,通带0.2~0.3(归一化),阻带衰减40dB:
# Python代码,在PC上运行
from scipy.signal import remez
# 参数:阶数,频带边界,幅值,权重
coeff = remez(31, # 阶数
[0, 0.15, 0.2, 0.3, 0.35, 0.5], # 频带边界
[0, 1, 0], # 各频带的目标幅值
[1, 10, 1]) # 各频带的权重
# 输出系数,直接复制到STM32代码中
for c in coeff:
print(f"{c:.8f}f,")
4.3 在STM32上的实现
好了,系数有了,怎么在STM32上跑起来?
FIR滤波器的实现就是乘累加运算。公式很简单:
y[n] = Σ h[k] · x[n-k]
但嵌入式实现时,有几个关键点要注意。
4.3.1 数据结构:环形缓冲区
每次来一个新样本,你都得把数据往前移?那太浪费CPU了。用环形缓冲区,只移动指针,不移动数据。
#define FIR_N 32 // 滤波器阶数
typedef struct {
float buf[FIR_N];
int index;
} fir_filter_t;
void fir_init(fir_filter_t *f) {
f->index = 0;
for (int i = 0; i < FIR_N; i++) {
f->buf[i] = 0.0f;
}
}
float fir_process(fir_filter_t *f, float input, const float *coeff) {
float output = 0.0f;
int idx = f->index;
// 存入新数据
f->buf[idx] = input;
// 乘累加
for (int i = 0; i < FIR_N; i++) {
output += coeff[i] * f->buf[(idx - i + FIR_N) % FIR_N];
}
// 更新指针
f->index = (idx + 1) % FIR_N;
return output;
}
4.3.2 定点数优化
STM32F4以上有FPU,用float没问题。但如果你用的是F1或者G0系列,没有硬件浮点,那就得用定点数。
我一般把系数放大到Q15格式(16位有符号整数),输入数据也是16位ADC值,乘累加后用32位累加器,最后再缩回来。
// Q15定点FIR实现
int16_t fir_q15(int16_t input, int16_t *coeff_q15, int16_t *buf, int *idx) {
int32_t acc = 0;
int i, pos;
buf[*idx] = input;
pos = *idx;
for (i = 0; i < FIR_N; i++) {
acc += (int32_t)coeff_q15[i] * (int32_t)buf[(pos - i + FIR_N) % FIR_N];
}
*idx = (pos + 1) % FIR_N;
// 右移15位,回到Q0格式
return (int16_t)(acc >> 15);
}
4.3.3 利用CMSIS-DSP库
别自己造轮子。ARM官方提供了CMSIS-DSP库,里面有针对Cortex-M4/M7优化的FIR函数,用了SIMD指令,速度比手写快很多。
#include "arm_math.h"
// 定义滤波器实例
arm_fir_instance_f32 S;
float32_t firStateF32[FIR_N + BLOCK_SIZE];
float32_t coeff[FIR_N];
// 初始化
arm_fir_init_f32(&S, FIR_N, coeff, firStateF32, BLOCK_SIZE);
// 处理数据块
arm_fir_f32(&S, input_buffer, output_buffer, BLOCK_SIZE);
我个人习惯用BLOCK_SIZE=1,每次中断处理一个样本。如果你用DMA双缓冲,可以设成64或128,效率更高。
4.4 避坑指南
最后分享几个我踩过的坑:
- 系数对称性: FIR系数是对称的,你可以只存一半,乘累加时先加再乘,省一半乘法运算。
- 初始化状态: 滤波器启动时,环形缓冲区必须清零。否则前几个输出会有瞬态响应,可能触发误检。
- 溢出问题: 定点实现时,累加器用32位,但多个大信号叠加可能溢出。我一般会在ADC前端加衰减,或者在累加后做饱和处理。
- 群延迟补偿: FIR滤波器有固定的群延迟,等于(N-1)/2个采样点。如果你做多通道同步处理,记得对齐各通道的延迟。
嗯,差不多就这些。窗函数法适合快速原型验证,等波纹设计适合对指标有严格要求的场景。在STM32上实现时,优先用CMSIS-DSP库,省心又高效。
下一章我们聊聊多速率信号处理,这东西在雷达里太常用了——降采样、升采样、CIC滤波器,都是实战利器。