☰
卷积码Viterbi译码MATLAB实现:从编码器到BER曲线避坑指南
2026/10/13 20:29:00 网站建设 项目流程

简介:这份资源是一套自编的卷积码编解码MATLAB实现,面向通信原理、信息论与编码课程的学习者及需要完成相关实验或作业的学生。内容围绕卷积码的编码与Viterbi译码展开,涵盖移存器与生成多项式编码、网格图路径搜索、不完全路径长度计算,以及硬判决采用Hamming距离、软判决采用Euclidean距离等关键知识点,可帮助理解编码效率、约束长度与译码复杂度之间的关系。压缩包共5个文件,约39KB,包含2个m脚本、1个fig图形文件、1个doc文档和1个mdl模型文件,分别对应算法实现、结果可视化、原理说明与Simulink仿真,便于对照代码与理论逐步调试。目前已有2072人学习下载,适合作为课程作业参考或Viterbi算法入门实践素材。

1. 从一次通信课设翻车说起:这份卷积编解码 MATLAB 代码到底能干什么

带过几届通信原理课设之后,我发现一个规律:真正卡住人的从来不是 Viterbi 译码的数学推导,而是“我明明按公式写了,为什么误码率曲线就是不对”。卷积码这个东西,课本上讲状态转移、网格图、生成多项式,一套下来头头是道,可一旦让你自己从零写一份能跑通、能画 BER 曲线、能改约束长度的 MATLAB 代码,很多人就懵了。这份自己写的卷积编解码 MATLAB 代码,解决的正是这个断层——它不是工具箱里convenc/vitdec的封装调用,而是把编码器的移位寄存器、译码器的分支度量计算、加比选(ACS)和回溯路径全部摊开写给你看。适合正在做通信课设、准备面试被问信道编码、或者想搞明白 Viterbi 到底怎么一步步“活”下来的从业者和学生。下面我按“先跑通、再拆解、后调参”的顺序,把这份代码的用法和坑一次讲透。

2. 卷积码编码器:从生成多项式到移位寄存器的落地实现

2.1 为什么不用 MATLAB 自带函数,非要自己写

MATLAB 通信工具箱确实提供了convenc和vitdec,两行代码就能完成卷积编解码。但问题在于,工具箱是个黑匣子——你传进去的是生成多项式矩阵,出来的是一串比特,中间的状态转移、寄存器抽头、尾比特处理全被封装了。课设答辩时老师问一句“你的编码器约束长度是多少,寄存器怎么移位的”,调用工具箱的人往往答不上来。

自己写编码器的价值在于:你能精确控制每一个比特进出寄存器的时刻,能打印出每个时刻的寄存器状态,能验证生成多项式矩阵的每一行到底对应哪几个抽头。这份代码的编码器部分就是按这个思路写的——用一个移位寄存器数组模拟硬件行为,逐时钟推进,每个时钟周期根据抽头位置做模二加,输出编码比特。常见做法是用一个for循环遍历输入比特流,内部维护一个长度为约束长度减一的寄存器状态向量。

2.2 编码器的核心代码与参数说明

先看编码函数的主体结构。假设约束长度为 3,生成多项式为 [7, 5](八进制),对应二进制 111 和 101,即每个输出比特与当前输入及前两个历史比特做模二加:

function encoded = conv_encode(bits, gen_poly, constraint_len) % bits: 输入比特行向量 (0/1) % gen_poly: 生成多项式八进制表示,如 [7 5] % constraint_len: 约束长度 K % encoded: 输出编码比特流 n = length(gen_poly); % 每输入1比特输出n比特 m = constraint_len - 1; % 寄存器级数 reg = zeros(1, m); % 初始化移位寄存器 encoded = zeros(1, n * length(bits)); % 将八进制生成多项式转为二进制抽头矩阵 taps = zeros(n, constraint_len); for i = 1:n taps(i, :) = de2bi(oct2dec(gen_poly(i)), constraint_len, 'left-msb'); end for k = 1:length(bits) % 当前寄存器内容:输入比特 + 历史状态 full_reg = [bits(k), reg]; for j = 1:n encoded((k-1)*n + j) = mod(sum(full_reg .* taps(j, :)), 2); end % 移位更新:新比特进入,最老的移出 reg = [bits(k), reg(1:end-1)]; end end

逻辑说明:taps矩阵把八进制生成多项式展开成二进制抽头模式,每一行对应一个输出支路。full_reg把当前输入比特和寄存器历史拼在一起,与抽头做点乘后模二加,得到该支路的输出。移位操作reg = [bits(k), reg(1:end-1)]保证最老的比特被挤出。

参数说明:constraint_len决定寄存器级数,K 越大纠错能力越强但译码复杂度指数增长;gen_poly的选择直接影响码的距离谱,常用的 (2,1,3) 码生成多项式就是 [7, 5]。注意de2bi的位宽参数必须等于constraint_len,否则抽头对不齐,编码结果会整体错位——这是新手最容易翻车的地方。

2.3 尾比特处理:一个容易被忽略的细节

