相干采样(Coherent Condition)------让 FFT 频谱变干净的关键
面向刚接触混合信号 / DSP 测试的工程师。读完你应该能:理解 FFT 为什么会"算错",掌握相干采样的两个条件,会用一段几十行的 Python 复现并诊断各种"奇怪的频谱"。文中所有图都是用 Python 现场跑出来的,代码你都可以直接复制运行。
0. 先建立背景:什么是 DSP-based testing
模拟世界里最典型的两类混合信号器件是 ADC(模数转换器) 和 DAC(数模转换器)。
在测试这类器件时:
- 激励信号通常由一台**任意波形发生器(AWG)**产生,它内部其实就是一个 DAC,把我们用数学算出来的一串数字变成模拟波形送给被测件(DUT)。
- 被测信号则由一台**数字化仪 / 采样器(digitizer / sampler)**采集,它内部是一个 ADC,把模拟波形重新变回一串数字。
也就是说,激励是"用数学造出来的",测量结果也是"用数学处理出来的"。整套方法建立在**数字信号处理(DSP)**之上,所以业内常把它叫做 DSP-based testing。
在这套方法里,最常用、最强大的工具就是 FFT(快速傅里叶变换)------它把一段时域波形变成频谱,让我们一眼看出信号里有哪些频率成分、噪声有多大、有没有谐波失真。
而要让 FFT 给出可信、干净 的频谱,有一个绕不开的前提条件,叫做相干采样(Coherent Condition)。这篇文章就专门讲它。
1. 为什么 FFT 需要"相干"?
1.1 FFT 的隐含假设:信号是"无限周期延拓"的
傅里叶变换的数学基础,是假设信号无限连续、无限重复 。但实际采集时,我们只能截取有限长的一段,这一段叫做单位测试周期(Unit Test Period, UTP)------也就是 FFT 实际"看到"的那 N 个采样点。
FFT 在内部其实是这么"脑补"的:把你给它的这一段 UTP,首尾相接、无限循环地拼下去,当成一个无限长的周期信号来分析。
这里就藏着一个陷阱:如果 UTP 里装的不是整数个信号周期,那么把它首尾相接时,结尾和开头对不上,会出现一个"接缝"(不连续点)。 FFT 看到这个突变,就会认为信号里含有大量额外的频率成分------于是频谱被"抹脏"了。这个现象叫频谱泄漏(spectral leakage)。
1.2 用拼接实验直观感受
我们令 M = UTP 里包含的信号周期数。下面这段代码把一段 UTP 重复拼接 3 次,对比 M=4(整数)和 M=4.3(含分数周期):
python
import numpy as np
import matplotlib.pyplot as plt
N = 64
t = np.arange(N)
fig, ax = plt.subplots(1, 2, figsize=(10, 3))
for a, M, title in [(ax[0], 4, '(a) M=4 整数周期'),
(ax[1], 4.3, '(b) M=4.3 含分数周期')]:
one = np.sin(2*np.pi*M*t/N) # 一个 UTP
three = np.concatenate([one, one, one]) # 首尾相接 3 次
a.plot(three)
for b in (N, 2*N):
a.axvline(b, color='r', ls='--', lw=.8) # 标出"接缝"
a.set_title(title)
plt.tight_layout(); plt.show()

