简介:正交幅度调制(QAM)与软解调是数字通信中的关键实现环节。这套基于MATLAB的仿真代码包面向通信工程专业学生、科研人员以及算法工程师,旨在帮助理解QAM调制星座映射、软比特生成及软判决解调的核心原理。压缩包共含8个m文件,整体大小仅4KB,均为可独立运行的MATLAB源码,模块覆盖随机二进制序列生成、符号映射、软解调判决、星座图绘制和比特逆映射等功能,结构清晰,便于按函数逐个阅读与二次开发。通过运行仿真,使用者能够直观对比硬判决与软判决的误码性能差异,掌握对数似然比(LLR)的计算过程,并了解软信息如何为Turbo码或LDPC码等纠错编码提供有效辅助,特别适合在低信噪比场景下分析系统增益。该代码包已有515人学习下载,可作为通信原理课程实验、毕业设计或移动通信系统仿真链路的基础参考工具。
1. 16QAM 接收机里那看不到的 1~2dB,就藏在“软解调”三个字里
做 QAM 软解调,最直接的理解是:接收端不再把每个符号硬性判成确定的 0/1,而是给每个比特一个“带符号、带大小”的可信度,这个可信度就是软比特。我在一套 16QAM 仿真链路上见过很典型的现象:硬判决解调在 SNR=12dB 时 BER 卡在 1e-4 附近上不去,改成软比特送进 LDPC 解码器之后,同样条件下误码率直接往下走了两个量级。这份 QAM.zip 方案的核心,就是把 QAM 调制解调中的硬判决替换成软比特(LLR),覆盖从原理、Python 实现到参数设置、FPGA 移植的完整落地路径。
做无线物理层仿真、接收机基带算法,或者正在给 LDPC/Turbo 解码器配链路的人,是这套方案最直接的受益者。你不需要先跑通一整套通信系统才能验证软解调的价值,只要把硬判决那一段替换成软比特输出,误码率曲线的变化会立刻告诉你答案。
2. 软比特是怎么算出来的:LLR 公式、格雷映射与三层简化
2.1 硬判决把什么丢在了路上
先看硬判决做了什么。16QAM 接收端拿到一个复平面上的点 r,计算它到 16 个星座点的欧氏距离,取最近的那个星座点,再查表得到 4 个比特。这个过程只保留“哪个点最近”,距离本身被丢掉了。可距离恰恰是接收机最值钱的输出——它代表了这个判决有多可靠。
举个例子,r 落在两个相邻星座点的正中间,硬判决会强行选一个点,输出一组看上去很确定的比特;而软解调会诚实地告诉解码器:这两个比特我是蒙的,可信度接近 0。信道解码器拿到 0 附近的软信息时,会给这个位置打上问号,用别的比特把它纠回来;拿到硬判决时则把它当成事实,纠错时反而帮倒忙。
这一点在工程里很容易被低估。做过多年 AM 调制解调的人习惯用包络检波那套思路,觉得“解出来就是 0/1”,但在 QAM 里相位也在承载信息,硬判决天然丢掉了幅度和相位的置信度。我接过不少做光纤陀螺信号处理的同事的咨询,他们问随机调制怎么解调,说到底是从带噪信号里恢复载波与调制信息,跟 QAM 软解调是同一个母题:解调器输出的每个值,都应该既给出方向,又给出大小。
2.2 从条件概率到 LLR:软比特的数学定义
软比特的工程叫法很多,有人叫软信息,有人叫软判决输出,最常见的落地形式是 LLR(对数似然比)。它的定义是:
LLR(b) = ln( P(b=0|r) / P(b=1|r) )
LLR 的符号代表这个比特更倾向 0 还是更倾向 1,绝对值代表倾向有多强。LLR 为正表示该比特更可能是 0,为负表示更可能是 1,为 0 表示完全不确定。这个定义和概率论完全对应,解码器内部很多算法就是基于 LLR 的加减运算设计的。
在 AWGN 信道下,发送符号 s、接收符号 r 的条件概率满足:
P(r|s) ∝ exp( -|r - s|² / N0 )
其中 N0 是复基带等效噪声总功率。对每个比特 b,把所有“该比特为 0”的星座点记为集合 S0,把“该比特为 1”的星座点记为集合 S1,理论上需要计算:
LLR(b) = ln( Σ_{s∈S0} exp(-|r-s|²/N0) / Σ_{s∈S1} exp(-|r-s|²/N0) )
这个式子在实际实现里没人直接算,因为 exp 和 ln 的组合在 FPGA 和定点化环境里非常贵。工程上做的是 max-log 近似:把求和换成取最大项,即:
LLR(b) ≈ ( min_{s∈S1} |r-s|² - min_{s∈S0} |r-s|² ) / N0
这个近似在信噪比不太低时损失极小,换来的是只需要计算欧氏距离和求最小值的逻辑,硬件友好度大幅提升。这里有一个容易混淆的点要注意:LLR 表达式中分子的顺序取决于你定义的软比特符号。按上面的定义,如果到“该位为 0”的星座点的最小距离更小,LLR 为正,表示该位更倾向 0。代码实现时务必把符号约定固定下来,并在接解码器之前确认一致。
2.3 格雷映射为什么让软解调事半功倍
同样是 16QAM,映射表用格雷码还是自然码,软解调的实现复杂度完全不同。格雷码的规则是:相邻星座点之间只有 1 个比特翻转。这意味着“该位为 0”和“该位为 1”的两组星座点在复平面上各自形成连续区域,做 min 运算时不容易因为某个远点干扰而选错。
反过来,如果用自然编码,相邻星座点可能有好几个比特同时翻转,LLR 计算中的 min 会经常落在一些“看起来近、实际上语义远”的星座点上,软比特的可信度就乱了。这也是我在代码里坚持用格雷映射的原因——它不需要额外的纠错逻辑,只是把表排好,软解调质量就直接上了一个台阶。
QPSK、16QAM、64QAM 的格雷表可以统一写成“找到一维电平序列,按格雷序生成索引,再组合成二维星座”。QPSK 是一维电平 {-1, +1} 的组合,16QAM 是 {-3, -1, +1, +3} 的组合,64QAM 是 {-7,-5,-3,-1,+1,+3,+5,+7} 的组合。有了这个统一视角,后面实现 16QAM 时顺手就能扩展到 64QAM。
3. 用 Python 把 16QAM 软解调跑起来:最小工程与核心代码
3.1 最小工程拆成三个文件,FPGA 移植时只动一个
我一般会把软解调工程拆成三个文件:qam_const.py 负责星座表和格雷映射;soft_demod.py 负责 LLR 计算;run_sim.py 负责 AWGN 信道下的误码率仿真。这样拆的原因很直接:星座表是纯数据结构,仿真链路是测试环境,真正要被移植到 FPGA 上的核心逻辑只在 soft_demod.py 里,替换起来边界清楚。
qam_const.py 里最需要注意的是归一化。16QAM 星座点的平均功率是 10,如果直接用 -3、-1、+1、+3 做坐标,每符号平均能量 Es=10,LLR 计算出来的量级会整体偏大,到后面的噪声方差设置就对不上。正确处理是将一维电平除以 sqrt(10),让 Es=1。64QAM 对应除以 sqrt(42),QPSK 不需要除。这个细节直接决定 SNR 换算是否准确,后面第 4 章还会专门展开。
3.2 核心代码:星座表生成与软比特 LLR 函数
下面这个函数生成 16QAM 的格雷星座表,返回星座点列表和对应的 4 比特标签:
import numpy as np def qam16_setup(): # 一维电平,按平均功率归一化,16QAM 的平均功率为 10 levels = np.array([-3., -1., 1., 3.]) / np.sqrt(10.) # 一维格雷码索引:00 01 11 10 gray = np.array([0b00, 0b01, 0b11, 0b10]) const = [] labels = [] # 组合 I/Q 两路,I 路决定高两位,Q 路决定低两位 for i in range(4): for q in range(4): lab = (gray[i] << 2) | gray[q] labels.append([ (lab >> 3) & 1, (lab >> 2) & 1, (lab >> 1) & 1, lab & 1 ]) const.append(levels[i] + 1j * levels[q]) return np.array(const), np.array(labels, dtype=int)这段代码的核心逻辑是把二维 16QAM 拆成独立的 I 路和 Q 路。I 路取 levels 的第 i 个电平,Q 路取 levels 的第 q 个电平,组合成一个复数星座点。格雷码的 2 比特索引通过移位拼成 4 比特标签,其中高 2 位来自 I 路,低 2 位来自 Q 路。这样做的好处是星座点与比特标签的对应关系一目了然,排错时可以打印出来对照标准 16QAM 格雷星座图。
接下来是软解调函数:
def soft_demod(rx, const, labels, noise_var): # rx: 接收复数符号,形状 (N,) # const: 星座点列表 # labels: 每个星座点对应的比特标签,形状 (16, 4) # noise_var: 复基带等效噪声总功率,线性值 n_bits = labels.shape[1] n = len(rx) llr = np.zeros((n, n_bits), dtype=np.float64) for i in range(n): # 到 16 个星座点的欧氏距离 dist = np.abs(rx[i] - const) ** 2 # 对每个比特分别统计该位为 0/1 的最小距离 for b in range(n_bits): d0 = dist[labels[:, b] == 0].min() d1 = dist[labels[:, b] == 1].min() # max-log 近似:距离差除以噪声方差得到 LLR llr[i, b] = (d1 - d0) / noise_var return llr这段代码直接对应第 2 章推导的 max-log 近似公式。对每一个接收符号,先算出它到 16 个星座点的距离,再按比特位筛选出“该位为 0”和“该位为 1”的星座点各自的最小距离,两者相减除以噪声方差。这里面有个细节:代码里是 d1 减 d0,所以正值表示该比特更倾向 0。如果你的解码器约定正数表示 1,只需要把返回值取负即可,但全链路必须统一。
性能方面,这种双重循环的写法在仿真规模到 1e6 个符号时会比较慢。我的习惯是先保证正确性,跑通后再向量化:将 dist 改成二维矩阵,用 numpy 在轴方向上做 mask 和 min。实际提速能到几十倍,但初版不要为了优化牺牲可读性,尤其是你做 FPGA 移植时,这个循环结构和硬件里的遍历逻辑是能一一对应的。
3.3 接上 AWGN 链路,看软判决比硬判决实际多了多少收益
有了解调和调制函数,还需要一条完整的仿真链路,才能在误码率上直观看到差异。下面这段代码把随机比特映射成星座点、加高斯白噪声、分别做硬判决和软判决统计:
def run_snr(snr_db, n_symbols=200000, seed=7): rng = np.random.default_rng(seed) const, labels = qam16_setup() # 随机生成比特并映射为星座点索引 bits = rng.integers(0, 2, size=(n_symbols, 4)) idx_map = {tuple(lab): i for i, lab in enumerate(labels)} tx_idx = np.array([idx_map[tuple(b)] for b in bits]) tx = const[tx_idx] # SNR(dB) 转为线性噪声方差,Es=1,所以 var = 10^(-snr/10) noise_var = 10 ** (-snr_db / 10.0) # 复高斯噪声:实部虚部各占一半功率 noise = np.sqrt(noise_var / 2) * ( rng.standard_normal(n_symbols) + 1j * rng.standard_normal(n_symbols) ) rx = tx + noise # 硬判决误码率 rx_idx = np.argmin(np.abs(rx[:, None] - const[None, :]), axis=1) hard_bits = labels[rx_idx] hard_ber = np.mean(hard_bits != bits) # 软判决输出 LLR(本仿真中仅统计分布,不接解码器) llr = soft_demod(rx, const, labels, noise_var) soft_bit = (llr < 0).astype(int) soft_ber = np.mean(soft_bit != bits) return hard_ber, soft_ber, llr噪声功率的换算是这段代码里最容易出问题的地方。SNR 定义为 Es/N0,Es 已经归一化为 1,所以线性噪声方差直接取 10^(-SNR_dB/10)。生成噪声时,复高斯噪声的总功率是实部虚部功率之和,要让总功率等于 noise_var,实部和虚部的方差要各取一半,也就是代码里的 sqrt(noise_var/2)。
注意这里的 soft_ber 只是把软比特取符号后回退成硬判决来对比用的,真正接 LDPC 解码器时,喂进去的是完整的 LLR 数组,而不是取符号后的 0/1。这一点是软解调链路里最常见的接口错误,第 5 章会专门讲。从仿真结果看,通常在 SNR 较高的区间,软判决取符号后的 BER 和硬判决几乎一致,但 LLR 的绝对值分布差异巨大,这正是解码器能发挥增益的信息来源。
4. 参数怎么设才不回退:噪声方差、星座归一化与 LLR 限幅
4.1 噪声方差估计:用错了单位和量级,软解调直接退化成硬判决
软解调函数里唯一的“外部参数”是 noise_var,它的准确性直接决定 LLR 的量级。噪声方差设得偏大,LLR 整体被压小,解码器会认为所有比特都不可信,迭代收敛变慢;设得偏小,LLR 虚高,解码器对错误比特过于自信,纠错时反而被带偏。两者都会让软解调的实际收益缩水,严重时不如硬判决。
仿真环境里最稳妥的做法是用理论噪声方差,也就是根据当前 SNR 直接用公式计算,不要从接收数据里估计。但从接收数据估计在真实系统里躲不掉,常见做法是用导频符号或 M2M4 盲估计器。M2M4 的基本思路是利用接收信号的高阶矩分离信号功率和噪声功率,对 QAM 信号在中等信噪比下效果尚可,但低信噪比时偏差会明显放大 LLR 的缩水问题。
实际仿真中最常见的翻车点有三个:把 SNR 的 dB 值直接当成线性值代入;把实部单维噪声功率当成总功率用;以及 Eb/N0 和 Es/N0 混用。带编码的链路里 Eb/N0 和 Es/N0 差了一个编码速率因子,忘记乘上这个因子,所有曲线都会横向移动,而且移动量不固定。排查这类问题时,先在 SNR=20dB 的高信噪比下跑一遍,如果 LLR 量级明显不符合预期,八成是噪声功率换算的问题。
4.2 星座归一化:16QAM 的 Es 为什么是 10,要不要除以 sqrt(10)
星座点归一化是软解调里最基础也最容易被忽略的一步。16QAM 的一维电平集合是 {-3, -1, +1, +3},一维平均功率是 (9+1+1+9)/4 = 5,两维加起来每符号平均功率就是 10。如果不做归一化,直接用电平集合生成星座图,Es=10,而噪声方差还是按 Es=1 算出来的,LLR 就会整体膨胀 10 倍。
正确做法是把一维电平统一除以 sqrt(10),这样每符号平均功率变成 1。同理,64QAM 的一维电平集合是 {-7,-5,-3,-1,+1,+3,+5,+7},平均功率 42,要除以 sqrt(42)。QPSK 的 {-1,+1} 平均功率本来就是 1,不需要缩放。这个缩放会直接影响 LLR 的数值范围,进而影响后面定点化时的位宽选择。
| 调制方式 | 一维电平集合 | 每符号平均功率 | 归一化因子 |
|---|---|---|---|
| QPSK | {-1, +1} | 2 | 1 |
| 16QAM | {-3,-1,+1,+3} | 10 | sqrt(10) |
| 64QAM | {-7,-5,-3,-1,+1,+3,+5,+7} | 42 | sqrt(42) |
判断归一化是否正确,可以打印任意一个星座点,看它的模平方再求平均,结果应该落在 1 附近。更直接的方法是检查代码注释里的缩放因子与表格是否一致。我说句实在话,这个坑我见到的频率比想象中高得多,很多团队在 16QAM 上正常,一升到 64QAM 就出问题,多半是缩放因子没跟上。
4.3 把 LLR 限幅和定点位宽定下来:FPGA 移植前先改这几行
仿真里 LLR 是浮点数,理论上范围是负无穷到正无穷,但落到 FPGA 上必须做定点化。定点化要做两件事:限幅和定标。限幅是把 LLR 截断到一个合理范围,比如 [-8, +8] 或 [-16, +16]。有人会担心限幅丢信息,其实不会,因为解码器对绝对值很大的 LLR 敏感度极低,8 和 100 在多数迭代算法里效果几乎一样,真正影响决策的是 LLR 在 0 附近的小数值。
定标位宽方面,16QAM 配合 LDPC 解码器,我一般用 6 比特整数部分加若干小数字宽,总位宽 8 到 12 比特;64QAM 的 LLR 动态范围会大一些,建议比 16QAM 多留 2 比特。具体位宽跟解码器的输入接口强相关,没有通用值,但有一条经验:先浮点仿真记录 LLR 的实际分布范围,再按分布的 99.9% 分位数设定限幅值,这样既不过度截断,也不浪费位宽。
FPGA 实现时,noise_var 通常不是直接除,而是做近似倒数并移位。如果噪声方差变化范围不大,可以预计算 1/N0 再乘法;如果需要在宽动态范围内跟踪 SNR,一般会做对数域处理或者查表。无论哪种方案,只要记住一点:软解调对噪声方差的精度要求不高,偏差在 20% 以内对系统 BER 影响很小,不需要把精力花在高精度除法上。
5. QAM 软解调避坑实录:5 个让我翻过车的细节
5.1 映射表错位:BER 曲线在高信噪比区间“悬空”
现象:16QAM 软解调仿真跑出来,低 SNR 区间曲线正常,但 SNR 超过 14dB 后,BER 不再随 SNR 增加而下降,像被什么东西吊住一样悬在半空。排查半天发现是解码器的问题,其实映射表就错了。
原因:星座点顺序用了自然编码而不是格雷码。相邻星座点之间可能同时翻转多个比特,LLR 计算里的 min 运算选出的“最小距离”在语义上是错的,高信噪比时这个错误被放大,BER 掉不下去。硬判决对映射表的容忍度高一些,偶尔查表错几个点只是小幅劣化,但软解调依赖距离结构,映射表错误会直接毁掉 LLR 的可信度。
解决:打印星座图和标签,逐点检查相邻星座点之间是否只有 1 个比特翻转。最稳妥的方式是像我第 3 章的代码那样,先写一维格雷索引 [00,01,11,10],再组合成二维,而不是手写 16 行的映射表。手写表的笔误率太高,我手写过两次,两次都栽了跟头。
5.2 I/Q 相位旋转:软比特全反号,LDPC 迭代不收敛
现象:把仿真代码移植到 FPGA 上之后,LDPC 解码器怎么迭代都不收敛,输出 BER 长期在 0.5 附近;换回纯仿真环境又一切正常。对比波形发现自己的软解调模块输出好像没问题,但整个链路就是解不出数据。
原因:上下变频或数字混频时 I/Q 相位没对齐,最常见的是 Q 路符号反了,相当于星座图整体做了镜像翻转。格雷映射下这种翻转会造成部分比特的 LLR 符号反号,解码器拿到错误的软信息,越迭代越乱。硬判决链路对此不敏感,因为符号反了照样判出同一组比特,但软解调把符号当成了倾向性信息,一翻就全乱了。
解决:移植到硬件平台后,先不接解码器,用已知伪随机序列做硬判决对比,确认解调输出的星座点是否和发送端严格一致。确认无误后,再打印 LLR 的分布直方图,正常时 LLR 直方图应当在 0 的左右各形成一个峰,且左右峰的面积接近。如果直方图整体偏向一侧,优先查 I/Q 相位和符号约定。
5.3 噪声方差与 SNR 换算:3dB 系数与线性/dB 混用
现象:仿真曲线整体比理论值差 1.5 到 3dB,且这个差距在低 SNR 时更明显;有人把这个误差归结为 max-log 近似的损失,其实差这么多根本不是近似的问题。max-log 近似在高斯信道下的损失一般在 0.1dB 量级,3dB 的偏移只可能是参数换算错误。
原因:复基带噪声总功率 N0 和实部单维功率 N0/2 混用。生成噪声时,实部和虚部各用了 variance=N0,导致总噪声功率变成 2N0,等效 SNR 降了 3dB。还有一种情况是把 SNR 的 dB 值直接代入了噪声方差公式,比如 SNR=12dB 时用了 12 而不是 10^(-12/10),LLR 量级会差出几十倍。
解决:在仿真入口加一个自检:生成一段确定功率的信号,加上噪声,测量接收信号功率与理论输入功率的比值,误差应该在 0.1dB 以内。另外在代码里明确区分 noise_var 的注释是这个量是“复噪声总功率”,凡是涉及除以 2 的地方都追溯到复基带定义,不要靠记忆来推。
5.4 Monte Carlo 统计不足:误码率曲线在 1e-5 处乱跳
现象:BER 曲线在 1e-4 以下的位置开始抖动,同样的参数跑两遍,结果差了 3 到 5 倍,而且调大循环次数之后乱跳的区间往后移,但始终存在。
原因:统计精度不够。BER=1e-5 时,如果只发 1e5 个符号,理论上错误比特数不到 1 个,每轮仿真的错误数全是随机噪声决定的,曲线自然是跳的。很多人习惯固定外层循环次数,而不是固定错误比特数,导致高 SNR 下统计置信度极低。
解决:蒙特卡洛循环的停止条件改成“累计错误比特数达到至少 100 个,再停止”,而不是“跑满多少符号”。同时在仿真脚本里固定随机种子,方便不同代码版本之间对比。我还有个小习惯:把仿真分为粗扫和精扫两轮,粗扫用少点数定大致区间,精扫只在高 SNR 区间加大点数,省时间也不牺牲曲线质量。
5.5 把软比特在接口处截成硬判决:等于没做软解调
现象:整条链路明明用了 LLR 软解调模块,解码器效果却和硬判决一模一样,误码率曲线也几乎重合,整个软解调白做了。代码走查发现,软解调模块和解码器之间的接口函数里,有人加了一行取符号的代码,把 LLR 全部变成了正负 1。
原因:接口设计不当。很多团队的链路是先把硬判决跑通,再接软解调,两者共用一个接口结构。接口里保留了上个版本的老字段,新模块输出的 LLR 在这个字段里被隐式转成了 0/1。这个问题的隐蔽性在于所有模块都报“运行正常”,唯独收益不见了。
解决:从一开始就把软解调链路的接口设计成 LLR 数组,不兼容 0/1 硬判决。代码里不做任何取符号操作,LLR 以带符号浮点数组或定点数的形式一路送进解码器。责任人验收时加一条断言:检查解码器输入的数据中,有多少比例的值落在 0 附近且未发生硬截断,低于阈值就拒绝发布。
6. 从 16QAM 扩展到 64QAM:软比特模块的查表式改造与验证技巧
6.1 把软解调写成查表函数:16QAM 换 64QAM 只改一行表
第 3 章的 soft_demod 函数其实对调制阶数并不敏感,它只依赖 const 和 labels 两个参数。只要把星座表生成函数从 qam16_setup 换成 qam64_setup,软解调代码一行都不用动。我在实际项目里就是这么做的:星座表与解调逻辑彻底解耦,后续加 256QAM 也只是新增一张表的事。
唯一需要留意的是复杂度。64QAM 每个符号要算 64 个距离,每个比特要求 6 次 min,浮点仿真还好,FPGA 上纯遍历就比较吃资源。常见的优化是两级查表:第一级先粗判决落在哪个象限,第二级只在象限内及其相邻区域做精算。具体阈值怎么划,要看星座图和硬件资源,但查表式框架能保证优化前后接口不变,我在业界看到的商用方案也基本都是这个思路。
# 换成 64QAM 时只需要这一行 const, labels = qam64_setup() # qam64_setup 与 qam16_setup 结构相同 llr = soft_demod(rx, const, labels, noise_var)6.2 上线前用三个验证手段,避免反复返工
我在每个软解调模块上线前都会跑三个验证。第一个是看 LLR 直方图:噪声环境下,发送 0 和发送 1 的比特对应的 LLR 应当分别形成正负两侧的峰,峰之间有明显间隔,且两侧面积大致相等。如果直方图单峰或者整体偏移,说明符号约定或噪声方差有问题。第二个是硬软对照:在同样条件下,把软比特取符号后统计误码率,必须和独立写的硬判决误码率一致,这是验证解调逻辑本身正确的最快路径。第三个是先用 QPSK 落地,再去升 16QAM、64QAM。QPSK 的 LLR 计算简单到可以手推验证,如果 QPSK 都对不上,别急着调高阶调制。
这套软解调方案做到位之后,收益是可以量化的。配 LDPC 的链路上,从硬判决换成软比特,典型的增益在 1 到 2dB,64QAM 比 16QAM 的收益更明显。如果你正在做接收机基带算法,或者准备把 QAM 调制解调链路往 FPGA 上搬,软比特解调值得你认真投入。以前我贪快,总是先把硬判决跑通再包一层软输出,结果接口反复返工;现在回头想,一开始就按 LLR 设计全链路,才是止血最快的方式。希望帮到你。
本文还有配套的精品资源,点击获取