ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MATLAB信号实验入门:从FFT频谱分析到滤波器设计实战

MATLAB信号实验入门:从FFT频谱分析到滤波器设计实战

信号实验这四个字,搁课堂上听着挺唬人,其实说白了就是把一堆看不见摸不着的波形,折腾成你能看懂、能算出来、能改来改去的东西。我当年第一次接触MATLAB干这事儿的时候,也是心里打鼓,总觉得后面得跟着一长串公式推导。真上手发现,只要先会敲代码、会看图,理论那点事儿回头再补,反而顺溜得多。

这篇文章不整虚的,直接从一段能跑的MATLAB代码开始,把信号生成、频谱分析、滤波、加窗这些实验里绕不开的活儿,挨个用实例过一遍。每个环节我都尽量说清楚为什么要这么干、代码每一步在干嘛、跑出来应该长什么样,以及我实际踩过的坑。适合刚接触信号实验、想快速拿MATLAB出结果的同学,也适合做项目做到一半、想回头补补基础操作的朋友。

1. 信号实验这事儿,到底在捣鼓什么

信号实验的核心,说白了就是三件事:造信号、看信号、改信号。造信号就是用代码生成你想要的波形,比如正弦波、方波、带噪声的混合信号;看信号就是把它变到频域里,搞清楚这个信号里到底有哪些频率成分,谁强谁弱;改信号就是按需滤波、提取、增强,把你要的那部分留住,不要的干掉。

用MATLAB做这三件事,最大的优势就俩字:快。它不像C语言那样得先搭环境、处理内存,也不像Python那样有些库还得掂量版本。MATLAB打开就是命令行和编辑器,所有信号处理相关的函数几乎都是内置的,画图也直接出窗口,特别适合实验这种需要“快速验证想法”的场景。

我通常把整套流程固定成下面这个套路,不管是上课作业还是自己练手都适用:

  1. 确定采样率和时长,这是所有后续操作的地基
  2. 生成合成信号(或者载入采集数据)
  3. 先看看时域波形,确认信号长什么样
  4. 做傅里叶变换,看频谱结构
  5. 根据频谱设计滤波器,做滤波处理
  6. 对比处理前后的波形和频谱,验证效果

这套流程看着简单,但每一步都有讲究。比如采样率设置不对,后面频谱全是错的;滤波器的参数没选好,滤完信号也变形了。下面我按这个流程,一步一步带你把代码写出来。

2. 准备工作:别急着敲代码,先搞定采样率和时间轴

很多人一上来就写sin(2*pi*f*t),结果画出来图怪怪的,或者频谱完全对不上,十有八九是采样率没设对,或者时间轴没整明白。

2.1 采样率为什么是最重要的那个数

采样率fs的意思是每秒采多少个点。它直接决定了你“能不能看见”某个频率的信号。这里有个绕不开的规矩叫奈奎斯特采样定理:要完整保留频率为f的信号,采样率必须大于2*f。实际工程里一般取5到10倍,留足余量。

打个比方,你拍高速运动的物体,快门太慢拍出来就是糊的。采样率就是信号世界的快门速度,采得不够快,高频部分就全糊成一团,还会出现“假频率”,也就是所谓的混叠。

我在实验里常见的取值是这样:如果信号最高频率是100Hz,fs至少取1000Hz,这样既能看清波形细节,也不至于数据量大到处理不动。

fs = 1000; % 采样率,单位Hz,代表每秒1000个采样点 T = 1; % 信号时长,单位秒 t = 0:1/fs:T-1/fs; % 时间向量,一共fs*T个点

这段代码里,t是核心。它是一个从0开始、步长为1/fs的向量,长度正好是fs*T。注意我写了T-1/fs而不是T,这是为了避免多出一个点,导致信号首尾不连贯,做FFT的时候出现不必要的泄漏。

2.2 合成一个“看起来像样”的测试信号

做实验最好别一开始就上真实采集数据,因为真实数据噪声大、成分杂,你根本不知道程序算得对不对。自己合成一个信号,每个频率成分都是你亲手放进去的,跑完就能对照结果,程序对不对一目了然。

我常用的测试信号是这么造的:50Hz的正弦波作为主角,加上一个120Hz的小幅度信号作为干扰,再来一点随机噪声模拟真实环境。

f1 = 50; % 信号1频率 f2 = 120; % 信号2频率 x = sin(2*pi*f1*t) + 0.5*sin(2*pi*f2*t) + 0.1*randn(size(t));

这里的randn是高斯白噪声,标准差0.1,幅度不大,但能让波形看起来更真实。合成好之后,强烈建议先画出来看一眼:

