简介:面向通信工程、编码理论研究人员及高年级学生,一套基于Matlab的极化码SCL译码实现提供了从信道极化构造、编码到列表译码的完整链路。极化码是可达香农极限的编码方案,已应用于5G控制信道;SCL译码通过维护候选路径列表来降低误码率,比SC译码更适合实际系统。RAR压缩包内共10个m文件,整体仅6KB,模块化代码涵盖极化码构造、编码、信道似然计算、路径度量更新与克罗内克积运算等关键环节,便于逐模块研读,已有289人学习/下载。借助SCL译码的完整注释,读者可逐行跟踪路径扩展、候选列表维护与剪枝过程,理解性能优势;还能调整列表大小等参数观察误码率变化,对课程设计、算法对比或5G通信研究具有轻量参考价值,也可与ExpressVW等开源工具互补验证。实现结构清晰、代码量小,适合快速上手。
1. 从SC到SCL:极化码译码为什么要维护“候选路径列表”
极化码在5G控制信道里已经是成熟方案,但大多数人第一次打开一份SCL译码代码时,还是会被路径分裂和度量更新绕晕。一个反直觉的结论是:SCL并不会让每一位的判决变得更“聪明”,它只是同时维护L条候选译码路径,每译完一位就按路径度量排序剪枝,最后从L条候选中挑一条输出。这份带完整注释的MATLAB实现,覆盖了极化码编码、LLR计算、路径矩阵维护和SCL译码的完整调用链,适合通信专业学生和刚开始接触极化码的工程师把它当作第一份能跑通的参考代码。
2. 极化码的构造:信道极化、Kronecker结构与信息位选择
拿到压缩包后先别急着跑test.m,先把代码里的基础模块捋一遍。极化码之所以能逼近香农极限,靠的是“信道极化”这个现象:N个独立同分布的信道,经过一次线性变换后,一部分信道容量趋向1,另一部分趋向0,且两者的比例恰好等于原始信道容量。SCL译码的所有操作,都是在这个已经“极化”的码结构上进行的。压缩包里的kronecker_product.m和kronecker_power.m负责搭建这个结构,bit_reversed.m负责把编码端和译码端的索引对齐,polarcode.m则决定K个信息位放在哪些位置。这四件事搞懂,后面译码才不会迷路。
2.1 信道极化:从“容量均分”到“容量两极分化”
极化码的编码矩阵来源于对一个2×2核矩阵的反复Kronecker积。假设原始信道是容量为I的二进制输入离散无记忆信道,把两个独立信道合并再拆分后,会得到两个新信道,一个容量接近I²,另一个容量接近2I-I²。对N个信道递归做同样操作,极端信道越来越多,中间段的信道则不稳定。理论上当N趋近无穷时,只要把信息位放在那些容量接近1的坐标上,就能以任意低的错误概率逼近容量。5G控制信道看中极化码,正是因为它的构造有明确的结构性,不像LDPC那样依赖大矩阵搜索。不过实际码长总是有限的,N=256或512时“好信道”和“坏信道”的划分并不完全干净,这正是SCL译码发挥价值的地方:它不指望每位都判对,而是把不确定性留在路径列表里,最后再统一裁决。
这里有一个容易误解的点:信道极化并不是“编码把信道变好了”,而是编码在多个信道副本之间制造了相关性,让一部分信息位享受到接近1的容量,另一部分则被牺牲掉。牺牲掉的坐标放冻结位(固定为0),不携带信息;SCL译码对冻结位不做任何猜测,直接视为已知。理解这一层,后面看scl_decoder.m里的分支逻辑就不会懵。
2.2 Kronecker 积与比特反转:生成矩阵里的两个基础函数
在MATLAB实现里,生成矩阵通常由这两个文件配合完成。常见做法是先用Kronecker积把核矩阵扩展成N×N的生成矩阵,再用比特反转重排行序。源代码里kronecker_product.m处理单次Kronecker积,kronecker_power.m循环调用它,得到完整矩阵:
function G = kronecker_power(F, n) % 对 2x2 极化核 F 做 n 次 Kronecker 幂,得到 N = 2^n 维生成矩阵 G = 1; for i = 1:n G = kron(G, F); % 每次维度翻倍 end end逻辑说明:kron是MATLAB自带的Kronecker积函数,G从标量1开始,每循环一次维度乘2。如果F取[1 0; 1 1](GF(2)上的极化核),跑完n次后G就是标准N×N生成矩阵。参数说明:n不能超过10左右,否则G的维度按2^n增长,内存会先撑不住;实际调试从n=7(N=128)起步最稳妥。
注意:[1 0; 1 1]是在GF(2)上定义的,但MATLAB的kron函数不知道这层含义,所以编码时必须在乘法之后对2取模。这个细节放到第3章编码端再展开。
比特反转的作用很多人容易看漏。SC译码的递归顺序依赖“比特反转置换”:把序号i-1写成n位二进制再倒序,得到新序号。这份代码里的bit_reversed.m干的就是这件事:
function idx = bit_reversed(n) % 返回 1 到 2^n 的比特反转索引,用于对齐编码输出与译码递归 N = 2^n; idx = zeros(1, N); for i = 1:N orig = i - 1; rev = 0; for k = 1:n rev = rev * 2 + mod(orig, 2); orig = floor(orig / 2); end idx(i) = rev + 1; end end这个函数只依赖n,与信道无关,所以可以提前算好缓存。真正需要警惕的是:编码时是否对信息位做了反转、译码时输出是否需要反转回去,两者必须严格配对。项目实现里一般把反转放在编码入口,也就是“先反转再乘生成矩阵”,而不是对生成矩阵本身做行置换。你拿到代码后第一件事就是确认bit_reversed.m被encode.m调用而不是被test.m单独瞎用。参数说明:n是log2(N),返回值idx是1×N的行向量,元素是重排后的索引。
2.3 polarcode.m:用巴氏参数选择信息位
信息位放在哪些坐标,直接决定误码率。最简单的构造方式是针对BEC信道计算每个子信道的巴氏参数z,z越小代表信道越可靠。BEC下z的递推有闭式表达式,所以很多教学实现都用BEC做设计参考,即使实际信道是AWGN也照用。polarcode.m常见做法是:
function info_idx = polarcode(N, K, z_init) % z_init 是 BEC 删除概率,典型值取 0.5 z = zeros(1, N); z(1) = z_init; for level = 1:log2(N) old = z(1:2^(level-1)); % 只取当前层已有的值 for j = 1:length(old) z(2*j - 1) = old(j) * old(j); % 分裂后的差信道 z(2*j) = 2*old(j) - old(j)*old(j); % 分裂后的好信道 end end [~, order] = sort(z); info_idx = order(1:K); % 选 z 最小的 K 个位置 end逻辑说明:这里level循环是关键。很多第一次接触的人把递推写成单层for j=1:N/2,算出来的z是乱的,因为z(j)在同一轮里被覆盖了。代码里先用old把上一层的z缓存下来,再向后扩展生成下一层的两个新值,顺序才正确。参数说明:z_init取值在(0,1)之间,它影响信息位集合但影响不大;真正要注意的是N必须是2的幂,K必须小于N。这套BEC构造在AWGN下依然够用,因为信道极化后的可靠度排序在两类信道下高度相似,工程上没必要为每个信噪比单独重算一遍信息位。
3. SCL译码流程拆解:编码端、路径分裂与路径度量更新
SCL之所以比SC强,核心在于“不确定性不急着拍板”。SC每译一位就硬判决一次,前面错了后面全错;SCL把每次0/1判决都保留成两条候选路径,最后用路径度量打分。这一章把压缩包里的encode.m、scl_decoder.m、likelihood.m、path_matrix.m和path_metric.m串成一条线。
3.1 encode.m:信息位、冻结位与生成矩阵相乘
编码端做的事很简单:把K个信息比特放到polarcode.m选出的info_idx位置上,其余N-K个冻结位全部置0,然后乘生成矩阵。常见实现是:
function x = encode(u, info_idx, N) % u 是长度 K 的信息比特向量 n = log2(N); F = [1 0; 1 1]; G = kronecker_power(F, n); u_full = zeros(1, N); u_full(info_idx) = u; % 信息位放数据,冻结位默认 0 x = mod(u_full * G, 2); % GF(2) 上的生成矩阵乘法 end逻辑说明:u_full长度N,除info_idx以外的位置全部是0,这就是“冻结位为0”的物理含义。x是编码后的码字,后面调制时0映射为+1、1映射为-1。参数说明:info_idx从上一步polarcode.m得到,注意它是1-based索引,MATLAB里直接用于向量赋值不会越界。如果N=256而G是256×256,一次乘法开销不大,但要是做蒙特卡洛仿真,建议把G提前算好存成全局变量,不要每帧都重新kronecker_power。
压缩包里encode.m的具体写法可能带了比特反转处理,这取决于它是否在乘G前先对u_full做了反转。判断方法很简单:查看bit_reversed.m的调用位置。如果在encode.m里调用,说明码字生成顺序和译码递归是对齐的;如果在test.m里单独调用,就要小心是不是漏了。
3.2 scl_decoder.m:路径分裂、剪枝与列表维护
scl_decoder.m是整份代码的核心。它接收信道观测和编码参数,维护一个最多L行的候选路径矩阵。每译一个信息位,每条活跃路径分裂成0/1两条,路径数最多变2L,再按路径度量排序只留L条。冻结位不分裂,直接在当前路径上追加0。
function [decoded, pm_out] = scl_decoder(y, info_idx, L) N = length(y); paths = zeros(L, N); pm = zeros(L, 1); n_active = 1; % 当前活跃路径数 for i = 1:N cand_bits = zeros(2*n_active, i); cand_pm = zeros(2*n_active, 1); cand_count = 0; for l = 1:n_active llr_i = likelihood(y, paths(l, 1:i-1), i); % 条件LLR,依赖前序比特 if ismember(i, info_idx) % 信息位:分裂 for b = 0:1 cand_count = cand_count + 1; cand_bits(cand_count, 1:i-1) = paths(l, 1:i-1); cand_bits(cand_count, i) = b; cand_pm(cand_count) = path_metric(pm(l), llr_i, b); end else % 冻结位:直接置 0 cand_count = cand_count + 1; cand_bits(cand_count, 1:i-1) = paths(l, 1:i-1); cand_bits(cand_count, i) = 0; cand_pm(cand_count) = path_metric(pm(l), llr_i, 0); end end [~, order] = sort(cand_pm); keep = order(1:min(L, cand_count)); paths(1:length(keep), :) = cand_bits(keep, :); pm(1:length(keep)) = cand_pm(keep); n_active = length(keep); end [~, best] = min(pm(1:n_active)); decoded = paths(best, :); end不要把这版代码当成压缩包里的原版,它是我按标准流程写的教学骨架,重点在于和文件清单里的模块一一对应。逻辑说明:每条路径其实只需要维护当前位之前的部分比特序列,完整实现会用path_matrix.m把N×log2(N)的中间LLR结果存起来,避免每层都重算整棵递归树。上面的循环里每层重新复制路径矩阵,只是为了把原理讲清楚。参数说明:L是列表大小,L=1时退化为SC,L=4~32是常用区间。ismember(i, info_idx)在仿真里比较慢,实际代码通常先建一个frozen_flag数组,O(1)判断。
真正决定性能的是likelihood.m。SCL译码的LLR是递归传播的:第i位的条件LLR依赖前面已判决的比特,不是简单取2y/σ²。likelihood.m内部会沿极化码的蝶形图从右往左逐层计算,path_matrix.m正是为这个递归过程缓存中间结果。第一次读代码的人容易只看懂最后的度量比较,忽略了LLR递归,后面调试会非常痛苦。
3.3 path_matrix.m 与 path_metric.m:路径度量更新里的“加减法”
path_metric.m的更新逻辑直接决定候选路径排序。它的定义是对数似然比的负累积:路径与观测越一致,PM越小。常见近似写法是:
function pm_new = path_metric(pm_old, llr, bit) % 路径度量近似:硬判决不一致时,惩罚 |llr| if bit == (llr > 0) pm_new = pm_old; else pm_new = pm_old + abs(llr); end end逻辑说明:如果当前位译成0而llr为正说明观测支持0,路径不惩罚;如果译成1而llr为正说明路径与观测冲突,惩罚量就是|llr|。这个近似在调试时足够用,正式性能仿真可用完整公式ln(1+exp(-(1-2bit)*llr)),但结果差异很小。参数说明:llr>0代表传输0的概率更大,这是BPSK映射下(0→+1, 1→-1)的惯例,如果你的test.m里映射反了,这里符号也会跟着反。
path_matrix.m的作用是给每条路径保存一份独立的LLR中间矩阵。SCL最耗内存的地方就是这里:L条路径各自维护一个N×log2(N)的数组,L=32、N=512时很容易跑到几十MB。好的实现会用增量复制,只复制被修改的列;简单实现则把整个矩阵深拷贝,这也是后面要说的性能坑之一。
4. 复现与调参:test.m 跑通流程与列表大小 L 的性能边界
前两章把原理和模块拆完了,现在落到能跑的东西。压缩包里的test.m是入口脚本,它把encode、信道、SCL译码和误码统计串起来。我的建议是不要一上来就全码率全码长地跑,先用小参数验证链路通不通,再逐步加码。
4.1 test.m 跑通一次完整编解码与误码统计
一份典型的test.m设置如下:
N = 128; % 码长 K = 64; % 信息位长度 L = 8; % SCL 列表大小 EbN0 = 3; % 目标信噪比,单位 dB R = K / N; % 码率 sigma2 = 1 / (2 * R * 10^(EbN0/10)); % 噪声方差参数说明:sigma2的计算隐含了BPSK符号能量归一化和码率折算。很多刚接触的人直接把AWGN噪声方差设成1/(2*10^(EbN0/10)),忘了乘1/R,跑出来性能会比理论低1.5~3dB,误码曲线整体右移。原因在于每发送一个信息比特实际消耗N/K个信道符号,能量要按码率摊到信息比特上。这一条也是后面排查的热点。
调用链按顺序写:
u = randi([0 1], 1, K); % 随机信息比特 info_idx = polarcode(N, K, 0.5); % 构造信息位 x = encode(u, info_idx, N); % 编码 s = 1 - 2*x; % BPSK 调制 y = s + sqrt(sigma2)*randn(1, N); % 过 AWGN 信道 [decoded, pm_vals] = scl_decoder(y, info_idx, L); % SCL 译码 err = sum(decoded(info_idx) ~= u);逻辑说明:scl_decoder返回整段码字,所以统计误码时要按info_idx取回信息位,和原始u比较。pm_vals可以顺便打出来看看,路径度量为0往往说明译码路径非常有信心,但要注意冻结位不产生惩罚,PM值本身只能在同一帧内比较,不能跨信噪比比较。函数名以你手里的scl_decoder.m为准,有的版本第一步会在函数内部把信道观测转成LLR,不影响这个调用结构。
第一次跑通后,把误码统计包一层蒙特卡洛循环:
num_trials = 1000; err_count = 0; for t = 1:num_trials u = randi([0 1], 1, K); x = encode(u, info_idx, N); s = 1 - 2*x; y = s + sqrt(sigma2)*randn(1, N); decoded = scl_decoder(y, info_idx, L); err_count = err_count + sum(decoded(info_idx) ~= u); end ber = err_count / (num_trials * K);这里的num_trials不要一上来设10000。N=128、L=8时单帧仿真很快,但N=512、L=32时一帧可能要几十毫秒,10000帧就是几分钟到十几分钟。我的习惯是先跑100帧看量级,再按需加大,不然调参过程全耗在等待上。
4.2 列表大小 L 的调参边界与复杂度成本
L是SCL里唯一真正意义上的调参旋钮。它的作用可以用一句线性的表述概括:复杂度几乎随L线性增长,误码性能则按对数级别改善,越到后面收益越小。实际仿真常见这样的趋势:
| L | 复杂度 | 性能表现 |
|---|---|---|
| 1(SC) | 最低 | 高误码率 |
| 2 | 约2倍 | 有明显提升 |
| 4~8 | 4~8倍 | 提升显著,性价比高 |
| 16~32 | 16~32倍 | 增益递减,内存压力变大 |
不用把这个表当成固定结论,它只反映一般规律。测试时可以写一个L扫描循环,对比同一批帧在不同L下的误码率:
for L = [1 2 4 8 16] errs = 0; for t = 1:num_trials decoded = scl_decoder(y_all(:,t), info_idx, L); errs = errs + sum(decoded(info_idx) ~= u_all(:,t).'); end fprintf('L=%2d, BER=%.4f\n', L, errs / (num_trials * K)); end注意这里的y_all是预先生成的一批固定接收样本。为什么要固定样本?因为不同L用同一批噪声,才能公平看出路径列表带来的增益;如果每轮重新生成随机数,误码率的抖动会掩盖L的差异。这个做法也适合用来调试:一旦L增大而误码率没有改善,说明实现里大概率有bug,而不是参数问题。
L的上限还受内存约束。path_matrix.m每多一条路径就要多一份递归中间值,L=32、N=512时,如果你跑的是完整SCL而LLR中间矩阵不做增量复用,一次译码占用几百MB都很正常。工程上一般用L=8或16做性能演示,L=32留给论文曲线。
5. SCL译码避坑与常见问题排查:四个必踩的坑
SCL译码翻车的地方往往不在算法本身,而在数据流与参数约定。下面的四条是我按这份代码的模块结构总结的典型踩坑记录,照着排查比从头读代码快得多。
5.1 译码输出全零或错位:先查比特反转是否成对
现象:把test.m跑通后,误码率接近0.5,或者输出码字和发送码字在固定位置差一截。有时候L=1跑出来全零,信息位一个都对不上。
原因:encode.m里先对u_full做比特反转再乘生成矩阵,但译码端scl_decoder.m的递归顺序默认输入已经是正常顺序,两边没有对齐。比特反转这类“小函数”最容易被复制错,因为单独看每个模块都合理。
解决:在encode.m和scl_decoder.m里各放一个断点,检查同一索引位置对应的LLR和比特来源。常见的配对方式是“编码端反转、译码端不反转”或者“编码端不反转、译码端输出反转”,两种都行,但必须二选一。如果压缩包里的bit_reversed.m只在encode.m里被调用,那么scl_decoder.m的输出大概率要经过一次反转才对齐。
5.2 性能反而不如SC:冻结位被当成信息位分裂
现象:把L从1调到8,误码率不但没下降,反而比SC还差。看起来很奇怪,因为理论上SCL至少不应该差于SC。
原因:信息位索引和冻结位索引没分开处理。如果在冻结位上做了0/1分裂,列表里就会混入大量“本来不该有”的路径,这些路径的度量惩罚可能恰好很小,把正确路径挤掉。SCL的核心前提是冻结位已知,不能有任何不确定性。
解决:检查译码循环里对每个位置的判断。正确做法是信息位分裂、冻结位直接追加0。把ismember(i, info_idx)换成预计算的frozen_flag数组,既快又不容易漏。修完后用同一批噪声做L=1和L=8对比,性能应单调改善。
5.3 列表增大误码率不降反升:SCL缺了检错能力
现象:在短码长下,L从4加到16,性能曲线先降后升,或者到某个L之后基本不动。
原因:SCL本身没有“检错”手段,列表里最后挑出来的是L条路径里相对最好的,但当噪声很恶劣时这L条可能全都错了。列表变大只是让“矮子里拔将军”的选择范围变大,没有外部约束来识别哪条是真正合法路径。
解决:工程上几乎都用CA-SCL,即把CRC校验位编进信息位序列,译码结束时从L条候选里先过CRC,再选PM最小的那条。压缩包是基础SCL教学实现,不含CRC,复现时可以在encode.m里先给u追加一段CRC,再把信息位数量K扣除校验位长度,译码侧做相同约束。这是短码性能提升的最大杠杆,比盲目加大L有效得多。
5.4 内存与耗时失控:路径矩阵深拷贝问题
现象:N=512、L=32时,单帧译码时间从几十毫秒跳到几秒,内存占用暴涨。
原因:path_matrix.m如果对每条路径都整体复制一份N×log2(N)的LLR中间矩阵,L条路径就是L份,内存和时间都呈线性放大。很多初学者为了图省事直接在循环里写paths(l, :) = cand;这种整行复制,看起来没毛病,实际上把递归中间结果也连带复制了。
解决:改成增量复制,只维护每条路径被修改的分支节点;同一条路径在不同层的分叉共享未修改部分。MATLAB里可以先用元胞数组存放每层的节点引用,避免整矩阵深拷贝。如果你只是要验证正确性,暂时把L控制在8以下也能接受,但要跑正式曲线就必须优化这里。
6. 用BEC删除信道验证SCL译码器的正确性:一个低成本冒烟测试技巧
AWGN信道下的误码曲线受噪声方差、信噪比定义和随机数种子影响,稍有不对曲线整体平移,很难判断译码器到底对不对。我一直习惯在做任何性能对比前,先用BEC删除信道做一次冒烟测试。BEC下信道输出只有三种:收到的比特、被删除的比特,没有噪声数值干扰,任何实现错误都会直接表现为误码率异常。
6.1 BEC下的冒烟测试步骤
BEC信道由删除概率p刻画。发送端照常编码,接收端以概率p把某个符号置为删除,删除位置在LLR里对应的值就是0;未被删除的位置LLR为±一个大数。测试脚本核心只有几行:
p = 0.4; del = rand(1, N) < p; y = s; y(del) = 0; llr_bec = zeros(1, N); llr_bec(~del & s > 0) = 20; llr_bec(~del & s < 0) = -20; decoded = scl_decoder(llr_bec, info_idx, L); err = sum(decoded(info_idx) ~= u);这里的20是“无穷大”的数值替身,只要远大于信道LLR量级就行。BEC下正确的SCL实现应该满足两个可检验的性质:一是当L增大时误码率单调不增;二是删除概率p越大误码率越高,趋势曲线应该平滑。如果L=1和L=8在BEC下表现几乎一样,或者误码率随L振荡,说明路径度量或冻结位处理有问题,不用急着进AWGN调参。
一句话解释为什么这个测试有效:BEC的删除事件是离散的,p和巴氏参数z的递推公式可以直接对应,polarcode.m里用的z_init正好就是BEC删除概率。所以BEC测试把“构造”“编码”“译码”三个环节绑定在同一套理论模型下,任何一个环节的约定不一致都会立刻暴露。从那以后我每次拿到新的SCL实现,都先强制走一遍BEC冒烟测试,再进AWGN做性能仿真,省掉的排查时间远大于写脚本的几分钟。希望帮到你。
本文还有配套的精品资源,点击获取