Turbo码这个题目在通信领域始终占着一个很特别的位置。它不像5G、WiFi那样经常出现在新闻里但凡是学过信道编码的人基本绕不开它。1993年它被公开报道时直接用仿真结果把编码界震住了码率1/2的情况下误码率达到10^-5所需信噪比比当时最好的级联码还要低2个多dB几乎贴到了香农极限边上。从一个做通信系统仿真的人的角度看Turbo码最值得研究的地方还不是那零点几个dB的性能而是它把一个看起来特别简单的思想——两个卷积码器并联再加迭代译码——做到了极致。这篇内容我会从“为什么需要它”讲起把编码器结构、交织器、迭代译码和BCJR实现掰开揉碎最后给出一套可以直接跑起来的Python仿真Demo。正在学信道编码、准备做毕业设计仿真、或者在校招面试前突击通信原理的人这篇应该能帮上忙。1. 从香农极限说起Turbo码到底解决了什么问题1.1 香农画了一个圈但没人知道怎么踩进去香农在1948年给出的信道编码定理本质上说的是任何一个有噪信道都存在一个容量C只要传输速率小于C就存在某种编码方案让错误概率无限趋近于零。这句话听起来很美好但它是存在性结论不是构造性结论。它告诉你可以做到却没有告诉你怎么做到。这就好比有人告诉你地下一定有金矿但没给你地图。在Turbo码出现之前工程界靠的是卷积码、分组码、级联码这些具体方案。它们确实能纠错但付出的代价很大。要么码率压得很低要么约束长度做得很长要么译码复杂度高到不太适合工程落地。当时实际系统离香农极限普遍还有2到5dB的差距。别小看这几个dB在无线通信里每节省1dB意味着发射功率可以降下来或者相同功率下覆盖半径能扩出去这是实打实的成本。Turbo码的第一贡献是把“可以接近香农极限”从理论变成了工程可实现的方案。它在不到1dB的信噪比区间内做到了10^-5以下的误码率这在那时候是前所未有的。后来LDPC码也做到了类似效果但Turbo码在3G、4G时代已经大量商用直到今天LTE和5G的某些控制信道里你依然能看到Turbo码的影子。1.2 Turbo码的整体思想分而治之再加互相纠错Turbo码全名是并行级联卷积码英文缩写PCCC。它的核心结构不复杂把同一段信息数据经过一个交织器打乱顺序后分别送入两个递归系统卷积码编码器。第一个编码器直接在原始顺序上生成校验位第二个编码器在交织后的顺序上生成校验位。发送的时候数据位只发一份两个编码器的校验位则可以按需要删余后组合发送。为什么这么设计一个卷积码器的纠错能力有限它对某些错误模式会“盲区”。如果把数据重新排列后再交给另一个独立的编码器两个编码器面对的错误分布就不一样了。第一个编码器没抓住的错误第二个编码器很可能能抓住。译码时两个译码器互相交换各自的判断信息一轮一轮迭代像两个医生会诊一样一个疑点被指出后另一个再复查直到整个病例被确认下来。这个“迭代”就是Turbo这个名字的由来——涡轮增压一圈一圈往里压气越压越有力。Turbo码把译码从单次硬判决变成了多次软信息交换这是它最革命性的地方。1.3 为什么Turbo码当年让学术界震动1993年的那次报道给出的是一个仿真结果不是严格的理论证明。但它做到了当时理论界认为非常困难的事情用一种复杂度可以接受的迭代译码方法逼近随机码才能达到的性能。很多人事后分析说Turbo码的真正突破不在于编码结构多精巧而在于它把“软输入软输出”译码这个概念变成了可迭代的引擎。从实现角度看Turbo码的编码器很简单两个移位寄存器加一个交织器就行。真正的复杂度在译码端每一轮迭代都要对整帧数据做一遍前向后向递推多轮迭代后计算量会放大好几倍。但相比它换来的性能增益这笔账是划算的。这也是它后来能在移动通信系统里大规模商用的根本原因。2. 编码端拆解两个RSC和一个交织器2.1 为什么分量编码器必须用RSC而不是普通卷积码Turbo码的分量编码器不是随便选一个卷积码就行的它要求使用递归系统卷积码也就是RSC。这个选择里有很深的门道。普通卷积码是非递归的输入比特直接参与输出移位信息位和校验位的关系是“有限冲击响应”。RSC则把输出反馈回输入端相当于把有限冲击响应变成了无限冲击响应。一个输入比特的“影响尾巴”会被拖得很长这在低码率场景下能明显改善距离谱特性。更关键的在于删余。Turbo码通常要删掉一部分校验位来提升码率。如果用非递归卷积码删余后容易出现很差的自由距离而RSC的内部反馈结构让信息比特的能量均匀地扩散到校验序列里即使删掉一半校验位纠错能力也不会断崖式下跌。打个比方非递归卷积码像一摞砖抽掉几块就可能塌RSC像钢筋混凝土抽掉一些钢筋整体结构还在。RSC的反馈多项式通常用八进制表示比如7表示1DD^25表示1D^2。不同多项式的选取会影响码的自由距离和迭代译码的收敛速度这也是Turbo码设计里一个可以反复优化的点。2.2 交织器让差错分布从“扎堆”变成“均匀”交织器是Turbo码里看起来不起眼、实际上决定性能上限的组件。它做的工作就是按照一张置换表把原始数据重新排列。为什么需要交织信道里的噪声干扰往往是突发的比如一个深衰落脉冲打过来连续几十个比特都被打坏。如果第一个编码器面对这串连续错误无能为力那第二个编码器如果也面对同样顺序的数据同样无能为力。交织器把顺序打乱之后第二个编码器看到的错误位置就分散到了不同时刻卷积码处理随机分布错误的能力就发挥出来了。交织器的设计有几个层次。最简单的就是伪随机交织器用随机数生成一张置换表。好一点的会用S随机交织器限制任意两个距离很近的位置在交织后不能离得太近从而保证局部随机性更好。再讲究一点的还有螺旋交织器、无冲突交织器它们各有各的应用场景。在仿真里我建议先用固定种子的伪随机交织器把整条链路调通后再换更复杂的交织器对比性能。一上来就用复杂交织器出了问题很难定位是交织器的问题还是译码器的问题。2.3 删余与码率调整用一点性能换传输效率两个RSC编码器如果不删余每个信息位会输出两个校验位加上一个系统位总码率是1/3。这在很多场景下太奢侈了所以实际系统几乎都会做删余。删余的原理很简单按照一定模式间隔地丢弃校验位。比如想得到1/2码率就保留第一个编码器的偶数位置校验位保留第二个编码器的奇数位置校验位。这样总输出比特数变成原来的2倍码率从1/3变成1/2。代价是距离特性变差同样信噪比下误码率会上升。删余模式也可以自由设计。不同模式对性能的影响是非线性的有时候保留某个位置的校验位优于保留另一个位置。我做仿真时通常会先跑一遍不删余的1/3码率版本确认整条链路正确后再打开删余对比这样能清楚看到删余带来的性能损失有多少。3. 迭代译码的机理软信息交换和BCJR3.1 为什么Viterbi译码在这里不够用Turbo码译码的第一要求是译码器必须输出软信息也就是每个比特的对数似然比LLR而不是直接给出0或1的硬判决。Viterbi译码解决的是序列最大似然估计问题它输出的是整条最优路径虽然也能给出软输出变体但要支撑Turbo码的迭代必须使用真正的软输入软输出算法。迭代的核心逻辑是第一个译码器把自己的“疑虑”告诉第二个译码器第二个译码器结合自己的校验信息给出更新后的“疑虑”再传回来。每一轮交换的都是概率意义上的软信息硬判决一旦做了信息就丢失了迭代就成了无源之水。这就像两个人核对账单如果一个人直接说“这行肯定错了”另一个人就只能被动接受但如果他说“这行我只有七成把握”另一个人就有空间结合自己的记录重新判断。Turbo码的性能增益全部来自这一来一回的软信息互动。3.2 BCJR算法前向、后向与分支度量的三重奏BCJR算法是Turbo码译码里的主力由它的几个提出者名字缩写而来。它的目标是为每个比特计算后验概率。算法分三步前向递推、后向递推、逐符号概率计算。前向递推从帧头开始维护每个网格状态下“到达该状态的路径累积概率”。后向递推从帧尾开始维护“从该状态出发走到帧尾的路径累积概率”。两者结合再加当前时刻的分支度量就能算出每个比特为0和为1的总概率。这个结构和隐马尔可夫模型的前向后向算法是同一个数学框架。实现时必须在对数域操作。直接乘概率会导致几百比特帧长的浮点下溢对数域把乘法变成加法动态范围就可控了。对数域里有个常见操作叫log-sum-exp可以用最大值加修正项近似这就是Max-Log-MAP和Log-MAP的区别。Max-Log-MAP实现简单但性能略差Log-MAP性能好但计算量大一些。3.3 外信息迭代译码的“秘密武器”BCJR算出来的LLR里实际上包含三部分信息来自信道系统位的直接观测、来自本译码器先验输入的信息、来自校验位约束的信息。迭代译码的关键操作是把后两者区分开。具体做法是本轮的软输出LLR减去信道系统位LLR再减去上一轮输入的先验LLR剩下的部分就是这个译码器“新学到的东西”叫外信息。外信息经过交织或解交织后送给另一个译码器作为下一轮的先验。为什么必须减掉先验信息如果不减两个译码器共享的信息会在迭代中被反复叠加放大最终导致正反馈失控误码率不降反升。这是Turbo码实现里最容易踩的坑之一。我见过不少同学在仿真里发现迭代第三次以后误码率反而上涨查到最后基本都是外信息没做干净把已用过的信息又绕回来了。4. 代码实现一个完整的Turbo码仿真Demo4.1 仿真系统的整体框架先交代一下我搭的仿真链路随机生成信息比特经过Turbo编码器得到码字BPSK调制映射到正负一过AWGN信道接收端做迭代译码统计误码率。这一条链路是理解Turbo码性能最简单有效的方式。演示里我选用码率1/3的不删余方案这样能减少一个变量先把原理跑通。分量编码器用约束长度3的RSC反馈多项式7前馈多项式5。交织器用固定种子的伪随机置换。迭代次数设4轮跑仿真时再增加次数观察增益。需要注意一点信噪比的定义要用Eb/N0也就是每个信息比特的能量除以噪声功率谱密度。码率1/3时每个码字比特能量是Eb的三分之一BPSK下噪声方差要按这个关系折算。很多第一次做信道编码仿真的人在这里算错导致整条误码率曲线偏移好几个dB。4.2 RSC编码器的Python实现我用的RSC编码器代码如下逻辑很简单逐个比特喂进去同时输出系统位和校验位。import numpy as np def rsc_encode(data, fb0b111, ff0b101): 约束长度3的RSC编码器。 参数: data: 0/1比特列表 fb: 反馈多项式八进制 ff: 前馈多项式八进制 返回: 系统位列表, 校验位列表 reg0, reg1 0, 0 sys_out, par_out [], [] for u in data: feedback u ^ reg0 ^ reg1 # 反馈抽头对应多项式 7 111 parity u ^ reg1 # 前向抽头对应多项式 5 101 sys_out.append(u) par_out.append(parity) # 寄存器右移 reg1 reg0 reg0 feedback return sys_out, par_out这个实现里最重要的是理解寄存器更新顺序先根据当前输入和寄存器状态计算反馈再计算校验位最后移位。如果顺序写反整个网格状态就对不上了。调试时可以在编码器后面打印几个固定输入序列的输出和手推的网格对比验证。4.3 交织器与主循环的搭建交织器我用固定种子的随机置换这样每次仿真结果可复现。译码时既需要正置换表也需要逆置换表所以一次性生成两个返回。def make_interleaver(n, seed7): 生成交织表与逆交织表n为帧长。 rng np.random.default_rng(seed) perm rng.permutation(n) inv np.empty_like(perm) inv[perm] np.arange(n) return perm, invTurbo编码的主流程按下面步骤走原始数据进第一个RSC得到系统位和校验位1原始数据交织后进第二个RSC得到校验位2发送端把系统位和两路校验位拼接起来。实际工程里系统位只发一份我这里也是。4.4 BCJR教学版代码对数域的前向后向递推BCJR是这个仿真里最核心也最容易写错的部分。我给出一个教学版实现先把关键结构讲清楚不追求工程优化但保证逻辑完整。def logsumexp(a, b): 对数域的加法防止数值下溢。 if a -np.inf: return b if b -np.inf: return a m max(a, b) return m np.log(np.exp(a - m) np.exp(b - m)) def bcjr(sys_llr, par_llr, prior_llrNone): 对数域BCJR教学版实现。 n len(sys_llr) S 4 # 2个寄存器4个状态 if prior_llr is None: prior_llr np.zeros(n) # 构建网格转移表: (state, u) - (next_state, parity) trans {} for s in range(S): r0 (s 1) 1 r1 s 1 for u in (0, 1): fb u ^ r0 ^ r1 parity u ^ r1 ns (fb 1) | r0 trans[(s, u)] (ns, parity) alpha np.full((n 1, S), -np.inf) beta np.full((n 1, S), -np.inf) alpha[0, 0] 0.0 # 编码器从全零状态开始 beta[n, :] 0.0 # 不终止网格结束状态等概率简化 # 前向递推 for t in range(n): for s in range(S): for u in (0, 1): ns, p trans[(s, u)] g (2*u - 1) * sys_llr[t] / 2 \ (2*p - 1) * par_llr[t] / 2 \ (2*u - 1) * prior_llr[t] / 2 alpha[t 1, ns] logsumexp(alpha[t 1, ns], alpha[t, s] g) # 后向递推 for t in range(n - 1, -1, -1): for s in range(S): for u in (0, 1): ns, p trans[(s, u)] g (2*u - 1) * sys_llr[t] / 2 \ (2*p - 1) * par_llr[t] / 2 \ (2*u - 1) * prior_llr[t] / 2 beta[t, s] logsumexp(beta[t, s], beta[t 1, ns] g) # 计算每个比特的LLR llr_total np.zeros(n) for t in range(n): sum0 -np.inf sum1 -np.inf for s in range(S): for u in (0, 1): ns, p trans[(s, u)] g (2*u - 1) * sys_llr[t] / 2 \ (2*p - 1) * par_llr[t] / 2 \ (2*u - 1) * prior_llr[t] / 2 val alpha[t, s] g beta[t 1, ns] if u 0: sum0 logsumexp(sum0, val) else: sum1 logsumexp(sum1, val) llr_total[t] sum1 - sum0 return llr_total这段代码需要注意几点。第一分支度量g在三个位置出现必须保持一致千万别写两套公式。第二alpha和beta初始化要用负无穷填充负无穷的加法在浮点数里容易出问题所以logsumexp要处理好-inf的情况。第三这个版本做了结束状态等概率的简化实际Turbo码分量编码器通常会做终止处理让编码器回到全零状态性能会更好。4.5 迭代译码主流程两个BCJR互相交换外信息有了BCJR模块迭代译码主循环就比较清晰了。核心步骤是用译码器1算LLR减去系统LLR和先验得到外信息1外信息1经过交织变成译码器2的先验用译码器2算LLR减去交织后的系统LLR和先验得到外信息2外信息2经过解交织变成下一轮译码器1的先验。如此循环。def turbo_decode(sys_llr, par1_llr, par2_llr, perm, inv, iterations4): n len(sys_llr) prior1 np.zeros(n) for _ in range(iterations): # 译码器1 llr1 bcjr(sys_llr, par1_llr, prior1) ext1 llr1 - sys_llr - prior1 # 交织后给译码器2 prior2 ext1[perm] sys_llr_interleaved sys_llr[perm] llr2 bcjr(sys_llr_interleaved, par2_llr, prior2) ext2 llr2 - sys_llr_interleaved - prior2 # 解交织回译码器1 prior1 ext2[inv] # 最后用系统信息和两路外信息综合判决 total_llr sys_llr ext1 ext2[inv] return (total_llr 0).astype(int)我在这里采用的判决方式是最后一次迭代的系统LLR加上两条外信息路径这个做法在工程里很常见。另外第二路校验序列par2_llr对应的编码器输入是交织后的数据但校验位本身不需要交织因为接收端的交织器只影响系统位的顺序校验位本来就是按交织前的位置排列接收的。这个细节不搞清楚交织解交织的索引一定会乱。5. 踩坑实录调试Turbo码的常见问题5.1 常见问题速查表我在调Turbo码仿真时遇到过不少问题有些甚至花了一整天排查。整理成一张速查表方便对照。现象可能原因处理办法迭代多次后误码率反而升高外信息未扣除干净先验被反复放大检查每次外信息是否都减去了本轮的输入先验交织器似乎没有起到作用交织表和解交织表没有对应索引错位单独打印交织前后的索引做核对所有增益消失和单卷积码差不多信道LLR的噪声方差计算错误确认Eb/N0到噪声方差的换算译码输出全是NaN或无穷对数域出现log(0)或exp溢出检查logsumexp对-inf的处理性能比预期差好几个dB删余后没有恢复校验位位置或者码率归一化错误先跑不删余的1/3码率验证链路换一个交织种子性能波动很大帧长太短交织器随机性不够增大帧长或改用S随机交织器5.2 外信息计算一个必须较真的细节外信息的计算是整个迭代译码里最容易出错、也最需要较真的地方。公式说起来就一句话——当前软输出减信道信息减先验信息但实际代码里三个量的对齐要格外小心。如果当前译码器用的是交织后的系统LLR那么减去的信道信息也必须是交织后的。如果先验信息是从上一个译码器外信息交织来的那么减去的先验也必须是交织后的同一份。错一个索引迭代出来就是噪声。我自己的调试习惯是每一轮迭代都打印外信息的均值、方差和符号翻转比例。如果均值稳定增长、方差扩大、翻转比例逐步变小说明迭代在朝正确方向收敛如果出现震荡或爆炸先回去查外信息。这个习惯帮我节省了大量时间。5.3 交织器和帧长的选择经验仿真用的帧长不要太短。Turbo码的性能在短帧下会明显变差这是交织增益不足导致的。我建议至少用512比特起步想看到漂亮的误码率曲线帧长最好在1024以上。帧长增加后交织器能更充分地分散错误迭代增益会更大。交织器本身也别偷懒用最简单的随机表。S随机交织器在中等帧长下性能明显更好实现只比随机交织器多一个约束检查。等整个仿真链路跑通之后可以写一个小脚本固定信噪比跑几十个交织种子统计不同交织器的平均误码率选性能最好的那组参数固定下来。5.4 迭代次数和复杂度怎么取舍迭代次数不是越多越好。通常两轮迭代就有主要增益三轮到四轮接近饱和五轮以上增益非常有限但计算量线性增加。仿真时可以先按五轮跑画出每轮迭代后的误码率变化曲线看到增速明显放缓的位置再确定实际需要的迭代次数。还有一点值得提醒BCJR算法的计算量大约是Viterbi的好几倍因为它同时要做前后向两趟递推。如果仿真跑得很慢可以先从Max-Log-MAP入手它的性能比Log-MAP差零点几个dB但计算速度快不少。把整个系统验证正确后再换回Log-MAP精调。我用这套流程跑过不少次仿真最深的体会是Turbo码的性能是模块协同的结果编码器、交织器、信道LLR、BCJR、外信息任何一环出问题整个链路都会偏离预期。先把每一步拆开验证再组合起来整体调比对着整条曲线猜原因要高效得多。信道编码这个领域里Turbo码是很值得亲手实现一遍的内容因为它几乎包含了纠错码设计里所有核心概念软信息、迭代、交织、分量码。跑通一遍之后再看LDPC、Polar码的译码算法很多思路都是相通的。