☰
卷积编码C++实现:维特比译码与误码率仿真源码详解
2026/10/19 9:02:01 网站建设 项目流程

简介:这是一个面向数字通信与C++开发者的卷积编码实现项目,适合需要在编码理论、Viterbi译码与信道仿真之间打通动手环节的读者。项目可在Visual Studio下直接编译调试,支持调整通道参数以模拟高斯白噪声、信道衰减等传输条件,用于观察不同生成器多项式下的抗干扰性能。压缩包共52个文件,包括23个C++源文件、21个头文件、3个工程配置与1个解决方案文件,另有输入配置、误码率绘图脚本等辅助内容;目录按Library基础库、3 Conventional Coding卷积编码主模块等方式分区组织。编码部分涵盖编码器、基于Trellis与Viterbi算法的译码器,以及AWGN、BSC等信道仿真模块,便于逐步跟踪编码输出与误码率变化。已有587人浏览学习,包体仅77KB,尤其适合在高级编码或C++仿真方向上做实验验证与二次扩展的通信专业师生和工程师。

1. 卷积编码的 C++ 实现:一份能跑通的信道纠错模拟源码

做无线通信仿真、数字信号处理实验,或者准备手撕 FEC(前向纠错)相关面试题的人,大概率会碰到同一个需求:要在 C++ 工程里加一个卷积编码模块,不是调 MATLAB 工具箱,而是从编码器到维特比译码器完整自己实现。这份「卷积编码的 C++ 实现」源码包解决的就是这个问题——它把一颗标准的 (2,1,3) 卷积码编码器、对应的维特比硬判决译码器,以及一条带高斯白噪声的 BPSK 信道模拟链路全部写好,拿到手直接编译就能跑出误码率结果。它适合两类人:一类是想在 C++ 里做高级编码算法模拟的学生和研究人员,另一类是要把卷积码集成进自己通信链路、需要对编码结构逐位掌控的开发者。下面我会把源码的建模方式、核心实现和改造思路一层层拆开讲。

2. 架构与数据结构:把卷积码网格图装进转移表和路径度量

2.1 为什么用 C++ 做卷积码模拟:位操作与内存布局的优势

先回答一个很多人会问的问题:卷积码的编码和维特比译码,用 Python 或者 MATLAB 写起来不是更快吗?答案是:模拟是快了,但离「真实可用」远了。卷积编码本质是逐位异或和移位寄存器的操作,C++ 的位运算可以在一两个指令周期内完成一个 bit 的编码;维特比译码的加比选(ACS,Add-Compare-Select)循环要对每个状态反复计算路径度量,用 C++ 的数组和 vector 管理内存,可以做到完全可控的连续内存访问,这对后续做软判决、删余(Puncturing)、甚至把模块移植到嵌入式平台都是友好的。

另外还有一个现实原因:做通信链路仿真时,往往需要把卷积码和交织器、QAM 调制、信道估计等模块串在同一个工程里,Python 的 GIL 和动态类型在这种逐 bit 密集计算场景下会明显拖慢整个仿真链路。C++ 的模拟代码可以直接被主工程调用,不用再费劲做跨语言封装。所以这个项目选择纯 C++ 实现,不是为了演示语法,而是为了让编码译码这个核心黑匣子能被高效地嵌入到更大的仿真系统里。

我自己的习惯是:先用 C++ 把编码器和译码器做成独立的模块,再写一个 main.cpp 做链路级模拟。这样核心算法和实验入口分离开,改参数、加新功能都不需要动底层代码。

2.2 源码目录与数据流:从 bit 流到网格图的映射

这份源码采用了一个很干净的目录拆分方式,适合直接作为模板复用到自己的工程里。核心文件如下:

文件路径职责
src/conv_codec.h编码器与译码器类的声明,常量与数据结构定义
src/conv_codec.cpp状态转移表生成、编码函数、维特比译码函数
src/main.cpp命令行参数解析、BPSK 加噪信道模拟、误码率统计
CMakeLists.txtCMake 构建脚本,也支持直接用 g++ 编译
run.sh一键编译并运行示例参数的脚本