figure; plot(t(1:500), x(1:500)); % 只看前0.5秒,太长了挤成一团看不清 xlabel('时间 (s)'); ylabel('幅度'); title('合成信号时域波形'); grid on;

我为什么只画前500个点?因为如果画出整整1秒共1000个点,50Hz的正弦波会有50个周期,密密麻麻的根本看不出波形细节。取前0.5秒,能看到25个周期,形状、幅度、噪声情况一目了然。

提示:画图之后第一件事是检查幅值范围。合成信号里两个正弦波峰值分别是1和0.5,加上噪声后总幅值应该在±1.6左右。如果画出来幅值小一个数量级,或者全是直线,基本就是t的定义出了问题,回去检查时间向量。

3. 上频谱:FFT这一步,是信号实验最好玩的转折点

时域波形看多了,其实看不出太多名堂。真正让信号实验“高大上”起来的,是傅里叶变换——把信号从时间的视角切换到频率的视角。在MATLAB里做这件事只需要一个函数:fft。

3.1 FFT代码模板,直接拿去用

X = fft(x); L = length(x); P2 = abs(X/L); % 归一化幅值 P1 = P2(1:L/2+1); % 取单边谱 P1(2:end-1) = 2*P1(2:end-1); % 单边谱幅值还原 f = fs*(0:(L/2))/L; % 频率轴 figure; plot(f, P1); xlabel('频率 (Hz)'); ylabel('幅度'); title('单边频谱'); grid on;

这段代码每次做实验我基本上都是直接复制改改,有几点必须说清楚:

第一,abs(X/L)里的除以L是必须的。fft算出来的结果数值很大,是原始幅值的L倍,不除以长度,你看到的频谱图幅值会大得离谱。

第二,为什么要取P2(1:L/2+1)然后乘2?因为实数信号的FFT结果是对称的,后半部分跟前半部分镜像重复,画出来浪费,取前半段再翻倍,幅值才对得上原始信号。举个具体例子:如果原始信号是峰值为1的50Hz正弦波,频谱图上50Hz那根线的幅值就应该正好是1,这样处理完才能对得上。

第三,频率轴f的构造别搞错。fs*(0:(L/2))/L得到的是从0到500Hz的501个点,每个点的间隔是fs/L,也就是1Hz。如果你把采样率改成2000Hz,频率间隔就变成2Hz,频率分辨率就变粗了。

3.2 从频谱图上看懂你的信号

这段代码跑完,应该能看到两根明显的谱线:一根在50Hz处,幅度接近1;一根在120Hz处,幅度接近0.5。它们之间和周围有一些低幅度的毛刺,那是噪声的贡献。

这里有个我反复跟人强调的点:频谱图上的谱线不是一根“细线”,它有一定宽度,底部会展开。这是因为我们只截取了有限长度的信号,本质上是给无限长的正弦波加了一个矩形窗。这个矩形窗的傅里叶变换是sinc函数,所以谱线根部会拖出旁瓣。如果信号长度刚好是周期的整数倍,旁瓣的影响会小一些;如果不是整数倍,谱线就会明显变胖,这在实验报告里叫“频谱泄漏”。

我当时第一次看到50Hz那根线底部变宽,一度以为是自己代码写错了,查了半天才发现这就是泄漏的正常现象,后面第三节会专门讲怎么用窗函数缓解。

3.3 FFT点数选择:补零到底有没有用

MATLAB的fft函数可以传第二个参数,比如fft(x, 4096),意思是做4096点的FFT。有的同学以为点数越多,频率分辨率越高,这是个常见的误解。

补零FFT只是在原来频谱的相邻点之间进行插值,让曲线更平滑,看起来更“细腻”,但并没有真正增加信息量。真正决定频率分辨率的是你采集信号的时长:时长越长,分辨率越高。分辨率公式是fs/L,如果你采样了1秒的数据,分辨率就是1Hz,补再多的零也是1Hz,只是画出来的图更好看而已。

什么时候补零有用?当你想用findpeaks这类函数去搜索峰值,而插值后的曲线能让峰值定位更准的时候,可以适当补零。但如果只是为了显示好看,意义不大,还白白增加计算量。

实操心得:我自己的习惯是,FFT点数直接取信号长度,不做补零。先看原始频谱,如果谱线形状不够清晰,再考虑补零到下一个2的幂次。多数情况下,原始长度已经够用。

4. 滤波实战:把不想要的频率成分干掉

频谱看完,接下来就是信号实验的重头戏——滤波。继续用上面的例子:50Hz是你要的信号,120Hz是干扰,噪声也在频谱上到处都是。现在要做的是把50Hz附近的成分留下,其他的尽量压掉。

