简介这是一个面向数字通信与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 的编码维特比译码的加比选ACSAdd-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 映射为 -11 映射为 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; // 两位输出打包bit11 | 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; // 新寄存器值reg0in, reg1reg0 int out0 in ^ reg0 ^ reg1; // 多项式 111in、reg0、reg1 三个位异或 int out1 in ^ reg1; // 多项式 101跳过 reg0in 与 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::vectoruint8_t ConvCodec::Encode(const std::vectoruint8_t data) { std::vectoruint8_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::vectoruint8_t ConvCodec::Decode(const std::vectoruint8_t rx_bits) { const int total_steps static_castint(rx_bits.size()) / 2; // 编码总步数 信息长度 M const int info_len total_steps - M; // 真实信息比特数 const int kBig 1 29; // 足够大的初始度量防止路径度量累加溢出 std::vectorint metric(NUM_STATES, kBig); std::vectorint next_metric(NUM_STATES, kBig); // 记录每个时间步每个状态的前驱状态和对应输入比特 std::vectorstd::vectorint prev_state(total_steps, std::vectorint(NUM_STATES, 0)); std::vectorstd::vectorint prev_bit(total_steps, std::vectorint(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::vectoruint8_t decoded(info_len); int state 0; for (int t total_steps - 1; t M; --t) { decoded[t - M] static_castuint8_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_distributiondouble noise(0.0, sigma); long long total_bits 0, error_bits 0; for (int frame 0; frame frames; frame) { // 1. 生成随机信息比特 std::vectoruint8_t data(info_len); for (int i 0; i info_len; i) data[i] (rng() % 2) 1; // 2. 编码 std::vectoruint8_t coded codec.Encode(data); // 3. BPSK 映射并加噪声 std::vectordouble rx_double; for (uint8_t b : coded) { double tx b ? 1.0 : -1.0; rx_double.push_back(tx noise(rng)); } // 4. 硬判决还原比特 std::vectoruint8_t rx_bits; for (double v : rx_double) rx_bits.push_back(v 0.0 ? 1 : 0); // 5. 译码并统计误码 std::vectoruint8_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_castdouble(error_bits) / total_bits;sigma的计算公式是整个模拟的信噪比核心。它基于 BPSK 基带模型的信噪比定义信号幅度为 1 时Es/N0 与噪声方差的关系是Es/N0 1 / (2 * sigma^2)所以已知 SNRdB反解噪声标准差就是sqrt(1 / (2 * 10^(SNR/10)))。如果你直接用sigma 1 / SNR这种线性公式得到的结果会跟理论曲线差得很远这是调信道模拟时最容易翻车的点。我一般会让用户显式传入--snr、--frames、--seed三个参数前两个控制仿真规模第三个保证结果可复现。通信系统的误码率仿真必须有随机种子参数否则换一台机器跑同一个命令得到完全不同的 BER很难判断代码改动到底是变好了还是变差了。运行后程序会输出总比特数、错误比特数和 BER 三个值方便你对比不同 SNR 下的性能趋势。4. 编译运行与避坑把误码率仿真跑通要躲开的五个问题4.1 编译与运行从 g 到 CMake 的一键构建这份源码既能用 CMake 构建也能直接几行 g 命令搞定。最简单的编译方式如下g -O2 -stdc17 src/main.cpp src/conv_codec.cpp -o conv_sim ./conv_sim --snr 5.0 --frames 1000 --seed 42-O2在这里不是可选项维特比 ACS 循环对性能很敏感开优化和不开优化能差出好几倍的时间-stdc17是因为代码里用了 lambda 表达式和std::mt19937低版本标准会报编译错误。如果你更习惯 CMake仓库里也带了构建脚本两步就能跑起来mkdir build cd build cmake .. make ./conv_sim --snr 5.0 --frames 1000 --seed 42跑起来之后程序会输出类似下面的信息info_len1024, frames1000, snr5.0 dB, seed42 total_bits1024000, error_bits203 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 提高到 67 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 dBBER 只从 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 让约束长度与生成多项式变成输入参数源码默认写死 K3、多项式 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 明显降低大约是 12 dB 的编码增益差值。工程化方面我建议把 ConvCodec 改成真正的类成员变量持有next_state_和out_bits_两张表去掉所有全局静态表再把 Encode/Decode 的输入输出类型从vectoruint8_t逐步泛化为模板方便接硬判决 bit 流和软判决浮点序列。最后用上面 4.2 节的 SNR 扫描脚本重跑一遍把硬判决和软判决的 BER 曲线画在同一张图上做对比验证。从那以后我每次拿到编码类源码第一件事就是先跑固定输入冒烟测例再跑 SNR 扫描两步都通过才敢继续往里加新功能——这个习惯帮我挡掉了至少三次多项式位序和噪声方差的低级错误。希望帮到你。本文还有配套的精品资源点击获取