从数据流角度看,整个程序的处理链路是:输入一段二进制信息比特,送入编码器得到两倍长度的编码比特;编码比特做 BPSK 映射(0 映射为 -1,1 映射为 +1)后叠加高斯白噪声;接收端先硬判决还原成 0/1 比特,再送入维特比译码器,最后输出恢复出的信息比特。这个流程跟真实通信系统的基带仿真链路一致,后面你换软判决、换调制方式,都只需要替换链路中的某一个环节。

理解卷积码的关键在于网格图(Trellis)。每次输入 1 个比特,移位寄存器状态从当前状态跳到下一个状态,同时产生 2 个输出比特。网格图展开后就是一张「状态 × 时间」的路径图,维特比译码就是在所有可能路径中找一条度量最小的幸存路径。用 C++ 表达这张图,最常见的手段就是预计算状态转移表和输出表。

2.3 转移表与度量表的初始化:避坑从数据结构开始

下面这段代码是编码器模块的数据结构基础。它把「当前状态 + 输入比特 → 下一状态 + 输出比特」的关系预先算好,存成两张查询表。这样编码和译码的热循环里只需要查表,不用每次现算多项式逻辑。

// conv_codec.h 中的关键定义 constexpr int K = 3; // 约束长度,对应 2 级移位寄存器 constexpr int M = K - 1; // 移位寄存器个数 constexpr int NUM_STATES = 1 << M; // 状态数 = 4 constexpr int POLY_G1 = 07; // 生成多项式 111 constexpr int POLY_G2 = 05; // 生成多项式 101 struct TransEntry { int next_state; // 输入 in 后跳到的下一状态 int out_bits; // 两位输出打包:bit1<<1 | bit0 }; // 下标 = state * 2 + in static TransEntry g_trans[NUM_STATES * 2]; void BuildTransTable() { for (int state = 0; state < NUM_STATES; ++state) { // 从状态号反解两个寄存器的值:reg0 是离输入最近的一级 int reg0 = (state >> 1) & 1; int reg1 = state & 1; for (int in = 0; in < 2; ++in) { int next = (in << 1) | reg0; // 新寄存器值:reg0'=in, reg1'=reg0 int out0 = in ^ reg0 ^ reg1; // 多项式 111:in、reg0、reg1 三个位异或 int out1 = in ^ reg1; // 多项式 101:跳过 reg0,in 与 reg1 异或 g_trans[state * 2 + in] = {next, (out0 << 1) | (out1 & 1)}; } } }

这段代码的核心逻辑是状态编码约定:state = (reg0 << 1) | reg1,其中 reg0 是当前时钟周期里离输入最近的移位寄存器。编码时先算输出,再移位,所以输出公式用的是当前寄存器的值,而next用的是移位后的值。参数上,POLY_G1 = 07表示二进制 111,说明输出位 1 由输入、reg0、reg1 三者异或得到;POLY_G2 = 05表示二进制 101,说明输出位 2 只由输入和 reg1 异或得到。

这里有一个常见的误解:很多人以为生成多项式07和05一定要对应特定的上下位置,其实只要编码器内部和译码器的转移表使用同一套约定,任何合理的多项式组合都能正确工作。但要保持交叉验证一致,否则会出现「编码结果对、译码全错」的诡异问题。下一章我会把这份表的实际编码效果和译码逻辑完整对应起来。

3. 核心代码拆解:编码器、维特比译码与误码率统计

3.1 编码器实现:一步一移位,输出两位校验

编码器实现采用的是最直观的方式:维护两个移位寄存器变量,每进来一个 bit,先计算两位输出,再把寄存器往前推。完整代码如下:

// conv_codec.cpp 中的编码函数 std::vector<uint8_t> ConvCodec::Encode(const std::vector<uint8_t>& data) { std::vector<uint8_t> output; output.reserve(data.size() * 2 + M * 2); // 预留输出空间,避免反复扩容 int reg0 = 0, reg1 = 0; // 两级移位寄存器,初始状态为全 0 // 内部函数:处理单个输入 bit auto step = [&](int in) { int out0 = in ^ reg0 ^ reg1; // 生成多项式 111 int out1 = in ^ reg1; // 生成多项式 101 output.push_back(out0 & 1); output.push_back(out1 & 1); // 移位:新输入进入 reg0,原 reg0 滚到 reg1 reg1 = reg0; reg0 = in; }; // 逐比特编码 for (uint8_t b : data) { step(b & 1); } // 收尾:补 M 个 0,把寄存器状态清回全 0 for (int i = 0; i < M; ++i) { step(0); } return output; }

