简介:空间索引调制(Spatial Modulation, SM)是面向未来无线通信系统的重要MIMO传输技术,通过天线选择承载额外信息比特,在提升频谱效率的同时有效降低系统复杂度;该MATLAB压缩包围绕SM及其扩展方案提供完整可运行代码,适合学习无线通信、MIMO与信号处理的本科高年级或研究生读者,也可作为课程设计或科研入门参考。资源共3个文件,均为m格式源码,包括基本的空间调制、正交空间调制及广义空间调制实现,覆盖发射端天线索引映射、QPSK/16QAM符号调制、AWGN信道模拟与接收端误码率统计等完整流程。整体包体仅4KB,结构精炼,便于直接阅读和修改仿真参数;当前已有340人学习下载。通过调试这套代码,读者可以直观理解不同天线数、调制阶数与信噪比条件下SM系统的性能差异,并为进一步结合预编码、波束赋形或OFDM-MIMO的扩展研究提供可复用基础。这套代码尤其适合以误码率曲线为切入点的仿真分析实践,帮助读者快速完成从原理理解到实验验证的闭环。
1. 空间索引调制是什么:把比特藏在「用哪根天线」里
第一次跑通 spatial-modulation-master 的仿真时,我盯着发射端脚本看了很久:4 根发射天线,每个时隙却只让其中一根工作。这不是浪费,而是空间索引调制(spatial modulation,SM)的核心——「用哪根天线发」本身就是调制维度。索引比特不改变星座符号,而是改变激活天线编号,因此它叫空间索引调制,不叫多天线并行。
它解决的是射频链路成本问题:传统 MIMO 每条天线都要一套射频链路,而空间调制只需一根链路,同时保留天线的分集潜力。毫米波基站、车联网终端这类对功耗和成本敏感的场景很吃这个特性。本文写给做链路仿真、接收机算法和原型验证的工程师:我会从发射映射讲起,给出一套可复现的最小仿真,再讲检测算法和参数设置,最后把最常见的翻车点逐条列出来。
2. 空间调制的发射机模型与索引映射:从比特到激活天线再到星座
2.1 为什么要把索引当信息:省链路与抗干扰是两个动机
要理解空间调制为什么要走「索引」这条路,得先看传统 MIMO 在硬件上付出的代价。以 V-BLAST 为代表的垂直分层传输,需要 Nt 根发射天线同时发送 Nt 路独立数据,每根天线都需要一路 DAC、上变频混频器和功率放大器。到了毫米波频段,这套链路还要跟着天线数乘一遍,成本和散热立刻失控。
空间调制的思路完全反过来:每个时隙只激活一根发射天线,把它当作单输入单输出信道。由于一次只有一根天线工作,接收端在做信道估计时天然知道哪根天线的信道是有效的,天线之间没有数据流干扰,也就不需要复杂的干扰消除。这个性质在链路仿真里表现得非常干净:检测矩阵从 Nt 维向量退化成一个稀疏向量加一个非零约束。
索引为什么能携带信息?本质原因是发射天线集合在空间上是有限的。Nt 根天线里选哪一根,是一个有 log2(Nt) bit 容量的组合选择。把星座符号固定不动,仅仅改变激活天线,解调端能通过测哪个信道与接收信号更吻合,反向推出发射端选的是哪根天线。这个反向推断的能力,就是空间索引调制的信息通道。
我一般在给团队讲选型时会放一张对比表,把它和 V-BLAST、天线选择放在一起看:
| 方案 | 激活天线数 | 发射链路数量 | 是否需要干扰消除 | 索引是否携带信息 |
|---|---|---|---|---|
| V-BLAST | Nt | Nt | 需要 | 否 |
| 天线选择(AS) | 1 | 1 | 否 | 否,索引由信道状态决定 |
| 空间调制(SM) | 1 | 1 | 否 | 是,索引由比特决定 |
这张表点明了空间调制和传统天线选择的本质差别:天线选择的索引由信道状态决定,接收端事先不知道;空间调制的索引由数据比特决定,接收端要靠检测恢复出来。这一行差别,让空间调制从「降低复杂度手段」变成了「面向分集的调制方式」。
2.2 比特到激活天线的映射:一个四位 QPSK 的完整例子
发射端的全部工作,就是把 m 个比特拆成两组:索引比特和调制比特。假设 Nt=4、调制方式为 QPSK,m = log2(4) + log2(4) = 4 bit/时隙。前 2 bit 决定激活天线编号,后 2 bit 决定星座符号。
我在仿真里一般维护一个显式的映射字典,而不是用位运算直接拼,原因有两个:一是格雷码映射和二进制映射在误码率上的表现不同,显式字典更好改;二是接收端解调时可以复用同一个字典,避免「发射端用二进制、接收端用顺序编号」这种低频但致命的错误。QPSK 的星座点我取 1+1j、1-1j、-1+1j、-1-1j 除以 sqrt(2),保证平均发射功率为 1。
下面是一个 4 天线 QPSK 的映射示例。索引比特 00 对应 0 号天线,01 对应 1 号天线,10 对应 2 号天线,11 对应 3 号天线;后 2 bit 00 映射到 (1+1j)/sqrt(2),01 映射到 (1-1j)/sqrt(2),10 映射到 (-1+1j)/sqrt(2),11 映射到 (-1-1j)/sqrt(2)。
合成后的发射向量 x 是一个长度为 4 的向量,例如索引 bit 为 01、调制 bit 为 11 时,x = [0, (-1-1j)/sqrt(2), 0, 0]。发射功率仍然只有单符号功率,没有因为向量长度是 4 而变大,这是空间调制不同于传统波束成形的一个关键点:它不累加功率,只选择信道。
2.3 接收端的检测问题:联合估计激活天线编号和星座符号
接收信号模型是 y = H x + n。这里 H 是 Nr x Nt 的瑞利衰落信道,n 是复高斯白噪声。x 的结构很特殊:只在一个位置上有非零值。
ML 检测是性能基准:对所有可能的 (k, s) 组合做全搜索,取使欧氏距离最小的候选。用公式表达就是 \hat{k}, \hat{s} = argmin_{k, s} || y - h_k s ||^2。这里的 h_k 是 H 的第 k 列,也就是被激活天线对应的信道响应。
这个目标函数揭示了一个重要性质:天线的索引检测和符号检测耦合在一起。如果你先检测 k 再解 s,等于把一个二维搜索问题拆成了两个一维问题,会损失联合最优性;而 ML 把符号的相位信息也用于天线判决,所以它在高 SNR 下比「先索引后符号」的两级检测有明显增益。这个增益在多大 SNR 上出现,取决于发射天线数和调制阶数。
当调制阶数升高时,符号部分的误码率下降变慢,索引部分却因为天线联合搜索的存在而受益。结果就是,你需要分别统计「索引比特误码率」和「符号比特误码率」,才能判断当前曲线到底被哪部分卡住。后文避坑章节会单独讲这一点,这里先记住结论:空间调制的 BER 曲线不是一条单调光滑指数曲线,它有两个不同斜率段。
3. 跑通一个最小空间调制仿真:Python 脚本、参数表与三条必检曲线
3.1 仿真脚本:发射、瑞利信道、ML 检测
我会直接用 numpy 写一个最小可运行版本,去掉一切框架依赖,方便你把它放进自己的链路里改。整个流程分为五个函数:星座生成、比特到发射向量映射、ML 检测、单帧传输和 BER 统计。
import numpy as np # ========== 1. 星座生成:QPSK / 16QAM ========== def get_constellation(order): if order == "qpsk": const = np.array([1+1j, 1-1j, -1+1j, -1-1j]) / np.sqrt(2) elif order == "16qam": # 16QAM 需要做功率归一化,平均功率 = 1 a = np.array([-3, -1, 1, 3]) const = np.array([x + 1j*y for x in a for y in a]) / np.sqrt(10) else: raise ValueError("unsupported modulation order") return const # ========== 2. 发射端比特映射 ========== def bits_to_sm_symbol(bits, Nt, const): # bits 是一维数组,长度 = log2(Nt) + log2(M) idx_len = int(np.log2(Nt)) sym_len = int(np.log2(len(const))) k = int("".join(map(str, bits[:idx_len])), 2) s_idx = int("".join(map(str, bits[idx_len:])), 2) x = np.zeros(Nt, dtype=complex) x[k] = const[s_idx] return x, k, s_idx # ========== 3. 接收端 ML 检测 ========== def ml_detect(y, H, const): Nt = H.shape[1] metric_min = np.inf k_hat, s_hat = -1, -1 for k in range(Nt): h_k = H[:, k] for s_idx, s_val in enumerate(const): metric = np.sum(np.abs(y - h_k * s_val) ** 2) if metric < metric_min: metric_min = metric k_hat, s_hat = k, s_idx return k_hat, s_hat # ========== 4. 单帧传输 ========== def simulate_frame(rng, Nt, Nr, const, noise_var): idx_len = int(np.log2(Nt)) sym_len = int(np.log2(len(const))) m = idx_len + sym_len bits = rng.integers(0, 2, size=m) x, k_tx, s_tx = bits_to_sm_symbol(bits, Nt, const) H = (rng.normal(size=(Nr, Nt)) + 1j * rng.normal(size=(Nr, Nt))) / np.sqrt(2) n = (rng.normal(size=Nr) + 1j * rng.normal(size=Nr)) * np.sqrt(noise_var / 2) y = H @ x + n return bits, y, H, k_tx, s_tx这一版脚本故意把一帧一帧的逻辑写出来,方便初学者理解,没有做批量矩阵加速。跑完整个 SNR 列表后,你会看到 BER 曲线在低信噪比段下降慢、高信噪比段下降快的形态,这正是空间调制区别于传统 QPSK 单天线链路的第一个视觉信号。
ML 检测里的两层循环对应候选空间的所有 (k, s) 组合。候选总数为 Nt 乘以星座点数。Nt=4、QPSK 时是 16 次范数计算,每一帧也就是 16 次向量操作,在 Python 里完全可接受。如果你要跑到 64 天线、16QAM,候选数变成 1024,这时代码需要向量化或改成球形译码。
注意:当前脚本是逐帧循环,追求可读性,不是性能最优。跑完整个 SNR 列表后可以再用向量化改造,但先确认算法逻辑正确再优化更稳。
3.2 关键参数表与 Eb/N0 换算
仿真里最容易出问题的是信噪比定义。空间调制每个时隙发送 m 个比特,m = log2(Nt) + log2(M),因此每个符号携带的能量是每比特能量的 m 倍。你如果想画 Eb/N0 曲线,不能直接把 esn0_db 当 ebn0_db 用,否则曲线会整体左移 10*log10(m) dB。我一般是先定 Eb/N0,再按 m 换算成 Es/N0 再生成噪声:
def ebn0_to_esn0(ebn0_db, Nt, mod_order_len): # mod_order_len = log2(M),例如 QPSK 是 2 m = int(np.log2(Nt)) + mod_order_len return ebn0_db + 10 * np.log10(m)噪声功率计算公式是 noise_var = 10 ** (-esn0_db / 10)。因为发射信号的平均功率为 1,所以在给复数信号加噪声时,实部和虚部各自使用的方差是 noise_var / 2,也可以直接生成复高斯噪声再乘 sqrt(noise_var/2)。这两种写法等价,但后者更不容易漏掉功率因子。
| 参数 | 取值 | 说明 |
|---|---|---|
| Nt | 4 | 发射天线数,必须为 2 的幂 |
| Nr | 4 | 接收天线数 |
| 调制 | QPSK / 16QAM | 星座点数 M 对应 log2(M) 个调制比特 |
| SNR 列表 | -5, 0, 5, 10, 15, 20, 25 dB | 按 Eb/N0 定义 |
| 每信噪比帧数 | 2000 起,可按错误帧数扩展 | 低 BER 处必须收够错误样本 |
| 随机种子 | 固定 seed,按 SNR 分段 | 保证可以复现曲线 |
如果你要比较不同 Nt 下的性能,我建议把横轴统一成 Eb/N0 而不是 Es/N0,因为随着 Nt 增大,m 变大,同样的 Es/N0 对应的每比特信噪比是下降的,曲线会误导人。
3.3 第一次跑完该看到的曲线形态
用上面脚本跑完,你应该看到三条我反复检查的曲线。第一条是 4 天线 QPSK 空间调制在 ML 检测下的 BER 曲线,它在 Eb/N0 约为 15 dB 时进入 1e-4 量级,比 2x2 V-BLAST 略差,但明显好于单天线的 1x1 QPSK。第二条是换成 16QAM 后,曲线整体右移,这是符号检测变难造成的。第三条是保持 QPSK 不变、把 Nt 改成 8,你会发现索引比特占比从 1/2 变成 3/5,高信噪比段的下降斜率变缓。
第三条曲线的变化值得单独看:天线数翻倍,索引携带的比特只多了一个,但索引出错的代价提升。因为一次天线索引错误会错掉 idx_len 个比特,而一次符号错误只错掉 sym_len 个比特(理想星座下通常只有 1 个或 2 个格雷码相邻比特)。在高信噪比下,符号错误已经很少,剩余的错误大量来自天线索引。这就是空间调制 BER 曲线上常见的「平台段」——不是译码算法问题,而是索引比特的检测难度天然比符号比特高。
4. 接收机检测算法怎么选:ML、ED、MMSE 的复杂度与性能边界
4.1 ML 联合搜索:性能基准与复杂度量级
ML 检测把索引和符号放进同一个目标函数,理论上是空间调制的最优接收机。它的复杂度是 O(Nt * M) 次欧氏距离计算。Nt=4、M=4 时只有 16 次,很轻松;Nt=64、M=16 时是 1024 次,加上信道向量长度 Nr=64,每帧要做 65536 次复数乘法,实时实现就不太现实了。
我在做算法预选时会把候选表预计算出来,避免每帧重复枚举星座点。候选表的每一项是 (天线编号, 星座符号索引, 星座符号值),提前展开成列表,检测时只遍历这个表:
def build_candidate_table(Nt, const): table = [] for k in range(Nt): for s_idx, s_val in enumerate(const): table.append((k, s_idx, s_val)) return table用这张表跑 ML,代码逻辑更清晰,也便于后续换成球形译码或列表检测。需要注意的是,ML 的性能基准建立在「每个候选等概率、信道已知」的假设上,任何硬件实现如果引入信道估计误差、IQ 不平衡或功放非线性,曲线会先在某些 SNR 处偏离这一基准。
4.2 ED 能量检测:很多时候够用,省两个数量级
所谓 ED 检测,就是先不管符号,先找哪根天线最可能被激活。对每根天线做匹配滤波,用自身信道功率归一化后取能量最大的那根:
\hat{k} = argmax_k |h_k^H y|^2 / ||h_k||^2
然后基于选出的信道再做一次符号切片。
这种两级检测把联合 ML 拆成了独立步骤。复杂度从 O(Nt * M) 降到 O(Nt * Nr) 的匹配滤波加 O(M) 的符号切片,在高阶调制下能省一到两个数量级。性能上,ED 相对 ML 通常损失 1 到 2 dB,具体多少取决于信道相关性和天线数。当天线数增多时,ED 的损失会加大,因为不同天线信道之间的独立性下降,能量判决更容易混淆。
ED 的实现代码不复杂:
def ed_detect(y, H, const): Nt = H.shape[1] power = np.zeros(Nt) for k in range(Nt): h_k = H[:, k] mf = np.vdot(h_k, y) # 匹配滤波 power[k] = np.abs(mf) ** 2 / (np.vdot(h_k, h_k).real) k_hat = int(np.argmax(power)) z = np.vdot(H[:, k_hat], y) / (np.vdot(H[:, k_hat], H[:, k_hat]).real) s_hat = int(np.argmin(np.abs(const - z))) return k_hat, s_hat功率归一化的分母我在仿真里始终保留。漏掉它,不同天线增益差异会直接混进判决量,在高 SNR 时出现不该有的错误地板。这也是我后来总结的一类「看着曲线不对先查归一化」的问题。
4.3 MMSE 检测:在空间调制里,它并不是默认选项
进入工程选型时,很多人会习惯性把 MIMO 里的 MMSE 检测搬过来。这能跑,但要清楚它为什么不是空间调制的好选择。MMSE 滤波解的是 y = H x + n 中的 x,给出的估计 x_hat 是 Nt 维稠密向量。空间调制要求 x 只有一根非零,MMSE 没有这个先验,所以它会往所有天线上分配噪声和能量,再用 argmax 取最大的一路,这实际上就是 ED 的线性版本,而且把矩阵求逆复杂度也带了进来:
def mmse_sm_detect(y, H, const, noise_var): Nt = H.shape[1] gram = H.conj().T @ H + noise_var * np.eye(Nt) W = np.linalg.solve(gram, H.conj().T) x_hat = W @ y k_hat = int(np.argmax(np.abs(x_hat))) z = x_hat[k_hat] s_hat = int(np.argmin(np.abs(const - z))) return k_hat, s_hat这个实现的不合理之处在于:MMSE 滤波本身没有利用「只有一根天线发射」的稀疏结构,当 Nt 增大时,滤波后噪声会在所有天线上铺开,激活天线的能量优势被稀释。仿真中它的性能通常比 ED 还差 0.5 dB 以上,只在高阶调制且 SNR 较高时与 ED 接近。
如果你确实需要比 ED 更好的性能又付不起 ML 的复杂度,业界更常见的方向是 OMP 类稀疏恢复检测,或者基于深度展开的迭代检测。它们的共同点是显式地把「稀疏」写进模型。我在工程里倾向的排序是:实时低功耗用 ED,性能优先用 ML,想研究新方向再上稀疏恢复。
| 检测算法 | 复杂度量级 | 相对 ML 损失 | 适用场景 |
|---|---|---|---|
| ML | O(Nt * M) | 0 dB | 性能基准、小规模天线 |
| ED | O(Nt * Nr + M) | 1~2 dB | FPGA、低功耗接收机 |
| MMSE 滤波 + argmax | O(Nt^3 + Nt * Nr) | 1.5~3 dB | 传统 MIMO 代码复用,不利于 SM |
| OMP | O(Np * Nt * Nr) | 约 1 dB | 稀疏信道、压缩感知方向 |
近两年论文里还有用 CNN 做 SM 检测的方向,思路是把接收信号实虚部堆成两张图,网络输出天线激活概率分布和符号概率。实测增益集中在信道相关性高或 IQ 失衡的场景,普通瑞利信道下并不比 ED 明显好。我不建议在链路仿真阶段就引入学习模型,先用上述三种算法把系统上限摸清,再决定要不要加复杂模型。
5. 空间调制仿真与联调避坑:5 个必须排查的点
5.1 收发两端天线索引映射顺序不一致
现象:BER 曲线在中高信噪比段下降变慢,甚至出现平台,低信噪比段却完全正常。
原因:发射端按二进制序号映射天线,接收端的判决表却按格雷码或顺序编号排列,相当于大部分帧在索引比特上系统性判错。在低 SNR 时噪声主导,这种系统性错误被噪声淹没;SNR 升高后噪声变小,系统性错误就成了天花板。
解决:把天线编号表和星座映射表都写成全局常量,发射和接收共用。我一般在代码里加一行 assert,检查 idx_len 个比特解码出的天线编号小于 Nt,并打印一个自检帧,确保收发端对同一组比特得到同一个 k。
5.2 信道归一化与发射功率定义不一致
现象:改 Nt 或 Nr 参数后,整条 BER 曲线左右平移,平移量正好等于 10*log10(Nt) 的倍数。
原因:发射向量 x 里只有一个非零符号,平均功率是 1,但有人习惯性做了 x / sqrt(Nt) 的功率归一化,把发射功率削掉了;或者是 H 的生成里给每列乘了 1/sqrt(Nt)。空间调制的基本假设是「激活天线以单位功率发送」,信道矩阵 H 的单位是 1。
解决:统一约定 H 元素服从 CN(0,1),发射符号平均功率为 1,噪声功率按 Es/N0 生成。改完参数后先跑一个 Nt=1 的对照,若对照与单天线 QPSK 的理论曲线一致,说明天线的归一化链没问题。
5.3 信噪比定义混用:Es/N0 当成 Eb/N0
现象:曲线与理论值差了固定 dB,比如 4 天线 QPSK 差了 6 dB,而且不随 SNR 变化。
原因:m = log2(Nt) + log2(M) = 4,每符号承载 4 bit,Es/N0 比 Eb/N0 高 10*log10(4) = 6 dB。如果仿真脚本里直接把外部输入的信噪比当 Es/N0 用,再把 BER 画在 Eb/N0 横轴上,就会出现整体平移。
解决:在参数入口处强制区分两种信噪比。我习惯画图前统一换算成 Eb/N0,换算公式就是 esn0_db = ebn0_db + 10*log10(m)。多个调制阶数对比时,这条换算决定曲线能不能横向可比。
5.4 蒙特卡洛统计样本不够导致曲线抖动
现象:BER 低于 1e-3 后曲线呈锯齿状,同一个 SNR 跑两次结果不同。
原因:统计样本太少。BER 是二项分布估计,要测到 1e-4 量级,至少需要收到几十到上百个错误帧,否则估计方差太大。有些人会在低 SNR 段就设置大帧数导致仿真跑太慢,有些人则在高 SNR 段用固定小帧数导致曲线乱跳。
解决:给每个 SNR 设置两个停止条件,错误帧数达到 100 或最大帧数达到上限,谁先满足谁停。脚本里用 while 而不是固定 for 循环。低 SNR 段帧数可以少一点,高 SNR 段以错误数为主,这样总耗时反而更短。还要固定随机种子,否则你无法判断曲线差异来自算法改动还是随机抖动——这个问题经常坑到做对比实验的人。
5.5 索引比特误码和符号比特误码混在一起看
现象:BER 曲线中段的斜率出现「转折」,上半段下降快,下半段下降慢,疑似错误地板。
原因:空间调制的总误码率是索引比特误码率和符号比特误码率的加权和。天线索引出错的概率不会像符号检测那样随 SNR 指数下降,它受最小信道距离约束,下降斜率更缓。当符号错误被抑制后,剩下的就都是索引错误,曲线看起来就像有了地板。
解决:统计时分两本账,分别记录索引比特错误数和符号比特错误数。打印出来检查:如果索引错误占了大头,说明当前工作点是索引受限区,别在符号检测算法上浪费时间,应当去优化天线映射或做发射端天线选择。
6. 从空间调制到广义空间调制:扩展方向与性能验证习惯
空间索引调制再往前走一步,就是每次激活多根天线的广义空间调制(GSM)。激活 Np 根天线时,索引携带的比特数是 log2(C(Nt, Np))。这里组合数的比特容量不一定正好是 2 的整数次幂,所以需要做映射截断。我一般先用 itertools 枚举所有组合,再截断到小于等于组合数的最大 2 的幂:
import itertools def build_gsm_activation_map(Nt, Np): combos = list(itertools.combinations(range(Nt), Np)) num_usable = 1 << int(np.floor(np.log2(len(combos)))) combos = combos[:num_usable] n = int(np.log2(num_usable)) bit_to_combos = { format(i, f"0{n}b"): combos[i] for i in range(num_usable) } return bit_to_combos这段代码的细节是:组合顺序用字典序,截断操作发生在组合列表末端而不是开头,能保证截断不会破坏字典序稳定性。多天线同时激活时,发射功率要在 Np 根天线之间等分,也就是每个符号幅度乘 1/sqrt(Np),这个因子漏掉会让低 SNR 段立刻出现功率偏差。
验证一个 SM 或 GSM 方案是否值得做,我的习惯是画频谱效率对所需 SNR 的曲线,横轴 Eb/N0,纵轴 m bit/s/Hz。相比纯 BER 曲线,这张图能同时看出「多天线带来的每符号比特提升」和「为这些比特付出的功率代价」。如果 GSM 比 SM 多了 0.5 bit/s/Hz 却要多付 3 dB,那在工程上就不值得换。
最后说一个我自己的教训:早期做 SM 仿真时我从不在配置里写随机种子,换机器跑结果就对不上,排查半天才意识到是 MC 样本不同。后来我把随机种子、信道归一化因子和信噪比换算函数统一写进一个 config.py,每次实验前打印一次配置摘要。这套习惯帮我少走了很多玄学弯路。希望帮到你。
本文还有配套的精品资源,点击获取