C++实现卷积编码与维特比译码:从基带链路到BER曲线
简介一套面向数字通信与C开发者的卷积编码工程实现在Visual Studio环境中可直接编译运行。项目围绕生成器多项式、卷积编码器、Trellis编码、Viterbi译码器及信道仿真展开支持调整通道参数加入高斯白噪声、信道衰减等条件以评估不同信道环境下的传输性能。资源共52个文件以23个C源文件和21个头文件为主体辅以3个Visual Studio工程文件、2个参数配置文本和2个绘图脚本压缩包仅77KB结构紧凑、模块划分清晰。已有587人学习下载通过修改生成器多项式并运行编码、译码、误码率统计等模块读者能直观掌握卷积编码与Viterbi算法的实际代码实现同时理解BCH、Trellis等高级编码技术的异同。项目还提供参数管理模块和绘图脚本便于对比仿真结果适合通信专业学生、算法工程师以及希望提升C模拟能力的IT从业者深入研读与二次开发。1. 卷积编码的C实现把教科书里的维特比译码跑成一条能对上理论的BER曲线做信道编码模拟的人大多有个血泪经验教科书上维特比译码的画法很好看自己用C写出来误码率曲线却在高信噪比下死活降不下去。这个项目把卷积编码的完整基带链路做成了C模拟——编码、BPSK调制、AWGN信道、硬判决、维特比译码、误码率统计一条龙默认参数是经典的(2,1,7)卷积码生成多项式171和133。适合不想再抱着商用数值计算软件、想在原生C环境里把高级编码每个细节抠明白的开发者跑完你能得到和理论编码增益对得上的BER曲线而不是一个黑匣子。项目规模不大四个源文件加一个配置文件就能编译既适合拿来做课程设计的落地参考也适合作为后续扩展软判决、打孔码的实验台。2. 链路架构与C模拟的模块划分编码、信道、译码怎么咬合这个项目在结构上坚持「一个模块只干一件事」的划分方式。编码器只负责把信息比特变成卷积校验比特信道只负责加噪声译码器只负责从带噪序列里恢复信息比特main.cpp 只做编排和统计。这样拆的好处是后面你想换软判决、换信道模型、换码率都只需要动一个模块不用把整条链路推倒重来。下面先看最上层的仿真编排再落到目录和构建。2.1 一条数据从比特到误码率要经过几步main.cpp 里的核心是一个逐信噪比扫描的统计函数对每个 Eb/N0 点生成若干帧随机比特依次过编码、映射、加噪、硬判决、译码最后累计误码数。这里每一步的输出都是下一步的输入数据类型在「比特——浮点符号——比特」之间切换边界非常清楚。// main.cpp 中的单点信噪比统计函数 double runSimulation(ConvEncoder enc, ViterbiDecoder dec, int frame_bits, int num_frames, double sigma) { int bit_errors 0, total_bits 0; std::vectoruint8_t tx, tx_enc, rx_hard, decoded; std::vectorfloat symbols, rx_symbols; for (int f 0; f num_frames; f) { randomBits(frame_bits, tx); // 1. 随机信息比特 enc.encode(tx, tx_enc); // 2. 卷积编码 (2,1,K) bpskMap(tx_enc, symbols); // 3. 0 - -1, 1 - 1 awgnChannel(symbols, sigma, rx_symbols); // 4. 加高斯白噪声 hardDecide(rx_symbols, rx_hard); // 5. 0 判 1否则判 0 dec.reset(); dec.decodeHard(rx_hard, frame_bits, decoded); // 6. 维特比译码 bit_errors countErrors(tx, decoded); total_bits frame_bits; } return (double)bit_errors / total_bits; }这段代码里最值得注意的参数是 sigma它把 Eb/N0 从 dB 值换算成噪声标准差。BPSK 映射下每符号携带 1 个编码比特符号能量 Es 和每信息比特能量 Eb 之间差一个码率 R所以换算公式是double sigma std::sqrt(1.0 / (2.0 * rate * std::pow(10.0, eb_n0_db / 10.0)));rate 在编码链路里取 0.5未编码链路里取 1.0。这是整个模拟最容易出错的地方第 5 章的避坑部分会专门展开。另一个细节是 num_frames 的取值低信噪比下误码多几千帧就够高信噪比下误码率掉到 1e-5 以下我一般按「期望抓到 200 到 500 个错误」反推帧数否则 7dB 那排点会因为没有错误而统计失真。2.2 目录结构与一键构建项目文件不多职责划分很直白conv_coding_sim/ ├── include/ │ ├── conv_encoder.h # 编码器移位寄存器状态 生成多项式 │ ├── viterbi_decoder.h # 译码器网格表 ACS 回溯 │ └── channel.h # BPSK 映射、AWGN、硬判决 ├── src/ │ ├── conv_encoder.cpp │ ├── viterbi_decoder.cpp │ ├── channel.cpp │ └── main.cpp # 仿真编排和 BER 统计 ├── CMakeLists.txt ├── Makefile # 没有 cmake 的环境也能编 └── config.txt # K / 生成多项式 / 帧长 / 信噪比范围通道模块的代码是最短的几乎不需要解释但它承担了 BPSK 映射和硬判决两个关键约定发射端把 0 映射成 -1、1 映射成 1接收端把大于 0 的浮点判成 1。这个约定必须在编码器、信道、译码器三处保持一致否则闭环自检永远过不了。构建上我习惯用 CMake 做为主入口顺便留一个 Makefile 兜底cmake_minimum_required(VERSION 3.10) project(conv_coding_sim CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(conv_sim src/main.cpp src/conv_encoder.cpp src/viterbi_decoder.cpp src/channel.cpp) target_include_directories(conv_sim PRIVATE include) target_compile_options(conv_sim PRIVATE -O2 -Wall -Wextra)编译命令就三行cmake -B build -DCMAKE_BUILD_TYPERelease然后cmake --build build -j最后./build/conv_sim。Release 模式对这套蒙特卡洛模拟是必须的Debug 下编码器那点计算量差别不大但高信噪比点上几十万帧的循环差距能有几倍。如果编译环境里没有 CMake直接make也能出可执行文件Makefile 里把同样四个源文件列一遍就行。2.3 默认参数和基准输出项目的默认配置固定了一套经典组合约束长度 K7、码率 1/2、生成多项式 171 和 133。这个组合在大量无线协议栈原型里都能见到(2,1,7) 加维特比译码是教科书级别的标准配置拿来当基准非常合适。参数取值说明约束长度 K7移位寄存器 6 位译码状态数 64码率 R1/2每个信息比特输出 2 个编码比特生成多项式171oct / 133oct八进制表示各有 7 个抽头位置帧长2048 bit每帧随机信息比特数尾比特6每帧末尾补 K-1 个 0噪声模型AWGN双端高斯方差 N0/2跑完默认配置程序输出的是这样一个对照表数值是数量级参考具体和随机种子、帧数有关Eb/N0(dB) 未编码BER 编码后BER(硬判决) 0.0 8.0e-2 2.6e-2 1.0 5.6e-2 7.0e-3 2.0 3.8e-2 1.6e-3 3.0 2.3e-2 2.4e-4 4.0 1.3e-2 3.0e-5 5.0 6.0e-3 4.0e-6 6.0 2.4e-3 1e-6认真看这张表会发现两个关键信息第一低信噪比时编码后 BER 甚至比未编码还差这是卷积码的正常现象纠错编码在 0dB 附近没有增益第二到了 4dB 以后编码增益明显拉开硬判决下大约能换来 3 到 4 个 dB这正是这个项目要复现的核心结论。如果你的曲线在 4dB 之后掉不下去大概率踩了第 5 章里某个坑。3. 卷积编码器拆解生成多项式怎么变成输出比特编码器是整条链路里最单纯的部分但它定义了两个被全文反复引用的约定状态怎么表示、输出在哪一刻计算。这两个约定如果不和译码器对齐后面维特比译码怎么调都白搭。这一章把编码器从参数到代码拆开讲。3.1 状态、约束长度与八进制生成多项式卷积编码器的核心是一个 K-1 位的移位寄存器K 叫约束长度。每个时刻移入 1 个信息比特然后对寄存器内容按生成多项式选中的抽头做异或产生 n 个编码比特。寄存器状态是一个 m K-1 位的整数用掩码(1u m) - 1截断确保移位后始终落在有效范围内。生成多项式用八进制传入这是所有文档里最常见的写法也是最容易理解错的点。八进制转成二进制不是把 171 当十进制数转而是把每一位八进制数字展开成 3 位二进制171 → 001 111 001一共 9 位对 K7 只取低 7 位得到 1111001。二进制里的每个 1 对应一个抽头位置最低位对应刚移入的最新比特。生成多项式(八进制)二进制展开(取低 K 位)抽头位置(从低位起)17111110010, 1, 2, 3, 613310110110, 1, 3, 4, 6项目里用一个小的转换函数统一处理这件事编码器和译码器共用同一份转换结果避免两边各写一遍导致抽头对不上。代码里我按八进制数字逐位展开每展开一位就左移 3 个比特位uint32_t octalToBinary(int oct, int K) { uint32_t bin 0, digit_pos 0; while (oct 0) { int digit oct % 10; // 每一位都是 0~7 bin | (uint32_t)digit (3 * digit_pos); oct / 10; digit_pos; } return bin ((1u K) - 1); // 只保留低 K 位 }这里有个常见陷阱如果直接把 171 当十进制转二进制会得到 8 位的结果 10101011取低 7 位是 0101011抽头位置完全错位闭环自检会一直报错。所以生成多项式相关的代码必须走这个统一的转换函数而不是临时手算。3.2 编码核心先移入、再算校验编码器的对外接口很简单构造时传入 K 和一组八进制生成多项式之后每次调用 encode 就完成一帧的编码。内部实现里我把每一次移位加校验封装成一个 step 函数主流程读起来更清晰void ConvEncoder::step(uint8_t input, std::vectoruint8_t out) { state_ ((state_ 1) | input) mask_; // 先移入新比特 for (int g 0; g n_; g) { uint32_t tapped state_ gen_[g]; // 按多项式选抽头 uint8_t parity 0; while (tapped) { parity ^ (tapped 1); // 逐位异或等价于奇偶校验 tapped 1; } out.push_back(parity); } } void ConvEncoder::encode(const std::vectoruint8_t bits_in, std::vectoruint8_t bits_out) { bits_out.clear(); bits_out.reserve((bits_in.size() m_) * n_); for (uint8_t b : bits_in) { step(b 0x01, bits_out); // 信息比特逐位移入 } for (int i 0; i m_; i) { step(0, bits_out); // 尾比特补 K-1 个 0 } state_ 0; }这段代码有两个关键顺序。一是「先移入再算校验」state_ 先左移并入新比特再做异或这时多项式抽头作用在包含新比特的完整状态上。如果反过来先算再移入输出的编码序列就和译码器的网格转移表对不上。二是尾比特处理编码完所有信息比特后再补 m_ 个 0 移入把寄存器清零。这一步不是可选的它保证了编码帧的终点状态一定是全零维特比译码的回溯才能从确定的零状态出发。bit 顺序上所有接口都按「最低位在前」处理randomBits 生成的字节只取最低位参与编码译码后同样按位比较误码。项目里没有做字节打包直接一比特一个 uint8_t虽然浪费内存但换来的是调试时能直接打印 0101 序列不用心疼那点空间——这个规模下性能瓶颈在蒙特卡洛帧数不在表示方式。3.3 尾比特与网格转移表给译码器铺路编码器自身的代码到此就结束了但项目里编码器还顺带承担了一个任务生成译码器需要的网格转移表。因为编码器和译码器共用同一套生成多项式、同一个掩码把「状态 输入 → 下一状态 输出」的映射表放在编码器侧生成可以保证两边永远一致。这个表在译码器里被分支度量和回溯两个阶段反复查询。void ConvEncoder::buildTrellis( std::vectorstd::vectorint next_state, std::vectorstd::vectoruint8_t out_bits) { next_state.assign(1 m_, std::vectorint(2, 0)); out_bits.assign(1 m_, std::vectoruint8_t(2 * n_, 0)); for (int state 0; state (1 m_); state) { for (int in 0; in 2; in) { uint32_t ns ((uint32_t)state 1 | in) mask_; next_state[state][in] (int)ns; for (int g 0; g n_; g) { uint32_t tapped ns gen_[g]; // 用转移后的新状态算输出 uint8_t parity 0; while (tapped) { parity ^ (tapped 1); tapped 1; } out_bits[state][in * n_ g] parity; } } } }注意这里计算输出时用的是 ns 而不是 state这和编码器 step 函数里的顺序完全一致先产生新状态再对新状态做校验。如果把 table 里输出算成旧状态的校验编码器发给信道的数据和译码器期望的数据就会系统性错位具体表现就是零噪声下误码率不为零。这个表建立一次以后编码器本身不需要它但维特比译码器每一帧都在重复查询它所以我把 buildTrellis 做成独立接口编码器构造后自动执行一次译码器通过引用直接读取。4. 维特比译码实现分支度量、加比选与回溯的完整链路维特比译码是整条链路最核心也最容易写错的部分但它其实只做三件事算分支度量、做加比选ACS、回溯输出。项目里的实现是经典的硬判决版本状态数 64每步每个状态只有两个候选输入逻辑非常规整。这一章按译码流程顺序拆开讲。4.1 分支度量硬判决下的汉明距离decodeHard 是译码器的总入口接收端硬判决后的 0/1 序列从这里进入内部先对路径度量做初始化然后逐帧推进 ACS。路径度量的含义是「到达某个状态的最短累计距离」所以初始时状态 0 的度量是 0其它所有状态的度量是 INT32_MAX / 2——用一半而不是 INT32_MAX是为了防止后面加上分支度量时直接溢出成负数。void ViterbiDecoder::decodeHard(const std::vectoruint8_t rx_bits, size_t info_len, std::vectoruint8_t decoded) { size_t total_steps info_len m_; // 信息比特 尾比特 path_metric_.assign(num_states_, INT32_MAX / 2); path_metric_[0] 0; // 编码一定从全零状态开始 for (size_t t 0; t total_steps; t) { std::vectorint new_metric(num_states_, INT32_MAX / 2); for (int s 0; s num_states_; s) { for (int in 0; in 2; in) { int ns next_state_[s][in]; int cand path_metric_[s] branchMetric(s, in, rx_bits, t * n_); if (cand new_metric[ns]) { new_metric[ns] cand; survivor_[t % tb_depth_][ns] s; // 记前驱状态 } } } path_metric_ new_metric; normalizeIfNeeded(); } traceback(decoded); }分支度量在硬判决模式下就是汉明距离这一跳的期望输出和实际接收到的 n 个比特逐位比较不同的位记 1 分。期望输出直接从网格转移表里查不用现场计算int ViterbiDecoder::branchMetric(int state, int input_bit, const std::vectoruint8_t rx, size_t pos) { int dist 0; for (int g 0; g n_; g) { dist (out_bits_[state][input_bit * n_ g] ! rx[pos g]) ? 1 : 0; } return dist; }rx_bits 的长度是 (info_len m_) * n_pos 用t * n_定位当前时刻对应的接收比特。做硬判决时每个接收符号只有 0/1 两种取值所以分支度量的取值范围是 0 到 n_每步最多加 2路径度量的增长是缓慢而且有界的这给后面归一化留了很大的余地。4.2 加比选主循环两个候选里选出更短的路ACS 的语义是 add路径度量加分支度量、compare两个候选比大小、select留下小的并记录幸存前驱。上面的 decodeHard 里已经写完了完整 ACS 循环这里把幸存路径和归一化单独拿出来看因为它们是最容易写拧巴的地方。// 每个状态有 2 个前驱新度量取更小的那个 int cand path_metric_[s] branchMetric(s, in, rx_bits, t * n_); if (cand new_metric[ns]) { new_metric[ns] cand; survivor_[t % tb_depth_][ns] s; // 记录这一时刻该状态的前驱 } // 度量平移减去最小度量防止长流累积溢出 void ViterbiDecoder::normalizeIfNeeded() { int min_metric *std::min_element(path_metric_.begin(), path_metric_.end()); if (min_metric 4096) { for (int v : path_metric_) v - min_metric; } }幸存表里我存的是前驱状态而不是输入位这是刻意的如果以后想把码率从 1/2 扩到 k1 的多比特输入输入位就不再是单个比特而完整的 64 状态前驱表不需要改结构就能支撑。环状缓冲的大小 tb_depth_ 由构造参数决定默认取整帧长度索引用t % tb_depth_取模这样同时兼容流式译码场景。new_metric 用局部数组而不是直接覆盖 path_metric_是为了避免一个经典错误当前时刻的候选度量还没全部算完就先把旧度量覆盖掉导致后面的状态用上了新度量、前面的状态还在用旧度量路径比较的公平性被破坏。用双缓冲实现每一时刻的 ACS 都建立在同一份旧度量之上这是不变量。4.3 回溯阶段从零状态把比特翻出来整帧 ACS 结束后path_metric_ 里存的是所有状态的最终度量而尾比特保证了编码终点一定是零状态所以回溯起点明确是状态 0。回溯的关键观察是在 rate1/n 的卷积码里状态 s_t (s_{t-1} 1) | u_t也就是说当前状态的最低位恰好就是该时刻移入的信息比特。因此回溯每一步从当前状态取最低位然后通过幸存表跳到前一时刻的状态循环到帧头再反转即可。void ViterbiDecoder::traceback(std::vectoruint8_t decoded) { std::vectoruint8_t rev; rev.reserve(total_steps_); int state 0; // 尾比特保证终点全零 for (int t (int)total_steps_ - 1; t 0; t--) { rev.push_back((uint8_t)(state 1)); // 最低位就是输入比特 state survivor_[t % tb_depth_][state]; // 回到上一时刻 } std::reverse(rev.begin(), rev.end()); // 恢复时间顺序 decoded.assign(rev.begin(), rev.begin() info_len_); // 去掉尾比特段 }这段代码里有三个细节值得注意。一是取state 1作为输出位的技巧只对 rate1/n 成立如果你把项目扩成多比特输入就必须回到幸存表里存的完整信息二是回溯顺序是从帧尾到帧头所以收集到的比特必须先 reverse 才是时间正序三是最终只取前 info_len_ 个比特因为后面 m_ 个位置对应的是尾比特它们一定是 0统计误码时不能把它们算进去。实际调试时我经常在回溯前后各打一屏状态序列检查绝大部分译码器「莫名其妙全错」的问题最后都定位在起点不是零状态或者环状缓冲深度不够这两处。5. 避坑专题卷积模拟里最容易翻车的四个细节这一章的每一条都来自真实调试记录现象、原因、解决三件套写全。如果你跑出来的 BER 曲线对不上理论值按顺序检查这四个地方命中率很高。5.1 高信噪比下误码率出现错误平层现象3dB 以下曲线还正常3dB 之后 BER 不再下降停在一个平台值上怎么增加帧数都压不下去。原因回溯深度 tb_depth_ 设置得太小。流式维特比里回溯窗口必须大于幸存路径的收敛长度经验值是 5 倍约束长度以上也就是 5K。这个项目默认 K7窗口至少 35实际很多人嫌内存大写成 K 甚至 K/2结果高信噪比下路径还没收敛就被截断尾部状态选错拖累整帧输出。解决块式译码直接把 tb_depth_ 设成整帧长度一帧 2048 位对 64 状态的幸存表来说只有一百多KB流式场景下从 5K 起步逐步加大观察 BER 是否继续改善如果加深窗口后曲线没变化说明深度已经够用。另外每帧译码前必须调dec.reset()否则上一帧的幸存路径会泄漏到下一帧表现同样是高信噪比下的异常平台。5.2 长帧跑到一半译码输出整段乱掉现象帧长从 2048 加到 65536低信噪比下前面几千比特正常中段开始整块整块翻转BER 飚到接近 0.5。原因路径度量初始化用了 INT32_MAX第一次 ACS 时候选度量 INT32_MAX 分支度量整数溢出变成负数被 min 比较选中幸存表被一条不可能存在的路径污染。长帧让这条非法路径有足够的步数滚雪球错误集中爆发。另一个帮凶是幸存表和度量更新不同步用新度量去查上一时刻的幸存记录状态和时间戳错位。解决初始化改成 INT32_MAX / 2给后面的加法留出余量这是维特比实现的常规写法。同时坚持双缓冲——每步先算完 new_metric 整体再赋值给 path_metric_幸存表也只记录当前时刻t % tb_depth_的条目。加上每步的归一化平移路径度量在任意帧长下都保持有界。5.3 八进制生成多项式被当成十进制处理现象把生成多项式换成 {133, 171} 之后零噪声下闭环自检的误码率恒为 0.5或者手算 171 的二进制发现和程序里打出来的对不上。原因八进制 171 的正确展开是 001 111 001取低 7 位得 1111001如果有人直接 std::bitset7(171) 按十进制转得到的是 10101011 截断后的 0101011抽头位置几乎全部错位。两边多项式都错位时编码器产出的序列和译码器期望的序列完全不兼容自检当然过不了。解决统一走项目里的 octalToBinary 函数并在 main 开头放一组断言卡住配置错误171 转换结果等于 0b1111001133 等于 0b1011011。任何一次手改生成多项式跑仿真之前先跑一遍零噪声的编码—译码闭环要求误码严格为零。零噪声都过不了后面加噪声再修就是白费时间。5.4 Eb/N0 与符号信噪比换算偏差导致曲线整体平移现象编码后的 BER 曲线整体比理论位置差 3dB或者干脆比未编码曲线还差且随着信噪比增大差距不变。原因噪声方差公式里漏了码率 R。BPSK 下每个符号携带 1 个编码比特而每个信息比特的能量被摊到 1/R 个符号上所以符号信噪比 Es/N0 和 Eb/N0 之间差一个 R。码率 1/2 时 Es/N0 比 Eb/N0 低 3dB如果用未编码的公式给编码链路配噪声相当于把编码信号当成满能量发送曲线必然整体右移 3dB 左右。解决统一用sigma sqrt(1 / (2 * R * 10^(dB/10)))编码链路 R0.5未编码链路 R1.0。验证方法是对照 0dB 点未编码 BPSK 在 0dB 时的理论 BER 是 Q(√2) ≈ 0.079如果程序输出的第一行明显偏离这个值先查噪声方差而不是查译码器。6. 二次开发把写死的参数改成可配置的模拟系统跑通默认配置之后最值得做的第一步是把 main.cpp 里写死的参数搬进 config.txt。这个改造的性价比极高因为它让每一组实验都变成可复现的生成多项式、帧长、信噪比范围、随机种子全在配置里跑出来的数据文件可以追溯不至于一周以后自己都忘了当时用的哪组参数。# config.txt K 7 gen_poly 171 133 frame_bits 2048 num_frames 10000 eb_n0_start 0.0 eb_n0_stop 7.0 eb_n0_step 1.0 random_seed 42main 读入这些值后构造编码器和译码器输出格式固定成 CSV 文件每行是eb_n0,coded_ber,uncoded_ber方便直接丢进绘图脚本。我习惯把 num_frames 从固定值改成自适应——低信噪比几千帧高信噪比按目标错误数往上加否则 6dB 那排点一个错误都抓不到统计出来全是零画图时曲线像被砍了一刀。往深了扩展有三个方向。一是软判决保留信道输出的浮点幅度分支度量从汉明距离换成|接收值 - 期望值|或平方欧氏距离同样的 (2,1,7) 码能多拿 1.5 到 2dB 增益改动的粒度只在 branchMetric 和信道接口。二是打孔在编码输出按一个周期模板删掉部分校验位把码率提到 3/4 或 7/8译码时被删掉的位置不参与度量计算即可。三是从 rate1/n 扩到 k1 的多比特输入——幸存表里存的本来就是完整前驱状态这个结构天然支持扩展真正要改的是分支度量和回溯的输出位处理。我自己的习惯是每改一次配置先跑零噪声闭环自检确认 BER 严格为零再逐点加噪声扫曲线。从那以后凡是要动生成多项式或者帧长我都会强制走一遍这个流程确认状态约定两端对齐了才敢放心跑大批量蒙特卡洛否则一晚上攒下来几十万帧数据最后只得到一个「配置打印错了」的结论连后悔药都没得吃。希望帮到你。本文还有配套的精品资源点击获取