左图(M=4)三段拼起来平滑连续 ,看不出接缝;右图(M=4.3)每个红色虚线处都有一个明显的台阶式跳变。FFT 正是被这种跳变误导,才产生泄漏。
结论:要让频谱干净,UTP 必须恰好装下整数个信号周期。
2. 相干条件的数学表达
把"UTP 恰好装下整数个周期"写成公式。设:
Fin= 信号频率Fs= 采样率N= 采样点数(一个 UTP 的长度)M= UTP 内的信号周期数
一个 UTP 的时长是 N / Fs,这段时间里信号走过 Fin × N / Fs 个周期。要它正好等于整数 M,就得到相干条件:
FinFs=MN\frac{F_{in}}{F_s} = \frac{M}{N}FsFin=NM
等价地:Fin = M × (Fs / N)。这里的 Fs / N 是 FFT 的频率分辨率 (每个频率 bin 代表的频率宽度)。换句话说:信号频率必须正好落在某个 FFT bin 的中心上。
相干条件要满足两条:
- M 和 N 都必须是整数;
- M 和 N 必须互质(最大公约数为 1,没有公因子)。
第 2 条为什么重要,第 3 节专门讲。
工程实践小贴士:因为 FFT 通常要求
N = 2ⁿ(如 512、1024、2048),而 2 的幂只有 2 这个素因子,所以只要把 M 取成奇数,M 和 N 就自动互质了。这就是为什么在 DSP 测试里你会反复看到"周期数取奇数"的约定。
2.1 一个真实的数值例子
设采样率 Fs = 110 MHz,采样点数 N = 512。频率分辨率:
python
Fs, N = 110e6, 512
print("bin 分辨率 Fs/N =", Fs/N, "Hz") # 214843.75 Hz
- 想测一个约 5 MHz 的信号。直接用 5.000 MHz :对应
M = Fin×N/Fs = 5e6×512/110e6 = 23.27,不是整数,不相干。 - 换成离它最近的相干频率 :取
M = 23,则Fin = 23 × 214843.75 = 4.94140625 MHz,正好 23 个整周期落进 UTP。
两者频谱对比:
python
def quantize(x, bits=8): # 模拟 8-bit ADC 量化
return np.clip(np.round((x*0.5+0.5)*(2**bits-1)), 0, 2**bits-1)
def spectrum_db(code, bits=8):
x = code/(2**bits-1)*2 - 1
X = np.fft.rfft(x)/len(x)*2
mag = np.abs(X); mag[0] /= 2
return 20*np.log10(np.maximum(mag, 1e-12))
n = np.arange(N)
green = quantize(np.sin(2*np.pi*23*n/N)) # M=23,相干
yellow = quantize(np.sin(2*np.pi*(5e6*N/Fs)*n/N)) # 5MHz,不相干(M=23.27)

左边(相干):一根又细又高的基波谱线,底下铺着均匀的量化噪声本底------这正是我们想要的"干净频谱"。
右边(不相干):基波"散开"成一座宽宽的山包,能量从主谱线泄漏到了周围所有 bin 里,噪声本底被整体抬高。如果你拿这个频谱去算信噪比或谐波,结果完全不可信。
3. 为什么 M 和 N 还必须"互质"?
满足"M 是整数"只是第一关。即使 M 是整数,如果它和 N 有公因子,量化噪声的分布也会出问题。要看清这一点,需要一个很巧妙的工具:相位重排。
3.1 相位重排(phase reshuffle)------把整段波形折叠成一个周期
思路很简单。相干采样时,第 n 个采样点对应的相位是 2π·M·n/N。如果我们按相位大小给所有采样点重新排序 ,就能把分散在 N 个点里、横跨 M 个周期的数据,全部折叠回单独一个周期上。这相当于把所有采样点叠在一张"单周期正弦模板"上,于是 ADC 在整条正弦曲线上的表现一览无余。
排序的关键量是 (M·n) mod N------它代表每个点在"一个周期"里的相对位置:
python
def reshuffle(code, M):
N = len(code)
n = np.arange(N)
order = np.argsort((M*n) % N, kind='stable') # 按相位位置排序
return code[order]
这里有个关键数学事实 :当 M 与 N 互质时,(M·n) mod N 在 n = 0,1,...,N-1 上取遍 0...N-1 的每一个值(是一个排列),所以 N 个采样点会均匀铺满 整条正弦曲线;而当 M 与 N 的最大公约数是 g 时,(M·n) mod N 只能取到 N/g 个不同的值,每个值被重复 g 次------也就是说,无论你采多少点,实际只落在 N/g 个相位位置上。
3.2 互质(65/512)vs 不互质(64/512)
python
from math import gcd
N = 512; n = np.arange(N)
for M in (65, 64):
print(f"M={M}: gcd(M,N)={gcd(M,N)}, 不同相位点数={N//gcd(M,N)}")
# M=65: gcd=1, 不同相位点数=512 ← 互质
# M=64: gcd=64, 不同相位点数=8 ← 不互质 (512/64=8)
好的情况:M=65(65 和 512 没有公因子)

上图是采集到的波形,下图是相位重排后的结果:512 个点均匀散布在 0~255 整条正弦轨迹上。对应的频谱也很干净:

坏的情况:M=64(64 和 512 的公约数是 64,不互质)

重排后所有点只落在 8 个离散的台阶上(512 / 64 = 8),其余码值根本没被采到。这意味着量化噪声没有被均匀"搅匀",而是高度规律化。结果就是频谱出问题:

