前段时间在做一个振动监测的上位机项目,Qt负责界面、数据采集和波形显示,传感器传回来的原始信号需要实时算成功率谱密度(PSD),用来判断设备有没有出现异常振动。当时第一反应是先用MATLAB离线验证算法,但因为现场要部署独立的上位机,最后还是决定在Qt工程里直接集成FFTW来做。FFTW这个库给我的印象非常直接——它足够快、跨平台、开源协议友好(MIT License),配合经典的周期图法做稳态信号的功率谱估计,工程上非常成熟。这篇文章把我的实现过程完整梳理了一遍,从库的接入、公式的拆解、关键代码,到实测中踩过的坑,尽量写清楚。适合正在Qt项目里做频谱分析、又不想在FFT底层细节和信号处理公式里绕圈的开发者参考。
1. 功率谱密度分析:为什么要从时域走到频域
1.1 时域波形看不到的东西,频谱能放大
做振动监测或者音频分析的时候,原始信号在时域图上看起来就是一个上下抖动的不规则波形。设备正常运行时振动很小,一旦轴承磨损或者齿轮出现故障,特征频率附近的能量就会异常抬升,但如果你只看时域波形,很难发现这种细微变化。
举个我项目里的例子。一个电机转速大约是3000rpm,对应的基频是50Hz,采样率设定为1024Hz,采集1024个点。信号里如果混入一个幅值很小的120Hz分量,时域图上根本看不出来,波形几乎和纯50Hz正弦没有区别。但是对它做FFT之后,120Hz位置会出现一个明显的谱峰,再结合不同工况对比,就能判断频率成分的变化。
这就是从时域走到频域的直接理由:把叠加在一起的不同频率成分“拆开”,让特征频率的能量变化可以被量化。拿音频来类比更容易理解——人耳能分辨出一首曲子里的不同乐器,靠的也是频谱分析,时域波形反而看不出门道。
1.2 PSD和普通FFT频谱不是一回事
不少人一开始会把PSD和FFT幅度谱混在一起,实际它们解决的问题不一样。
FFT输出的是一组复数,包含幅度和相位信息。对于确定性信号(比如正弦波叠加),幅度谱非常直观,某个频率点的谱峰高度直接对应信号幅度。但对于随机信号(振动噪声、湍流、语音背景噪声),幅度谱会非常毛糙,没有统计意义,你很难拿一个谱峰的数值去做定量分析。
PSD描述的是信号功率在频率上的分布,单位是V^2/Hz(对应传感器物理单位也可以),它更适合随机信号和振动分析的场景。工程上判断“哪个频段能量占比高”“振动烈度是否超限”,用的基本都是PSD。
用生活化的类比来说:FFT幅度谱像一张各年龄段人数的统计表,PSD则像是按年龄分组的“人口密度分布”——数值的含义和单位不同,使用场景也就不同。对于振动监测、噪声评估、结构健康监测这类应用,PSD是更常态的选择。
2. FFTW选型与工程接入:性能和许可都要看
2.1 比了三个方案,最后还是选FFTW
在Qt里做FFT,方案其实不少。我认真对比过几类:
| 方案 | 性能 | 许可 | 工程集成 | 备注 |
|---|---|---|---|---|
| 自己写基2 FFT | 中低 | 无限制 | 简单 | 需要维护、边界问题多,性能远弱于FFTW |
| KissFFT | 中 | BSD | 简单 | 轻量,适合嵌入式,速度不及FFTW |
| FFTW | 高 | MIT | 中等 | plan机制自动选择最优算法,非常成熟 |
| Intel MKL | 高 | 商业 | 较繁琐 | 非Intel平台部署麻烦,体积大 |
我的选型逻辑很简单:上位机跑在普通的x86平台,CPU性能有限但是要实时刷新,FFTW的性能收益非常可观。它最核心的特性是plan机制——在执行真实变换前,先对输入规模和算法参数做一次“试运行”,从中挑出最快路径,后面每次执行都复用这个方案。对于固定采样点数(例如每次处理1024点或者4096点)的周期性分析任务,这个机制几乎是为我们量身定做的。
还有一点很重要:FFTW的许可协议是MIT,可以放心集成在商业上位机里,不需要对外公开代码。相比之下,Intel MKL的许可政策和企业部署条件要麻烦一些。
2.2 Windows下Qt接入FFTW:预编译库和导入库
Windows下面最省事的方式是直接用FFTW官网的预编译包。需要留意的是,官方预编译包有32位和64位的区别,必须和你的Qt构建套件保持一致。我自己曾经因为本机装的是64位MinGW Qt,结果拿了个32位的库,链接期各种符号找不到,浪费了不少时间。
具体步骤:
- 从FFTW官网下载对应的预编译包(例如64位版本)。
- 解压后会看到libfftw3-3.dll、libfftw3f-3.dll、libfftw3l-3.dll三个文件,分别对应double、float、long double三个精度版本。做功率谱分析用double精度的libfftw3-3.dll即可,省去某些场景下float精度不够的隐患。
- 用Visual Studio的lib.exe生成导入库文件,命令大致是:
如果是MinGW环境,也可以用dlltool来生成;不过我个人的建议是直接用别人编译好的.lib文件,省心很多。lib /def:libfftw3-3.def /out:libfftw3-3.lib /machine:x64 - 把dll放在可执行文件目录,lib文件路径加入Qt的LIBS,头文件路径加入INCLUDEPATH。
.pro文件中这样写:
INCLUDEPATH += $$PWD/3rdparty/fftw/include LIBS += -L$$PWD/3rdparty/fftw/lib -lfftw3-3注意Windows下库名写成libfftw3-3.a还是-lfftw3-3,取决于工具链:MinGW一般用-lfftw3-3对应libfftw3-3.a,MSVC则对应.lib文件。这里最容易出问题的地方有两个:下载库的位数和编译器套件不匹配,以及没把dll放到运行目录导致exe启动报“找不到DLL”。
2.3 Linux下的接入:apt一行搞定
如果是在Ubuntu这类Linux发行版上开发,接入就简单很多:
sudo apt install libfftw3-dev安装完成后,头文件在/usr/include,库文件在/usr/lib/x86_64-linux-gnu/,Qt的.pro文件只需要加一行:
LIBS += -lfftw3f如果你打算用float精度的版本,链接名是libfftw3f;double精度版本则链接-lfftw3。需要说明的是,FFTW的多线程版对应-lfftw3_omp或-lfftw3_threads,如果单帧数据量不大,单线程完全够用,可以暂时不用引入多线程复杂度。
3. 周期图法公式拆解:代码里的每一行在算什么
3.1 周期图法的基本形式
周期图法(Periodogram)是功率谱密度估计里最经典也是最直接的方法,思路就是把一段有限长信号做一次FFT,再对频谱幅值取平方,最后除以采样率和点数做归一化。
对于N点离散信号x[n],采样率为fs,周期图法的计算公式是:
PSD[k] = |X[k]|^2 / (fs * N)
其中X[k]是N点DFT结果,k=0,1,...,N/2(对应实信号单边分析时只取前一半)。
这个公式看起来很简洁,但每一项都有实际含义。|X[k]|^2表示频率为k*fs/N处的信号能量在N个采样点内的总和,除以N是平均到每个点,再除以fs是把“每个点的能量”换算成“每赫兹的功率密度”,最终单位才是V^2/Hz。
为什么除以fs?因为DFT的频率分辨率Δf=fs/N,每个频点代表的频率带宽是Δf。能量除以带宽,得到的就是密度。直观理解就是:把一段信号的总能量按照频率划分成一个个“小篮子”,每个篮子的容量是Δf赫兹,然后用篮子里的能量除以篮子的带宽,得到该频段的密度。需要说明的是,严格意义上的周期图法还涉及极限和期望运算,工程实现时用一段有限数据直接算即可,这在实际应用里也是最常用的近似。
3.2 窗函数:为什么拿到的谱总是“糊”的
直接对原始信号做FFT,相当于默认加了一个矩形窗。矩形窗的频谱旁瓣衰减很慢,结果是强频率分量会在旁边泄漏出一堆“假”的谱线,弱信号很容易被淹没。
解决方式是给数据加一个边缘平滑的窗函数,汉宁窗是最常用的:
w[n] = 0.5 * (1 - cos(2πn / (N - 1))), n = 0,1,...,N-1
加窗之后,主瓣会变宽,但旁瓣衰减明显改善,弱信号更容易被看到。代价是信号的总能量变小了,因为窗函数把边缘的数据衰减了。因此做PSD归一化时,不能简单地除以fs*N,要改用:
PSD[k] = |X[k]|^2 / (fs * Σ w[n]^2)
其中Σ w[n]^2是窗函数的能量。这是很多人容易漏掉的地方:加窗后如果不修正归一化因子,幅值会系统性偏小。汉宁窗的Σw^2大约在0.375N左右(N较大时),也就是说,如果不修正,PSD会整体被低估将近一半。
窗函数的选择实际上是在“频率分辨率”和“幅值精度/旁瓣抑制”之间做权衡。矩形窗分辨率最高但泄漏严重,汉宁窗综合表现最好,所以是工程默认选项。
3.3 单边谱与双边谱:为什么不乘2就少3dB
FFTW的r2c变换输出N/2+1个复数,对应频率从0到fs/2。因为实信号的频谱共轭对称,负频率部分没有额外信息,通常分析时只看正频率部分。
这时候有个细节:如果直接用|X[k]|^2/(fs*Σw²)计算,得到的是双边谱,因为原来总能量被分成了正负两个频率区域。工程上更常用的是单边谱,做法是把正频率部分的PSD乘以2(直流分量k=0不乘):
PSD_single[k] = 2 * PSD_double[k], k > 0
如果不乘2,单频正弦信号的谱峰高度会比MATLAB的pwelch函数结果小一半,换算成对数坐标就是3dB差距。这个问题非常隐蔽,因为信号形状看起来是“对的”,只是整体偏低,特别容易被忽略。
注意:单边谱乘2只对k>0的频点生效,直流分量(k=0)不需要乘2。
4. 核心实现:从原始数据到PSD曲线
4.1 数据帧准备:采样长度和频率分辨率怎么定
在写FFT代码之前,先要确定两个参数:采样率fs和单帧点数N。它们直接决定频率分辨率Δf=fs/N。比如采样率1024Hz,取1024点,那么分辨率是1Hz;取4096点,分辨率变成0.25Hz。
实际项目中,我一般根据“需要分辨的最小频率间隔”来反推N。比如振动监测要看50Hz和60Hz两个近邻频率是否分离,至少需要10Hz左右的频率分辨率,那么N=fs/10=102.4,取2的幂就是128或256。当然N越大,FFT计算量越大,界面刷新频率也受影响,这里需要平衡。
FFTW本身不要求数据长度必须是2的幂,但实际工程中我仍然建议使用2的幂,一方面因为FFTW对这种长度做了高度优化,另一方面很多采集系统出来的数据天然是2的幂长度。如果数据长度不是2的幂,可以考虑补零到最近的2的幂,注意补零不会提高真实频率分辨率(分辨率由有效数据长度决定),只是让频谱看起来更平滑。
4.2 FFTW三步走:内存分配、plan创建、执行
FFTW的API风格非常统一,核心就三个阶段。以double精度、实信号转复数频谱为例:
#include <fftw3.h> int N = 1024; // 1. 分配对齐内存 double* in = (double*)fftw_malloc(sizeof(double) * N); fftw_complex* out = (fftw_complex*)fftw_malloc(sizeof(fftw_complex) * (N / 2 + 1)); // 2. 创建plan fftw_plan plan = fftw_plan_dft_r2c_1d(N, in, out, FFTW_ESTIMATE); // 3. 执行变换 fftw_execute(plan); // 使用完销毁plan并释放内存 fftw_destroy_plan(plan); fftw_free(in); fftw_free(out);关键点在于fftw_malloc,它分配的是SIMD指令要求的对齐内存。如果使用普通的new或者malloc去分配in和out,在某些平台上可能产生性能下降甚至崩溃(特别是使用了FFTW的SIMD优化路径时)。这个细节看起来小,但遇到莫名其妙的崩溃时非常坑。
plan创建时有两个常用标志:FFTW_ESTIMATE和FFTW_MEASURE。前者创建速度快,但执行性能可能不是最优;后者会做一轮测量优化,创建时间明显更长,但后续执行更快。对于固定点数、长期重复计算的分析任务,我建议用FFTW_MEASURE创建plan之后反复executed(注意不是反复create plan),效率最好。
4.3 完整的PSD计算函数
把前面说的原理整合成一个可复用的函数,输入信号和采样率,输出频率数组和PSD数组:
#include <fftw3.h> #include <vector> #include <cmath> struct PSDResult { std::vector<double> freq; std::vector<double> psd; // 单边功率谱密度 V^2/Hz }; PSDResult computePsd(const std::vector<double>& signal, double fs, bool useHanning = true) { const int N = static_cast<int>(signal.size()); PSDResult result; // 1. 分配FFTW内存 double* in = (double*)fftw_malloc(sizeof(double) * N); fftw_complex* out = (fftw_complex*)fftw_malloc(sizeof(fftw_complex) * (N / 2 + 1)); // 2. 构造窗函数并应用到输入信号 std::vector<double> window(N, 1.0); double windowEnergy = 0.0; if (useHanning) { for (int i = 0; i < N; ++i) { window[i] = 0.5 * (1.0 - cos(2.0 * M_PI * i / (N - 1))); in[i] = signal[i] * window[i]; windowEnergy += window[i] * window[i]; } } else { for (int i = 0; i < N; ++i) { in[i] = signal[i]; } windowEnergy = N; // 矩形窗 } // 3. 创建plan并执行(固定N时可复用plan,这里为清晰起见每次创建) fftw_plan plan = fftw_plan_dft_r2c_1d(N, in, out, FFTW_ESTIMATE); fftw_execute(plan); // 4. 计算单边PSD const int nOut = N / 2 + 1; const double df = fs / N; for (int k = 0; k < nOut; ++k) { double magSq = out[k][0] * out[k][0] + out[k][1] * out[k][1]; double psdVal = magSq / (fs * windowEnergy); if (k > 0) { psdVal *= 2.0; // 单边谱 } result.freq.push_back(k * df); result.psd.push_back(psdVal); } fftw_destroy_plan(plan); fftw_free(in); fftw_free(out); return result; }这段代码可以直接编译使用。需要注意的地方有两处:第一,windowEnergy的累加是在所有点循环完之后才完成的,所以要先循环填充in和windowEnergy,再执行FFT;第二,单边谱乘2的时候跳过了k=0的直流分量。
如果在实际项目中每帧数据长度固定,建议把分配内存、创建plan的代码提取到初始化阶段,避免每帧都重新分配内存和创建plan,这样可以减少大量无关开销。
4.4 用QChart把PSD曲线画出来
Qt Charts是Qt官方提供的绘图模块,画频谱曲线非常方便。在.pro里加上:
QT += charts然后创建曲线并显示:
#include <QtCharts/QChart> #include <QtCharts/QChartView> #include <QtCharts/QLineSeries> #include <QtCharts/QValueAxis> // 假设result是上面computePsd的返回值 QLineSeries* series = new QLineSeries(); for (size_t i = 0; i < result.freq.size(); ++i) { series->append(result.freq[i], result.psd[i]); } QChart* chart = new QChart(); chart->addSeries(series); chart->legend()->hide(); chart->setTitle(QStringLiteral("功率谱密度分析")); QValueAxis* axisX = new QValueAxis; axisX->setTitleText(QStringLiteral("频率 (Hz)")); axisX->setRange(0, 500); QValueAxis* axisY = new QValueAxis; axisY->setTitleText(QStringLiteral("PSD (V^2/Hz)")); axisY->setRange(0, 5); chart->addAxis(axisX, Qt::AlignBottom); chart->addAxis(axisY, Qt::AlignLeft); series->attachAxis(axisX); series->attachAxis(axisY); QChartView* chartView = new QChartView(chart); chartView->setRenderHint(QPainter::Antialiasing);如果你觉得PSD的值域跨度太大,可以转换成对数坐标显示:10 * log10(psd),单位变成dB/Hz,动态范围更直观。也可以把Y轴换成QLogValueAxis,但要注意PSD接近0的地方取对数会得到负无穷,需要做下限保护。
5. 实测中踩过的坑:排查链路和修复方案
5.1 少了乘2,单边谱整体低3dB
第一次把PSD曲线和MATLAB的pwelch结果对比时,数值整体差了一半,换成对数坐标就是整整3dB。当时第一反应是归一化系数错了,反复检查公式感觉也没问题。后来翻资料才发现,pwelch默认输出的是单边PSD,正频率部分直接乘了2,而我用的是双边谱公式,压根没乘。
排查思路是这样的:
- 排除采样率错误。如果采样率不对,频谱峰值位置会偏,而这里峰值频率是对的,说明fs和N匹配。
- 排除窗函数归一化。加窗后的归一化因子已经用了Σw²,而且对比矩形窗和汉宁窗的结果,差异是正常的。
- 最后锁定在单边/双边的倍率问题,用单频信号手工推导了一遍期望值,确认需要乘2。
这个坑的迷惑性很强,因为曲线形状、频率位置全对,只是整体高度不对,容易让人怀疑是数据本身的问题。
5.2 用new分配输入输出缓冲区导致崩溃
FFTW的文档明确要求数据缓冲区使用fftw_malloc分配,以保证内存对齐。但很多新手(包括我第一次做集成)会习惯性用new double[N],原因是Qt项目里到处都是new,不觉得有问题。实际使用中,如果FFTW检测到SIMD对齐条件不满足,可能降级到慢速路径,也可能在特定CPU上直接崩溃。
这里有个值得分享的排查过程:程序在Debug下能跑,Release下频繁偶发崩溃,崩溃位置在fftw_execute内部。一开始以为是多线程并发问题,加锁后依然崩溃。后来把内存分配方式全部换成fftw_malloc,问题消失。原因就是编译器优化级别不同时,对未对齐数据的处理路径不一样。
重要提醒:FFTW的plan对象不要在多线程中共享。每个线程最好单独创建自己的plan,或者用锁保护执行过程。
5.3 plan跨线程使用的问题
如果采集线程和UI线程分离(通常都应该分离),FFTW的plan对象不能跨线程直接使用。有两种处理方式:一是在每个线程里各自创建plan;二是用互斥锁保护plan的创建和执行。我选择的是在初始化阶段为采集线程创建独立的plan,之后所有执行都在同一个线程内完成,完全避免锁竞争。
如果必须跨线程共享,需要注意FFTW的plan在内部可能包含依赖于线程上下文的优化数据,除了加锁,最好不要同时执行。
5.4 加窗后有效幅值变小的修正
用汉宁窗后,信号的幅值会被窗函数的形状拉低,如果直接用原始的幅度谱去和时域幅值比对,结果会偏小。对于PSD来说,因为最终除以了窗能量Σw²,这个系统性偏差已经被修正了。但如果你是拿幅度谱(不是PSD)去做定量分析,就需要额外的窗函数补偿系数,典型的做法是除以窗函数的平均值或者峰值。
我的建议是:如果只做PSD分析,统一用Σw²归一化;如果同一套代码还要输出幅度谱,再单独加补偿逻辑,不要把两套归一化混在一起,否则很容易出现“这个功能对了另一个功能又不对”的情况。
6. 用仿真信号验证整条链路
6.1 构造已知信号检验代码
在把代码接入真实传感器之前,强烈建议先用一组频率和幅值已知的仿真信号验证。我常用的是50Hz和120Hz两个正弦叠加的测试信号:
std::vector<double> signal(1024); double fs = 1024.0; for (int i = 0; i < 1024; ++i) { double t = i / fs; signal[i] = 1.0 * sin(2.0 * M_PI * 50.0 * t) + 0.5 * sin(2.0 * M_PI * 120.0 * t); }这里采样率1024Hz,数据长度1024点,频率分辨率正好是1Hz,50和120都是整数倍频,所以用矩形窗(不加窗)时不会出现频谱泄漏,结果可以精确验证。
对于幅值为A的单频正弦,在整周期采样且不加窗的情况下,DFT在对应频点上的幅度约为AN/2,平方后得到A²N²/4,再套用单边PSD公式:
PSD_single = 2 * (A²N²/4) / (fsN) = A²N / (2fs)
把A=1、N=1024、fs=1024代入,得到50Hz处PSD峰值=0.5 V^2/Hz;A=0.5的120Hz分量,PSD峰值=0.125 V^2/Hz。这个值可以直接和computePsd的输出对比,误差应在浮点精度范围内。
6.2 判断PSD正确性的几个关键点
仿真信号验证时,重点检查以下几点:
- 频率坐标是否与设定的信号频率吻合。如果出现频率偏移,优先检查采样率fs和频率轴计算公式df=fs/N是否正确。
- 峰值高度是否与手工推导一致。直接用矩形窗+整周期采样,峰值应精确等于A²N/(2fs)。
- 直流分量处理是否正确。信号如果带直流偏置,PSD在0Hz处会出现一个谱峰,且直流点不能乘2。
- 加窗后谱峰变宽、旁瓣降低,是正常现象;如果加窗后峰值高度和矩形窗几乎一样,说明窗函数没有生效。
这些验证做完之后,再接入真实传感器数据才比较放心。否则现场数据本身千奇百怪,一旦PSD不对,根本分不清是算法问题还是信号问题。
6.3 从周期图法继续往前走
周期图法是PSD估计最基础的版本,它的缺点是单帧估计方差大。如果现场信号平稳性较好,可以升级到Welch方法:把一帧数据分成多段重叠的子段,分别计算周期图再取平均,方差显著下降,代价是频率分辨率降低。FFTW对Welch这种多次小FFT的场景同样很适合,只要复用plan即可。
实时场景下,可以做成滑窗形式,每来一个新采样块就更新一次频谱,配合多线程采集和UI刷新,就能实现“准实时”的频谱监测界面。对于PSD显示,转成dB/Hz之后加上阈值线,超过阈值就报警,这套逻辑在振动监测类项目里几乎是标配,代码层面也并不复杂。