卷积码编码有一个绕不开的问题:输入比特流结束后,寄存器里还残留着历史状态。如果不做处理,译码器在末尾几个时刻的分支度量会缺少后续信息,导致尾比特译码错误率飙升。标准做法是在信息比特后面补constraint_len - 1个零比特,把寄存器“冲干净”,这叫尾比特(tail bits)。

这份代码在编码前会自动在输入序列末尾补零,译码后再把对应的尾比特丢弃。如果你自己改代码,切记补零的数量必须等于寄存器级数,少补一个都会让最后几个比特的译码变成玄学。我一般会在编码函数入口处加一行断言检查,确保调用者传入的比特流长度和尾比特处理逻辑一致。

3. Viterbi 译码器:分支度量、ACS 与回溯的完整拆解

3.1 网格图构建:状态数为什么是 2 的幂

Viterbi 译码的核心是网格图(trellis)。对于约束长度 K、每输入 1 比特输出 n 比特的卷积码,状态数等于 2^(K-1)。比如 K=3 时有 4 个状态,K=7 时有 64 个状态。每个时刻,每个状态有两条入边(对应输入 0 和输入 1),译码器要做的就是计算每条边的分支度量,然后做加比选。

这份代码用一个state_table矩阵预存所有状态转移关系:行表示当前状态,列表示输入比特,矩阵元素是下一状态和对应的输出比特。构建方式很直接——把状态编号转为二进制,左移一位后补上输入比特,再取低 K-1 位就是下一状态。输出比特则用编码器同样的抽头逻辑算出来。

function [next_state, output_bits] = build_trellis(constraint_len, gen_poly) num_states = 2^(constraint_len - 1); n = length(gen_poly); next_state = zeros(num_states, 2); output_bits = zeros(num_states, 2, n); taps = zeros(n, constraint_len); for i = 1:n taps(i, :) = de2bi(oct2dec(gen_poly(i)), constraint_len, 'left-msb'); end for s = 0:num_states-1 state_bin = de2bi(s, constraint_len-1, 'left-msb'); for inp = 0:1 full_reg = [inp, state_bin]; for j = 1:n output_bits(s+1, inp+1, j) = mod(sum(full_reg .* taps(j,:)), 2); end next_state(s+1, inp+1) = bi2de([inp, state_bin(1:end-1)], 'left-msb'); end end end

这段代码的关键在于next_state的计算:新状态等于输入比特拼接当前状态的高 K-2 位。output_bits存的是三维数组,第三维是输出支路编号。参数constraint_len一变,状态数翻倍,整个表的大小跟着变,所以译码复杂度对 K 是指数敏感的。

3.2 分支度量与 ACS:硬判决和软判决的差别

分支度量衡量的是“接收到的比特”和“网格图上预期输出”之间的差异。硬判决下,接收端先做二值化,度量就是汉明距离——预期输出和接收比特不同的位数。软判决则保留解调后的似然比信息,用欧氏距离做度量,性能通常能提升 2 dB 左右。

这份代码默认走硬判决路径,分支度量用sum(xor(expected, received))计算,简单直接。ACS 单元对每个状态比较两条入边的累积度量,保留小的那条,同时记录幸存路径。代码里用一个path_metric数组存累积度量,一个survivor矩阵存每个时刻每个状态的前驱状态。

% ACS 核心循环(硬判决) for t = 1:num_steps for s = 1:num_states % 两条入边的分支度量 bm0 = sum(xor(squeeze(output_bits(prev0, 1, :))', rx_bits((t-1)*n+1:t*n))); bm1 = sum(xor(squeeze(output_bits(prev1, 2, :))', rx_bits((t-1)*n+1:t*n))); % 累积度量比较 if path_metric(prev0) + bm0 <= path_metric(prev1) + bm1 path_metric(s) = path_metric(prev0) + bm0; survivor(t, s) = prev0; else path_metric(s) = path_metric(prev1) + bm1; survivor(t, s) = prev1; end end end

参数说明:prev0和prev1是当前状态的两条入边对应的前驱状态,需要从网格表中反查。rx_bits是接收比特流,按每 n 个一组切片。注意path_metric在每轮开始前要初始化为上一轮的累积值,不能清零——清零等于把历史信息全丢了,译码结果会完全随机。

3.3 回溯译码:从终止状态倒推最优路径

ACS 循环结束后,每个状态都有一个累积度量。理论上应该从度量最小的状态开始回溯,但如果加了尾比特,终止状态必然是 0 状态,直接从状态 0 回溯即可。回溯过程就是沿着survivor矩阵从最后一个时刻往前走,每个时刻记录下前驱状态,同时根据状态转移反推输入比特。

% 回溯 decoded = zeros(1, num_steps); current_state = 0; % 尾比特保证终止于0状态 for t = num_steps:-1:1 prev = survivor(t, current_state + 1); % 根据 prev -> current_state 的转移反推输入比特 decoded(t) = find_transition_input(prev, current_state, next_state); current_state = prev; end % 去掉尾比特 decoded = decoded(1:end - (constraint_len - 1));