量化噪声不再均匀铺在本底上,而是全部堆到了少数几根谱线上,频谱严重畸变。这正是违反"M、N 互质"的后果。
一句话记住:M 是整数保证没有泄漏;M 与 N 互质保证量化噪声被均匀打散。两条都满足,频谱才真正干净。
4. 量化噪声本底有多低?------理论公式
频谱里那条平平的"地板"叫噪声本底(noise floor),对理想 ADC 而言它来自量化噪声。它的理论值可以算出来:
Noise Floor dB=(6.02n+1.76)+10log10 (N2)\text{Noise Floor dB} = (6.02n + 1.76) + 10\log_{10}\!\left(\frac{N}{2}\right)Noise Floor dB=(6.02n+1.76)+10log10(2N)
n= ADC 位数,N= 采样点数。- 第一项
6.02n + 1.76是大名鼎鼎的量化信噪比(SNR):每多 1 位,动态范围约多 6 dB。 - 第二项
10·log₁₀(N/2)叫噪声改善因子(NIF, Noise Improving Factor) ,体现过采样的好处:采的点越多,噪声本底被摊得越低(因为同样的总噪声功率被分摊到更多 FFT bin 里)。
python
n, N = 8, 2048
snr = 6.02*n + 1.76
nif = 10*np.log10(N/2)
print(f"SNR项={snr:.2f} dB, NIF项={nif:.2f} dB, 噪声本底={snr+nif:.2f} dB")
# SNR项=49.92 dB, NIF项=30.10 dB, 噪声本底=80.02 dB
怎么用它 :在离散仿真里这是能达到的理论极限;真实测量受各种噪声和杂散影响,通常达不到 这个水平。反过来,如果你的实测动态范围比理论值还好,那不是运气好------多半是测试方法里有问题或有"取巧",需要警惕。
5. 实战:五种"奇怪频谱"的诊断
下面把方法用起来。被测件是一个理想 8-bit ADC,采集 2048 点 。我们先有一张正常的参考频谱(一根干净基波 + 平坦本底):

诊断的核心套路只有两步:
- 看频谱形态------不同故障有不同的"长相";
- 回到原始波形,并做相位重排------很多时间域里看不出的缺陷,重排后一眼就现形。
所有问题波形都按 M=81 个周期构造,所以重排时统一用 reshuffle(code, 81)。
问题 1:基波"裙边"散开

现象 :基波主谱线根部散开成一圈"裙边"。
原因 :这是周期数 M 不是整数 的典型表现。这条波形其实是用 M = 81.01 构造的------差那么一点点没对齐 bin,就泄漏了。
复现:
python
N = 2048; n = np.arange(N)
p1 = quantize(0.9*np.sin(2*np.pi*81.01*n/N)) # M 非整数
排查方向 :检查 Fin、Fs、N 的设置,确认 Fin/Fs = M/N 严格成立、M 取了整数。
问题 2:出现大量谐波

现象 :频谱里冒出一排等间距的谐波 成分。
直觉 :谐波一般意味着波形被非线性失真了。但直接看原始波形不一定看得出来。这时相位重排最有用:

重排成单周期后,正弦的顶部和底部被削平了 ------这是典型的饱和 / 过载(clipping) 。信号幅度太大,超出了 ADC 满量程。
复现:
python
p2 = quantize(np.clip(1.4*np.sin(2*np.pi*81*n/N), -1, 1)) # 幅度过大被削顶
排查方向:降低输入幅度或调整量程,让信号落在 ADC 满量程之内。
问题 3:噪声本底整体偏高

现象 :基波正常,但噪声本底比参考高了一截 。原始波形乍看完全正常。
诊断 :做相位重排------本该严丝合缝贴在正弦模板上的点,有几个明显跳到了曲线之外 。说明波形里混进了个别坏点 (偶发的错误采样值)。
复现:
python
p3 = quantize(0.9*np.sin(2*np.pi*81*n/N)).copy()
idx = np.random.choice(N, 2, replace=False)
p3[idx] = np.random.randint(0, 256, 2) # 注入 2 个随机坏点
排查方向:几个随机坏点就能把本底抬起来,需要进一步定位是采样、传输还是供电环节出了问题。
问题 4:本底形状很怪 + 末尾掉点

