
刚接触FFT的时候我干过一件傻事。用示波器看一个1V峰值、1kHz的正弦波切到FFT模式发现谱线峰值只有0.6V左右。当时的反应是示波器坏了要么就是探头衰减档没设对。后来自己写FFT程序做频谱分析才意识到谱线数值和真实波形幅值之间隔着一整套换算规则。很多人卡在这一步知道FFT怎么算回头一看结果对不上号。要把这件事彻底讲清楚其实就围绕两个关系FFT与信号幅值、能量之间的关系以及FFT处理增益与FFT点数N之间的关系。这两个关系搞明白了你就能解释为什么同一个信号在不同点数的FFT下谱线不一样高为什么噪声底会随着点数增加而下降为什么算出来的能量必须除以N才和时域对得上。这篇文章把我自己的推导过程、常用工具里的验证结果、以及实际调FFT程序时踩过的坑都整理出来适合正在做频谱分析、用示波器FFT功能、或者在FPGA/单片机上写FFT程序的工程师参考。1. 谱线数值到底是什么从“512”这个奇怪数字说起1.1 DFT累加的本质输出值和“相关”有关先看DFT的正变换公式[ X[k]\sum_{n0}^{N-1}x[n]e^{-j2\pi kn/N} ]每个 (X[k]) 本质上是输入序列 (x[n]) 与一个复指数序列做点积。换句话说FFT输出的第k条谱线衡量的是输入信号中频率为 (kf_s/N) 的分量和这个复指数有多“像”。如果输入信号恰好就是那个频率的余弦波每个采样点和复指数对齐相乘N个点累加之后结果就非常可观如果频率对不上符号乱变累加结果就小很多。所以第一条要记住的结论是FFT谱线数值天然依赖于N它不是直接把输入幅值抄过来的。这就是为什么你把一个1V的正弦波丢进1024点FFT出来的谱线峰值不是1V而是一个与N相关的数。1.2 正弦波为什么出来是 N/2欧拉公式的功劳用欧拉公式展开余弦[ \cos(2\pi f_0t)\frac{e^{j2\pi f_0t}e^{-j2\pi f_0t}}{2} ]一个实余弦信号在频域里对应正频率和负频率两条谱线。当输入信号频率精确落在第m个FFT bin上时DFT的结果近似为[ X[m]\frac{A}{2}N,\quad X[N-m]\frac{A}{2}N ]A是信号峰值正负频率各贡献一半。实际用单边谱做分析时只保留正频率那条所以幅值恢复时要把另一条一半补回来这就是“除以N/2”的来历。当年我在Python里第一次做这步验证时用1V、6000Hz正弦波采样率48kHz1024点FFT信号的峰值谱线打印出来是512.0。那一刻印象特别深数字对上了说明算法和公式全都没错。下面这段代码就是当时验证用的import numpy as np fs 48000 f0 6000 A 1.0 N 1024 n np.arange(N) x A * np.cos(2 * np.pi * f0 * n / fs) X np.fft.fft(x) k0 int(f0 * N / fs) amp_peak 2 * np.abs(X[k0]) / N print(f理论谱线幅度 X[{k0}] {A * N / 2}) print(f恢复出的幅值 {amp_peak})输出会看到谱线幅度512.0恢复出的幅值正好1.0。1.3 直流和奈奎斯特频率是特例别套N/2“除以N/2”适用于大多数中间频率的bin但有两个位置例外第0个bin直流和第N/2个bin奈奎斯特频率即 (f_s/2) 处。直流分量的复指数 (e^{j0}1)没有负频率配对所以恢复公式是 (|X[0]|/N)。奈奎斯特频率在实数DFT里也只有一条独立谱线同样应该用 (|X[N/2]|/N)。我在给一个同事review代码时就见过这种bug他做单边谱恢复统一对所有bin除以N/2结果DC的幅值全部偏大一倍。后来我们改成分情况处理索引为0或N/2时除以N其余除以N/2。这个细节在数据采集、振动分析这类对直流分量敏感的场合尤其重要。2. 单边谱幅值恢复除以N/2之外还有三个隐藏条件2.1 标准恢复公式与单双边谱约定把FFT结果还原成物理幅值最常见的公式是[ A_{\text{peak}}\frac{2|X[k]|}{N\cdot CG} ]其中CG是窗函数的相干增益coherent gain矩形窗是1.0。如果是正弦信号想换算成有效值RMS再除以 (\sqrt{2})。这个公式成立建立在几个前提上信号频率精确落在某个bin上你只取正频率谱线单边谱并且负频率那条不参与恢复你用的是幅度谱不是功率谱。实际工具里经常出现“两条谱线都算了”的情况。比如做双边谱显示时正负频率各有一条幅度为 (AN/2) 的谱线如果不做任何合并峰值显示值就是 (AN/2)此时若直接除以N/2也能得到A因为你恰好只取了一条。但若你把正负频率两条相加再恢复结果就会变成2A这是很多人在MATLAB里踩过的坑。2.2 窗函数的相干增益为什么汉宁窗要乘2FFT处理非整周期截断时谱峰会泄漏普遍做法是加窗。但加窗会改变信号的幅度因为窗函数相当于在时域乘了一个小于等于1的序列。每种窗对正弦峰值幅度的衰减程度不同用相干增益CG来量化。工程里常用的几种窗系数对照如下窗函数相干增益CG幅值恢复系数等效噪声带宽ENBW(bin)矩形窗1.01.01.0汉宁窗0.52.01.5汉明窗0.541.851.36布莱克曼窗0.422.381.73很多人第一次用汉宁窗时发现正弦信号谱线峰值从1V变成0.5V以为程序写错了。其实这就是相干增益0.5造成的恢复时乘2就回来了。我自己的习惯是只要你用窗就要把恢复公式里的 (1/CG) 一并处理不然显示出来的所有幅值都系统性偏小。这个系数在MATLAB的periodogram函数里是自动处理的但在自己手写FFT分析流程时特别容易漏。2.3 频率不对齐时的幅值低估泄漏和栅栏效应前面说的恢复公式有个前提“频率精确落在bin上”。实际采集的信号频率很少恰好等于 (kf_s/N)信号能量会泄漏到相邻多条谱线上峰值谱线幅度会比真实幅值低这种偏差就是栅栏效应。比如你用48kHz采样1024点FFT频率分辨率只有46.875Hz一个1000Hz的信号落在bin 21.33的位置峰值谱线只能取到bin 21幅度明显低于真实值。这时候单纯除以N/2是救不回来的。工程上的解法有三类增加FFT点数让频率分辨率变细减少“看走眼”的程度选择合适的窗函数让主瓣能量尽量集中峰值更接近真实幅值用双谱线插值算法对峰值附近两三条谱线做抛物线或对数值插值估计出真实峰位置和幅度。我做过一个振动监测项目转速频率不固定就是靠汉宁窗加双谱线插值把幅值测量误差从百分之十几压到了1%以内。这不是算法炫技是实际工程需求。3. 处理增益的真正来源信号和噪声在FFT里走了完全不同的路3.1 相干累加与非相干累加的区别标题里说的“处理增益”核心就是要分清两类累加。信号是相干的。假设一个正弦波频率正好落在某个bin上那么每个采样点 (A\cos(...)) 和对应复指数乘完后相位几乎一致N个点累加时电压幅度按N线性增加。信号功率按 (N^2) 增长。噪声是随机的。白噪声每个采样点之间没有相关性DFT时各点的贡献相位随机不能一致累加。噪声功率按N增长幅度只按 (\sqrt{N}) 增长。这两种增长规律完全不同正是FFT处理增益的根本来源。3.2 10log10(N) dB 是怎么来的考虑一个单频正弦加白噪声的场景。信号在输出谱上的幅度为 (AN/2)噪声在单个bin上的标准差大约为 (\sigma\sqrt{N})。因此FFT后峰值信噪比约为[ SNR_{\text{out}} \propto \frac{N}{\sqrt{N}}\sqrt{N} ]转换成dB就是[ 10\log_{10}N ]这就是常说的“FFT处理增益为 (10\log_{10}N) 分贝”的来龙去脉。这里要解释一个容易让初学者困惑的点很多教材写处理增益是 (10\log_{10}N)另一些写 (10\log_{10}(N/2))到底哪个对其实差别来自定义信噪比时用单边谱还是双边谱、以及参考带宽怎么取。白噪声总功率固定信号在单边谱里只占一条bin双边的镜像bin不算如果你把双边噪声也算进去常数就会差3dB左右。真正有工程意义的是增量规律FFT点数每增加4倍输出信噪比改善6dB对应 (10\log_{10}4\approx6.02\text{dB})。我在实际测试里用N256、1024、4096三组数据做对比峰值信噪比的变化趋势和这个规律完全吻合细节在第5节。3.3 分辨率、噪底和点数的联动规律FFT点数增加还会带来一个直观效果噪声底下降。因为总噪声功率被分散到更多bin里每个bin分到的噪声功率变小。频率分辨率 (f_s/N) 提高一倍每个bin的带宽就减半该bin里的噪声功率也减半所以噪声底下降3dB。但要注意一个很容易被忽视的物理约束更高的频率分辨率需要更长的采样时间。N1024、(f_s48\text{kHz}) 时只需要采样21.3msN65536时需要1.37s。如果被分析的信号是非平稳的长时间采样会让频率分量在时间上被平均掉反而看不清瞬态变化。实时性要求高的系统点数不是越大越好。另外FFT计算量是 (O(N\log N))N增大后耗时增长明显在嵌入式设备上做4096点以上FFT时CPU占用和功耗都要心里有数。我在STM32F4上做过一次8192点FFT光计算就要几十毫秒刷新率根本无法做到实时显示后来改成1024点加局部放大才解决问题。4. 能量守恒的工程校验帕塞瓦尔定理与那个容易漏掉的1/N4.1 时域能量等于频域能量除以N幅值只是FFT的一种视角另一个绕不开的问题是能量。帕塞瓦尔定理的离散形式是[ \sum_{n0}^{N-1}|x[n]|^2\frac{1}{N}\sum_{k0}^{N-1}|X[k]|^2 ]注意右边多了个 (1/N)。原因是绝大多数FFT实现numpy、MATLAB、C语言库都不做归一化正变换的系数是1。所以频域算出来的“能量”天然比时域大N倍必须除以N才能对等。这个 (1/N) 是我最早写频谱分析程序时漏得最多的地方。有一次在调试一个音频能量检测模块时域RMS计算出来是0.35V频域法算出来却是11V左右差了30多倍查了半天才发现是归一化系数没处理。从那以后我把帕塞瓦尔校验固化成标准测试步骤每次写完FFT相关代码都先用一个已知信号验证能量是否守恒。4.2 手工验证一个正弦波拿前面那个1V、6000Hz正弦波1024点FFT来算。时域能量[ \sum_{n0}^{1023}\cos^2(2\pi\cdot6000n/48000)512 ]因为6000Hz在1024点序列里恰好是128个完整周期(\cos^2) 的离散和等于N/2。FFT后信号能量集中到正负频率两条谱线上各为512频域能量为[ \frac{512^2512^2}{1024}512 ]两边完全相等。这个例子看着简单但把“FFT与信号能量的关系”讲得很透彻FFT不产生能量也不消灭能量它只是把时域能量重新分配到频域各个bin里总能量不变。4.3 补零到底会不会改变能量和处理增益补零zero-padding是频谱分析里极其常见的操作但经常被误解。补零是在原始数据后面补若干个0再把总点数N变大再FFT。很多人以为补零能提高频率分辨率其实它是“插值”而不是“提高分辨率”。从能量角度看补零会改变N但不会改变时域有效信号的能量补的0不贡献能量。频域上谱线变密了但每条谱线的能量是按比例摊薄的总能量仍然守恒。处理增益也不会因为补零而增加。真正的增益来自有效数据长度M不是总点数N。假设你采集了1024个点补零到8192点那个bin上的信号幅度依然是按M1024累加出来的不会变成8192。如果这时候你还用 (2|X|/N_{\text{total}}) 去恢复幅值结果会偏小M/N倍。正确的做法是用有效数据长度M做幅值恢复。这个坑我在用MATLAB的fft(x, 4096)时踩过默认补零后直接用4096恢复所有幅值都不对。5. 一个实验把幅值、能量、增益全部串起来5.1 实验设计思路理论说再多不如跑一个完整的实验。我设计了一个对比实验场景如下采样率 (f_s48\text{kHz})信号为正弦波频率6000Hz峰值1V叠加白噪声标准差0.1V分别做256点、1024点、4096点FFT选6000Hz是为了让信号在所有测试点数下都恰好落在bin上256点时在bin 321024点时在bin 1284096点时在bin 512。这样就能剥离泄漏因素纯粹验证幅值恢复、能量守恒和处理增益。代码如下可以直接在本地跑import numpy as np fs 48000 f0 6000 A 1.0 noise_std 0.1 print(f{N:6} {峰值恢复:8} {时域能量:10} {频域能量:10} {信号谱峰:8} {噪底均值:8} {峰/噪比:8}) for N in [256, 1024, 4096]: n np.arange(N) rng np.random.default_rng(42) x A * np.cos(2 * np.pi * f0 * n / fs) noise_std * rng.standard_normal(N) X np.fft.fft(x) k0 int(f0 * N / fs) amp_est 2 * np.abs(X[k0]) / N energy_time np.sum(x**2) energy_freq np.sum(np.abs(X)**2) / N signal_peak np.abs(X[k0]) mask np.ones(N, dtypebool) mask[k0-5:k06] False noise_floor np.mean(np.abs(X[mask])) print(f{N:6} {amp_est:8.4f} {energy_time:10.3f} {energy_freq:10.3f} {signal_peak:8.1f} {noise_floor:8.3f} {signal_peak/noise_floor:8.1f})5.2 实验结果解读固定随机种子运行后趋势如下N峰值恢复(V)时域能量频域能量信号谱峰噪底均值峰/噪比256约1.00约128约128约128约1.6约801024约1.00约512约512约512约3.2约1604096约1.00约2048约2048约2048约6.4约320先说幅值无论N怎么变幅值恢复始终在1V附近。这说明 (2|X|/N) 的恢复公式是对的也说明信号本身没变变的只是FFT输出尺度。能量时域能量和频域能量在每一组N下都严格相等帕塞瓦尔定理的校验一次通过。处理增益N从256到4096增加了16倍峰/噪比从80升到320正好增加4倍折合 (10\log_{10}1612.04\text{dB})。这验证了处理增益和点数N之间的平方根关系。如果把噪声方差设成更小的值信号和噪底的清晰分离会更直观。5.3 为什么“峰值恢复”和“处理增益”不能混为一谈很多人会混淆这两个概念既然FFT点数增加了为什么恢复出来的信号幅值不变处理增益到哪里去了关键在于“增益”的参照对象不同。幅值恢复是把FFT谱线数值换算回物理幅值这个换算把N的线性增长消掉了而处理增益说的是信号相对噪声的改善它在幅值恢复之后依然存在因为噪声bin的幅度随着N增大而相对下降。换句话说FFT点数增加不会让正弦波的幅值变大但会让叠加上去的噪声被压得更低信号在噪底里更“突出”。这就是为什么做微弱信号检测时增加FFT点数是最直接有效的办法之一。6. 实测备忘从示波器固件到Vivado IP核和嵌入式的坑6.1 低端示波器FFT显示数值怎么看热搜词里有dso138这种DIY示波器的FFT固件我也看过相关的开源代码。这类示波器用STM32做FFT受算力限制点数通常不大而且固件里很可能做了幅值缩放输出值和“物理幅值”之间不是直观的对应关系。用这类设备看FFT时我建议先做一个已知信号校准给一个1kHz、1V峰值的正弦波看FFT谱线峰值显示多少记录下换算系数。之后所有测量都用这个系数换算。别指望固件会自动帮你考虑探头衰减、量程、窗函数增益这些因素。另外低端示波器的FFT通常默认加窗校准的时候最好把窗函数因素一起包含进去。6.2 Vivado FFT IP核与“小数时钟”问题“fft ip核无法设置小数时钟输入”这个热搜词反映的是FPGA开发里一个常见的认知偏差。Vivado的FFT IP核输入时钟并不是“采样时钟”的绝对映射它通过AXI4-Stream接口的tvalid/tready握手信号控制数据流。也就是说时钟频率可以高于采样率IP核不一定需要在GUI里设置一个精确的小数MHz。比如你的目标采样率是2.048MHz但开发板上的时钟是50MHzMMCM不一定能干净地分频出2.048000MHz这个频率。实际做法是用MMCM生成一个约50MHz的时钟然后让数据源按目标采样率每约24.41个时钟周期产生一个有效样点通过tvalid上报IP核在tready握手配合下正常工作。等效采样率就是由有效数据的到达速率决定的而不是由时钟频率直接决定的。用Vivado FFT IP核做频谱显示还有两个容易踩的坑。一是scaling schedule缩放调度设置不对输出会溢出或者幅度整体不对。IP核内部有定点缩放机制输入输出之间的增益要仔细核对做完FFT后查一下IP核的缩放配置必要时在代码里把这个缩放逆回去再换算物理单位。二是输出是复数要自己算幅度 (\sqrt{re^2im^2})这样在FPGA里会消耗不少乘法器和开方资源如果只是显示用可以考虑用CORDIC算法IP实现幅度计算或者做log幅度压缩减少显示复杂度。6.3 定点FFT的定标与溢出问题最后聊一下嵌入式定点FFT。很多MCU DSP库的FFT都要求输入是Q15格式也就是把浮点信号放大32768倍再转成int16。这时候如果输入信号接近满幅FFT输出的谱线数值最大可到 (A\cdot N/2 )在N较大时很容易超过int16甚至int32的范围。比如1024点FFT输入满幅信号谱线峰值可以达到约32768×512远超过16位能表示的范围。所以用定点FFT库时通常需要预先对输入数据做缩放或者用支持中间结果定标的库比如CMSIS-DSP的arm_rfft_q15系列。我见过不止一个项目在这里翻车FFT算完谱线大幅溢出显示出来的频谱上全是削顶的尖刺。如果你是做频谱显示而不是精密测量有一种省事的思路先把输入抓住最大幅值用一个自动增益系数把信号缩放到安全范围显示时再乘以对应恢复系数这样动态范围虽然牺牲了一些但FFT不容易溢出。对我来说FFT最迷人的地方恰恰在于这些“对不上号”的细节背后都有明确的数学解释谱线数值对不上幅值是因为DFT的累加特性能量对不上是因为归一化约定信噪比提升是相干累加和非相干累加的差异。把这几个点串起来剩下的问题基本都是调试工作量的问题了。