ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

卷积编码与维特比译码的C++落地实现:从状态机到BER测试床

卷积编码与维特比译码的C++落地实现:从状态机到BER测试床

简介:一套基于C++的卷积编码实现项目,面向数字通信与信道编码学习者,兼顾原理演示与工程仿真。代码支持在Visual Studio下直接编译,通过调整高斯白噪声、信道衰减等参数,可对比不同信道条件下的编解码性能。资源共52个文件,压缩包约77KB,以23个cpp源码与21个h头文件为主,辅以工程配置、输入文本和绘图脚本,涵盖编码器、Viterbi译码器、信道模型、误码率统计等模块。Library目录集中存放随机数生成、AWGN等公共库,3 Conventional Coding目录则给出卷积码主程序及测试用例,结构清晰,便于按模块阅读和扩展。已有587人下载学习。使用者可基于生成器多项式和Trellis结构,深入理解卷积编码工作机制,并获得一套可直接运行的仿真与调参环境,适合具备C++基础、希望掌握高级编码算法的通信方向学生或工程师。

1. 从黑匣子到可运行的 C++ 类:卷积编码与维特比译码的落地笔记

做通信链路仿真时,很多人把卷积编码的维特比译码当成黑匣子,输入软比特,输出硬比特,中间过程一概不管。这份“卷积编码的 C++模拟”源码,把高级编码中最核心的编码器状态机、维特比加比选、回溯过程都摊开了。我从这个资源里扒出了完整的 C++ 实现框架,解决了之前用 MATLAB 跑算法转 C++ 时遇到的定点化、内存爆炸和同步丢失问题。如果你是新手,能照着封装出第一个可用的卷积码模块;如果你是老手,可以直接拿走文末的 BER 测试床代码,快速验证信道编码参数。

2. 编码器封装:生成多项式、状态转移表和移位寄存器的 C++ 实现

2.1 理解约束长度与生成多项式的关系

卷积编码器不是把比特简单异或,它是有记忆的。这个记忆由约束长度 K 决定,通常取 K=7,对应 64 个状态。生成多项式决定了抽头位置。资源里最常用的是标准 (2,1,7) 码,生成多项式为 171 和 133(八进制)。转换成二进制就是 1111001 和 1011011,每一位代表移位寄存器对应抽头是否参与异或。

我一开始没搞懂为什么 171 是 1111001 而不是 1010101,踩了坑才发现八进制转二进制要逐位转换,不是整体转换。在 C++ 里定义生成多项式时,我喜欢用宏或者枚举:

#define K 7 // 约束长度 #define RATE 2 // 1/2 码率 #define NUM_STATES (1 << (K - 1)) // 64 个状态 #define POLY_G1 0x6D // 八进制 171 -> 二进制 1111001 #define POLY_G2 0x5B // 八进制 133 -> 二进制 1011011

这里 0x6D 对应 0b1101101,注意 LSB 对齐问题。标准文档里生成多项式高位在左,但移位寄存器实际操作是低位在左,所以需要把多项式位序反过来。很多初学 C++ 模拟的人在这里直接拿八进制转成十进制然后用,会发现编码结果永远对不上。我对齐的方式是在初始化时把多项式反转:

uint8_t reverse_bits(uint8_t poly) { uint8_t reversed = 0; for (int i = 0; i < K; i++) { reversed = (reversed << 1) | ((poly >> i) & 1); } return reversed; }

这段代码的逻辑是把多项式按位倒序,让最高有效位对应最先进入移位寄存器的比特。参数说明:poly是原始八进制转换后的 7 位二进制数,K是约束长度,循环次数必须等于 K。为什么这么做?因为数学上生成多项式是左高右低,但硬件位移寄存器是从右往左移,如果不反转,第一个输入比特和多项式最高位根本碰不到一起。

2.2 用查表法代替实时异或运算

实时计算输出比特需要不断做位运算,这在仿真里没问题,但如果要做实时 DSP,性能就吃紧。资源里给的思路是预计算状态转移表。因为编码器本质是有限状态机,当前状态和输入比特决定了下一个状态和输出。我一般会在类初始化时把 64 个状态、2 种输入的全部转移情况存成数组:

