简介:这是一份面向通信工程与C++开发者的卷积编码教学与仿真项目,聚焦数字通信中的信道编码技术。项目在Visual Studio环境下编译运行,支持调整高斯白噪声、信道衰减等参数,结合生成器多项式、Viterbi译码等核心模块,帮助读者理解卷积编码的构造、译码与抗干扰性能。适合对高级编码、C++模拟感兴趣的IT专业人员实践学习。
包体共52个文件,包含23个cpp、21个h头文件、3个vcxproj工程文件、2个txt及若干plt绘图脚本,压缩包大小77KB。目录分为Library与“3 Conventional Coding”两大部分,前者提供随机数生成、AWGN信道、BCH编解码等库函数,后者实现卷积编码器、Viterbi译码器及信道仿真模块,并配备BCH编码对比与误码率绘图工具。
目前已有586人学习下载。读者可通过Viterbi算法调试、参数调整与误码率可视化,系统掌握卷积编码从原理到C++落地的完整流程,亦可作为通信系统设计、课程实验与研究验证的实用工具。 做无线通信或者音视频传输这行的工程师,对“卷积编码”这四个字应该都不陌生。它不像CRC那样只做检错,也不像汉明码那样一次只保护一小块数据,而是在连续比特流上施加状态记忆,让接收端借助前后比特的关联把被噪声污染的信息恢复出来。下面就用C++完整实现一个(2,1,3)卷积编码器,从生成多项式、状态机表驱动到手工验证、尾比特归零,再到和维特比解码的衔接,一次理清楚。无论你是刚学完C++基础想找实战练手,还是做通信系统集成时需要自己写编码模块,这篇都适用。
1. 卷积编码到底是什么:一个比特如何变成两个比特
1.1 信道噪声面前,编码为什么有用
先说一个最原始的问题:一段0101的比特流走无线信道,遇到突发干扰或者随机噪声,接收端收到的是0101还是0100?没人能保证。检错码能告诉你“这包坏了”,但坏了之后怎么办?要么重传,要么通过冗余信息硬猜。在卫星通信、深空通信、广播这类不适合频繁重传的场景里,行业习惯是“带纠错能力的前向纠错码”,卷积码就是最常见的一种。
卷积码的核心思想很朴素:当前输出的n个比特,不仅由当前输入的k个比特决定,还跟之前K-1个比特有关。这种“记忆性”让信息被分散到一串编码比特里,单个比特被信道打翻,接收端仍然可以从前后比特的相互约束里把它揪出来。代价是每个信息比特只输出一个比特的码也要带冗余,码率R = k/n直接决定了冗余比例。
1.2 卷积码和分组码的区别
分组码(比如RS码、LDPC码)的处理单位是固定长度的块,一块进来,一块出去,块与块之间互不影响。卷积码则没有严格的分组边界,编码器像一条流水线,每个信息比特进去都会影响后续一段时间的输出,因此它天然适合连续的数据流场景。
工程里卷积码经常用三个参数描述:(n, k, K)。n是每个时隙输出比特数,k是每时隙输入比特数,K是约束长度,代表当前输出最多受多少比特影响。码率R = k/n告诉你冗余开销,而K决定了编码器的记忆深度和纠错能力。最常见的教学和实践组合就是(2,1,3),码率1/2,约束长度3,硬件开销小,性能却已经足够解释清楚整套原理。
2. 搭好骨架:从生成多项式到状态机
2.1 生成多项式怎么读
(2,1,3)卷积码有两个生成多项式,工程上通常写成八进制:G0 = 7,G1 = 5。这两个数不是随便取的,展开成二进制就清楚了:
- 7 = 111,表示第一个输出比特c0由当前输入、上一比特、上上一比特三者异或得到。
- 5 = 101,表示第二个输出比特c1由当前输入和上上一比特异或得到,中间那级寄存器不参与。
多项式里的每一位对应一个抽头位置,从当前输入到最远的寄存器依次排列。写代码前先把这个对应关系搞清楚,否则后面查表会有大麻烦。
2.2 状态机的四个状态
因为K=3,编码器内部有两个记忆单元。这两个记忆单元的取值组合共有2^(K-1) = 4种,这就是编码器的状态:00、01、10、11。每输入一个比特,内部状态跳转到下一个,同时产生2个输出比特。
你可以把编码器看成一个有限状态机。初始状态约定为全零,输入比特流驱动状态不断迁移,每一次迁移都伴生一组输出。这种视角非常关键,因为维特比解码器的整个算法就是围绕“网格图上的状态路径”展开的,编码器侧用状态机描述,解码器侧才能无缝对接。
2.3 C++中的数据结构选型
C++里表达状态机和输出关系,我建议直接预计算成表,而不是每处理一个bit临时算异或。表的规模极小:状态4种,输入2种,所以任何表最多4×2=8项,查表比反复位运算快得多,代码也更直观。
#include <cstdint> #include <vector> struct ConvEncoder2213 { static constexpr int K = 3; static constexpr int n = 2; int state; int next_state[1 << (K - 1)][2]; int output[1 << (K - 1)][2]; ConvEncoder2213() : state(0) { for (int s = 0; s < (1 << (K - 1)); ++s) { for (int in = 0; in < 2; ++in) { int reg1 = (s >> 1) & 1; // 第一级寄存器 int reg2 = s & 1; // 第二级寄存器 int c0 = in ^ reg1 ^ reg2; // 多项式 111 -> 7 int c1 = in ^ reg2; // 多项式 101 -> 5 next_state[s][in] = (in << 1) | reg1; output[s][in] = (c0 << 1) | c1; } } } };这里的状态定义我约定高位是离输入近的那一级寄存器,低位是更远的一级。状态转移逻辑是:新比特进第一级,原来的第一级推到第二级,原先的第二级丢弃,所以next_state = (in << 1) | reg1。这个顺序搞反的话,你的编码结果会跟任何一本教科书都对不上。
3. 查表驱动的编码核心:两个表搞定全部逻辑
3.1 预计算nextState和output表
构造函数里做的两件事是整个编码器的心脏:为每个“当前状态 + 输入比特”组合,预计算好对应的下一个状态和输出比特对。查表负责把编码从繁复的异或链中解放出来,实际运行时的逻辑变成了一行循环体:
std::vector<uint8_t> encode(const std::vector<uint8_t>& info_bits, bool tail = true) { state = 0; std::vector<uint8_t> work = info_bits; if (tail) { work.insert(work.end(), K - 1, 0); // 尾部归零 } std::vector<uint8_t> coded; coded.reserve(work.size() * n); for (uint8_t bit : work) { int out2 = output[state][bit]; coded.push_back((out2 >> 1) & 1); coded.push_back(out2 & 1); state = next_state[state][bit]; } return coded; }encode函数做的事情一句话就能描述:梳过每一个输入比特,查一次输出表得到两个编码bit,再查一次转移表更新状态。没有循环嵌套,没有条件分支,编码一个比特就是两次数组索引加两次赋值。
3.2 分清楚“当前状态”和“下一个状态”
写代码最容易翻车的地方就是状态更新时序。每次输入必须先基于当前state查输出,再更新state,顺序不能反。如果先更新了state再查输出,你实际用的是新状态,输出结果完全错位。
还有一点,编码器跑完一段信息后,如果不做处理直接编下一段,编码器会带着上一段的记忆进入下一段,两段数据之间会产生耦合。所以要么每段都补尾比特归零,要么重置换编码器状态,二者必选其一。
3.3 复杂度分析
整个编码过程的时间复杂度是O(L×n),L是输入比特数,n是每时隙输出比特数。空间上除了最终输出的编码bit流外,表和状态只占常数空间。这种计算量与实现方式让卷积编码器可以轻松跑在微控制器或者实时DSP链路上,不需要专门的硬件加速单元。
4. 完整可编译示例与手工验证
4.1 一个可以直接跑的示例
把上面的类补全一个main函数,喂一段测试序列进去,直接观察输出:
#include <iostream> int main() { ConvEncoder2213 enc; std::vector<uint8_t> info = {1, 0, 1, 1}; std::vector<uint8_t> coded = enc.encode(info, false); // 先不补尾,便于验证 std::cout << "info: "; for (uint8_t b : info) std::cout << static_cast<int>(b); std::cout << "\ncoded: "; for (uint8_t b : coded) std::cout << static_cast<int>(b); std::cout << std::endl; return 0; }每次编码前最好显式重置state为0。虽然构造函数里已经初始化了,但如果同一个编码器对象被多次调用,上一次残留的状态会污染下一次输出。工程上建议encode函数入口强制state = 0,不要依赖对象构造时的初始状态。
4.2 手工推演:验证输出对不对
我拿信息序列1011做一次完整推演,这样你拿到代码跑出来的结果可以立即对比。
- 初始状态00。输入1:c0 = 1^0^0 = 1,c1 = 1^0 = 1,输出11,状态变10。
- 状态10。输入0:c0 = 0^1^0 = 1,c1 = 0^0 = 0,输出10,状态变01。
- 状态01。输入1:c0 = 1^0^1 = 0,c1 = 1^1 = 0,输出00,状态变10。
- 状态10。输入1:c0 = 1^1^0 = 0,c1 = 1^0 = 1,输出01,状态变10。
所以编码输出是11100001。跑一遍代码,如果输出不是这个,说明生成多项式顺序或者状态定义跟这里有出入。这种“先手工算一行,再对代码输出”的方式是调试编码器最有效的手段,比对着波形猜快得多。
4.3 加入尾比特归零后的变化
上面的推演没有做尾部归零。实际通信系统里,编码器最终要回到全零状态,方便维特比解码器在网格图末尾做路径收敛,所以信息序列后面要追加K-1个0。针对1011,末尾加两个0后,完整状态序列是:
- 继续从状态10开始,输入0:c0 = 0^1^0 = 1,c1 = 0^0 = 0,输出10,状态变01。
- 状态01,输入0:c0 = 0^0^1 = 1,c1 = 0^1 = 1,输出11,状态变00。
最终输出就是111000011011,最后四个比特1011是归零尾带出的冗余。接收端解码后需要把末尾这K-1个比特对应的信息丢弃,或者利用它们来校验解码路径的合法性。
5. 从编码走向实战:与维特比解码的衔接和常见坑
5.1 软判决与硬判决的接口差异
编码器的输出是0或1,但信道送给解码器的往往是软信息(LLR,对数似然比)。维特比解码器有两种工作模式:硬判决直接吃0/1,软判决吃量化后的可靠性值。从C++接口设计角度,建议编码器输出的编码bit不要直接丢给信道模拟器,而是先定义一层的接口,比如SoftBit,这样后期切换调试手段不用动编码器。
如果你只是验证编码器正确性,用一个高斯白噪声信道把0映射成+1、1映射成-1,再叠加噪声,接收端做符号判决就行。这个流程能帮你把编码、信道、解码三段逻辑彻底解耦。
5.2 保持起始状态和结束状态对齐
维特比解码的网格图依赖一个假设:编码器从全零状态开始。如果你的上游数据是按包来的,每一包编码前必须归零或重置换状态。我有一个亲测踩过的坑:某个模块被复用到多个线程里,每个线程各自持有编码器实例,但状态重置只在第一个线程创建时做了一次,结果后面所有包的解码误码率都异常高。排查了半天才发现是状态残留。
这类问题最好的预防方案是把“创建编码器+编码”封装成无副作用的纯函数,内部临时创建局部状态,不在对象上保留跨调用的状态字段。
5.3 打孔:用更少的冗余换更高的码率
(2,1,3)的码率固定1/2。实战里经常需要2/3、3/4这类更高码率,办法是打孔:编码器还是正常输出每时隙两个bit,但按照一个给定模板周期性丢弃部分bit。比如2/3码率就是每4个编码bit里丢掉1个,保留3个。
打孔的代价是编码冗余少了,纠错能力下降。C++实现时建议在编码器外层单独封装一个Puncturer,编码和打孔职责分开,别把打孔逻辑塞进编码循环里,否则将来换打孔模板或者做自适应调制编码时会非常痛苦。
6. 工程实践中的性能优化与调试心得
6.1 性能优化:查表法之外还能做什么
表驱动版本已经够快,但还能继续压。一个方向是扩展查表粒度:输入bit不再一个一个处理,而是8个bit一组,预计算出8bit组合对应的16个输出bit、最终状态和输出偏移,编码循环次数直接降到原来的1/8。这个思路跟软件无线电里查表生成扩频码的手法一样。
另一个方向是用uint16_t打包输出。每时隙产生的2个编码bit只占2bit空间,如果直接把查表结果累加到一个uint16_t缓冲区里,最后再按bit位拆包,缓存访问效率更高。但要注意字节序和位序的约定,别在跨平台移植时给自己挖坑。
6.2 调试三板斧:状态打印、参考向量、随机回环
调试卷积编码器,我最常用三个手段:
第一,逐状态打印。在编码循环里临时加一行,把每步的state和out2打印出来,跟手工推演逐行对比。这能最快定位是多项式错了、状态定义反了,还是更新时序错了。
第二,构造参考向量。找一本通信原理教材,把里面现成的编码例题输入跑一遍,输出和教材对上了,你的编码器基本就稳了。教材里的例题本身就是被人反复验证过的测试向量。
第三,随机回环测试。自己写一个极简的硬判决维特比解码器,或者直接调用成熟的开源库,对随机生成的十万个包做“编码-加噪声-解码”回环,统计误码率。如果误码率趋近理论值,说明编码器行为正常;如果出现系统性偏高,回头看状态定义和打孔模板。
6.3 常见实现漏项
- 生成多项式读取方向读反。八进制多项式展开成二进制时,有人习惯从左往右对应最近寄存器,有人习惯从右往左,必须以“第一位对应当前输入”为基准统一。
- 状态变量的位宽不够。约束长度变大后,状态数会变成2^(K-1),比如K=7时状态数就到64。用int没问题,但如果你用位域或者单个char存状态,注意上限。
- 忽略了输出bit次序。c0做高位还是c1做高位,必须全链路统一,包括解码器端的branch metric计算。我只见过高位置c0在解码器里用了c1高位导致误码率50%的情况。
- 用vector 存编码bit。vector 有比特压缩,但它的proxy引用机制在并发或者外部接口传参时容易出幺蛾子。工程上更推荐vector<uint8_t>,代价只是多占内存,换来的是类型语义明确。
结尾处我再分享一个实操体会:很多刚接触信道编码的C++开发者,第一反应是去网上找现成库,但真到做系统集成时,还是会因为接口约定、位域排列这类细节被迫回到源码层面。与其依赖别人,不如自己把编码器这类核心小模块吃透并掌控在手里。它的实现量不超过百行,原理也不复杂,但把生成多项式、状态机、查表、尾比特归零这一整套逻辑跑通之后,再往Turbo码、LDPC这些更复杂的方向走,你会发现很多设计思路都是相互贯通的。后续想扩展的话,可以从打孔和软判决维特比这两条线继续,编码器本身完全不用推翻重写。
本文还有配套的精品资源,点击获取