简介:卷积码及译码是一份围绕卷积码编译码原理与维特比译码算法的毕业论文资料,适合通信工程、信息工程专业学生及数字通信初学者系统学习差错控制编码,也可作为相关课程设计与毕业设计的参考。包内仅有1个doc文档,大小约991KB,文档结构完整,包含从摘要、目录到理论推导和仿真分析的完整内容,doc格式便于批注与二次编辑;整体层次分明,先理论后仿真,便于按章节查阅。已有346人学习浏览,属于体量精悍、针对性强的技术文献。文档从纠错码基本理论切入,结合GSM、CDMA2000等实际应用背景,详细讲解卷积码编码器结构与维特比译码的最大似然路径搜索流程,并通过MATLAB仿真对比不同码率、约束长度、回溯深度及硬判决/软判决方式下的误码率表现,借助仿真曲线直观展示参数影响规律,最终给出优化卷积码系统性能的参数选择依据,便于读者将理论应用于实际通信系统设计。
1. 先搞清楚卷积码在链路里的位置:它不丢包,它压误码
做过无线通信嵌入式开发的人,大概率都碰过这种场景:一个三百字节的控制帧,信道稍差就连续丢包,重传三次还是错。协议栈里加 CRC、加重传都压不住信号噪底的时候,卷积码的作用就出来了——它不保证不丢包,它保证的是在同样噪声下把误码率往下压几个数量级。卷积码及译码,本质就是“给信道看的冗余”:把连续几个信息比特按生成多项式打散成更长的输出序列,让接收端在噪声里用概率而不是运气恢复出原码。这篇笔记从参数读到编解码实现,配可运行的 Python 代码帮你跑通一条完整的卷积码链路,再把落地时的高频坑一次讲完。适合所有要在真实信道上做链路开发、又不想只停留在背概念的人。
2. 读卷积码参数的三件套:码率、约束长度和生成多项式
2.1 码率、约束长度、生成多项式:一张表读懂三个参数
卷积码的参数没有玄学,核心就三个:码率、约束长度、生成多项式。它们分别回答三个问题:一个信息比特变成几个编码比特、当前输出受多少个历史比特影响、每个输出支路到底抽哪些寄存器。做链路预算时,这三个参数决定带宽占用、译码复杂度和纠错增益。
码率记作 R,卷积码习惯写成 n/k 或直接 k/n 的分式,比如 1/2、2/3、3/4。R = 1/2 表示每个信息比特发送 2 个编码比特,带宽翻倍但每比特能量也翻倍,抗噪能力更强。R 越大,同样带宽里能塞的有效数据越多,但冗余越少,纠错能力越差。工程上 R 从 1/2 到 3/4 都有,看链路余量够不够。
约束长度记作 K,定义是“影响当前输出编码比特的输入比特总数”,包括当前比特和前 K-1 个历史比特。有的教材用记忆长度 m = K-1,容易混。给某开发者改代码时经常看到他把 K 和 m 弄反,导致状态数算错,这类问题在坑里再细讲。生成多项式 G 则是每个输出支路的抽头组合,通常用八进制数表示,例如 (2,1,3) 码的 G = (7,5),展开成二进制分别是 111 和 101。参数之间的关系是:状态数 = 2^(K-1),译码器复杂度随状态数线性增长,所以约束长度越大,纠错越强,但译码越贵。
一张表把这几个参数和典型取值列清楚。下面这张表对应的是最常见的教学码和工程码,可以直接抄去估算:
| 参数 | 符号 | 含义 | 常见取值 |
|---|---|---|---|
| 码率 | R | 每个信息比特对应的编码比特数 | 1/2、2/3、3/4 |
| 约束长度 | K | 影响当前输出的历史输入长度 | 3~9,链路主用 7 |
| 记忆长度 | m | K-1,移位寄存器级数 | K-1 |
| 生成多项式 | G | 各输出支路的抽头组合,八进制 | (7,5)、(171,133) |
| 状态数 | 2^(K-1) | 维特比译码器需要跟踪的网格状态数 | K=3 时 4 个,K=7 时 64 个 |
2.2 从网格图看懂“为什么维特比可行”
卷积码不用查表译码,它天然长成一张网格图。网格的每一列代表一个时刻,每个时刻有 2^(K-1) 个状态,每个状态从左到右延伸出两条边——对应输入比特 0 和 1。选哪条边,就同时决定了这一拍的输出和下一个状态。
拿 (2,1,3) 码,生成多项式 7、5,约束长度 3,状态用移位寄存器里的前两个比特 D1D2 表示。完整的单步状态转移和输出如下表,这个表可以直接用于写译码器:
| 当前状态 D1D2 | 输入 b | 下一状态 bD1 | 输出 G1G2 |
|---|---|---|---|
| 00 | 0 | 00 | 00 |
| 00 | 1 | 10 | 11 |
| 10 | 0 | 01 | 10 |
| 10 | 1 | 11 | 01 |
| 01 | 0 | 00 | 11 |
| 01 | 1 | 10 | 00 |
| 11 | 0 | 01 | 01 |
| 11 | 1 | 11 | 10 |
这张表就是整个维特比译码的“黑匣子”被打开后的样子:只要知道当前状态和输入比特,输出和下一状态都是确定的。噪声加进来后,接收端看到的不是干净的 00/11,而是可能被翻转的比特。这时要做的不是逐比特猜,而是在网格里找一条“总距离最小”的路径。
为什么暴力枚举不可行而维特比可行?如果 N 个信息比特,暴力枚举 2^N 条路径,N 到几十就爆了。维特比的关键在于:到同一个状态的多条路径,只需要保留“累计度量最小”的那一条,因为后面的路径选择只取决于当前状态,不取决于前面怎么来的——这满足动态规划的最优子结构。每一拍只对 2^(K-1) 个状态做“加比选”,总复杂度 O(N · 2^(K-1))。K=7 时 64 个状态,N=1000 时也就 64000 次状态更新,实时系统完全扛得住。
2.3 约束长度定多大:性能与成本的取舍
约束长度 K 往上加,纠错能力会好,但不是线性变好,而是靠自由距离 d_free 跳台阶。自由距离是网格里任意两条不同路径之间的最小汉明距离,它决定了卷积码在高信噪比下渐进误码率曲线的斜率。对 (2,1,3) 码 d_free = 5,对 (2,1,7) 码 d_free = 10,每增加一级约束都能带来可观的编码增益,代价是状态数翻倍。
选择约束长度没有万能答案,要结合帧长、实时性要求和实现平台。我一般给三条参考线。第一,学习验证最小用 K=3,状态数只有 4 个,出问题能直接手算核对状态表。第二,多数窄带控制链路用 K=7,生成多项式取 (171,133),这是常被引用的经典组合,性能和成本均衡。第三,极低信噪比或深空场景才会用 K=9 以上,配合软判决译码,此时状态数涨到 256 个,单路 MIPS 预算要提前算。下表是常用配置的汇总,按约束长度从小到大排:
| 码型 | R | K | 生成多项式(八进制) | 状态数 | 适用场景 |
|---|---|---|---|---|---|
| (2,1,3) | 1/2 | 3 | 7, 5 | 4 | 教学、最小验证 |
| (2,1,5) | 1/2 | 5 | 23, 35 | 16 | 低功耗传感器链路 |
| (2,1,7) | 1/2 | 7 | 171, 133 | 64 | 链路主用组合 |
| (3,1,3) | 1/3 | 3 | 7, 5, 7 | 4 | 低信噪比特例 |
落到选型时还有个容易忽略的点:约束长度越大,译码回溯深度也要跟着加,否则尾巴上几个比特必错。回溯深度一般取 K 的 5 到 10 倍,K=7 时建议 35 到 70,这部分到维特比实现章会给出具体建议值。整体上,如果系统对时延敏感且每帧只有几十字节,选 K=7 的 1/2 码成本很低;如果链路预算极度紧张,才值得为更大的 K 付出硬件和内存代价。
3. 从移位寄存器到可跑代码:一个能落地的卷积编码器
3.1 编码器就是移位寄存器加模二和
卷积编码器结构非常简单:一串移位寄存器,加上若干条模二和支路。输入比特从左侧进入,每来一个比特,所有寄存器向右挪一格,同时每个输出支路按生成多项式的抽头把对应位置的寄存器值与当前输入做异或,得到该支路的输出。因为输出不仅依赖当前输入,还依赖寄存器里的历史值,所以叫“卷积”码——这里的卷积是模 2 意义下的卷积。
写代码前有一个位序坑必须先讲清楚,否则必翻车。生成多项式给的是八进制数,比如 5,展开是二进制 101。在绝大多数实现里,八进制数的最低位对应当前输入比特,次低位对应第一个寄存器 D1,以此类推。也就是说 poly=5 的二进制 101,含义是“抽当前输入和 D2”,也就是 1 + D²,而不是“抽 D1”。
我在某跨平台系统里第一次写编码器时就踩过这个:直接用poly >> i & 1去和 reg[i] 相乘,结果 5 被当成抽 D1,编码输出全错,但译码器如果同样错着写,回环居然能通——直到换一组多项式才暴露。所以编码器和译码器必须共享同一套位序定义,且要在注释里写清楚。
3.2 一个可直接复制的 Python 编码器
下面这个编码器实现从移位寄存器和模二和的物理结构出发,poly_list 传入所有生成多项式,逐比特输出完整编码序列。注意循环里对 reg 的索引和位序的处理:
def conv_encode(bits, poly_list, constraint): # bits: 0/1 列表,信息比特 # poly_list: 生成多项式列表,如 [7, 5] # constraint: 约束长度 K,寄存器级数为 K-1 reg = [0] * (constraint - 1) coded = [] for b in bits: for poly in poly_list: acc = b # poly 二进制的最低位对应当前输入 b, # bit(i+1) 对应第 i 级寄存器 reg[i] for i, r in enumerate(reg): if (poly >> (i + 1)) & 1: acc ^= r coded.append(acc) # 输入比特进入移位寄存器,丢掉最老的一级 reg.insert(0, b) reg.pop() return coded逻辑说明:外层循环遍历每个信息比特,内层循环遍历所有生成多项式,每个多项式产生一个编码比特。acc 先取当前输入 b,再按多项式的抽头位置异或对应寄存器值。最后reg.insert(0, b); reg.pop()完成移位:新的输入放到寄存器头部,最老的丢弃,这和硬件移位寄存器行为一致。
参数说明:bits 长度是 N,poly_list 长度是 n,输出 coded 长度就是 N × n,即码率是 1/n。constraint 只决定寄存器级数,不改变输出长度。这个函数返回的是硬判决 0/1 序列,后续无论是做噪声注入还是软判决,都从这个序列出发。
用法和验证也很简单,可以直接手算核对。对输入 [1, 0, 1, 1],用 (7,5) 码,期望输出是 11 10 00 01,展开成列表就是 [1, 1, 1, 0, 0, 0, 0, 1]:
bits = [1, 0, 1, 1] poly_list = [7, 5] constraint = 3 print(conv_encode(bits, poly_list, constraint)) # 预期输出: [1, 1, 1, 0, 0, 0, 0, 1]建议拿到代码后先把这组简单向量跑通,再进入仿真。手算验证这一步能省掉后面排查译码器的一大堆时间,别直接跳到随机比特测试。
3.3 打孔变速率:从 1/2 母码到 2/3
实际系统不会只用一种码率:信道好时想多传数据,信道差时想多抗噪。打孔是最省事的变码率手段——先按母码编码,再按规则删掉一部分编码比特。以 1/2 母码为例,想变成 2/3 码率,每两个信息比特原本产生 4 个编码比特,删掉其中 1 个,保留 3 个,码率就从 1/2 变成 2/3。
打孔表用一个 0/1 列表表示,1 表示保留,0 表示删除。对 2/3 打孔,周期是 4 个编码比特,典型打孔表是 [1, 1, 1, 0]。编码侧实现只需要在完整编码序列上按周期过滤:
def punctured_encode(bits, poly_list, constraint, punc_pattern): # punc_pattern: 打孔表,1 保留 0 删除,按周期循环 coded = conv_encode(bits, poly_list, constraint) return [bit for idx, bit in enumerate(coded) if punc_pattern[idx % len(punc_pattern)]]逻辑说明:先调用基础编码器得到完整码流,再按打孔表周期遍历索引,只保留表中标记为 1 的位置。这里有个隐含约定:打孔表的长度必须是母码一个周期输出长度的整数倍。对 (2,1,3) 母码,每个信息比特产生 2 个编码比特,所以 2/3 打孔表的周期要取 4,覆盖两个信息比特的输出。译码端则需要把收到的高效码流“铺”回完整网格,删掉的位置补成 None,交给后续维特比译码器处理。
打孔后每帧长度会变化,接收端必须知道本帧打孔表是哪个,否则译码器长度对不齐。工程上打孔表通常由调制编码方式索引决定,帧头里带索引号,而不是每次收发协商。
3.4 收尾处理与起始状态清零
很多第一次写卷积码链路的人会在帧和帧之间翻车:上一帧编码完,移位寄存器里残留着上帧末尾的比特,下一帧直接接着编,相当于把上一帧的尾巴当成了这一帧的开头。结果是每帧开头几个比特的误码率明显偏高,但又查不出确定性错误。
解决方法是帧起始清零、帧尾补零。编码每个独立帧前,把寄存器全部置 0;编码完有效信息后,再补送 K-1 个 0,把寄存器状态“冲刷”回 0。这样接收端译码也可以强制从状态 0 出发、最终落在状态 0,首尾段的路径度量约束更强,误码率更稳:
def conv_encode_frame(bits, poly_list, constraint): # 帧尾补 K-1 个 0,使编码器回到全零状态 tail = [0] * (constraint - 1) return conv_encode(bits + tail, poly_list, constraint)逻辑说明:tail 的长度取 constraint-1,正是寄存器的级数。补 0 后编码器从任意状态经过 K-1 拍必然回到全零状态,代价是每帧多发送 (K-1) × n 个编码比特。对 K=7 的 1/2 码,多出 12 个比特,相对几百字节的帧来说开销可忽略。
参数说明:对于连续编码场景(比如长流音频),无法每帧清状态,这时要靠帧结构里的同步头和长度字段来定位,译码器用滑动窗连续工作,状态不清零是正确的。所以要先搞清楚你的系统是块传输还是连续流,再决定要不要做 flush 处理。我见过的最隐蔽问题就是:帧内清了,帧间没清,误码率统计出来不高不低,卡在某个数值上不去,排查大半天才发现是清状态的时机错了。
4. 维特比译码器:分支度量、路径回溯与最小可运行实现
4.1 分支度量、路径度量与幸存路径:维特比的四步
维特比译码流程可以压缩成四步:初始化、计算分支度量、加比选、回溯。初始化时给状态 0 赋度量 0,其他状态赋无穷大,理由是一切从全零状态出发。每一步收到 n 个编码比特后,对每个状态的两条入边分别计算“如果走这条边,和实际收到比特差多少”,这个差值就是分支度量。
分支度量分硬判决和软判决两种算法。硬判决把接收比特量化成 0/1,用汉明距离当分支度量,收到 00 而某条边输出 11,距离就是 2。软判决则保留量化后的幅度信息,用欧氏距离的平方当度量,能额外拿到约 1.5 到 2 dB 增益。硬判决实现简单、逻辑清晰,适合先跑通;软判决只是换一个距离函数,后续进阶很容易改。
加比选是维特比的核心:每个目标状态会有两条来自不同前一状态的候选路径,分别计算“前一状态的累计度量 + 当前分支度量”,选较小的作为该状态的新累计度量,并把这个幸存路径存储下来。反复执行到帧尾后,找到最小累计度量的状态,从它开始沿存储的幸存路径回溯,得到的就是最大似然序列。为什么追踪最小累计度量就是最优?因为对数似然可以逐拍累加,最小累计度量等价于整条路径的联合概率最大,这一步在数学上保证了维特比不是启发式搜索,而是精确最优。
4.2 硬判决维特比的最小可运行实现
下面这段代码是完整可跑的硬判决维特比,直接支持打孔场景——接收序列里被打孔删掉的位置用 None 填充,分支度量计算时会跳过这些位置。先贴完整实现,再逐块解释:
import numpy as np def viterbi_hard(rx_obs, poly_list, constraint): # rx_obs: 观测序列,0/1 为硬比特,None 表示被打孔位置 # poly_list: 生成多项式,与编码器保持一致 # constraint: 约束长度 K,状态数 = 2^(K-1) K = constraint num_states = 1 << (K - 1) n_out = len(poly_list) def state_reg(state): # 状态二进制高位在前:bit(K-2) 对应 D1,bit0 对应 D(K-2) return [(state >> (K - 2 - i)) & 1 for i in range(K - 1)] # 预计算所有转移:输入 (state, b) -> (下一状态, 输出序列) trans = {} for state in range(num_states): reg = state_reg(state) for b in (0, 1): out = [] for poly in poly_list: acc = b for i, r in enumerate(reg): if (poly >> (i + 1)) & 1: acc ^= r out.append(acc) ns = (b << (K - 2)) | (state >> 1) trans[(state, b)] = (ns, out) # 初始化累计度量,状态 0 为 0,其余为 inf pm = np.full(num_states, np.inf) pm[0] = 0.0 trace = [] n_step = len(rx_obs) // n_out for t in range(n_step): obs = rx_obs[t * n_out:(t + 1) * n_out] new_pm = np.full(num_states, np.inf) tr = {} for state in range(num_states): for b in (0, 1): ns, out = trans[(state, b)] # 分支度量:跳过 None(打孔位置) dist = 0 for ob, o in zip(obs, out): if ob is not None and ob != o: dist += 1 cand = pm[state] + dist if cand < new_pm[ns]: new_pm[ns] = cand tr[ns] = (state, b) pm = new_pm trace.append(tr) # 回溯:从最大似然状态反推输入比特 best = int(np.argmin(pm)) rev = [] for tr in reversed(trace): prev, b = tr[best] rev.append(b) best = prev return list(reversed(rev))逻辑说明:代码前半部分是预计算状态转移表,把每个 (state, b) 对应的下一状态和输出先算好,译码主循环只查表,避免每拍重复计算卷积。主循环里对每个状态的两条入边做加比选,dist是分支度量,cand是候选累计度量,只有比当前保存值更小才更新,这正是“幸存路径”的含义。回溯阶段从最小度量状态出发,逐拍取输入比特,再反转得到译码序列。
参数说明:rx_obs 长度必须是 n_out 的整数倍;如果不是,说明帧长或打孔恢复出了问题。poly_list 和 constraint 必须和编码侧完全一致,包括位序约定。状态数在 K=7 时是 64,累加度量最大值不超过帧长 × n_out,用 float 存完全够,不需要防溢出处理。
用第 3 章的编码结果做一次回环验证,是最低成本的正确性测试。无噪声时译码输出应该和原始信息比特完全一致:
coded = conv_encode(bits, [7, 5], 3) decoded = viterbi_hard(list(coded), [7, 5], 3) print(decoded) # 和 bits 一致这里用 list(coded) 而不是直接传 coded,是因为 viterbi_hard 内部不会修改输入列表,但如果后续要插入 None 表示打孔,必须显式拷贝出一份新列表再修改。这也是一个隐蔽的 Python 坑:如果直接把列表传入再原地改,会影响编码器的复用。
4.3 回溯深度和输出延迟:容易被忽略的窗口问题
上面代码是一次性处理完整帧再回溯,适合短帧。对长流传输,比如几万比特的语音帧或连续数据,不可能等整帧收完再译码,那样延迟不可接受。常见做法是滑动窗:每收 N 个编码比特就做一次局部回溯,只输出窗尾对应的信息比特,然后把窗往前挪。
回溯深度直接影响输出质量和延迟。太浅,窗尾附近的比特没有足够的历史来收敛路径,误码率会比理论值高不少;太深,延迟增大且内存占用翻倍。经验值是 5 到 10 倍约束长度。K=7 时取 35 到 70,基本能逼近理论性能。下表给出了常用 K 值的建议回溯深度和状态数,方便直接查:
| 约束长度 K | 状态数 | 建议回溯窗 | 输出延迟(信息比特) |
|---|---|---|---|
| 3 | 4 | 15~30 | 15~30 |
| 5 | 16 | 25~50 | 25~50 |
| 7 | 64 | 35~70 | 35~70 |
| 9 | 256 | 45~90 | 45~90 |
这里特别提醒:回溯窗的延迟不是“窗长减 K”之类简单公式,而是“窗长就近似等于输出延迟”。因为窗内最后 K-1 拍通常还在收敛中,工程上直接把前窗长的输出丢弃或当作无效。做协议对接时,物理层给 MAC 层上报数据必须带上这个固定延迟,否则上层做时间戳对齐时会莫名其妙地错位。我见过的翻车现场里,有一半以上不是译码错误,而是延迟没对齐导致比对错位,看起来像误码。
5. 卷积码落地避坑:五个让我反复翻车的真实场景
5.1 状态没清零:回环测试怎么都跑不对
现象:编码、加噪声、译码全链路跑起来,无噪声时回环测试竟然有少量误码,而且错误总出现在帧的开头或结尾。
原因:编码器上一帧的寄存器状态残留到这一帧。如果第一帧恰好从全零开始,第二帧开头就会被上一帧的尾巴污染;译码器却默认从状态 0 开始,两边状态假设不一致,维特比在起始段必然选错路径。
解决:每个独立帧编码前把寄存器清零,帧尾补 K-1 个 0 收尾。对应第 3.4 节的 conv_encode_frame 方法。如果是滑动窗连续流,则不要每帧清状态,改为用同步头和长度字段定位帧边界,再由译码器从帧同步位置开始工作。先确认系统是块传输还是连续流,再决定清不清。
5.2 输出对不齐:回溯深度和延迟窗口
现象:误码率仿真曲线在低信噪比区域和理论曲线贴合,但帧尾几个比特总是错,或者译码输出比原始信息比特“慢”了几拍。
原因:回溯深度太浅。帧尾部分的历史路径还没来得及收敛,被窗截断,导致尾部比特的幸存路径选择质量差。输出延迟则是滑动窗模式下固有的,不是 bug,是特性。
解决:把回溯深度调到 5 到 10 倍约束长度,K=7 时至少 35。工程上做实时译码时,从滑动窗输出数据要显式补偿固定延迟,协议层上报时减去该延迟。验证方法:对同一段数据分别用全帧回溯和滑动窗回溯各跑一次,比较尾部 K 个比特的差异,差异明显就说明窗长不够。
5.3 软硬判决结果一样:问题出在输入而非算法
现象:把硬判决译码器换成软判决后,误码率曲线几乎没动,增益只有 0.2 dB 而不是预期的 1.5 到 2 dB。
原因:软判决的输入根本没有保留软信息。常见误用是把接收信号先硬判决成 0/1,再以 0/1 喂给“软判决”接口,或者量化级数太少、量化范围没覆盖信号动态。分支度量再精巧,输入丢了信息也救不回来。
解决:软判决必须输入量化前的观测值,通常做法是提取每比特的似然比或直接输入解调后的软幅度。量化范围要覆盖信号的 3 到 4 个标准差,量化级数以 8 级起步,16 级更稳。验证方法:在 AWGN 信道下对比软硬判决两条曲线,若 1e-3 误码率点差距小于 1 dB,说明软信息链路里有环节把信息丢了,逐级检查量化、归一化和度量计算。
5.4 打孔码错位:收发打孔表不一致
现象:无噪声时打孔码回环测试就出错,错误位置呈周期性,每隔几个比特错一个,且错误模式和打孔周期相关。
原因:收发两端用了不同的打孔表。比如发送端删第 4 个比特,接收端却以为删第 1 个,恢复出来的观测序列全部错位,维特比等同于面对一条完全不同的码流。另一个常见原因是打孔表周期和母码输出长度没对齐,比如对 (2,1,3) 码却用了长度为 3 的打孔表。
解决:打孔表收发必须共享,并且定义成编码比特流的周期模式,长度取母码输出长度的整数倍。译码前先按打孔表把接收序列“铺”回完整长度,删掉位置补 None,再交给维特比。建议在帧头携带 MCS 索引而不是直接传表,接收端按索引查表,从根上避免不一致。
5.5 曲线异常或性能虚高:Eb/N0 换算和测试向量
现象:仿真出来的误码率曲线比教科书理论值“好”很多,或者横轴对不上,误码率 1e-4 时的信噪比点偏低 2 dB 左右。
原因:Eb/N0 和 Es/N0 换算出错。Es 是每个符号能量,Eb 是每个信息比特能量,二者相差一个码率因子。如果拿编码后的符号能量去当 Eb,相当于少扣了码率冗余的 2 倍,曲线自然虚高。另一个测试向量问题:仿真里反复用同一段随机数据,且没有避开全零码字,偶尔会出现异常低的误码点。
解决:统一按 Eb/N0 = Es/N0 - 10*log10(R) 换算,R 是母码码率,打孔后要按有效码率算。测试向量用随机种子固定但不重复,且帧长足够长,每点至少统计 100 个错误比特再记录误码率,避免置信区间太宽。画曲线时把硬判决和软判决放同一张图,和参考曲线对比,偏离超过 0.5 dB 就要回头查换算。
6. 验证入门的两个动作:回环测试与软判决升级
6.1 最小回环测试:三行代码验证编解码一致性
写完编码器和译码器后,第一件事不是跑 BER 曲线,而是跑无噪声回环测试。只有无噪声时能完全恢复,后续加噪声才有意义。这个测试能暴露编码器位序错、译码器状态数错、帧长不匹配等绝大多数低级问题:
def loopback_test(info_bits, poly_list, constraint): coded = conv_encode(info_bits, poly_list, constraint) decoded = viterbi_hard(list(coded), poly_list, constraint) return decoded == list(info_bits)更进一步,可以随机翻转几个编码比特,模拟轻度信道错误,确认译码器能纠回来。比如把第 6 个比特翻转,译码结果应仍然正确。如果这一步失败,先用第 4.2 节的固定向量 [1, 0, 1, 1] 手算核对,别急着怀疑信道模型。
6.2 软判决升级:把分支度量从汉明距离换成平方误差
软判决的改动其实非常小:把观测序列从 0/1 换成浮点幅度,分支度量不再数不同比特个数,而是算平方误差和。下面这段替换了 viterbi_hard 里的分支度量计算部分,其余加比选框架完全不变:
# 软判决分支度量:obs 为浮点软值,out 仍在 0/1 dist = 0.0 for ob, o in zip(obs, out): if ob is not None: soft_ref = 1.0 if o == 1 else -1.0 dist += (ob - soft_ref) ** 2逻辑说明:把编码比特 0/1 映射成 ±1,和观测软值做差平方,累加得到分支度量。这样接收幅度大的采样点贡献大,幅度小接近判决门限的采样点贡献小,相当于给了译码器“置信度”信息。增益大小取决于调制方式和信道,常见经验是相对硬判决多拿 1.5 到 2 dB,在 K=7 码上尤其明显。
我最早在模拟项目X上做卷积码链路时,把状态残留当成信道问题调了两天,最后发现只是编码帧间没清零;后来无论写什么编解码器,都先把回环测试跑成绿灯再加噪声,这个习惯保住了后面很多个项目的交付时间。这次代码都是从零能跑的,祝你一次跑通,希望帮到你。
本文还有配套的精品资源,点击获取