现象 :噪声本底呈现奇怪的形状。
诊断 :相位重排后能数出有 5 个点 孤零零地偏离正弦轨迹,规律地排在一起。注意:重排不保留时间信息 ,它只告诉你"有 5 个坏点",不告诉你它们在波形的哪个位置。于是回头紧盯原始波形,会发现最后 5 个采样点没采对,停在了零值附近 ------很可能是 ADC 没收到足够的采样时钟,没能采满需要的点数。
复现:
python
p4 = quantize(0.9*np.sin(2*np.pi*81*n/N)).copy()
p4[-5:] = 128 # 最后 5 点丢失,停在中点(对应信号≈0)
排查方向 :波形末尾掉点 常与时钟数量不足有关;开头 的异常则往往和等待时间不够 或触发问题有关。两端都值得重点检查。
问题 5:噪声向 DC 方向翘起

现象 :基波本身很完美,但噪声本底在靠近 DC(低频)一侧逐渐抬高 。
诊断 :原始波形乍看正常,但仔细看会发现整体随时间缓缓向上倾斜 ------信号里叠加了一个直流漂移(DC drift / 趋势项) 。低频的缓慢漂移在频谱上正好表现为 DC 附近能量升高。
复现:
python
drift = np.linspace(0, 0.25, N) # 缓慢上升的趋势
p5 = quantize(0.9*np.sin(2*np.pi*81*n/N) + drift)
可能原因 :输入通路是交流耦合 或串了隔直电容 、电路尚未稳定、需要更长建立时间;也可能是器件 / 外围温度未稳,或信号源本身带有 1/f 噪声。找到并消除漂移源,频谱才会恢复干净。
6. 小结
| 关键点 | 内容 |
|---|---|
| FFT 的隐含假设 | UTP 被首尾相接、无限周期延拓 |
| 相干条件 | Fin/Fs = M/N |
| 条件一 | M、N 都是整数 → 没有频谱泄漏 |
| 条件二 | M、N 互质 → 量化噪声被均匀打散(不互质只命中 N/gcd(M,N) 个相位) |
| 实用约定 | N 取 2 的幂、M 取奇数,自动互质 |
| 噪声本底 | (6.02n + 1.76) + 10·log₁₀(N/2),第二项体现过采样增益 |
| 诊断套路 | 先看频谱形态,再回到波形 + 相位重排 |
诊断速查:
| 频谱长相 | 最可能的原因 |
|---|---|
| 基波裙边散开 | M 不是整数(不相干) |
| 整齐的谐波 | 饱和 / 过载等非线性失真 |
| 本底整体偏高 | 个别随机坏点 |
| 本底形状怪异 | 成串坏点(如末尾掉点、时钟不足) |
| 本底向 DC 翘起 | 直流漂移 / 交流耦合 / 1/f 噪声 |
最重要的一条经验 :一旦看到奇怪的频谱,别急着怀疑算法,先回头仔细看原始波形。把几种典型"长相"记在脑子里,配上相位重排这把小工具,绝大多数现场问题都能很快定位------这也正是在线调试的乐趣所在。
附录:完整可运行代码
python
import numpy as np
import matplotlib.pyplot as plt
from math import gcd
# ---------- 基础工具 ----------
def quantize(x, bits=8):
"""把 [-1,1] 的模拟信号量化成 0..2^bits-1 的 ADC 码"""
return np.clip(np.round((x*0.5+0.5)*(2**bits-1)), 0, 2**bits-1)
def spectrum_db(code, bits=8):
"""对 ADC 码做 FFT,返回 dB 幅度谱"""
x = code/(2**bits-1)*2 - 1
X = np.fft.rfft(x)/len(x)*2
mag = np.abs(X); mag[0] /= 2
return 20*np.log10(np.maximum(mag, 1e-12))
def reshuffle(code, M):
"""相位重排:按 (M*n) mod N 排序,把整段波形折叠成单个周期"""
N = len(code); n = np.arange(N)
return code[np.argsort((M*n) % N, kind='stable')]
# ---------- 相干条件检查 ----------
def is_coherent(M, N):
return float(M).is_integer() and gcd(int(M), N) == 1
N = 2048; n = np.arange(N); M = 81
print("相干?", is_coherent(M, N)) # True
print("不同相位点数 =", N // gcd(M, N)) # 2048
# ---------- 噪声本底理论值 ----------
def noise_floor(bits, N):
return (6.02*bits + 1.76) + 10*np.log10(N/2)
print("理论噪声本底 =", round(noise_floor(8, 2048), 2), "dB") # 80.02 dB
# ---------- 一个干净的相干频谱 ----------
code = quantize(0.9*np.sin(2*np.pi*M*n/N))
plt.plot(spectrum_db(code)); plt.xlabel("FFT bin"); plt.ylabel("dB")
plt.title("Coherent spectrum (M=81, N=2048)"); plt.show()