简介:这份rar压缩包提供了一套基于MATLAB平台的极化码SCL(Successive Cancellation List)译码完整实现,面向通信工程、信号处理及编码理论方向的初学者和研究者,尤其适合正在学习极化码原理或需要参考具体译码代码的读者。压缩包内共10个文件,全部为m脚本,覆盖编码、SCL译码、似然计算、极化码构造、路径矩阵与度量更新等功能模块,其中带有'完整注释'的脚本对每个函数和变量的作用都做了详细说明,方便逐行对照译码流程。整个压缩包仅6KB,结构非常精简,既可直接运行验证,也便于二次修改。目前已有289人学习下载。通过阅读这些代码,读者能清楚看到SC逐位判决与SCL多候选路径之间的差异,理解路径扩展、分支取舍、路径合并以及最终概率比较的具体实现方式,从而为后续优化译码速度或调整列表大小提供扎实的代码基础。
1. 极化码译码开场:SCL 才是 5G 控制信道敢用的那把刀
做通信物理层的人对极化码都不会陌生,但真正到了译码环节,很多人第一反应是往 CA-SCL 上靠。SCL 译码,全称是 Successive Cancellation List,连续消除列表译码,本质是在经典 SC 译码的串行框架里引入路径分裂和路径度量排序,用 L 条候选路径并行探索,最后从中挑一条最可靠的作为译码输出。为什么工程上必须用它?因为纯 SC 译码在中短码长下的误块率实在没法看,而 5G 控制信道的 Polar 码场景对时延和可靠性要求都很苛刻,SCL 几乎是唯一能在性能和复杂度之间站稳的方案。这篇笔记适合两类人:一类是刚接触极化码,想弄明白 SCL 到底在做什么的;另一类是在 MATLAB 或 C 里实现过 SC,想升级到 SCL 却在列表管理、CRC 校验和定点量化上反复踩坑的。我会从编码侧先讲清楚路径分裂的前提,再一步步拆译码流程和参数选择,最后把最容易翻车的几个工程问题单独拎出来说透。
很多人在网上搜“极化码译码”会看到大量关于 SC 和 BP 的资料,但真到了要过误块率指标时,SCL 的出现频率远高于它们。原因很简单:极化码的极化效应在有限码长下并不完美,窄带信道里那些极化不充分的比特信道,单靠硬判决的 SC 串行传递,错误一旦发生就没有回头路。SCL 引入了“后悔药”机制,每一层保留多条路径,最后用 CRC 或路径度量来定胜负。这也是为什么 5G NR 的 DCI 和 UCI 场景里,Polar 码配套的译码器几乎都是 CA-SCL 结构。
2. 极化码编码这一步没做对,后面 SCL 译码全是白忙
2.1 可靠度排序与冻结位集合:信息位选错,SCL 也救不回来
SCL 译码的质量上限由编码侧的极化权重决定。很多人以为译码性能不好就调 L 值,实际上如果信息位集合本身选得不对,L 再大也只是在错误的路径集合里打转。极化码的核心思想是把 N 个独立信道通过递归蝶形变换,极化出 N 个可靠度不同的比特信道,可靠度高的传信息比特,可靠度低的冻结成 0。这个可靠度排序在 5G 标准里用 PW(Polarization Weight)序列来算,也就是要对每个子信道计算一个权重值。
def calculate_pw(n): pw = [0] * n for i in range(n): # 按二进制位展开,每一级的权重是 2^(j * 1/4) bits = bin(i)[2:][::-1] # 低位在前 weight = 0.0 for j, b in enumerate(bits): if b == '1': weight += 2 ** (j * 0.25) pw[i] = weight return pw n = 16 pw = calculate_pw(n) # 按可靠度从大到小排序,取前 K 位作为信息位 info_len = 8 order = sorted(range(n), key=lambda i: pw[i], reverse=True) info_pos = sorted(order[:info_len]) # 信息位位置(升序) frozen_pos = sorted(order[info_len:]) # 冻结位位置 print("信息位:", info_pos) print("冻结位:", frozen_pos)这段代码做的是最朴素的 PW 序列生成。核心逻辑是把子信道索引按二进制展开,每个二进制位贡献一个权值 2 的 0.25 次方倍的权重,低位贡献小、高位贡献大,累加后就是这个子信道的可靠度。需要注意的是,这段代码只能用于 N 不超过 512 左右的场景验证,5G 标准里用的是查表法(Q 序列),实际工程直接查表更快也更稳。信息位选好之后,编码就是标准的结构化比特写入,再走蝶形变换。这一步的关键是冻结位集合必须固定,编码和译码两侧用同一个可靠度表,否则译码端无法判断哪些位是已知的 0。
2.2 编码矩阵的蝶形实现与码率匹配
编码侧的另一类常见问题是码率匹配。5G 的 Polar 编码不是随便给 N 和 K 就能编码,它需要先做速率匹配,从母码长度 N 的编码比特中挑选一部分作为实际传输比特。选比特的方式有 puncturing(打孔)和 shortening(缩短)两种,前者删掉可靠性最低的比特,后者把部分冻结位对应的编码比特固定为 0 后删掉。如果这一步的处理和译码端不一致,SCL 译码器拿到的对数似然比(LLR)初始值就会错位,性能直接塌方。
def polar_encode(info_bits, info_pos, frozen_pos, n): u = [0] * n for pos, bit in zip(info_pos, info_bits): u[pos] = bit # 蝶形编码 m = 1 while m < n: step = m * 2 for i in range(0, n, step): for j in range(m): a = u[i + j] b = u[i + j + m] u[i + j] = (a ^ b) % 2 u[i + j + m] = b m = step return u编码蝶形的实现逻辑是标准的时变合并。每一级把两个相邻子信道的比特做异或,高位的子信道保留原值,低位的子信道变成两者异或的结果,这对应了极化变换矩阵 G 的递归结构。外层循环控制级数,内层循环对每一组蝶形做变换,整个过程是纯位运算,没有浮点参与。这个函数跑通之后,配合前面的 PW 排序,就得到了一个完整的编码链路。参数上要注意 n 必须是 2 的幂,而且信息位长度 K 要小于 n,否则索引会越界。
3. SCL 译码原理:从 SC 到路径分裂,L 越大误块率越低
3.1 对数似然比与 SC 译码的串行骨架
在讲 SCL 之前,必须先说清楚 SC 译码的骨架。SC 译码按比特索引从小到大串行推进,每个比特的判决都依赖前面所有比特的判决结果和信道接收到的 LLR。LLR 的定义是收到信号 y 后,发送比特为 0 的对数概率与为 1 的对数概率之差,工程上用浮点数或定点数实现。SC 译码的问题是:一旦某一位判错,后面所有位的条件概率都建立在错误的前提上,错误就会沿着蝶形结构扩散。SCL 的思路就是在这个扩散发生之前,保留多个候选分支,把赌注分散到 L 条路上。
def sc_decode(llr, n, info_pos, frozen_pos): # 简化的 SC 译码:逐位硬判决 u_hat = [0] * n # 实际实现需要递归计算节点 LLR,这里展示判决顺序 for i in range(n): if i in frozen_pos: u_hat[i] = 0 else: # 根据当前路径的 LLR 符号做硬判决 cur_llr = compute_node_llr(llr, u_hat, i) u_hat[i] = 0 if cur_llr >= 0 else 1 return u_hat这段伪代码故意省略了节点 LLR 的递归计算,因为那个部分会涉及大量左右子树的递归调用。真实实现里,每个比特位的 LLR 取决于当前比特前所有已判决比特的值,以及接收到的全部信道 LLR。工程上常见的做法是做成迭代式蝶形更新,而不是每次从头递归。代码里的 compute_node_llr 是一个占位函数,实际是沿着蝶形图从接收端往根节点方向合并 LLR,这个过程需要维护一个树状的数据结构。理解 SC 的串行骨架是理解 SCL 的前提,因为 SCL 没有改变这个串行顺序,只是在每一步把可能性分叉出去了。
3.2 路径分裂与路径度量更新:SCL 的关键机制
SCL 译码器的核心数据结构是一个路径列表,初始只有一条空路径。每当译到一个信息位时,每条现存路径都可以尝试 0 和 1 两个分支,这样路径数就会翻倍。但如果放任不管,路径数会指数增长,所以每译完一个比特,就要用路径度量(PM)对当前所有路径排序,只保留 PM 最小的 L 条。这个“分裂-排序-剪枝”的过程就是 SCL 的核心循环。对于冻结位不分裂,直接在该位上强制取 0,并把对应的惩罚加到路径度量上。
def scl_step(paths, llr_value, is_frozen): # paths: 每条路径包含 (bits_so_far, pm) new_paths = [] if is_frozen: for bits, pm in paths: # 冻结位必须为 0,若当前 LLR 表明 1 更可能,则增加惩罚 penalty = 0.0 if llr_value >= 0 else abs(llr_value) new_paths.append((bits + [0], pm + penalty)) else: for bits, pm in paths: # 两个分支:0 和 1 penalty_0 = 0.0 if llr_value >= 0 else abs(llr_value) penalty_1 = 0.0 if llr_value < 0 else abs(llr_value) new_paths.append((bits + [0], pm + penalty_0)) new_paths.append((bits + [1], pm + penalty_1)) # 按 pm 升序排序,取前 L 条 new_paths.sort(key=lambda x: x[1]) return new_paths[:L]这个代码片段展示了 SCL 每一步的路径更新逻辑。对于冻结位,路径不会分裂,但该比特对应的 LLR 如果强烈表明发送的是 1,就要在 PM 上加上这个 LLR 的绝对值作为惩罚,因为译码结果违背了信道观测。对于信息位,每条路径分裂成两个候选,分别计算两个方向的惩罚值,然后按 PM 排序保留 top L。这里有一个细节容易出错:惩罚值不应该用整个 LLR 绝对值,而应该用“被选中分支的概率”。
严格来说,PM 的计算应该是对数域下的路径概率近似。假设当前位判决为 b,那么惩罚值为 |LLR|(如果 b 和 LLR 符号不一致)。LLR 的绝对值越大,说明信道对这位的置信度越高,如果判决方向与高置信度方向相反,这条路就不太可能是正确的,惩罚就应该更大。这个近似在工程上是够用的,定点实现时也容易量化。
3.3 CRC 辅助的最后一击:CA-SCL 为什么能压过 SC
SCL 单独使用时的性能已经优于 SC,但真正让它成为 5G 标配的是 CRC 辅助的 SCL,即 CA-SCL。做法是在编码侧把 K 个信息比特先通过 CRC 生成多项式算出 CRC 校验比特,然后把这些比特一起作为实际的“信息位”送到极化编码器里。译码时,SCL 内部仍然按 L 条路径维护,但最后不再只看 PM,而是对所有幸存路径做 CRC 校验,优先输出通过 CRC 的路径中 PM 最小的一条,如果都不通过,则仍然输出 PM 最小的那条。
def ca_scl_final_select(paths, crc_poly): valid = [] for bits, pm in paths: # bits 是译出的完整序列,其中最后 crc_len 位是 CRC info_bits = bits[:info_len] crc_bits = bits[info_len:info_len+crc_len] if crc_check(info_bits, crc_bits, crc_poly): valid.append((bits, pm)) if valid: valid.sort(key=lambda x: x[1]) return valid[0][0] else: paths.sort(key=lambda x: x[1]) return paths[0][0]代码注释里已经点出关键:CRC 不是对整条路径做校验,而是对信息位部分做校验,CRC 位本身是编码前的附加位。这里要特别注意,CRC 校验的比特位置必须和编码侧完全一致。如果信息位索引和 CRC 位索引错位,校验永远失败,导致 CRC 形同虚设。实际操作上,CRC 多项式通常选用标准 CRC-24 或 CRC-11,但具体用哪个取决于传输信道的质量要求和控制信道的载荷长度。CRC 长度越长,检错能力越强,但代价是有效信息率下降。
4. 工程落地前的链路参数:L、CRC、量化位宽的取与舍
4.1 列表大小 L 的调参方法与性能曲线
列表大小 L 是 SCL 译码器最核心的旋钮。L=1 时退化为 SC 译码,L 增大时误块率显著下降,但计算量和内存占用也线性上升。工程上常见的做法是先用浮点仿真扫出 L=4、8、16、32 四条 BLER 曲线,看目标误块率处哪个 L 值能满足需求,再去权衡硬件成本。需要注意的是,L=8 到 L=16 的性能增益通常比 L=16 到 L=32 大得多,但复杂度是线性增长的,所以要找曲线的拐点而不是一昧调大。
| L 值 | 相对复杂度 | 典型应用场景 | 性能表现 |
|---|---|---|---|
| 1 | 1x | SC 基线,复杂度极低 | 中短码长下 BLER 偏高 |
| 4 | 4x | 测试验证,快速迭代 | 比 SC 有明显改善 |
| 8 | 8x | 硬件实现常用起点 | 大多数场景可用 |
| 16 | 16x | 5G 控制信道典型配置 | BLER 曲线接近 ML 界 |
| 32 | 32x | 极低误块率需求 | 增益递减明显 |
从工程角度看,L=8 往往是性价比最高的起点。如果你是在 MATLAB 里做链路仿真验证,先跑 L=8 确认整体流程无误,再切到 L=16 看性能余量。如果你在 FPGA 里做定点实现,L=8 意味着路径存储需要 8 份完整的状态,包括路径度量、部分和、LLR 缓存等,这些都是硬件资源的大头。我在实际项目里见过有人一上来就设 L=32,结果 FPGA 资源爆了,时序收敛不了,最后不得不改回 L=8 并配合 CRC 来补性能。
4.2 定点量化:浮点仿真没问题,上硬件就翻车的重灾区
SCL 译码对 LLR 量化非常敏感。浮点仿真里 PM 用的是 double 精度,几乎没有数值稳定性问题,但硬件实现只能用定点数,位宽的选择直接决定误块率是否劣化。LLR 输入量化的典型位宽是 6 比特或 8 比特,路径度量 PM 的位宽则需要根据 L 和码长做饱和保护。
// 定点 LLR 量化示例:输入软比特为 8bit 有符号数 int8_t llr_quantize(float llr_in, float scale) { float scaled = llr_in * scale; if (scaled > 127.0f) return 127; if (scaled < -128.0f) return -128; return (int8_t)roundf(scaled); }量化 scale 的选取决定了 LLR 的动态范围映射。scale 太大,小幅度 LLR 被截断成 0,丢失信息;scale 太小,大幅度 LLR 饱和,极端置信度被削平。经验做法是从接收机的自动增益控制(AGC)标定值出发,统计实际 LLR 的分布,让 scale 把均值的 4 倍标准差映射到量化范围的 80% 左右。这个参数在不同信噪比下会有差异,需要做最差情况下的校核。我在项目里踩过一个坑:噪声方差估计偏小导致 scale 偏大,高信噪比下性能正常,低信噪比下误块率突然恶化,后来定位到是小 LLR 被量化成 0,路径度量无法区分两个候选路径。
路径度量的定点实现更讲究。PM 的更新是累加惩罚值,如果位宽不够,经过几十比特累加后会溢出,导致排序紊乱。常见做法是每译完一个比特或每译完一层蝶形后做一次归一化,把当前最小的 PM 减去。这样能保证所有 PM 保持在一个相对较小的动态范围内。
void pm_normalize(int16_t* pm, int L) { int16_t min_pm = pm[0]; for (int i = 1; i < L; i++) { if (pm[i] < min_pm) min_pm = pm[i]; } for (int i = 0; i < L; i++) { pm[i] -= min_pm; } }这段代码做的事情很简单:从所有 L 条路径里找到最小的 PM,然后全部减去这个最小值。这样做的目的是防止累加器溢出,同时不影响路径之间的相对大小关系。定点实现里每次分裂后的惩罚值也可能需要做饱和截断,避免加法溢出产生符号翻转。
5. SCL 译码避坑清单:浮点对齐、路径分裂和边界参数
5.1 现象:浮点模型和定点模型误块率差异巨大
浮点仿真正常,转定点后误块率恶化 1dB 以上。这个问题在 SCL 译码里出现频率极高,很多人第一反应是量化位宽不够,于是把 LLR 从 6 位加到 8 位,甚至加到 10 位,但性能并没有明显改善。原因往往不在量化精度,而在路径度量更新的计算顺序。浮点模型里 PM 的累加顺序是“先计算所有分支惩罚,再做累加和排序”,定点模型如果改成了“先排序再累加”,浮点结果和定点结果会出现差异,因为浮点加法有舍入误差而定点没有,排序结果在候选路径 PM 接近时会发生翻转。解决方法是完全对齐浮点和定点的计算流程,包括排序的稳定性准则,PM 相同时必须用路径索引做次级排序,否则两条等价路径的保留结果会不确定。
5.2 现象:CRC 校验反而导致误块率变差
加了 CRC 辅助之后,性能不升反降。这个问题通常出现在 CRC 比特的放置位置上。编码侧把 CRC 比特附加到信息比特后面,一起作为极化码的输入比特,但译码侧的信息位长度如果还是原来的 K 而没有加上 CRC 长度,SCL 会把 CRC 比特位置当作冻结位,路径分裂在这些位置上被强制收敛到 0,导致所有路径的信息都不是真正编码时的候选,CRC 校验自然失败。解决方法是把 CRC 长度计入信息位集合,让译码器在这些位置上正常分裂,最后再做 CRC 校验从候选中筛选。这种现象我曾经帮人排查过,对方把 info_len 写成了固定常量,换了 CRC 长度之后忘了同步更新。
5.3 现象:L 值改了之后,路径存储越界或排序性能异常
L 值从浮点模型的 8 改成 16 之后,定点实现的存储和排序时间翻倍,甚至出现内存越界。这类问题本质上是路径管理数据结构没有按照最大 L 做存储规划。SCL 的路径存储通常包含两部分:每个比特的判决值(硬比特)和已经计算出的部分和(用于蝶形 LLR 更新)。当 L 增大时,这两块存储都线性增长,如果结构体里用的是动态分配或固定数组但下标没有同步扩展,越界是必然的。更隐蔽的问题是排序算法:浮点模型用的排序函数在 L 小的时候性能尚可,L 增大后排序周期成为译码时延的瓶颈,硬件实现里 L 超过 8 时一般要换用基 2 或基 4 的排序网络,而不是继续用插入排序。
5.4 现象:特定码长下误块率出现地板效应
误块率曲线在某个信噪比之后不再下降,出现平台。这在中短码长和特定码率下很典型,尤其是 N=256、K=100 左右这种非标准配置。原因通常是冻结位可靠度排序和实际信道的极化程度不匹配。PW 序列计算的是理论上无限码长下的可靠度,实际信道在有限码长下的极化并不完全按这个顺序收敛,所以个别实际可靠度低的子信道被误判为信息位,形成错误地板。解决方案是把 PW 排序和密度进化(DE)或高斯近似(GA)得到的可靠度做交叉验证,以仿真结果为准,必要时对排序表做局部修正。这一点在 5G 标准定义的码长和码率下不需要担心,因为 3GPP 已经做了充分的仿真验证,但非标准场景一定要警惕。
6. 落地技巧:路径度量归一化、剪枝排序和码长适配
6.1 路径度量归一化的时机选择
定点实现中 PM 归一化的时机并不需要每个比特都做。如果每个比特都归一化,虽然安全但白白消耗了时钟周期。更合理的做法是设定一个阈值,当 PM 的最大值超过 2 的 15 次方时触发归一化,或者每译完一个蝶形层级后做一次。如果码长 N=512,每个比特就对应一个二进制的层级,每处理完一整层再归一化,可以把归一化次数控制在 5~6 次以内。这个阈值的选择要结合 PM 的惩罚值动态范围和 L 条路径之间的差异程度来定,不能拍脑袋用固定间隔。
6.2 排序剪枝的硬件友好实现
SCL 译码器的排序网络占硬件资源的比重很大,尤其是 L=16 或 L=32 时。工程上可以做一个近似:不把每个比特的路径全部展开后再排序,而是采用分步剪枝策略。第一轮先按 PM 排序保留 2L 条路径,第二轮再做精确排序取 L 条。这样可以大幅减少排序器的输入端口数量。另一个技巧是利用 PM 惩罚值的非负特性,在分裂前先检查当前路径的 PM,如果它已经比当前 top L 中的最大值大了某个阈值,可以直接剪掉,而不必分裂后再排序。这种做法在误码率性能上的损失很小,但能省下不少比较器。
6.3 码长适配:从 N=128 到 N=1024 的移植注意点
很多人在 MATLAB 里验证时用的是 N=128 或 N=256,直接移植到 N=1024 的硬件设计后发现时序崩了。原因不只是存储扩大,而是 SCL 译码的串行级数从 7 级变成了 10 级,每一级内的节点计算量也变了。调试策略是先固定 L 和量化位宽,单独测 N=512 和 N=1024 的误块率,确认性能趋势符合预期,再看时序。另一个隐蔽的问题是部分和更新逻辑:N 增大后蝶形网络的深度变深,部分和存储的寻址方式要重写,不能用小码长的硬编码索引。我的习惯是先把部分和的地址计算做成通用函数,再针对不同 N 做参数化配置,宁可多花两天重构,也不要复制粘贴修修补补。这套方法论帮我避开了很多次项目返工。
SCL 译码的工程实现就是这样,看起来知识点分散,其实核心就三件事:编码侧的可靠度排序做对、译码侧的路径管理做稳、定点侧的量化与归一化做准。我第一次用 SCL 替换 SC 时也吃过 CRC 位置错位的亏,后来养成的习惯是每次改参数前先画一张编码器和译码器的比特位置对照表。希望这篇笔记也能帮你在调 SCL 的时候少走几步弯路。
本文还有配套的精品资源,点击获取