这里的核心动作在step内部函数里:先根据当前寄存器的两个值和输入 bit 计算出两个输出位,再完成移位。out0 = in ^ reg0 ^ reg1对应生成多项式 111 的三个抽头,out1 = in ^ reg1对应生成多项式 101 的两个抽头。输出时我把两位分别 push 进同一个数组,所以编码后的 bit 流顺序是「输出位 1、输出位 2、输出位 1、输出位 2……」,如果你的后续模块期望的是「先全输出位 1 再全输出位 2」的交织格式,需要在这里调换顺序。

收尾的M个 0 是很多初学者会忽略的细节。它有两个作用:一是把移位寄存器的残存状态清出来,让最后几个信息比特也有完整的校验输出;二是让编码结束后的寄存器状态回到全 0,这样维特比译码的回溯起点才是确定的。如果不做收尾,你会发现译码结果前面是对的,最后两个 bit 大概率出错,而且很难定位到原因。

用上面的编码器跑一个例子:输入比特1011,寄存器初始为00。第一步输入 1,输出11;第二步输入 0,输出10;第三步输入 1,输出00;第四步输入 1,输出01;随后补两个 0,输出01和11。最终编码结果是11 10 00 01 01 11,共 12 个 bit。这个手算结果在调试时非常关键,我每次改完代码都会先拿它做冒烟测试。

3.2 维特比译码实现:加比选、幸存路径与回溯输出

译码器是这份源码的核心,也是最容易写崩的地方。维特比译码的整体思路是:从初始状态 0 出发,在每个时间步对每个状态维护一条度量最小的幸存路径;走完所有编码步后,从终止状态 0 开始回溯,把每一步记录的输入比特吐出来。完整实现如下:

// conv_codec.cpp 中的维特比硬判决译码函数 std::vector<uint8_t> ConvCodec::Decode(const std::vector<uint8_t>& rx_bits) { const int total_steps = static_cast<int>(rx_bits.size()) / 2; // 编码总步数 = 信息长度 + M const int info_len = total_steps - M; // 真实信息比特数 const int kBig = 1 << 29; // 足够大的初始度量,防止路径度量累加溢出 std::vector<int> metric(NUM_STATES, kBig); std::vector<int> next_metric(NUM_STATES, kBig); // 记录每个时间步每个状态的前驱状态和对应输入比特 std::vector<std::vector<int>> prev_state(total_steps, std::vector<int>(NUM_STATES, 0)); std::vector<std::vector<int>> prev_bit(total_steps, std::vector<int>(NUM_STATES, 0)); metric[0] = 0; // 编码器从全 0 状态出发 // 逐时间步执行加比选(ACS) for (int t = 0; t < total_steps; ++t) { int rx0 = rx_bits[2 * t]; int rx1 = rx_bits[2 * t + 1]; std::fill(next_metric.begin(), next_metric.end(), kBig); for (int state = 0; state < NUM_STATES; ++state) { if (metric[state] >= kBig) continue; // 剪掉不可达路径 for (int in = 0; in < 2; ++in) { const TransEntry& tr = g_trans[state * 2 + in]; int branch_metric = (rx0 ^ ((tr.out_bits >> 1) & 1)) + (rx1 ^ (tr.out_bits & 1)); int cand = metric[state] + branch_metric; if (cand < next_metric[tr.next_state]) { next_metric[tr.next_state] = cand; prev_state[t][tr.next_state] = state; prev_bit[t][tr.next_state] = in; } } } metric.swap(next_metric); } // 回溯:编码器收尾后一定回到状态 0,所以从状态 0 反向走 std::vector<uint8_t> decoded(info_len); int state = 0; for (int t = total_steps - 1; t >= M; --t) { decoded[t - M] = static_cast<uint8_t>(prev_bit[t][state]); state = prev_state[t][state]; } return decoded; }

