16QAM软解调:LLR计算、Python实现与FPGA移植
简介正交幅度调制QAM与软解调是数字通信中的关键实现环节。这套基于MATLAB的仿真代码包面向通信工程专业学生、科研人员以及算法工程师旨在帮助理解QAM调制星座映射、软比特生成及软判决解调的核心原理。压缩包共含8个m文件整体大小仅4KB均为可独立运行的MATLAB源码模块覆盖随机二进制序列生成、符号映射、软解调判决、星座图绘制和比特逆映射等功能结构清晰便于按函数逐个阅读与二次开发。通过运行仿真使用者能够直观对比硬判决与软判决的误码性能差异掌握对数似然比LLR的计算过程并了解软信息如何为Turbo码或LDPC码等纠错编码提供有效辅助特别适合在低信噪比场景下分析系统增益。该代码包已有515人学习下载可作为通信原理课程实验、毕业设计或移动通信系统仿真链路的基础参考工具。1. 16QAM 接收机里那看不到的 1~2dB就藏在“软解调”三个字里做 QAM 软解调最直接的理解是接收端不再把每个符号硬性判成确定的 0/1而是给每个比特一个“带符号、带大小”的可信度这个可信度就是软比特。我在一套 16QAM 仿真链路上见过很典型的现象硬判决解调在 SNR12dB 时 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(b0|r) / P(b1|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 做坐标每符号平均能量 Es10LLR 计算出来的量级会整体偏大到后面的噪声方差设置就对不上。正确处理是将一维电平除以 sqrt(10)让 Es1。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, dtypeint)这段代码的核心逻辑是把二维 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), dtypenp.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_symbols200000, seed7): 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) 转为线性噪声方差Es1所以 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, :]), axis1) 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/N0Es 已经归一化为 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 差了一个编码速率因子忘记乘上这个因子所有曲线都会横向移动而且移动量不固定。排查这类问题时先在 SNR20dB 的高信噪比下跑一遍如果 LLR 量级明显不符合预期八成是噪声功率换算的问题。4.2 星座归一化16QAM 的 Es 为什么是 10要不要除以 sqrt(10)星座点归一化是软解调里最基础也最容易被忽略的一步。16QAM 的一维电平集合是 {-3, -1, 1, 3}一维平均功率是 (9119)/4 5两维加起来每符号平均功率就是 10。如果不做归一化直接用电平集合生成星座图Es10而噪声方差还是按 Es1 算出来的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}2116QAM{-3,-1,1,3}10sqrt(10)64QAM{-7,-5,-3,-1,1,3,5,7}42sqrt(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 混用。生成噪声时实部和虚部各用了 varianceN0导致总噪声功率变成 2N0等效 SNR 降了 3dB。还有一种情况是把 SNR 的 dB 值直接代入了噪声方差公式比如 SNR12dB 时用了 12 而不是 10^(-12/10)LLR 量级会差出几十倍。解决在仿真入口加一个自检生成一段确定功率的信号加上噪声测量接收信号功率与理论输入功率的比值误差应该在 0.1dB 以内。另外在代码里明确区分 noise_var 的注释是这个量是“复噪声总功率”凡是涉及除以 2 的地方都追溯到复基带定义不要靠记忆来推。5.4 Monte Carlo 统计不足误码率曲线在 1e-5 处乱跳现象BER 曲线在 1e-4 以下的位置开始抖动同样的参数跑两遍结果差了 3 到 5 倍而且调大循环次数之后乱跳的区间往后移但始终存在。原因统计精度不够。BER1e-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 到 2dB64QAM 比 16QAM 的收益更明显。如果你正在做接收机基带算法或者准备把 QAM 调制解调链路往 FPGA 上搬软比特解调值得你认真投入。以前我贪快总是先把硬判决跑通再包一层软输出结果接口反复返工现在回头想一开始就按 LLR 设计全链路才是止血最快的方式。希望帮到你。本文还有配套的精品资源点击获取