4.1 滤波器类型怎么选:FIR还是IIR

MATLAB里设计滤波器,最常用的有两条路线:fir1设计FIR滤波器,butter设计IIR滤波器。

FIR滤波器的好处是线性相位,信号经过它波形不会“走样”,但代价是需要较高的阶数才能达到同样的陡峭程度。IIR滤波器效率高,同样的性能指标用的阶数低得多,但存在相位非线性,信号会有一定程度的畸变。

实验里如果只是考前看效果,我建议 FIR 起步,因为线性相位意味着你可以清楚地判断滤波到底有没有把波形搞坏。阶数高一点没关系,现在的电脑算力完全扛得住。如果是要做实时处理或者对资源敏感,再考虑IIR。

我用fir1做个带通滤波器,通带设在50Hz附近:

f_lo = 45; % 通带低端 f_hi = 55; % 通带高端 order = 100; % 滤波器阶数 b = fir1(order, [f_lo f_hi]/(fs/2), 'bandpass'); y = filter(b, 1, x);

这里有个关键参数容易踩坑:[f_lo f_hi]/(fs/2)是归一化截止频率,范围是0到1,对应0Hz到500Hz。你要滤波的实际频率必须先除以fs/2转成归一化值,直接拿50这种原值传给fir1是错的。

4.2 阶数怎么定,这个值得说透

order = 100是我拍脑袋定的吗?不是。FIR滤波器的阶数决定了过渡带的宽度,也就是从通带到阻带那一段下降有多快。过渡带宽度近似等于4/order(归一化频率)。我这里的过渡带大概是4/100=0.04,对应实际频率40Hz,也就是说从45Hz的边界往两边各降40Hz,信号基本压平。

如果你想要更陡峭的过渡带,比如20Hz以内从通带降到阻带,阶数就得提到200以上。阶数越高,计算量越大,相位延迟也越大。我通常的做法是先用100阶跑一次,看频谱效果,不够陡就加,过头了再减。阶数这个东西,实测几步比理论算半天快得多。

不过用filter函数有个特点:信号起始的几个采样点,滤波器还没“反应过来”,会有一段瞬态过程,表现为输出信号开头有明显波动。我一般把前100个点丢掉不看,或者用filtfilt来消除这个问题。

4.3 零相位滤波:用filtfilt代替filter

filtfilt的原理是正向滤波一遍,再把信号倒过来滤波一遍,两次滤波的相位失真互相抵消,实现零相位偏移。代价是计算量翻倍,但它对波形形状的保护效果是真的值。

y = filtfilt(b, 1, x);

跑完这句,再把滤波前后两个波形叠在一起画:

figure; plot(t(1:500), x(1:500), 'b', t(1:500), y(1:500), 'r'); xlabel('时间 (s)'); ylabel('幅度'); legend('原始信号', '滤波后信号'); grid on;

你会在画布上看到:蓝线是有毛刺的粗糙波形,红线是光滑的50Hz正弦波。120Hz的成分和噪声基本被压没了。红线相比原始信号的50Hz成分,幅值保持一致,相位也没有明显偏移。

4.4 滤波效果怎么看:别只看图,要量化

光靠眼睛看图容易自欺欺人,做实验就得拿数据说话。我一般用两个指标:

第一个是滤波前后的功率对比。对50Hz附近的频谱幅值做积分,看差了多少。第二是信噪比估算。先算噪声在滤波前后的均方根值,滤波前的噪声方差减去滤波后的方差,再除以滤波前的方差,就是噪声抑制比例。

noise_before = std(x - sin(2*pi*f1*t) - 0.5*sin(2*pi*f2*t)); noise_after = std(y - sin(2*pi*f1*t)); fprintf('噪声均方根 滤波前: %.4f, 滤波后: %.4f\n', noise_before, noise_after);

我实测下来,100阶FIR带通滤波后,噪声均方根能从0.1降到0.01以下,也就是降了20dB左右。如果发现噪声抑制比例不够,或者50Hz幅值也被削弱了,优先检查滤波器的通带边界是不是设得太窄,或者阶数偏低导致过渡带太宽。

5. 窗函数:让频谱别再“漏”了

第一节提到过频谱泄漏,这里正式处理一下。窗口函数是信号处理里一个特别容易忽略、但实际效果很明显的工具。

5.1 泄漏是怎么来的,为什么读到的幅值会偏小