ACS 循环的每一行都有自己的任务:rx0和rx1是当前时间步收到的一对编码比特;branch_metric是接收比特与分支输出比特之间的汉明距离,也就是「这一对输出和我们预期的差了几个 bit」;cand是走到当前状态的累计度量加上这次分支的度量,代表了候选路径的总代价。如果cand比next_metric里已有的值更小,就更新这个目标状态的最佳路径,并记录下来前驱状态和输入 bit。

这里有两个值得注意的参数设计。第一个是kBig = 1 << 29,它不是随手写的:路径度量最多累计total_steps个分支,每个分支度量最大 2,用 int 完全不会溢出,但选一个远小于INT_MAX的初值可以避免在加法时出现未定义行为。第二个是回溯起点固定为状态 0,这是编码器尾部补零的直接结果——如果编码时没有保证收尾归零,回溯起点就不确定了,这也是为什么上一节编码器里必须保留那两步补零。

硬判决版本的 branch metric 用的是异或结果相加,也就是汉明距离。这里rx0 ^ ((tr.out_bits >> 1) & 1)的含义是:接收比特和预期输出位不同则为 1,相同则为 0。两个输出位各自比较再相加,正好是 0、1、2 三个可能值。逻辑简单,性能也够快,但性能代价是后续章节要提到的——它丢失了接收信号里的幅度信息。

3.3 误码率统计:让模拟结果可复现的参数设定

有了编码器和译码器,最后一块拼图是信道模拟和误码率(BER)统计。main.cpp 里把整条链路串起来,代码结构如下:

// main.cpp 中的链路模拟核心 double sigma = sqrt(1.0 / (2.0 * pow(10.0, snr_db / 10.0))); // 噪声标准差 std::mt19937 rng(seed); std::normal_distribution<double> noise(0.0, sigma); long long total_bits = 0, error_bits = 0; for (int frame = 0; frame < frames; ++frame) { // 1. 生成随机信息比特 std::vector<uint8_t> data(info_len); for (int i = 0; i < info_len; ++i) data[i] = (rng() % 2) & 1; // 2. 编码 std::vector<uint8_t> coded = codec.Encode(data); // 3. BPSK 映射并加噪声 std::vector<double> rx_double; for (uint8_t b : coded) { double tx = b ? 1.0 : -1.0; rx_double.push_back(tx + noise(rng)); } // 4. 硬判决还原比特 std::vector<uint8_t> rx_bits; for (double v : rx_double) rx_bits.push_back(v > 0.0 ? 1 : 0); // 5. 译码并统计误码 std::vector<uint8_t> decoded = codec.Decode(rx_bits); for (int i = 0; i < info_len; ++i) { if (decoded[i] != data[i]) error_bits++; } total_bits += info_len; } double ber = static_cast<double>(error_bits) / total_bits;

sigma的计算公式是整个模拟的信噪比核心。它基于 BPSK 基带模型的信噪比定义:信号幅度为 1 时,Es/N0 与噪声方差的关系是Es/N0 = 1 / (2 * sigma^2),所以已知 SNR(dB)反解噪声标准差就是sqrt(1 / (2 * 10^(SNR/10)))。如果你直接用sigma = 1 / SNR这种线性公式,得到的结果会跟理论曲线差得很远,这是调信道模拟时最容易翻车的点。

我一般会让用户显式传入--snr、--frames、--seed三个参数,前两个控制仿真规模,第三个保证结果可复现。通信系统的误码率仿真必须有随机种子参数,否则换一台机器跑同一个命令得到完全不同的 BER,很难判断代码改动到底是变好了还是变差了。运行后程序会输出总比特数、错误比特数和 BER 三个值,方便你对比不同 SNR 下的性能趋势。

4. 编译运行与避坑:把误码率仿真跑通要躲开的五个问题

4.1 编译与运行:从 g++ 到 CMake 的一键构建

这份源码既能用 CMake 构建,也能直接几行 g++ 命令搞定。最简单的编译方式如下:

