咱们今天不整那些虚的,直接拿MATLAB把2FSK调制解调从头到尾捋一遍。标题里写了重点对比包络检波和相干解调,那这篇就把两套解调方案都做出来,从原理到代码再到误码率曲线,一步都不落下。不管你是通信课设要用,还是面试前临时抱佛脚,或者单纯想搞懂这两个解调方式到底差在哪,这篇文章都能让你照着重现一遍。先交代一下代码环境:MATLAB R2021a及以上版本都能跑,不需要额外工具箱,所有代码纯手写,复制粘贴就能出结果。
1. 2FSK为什么值得手动仿真一遍
2FSK全称是二进制频移键控,用两个不同频率的载波代表二进制里的0和1。它和2ASK最大的区别在于信息承载在频率上而不是幅度上,所以抗幅度衰落的能力更强,这也是它在无线对讲、低速数传里依旧占有一席之地的原因。但深入做仿真之前,有几个概念必须掰扯清楚,不然代码写出来也只是一堆数组拼接,出了问题根本不知道错在哪。
1.1 FSK信号的本质:两个频率的开关切换
2FSK的时域表达式可以写成:
s(t) = A * cos(2π * f1 * t + φ1) 表示发送“0” s(t) = A * cos(2π * f2 * t + φ2) 表示发送“1”
这里最关键的不是两个频率本身,而是切换是否连续。相位连续FSK(CPFSK)的频率切换过程平滑,频谱旁瓣衰减得快;相位不连续FSK在码元切换瞬间会有相位跳变,频谱扩展明显,实际工程中一般会做额外的滤波处理。
用MATLAB仿真时有两种建模思路:一种是根据码元状态分别生成两段正弦波然后拼接,另一种是先构造频率控制序列再用频率调制函数。我推荐新手用第一种,因为它把FSK的物理含义直接体现在代码里:每个码元就是一个固定频率的余弦片段,拼接起来就是完整的FSK波形。
1.2 仿真参数怎么定才能说明问题
参数设计直接决定仿真结果的可靠程度。对比包络检波和相干解调,核心指标是误码率随着信噪比的变化,所以参数设计必须保证:采样率足够高、码元周期内有足够的采样点、两个载波频率不互相干扰。
我用的参数如下:
- 码元速率Rb = 1000 bps,即每个码元持续1ms
- 载波频率f1 = 3000 Hz(代表“0”),f2 = 5000 Hz(代表“1”)
- 采样率fs = 50000 Hz
- 每个码元采样点数 = fs / Rb = 50个点
为什么f1取3000Hz、f2取5000Hz?这两个频率间距2000Hz,正好是码元速率的两倍,保证了两个频率的频谱不会重叠太多。采样率取50kHz,意味着最高信号频率5kHz还有十倍的过采样余量,还原正弦波形完全够用。
实际上工程里有更讲究的频率间隔选取方式,最小频移键控(MSK)把频偏压缩到码元速率的0.5倍,但那是另一个话题,咱们这次就不展开了。
2. 调制端代码:从比特序列到FSK波形
模拟通信系统的第一步是生成发送端的信号。这里看起来简单,但“生成比特序列”这个环节就有一个隐蔽的坑,不少新手在这里翻车。
2.1 随机比特序列生成与两个小坑
用randi生成随机序列,我得提醒两件事。
第一,randi([0 1], 1, N)返回的是double类型的0/1,如果直接拿去当索引用会出问题;第二,第一次跑仿真之前务必固定随机种子,否则每次都得到不同曲线,无法判断代码改动的影响。固定种子的方式很简单:
rng(42); % 固定随机种子,保证仿真可复现 data = randi([0 1], 1, 1000); % 生成1000个随机比特这里固定随机种子不是偷懒,而是工程仿真的基本素养。调试阶段不锁随机源,基本等于闭着眼睛改代码。
2.2 码元映射与载波拼接实现
接下来把0映射到f1,1映射到f2,为每个码元生成对应的正弦片段。这段代码是调制端的核心,也是整个系统的地基:
fs = 50000; % 采样率50kHz Rb = 1000; % 码元速率1000bps f1 = 3000; % 代表0的载波频率3kHz f2 = 5000; % 代表1的载波频率5kHz samplesPerBit = fs / Rb; % 每个码元的采样点数 N = length(data); t_bit = (0:samplesPerBit-1) / fs; % 单个码元的时间轴 % 预分配调制信号数组 modSignal = zeros(1, N * samplesPerBit); % 逐码元生成FSK信号 for k = 1:N idx = (k-1)*samplesPerBit + 1 : k*samplesPerBit; if data(k) == 0 modSignal(idx) = cos(2*pi*f1*t_bit); else modSignal(idx) = cos(2*pi*f2*t_bit); end end这里有个MATLAB性能小技巧:预分配modSignal数组,避免循环中动态增长。1000个码元还算小事,但如果仿真几百万比特,动态增长的数组会让运行时间从秒级变成分钟级,这不是危言耸听。
2.3 观察一下时域波形和功率谱,确认调制正确
信号生成之后,先别急着加噪声,看一眼波形再往下走。取前50个码元,画时域波形:
figure; plot((0:length(modSignal)-1)/fs * 1000, modSignal); xlabel('时间 (ms)'); ylabel('幅度'); title('2FSK调制信号时域波形'); xlim([0 5]); % 只看前5ms如果调制正确,你会看到波形在3kHz和5kHz之间切换,密集程度有明显差异。再用pwelch看功率谱,两个频率处会出现明显的谱峰。这一步能快速确认f1和f2是否真的落在预期位置,也能看出相位不连续导致的频谱扩展——旁瓣掉得不够快,那就是相位不连续的直接体现。
3. 高斯白噪声信道与信噪比换算
调制信号不经过信道直接解调,得到的一定是“完美结果”,毫无参考价值。真正的通信系统必然经历噪声干扰,所以必须在接收端加上高斯白噪声,并且对于每个信噪比都要独立做一次蒙特卡洛仿真。
3.1 AWGN信道的MATLAB实现
给信号加噪声,MATLAB里最直接的方式是awgn函数:
SNR_dB = 10; % 信噪比10dB rxSignal = awgn(modSignal, SNR_dB, 'measured');第三个参数写成'measured'表示函数会根据输入信号的实际功率自动计算噪声方差,这样能保证信噪比的准确性,比自己手动算噪声功率靠谱得多。这一段最简单的代码背后藏着一个容易忽略的问题——awgn默认假设输入信号是实信号,如果输入复数信号会产生错误的噪声功率计算,这次仿真是实信号所以没问题,但如果你以后仿真QPSK,一定要记得改用复数噪声添加方式。
3.2 不同信噪比下波形长什么样
拿SNR=0dB和SNR=15dB两种情况做个对比,0dB时噪声幅度和信号幅度几乎一样,肉眼已经很难从时域波形直接分辨频率切换;15dB时波形仍然干净,频率切换一目了然。
这就是后面误码率曲线会呈现出来的基本趋势:低信噪比下解调恢复正确信息的难度陡增,而在高信噪比下两种解调方式的差距在缩窄。理解这一层,再去看误码率曲线,就会有更直观的感知。
4. 包络检波解调:实现简单但别轻视理论
包络检波的本质是把频率差异转化成功率差异。2FSK信号通过两个中心频率分别为f1和f2的窄带带通滤波器,再各自做包络提取,比较两个包络大小,就可以判断当前码元是0还是1。整个过程中频率信息被“丢弃”了,提取的是幅度包络,所以这个方法也叫非相干解调。
4.1 带通滤波器设计与代码
首先要为两条支路分别设计带通滤波器。MATLAB里最方便的是designfilt函数:
fpass1 = [2500 3500]; % 支路1通带,中心频率3000Hz fpass2 = [4500 5500]; % 支路2通带,中心频率5000Hz bpFilt1 = designfilt('bandpassiir', ... 'FilterOrder', 8, ... 'HalfPowerFrequency1', fpass1(1), ... 'HalfPowerFrequency2', fpass1(2), ... 'SampleRate', fs); bpFilt2 = designfilt('bandpassiir', ... 'FilterOrder', 8, ... 'HalfPowerFrequency1', fpass2(1), ... 'HalfPowerFrequency2', fpass2(2), ... 'SampleRate', fs);滤波器设计里的取舍值得多说两句。滤波器阶数越高,通带越平坦、过渡带越窄,但相位延迟也会变大,导致波形失真。8阶对于这个仿真场景足够,再高就会出现滤波后码元之间相互拖尾干扰的情况。滤波器的通带宽度也不能太窄,否则码元切换瞬间的高频分量被削掉,包络会变得圆润,边缘模糊,判决点不好选。
4.2 包络提取与判决
包络提取最简单的办法是对滤波后的信号求绝对值,再经过一个低通滤波器或者移动平均,得到平滑的包络曲线。这里用movmean移动平均来做:
env1 = abs(filter(bpFilt1, rxSignal)); env2 = abs(filter(bpFilt2, rxSignal)); windowSize = 10; % 滑动平均窗口大小 env1 = movmean(env1, windowSize); env2 = movmean(env2, windowSize);包络提取后,在每个码元的判决时刻比较两支路的包络大小。抽样点取在码元周期的中点附近最合理,因为码元切换瞬间的暂态已经结束,包络相对稳定:
% 抽样判决 samplingPoints = round(samplesPerBit/2 : samplesPerBit : length(env1)); decoded_data_coherent = double(env1(samplingPoints) < env2(samplingPoints));这里用<而不是<=是防止包络相等时误判,实际信号中两个包络相等的概率极低,但代码逻辑上要先定义清楚。
4.3 包络检波的误码率统计
误码率的计算直接统计判决结果和原始数据的差异:
errors = sum(decoded_data_coherent ~= data); ber_coherent = errors / N;到这里包络检波就完整跑通了。但别高兴太早,跑完第一次仿真之后大概率会发现一个匪夷所思的现象——不论SNR多高、误码率都降不下去。原因往往出在两个地方:一个是滤波器相位延迟导致抽样点偏移,另一个是移动平均窗口太大导致包络变化被平滑过头。我建议你在包络检波仿真时,画一条env1和env2的曲线和原始信号叠加在一起看,亲眼确认抽样时刻包络是不是能正确区分0和1。
5. 相干解调:两路乘法器加低通滤波器
相干解调走的是另一条路线:接收端需要产生与发送端同频同相的本地载波,将接收信号分别与两路本地载波相乘,再做低通滤波恢复基带信号,最后比较两路输出。MATLAB仿真时我们可以简化一个操作——直接用发送端的载波作为本地载波,这就等于给了接收端“上帝视角”,但现实系统中载波同步是要专门解决的难题。
5.1 本地载波相乘并用低通滤波器提取基带信号
相干解调的MATLAB实现如下:
% 生成本地载波 t_total = (0:length(rxSignal)-1) / fs; localCarrier1 = cos(2*pi*f1*t_total); localCarrier2 = cos(2*pi*f2*t_total); % 乘法器输出 mixSignal1 = rxSignal .* localCarrier1; mixSignal2 = rxSignal .* localCarrier2; % 低通滤波器设计 lpFilt = designfilt('lowpassiir', ... 'FilterOrder', 8, ... 'HalfPowerFrequency', 1500, ... 'SampleRate', fs); basebandSignal1 = filter(lpFilt, mixSignal1); basebandSignal2 = filter(lpFilt, mixSignal2);这个低通滤波器的截止频率选1500Hz,道理在于:乘法器输出中包含直流分量(信息所在)和位于2f1、2f2附近的二倍频分量。低通滤波器需要把这些二倍频分量滤除掉,只保留基带分量,截止频率过高就滤不干净,过低又会把码元波形压扁。1500Hz对1kbps的码元速率是合理的折中。
注意:这里用的是filter而不是conv,因为我们要保持输出数组长度与输入一致,后续抽样才不需要额外处理索引。
5.2 抽样判决的实现细节
低通滤波之后,两路输出分别反映了“这个码元与f1的相关程度”和“这个码元与f2的相关程度”。对每个码元周期取中点抽样,比较两路大小:
samplingPoints = round(samplesPerBit/2 : samplesPerBit : length(basebandSignal1)); decoded_data_coherent = double(basebandSignal1(samplingPoints) < basebandSignal2(samplingPoints));这里有一个MATLAB新手经常踩的坑:不要提前对basebandSignal做归一化或标准化,除非你有明确理由。相干解调两路信号在同一信道条件下受到同一个噪声影响,它们之间的相对大小才是判决依据,任何独立的幅度归一化操作都会破坏这种相对比较关系。
5.3 为什么相干解调理论误码率更低
相干解调的误码率理论上优于包络检波约1.5dB,原因在于相干的乘法器实际上是一个相关器,把信号能量集中到了基带直流分量上,而噪声经过低通滤波后统计特性是均匀分布在宽带内的,两者相乘后信噪比获得了处理增益。包络检波本质上是能量检测,没有利用信号的相位信息,在低信噪比下更容易被噪声把包络顶起来,导致误判。
6. 蒙特卡洛仿真与误码率曲线对比
单次仿真只能得到一个点的误码率,要画出完整的曲线必须在多个信噪比下重复仿真。这里用蒙特卡洛法,对每个SNR值做若干次独立仿真求平均误码率。
6.1 完整蒙特卡洛仿真循环
这段代码会遍历从-2dB到15dB的信噪比,每一步运行50次独立仿真:
SNR_dB_list = -2:1:15; numTrials = 50; numBits = 2000; ber_env = zeros(size(SNR_dB_list)); ber_coh = zeros(size(SNR_dB_list)); for snrIdx = 1:length(SNR_dB_list) SNR_dB = SNR_dB_list(snrIdx); errEnvTotal = 0; errCohTotal = 0; for trial = 1:numTrials rng(trial * snrIdx); % 每次试验独立随机种子 data = randi([0 1], 1, numBits); modSignal = zeros(1, numBits * samplesPerBit); for k = 1:numBits idx = (k-1)*samplesPerBit + 1 : k*samplesPerBit; if data(k) == 0 modSignal(idx) = cos(2*pi*f1*t_bit); else modSignal(idx) = cos(2*pi*f2*t_bit); end end rxSignal = awgn(modSignal, SNR_dB, 'measured'); % 包络检波 env1 = abs(filter(bpFilt1, rxSignal)); env2 = abs(filter(bpFilt2, rxSignal)); env1 = movmean(env1, 10); env2 = movmean(env2, 10); samplingPoints = round(samplesPerBit/2 : samplesPerBit : length(env1)); decodedEnv = double(env1(samplingPoints) < env2(samplingPoints)); errEnvTotal = errEnvTotal + sum(decodedEnv ~= data); % 相干解调 mix1 = rxSignal .* localCarrier1; mix2 = rxSignal .* localCarrier2; bb1 = filter(lpFilt, mix1); bb2 = filter(lpFilt, mix2); samplingPoints = round(samplesPerBit/2 : samplesPerBit : length(bb1)); decodedCoh = double(bb1(samplingPoints) < bb2(samplingPoints)); errCohTotal = errCohTotal + sum(decodedCoh ~= data); end ber_env(snrIdx) = errEnvTotal / (numBits * numTrials); ber_coh(snrIdx) = errCohTotal / (numBits * numTrials); end每个SNR点做50次独立仿真,2000个比特,总统计量为50*2000=100000个比特。这个量级算误码率在10^-3左右还能保证统计稳定性,再低的误码率就需要更多的仿真次数,否则曲线会剧烈抖动。
6.2 理论误码率曲线对比
把仿真结果和理论公式放到同一张图上。2FSK相干解调的理论误码率为:
Pe_coherent = 0.5 * erfc(sqrt(Eb/N0/2))
包络检波的理论误码率为:
Pe_envelope = 0.5 * exp(-Eb/(2*N0))
这里要注意理论公式中的Eb/N0和信噪比SNR之间的换算:
Eb_N0_dB = SNR_dB_list - 10*log10(samplesPerBit/2);为什么会有这个换算?因为awgn函数的SNR定义是信号功率与噪声功率的比值,而误码率公式中的Eb/N0是每比特能量与噪声功率谱密度之比。对于2FSK信号,在采样率fs下,每个码元有samplesPerBit个采样点,信号能量分布在频率维度上,换算关系不同导致两者差了一个10log10(samplesPerBit/2)的偏移量。具体到这里,samplesPerBit=50,所以偏移量是10log10(25)≈14dB。
不搞清楚这个换算,你会发现仿真曲线比理论曲线整体平移了十几dB,误以为自己代码写错了。
6.3 两种解调方式在低信噪比下的真实差距
画完图你会看到非常典型的结果:高信噪比下(10dB以上),两条曲线都几乎贴着零轴,已经看不出明显差别;但在0dB附近,相干解调的误码率明显低于包络检波,差距大约在1~2dB。这个结果和理论预期吻合,因为相干解调利用相位信息获得了更多的有效信号能量,而包络检波靠幅度信息,天生处在劣势。
蒙特卡洛仿真次数如果太少,低误码率区域会出现曲线不平滑甚至某些点误码为0,导致对数坐标画不出来。遇到这个问题不要紧张,要么增加仿真次数,要么减小低误码率区域的仿真点数范围。
7. 代码搬运过程中的常见报错和解决方式
写这篇博客之前,我又把完整的仿真过程从头到尾跑了一遍,过程中确实有几个小地方很容易踩坑,这里集中列出来。
7.1 滤波器输出长度与抽样索引不一致
filter函数的输出长度默认和输入一致,这点没问题。但如果有人用了conv或者conv2,输出长度就变成了L_in + L_filt - 1,导致后续抽样索引超出数组边界。解决方案是使用filter而不是conv,如果非要使用卷积,记得截断输出到输入长度。
7.2 随机种子问题导致误码率曲线抖动剧烈
我之前调试时发现,每个SNR点只用一次随机序列,画出来的误码率曲线锯齿严重,根本没法看。后来改成多次试验取平均,曲线才平滑下来。这个方法在通信仿真里叫蒙特卡洛平均,是处理随机数据必不可少的步骤。
7.3 抽样时刻偏移导致误码率虚高
滤波器是有群延迟的,尤其IIR滤波器在中高频段会有明显的相位非线性。designfilt设计的滤波器虽然具有零相位响应(使用filtfilt)或者线性相位(使用FIR滤波器),但普通IIR滤波器的filter输出在起始阶段会有暂态过渡,导致前几个码元的抽样结果异常。解决方式有两种:仿真时丢弃前几个码元(比如50个),或者把抽样点适当往后移。实际工程里都会预留一段前导码或训练序列专供滤波器过渡,仿真时也要有这个习惯。
7.4 理论曲线画不出来或者错位
最常见的原因是erfc函数里忘记除以2,或者Eb/N0换算错误。我的建议是先把仿真曲线和理论曲线画在同一个坐标下,找到偏移量后反向验证自己的换算是否正确。如果你发现曲线形状完全一致但水平方向差了一个固定值,那基本就是换算关系没对上。
8. 扩展思考:代码还能往哪个方向迭代
到这里,2FSK的调制解调完整仿真已经跑通了。但收尾之前,我想多说几句怎样在这个基础上继续往深处走,据我经验,很多刚接触通信仿真的人都栽在“只会跑通,不会改”这个阶段。
8.1 把相位不连续改成连续相位FSK
上面的代码是直接把两个频率的独立余弦波拼在一起,切换瞬间相位会有跳变。你可以试验在码元切换时调整下一段波的起点相位,使其与上一段波的终点相位连续,构造一个连续相位FSK波形,对比两者的功率谱特性。实际操作起来非常有意思:连续相位FSK的频谱旁瓣下降更快,但实现上需要多一行相位累积的代码。
8.2 加入频偏估计补偿
仿真中的本地载波是完美的,现实中接收端与发送端存在频率偏差,可能是几赫兹到几十赫兹。你可以人为给接收信号加一个频率偏移,再实现一个简单的频偏估计和校正算法,看看误码率会恶化到什么程度。这个改进会让你对“载波同步为什么难”有切身体会。
8.3 从2FSK到MFSK的推广
2FSK只是最基础的二进制频率调制,可以扩展为4FSK、8FSK甚至是16FSK,每个码元携带更多比特信息。MFSK在低信噪比下表现优异,但代价是占用更大的带宽,仿真中对比不同M值的误码率曲线会很有趣,但需要关注的参数和滤波器设计复杂程度也会上一个台阶。
8.4 跟理论误码率公式印证的一个小技巧
最后分享我在调这个仿真时用的小技巧:先把仿真曲线画出来,再画理论曲线,如果两者对不上,不要在第一时间怀疑公式推导,而是检查你的Eb/N0换算是否准确。绝大多数情况下,仿真代码只要逻辑没写错,曲线应该和理论值非常接近。真正理解了这一点,2FSK这块基本就吃透了。