简介:C++实现的Golay码译码器项目,面向通信与编码理论初学者、高校相关课程学生以及需要处理纠错编码的软件开发者。Golay码是经典线性分组码,可纠正单个错误并检测双误,常用于深空通信、遥测、数据存储等场景,本实现覆盖编码译码关键流程,包括前处理、奇偶校验、错误定位与校正、后处理等,便于从代码层面理解理论并动手验证。压缩包共28个文件,除.h头文件与.cpp源文件外,还包含.dsw/.dsp工程配置、.exe可执行程序、.rc/.ico界面资源,以及obj、pdb、pch等编译中间与调试文件,整体约2.23MB,结构完整。已有585人浏览学习,适合作为课程设计或自学实践素材;通过运行和调试该项目,可掌握Golay码译码器的具体实现思路,同时锻炼C++在数组、位运算与算法设计方面的运用能力,并能加深对线性分组码原理的理解。
1. Golay码译码C++实现:纠3错、查表8KB,先看清边界再动手
拿到一个Golay码译码的C++实现时,我习惯先问三个问题:它能纠几个错、查表占多大、误纠时怎么发现。二进制Golay(23,12,7)能纠正3个随机错误,理论编码增益在深空通信这类低信噪比场景里很能打,而C++里用uint32_t位运算刚好能把编码、伴随式、查表全部压在几十行内。这篇笔记围绕一个常见的Golay码译码C++实现展开,适合正在做信道编码仿真、纠错码课程设计,或者想给无线数传链路加一层前向纠错的人。我会把生成多项式、伴随式查表、扩展位处理这些核心点拆开讲,也把查表法最容易踩的静默误纠坑摆出来。
2. 生成矩阵与伴随式:把Golay码的数学骨架落进C++类
Golay码有两条实现路线,一条是矩阵路线,构造12行生成矩阵和11行校验矩阵,另一条是多项式路线,直接拿生成多项式做除法。我拆过的这个C++项目走的是第二条,因为代码量最少,而且一个除法LFSR既能做编码,又能算伴随式,非常适合在嵌入式环境里追求低延迟的场景。
2.1 为什么选二进制Golay码:与Hamming和BCH的权衡
先看参数。二进制Golay(23,12,7)表示码长23、信息位12、最小汉明距离7。根据纠错能力公式 t=floor((d_min-1)/2)=3,它能纠正任意3个比特错误。其他常用选择是Hamming(15,11,3)只能纠1错,BCH(31,21,5)能纠2错但码长更长。在误码率10^-2量级的信道里,3个错误和1个错误的块差错率差距可能到两三个数量级,这正是Golay码在卫星遥测、低速深空数传里仍有生命力的原因。
扩展码(24,12,8)进一步把最小距离从7提到8,代价是增加1位偶校验。这样除了能纠3错,还能检测4错。工程里常见做法是把(23,12)和(24,12)做成两套接口,同一套C++核心,外层加一个bit位。这个选型逻辑对课程设计和工程落地都通用:码长越短,查表越小,纠错能力又足够覆盖典型的突发误码。如果你只需要纠1错,Hamming更省;需要纠更多就上BCH或RS,Golay的价值在于中间这档的完美码特性,后面查表那节会看到这个特性的好处。
2.2 生成多项式0xC75:C++里的除法编码器
二进制Golay(23,12,7)是循环码,生成多项式常用十六进制0xC75表示,展开是 g(x)=x^11+x^10+x^6+x^5+x^4+x^2+1。我一般不用手写生成矩阵,因为直接用LFSR计算余式就能得到系统码校验位:把12位信息左移11位,再除以生成多项式,余数就是11位校验位。C++实现如下:
#include <cstdint> #include <cassert> // 生成多项式:x^11 + x^10 + x^6 + x^5 + x^4 + x^2 + 1 // 二进制:1100 0111 0101,取低11位参与LFSR反馈 constexpr uint32_t GOLAY_POLY = 0xC75; constexpr uint32_t GOLAY_POLY_MASK = GOLAY_POLY & 0x7FF; // 编码:输入12位信息,输出23位系统码字 uint32_t golay23_encode(uint32_t msg12) { uint32_t data = (msg12 & 0xFFF) << 11; // 信息位放到高位 uint32_t reg = 0; // 从最高位到最低位逐位移入LFSR for (int i = 22; i >= 0; --i) { uint32_t fb = ((reg >> 10) & 1) ^ ((data >> i) & 1); reg = (reg << 1) & 0x7FF; if (fb) { reg ^= GOLAY_POLY_MASK; } } return data | (reg & 0x7FF); // 高12位信息 + 低11位校验 }这段代码的关键在反馈项fb:当前寄存器最高位(bit10)和输入位的异或。如果为1,就异或掉多项式的低11位,这样相当于在做GF(2)下的多项式长除法。循环从i=22到0,共23拍,覆盖被除数的全部位。参数msg12必须小于0x1000,超出部分会被& 0xFFF截断,这是故意的,为了防止外部传入脏数据。最终返回值的bit22到bit11是原始信息,bit10到bit0是余数,也就是校验位。
这里有个容易看花眼的点:GOLAY_POLY_MASK不是0xC75本身,而是0x475。因为在LFSR反馈时最高阶x^11已经被反馈动作消掉了,寄存器里只需要存低11位系数。如果你写成reg ^= GOLAY_POLY,结果会错得相当隐蔽。编码写完先别急着往下走,按下面的方式做一轮全量自检:
// 遍历全部4096组信息,验证“编码后伴随式恒为0” void test_golay23_encode() { for (uint32_t m = 0; m < 4096; ++m) { uint32_t c = golay23_encode(m); assert(golay23_syndrome(c) == 0); } }这里golay23_syndrome还没定义,但测试目标很明确:一个合法码字被自己的校验方程除过之后,余式必须为零。如果这条过不了,说明多项式常量、位序或循环次数至少有一个是错的,必须回头排查,而不是继续往后写查表。
2.3 伴随式寄存器:让同一个LFSR兼任检错
译码的第一步是算伴随式。接收向量 r(x) 除以生成多项式 g(x),余式就是伴随式 s(x)。如果传输无错误,伴随式为0;如果伴随式不为0,说明出错,并且错误图案也对应同一个余式。这个性质让编码和译码共用同一个LFSR函数。
// 计算23位接收向量的伴随式,返回11位值 uint32_t golay23_syndrome(uint32_t r23) { uint32_t reg = 0; for (int i = 22; i >= 0; --i) { uint32_t fb = ((reg >> 10) & 1) ^ ((r23 >> i) & 1); reg = (reg << 1) & 0x7FF; if (fb) { reg ^= GOLAY_POLY_MASK; } } return reg & 0x7FF; }注意这里和编码唯一的区别:编码时输入的是(msg12 << 11),伴随式输入的是完整的23位接收码字。相同的是那个23拍的LFSR循环。算出来的伴随式是一个11位整数,范围0到2047,正好对应2048个伴随式值。这个数字在下一页查表里会直接用上。
使用这个函数验证编码是否正确,可以调用golay23_syndrome(golay23_encode(msg)),理论上恒等于0。我通常会在测试函数里加一个断言:遍历全部4096个信息组合,编码后伴随式必须全为0。这一步很便宜,能挡掉大部分多项式常量和位序错误。LFSR输入的位序是约定的bit22为最高位,如果你在外部接口里拿到的是相反位序,必须在这里统一转换,否则后面所有表都是废的。
3. 查表译码与代数译码:C++实现的路径选择与内存取舍
伴随式只是第一步,真正的译码难点在于“由伴随式反推错误位置”。Golay(23,12,7)是完美码,所有2048个伴随式正好一一对应重量不超过3的2048个错误图案,所以最直接的实现就是查表。这一章把查表法拆开讲,也对比一下代数译码的适用边界,最后落到C++的位布局和内存取舍。
3.1 查表法:2048项错误图案表的构建
因为码是完美的,伴随式与可纠错误图案是一一映射。查表表的大小就是这个映射的规模:2^11=2048项。每项存23位错误图案,用uint32_t存储就是4字节,整张表8KB。这个内存量在单片机上可以接受,在PC上更是可以忽略。
构建表的思路是枚举所有重量0、1、2、3的错误图案,把每个图案送入golay23_syndrome,然后以伴随式为下标,把错误图案写入表项。由于完美码性质,不会发生两个可纠图案对应同一个伴随式。我在构建表时都会加断言,如果发现同一个下标被重复写入,直接终止程序,让大家尽早暴露多项式或位序错误。
#include <array> #include <cassert> std::array<uint32_t, 2048> g_syndromeTable; void build_golay23_table() { g_syndromeTable.fill(0xFFFFFFFF); // 用全F标记空表项 // 重量0:全零图案 uint32_t s0 = golay23_syndrome(0); assert(g_syndromeTable[s0] == 0xFFFFFFFF); g_syndromeTable[s0] = 0; // 重量1 for (int i = 0; i < 23; ++i) { uint32_t e = 1u << i; uint32_t s = golay23_syndrome(e); assert(g_syndromeTable[s] == 0xFFFFFFFF); g_syndromeTable[s] = e; } // 重量2 for (int i = 0; i < 23; ++i) { for (int j = i + 1; j < 23; ++j) { uint32_t e = (1u << i) | (1u << j); uint32_t s = golay23_syndrome(e); assert(g_syndromeTable[s] == 0xFFFFFFFF); g_syndromeTable[s] = e; } } // 重量3 for (int i = 0; i < 23; ++i) { for (int j = i + 1; j < 23; ++j) { for (int k = j + 1; k < 23; ++k) { uint32_t e = (1u << i) | (1u << j) | (1u << k); uint32_t s = golay23_syndrome(e); assert(g_syndromeTable[s] == 0xFFFFFFFF); g_syndromeTable[s] = e; } } } // 完美码应该恰好填满2048项 int filled = 0; for (uint32_t v : g_syndromeTable) { if (v != 0xFFFFFFFF) ++filled; } assert(filled == 2048); }这段代码的核心是三重循环枚举组合,复杂度是O(n^3)级别,但n=23,实际执行次数不到两千,构建时间在微秒到毫秒量级,完全不必优化。assert在这里的作用是验证表的完备性,如果2048项没有填满,说明伴随式定义或位序有错。注意1u << i在i=22时得到0x400000,正好落在23位范围内,用uint32_t安全。
3.2 代数译码的另一种形态:为什么不推荐在工程里直接写迭代
很多教材会提代数译码,比如用迭代校验子搜索、捕错译码。捕错译码的思路是:如果错误集中在低11位,可以通过循环移位把伴随式降权,从而直接修正校验位,但这要求错误图案是“可捕获”的,并非任意3错都能捕获。Golay码存在一套基于位置多项式的译码算法,但实现起来比查表复杂得多,而且由于完美码的查表只有8KB,工程上几乎没人会去手写代数迭代。
我拆过的一个项目里,最初实现用了捕错译码,目的是省内存。后来发现两个问题:一是每个接收码字的循环移位次数不确定,导致延迟抖动;二是边界条件特别多,测试向量一多就开始翻车。换回查表后,内存多了8KB,单次译码时间变成两次查表和一次异或,反而更好调。所以如果你不是在做硬件电路或极端内存受限的场景,查表法是我的默认选择。代数译码更适合作为深入研究时的对照实现,不适合作为第一版交付代码。
3.3 内存布局与位操作:uint32_t还是bitset
C++里表示23位码字有两种常见选择:std::bitset<23>和原生uint32_t。我强烈建议用uint32_t,因为bitset的[]访问、位运算稍慢,而且无法直接做整表索引。查表译码的核心操作是:
// 查表译码:输入23位接收向量,输出纠错后的码字 uint32_t golay23_decode(uint32_t r23) { uint32_t s = golay23_syndrome(r23); uint32_t e = g_syndromeTable[s]; // 从伴随式查错误图案 return r23 ^ e; // 异或掉错误 }这段代码8行,但已经完成了查表法译码全部逻辑。参数r23必须是23位有效数据,高9位应全为0;如果外部传入的是24位扩展码,先r23 &= 0x7FFFFF再调用。e从表里取出后,异或操作会把错误位翻转,达到纠错效果。查表的时间开销主要是两次LFSR计算(一次在构建,一次在译码时算伴随式)和一次内存访问,总耗时在几十个时钟周期数量级,适合实现在数据率不高的低速链路上。
对于扩展Golay(24,12,8),我习惯把第24位放在bit23,低23位放原Golay码。这样编码时先调golay23_encode,再算奇偶校验;译码时先对低23位查表,再用额外那位做检错。这个布局在后面避坑章节还会提到,因为位序只要差一点点,整个校验逻辑就对不上了。
4. 避坑与排查:Golay译码实战中五个翻车点
查表法看起来简单,但我在实际调试里踩过不少坑,下面这些现象你大概率也能遇到。每条都按“现象、原因、解决”给出,方便直接对照排障。
4.1 现象:误码率不降反升——查表法的静默误纠
现象:用随机误码注入测试,错误重量1到3时全部纠正,但把误码率曲线画出来后,在信道误码率0.05附近,纠错后的误码率反而比不纠错还高。
原因:查表法对重量大于3的错误图案不会报错,而是找一张表中存在的低重量图案来“纠正”,结果把一个已经烂掉的码字改成另一个合法但错误的码字,这就是静默误纠。查表法本质上是“最小汉明距离最近”的硬判决,当错误数量超出阈值时,它依然会朝最近码字收敛,而这个最近码字往往不是原始码字。
解决:明确查表法只对t=3以内负责,超过就要走检错路径。具体做法是改用扩展Golay(24,12,8)增加一个偶校验位,译码后检查扩展位是否匹配;不匹配时不再输出“纠正后的码字”,而是标记不可纠。这样能检测出4个错误的图案,虽然不能纠正,但至少不会让系统误以为数据是好的。在工程交付时,我会在接口注释里写明:23位版本没有检错能力,24位版本才能检4错。
4.2 现象:查表索引越界——伴随式没有掩码
现象:程序运行到g_syndromeTable[s]时偶尔崩溃,报出访问0x0000010C之类的非法地址,用调试器看s的值超过2047。
原因:golay23_syndrome的返回值没有做& 0x7FF,而LFSR寄存器reg在异或GOLAY_POLY_MASK后可能产生高于bit10的比特残留。比如reg左移后第11位变成1,异或后没有清掉,返回值就可能跑到2048以上。
解决:在函数返回处强制return reg & 0x7FF。同时把表下标检查做成assert(s < 2048),一旦越界立刻崩在调试期而不是线上。最好在构建表时也确认每个伴随式都在范围内,宁可早崩不要晚炸。这个坑的隐蔽之处在于,偶尔越界不会马上触发,只有特定误码图案才会,所以我会在译码函数里同时加两个断言:一个查s范围,一个查e是否为0xFFFFFFFF,双保险。
4.3 现象:扩展位校验方向不一致——第24位到底放哪
现象:扩展Golay编码后,发送端和接收端用同一个译码器,但纠错正确率完全不对,甚至无错时也报检错失败。
原因:编码时把扩展位放在bit23,译码时却用r23 >> 24读取,或者相反,两边位位置不一致。因为扩展位只在最高位,移位差1就会读到0或错误值。还有一种更隐蔽的情况:发送端把24位打包成3字节,接收端按字节拼接时,字节序没有统一,导致bit23跑到了bit7的位置。
解决:统一约定为“扩展位永远在bit23,低23位是原Golay码”,然后在编码、译码、校验三个函数里共用同一个常量constexpr int GOLAY_EXT_BIT = 23;,凡是涉及扩展位读取都用(value >> GOLAY_EXT_BIT) & 1,不要到处写魔法数字。如果外部数据是字节流,写一个pack24_to_uint32函数专门处理字节序,所有入口都过它,不要在每个函数里各写一遍拆位逻辑。
4.4 现象:位序倒挂——同一段码字两种解释
现象:构建表时断言撞车,两个不同的错误图案算出了同一个伴随式,或者查表后纠正效果时好时坏,跟信道误码注入方向有关。
原因:LFSR计算用的是“bit22为最高位”,但外部数据来源(比如某个模拟接口)把码字按“bit0为最高位”解析,位序没有折算,导致生成多项式和实际数据错位。在循环码里,位序直接决定多项式系数的对位关系,高位变成低位后,g(x)完全不是同一个多项式。
解决:在接口层定义唯一的码字序,比如“bit i表示码字从左到右第i位,bit0是最低位”。如果来自外部字节流,写一个reverse23(uint32_t)函数做镜像翻转,在进入译码器之前统一。我强烈建议把位序检查写进测试:构造特殊图案如1u << 22,算伴随式,和手算值比对一次就够了。不要信任“看起来一样”的位定义,C++里左移和右移的根本不是同一个概念。
4.5 现象:表构建撞车——多项式或组合枚举写错
现象:构建表时assert(g_syndromeTable[s] == 0xFFFFFFFF)失败,说明伴随式冲突,但代码看起来没错。
原因:最可能是GOLAY_POLY_MASK算错,把0xC75整个异或进寄存器;其次可能是组合枚举里写了i <= j导致重复覆盖自己。还有一个常见原因是把重量3的三重循环写成了从0开始而不是从j+1开始,导致同一个组合被重复算多次,第二次遇到时就撞了。
解决:回到定义,生成多项式低11位掩码是0xC75 & 0x7FF == 0x475,先把编码器的伴随式恒为零测试跑通,再构建表。枚举组合用严格的j = i + 1, k = j + 1,避免同一组合出现两次。如果仍然冲突,就打印冲突的伴随式和两个图案,反推多项式常量。我习惯在构建表入口加一个版本宏,让断言信息带上16进制多项式值,这样日志里直接能看到用的哪个常量。
5. 随机误码注入验证:把译码器放进真实误码环境跑一遍
查表法写完后,第一件事不是看性能,而是验证“纠3、检4”的边界。我会写一个最小闭环测试:随机生成消息,编码,按指定重量注入错误,译码,比较结果。代码大致这样:
#include <random> #include <cstdio> #include <cstdint> // 随机注入错误:在23位码字上翻转errors个随机位 uint32_t inject_errors(uint32_t codeword, int errors) { std::mt19937 rng(42); std::uniform_int_distribution<int> posGen(0, 22); bool flipped[23] = {false}; int count = 0; while (count < errors) { int p = posGen(rng); if (!flipped[p]) { flipped[p] = true; codeword ^= (1u << p); ++count; } } return codeword; } void run_error_test() { std::mt19937 rng(42); std::uniform_int_distribution<uint32_t> msgGen(0, 0xFFF); int corrected = 0, detected = 0, failed = 0; for (int trial = 0; trial < 4096; ++trial) { uint32_t msg = msgGen(rng); uint32_t cw = golay23_encode(msg); uint32_t r = inject_errors(cw, 3); // 先测3错 uint32_t decoded = golay23_decode(r); if (decoded == cw) ++corrected; else ++failed; } printf("3-error corrected: %d / 4096\n", corrected); }这段代码用固定随机种子,保证测试可复现。注入错误时用flipped数组避免重复翻转同一个位置,否则可能实际错误重量不足3。实际测试我会分成三档:错误重量0/1/2/3应该全部纠正;重量4且使用扩展码应该检错不纠错;重量5以上允许任意误判,但要把误判率统计出来。三档结果放在一个表格里看:
| 错误重量 | 期望行为 | 实际结果 |
|---|---|---|
| 0~3 | 纠正 | 译码码字与发送一致 |
| 4(扩展码) | 检错,不输出 | 返回不可纠标记 |
| 5+ | 误判可控 | 统计误码率并记录 |
如果第一档失败,查多项式;第二档失败,查扩展位奇偶逻辑;第三档只要不出现“纠错后码字和原码字汉明距小于错误图案重量”这类明显异常,可以接受。从那以后我每次做信道编码验证都强制走一遍随机误码注入流程,不再只用手工构造的几个固定向量。希望帮到你。
本文还有配套的精品资源,点击获取