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,看看频谱图的差别。亲手做一次,比看十遍书都管用。