泄漏的本质是:FFT默认把你要分析的那段信号当成一个周期信号来处理。如果你的信号截断长度不是信号周期的整数倍,FFT就会把一个“不完整”的周期当成完整周期来拼,在拼接处产生突变,频谱上就表现为能量从主瓣漏到旁瓣去。

结果是:本来应该是一根尖尖的谱线,变矮变宽了,峰值幅度也比真实值小。最典型的例子:50Hz信号,采样1秒,理想情况下50Hz处应正好是1;但如果信号实际是50.5Hz,采样1秒,FFT点数对应的频率网格是0,1,2,...Hz,50.5Hz的能量就会摊到50Hz和51Hz两个格子之间,两个峰值都不到1。

5.2 加窗的代码和参数选择

加窗就是在做FFT之前,把信号逐点乘以一个窗函数,让信号两端平滑地衰减到0,这样截断处的突变就没了,泄漏就小了。代价是主瓣会变宽,频率分辨率下降,两个靠得很近的频率成分更难分清。

MATLAB里最常用的是汉宁窗(Hann):

w = hann(L, 'periodic'); xw = x .* w'; Xw = fft(xw);

这里注意hann的'periodic'参数。对于频谱分析,'periodic'是更正确的选择,因为周期窗能保证窗函数首尾相接处连续,数学上更干净。如果不加这个参数,用的是对称窗,它更适合滤波器设计,不太适合FFT分析。

加窗前后对比一下频谱,你会看到:50Hz谱线稍微变胖了一点,但底部的旁瓣毛刺大幅减少,峰值幅度更接近真实值。代价是如果两个频率靠得很近,比如48Hz和52Hz,加窗后它们的谱线可能糊到一块,分辨不开。这种时候就需要增加信号时长,而不是靠提高FFT点数就能解决的。

注意:加窗之后,信号总能量变了,如果要做幅值对比,需要除以窗函数的相干增益。简单做法是用rms(w)对幅值做归一化修正。不加这个修正,你会发现加窗后频谱峰值变小了,这不是滤波器的锅,是窗函数的能量损失。

5.3 选窗公式表,直接对着抄

不同窗函数的特性差别不小,我整理了一个简单的对照表,实验时直接对着选:

窗函数主瓣宽度旁瓣衰减频率分辨率最适合的场景
矩形窗(无窗)最窄差(-13dB)最高频率成分已知且周期对齐
汉宁窗较宽较好(-31dB)中通用信号分析,最推荐起步
海明窗中等中等(-43dB)中需控制旁瓣却不希望主瓣太宽
布莱克曼窗最宽很好(-58dB)最低强干扰下识别弱信号

如果是实验课或者项目初期,我直接就上汉宁窗,基本不会出问题。要是频谱里有个大信号在旁边,要挖一个小信号,那就换布莱克曼,旁瓣压得更狠,小信号更容易露出来。

6. 完整实验流程串一遍:从零到出结果

前面把每个零件都讲完了,这里把它们拼成一台完整的机器。我贴一份完整的脚本,你可以直接复制到MATLAB里跑,步骤都写了注释,跑完对照着看每一段输出。

%% 参数设置 fs = 1000; T = 1; t = 0:1/fs:T-1/fs; %% 合成信号 f1 = 50; f2 = 120; x = sin(2*pi*f1*t) + 0.5*sin(2*pi*f2*t) + 0.1*randn(size(t)); %% 时域波形 figure('Name', '时域波形'); plot(t(1:500), x(1:500)); xlabel('时间 (s)'); ylabel('幅度'); title('合成信号时域波形'); grid on; %% 频谱分析 L = length(x); X = fft(x); P2 = abs(X/L); P1 = P2(1:L/2+1); P1(2:end-1) = 2*P1(2:end-1); f = fs*(0:(L/2))/L; figure('Name', '原始频谱'); plot(f, P1); xlabel('频率 (Hz)'); ylabel('幅度'); title('原始信号频谱'); grid on; %% 带通滤波 f_lo = 45; f_hi = 55; order = 100; b = fir1(order, [f_lo f_hi]/(fs/2), 'bandpass'); y = filtfilt(b, 1, x); %% 滤波后时域波形 figure('Name', '滤波后波形'); plot(t(1:500), x(1:500), 'b', t(1:500), y(1:500), 'r'); xlabel('时间 (s)'); ylabel('幅度'); legend('原始信号', '滤波后信号'); title('带通滤波效果'); grid on; %% 滤波后频谱 Y = fft(y); P2y = abs(Y/L); P1y = P2y(1:L/2+1); P1y(2:end-1) = 2*P1y(2:end-1); figure('Name', '滤波后频谱'); plot(f, P1y); xlabel('频率 (Hz)'); ylabel('幅度'); title('滤波后信号频谱'); grid on; %% 加窗对比 w = hann(L, 'periodic'); xw = x .* w'; Xw = fft(xw); P2w = abs(Xw/L) / rms(w); P1w = P2w(1:L/2+1); P1w(2:end-1) = 2*P1w(2:end-1); figure('Name', '加窗频谱对比'); plot(f, P1, 'b', f, P1w, 'r'); xlabel('频率 (Hz)'); ylabel('幅度'); legend('未加窗', '汉宁窗'); title('加窗前后频谱对比'); grid on;

