4、FIR滤波器设计:窗函数法、等波纹设计、在STM32上的实现

说到FIR滤波器,我脑子里第一个蹦出来的词就是「稳」。

为什么?因为FIR天生就是线性相位的,不会把信号的形状搞歪。你在做雷达信号处理时,目标回波的相位信息太重要了,IIR那种非线性相位搞不好就把多普勒频移算错了。所以,我个人习惯,只要资源够用,优先上FIR。

4.1 窗函数法:最直观的设计方式

窗函数法,说白了就是「截断」。

理想的低通滤波器频率响应是个矩形,但时域上是无限长的sinc函数。你不可能在嵌入式里跑无限长的系数,所以得截一段出来。直接截断会怎么样?会产生吉布斯现象——通带和阻带边缘会有严重的振荡。

解决办法就是加窗。用窗函数去平滑截断的边缘,抑制旁瓣。

常用的窗函数有这些:

窗函数 主瓣宽度 旁瓣衰减 适用场景
矩形窗 最窄 -13 dB 对旁瓣无要求,追求过渡带最陡
汉宁窗 较宽 -31 dB 通用场景,旁瓣抑制不错
海明窗 较宽 -41 dB 语音信号处理常用
布莱克曼窗 最宽 -57 dB 需要极低旁瓣,但过渡带很宽
凯泽窗 可调 可调 灵活,通过β参数控制

我在项目中遇到过一个问题:用矩形窗设计了一个带通滤波器,仿真时看着挺好,一上硬件,目标信号旁边总有个假目标。查了半天,就是矩形窗旁瓣太高,把强杂波的频谱泄漏进来了。后来换成海明窗,问题就解决了。

设计步骤其实很简单:

  1. 确定滤波器阶数N和截止频率fc
  2. 生成理想低通滤波器的单位脉冲响应hd[n]
  3. 选择窗函数w[n]
  4. 加窗: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;
    }
}
我的小技巧: 在STM32上,我一般把系数提前在PC上用Python算好,存成const数组。这样MCU不用跑浮点运算,省下不少时间。

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,")
注意: 等波纹设计在过渡带边缘可能会出现「过冲」,尤其是阶数选得不够高的时候。我曾经在雷达脉冲压缩滤波器上吃过这个亏,仿真时没注意,实测时发现旁瓣电平比预期高了3dB。后来把阶数从32提到48,问题就解决了。

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);
}
性能数据: 在STM32F407上,32阶FIR滤波器,float版本耗时约1.2μs(168MHz主频),Q15定点版本约0.8μs。如果你的采样率是100kHz,一个采样周期是10μs,完全够用。

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滤波器,都是实战利器。