回溯方向千万别搞反——从后往前是标准做法,从前往后需要额外存储前向路径,内存开销大且容易出错。find_transition_input是一个查表函数,在next_state矩阵里找prev状态下哪个输入对应current_state。如果找不到,说明网格表构建有问题,通常是生成多项式或约束长度传错了。

4. 避坑与排查:那些让 BER 曲线跑偏的常见问题

4.1 现象:误码率曲线在高信噪比下不降反升

原因:回溯深度不够。Viterbi 译码理论上需要回溯到序列起点才能保证最优,但实际实现中通常只回溯5 * constraint_len个时刻就截断。如果回溯深度设得太小,幸存路径还没收敛就被强制截断,高信噪比下错误反而累积。

解决:把回溯深度设为5 * constraint_len以上,K=3 时至少 15,K=7 时至少 35。代码里用一个traceback_depth参数控制,别图省内存把它设成个位数。

4.2 现象:编码输出全是 0 或者全是 1

原因:生成多项式转二进制时位宽不对。de2bi默认用最小位宽,比如八进制 7 转二进制是 111 需要 3 位,但如果约束长度是 4,抽头矩阵就会少一列,导致寄存器对齐错位。

解决:de2bi必须显式指定位宽为constraint_len,并且用'left-msb'保证高位在前。这个坑我在三个不同的课设项目里都见过,每次都是同一个原因。

4.3 现象:译码结果比不编码的误码率还高

原因:ACS 循环里path_metric没有正确初始化或没有跨时刻累积。常见错误是每轮循环都把path_metric清零,导致译码器变成“只看当前时刻”的短视决策,完全丧失了 Viterbi 算法的优势。

解决:path_metric只在译码开始时初始化一次(通常全零),之后每轮 ACS 都在上一轮的基础上累加。可以在循环里加一句打印语句观察度量值是否单调递增,如果发现每轮都归零,就是初始化位置写错了。

4.4 现象:不同信噪比下 BER 曲线抖动剧烈

原因:蒙特卡洛仿真次数不够。每个信噪比点只跑了几百个比特,统计涨落太大,曲线看起来像心电图。

解决:每个信噪比点至少跑 10^5 个比特,高信噪比下要跑到 10^6 以上才能看到稳定的误码率。代码里用一个num_bits参数控制仿真长度,调试阶段可以设小一点快速验证逻辑,出图时务必加大。

4.5 现象:软判决译码性能反而比硬判决差

原因:软判决的度量计算用错了距离公式。硬判决用汉明距离,软判决应该用欧氏距离,如果软判决代码里还在用xor算度量,相当于把软信息当硬比特用,性能自然上不去。

解决:软判决分支度量改为sum((expected * 2 - 1 - rx_soft).^2),其中rx_soft是解调后的似然比或软符号。注意符号映射关系,0 映射为 +1 还是 -1 要和调制端保持一致,否则度量会反向。

5. 进阶调参:约束长度、码率与 puncturing 的取舍

5.1 约束长度不是越大越好

约束长度 K 从 3 增到 7,编码增益大约提升 2 dB,但译码复杂度从 4 个状态暴涨到 64 个状态,ACS 计算量增加 16 倍。在 MATLAB 里跑 K=9 的仿真,一晚上可能只跑完一个信噪比点。我的经验是:课设演示用 K=3 或 K=4 足够,实际工程里 K=7 是性能和复杂度的平衡点,再往上就要考虑专用硬件了。

5.2 码率调整与 puncturing 实现

这份代码默认码率是 1/n,比如 (2,1,3) 码率就是 1/2。如果想做 2/3 或 3/4 码率,需要用 puncturing——按一定模式删除部分编码比特,接收端在对应位置插入擦除标记再译码。代码里预留了puncture_pattern参数,传入一个 0/1 矩阵,1 表示保留,0 表示删除。

% Puncturing 示例:从 1/2 码率得到 2/3 码率 puncture_pattern = [1 1; 1 0]; % 每2个输入比特删除1个编码比特 encoded_punctured = []; for k = 1:size(puncture_pattern, 1) block = encoded((k-1)*n+1 : k*n); encoded_punctured = [encoded_punctured, block(puncture_pattern(k,:) == 1)]; end

译码时要在删除位置插入NaN或擦除标记,分支度量计算时跳过这些位置。注意 puncturing 会降低纠错能力,码率越高性能越差,这是信息论决定的,没有后悔药可吃。

5.3 用 BER 曲线验证译码正确性

最终验证手段就是画 BER 曲线。横轴 Eb/N0 从 0 dB 到 8 dB,纵轴误码率取对数。正确的曲线应该随信噪比单调下降,且在 4-5 dB 附近出现明显的“瀑布区”。如果曲线在高信噪比下出现平台(error floor),通常是回溯深度不够或尾比特处理有误。我一般会把理论值和仿真值画在同一张图上对比,偏差超过 1 dB 就要回头查代码。

从那以后我每次写完卷积编解码,都会先用全零序列跑一遍——全零输入下编码输出应该全是零,译码结果也应该全是零,这个最简单的测试能过滤掉八成以上的低级错误。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询