4. 频域分析入门:傅里叶变换、频谱泄露、窗函数的作用

好,咱们进入第四章。说实话,很多做嵌入式传感器的朋友,一听到「频域分析」就头大。我当年刚入行时也一样,觉得时域波形看得清清楚楚,干嘛要折腾到频域去?

直到有一次,我在做一个电机振动监测项目。时域波形看起来就是一团乱麻,根本分不清是轴承磨损还是齿轮啮合的问题。后来把数据扔进频谱分析仪一看,好家伙,特征频率一目了然。从那以后,我就再也不敢小看傅里叶变换了。

4.1 为什么我们需要频域?

说白了,时域告诉你「什么时候发生了什么」,频域告诉你「什么频率在发生」。你想想看,一个传感器信号里可能混着多种成分:

  • 50Hz 的工频干扰
  • 几百赫兹的机械振动
  • 几赫兹的缓慢漂移

在时域里,它们全叠在一起,你根本分不清谁是谁。但在频域里,每个频率成分就像排队一样,清清楚楚地站在自己的位置上。

核心思想:傅里叶变换就是把信号从时间轴掰到频率轴。它告诉我们,任何一个看似复杂的信号,都可以拆解成一系列不同频率、不同幅值、不同相位的正弦波之和。

4.2 离散傅里叶变换(DFT)与 FFT

咱们在嵌入式系统里处理的是采样后的离散数据,所以用的是离散傅里叶变换(DFT)。但 DFT 的计算量太大了——N 个点需要 N² 次运算。我早期用 8 位单片机做 1024 点 DFT,算到天荒地老。

后来有了快速傅里叶变换(FFT),计算量降到了 N·log₂(N)。1024 点 FFT 只需要约 10240 次运算,比 DFT 快了 100 倍。嗯,这才是嵌入式能用的东西。

标准的 FFT 代码实现,我习惯用这个基 2 的版本:

// 基2 FFT,输入为复数数组,N必须为2的幂
void fft(float real[], float imag[], int n) {
    int i, j, k, m;
    int step, half;
    float temp_real, temp_imag;
    float c, s, angle;
    
    // 位反转排序
    j = 0;
    for (i = 0; i < n - 1; i++) {
        if (i < j) {
            temp_real = real[i]; real[i] = real[j]; real[j] = temp_real;
            temp_imag = imag[i]; imag[i] = imag[j]; imag[j] = temp_imag;
        }
        k = n >> 1;
        while (k <= j) {
            j -= k;
            k >>= 1;
        }
        j += k;
    }
    
    // 蝶形运算
    for (step = 2; step <= n; step <<= 1) {
        half = step >> 1;
        for (m = 0; m < half; m++) {
            angle = -2 * PI * m / step;
            c = cos(angle);
            s = sin(angle);
            for (i = m; i < n; i += step) {
                j = i + half;
                temp_real = c * real[j] - s * imag[j];
                temp_imag = c * imag[j] + s * real[j];
                real[j] = real[i] - temp_real;
                imag[j] = imag[i] - temp_imag;
                real[i] += temp_real;
                imag[i] += temp_imag;
            }
        }
    }
}

我的经验:在 Cortex-M4 这类带 FPU 的芯片上,用 CMSIS-DSP 库的 arm_cfft_f32() 函数,比手写快 5 倍以上。别重复造轮子,除非你想练手。

4.3 频谱泄露——一个让人头疼的问题

好,现在你学会了 FFT,高高兴兴地把一段传感器数据扔进去。结果一看频谱图——咦?明明只有一个 50Hz 的信号,怎么旁边冒出来一堆乱七八糟的频率分量?

这就是频谱泄露。为什么会这样?

你想想看,FFT 处理的是有限长度的数据。它默认这段数据是周期性重复的。但如果你的采样长度不是信号周期的整数倍,那在数据首尾连接处就会产生「跳变」。这个跳变在频域里就表现为额外的频率分量。

我记得有一次做音频降噪,采集了一段 1000Hz 的纯音,采样率 8000Hz,采了 100 个点。FFT 结果出来,1000Hz 旁边多了好多旁瓣,差点让我以为硬件坏了。后来一算,100 个点不是 1000Hz 周期的整数倍,泄露就来了。

避坑指南:我曾经在一个振动监测项目里,因为没注意频谱泄露,把轴承的微弱故障特征误判成了正常信号。后来加了窗函数才揪出问题。记住——不做窗函数处理的频谱分析,就像不戴手套做手术。

4.4 窗函数——解决泄露的利器

窗函数的作用,说白了就是「温柔地」把数据两端削到接近零,消除首尾的跳变。这样 FFT 看到的信号就是平滑过渡的,泄露自然就小了。

常用的窗函数有几种,各有各的脾气:

窗函数 主瓣宽度 旁瓣衰减 适用场景
矩形窗(不加窗) 最窄 -13dB 瞬态信号、频率成分间隔大
汉宁窗(Hanning) 较宽 -32dB 一般用途,最常用
海明窗(Hamming) 较宽 -43dB 语音信号、窄带信号
布莱克曼窗 最宽 -58dB 需要极低旁瓣的场景
凯泽窗 可调 可调 灵活控制主瓣与旁瓣

我个人习惯,90% 的场景直接用汉宁窗。它是个「万金油」,旁瓣衰减够用,主瓣宽度也能接受。除非你特别需要分辨两个很近的频率成分,那才考虑用矩形窗。

加窗的代码很简单,就是在 FFT 之前把数据乘上窗函数:

// 生成汉宁窗并应用到数据上
void apply_hanning(float data[], int n) {
    for (int i = 0; i < n; i++) {
        float window = 0.5 * (1 - cos(2 * PI * i / (n - 1)));
        data[i] *= window;
    }
}

小技巧:加窗后信号的总能量会减少,因为两端被削掉了。如果你需要精确的幅值测量,记得做幅值补偿——乘以 2(汉宁窗)或相应的补偿系数。

4.5 实战中的选择策略

讲了这么多理论,咱们回到实际项目里。你该怎么选?

  • 做频率成分检测:用汉宁窗,旁瓣低,不容易误判。
  • 做幅值精确测量:如果信号频率已知且采样长度是整数倍周期,用矩形窗(不加窗)。否则用平顶窗(Flat Top)。
  • 做瞬态信号分析:比如冲击响应,用矩形窗保留瞬态细节。
  • 做窄带信号分离:两个频率靠得很近,用矩形窗或凯泽窗(调低 β 值)。

嗯,这里要注意一点——窗函数不是万能的。它只是「缓解」泄露,不能「消除」泄露。真正消除泄露的方法,是让采样长度正好是信号周期的整数倍。但在实际项目中,信号频率往往是未知的或变化的,所以窗函数就成了我们最实用的工具。

最后说一句,频谱分析这东西,光看书没用。我建议你拿一段实际传感器数据,分别用矩形窗和汉宁窗跑一遍 FFT,看看频谱图的差别。亲手做一次,比看十遍书都管用。