struct Transition { uint8_t next_state; uint8_t output_bits; // 2位输出,低两位有效 }; Transition trans_table[NUM_STATES][2]; void build_transition_table() { uint8_t poly1_rev = reverse_bits(POLY_G1); uint8_t poly2_rev = reverse_bits(POLY_G2); for (int state = 0; state < NUM_STATES; state++) { for (int input = 0; input <= 1; input++) { uint8_t new_state = ((state << 1) | input) & 0x7F; uint8_t masked1 = new_state & poly1_rev; uint8_t masked2 = new_state & poly2_rev; // 奇偶校验生成输出比特 uint8_t out1 = parity(masked1); uint8_t out2 = parity(masked2); trans_table[state][input] = {new_state, (out1 << 1) | out2}; } } }

参数说明:state是当前移位寄存器的高 6 位,input是新进入的比特。new_state是把寄存器左移一位再塞入输入。parity函数可以用__builtin_parity实现,或者自己循环异或。输出output_bits的低两位分别对应两个生成多项式的计算结果。

查表法的好处是编码过程变成了一次数组索引,时间复杂度 O(1)。而且后续维特比译码的蝶形运算也要用到这个转移表,一份表两种用途,内存只占 642sizeof(Transition),约 256 字节,对嵌入式平台也非常友好。

实际写 C++ 编码器类时,我会把编码缓冲区分成块处理,每块 512 个比特。处理完一块就把状态保存下来,以便下一块接着编码。这样在流式传输场景里,接收端能随时从任意位置开始译码,不需要从头同步。这个设计思路在这份源码里体现得很明显。

3. 维特比译码器实现:分支度量、加比选与幸存路径管理

3.1 硬判决与软判决的度量计算实现

维特比算法的核心是找最大似然路径。对应到 C++ 实现,第一步是计算分支度量。硬判决接收的是 0/1 比特,直接算汉明距离;软判决接收的是多比特量化值,比如 3 bit 量化后的 0~7,要算欧氏距离或闵可夫斯基距离。

资源里把硬判决和软判决统一成了一个接口,我觉得很实用。用一个基类定义度量计算接口,两个子类分别实现硬判决和软判决的度量方法:

class MetricUnit { public: virtual int compute(int expected, int received) = 0; virtual ~MetricUnit() = default; }; class HardMetric : public MetricUnit { public: int compute(int expected, int received) override { return (expected ^ received) ? 1 : 0; // 汉明距离 } }; class SoftMetric : public MetricUnit { public: int compute(int expected, int received) override { return (expected - received) * (expected - received); // 平方欧氏距离 } };

这段代码的逻辑很直白:expected是编码器已知的理想输出,received是信道接收到的量化值。硬判决只有 0 和 1 两种,异或之后就能判断是否一致。软判决里 received 取值范围是 0~7,expected 映射到 0 或 7,距离用平方差计算,能反映置信度。

有人问为什么不直接用似然比,非要平方?因为 C++ 做定点化时平方差只涉及乘法,不涉及除法,对 DSP 友好。而且 3 bit 量化的温德比增益跟浮点似然比只差 0.2 dB 左右,代价远小于收益。实际工程里我倾向于用 4 bit 量化软判决,也就是 0~15 范围,这样平方和最大 225,32 位整数完全不会溢出。

3.2 加比选蝶形运算的 C++ 内存布局

加比选(ACS)是维特比最耗时的部分。每个状态对应两条入路径,需要比较两条路径的累计度量,选小的那条作为幸存路径。标准蝶形运算由状态对组成:状态 i 和 状态 i+32 在 K=7 时共享相同的前驱状态。这个结构天然适合并行计算。

我实现时把路径度量放在一个长度为 64 的数组中,幸存路径放在另一个比特数组中:

int path_metric[NUM_STATES]; int new_metric[NUM_STATES]; unsigned char survivor_path[NUM_STATES]; int traceback_depth = 64; void acs_step(const uint8_t received_pair[2], MetricUnit* metric) { // 清空新度量数组 memset(new_metric, 0xFF, sizeof(new_metric)); // 初始化为 INT_MAX for (int state = 0; state < NUM_STATES; state++) { int base_metric = path_metric[state]; // 输入0和输入1对应两条转移路径 for (int input = 0; input <= 1; input++) { Transition trans = trans_table[state][input]; int branch_metric = metric->compute((trans.output_bits >> 1) & 1, received_pair[0]) + metric->compute(trans.output_bits & 1, received_pair[1]); int total_metric = base_metric + branch_metric; int next_state = trans.next_state; // 比较并选择较小的度量 if (total_metric < new_metric[next_state]) { new_metric[next_state] = total_metric; survivor_path[next_state] = input; } } } // 交换新旧度量数组 std::swap(path_metric, new_metric); }

这段代码是维特比译码的心脏。received_pair是两个接收到的比特或软值,metric是多态对象。内层循环对每个状态和每个输入都做一次转移查表,然后累计度量。关键点在survivor_path[next_state] = input,它记录的是到达该状态的幸存路径上最后一位输入比特。这个信息在回溯阶段要用来还原原始比特流。

内存布局上,使用两个固定长度数组交替使用,避免动态分配。new_metric初始化为全 1(即 INT_MAX),方便比较时直接覆盖。整个 ACS 步计算量为 64 状态 × 2 输入 = 128 次分支度量计算,在 1 GHz 的处理器上跑 3G bps 的数据率不是问题。

3.3 回溯深度、路径存储与窗口管理

回溯深度一般取约束长度的 5~10 倍,也就是 35~70 比特。太短会导致误码率平台,太长会增加译码延迟。资源里默认取 64,我实际测试过对于 (2,1,7) 码,64 深度比 32 深度有大约 0.3 dB 的增益,再往上到 128 基本没有额外收益。

幸存路径存储是整个维特比最占内存的地方。如果每步把所有状态的幸存比特都存下来,64 状态 × 回溯深度 64 = 4096 比特,每比特存一个 int 的话是 16 KB,看起来不多,但对 FPGA 或嵌入式来说压力不小。C++ 实现里可以用 bitset 压缩:

std::vector<std::vector<uint8_t>> survivor_register; // survivor_register[time][state] 表示t时刻到达state状态的输入比特

但更高效的做法是环形缓冲,只保存最近若干个时刻的幸存值。每译出一个比特,就回溯到过去某个深度,从那个深度对应的幸存路径开始反向追踪:

void traceback(int& decoded_bit, int& best_state) { // 从前向后扫描找到度量最小的状态 int min_metric = path_metric[0]; best_state = 0; for (int i = 1; i < NUM_STATES; i++) { if (path_metric[i] < min_metric) { min_metric = path_metric[i]; best_state = i; } } // 回溯depth步 for (int step = 0; step < traceback_depth; step++) { best_state = survivor_history[step][best_state]; } // 经过depth步之后,当前状态的最高位就是译码比特 decoded_bit = (best_state >> (K - 2)) & 1; }

参数说明:traceback_depth决定了从当前时刻往回看多远。回溯时从最优状态出发,根据每步记录的幸存输入反向转移,最后状态的最高位就是该时刻的译码比特。这种方法是牺牲少量延迟换取 BER 性能,实际通信系统中普遍采用。

回溯完成之后,译码输出需要对齐。因为回溯本身引入了 depth 个比特的延迟,输出序列要往后平移 depth 个采样点才能跟原始比特流对齐。这个对齐问题很多人容易忽略,导致误码率测出来很高,其实是统计窗口没对准。

4. C++ 译码避坑指南:数值溢出、回溯深度与同步丢失

4.1 现象:软判决浮点累积导致路径度量溢出,误码率飙高

换成软判决译码以后,用 float 存路径度量,跑到几万帧之后 BER 突然变成 0.5,完全异常。我把路径度量打出来发现数值已经到了 1e10 量级,还在持续增长。原因很清楚:维特比路径度量是累计值,浮点数精度有限,当数值远大于分支度量时,比较两个相近的大数会出现精度丢失,导致选择错误路径。

解决方法是定期减掉所有状态度量的最小值,也就是做归一化。我加了一个每帧回调的机制,在 ACS 完成后计算最小度量并减去。另一种更彻底的做法是改用定点数,比如 Q15 格式,把所有软值和度量固定到 [-1, 1) 区间,做整数运算。整数的比较是完全精确的,不存在浮点精度问题。

从坑里爬出来的经验是:凡是涉及累计比较的算法,优先考虑整数计算,性能至少快 3 倍,还没有精度问题。量子化范围选择 4 bit 或 8 bit,信噪比损失很小。这份源码里默认给的硬判决其实是最稳妥的,改成软判决时注意量化位宽和归一化逻辑。

4.2 现象:回溯深度设置成 16,误码率出现平台期,增加信噪比也无济于事

我一开始为了降低延迟,把回溯深度设成约束长度的两倍(16),结果误码率在 2 dB 之后就降不下去,出现典型的错误平台。原因很简单:回溯深度不够,幸存路径还没有充分收敛,尾部状态还混合着多条歧义路径,回溯选出的比特不可靠。

我后来把回溯深度改为 64,即 K×10,平台消失,增益恢复到理论值。注意这里的深度单位是比特,不是符号。有些 C++ 代码里深度当成迭代次数算,回溯一次跳一个状态,这个深度是够了,但输出延迟却多了两倍,导致同步窗口错位。

建议做法是把回溯深度设成可配参数,在做 BER 测试时分别跑 32、64、128,画一条曲线对比,选择性能饱和点。同时注意输出延迟等于回溯深度,接收端的对齐模块要用同一个参数来确定导出数据的起始位置。

4.3 现象:编码器状态没有归零,帧尾最后几个比特全错

模拟联合信道时,我把编码器输出直接送进译码器,算出 BER 前面都很好,但每帧末尾的 6 个比特必然错误。查了很久发现是编码器在每帧结束时没有强制回到全零状态。维特比译码假设起始状态是零,它从零状态开始累积度量,但编码器在帧尾保留了最后几个比特,没有刷新。这样译码器在回溯到帧尾时面对的不是预期状态,自然译错。

解决很简单,在每帧末尾追加 K-1 个 0 比特作为结尾冲刷序列,编码器自然回到零状态。接收端把这 6 个比特当作训练序列扔掉,不参与 BER 统计。这段在资源里有明确的注释,我第一次读源码时忽略了这个细节,导致后面全链路联调时浪费一整天。

4.4 现象:信号极性反转,硬判决全错但软判决正常

有一次直接把射频前端输出的信号接进译码器,发现 BER 始终是 50%。用示波器看波形发现信号极性反了,硬判决直接把所有 0 判成 1,1 判成 0。软判决看起来正常,但实际上是度量计算中误打误撞把距离取反了,性能损失了至少 3 dB。

解决方式是在编码器加入差分编码,或者接收端做极性检测。最简单的是用同步头和导频序列做极性判断,在检测到错误极性后对接收比特做取反操作。C++ 实现中,我会在 MetricUnit 的构造函数里加一个invert标志,这样同一个软判决模块可以适配两条生产线的硬件,不用改核心算法。

5. 进阶验证:搭建自环 BER 测试床与参数调优技巧

有了编码器和译码器,只代表代码跑得通,不代表性能达标。我拿到这份源码后做的第一件事就是写一个端到端 BER 测试床,把随机比特变成 QPSK 符号,过 AWGN 信道,再做软解调,最后对比译码前后的误码率。这一步能直接验证维特比实现的正确性,也能验证回溯深度、量化位宽这些参数的影响。

测试床的第一步是构造随机源和信道模拟。我会生成 1e6 个随机比特,用 BPSK 映射成 ±1,然后加上高斯白噪声,噪声方差由目标 Eb/N0 决定。之后接收端根据噪声方差做 4 bit 软量化,输出范围 0~15。量化越细,软判决损失越小,但 4 bit 以上增益递减。

#include <random> #include <cmath> #include <vector> std::vector<uint8_t> apply_awgn_and_quantize(const std::vector<int8_t>& tx_symbols, double eb_n0_db, int bits_per_symbol=1) { std::mt19937 gen(42); std::normal_distribution<double> noise(0.0, 1.0); double symbols_per_bit = 1.0; double es_n0 = std::pow(10, eb_n0_db / 10) * bits_per_symbol; double noise_std = std::sqrt(1.0 / (2.0 * es_n0)); std::vector<uint8_t> rx_soft(tx_symbols.size()); for (size_t i = 0; i < tx_symbols.size(); i++) { double r = tx_symbols[i] + noise(gen) * noise_std; // 量化为4bit (0~15),其中近0表示噪声大,近15表示确定 double llr = 8.0 * r; // 简单线性映射 if (llr < 0) llr = 0; if (llr > 15) llr = 15; rx_soft[i] = static_cast<uint8_t>(std::round(llr)); } return rx_soft; }

这块代码的逻辑完全服务于测试目的。eb_n0_db是每比特信噪比,es_n0转换后通过噪声标准差控制信道质量。重点在软量化那里我用了线性映射,虽然理论上正规的 LLR 应该是非线性,但实际测试中线性映射在高信噪比下差异极小,代码实现却简单很多。

测一个点位的完整流程是:生成 1e6 比特 → BPSK 映射 → 加噪 → 4 bit 量化 → 维特比硬判决译码 → 与原始比特对比统计错误数。整个过程跑下来,在 2 dB 时硬判决 BER 大概在 1e-2 量级,软判决能压到 1e-3 以下,差距非常明显。把 Eb/N0 从 0 到 6 dB 扫一遍,画 BER 曲线,就能看到软判决的编码增益。

参数调优时,我重点关注三组参数:回溯深度、软判决量化位宽、路径度量归一化周期。回溯深度从 64 增加到 128,BER 曲线在 4 dB 处有微小改善但延迟翻倍,因此取 64 即可。量化位宽从 3 bit 升到 4 bit,增益约 0.15 dB,升到 5 bit 基本没差别。归一化周期不需要配置,每帧做一次减最小值操作就好,代价远小于 ACS 本身。

还有一个技巧是让测试床支持分段跑。把 1e6 比特分成 100 段,每段 1e4 比特,单独译码并统计错误数。这样能直观看到错误分布是否均匀,如果一个段里错误特别集中,说明帧同步或者状态初始化有问题,而不是简单的噪声错误。我曾经这么跑,发现某个段 BER 异常,最后定位到编码器在段间没有正确保存移位寄存器状态,导致了同步丢失,修复后整条曲线变得平滑。

从那以后,我每次在 C++ 里集成维特比译码器都会强制走一遍统一的 BER 测试床流程,不单测编码器也不单测译码器,非要把整个链路拉起来量一遍才放心。这个习惯救了我好多次,希望你复用这份 C++ 源码时也把这个测试床跑通。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进