简介:MSK调制解调Matlab仿真代码包,面向通信工程专业学生与无线通信系统设计人员,涵盖信号生成、调制映射、相干解调及误码统计的完整仿真链路,可帮助快速理解最小移频键控的核心原理和性能评估方法。压缩包共6个文件,仅3KB大小,包含5个m脚本和1个txt说明;脚本分别实现主程序、调制模块、解调模块与测试功能,文本说明提供运行流程和参数设置的简要指引。在AWGN信道下通过蒙特卡洛仿真绘制误码率随信噪比变化曲线,评价不同信噪比下的传输可靠性;同时计算并绘制MSK信号的功率谱密度,直观展示双峰频谱特征与连续相位优势,便于与理论曲线相互印证。已有1367人学习。学习者可直接运行主代码复现全部结果,也可修改载波频率、调制指数等关键参数,对比不同配置下的误码性能与频谱特性,从而加深对MSK调制解调技术及其在无线通信中应用的理解。
1. MSK 的代码仿真为什么值得一次性跑通
拿到“MSK调制解调代码+误码率曲线+功率谱”这个标题时,我第一反应是:这又是一个看起来“课程作业味”很重、但实际落地全是细节的仿真工程。MSK 表面上是 FSK 的一种特例,调制指数 h=0.5,可它的误码率性能却和 BPSK 几乎一样,频谱主瓣又比 BPSK 窄三分之一。这个“反直觉”的组合,让它在卫星通信、深空通信里经常被翻出来重新验证。这篇博文我会给出一套用 Python 实现的完整链路:从 MSK 调制、相干解调,到蒙特卡洛法画误码率曲线,再到周期图法估计功率谱,最后落在这类仿真最容易翻车的几个参数上。适合正在做通信物理层验证的工程师,也适合需要把课程设计与实际链路对上的学生。
2. 从相位连续到 I/Q 正交:MSK 调制代码与三个必调参数
2.1 调制指数 h=0.5 在代码里到底是什么
很多代码写着写着,就把 MSK 写成了普通 2FSK。关键区别就在调制指数 h=0.5。FSK 的两个频率间隔是 2h·Rb,MSK 的相邻码元频率差正好是 0.5·Rb,也就是说一个比特周期 Tb 内相位只旋转 ±π/2,积累两个比特才可能达到 π。这个性质让信号相位始终保持连续,没有跳变,功率谱的旁瓣才压得下去。
用相位累加模型看最直观:每个码元周期 Tb 内,相位变化量是 h·π·d,其中 d 是 ±1 的映射值。h=0.5 时每比特相位增量就是 ±π/2。要检查你拿到的代码是不是真 MSK,就去看瞬时频率或相位差分,如果每 Tb 的相位变化不是严格 ±π/2,那本质就是别的调制方式。
如果直接用相位累加生成复基带,代码非常短:
import numpy as np def msk_modulate_phase(bits, sps=8, h=0.5): """利用相位累加生成 MSK 复基带信号""" d = 2 * bits.astype(float) - 1.0 # 0/1 -> -1/+1 n_bits = len(bits) total_n = n_bits * sps t = np.arange(total_n) / sps # 以 Tb 为单位的时间轴 # 每个采样点的相位增量:每个比特周期 Tb 内相位变化 h*pi*d phase_increment = np.pi * h * d / sps # 每个采样点的相位步进 phase = np.zeros(total_n) # 把码元对应的相位步进展开到每个采样点 phase_steps = np.repeat(phase_increment, sps) phase = np.cumsum(phase_steps) + 0.0 # 相位连续累积 s = np.exp(1j * phase) # 恒包络复基带 return s这个写法直白,但缺点是把 I/Q 结构的物理意义藏掉了。后面要接正交相干解调,得知道 MSK 可以看作 I/Q 两路分别用 cos/sin 窗函数成形,所以我更喜欢用下一节的正交结构来生成信号。
2.2 I/Q 正交调制的完整实现
MSK 的正交表示是教科书级的结论:复基带信号可以写成
s(t) = a_I(t)·cos(πt / 2Tb) + j·a_Q(t)·sin(πt / 2Tb)
其中 a_I(t) 取偶数比特,每隔 2Tb 变一次;a_Q(t) 取奇数比特,同样每隔 2Tb 变一次,但整体延迟 Tb。这个形式的优势是接收端可以明确地分成 I/Q 两个支路匹配,和后面的相干解调直接对上。
def msk_modulate(bits, sps=8): """ MSK 正交法调制,返回复基带信号 bits: 0/1 numpy array, 长度会被截断为偶数 sps: 每比特采样点数(采样率 = sps * 比特率) """ if len(bits) % 2 != 0: bits = bits[:-1] # 保证串并转换后两路长度一致 d = 2 * bits.astype(float) - 1.0 # 0 -> -1, 1 -> +1 a_i = d[0::2] # 偶数比特 -> I 路 a_q = d[1::2] # 奇数比特 -> Q 路 n_sym = len(a_i) # 2 比特为一个符号 sym_len = 2 * sps # 一个符号持续 2*Tb total_n = n_sym * sym_len t = np.arange(total_n) / sps # 时间轴,单位 Tb A_i = np.zeros(total_n) A_q = np.zeros(total_n) for k in range(n_sym): A_i[k*sym_len:(k+1)*sym_len] = a_i[k] # I 路在 2Tb 内保持 start_q = k*sym_len + sps # Q 路延迟 Tb A_q[start_q:start_q+sym_len] = a_q[k] w_i = np.cos(np.pi * t / 2.0) # cos(pi t / 2Tb) w_q = np.sin(np.pi * t / 2.0) # sin(pi t / 2Tb) s = A_i * w_i + 1j * A_q * w_q return s这里有两个必调参数,直接影响后续所有结果。第一个是 sps,也就是每比特采样点数。太小会让波形不光滑,I/Q 窗函数产生明显量化误差,误码率曲线出现平台;一般取 8 到 16,代码里默认 8,足够看清楚功率谱结构。第二个是比特总数,为了凑成偶数长度函数内部会截断,实际仿真时建议直接传偶数个比特,避免无意间丢掉信息。
2.3 仿真参数怎么定:采样率、比特数、成形方式
采样率建议按照 fs = sps × Rb 来定,不要固定一个绝对赫兹数。比如比特率 Rb=1e6,sps=8,那 fs=8MHz。这样所有和码元周期相关的窗函数都直接用采样点数写,不容易错。真正需要统一的是时间轴单位:我习惯把所有时间轴写成“Tb 的倍数”,cos 窗写成 cos(πt/2),省去每次换算秒的麻烦。
比特数要分场景。画误码率曲线和画功率谱的需求不同。误码率曲线如果跑到 1e-5,一个 Eb/N0 点至少要几十万比特起步;功率谱估计想要平滑,也不需要海量比特,几万个比特配合 Welch 分段平均就够。所以不要用同一套长度跑两类图,否则要么功率谱毛刺多,要么误码率仿真等到天荒地老。
成形方式指正交法里合理的窗函数选择。MSK 的 I/Q 窗是天然的半正弦/半余弦,不需要额外加根升余弦脉冲。有人为了让频谱更窄,在 MSK 后面又串了一个低通滤波器,这在带外抑制上有效,但会破坏恒包络特性,后续解调时匹配滤波也得跟着改。如果目标是对照 MSK 理论误码率和理论功率谱,就不要加额外滤波,先跑通最纯净的版本。
3. 相干解调与差分编码:接收链路怎么搭才不漏码
3.1 为什么相干解调要做 I/Q 分离和积分匹配
MSK 的相干解调不是把接收信号直接乘一个本地载波再抽样那么简单。因为 MSK 实际上是把信息放在两路基带正交分量里,I 路用 cos 窗承载偶数比特,Q 路用 sin 窗承载奇数比特,两路还有半个码元的时延。接收端必须把这两路拆开,各自匹配,然后再按发送端的串并顺序交织回来。
匹配滤波器的核心就是那个窗函数。发送端 I 路用 cos(πt/2Tb) 成形,接收端最优匹配就是把收到的信号乘以同样的 cos 窗,在一个 2Tb 窗口内积分;Q 路对应 sin 窗。这样做等价于最大信噪比接收,能保证误码率逼近理论值。
另一个容易忽略的点是积分窗的位置。I 路的积分窗口是 [2kTb, 2(k+1)Tb],Q 路是 [(2k+1)Tb, (2k+3)Tb]。如果像 QPSK 那样 I/Q 都用同一个窗口,解调性能会直接劣化,而且越接近理论误码率越明显。
3.2 解调代码:积分清除与判决
下面这段解调代码和前面调制函数配套,假设接收端理想同步,无频偏、无相偏,只有高斯白噪声。
def msk_demodulate(rx, sps=8): """ MSK 相干解调:I/Q 分离 + 积分清除 + 判决 rx: 复基带接收信号 """ n_samples = len(rx) n_sym = n_samples // (2 * sps) # 完整 2 比特符号数 t = np.arange(n_samples) / sps w_i = np.cos(np.pi * t / 2.0) w_q = np.sin(np.pi * t / 2.0) a_i_hat = np.zeros(n_sym) a_q_hat = np.zeros(n_sym) for k in range(n_sym): # I 路积分窗口 [2kTb, 2(k+1)Tb] i0 = k * 2 * sps i1 = i0 + 2 * sps z_i = np.real(rx[i0:i1] * w_i[i0:i1]).sum() a_i_hat[k] = 1.0 if z_i > 0 else -1.0 # Q 路积分窗口 [(2k+1)Tb, (2k+3)Tb] q0 = k * 2 * sps + sps q1 = q0 + 2 * sps z_q = np.imag(rx[q0:q1] * w_q[q0:q1]).sum() a_q_hat[k] = 1.0 if z_q > 0 else -1.0 # 并串转换 d_diff_hat = np.zeros(2 * n_sym) d_diff_hat[0::2] = a_i_hat d_diff_hat[1::2] = a_q_hat # 这里假设发送端没有差分编码,按普通符号映射判决 bits_hat = ((d_diff_hat + 1) // 2).astype(int) return bits_hat注意代码里积分直接用了.sum(),省略了采样间隔 1/fs。因为 I/Q 两路信号都有相同的标度因子,对判决符号没有影响,所以可以省。如果你想在不同采样率之间严格对齐能量,再乘 dt 也不迟。
参数上要留意 sps 必须和调制端完全一致,且 rx 长度要能被 2×sps 整除,否则最后的符号数会截断。实际工程里解调端一般会多做一步定时恢复,这里为了聚焦误码率和功率谱,先假设定时理想。如果后续要加频偏,你会发现误码率对频偏非常敏感,后面章节会单独说。
3.3 实际系统不能省差分编码:相位模糊的抵消逻辑
理想仿真里,接收端知道绝对相位,所以 3.2 的代码不差分也能跑出理论误码率。但真实接收机用 Costas 环或类似载波恢复,存在 180° 相位模糊,无法判断收到的星座是正的还是反转的。MSK 的 I/Q 结构也有类似的整体符号不确定问题,所以工程实现一定要加差分编码。
差分编码的思路很简单:发送端把原始比特变成差分序列,接收端用相邻两个判决结果相减恢复原比特。这样即使整条链路反转了符号,相邻比特的乘积关系仍然正确。
def differential_encode(bits_in): """对 0/1 比特做差分编码,输出 0/1 序列""" out = np.zeros_like(bits_in) prev = 1 for i, b in enumerate(bits_in): d = 2 * int(b) - 1 d_diff = d * prev out[i] = 0 if d_diff == -1 else 1 prev = d_diff return out def differential_decode(bits_hat): """差分解码:当前差分符号乘以上一个差分符号""" x = 2 * bits_hat.astype(float) - 1.0 y = np.zeros_like(x) prev = 1 for i, v in enumerate(x): y[i] = v * prev prev = v return ((y + 1) // 2).astype(int)需要明确一点:差分编解码会带来大约 0.5 dB 的性能代价,因为一个差分符号出错可能连带影响两个原始比特。很多课程仿真把差分编码省略,误码率曲线直接比 Q(√(2Eb/N0)),这一点没毛病,只要心里清楚真实系统不会这么干净。后面误码率章节的仿真我会用无差分的理想模型和理论曲线对比,再用一节的篇幅说清楚相位模糊这个坑。
4. 误码率曲线必踩的坑:置信度、相位模糊与噪声功率匹配
4.1 理论误码率公式与蒙特卡洛仿真代码
MSK 相干解调的理论误码率和 BPSK 相同,比特误码率是 Pb = Q(√(2Eb/N0)),其中 Q 是标准正态分布的尾部积分。注意这里用的是 Eb/N0,不是 Es/N0。MSK 虽然按 2 比特一个符号做 I/Q 映射,但每比特能量独立,画曲线时横轴一定是 Eb/N0。
蒙特卡洛仿真没有捷径,就是每个 Eb/N0 点独立加噪声、独立解调、独立统计错误比特数:
from scipy.special import erfc def ber_theory(ebn0_db): """MSK 相干理论误码率,EbN0 单位 dB""" x = np.sqrt(2 * 10**(ebn0_db / 10.0)) return 0.5 * erfc(x / np.sqrt(2.0)) def ber_simulate(ebn0_db, n_bits=100000, sps=8, seed=0): """ MSK 误码率蒙特卡洛仿真 返回: (误码率, 错误比特数) """ rng = np.random.default_rng(seed) bits = rng.integers(0, 2, n_bits) s = msk_modulate(bits, sps=sps) # 复基带信号功率归一化为 1,每比特能量 Eb = Tb rb = 1.0 tb = 1.0 / rb fs = sps * rb ebn0_lin = 10**(ebn0_db / 10.0) n0 = tb / ebn0_lin # 噪声功率谱密度 # 每维噪声方差:N0*fs/2 noise_power_per_dim = n0 * fs / 2.0 noise = (np.sqrt(noise_power_per_dim) * (rng.standard_normal(len(s)) + 1j*rng.standard_normal(len(s)))) rx = s + noise bits_hat = msk_demodulate(rx, sps=sps) err = np.sum(bits_hat != bits[:len(bits_hat)]) return err / len(bits_hat), err这段代码的正确性依赖一个关键设定:MSK 复基带信号功率为 1,所以每比特能量 Eb=Tb。噪声每维方差取 N0·fs/2,这是复基带加性白噪声的标准功率关系。第一次跑通后务必先检查 0 dB 附近的误码率大概在 8% 到 10%,再检查高 Eb/N0 点的趋势,避免噪声模型错了反而误以为算法有问题。
4.2 低误码率区间要多少比特:用错误数控制仿真时长
这是误码率仿真里最容易被低估的坑。蒙特卡洛统计的本质是伯努利试验,误码率的相对标准差大约是 1/√(N·Pb)。想得到可靠的一点,至少需要累计到几十个错误比特,否则曲线看起来像锯齿一样上下乱跳。
常见做法是固定每个 Eb/N0 点都跑 10 万个比特,这在低误码率区间完全不够:Pb=1e-4 时平均只有 10 个错误,标准差就有 30%,曲线没法看。我一般用自适应停止条件:每个点至少收集 100 个错误比特,并且总比特数上限设到 200 万,防止高 Eb/N0 点跑太久。
def ber_simulate_adaptive(ebn0_db, min_err=100, max_bits=2000000, sps=8, seed=0): """自适应比特数的误码率仿真""" rng = np.random.default_rng(seed) total_err = 0 total_bits = 0 while total_err < min_err and total_bits < max_bits: n_bits = 20000 # 每轮累加的比特数 bits = rng.integers(0, 2, n_bits) s = msk_modulate(bits, sps=sps) fs = sps tb = 1.0 ebn0_lin = 10**(ebn0_db / 10.0) n0 = tb / ebn0_lin noise_power_per_dim = n0 * fs / 2.0 noise = (np.sqrt(noise_power_per_dim) * (rng.standard_normal(len(s)) + 1j*rng.standard_normal(len(s)))) rx = s + noise bits_hat = msk_demodulate(rx, sps=sps) err = np.sum(bits_hat != bits[:len(bits_hat)]) total_err += err total_bits += len(bits_hat) return total_err / total_bits, total_err, total_bits参数说明:min_err 决定曲线低端的稳定性,取 100 时误差约 10%,取 400 时误差约 5%,代价是仿真时间成倍增长。max_bits 是刹车片,防止在 9 dB 这种极低误码率点无限循环。实际画图时,Eb/N0 从 0 到 9 dB 每隔 0.5 dB 取点就够了,点太密既看不出新信息,又让总时长飙升。
4.3 三个高频翻车点:相位模糊、噪声方差、Eb/N0 与 Es/N0
先说过最常踩的相位模糊。如果仿真里给 rx 乘一个 e^{jθ},θ=π 时星座整体翻转,误码率会直接抬到 0.5 附近,而且理论曲线怎么调都对不上。有人以为是噪声方差错了,折腾半天才发现是载波相位没固定。理想仿真里可以用已知相位,但一旦开始模拟载波恢复环路,就必须配差分编码,否则这个坑一定会再踩一次。
第二个是噪声方差因子。复基带噪声的实部和虚部,每维方差取 N0·fs/2 是我这里的约定。如果你把方差设成 N0·fs,整条误码率曲线会向右平移约 3 dB;设成 N0/2 则会左移。排查方法很简单:只画 0 dB 到 3 dB 这一段,理论和仿真的差距若超过 0.5 dB,就检查噪声功率计算,而不是盲目怀疑调制解调代码。
第三个是横轴单位。MSK 一个符号携带 2 比特,Es=2Eb。如果代码里误把 Es/N0 当 Eb/N0 画,曲线会横移 3 dB。核对方法也很直接:看 0 dB 处理论误码率是否约 0.08,如果仿真出来的误码率明显偏低,大概率就是 Es/N0 和 Eb/N0 混用了。
5. 功率谱曲线:理论表达式、周期图估计与旁瓣对比
5.1 MSK 功率谱的理论表达式与主瓣宽度
MSK 功率谱的理论表达式可以写成
S(f) ∝ [ cos(2πfTb) / (1 - 16·f²·Tb²) ]²
以载波频率为原点,Tb 是比特周期,f 是相对载波的偏移。这个式子有两个值得记的特征:一是主瓣第一零点在 f=0.75·Rb,所以主瓣宽度是 1.5·Rb,比 BPSK 的 2·Rb 窄;二是旁瓣随频率按 f⁻⁴ 衰减,而 BPSK 只有 f⁻²。这就是连续相位带来的好处,也是 MSK 在带限信道里被选择的主要原因。
画理论曲线时,常数系数不重要,因为功率谱最终要对峰值归一化,所以直接用上面的比例式画就可以。要注意这里 f 的单位是 Hz,和前面代码里时间轴以 Tb 为单位的写法要统一:把时间轴换算成秒,或直接把 f 写成相对 Rb 的倍数,推荐后者,图不容易看错。
5.2 用 Welch 方法从仿真信号估计功率谱
实际信号功率谱用 Welch 分段平均比较稳,比直接对整段信号做一次 FFT 毛刺少得多。直接 FFT 的功率谱波动大,旁瓣细节完全看不清。
from scipy.signal import welch def estimate_psd(s, sps=8, rb=1.0): """ 估计复基带信号的功率谱,返回频率轴和 PSD fs = sps * rb,频率轴以 rb 为倍数 """ fs = sps * rb f, pxx = welch(s, fs=fs, nperseg=2048, return_onesided=False) f = np.fft.fftshift(f) / rb # 频率归一化到 Rb 倍数 pxx = np.fft.fftshift(pxx) pxx = pxx / np.max(pxx) # 峰值归一化,方便对比理论 return f, pxxwelch 的几个参数会影响曲线质量。nperseg 是分段长度,太长旁瓣泄漏小但曲线起伏大,太短则平滑过头把主瓣都抹平了;2048 对这个场景够用。return_onesided=False 是关键,MSK 复基带信号不是实信号,必须用双边谱,否则负频率的功率会折到正频率,曲线形态直接错。最后用 fftshift 把零频放到中间,对比理论表达式时更直观。
如果信号只有几万个采样点,nperseg 不建议超过总长度的一半。分段之间用默认汉宁窗,它会把旁瓣压低,但会轻微展宽主瓣,和理论曲线对比时允许有一点差别,重点看零点位置和旁瓣滚降斜率。
5.3 MSK 与 BPSK 旁瓣对比:连续相位带来的增益
把 MSK 和 BPSK 放在同一张图上,是验证调制实现是否正确的最快方法。BPSK 的基带功率谱表达式是 sinc² 形,主瓣零点在 Rb,旁瓣按 f⁻² 衰减。MSK 主瓣零点在 0.75Rb,旁瓣按 f⁻⁴ 衰减。
def bpsk_psd_theory(f_norm): """f_norm 为归一化频率 f/Rb""" x = np.pi * f_norm return np.sinc(f_norm) ** 2 # 实际是 sin(x)/x 的平方 def msk_psd_theory(f_norm): """f_norm 为归一化频率 f/Rb""" x = 2 * np.pi * f_norm denom = 1 - 16 * f_norm**2 return (np.cos(x) / denom) ** 2画图时 MSK 仿真 PSD、MSK 理论、BPSK 理论三条曲线叠一起。先看 MSK 主瓣是否确实收窄到 ±0.75Rb,再看 1.5Rb 以外旁瓣是不是明显比 BPSK 低。如果 MSK 的主瓣看起来和 BPSK 一样宽到 Rb,大概率调制端相位累加写错了,比如每比特相位增量不是严格 ±π/2。
单位换算这里经常出岔子。很多代码里时间轴以“采样点”为单位,直接平方出现频率轴的错位。我的习惯是始终把频率除以 Rb 画成无量纲数,这样无论采样率怎么调,理论曲线都不用改。验证通过后,功率谱这张图会成为你检查调制端实现最快的“黑匣子测试”:波形对不对,频谱一眼见分晓。
6. 进阶验证技巧:三招把 MSK 仿真从“能跑”变成“可信”
6.1 瞬时频率检查:一张图看穿相位是否连续
BER 曲线和功率谱都对上,不等于调制实现一定正确。我最先做的是瞬时频率检查。对复基带信号求相位差分,再换算成归一化频率:
def check_instantaneous_frequency(s, sps=8): """检查瞬时频率是否在 ±0.25·Rb 附近且无跳变""" phase = np.unwrap(np.angle(s)) freq = np.diff(phase) / (2 * np.pi / sps) # 单位:Rb 的倍数 return freqMSK 的瞬时频率应该只有两个值:+0.25·Rb 和 -0.25·Rb,分别对应比特 1 和 0。切换点处频率变化是跳跃的,但相位本身不能有毛刺。如果看到明显超过 0.25 的尖峰,说明调制端的串并映射或 Q 路延迟弄错了,这时候先别跑 BER,返工调制代码。这个检查比 BER 曲线敏感得多,因为 BER 在低信噪比时不容易看出调制细节问题。
6.2 噪声模型校准:先跑 BPSK 再跑 MSK
把整套噪声模型在一个已知答案的系统上验证一遍,是我跑所有调制仿真的固定动作。BPSK 相干解调的理论误码率是 Q(√(2Eb/N0)),实现简单,几分钟就能跑完几个点。如果 BPSK 的仿真曲线和理论对不上,那问题几乎一定在噪声功率换算,而不是 MSK 本身。校准完成后把同样的噪声模型搬给 MSK,不该再怀疑这个环节。
这一步看起来多花十分钟,实际能省下几小时的排错时间。我见过太多人直接跑 MSK,BER 曲线平移 2 dB,然后在调制、滤波、同步里反复找原因,最后发现是最基础的噪声方差公式抄错了。
6.3 BER 三点快速回归:改完代码后的固定动作
改完任何参数或代码逻辑后,不需要马上跑完整 BER 曲线。先跑三个点:0 dB、3 dB、6 dB。0 dB 应该有明显错误但不到一半;3 dB 对应理论约 1e-2;6 dB 对应理论约 2.4e-3。三个点和理论值差距都在预期范围内,再跑全曲线。如果其中一个点突然偏差,大概率是当次改动引入的边界条件问题,比如比特数截断了、sps 变了但解调端没同步改。
最后说一个我自己的教训:有一版代码为了省事,把差分编码去掉了,结果只要接收端加一个固定 π 相移,误码率就卡在 0.49 下不来。当时我盯着噪声方差调了整整一个下午,最后才意识到是相位模糊规律性出现。从那以后,凡是和“真实接收机”相关的仿真,我默认加差分编码;凡是和“理论曲线对比”相关的仿真,我默认固定已知相位。这两个场景不要混用,否则曲线会永远差一点但说不清差在哪。希望这些排错顺序和参数习惯能帮你少走几步弯路。
本文还有配套的精品资源,点击获取