简介:本资源面向无线通信与信号处理方向的学习者与研究人员,提供OFDM-IM(索引调制正交频分复用)系统的MATLAB仿真源码,用于理解并验证这一在4G LTE、Wi-Fi及5G NR等系统中广泛应用的调制技术。OFDM-IM在传统幅度相位调制基础上,利用子载波激活与非激活状态额外承载信息,从而降低能耗并获得编码增益。压缩包内共1个文件,为OFDM_IM.m脚本,整体约1KB,代码通常涵盖比特流生成、星座映射与子载波激活模式编码、IFFT变换、循环前缀添加、高斯白噪声与多径衰落信道建模,以及接收端FFT、激活状态检测、信道估计均衡与误比特率评估等完整链路。已有1892人学习下载,读者可据此复现仿真流程,通过调整子载波数量、激活模式复杂度与信噪比等参数,观察系统在不同条件下的误码率与频谱效率表现,为算法优化与系统设计提供参考。
1. 索引调制 OFDM:把“开哪些子载波”也变成信息比特
做无线通信基带的同行,大概率都遇到过这个尴尬:频谱就那么宽,功率就那么点,传统 OFDM 靠高阶 QAM 硬堆速率,结果 PAPR 飙高、误码率在低信噪比区间直接趴窝。索引调制 OFDM(OFDM-IM)换了个思路——不再让所有子载波都忙着传星座点,而是每个子块里只激活一部分子载波,“哪些子载波被激活”这件事本身就携带比特信息。换句话说,信息被拆成两路:一路走星座符号,一路走激活图样(索引)。这就是索引调制 OFDM 最核心的卖点,也是 OFDM-IM 和普通 OFDM 分道扬镳的地方。它适合谁?适合手里有 OFDM 链路、想在低信噪比下抠出增益、又不想大改射频前端的基带工程师。这篇笔记就按“原理怎么立住 → 最小链路怎么跑通 → 参数怎么调 → 坑在哪”的顺序,把索引调制 OFDM 从标题落到能复现的代码上。
2. 索引调制 OFDM 的比特映射:激活图样到底怎么算
2.1 子块划分与激活组合数
OFDM-IM 的第一步是把 N 个可用子载波切成 G 个子块,每块 n 个子载波。每个子块里只激活 k 个,剩下 n−k 个置零。激活图样总数是组合数 C(n,k),能携带的索引比特数就是 floor(log2(C(n,k)))。这里有个容易被忽略的点:C(n,k) 往往不是 2 的整数次幂,所以会有一部分图样用不上,直接丢弃。比如 n=4、k=2,C(4,2)=6,只能映射 4 种图样(2 比特),剩下 2 种图样是“浪费”的。这个浪费率直接决定索引调制的频谱效率,选参数时必须先算清楚。
星座比特这边,每个激活子载波承载 M 阶星座,k 个激活子载波贡献 k·log2(M) 比特。所以一个子块的总比特数是:
m_total = floor(log2(C(n,k))) + k * log2(M)拿 n=4、k=2、QPSK(M=4)举例:索引比特 2 位,星座比特 2×2=4 位,合计 6 位/子块。而同样 4 个子载波的传统 OFDM 用 QPSK 只有 8 位——等等,这里 OFDM-IM 反而少了?对,这就是索引调制的代价:频谱效率通常低于同阶传统 OFDM,它换的是误码率性能和 PAPR 改善。想清楚这个 trade-off,再决定要不要上。
2.2 比特到激活图样的映射代码
下面这段 Python 做的是最基础的一步:把输入比特流按子块切开,前段查表选激活图样,后段映射星座符号。用查表法而不是实时算组合,是因为组合数在 n 不大时枚举完全可行,查表更快也更不容易出错。
import numpy as np from itertools import combinations def build_lookup(n, k): """枚举所有激活图样,返回 {索引: 激活位置元组} 和反向表""" patterns = list(combinations(range(n), k)) # 只保留 2 的整数次幂个图样,多余的丢弃 num_used = 2 ** int(np.floor(np.log2(len(patterns)))) patterns = patterns[:num_used] idx_to_pat = {i: p for i, p in enumerate(patterns)} pat_to_idx = {p: i for i, p in idx_to_pat.items()} return idx_to_pat, pat_to_idx, num_used def bits_to_symbols(bits, n, k, M, idx_to_pat): """bits: 一维 0/1 数组; 返回每个子块的频域符号""" bits_per_idx = int(np.floor(np.log2(len(idx_to_pat)))) bits_per_sym = int(np.log2(M)) bits_per_block = bits_per_idx + k * bits_per_sym num_blocks = len(bits) // bits_per_block qam = np.arange(M) # 简化星座,实际用格雷映射 out = [] for b in range(num_blocks): seg = bits[b*bits_per_block:(b+1)*bits_per_block] idx_bits = seg[:bits_per_idx] sym_bits = seg[bits_per_idx:] # 索引比特转十进制,查激活图样 idx_val = int(''.join(map(str, idx_bits)), 2) active = idx_to_pat[idx_val] block = np.zeros(n, dtype=complex) for j, pos in enumerate(active): s_bits = sym_bits[j*bits_per_sym:(j+1)*bits_per_sym] s_val = int(''.join(map(str, s_bits)), 2) block[pos] = qam[s_val] out.append(block) return np.array(out)逻辑说明:build_lookup负责枚举并截断图样,bits_to_symbols按子块处理。参数上,n和k决定索引比特数,M决定星座比特数,bits_per_block必须整除输入比特长度,否则最后一块要补零——补零位置在接收端要能识别,通常靠固定帧长解决。注意星座这里用了简化的整数映射,真实系统要换成格雷映射的 QAM,否则误码率会差一截。
2.3 接收端检测:对数似然比怎么算
接收端拿到频域信号后,要对每个子块做联合检测:既要猜激活了哪些子载波,又要猜星座点。最优做法是最大似然,但复杂度随 C(n,k)·M^k 爆炸。工程上常用的是逐子载波算对数似然比(LLR),再送信道译码。下面给出单子块 LLR 的核心计算:
def block_llr(y, H, n, k, M, noise_var, idx_to_pat): """y: 接收频域符号; H: 对应信道估计; 返回每比特 LLR""" bits_per_idx = int(np.floor(np.log2(len(idx_to_pat)))) bits_per_sym = int(np.log2(M)) llr = np.zeros(bits_per_idx + k * bits_per_sym) # 枚举所有合法图样和星座组合,算欧氏距离 candidates = [] for idx_val, active in idx_to_pat.items(): # 每个激活位置遍历 M 个星座点 for sym_combo in np.ndindex(*([M]*k)): block = np.zeros(n, dtype=complex) for j, pos in enumerate(active): block[pos] = sym_combo[j] d = np.sum(np.abs(y - H*block)**2) / noise_var candidates.append((idx_val, sym_combo, d)) # 对每个比特,取该比特为 0/1 的最小距离做差 # 此处省略逐比特归并细节,核心是 min-sum 近似 return llr # 实际需按比特位归并参数说明:noise_var是噪声方差,估计不准会直接拉低 LLR 质量;H是信道估计,索引调制对信道估计误差比传统 OFDM 更敏感,因为激活图样的判决依赖子载波间的相对幅度。复杂度上,n=4、k=2、QPSK 时每个子块要枚举 6×16=96 种组合,还能接受;n 一大就必须上低复杂度检测,比如先做能量排序再局部搜索。
3. 用 Python 跑通一条 OFDM-IM 最小链路
3.1 发端:从比特到 OFDM 符号
把第 2 章的映射接上 IFFT 和循环前缀,就是一条完整的发端链路。下面这段代码把子块映射、子载波分配、IFFT、加 CP 串起来,跑完能直接看到时域波形和 PAPR。
import numpy as np def ofdm_im_tx(bits, N, G, k, M, cp_len): """N: 总子载波; G: 子块数; 每块 n=N//G""" n = N // G idx_to_pat, _, _ = build_lookup(n, k) blocks = bits_to_symbols(bits, n, k, M, idx_to_pat) # 拼成完整频域符号,注意子载波分配顺序 freq = blocks.reshape(-1) assert len(freq) == N, f"频域长度 {len(freq)} != {N}" # IFFT time = np.fft.ifft(freq) * np.sqrt(N) # 加循环前缀 tx = np.concatenate([time[-cp_len:], time]) return tx, freq def papr_db(tx): """计算 PAPR,单位 dB""" p = np.abs(tx)**2 return 10*np.log10(np.max(p)/np.mean(p))逻辑说明:ofdm_im_tx里blocks.reshape(-1)把 G 个子块拉平成一个 OFDM 符号的频域数据,这里假设子载波按顺序分配,实际系统可能要做交织或打孔。np.fft.ifft前的sqrt(N)是功率归一化,不加的话 PAPR 数值会偏。papr_db是最朴素的 PAPR 定义,工程上更常用 CCDF 曲线看统计特性,单次 PAPR 只能做粗判。
3.2 收端:去 CP、FFT、检测
收端是发端的逆操作,但多了信道和噪声。下面这段把 AWGN 信道、去 CP、FFT、LLR 检测串起来,重点看索引检测和星座检测怎么衔接。
def ofdm_im_rx(tx, N, cp_len, snr_db, H=None): """简化 AWGN 链路,H 为 None 时视为全 1""" # 加噪声 sig_power = np.mean(np.abs(tx)**2) noise_var = sig_power / (10**(snr_db/10)) noise = np.sqrt(noise_var/2) * (np.random.randn(len(tx)) + 1j*np.random.randn(len(tx))) rx = tx + noise # 去 CP rx = rx[cp_len:cp_len+N] # FFT freq = np.fft.fft(rx) / np.sqrt(N) if H is None: H = np.ones(N, dtype=complex) return freq, H, noise_var参数说明:snr_db是符号信噪比,noise_var按信号功率和 SNR 反推。H默认全 1 只适合纯 AWGN 验证,真实多径要换成估计出的频响。这里没写检测部分,是因为检测要按子块切分后调用第 2 章的block_llr,把freq按 n 切开逐块处理即可。跑通这条链路后,先看误码率随 SNR 的曲线,再对比同频谱效率的传统 OFDM,才能判断索引调制到底有没有赚。
3.3 参数怎么选:n、k、M 的取舍表
参数选择是 OFDM-IM 最容易翻车的地方,下面这张表把常见组合的索引比特数、总比特数和相对传统 OFDM 的效率列出来,方便直接对照。
| n | k | M | 索引比特 | 星座比特 | 总比特/子块 | 传统 OFDM 比特 | 效率比 |
|---|---|---|---|---|---|---|---|
| 4 | 2 | 4 | 2 | 4 | 6 | 8 | 0.75 |
| 4 | 3 | 4 | 2 | 6 | 8 | 8 | 1.00 |
| 4 | 2 | 16 | 2 | 8 | 10 | 16 | 0.625 |
| 8 | 4 | 4 | 6 | 8 | 14 | 16 | 0.875 |
| 8 | 2 | 4 | 4 | 4 | 8 | 16 | 0.50 |
从表里能看出,n=4、k=3、QPSK 是效率不打折的组合,但激活子载波多,PAPR 改善有限;n=8、k=4 效率比 0.875,索引比特多,适合对误码率敏感的场景。我一般会先锁定目标频谱效率,再反推 n 和 k,最后调 M。注意 k 不能太接近 n,否则激活图样区分度下降,检测容易错;k 也不能太小,否则索引比特太少,白忙一场。
4. 索引调制 OFDM 避坑:五个血泪踩坑记录
4.1 坑一:激活图样截断后没做比特对齐
现象:误码率曲线在低 SNR 下正常,高 SNR 下反而出现错误平台,怎么加功率都下不去。原因:C(n,k) 不是 2 的幂时,截断后的图样数num_used小于总组合数,但发端映射时如果没把“未使用图样”排除干净,收端枚举时又把它们算进候选,就会产生系统性误判。解决:发端和收端必须共用同一张idx_to_pat表,且表长严格等于2**bits_per_idx。建议把表固化后存成文件,收发两端加载同一份,别各自现算。
4.2 坑二:信道估计误差被索引检测放大
现象:传统 OFDM 在同样信道估计下误码率可接受,换成 OFDM-IM 后误码率明显恶化。原因:索引检测依赖子载波间的相对幅度和相位,信道估计的幅度误差会直接改变“哪个子载波更可能被激活”的判断,而传统 OFDM 只关心单点星座,对相对误差不敏感。解决:在子块内做信道响应的归一化,或者用块内平均信道做差分检测。实测中,把导频密度提高一档,OFDM-IM 的误码率能追回大半。
4.3 坑三:LLR 归并时把索引比特和星座比特混在一起
现象:译码后误码率比不译码还差,LLR 绝对值分布异常。原因:索引比特和星座比特的 LLR 量纲不同,索引比特的 LLR 来自图样距离差,星座比特来自星座点距离差,直接拼接送译码器会导致译码器权重失衡。解决:分别计算两类 LLR,按各自的噪声方差归一化后再拼接。更稳妥的做法是给索引比特 LLR 乘一个缩放因子,这个因子用仿真扫出来,通常 0.7 到 1.0 之间。
4.4 坑四:PAPR 改善被 CP 长度吃掉
现象:仿真里 PAPR 确实降了,但实际链路吞吐没提升。原因:OFDM-IM 的零子载波让时域符号能量分布更集中,但 CP 长度是按最大时延扩展定的,不会因为索引调制而缩短。PAPR 改善主要体现在功放回退,如果功放本来就工作在回退区,这个增益就体现不出来。解决:先确认功放工作点,再决定要不要为 PAPR 上 OFDM-IM。如果功放已经线性度很好,索引调制的卖点就只剩误码率,得重新算值不值。
4.5 坑五:子块划分和子载波交织顺序搞反
现象:AWGN 下正常,多径信道下误码率爆炸。原因:子块内的子载波如果相邻分配,多径频选会让整个子块一起衰落,激活图样全错。正确做法是子块内子载波尽量分散,或者子块间做交织。解决:在子载波分配阶段加一层交织,把子块内的子载波映射到相隔较远的频点。代价是信道估计要覆盖更宽的频段,导频设计要跟着改。
5. 进阶技巧:用能量排序把检测复杂度砍下来
最大似然检测在 n 稍大时就跑不动了,我一般用能量排序做预处理:先算每个子载波的能量 |y|²,按能量从高到低排序,只在前 k+1 个里搜激活图样。这个做法基于一个直觉——激活子载波的能量通常高于零子载波,但信道深衰落会让这个假设失效,所以要多留一个候选位。
def low_complexity_detect(y, H, n, k, M, noise_var, idx_to_pat): """能量排序 + 局部搜索的简化检测""" energy = np.abs(y)**2 order = np.argsort(energy)[::-1] # 只保留能量最高的 k+1 个位置作为候选激活位 cand_pos = set(order[:k+1]) best = None for idx_val, active in idx_to_pat.items(): if not set(active).issubset(cand_pos): continue # 激活位不在候选集,跳过 for sym_combo in np.ndindex(*([M]*k)): block = np.zeros(n, dtype=complex) for j, pos in enumerate(active): block[pos] = sym_combo[j] d = np.sum(np.abs(y - H*block)**2) / noise_var if best is None or d < best[0]: best = (d, idx_val, sym_combo) return best这段代码把候选图样从 C(n,k) 降到 C(k+1,k)=k+1 量级,n=8、k=4 时从 70 种降到 5 种,复杂度降一个数量级。代价是深衰落时可能把真正的激活位排到候选集外,误码率会有轻微损失。我的习惯是仿真时同时跑最大似然和这个简化版,看误码率差距是否在可接受范围,再决定上线用哪个。
验证方法上,别只看单次误码率,要跑 CCDF 看 PAPR 分布,跑 BER 随 SNR 曲线看斜率,再和同频谱效率的传统 OFDM 对比。如果索引调制在目标 SNR 区间没有明显优势,就别硬上——这个方向值不值得做,取决于你的功放回退空间和信道估计精度,不是所有场景都吃这套。我自己最早在一套窄带系统上试 OFDM-IM,结果因为信道估计太糙,误码率反而输给传统 OFDM,后来把导频加密才扳回来。希望帮到你。
本文还有配套的精品资源,点击获取