极化码SCL译码原理与MATLAB实现:从信道极化到路径度量 简介面向通信工程、编码理论研究人员及高年级学生一套基于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那样依赖大矩阵搜索。不过实际码长总是有限的N256或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增长内存会先撑不住实际调试从n7N128起步最稳妥。注意[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信道计算每个子信道的巴氏参数zz越小代表信道越可靠。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 j1: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里直接用于向量赋值不会越界。如果N256而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是列表大小L1时退化为SCL4~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(1exp(-(1-2bit)*llr))但结果差异很小。参数说明llr0代表传输0的概率更大这是BPSK映射下0→1, 1→-1的惯例如果你的test.m里映射反了这里符号也会跟着反。path_matrix.m的作用是给每条路径保存一份独立的LLR中间矩阵。SCL最耗内存的地方就是这里L条路径各自维护一个N×log2(N)的数组L32、N512时很容易跑到几十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。N128、L8时单帧仿真很快但N512、L32时一帧可能要几十毫秒10000帧就是几分钟到十几分钟。我的习惯是先跑100帧看量级再按需加大不然调参过程全耗在等待上。4.2 列表大小 L 的调参边界与复杂度成本L是SCL里唯一真正意义上的调参旋钮。它的作用可以用一句线性的表述概括复杂度几乎随L线性增长误码性能则按对数级别改善越到后面收益越小。实际仿真常见这样的趋势L复杂度性能表现1SC最低高误码率2约2倍有明显提升4~84~8倍提升显著性价比高16~3216~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每多一条路径就要多一份递归中间值L32、N512时如果你跑的是完整SCL而LLR中间矩阵不做增量复用一次译码占用几百MB都很正常。工程上一般用L8或16做性能演示L32留给论文曲线。5. SCL译码避坑与常见问题排查四个必踩的坑SCL译码翻车的地方往往不在算法本身而在数据流与参数约定。下面的四条是我按这份代码的模块结构总结的典型踩坑记录照着排查比从头读代码快得多。5.1 译码输出全零或错位先查比特反转是否成对现象把test.m跑通后误码率接近0.5或者输出码字和发送码字在固定位置差一截。有时候L1跑出来全零信息位一个都对不上。原因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数组既快又不容易漏。修完后用同一批噪声做L1和L8对比性能应单调改善。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 内存与耗时失控路径矩阵深拷贝问题现象N512、L32时单帧译码时间从几十毫秒跳到几秒内存占用暴涨。原因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越大误码率越高趋势曲线应该平滑。如果L1和L8在BEC下表现几乎一样或者误码率随L振荡说明路径度量或冻结位处理有问题不用急着进AWGN调参。一句话解释为什么这个测试有效BEC的删除事件是离散的p和巴氏参数z的递推公式可以直接对应polarcode.m里用的z_init正好就是BEC删除概率。所以BEC测试把“构造”“编码”“译码”三个环节绑定在同一套理论模型下任何一个环节的约定不一致都会立刻暴露。从那以后我每次拿到新的SCL实现都先强制走一遍BEC冒烟测试再进AWGN做性能仿真省掉的排查时间远大于写脚本的几分钟。希望帮到你。本文还有配套的精品资源点击获取