g++ -O2 -std=c++17 src/main.cpp src/conv_codec.cpp -o conv_sim ./conv_sim --snr 5.0 --frames 1000 --seed 42

-O2在这里不是可选项,维特比 ACS 循环对性能很敏感,开优化和不开优化能差出好几倍的时间;-std=c++17是因为代码里用了 lambda 表达式和std::mt19937,低版本标准会报编译错误。如果你更习惯 CMake,仓库里也带了构建脚本,两步就能跑起来:

mkdir build && cd build cmake .. make ./conv_sim --snr 5.0 --frames 1000 --seed 42

跑起来之后程序会输出类似下面的信息:

info_len=1024, frames=1000, snr=5.0 dB, seed=42 total_bits=1024000, error_bits=203 BER = 1.98e-04

注意这个数字不是固定值,它会随随机种子波动,但量级是对的:5 dB 信噪比下硬判决维特比译码应该落在 1e-3 到 1e-4 这个区间。如果你跑出来的 BER 在 1e-1 量级,那基本可以断定链路里某个环节错了,下面一节就是排查这些问题。

4.2 实验设计与结果判读:SNR 扫描该看哪些数

单跑一个信噪比点只能确认程序没死,要验证卷积码确实在工作,得做一次 SNR 扫描。常见做法是写一个循环:

for snr in 0 1 2 3 4 5 6 7; do ./conv_sim --snr $snr --frames 2000 --seed 42 done

把每个 SNR 点记录下来之后,你会看到这样的趋势:0 dB 时 BER 大约在 1e-1 量级,说明噪声很强,信道几乎不可用;随着 SNR 提高到 6~7 dB,硬判决维特比译码的 BER 应该降到 1e-4 以下,曲线斜率明显比未编码 BPSK 的 BER 曲线更陡。这才是卷积码带来的编码增益在仿真结果上的体现。

判断仿真结果合理性的方法很简单:和理论曲线做对比。未编码 BPSK 在 6.8 dB 时 BER 约为 1e-3,而 (2,1,3) 卷积码加上硬判决维特比在同样 SNR 下应该明显优于这个值。如果你的卷积码 BER 反而比未编码还差,优先检查两个方向:一是信道噪声的方差公式是否用对,二是生成多项式的序是否和译码表一致。这两点占了九成以上的翻车原因。

4.3 五个踩坑记录:现象、原因与修法

下面的问题全部来自我在调试卷积码 C++ 模拟链路时真实踩过的坑,每条按「现象 → 原因 → 解决」写。如果你的运行结果不正常,按这个清单逐个排查,比自己盯着代码猜要快得多。

第一坑:编译链接报错undefined reference to g_trans

现象:编码器单独编译没问题,把conv_codec.cpp和main.cpp一起编译时,链接阶段报找不到全局表g_trans。原因:我把static修饰的全局变量定义在了头文件里,每个包含它的文件都会生成一份内部链接的副本,而main.cpp里访问的是另一个翻译单元里的符号,自然链接失败。解决:把表定义为extern声明放头文件、定义放conv_codec.cpp,或者直接用类成员变量替代全局表,这也是我在二次开发章节里推荐的做法。

第二坑:手算编码结果是111000010111,程序输出对不上

现象:用输入1011做冒烟测试,手算期望得到11 10 00 01 01 11,程序输出却完全不同。原因:生成多项式在代码里的位序约定没对齐。多项式07表示二进制111,从左到右对应「输入、reg0、reg1」三个抽头,但如果先把07存成整数 7,再按(poly >> i) & 1的方式提取,提取顺序就会变成 reg1、reg0、输入。解决:写一个parity(int value)辅助函数,显式按「输入 → reg0 → reg1」的顺序提取每一位,并且在代码注释里写清楚位序,避免下次再踩。

第三坑:译码结果前半段全对,最后两个 bit 必错

现象:SNR 很高、几乎没有噪声时,译码输出前百分之九十的信息比特完全正确,但末尾两个 bit 经常错。原因:编码器没有做尾部补零,或者补零的位数小于 M。没有收尾时,编码结束后寄存器的剩余状态没有清出来,最后两个信息比特对应的校验输出是不完整的,维特比回溯从状态 0 出发也找不到真实路径。解决:在编码函数末尾固定补M个 0,并同步把译码的info_len = total_steps - M计算正确。