运行完这段,你的屏幕上会出现五个窗口,对应的就是分析的全链路。我在实验课上带学生跑这段脚本,最快的学生10分钟就能跑完看清楚所有结果。

有几个输出值得重点确认:第一是原始频谱里50Hz和120Hz两根谱线的幅度,应该分别在1和0.5左右;第二是滤波后时域图里,红色信号是否光滑且保持了50Hz的周期形状;第三是加窗对比图里,红色线条的旁瓣应该明显比蓝色更收敛。

7. 实验报告里常踩的坑,我都替你踩过

最后整理几个我反复在实操中踩过的坑,每一项都对应着一次实实在在的加班排查经历。

7.1 采样率设太低导致的混叠

我最初做实验为了“省事”,把采样率设成200Hz去分析一个120Hz的信号。结果FFT跑出来,120Hz的成分赫然出现在80Hz的位置。当时完全懵了,查了半小时才意识到这是混叠:200Hz采样率低于240Hz,120Hz的信号折返回了80Hz。

血泪教训就是:采样率必须先大于最高频率的两倍,这没有商量余地。做实验前,先明确你关心的最高频率是多少,再反推采样率该设多少。

7.2 FFT幅值没归一化,峰高全对不上

有个阶段我画的频谱图,峰值动不动就三四十,我还在那研究是不是信号本身强度就有这么大。后来才发现是没除以信号长度L。FFT输出的原始值是幅度乘以点数的累积效应,不归一化,幅值大小没有任何物理意义。

现在我的代码里abs(X/L)是固定写法,加窗情况就额外除以窗的相干增益。看到幅值对不上的问题,第一反应就是查归一化。

7.3 滤波器阶数太高,波形相位全乱了

有段时间为了追求带外衰减的极致,我把FIR滤波器阶数干到了1000,结果滤波后的信号在起始阶段出现了剧烈的振荡,波形看着都像被揉皱了。这是因为阶数过高导致延迟太大,瞬态过程特别长。改用filtfilt能缓解相位问题,但瞬态效应依然存在。

现在我的做法是:阶数从100开始,50步一档往上加,每加一档就看一次频谱和波形,直到带外衰减满意就停。宁可过渡带宽一点,也不能让波形烂掉。

7.4 频率轴坐标算错

fs*(0:(L/2))/L这个公式我手算错过一次,把频率轴画到了5000Hz。信号明明在50Hz,图上显示在500Hz。这个问题不仔细看,只会觉得“我这个信号怎么频率这么高”,还容易让人误判信号类型。

最简单的验证方式:造一个单一频率的正弦波,比如sin(2*pi*50*t),做FFT后找峰值坐标,看峰值对应的频率轴刻度是不是50Hz。如果是,频率轴就对了;不是,先别管别的,把频率轴修好再说。

8. 留给你们继续折腾的方向

这套流程跑通之后,后面可以玩的花样就多了。我自己后续扩展过几个方向,都有现成路径:

一是多信号分离。把刚才的例子扩展成三个频率成分,设计多组带通滤波器,把三个信号分别提取出来,再做相关性分析。这对理解滤波器组的概念很有帮助。

二是变采样率实验。把合成信号降采样到刚好是奈奎斯特边界附近,观察混叠现象的实际样子,再把信号升采样回去,对比恢复质量。

三是真实数据接入。用手机录音一段语音,导入MATLAB做频谱分析,设计一个滤波器把某个频段的噪声压掉,再做播放对比。这段会让你从“做题”切换到“做东西”,成就感完全不一样。

信号实验这件事,说到底就是找到一套你能快速验证想法的工具链,MATLAB无疑是最顺手的那个。我自己从最开始照抄代码,到后面能自己组合函数解决问题,中间没少出bug,但每次跑出正确结果的那一刻,都挺值的。这篇文章里所有代码都是可以改着玩的,采样率换个值、频率换个数、滤波器阶数调一调,跑一跑,看到波形和频谱跟着变,比背十遍理论都管用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进