第四坑:BER 随 SNR 升高几乎不变,一直在 0.1 附近

现象:SNR 从 0 dB 提到 8 dB,BER 只从 0.12 降到 0.09,完全看不到编码增益。原因:噪声标准差公式写错,最常见的是用了sigma = 1.0 / snr_db这种线性关系,导致 8 dB 下的实际噪声仍然巨大;或者是 BPSK 映射写反了,把 0 映射成了 +1、1 映射成了 -1,硬判决阈值v > 0恰好把信号判反。解决:用上文给出的sigma = sqrt(1/(2*10^(SNR/10)))公式,并且打印一帧无噪声时的接收值确认映射方向。

第五坑:加了大量帧之后程序变慢到无法忍受

现象:frames 调到 5000 以上,每帧 4096 bit,跑一个 SNR 点要几分钟。原因:译码器里对每个时间步都new或重新分配vector,内存分配次数过多;另一个隐藏点是没有开编译优化。解决:把prev_state和prev_bit这两张回溯表一次性分配好再进循环,不要在 ACS 循环里做动态扩容;编译时用-O2。改完之后同样的仿真规模能快 5 倍以上,这算是性能问题里的立竿见影修法。

5. 二次开发:从固定码参数到可配置卷积码模块

5.1 让约束长度与生成多项式变成输入参数

源码默认写死 K=3、多项式 07 和 05。要让模块能换码参数,第一步是把状态转移表生成改成通用算法。思路是保留「状态编号 ↔ 寄存器值」的换算关系,然后对任意 K,遍历所有寄存器和输入比特计算下一状态与输出。

for (int state = 0; state < num_states; ++state) { // 从状态号反解出 M 个寄存器的值,存入 reg[] for (int in = 0; in < 2; ++in) { // 下一状态:寄存器整体右移,新输入进最高位 int next_state = in << (M - 1); for (int i = 0; i < M - 1; ++i) { next_state |= reg[i] << (M - 2 - i); } // 输出:每个生成多项式独立计算一位校验 for (int p = 0; p < polys.size(); ++p) { int out = in * ((polys[p] >> (M + 1 - 1)) & 1); // 输入位参与异或 for (int i = 0; i < M; ++i) { out ^= reg[i] * ((polys[p] >> i) & 1); } out_bits[p] = out & 1; } } }

这里polys[p]的位序约定是:最高位对应输入,低 M 位对应从近到远的 M 个寄存器。这样只需要在构造函数里传入 K 和生成多项式数组,就能从 (2,1,3) 直接跳到 (2,1,4) 或 (2,1,7),观察更高约束度带来的编码增益。改完之后要重新跑一遍 3.1 节的手算用例,确认输出没有因为位序约定变化而错位。

5.2 软判决改造与工程化收尾

硬判决译码只用了接收比特的 0/1 信息,SNR 高时的性能上限有限。改成软判决很简单:把硬判决那一步去掉,直接把接收到的浮点值送进 ACS 循环,分支度量从汉明距离换成平方欧氏距离,也就是(rx0 - expected0)^2 + (rx1 - expected1)^2。代价是计算量略增,收益是同样 SNR 下 BER 明显降低,大约是 1~2 dB 的编码增益差值。

工程化方面,我建议把 ConvCodec 改成真正的类,成员变量持有next_state_和out_bits_两张表,去掉所有全局静态表;再把 Encode/Decode 的输入输出类型从vector<uint8_t>逐步泛化为模板,方便接硬判决 bit 流和软判决浮点序列。最后用上面 4.2 节的 SNR 扫描脚本重跑一遍,把硬判决和软判决的 BER 曲线画在同一张图上做对比验证。从那以后我每次拿到编码类源码,第一件事就是先跑固定输入冒烟测例,再跑 SNR 扫描,两步都通过才敢继续往里加新功能——这个习惯帮我挡掉了至少三次多项式位序和噪声方差的低级错误。希望帮到你。

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

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

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

立即咨询