ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C语言实现BCH纠错码:从有限域到BM译码的完整工程记录

C语言实现BCH纠错码:从有限域到BM译码的完整工程记录 简介BCH编译码的C语言实现面向通信、存储及数据传输系统中需要纠错编码技术的开发者与学习者可用于解决随机多位错误纠正问题。内容涵盖BCH码理论基础、伽罗华域上的多项式运算、生成多项式构造、编码流程以及伯雷坎普-梅西BM解码算法代码采用模块化写法便于理解与二次移植。压缩包内仅含1个C源文件大小约5KB代码精简适合对照注释快速掌握核心逻辑。目前已有1419人学习/下载适合具备C语言基础、正在学习编码理论或需要落地纠错算法的读者。通过阅读源码可直观看到有限域乘除法、错误位置多项式求解等关键步骤为深度理解BCH码及进一步性能优化提供可直接运行的参考实现。 前阵子在调一块NAND Flash控制器的ECC模块需求很明确在C语言环境下实现BCH编译码参数定在BCH(1023, 1013, t5)附近纠错能力强于Hamming、复杂度又低于LDPC。我翻遍了GitHub上能搜到的BCH实现发现一个尴尬事实——Matlab脚本跑得欢C工程能直接编译的寥寥无几能找到的又往往喜欢层层封装有限域运算套了好几层一旦出bug根本没法查。索性关了仓库页面从头写一份。这篇文章不是教科书复刻是我把BCH从GF(2^m)建表到BM译码全部用C实现一遍以后的完整记录连同调试中踩过的几个大坑一起分享出来。适合正在做闪存控制器、无线通信链路、串口透传纠错的朋友也适合准备写RS、LDPC但想先从BCH入手的读者。1. 为什么自己动手写BCH现成代码库的可用性陷阱1.1 开源BCH代码的三种典型状态我花了两天时间搜索BCH 编译码 C语言 开源实现接触到的代码大致分三类。第一类是Matlab或Python脚本算法验证很充分但移植到嵌入式C工程时向量化运算符和动态数组会让人头疼第二类是C语言库有些为了适配任意参数堆了大量条件编译代码量膨胀到几千行有些则写死了某个生成多项式想换个纠错能力就得重写第三类最危险——代码很短注释很少没有任何测试向量作者自己可能都没跑通过。我印象最深的一次是找到一个号称轻量级的实现编码出来的校验位和Matlab参考结果完全对不上。我逐行排查了很久最后发现是生成多项式的系数比特序颠倒了。这种问题在小型代码库里特别容易发生因为作者通常只在某一个参数组合下验证过换一个码长或者换一种位序约定就原形毕露。1.2 手写一套的真正收益自己写BCH编译码表面上多花时间实际上是把不可控变成可控。参数可以裁剪代码体积能做得很小每一层都能插入断言和测试点出问题能定位到具体函数更重要的是实现一遍之后你对有限域、生成多项式、伴随式这些概念的理解会完全不一样之后再去看RS码或者LDPC码的文献会发现很多东西是相通的。我当时给自己定的目标很简单核心编译码逻辑不超过500行C代码不依赖任何第三方库能在STM32这类MCU上直接跑起来。最后实现下来编码加译码的核心代码确实控制在400行左右加上表格和注释也就600行。这个规模在调试时可以做到所有代码都在脑子里。2. GF(2^m)有限域BCH运算的地基与建表细节2.1 域元素的三重身份BCH码所有的数学运算都在GF(2^m)有限域中完成。域里的每一个元素有三种等价身份一个m位的二进制数、一个次数小于m的多项式、以及某个本原元α的幂次。比如在GF(16)中元素 0b1010 可以看成多项式 x^3 x也可以写成 α^i 的某个幂次形式。域的加法就是按位异或这个没人会搞错。乘法则是先把两个多项式相乘再对本原多项式取模。手算乘法很麻烦但工程上根本不需要手算——用查表法。建表的核心思路是从 α^01 开始每次乘以α等价于左移一位如果移位后超出了m位范围就异或上本原多项式把结果拉回域内这样就能生成全部 GF_N2^m-1 个非零元素。这个过程类似模2除法求余数和CRC校验的移位过程是一模一样的。2.2 本原多项式与建表代码本原多项式是GF(2^m)的宪法选错了整个域结构就是错的编码译码必然全军覆没。常用参数一般是查表得到的GF(16)常用 0x13x^4x1GF(256)常用 0x11Dx^8x^4x^3x^21。我的建议是不要自己发明本原多项式直接用标准表。#define GF_M 4 #define GF_N ((1 GF_M) - 1) /* 15 */ static unsigned char gf_exp[GF_N * 2]; static unsigned char gf_log[GF_N 1]; static void gf_init(void) { int i, x 1; unsigned int prim 0x13; /* x^4 x 1用于 GF(16) */ for (i 0; i GF_N; i) { gf_exp[i] (unsigned char)x; gf_log[x] (unsigned char)i; x 1; if (x (1 GF_M)) x ^ prim; /* 用异或实现对本原多项式取模 */ } for (i GF_N; i GF_N * 2; i) gf_exp[i] gf_exp[i - GF_N]; }有了指数表和对数表乘法就变成三次查表加一次加法static inline int gf_mul(int a, int b) { if (a 0 || b 0) return 0; return gf_exp[gf_log[a] gf_log[b]]; } static inline int gf_inv(int a) { return gf_exp[GF_N - gf_log[a]]; }gf_exp 数组要开成两倍长度原因很直接gf_log[a] gf_log[b] 最大可以达到 2*GF_N-2如果不复制一份尾部数据数组就会越界。这是很多简化版实现容易漏掉的地方轻则段错误重则拿到一个随机值导致编译码间歇性出错。2.3 域表是调试的第一道关卡写完 gf_init 后我强烈建议先做一件事把 gf_exp 表打印出来和Matlab的gf(0x13)生成的域表逐项对照。不要觉得这一步多余本原多项式写错一位、移位方向写反、异或条件判断错误都会在表里留下明显的断层。我后面的排查经历里有将近一半的诡异问题最后都追溯到这张表所以先把地基打牢后面才不会被折腾。3. 编码端落地系统码生成与LFSR多项式除法3.1 生成多项式g(x)是怎么来的BCH编译码最核心的参数是生成多项式 g(x)。理论上t 位纠错能力的BCH码g(x) 是 α^1, α^3, ..., α^(2t-1) 这些元素的最小多项式的最小公倍式。为什么只需要奇数下标因为GF(2^m)中存在共轭关系偶数下标对应的最小多项式一定会和前面的某个奇数下标重复取lcm时不会贡献额外因子。以最经典的 BCH(15, 7, 5) 为例m4t2。α 的最小多项式是 x^4x1α^3 的最小多项式是 x^4x^3x^2x1两者相乘就得到g(x) x^8 x^7 x^6 x^4 1生成多项式次数为8正好等于 n-k15-78校验位就是8比特。每次确定参数后我都会先用这种方法手算或者用参考工具确认 g(x) 的系数再写进C代码里。不要直接抄网上的系数表因为不同教材在位序约定上经常不一致抄错的概率很高。3.2 编码的本质是多项式除法系统码的编码思路很朴素信息多项式 m(x) 乘以 x^(n-k)就是把信息位往高次方向挪出校验位的位置再除以 g(x)余数就是校验位。除法的商不需要关心只要余数。这种只关心余数的特性正好对应线性反馈移位寄存器——LFSR在每一拍做的事情就是多项式除法寄存器里存的就是当前余数。软件实现时LFSR可以写成一个整数移位寄存器按位处理uint32_t reg 0; /* 宽度 n-k 位的校验寄存器 */ for (i 0; i n_bits; i) { int bit (data[i / 8] (7 - i % 8)) 1; /* MSB-first */ int fb ((reg (n_parity - 1)) 1) ^ bit; reg 1; if (fb) reg ^ gen_poly_low; /* 生成多项式去掉最高次项后的低 n-k 位 */ } for (j 0; j n_parity; j) parity[j] (reg (n_parity - 1 - j)) 1;这里gen_poly_low对于 BCH(15,7,5) 就是去掉最高次 x^8 后剩下的 0xD1也就是二进制 11010001。代码里我特意用 MSB-first 的比特解包顺序这是整个链路中必须统一的约定后面会专门讲这个坑。3.3 为什么先逐位跑通再按字节优化上面这段代码是逐位处理的性能一般但逻辑非常直观适合第一次实现时调试。我建议先用这段逐位版本把功能跑通配合测试向量确认编码结果无误之后再去做按字节优化。按字节优化的思路是每次从数据字节取8个bit送入LFSR通过预计算16张或1张组合异或表把8次迭代合并成几次查表和异或。这个优化能把编码耗时缩小到原来的五分之一左右但代码可读性会明显下降如果一开始就写优化版出bug时很难分清楚是算法问题还是优化引入的问题。4. 译码三件套伴随式、BM迭代与Chien搜索4.1 伴随式计算把错误投影出来接收端拿到的是可能含错的码字 r(x)。BCH译码的第一步是计算伴随式 S_i r(α^i)i1,2,...,2t。如果接收码字没有错误那么 r(x) 是 g(x) 的倍式而 g(x) 的根包含 α^1 到 α^(2t)所以所有 S_i 都为0。有错时S_i 携带了错误位置和错误值的信息。static void calc_syndrome(const unsigned char *r, int n, int *syn, int t) { for (int i 1; i 2 * t; i) { int s 0; for (int j 0; j n; j) if (r[j]) s ^ gf_exp[(i * j) % GF_N]; syn[i - 1] s; } }这里的 r[j] 是比特值0或1gf_exp[(i * j) % GF_N]就是 α^(i*j)。整个循环本质上是在做加权求和只是GF(2)上的加法就是异或。有一类BCH码可以用 S_2i S_i^2 的性质减少一半计算量但前提是码是二元BCH我当时为了代码统一没有用这个技巧性能测试下来也足够。4.2 Berlekamp-Massey迭代从伴随式到错误位置多项式伴随式只知道有错不知道错在哪。Berlekamp-Massey算法的思路是把伴随式序列看成一个线性反馈移位寄存器的输出然后找一个最短的LFSR能生成这个序列这个LFSR的抽头系数就是错误位置多项式 σ(x) 的系数。迭代过程中每一步计算当前多项式的预测误差 d如果 d 不为0就修正 σ(x)否则跳过。static void bm_solve(const int *syn, int t, int *sigma, int *sigma_deg) { int C[32] {0}, B[32] {0}; int L 0, m 1, b 1; C[0] 1; B[0] 1; for (int n 0; n 2 * t; n) { int d syn[n]; for (int i 1; i L; i) d ^ gf_mul(C[i], syn[n - i]); if (d 0) { m; continue; } int T[32]; memcpy(T, C, sizeof(T)); int coeff gf_mul(d, gf_inv(b)); for (int j 0; j m 2 * t; j) C[j m] ^ gf_mul(coeff, B[j]); if (2 * L n) { L n 1 - L; memcpy(B, T, sizeof(B)); b d; m 1; } else { m; } } *sigma_deg L; memcpy(sigma, C, sizeof(C[0]) * (L 1)); }这段代码有三个容易写错的地方。第一迭代次数必须是 2t不是 t因为要用全部2t个伴随式来约束σ(x)第二C[] 和 B[] 数组的大小至少要是 2t1因为L最大就是t但中间临时数组T要备份整个C第三d ^ gf_mul(C[i], syn[n - i])里的^不是简单的二进制异或它是在GF(2^m)上做加法而GF加法恰好是异或所以写成^是对的但你要清楚这里的 syn[n-i] 是一个域元素而不是普通整数。4.3 Chien搜索逐点代值定位错误比特σ(x) 是一个次数不超过t的多项式它的根对应错误位置的倒数。如果 σ(α^{-p}) 0那么接收码字的第 p 个比特就是错的。逐个解方程不现实Chien搜索的思路简单粗暴把每个位置 p 的 α^{-p} 代进去算一遍判断是否为0。static int chien_search(const int *sigma, int deg, int n, int *pos) { int cnt 0; for (int p 0; p n; p) { int val 0; for (int k 0; k deg; k) { int power (GF_N - (p * k) % GF_N) % GF_N; val ^ gf_mul(sigma[k], gf_exp[power]); } if (val 0) pos[cnt] p; } return cnt; }找到错误位置后二元BCH的处理非常直接r[pos] ^ 1翻转比特即可。这里体现了二元BCH和RS码最大的区别——RS码还要用Forney公式算错误值因为一个符号错了几位需要恢复原始值二元BCH每个位置只有0和1错误就是翻转不需要额外的错误值计算。4.4 一个必须补上的防护逻辑Chien搜索找到的错误个数 cnt 如果等于0但伴随式不全为0说明错误数超出了tσ(x) 的阶数已经不足以描述错误模式。这种情况下宁可上报不可纠正也不要假装成功。我当时加了一段如果 cnt ! L直接返回译码失败。注意这里 L 是BM算法输出的σ(x)阶数正常情况下 cnt 应该等于 L如果不相等说明中间环节有问题或者错误模式太复杂直接放弃最安全。5. 实测踩坑从伴随式异常到误纠的完整排查链路5.1 坑一本原多项式选错伴随式全部异常第一次跑通编码后我做无差错译码测试编码出的码字直接送进译码器伴随式应该全为0。结果 syn[0] 和 syn[2] 明显不为0而且不是某一个位置错是每隔几个值就跳变。我一度怀疑是BM算法写错了但伴随式计算在BM之前问题一定出现在更底层。排查链路是层层往前的。先打印 gf_exp 表和参考对照——前几个元素正常到 α^4 开始对不上说明本原多项式选错了。我把 GF(16) 的本原多项式写成了 0x1F而不是标准的 0x13。这个错误导致的症状非常隐蔽因为表的前半段看起来还有规律不做对照根本发现不了。从那次以后我每次新建工程都会在代码里留一个gf_print_table()调试函数配套一份参考表的十六进制字符串比对通过才继续往下写。5.2 坑二比特位序不统一编码结果随机出错修好域表后无差错译码通过了但加错测试又翻车。具体情况是信息位原样保留因为是系统码校验位有时对有时错而且错误没有固定规律。我单步调试了很久终于定位到问题出在 LFSR 编码的输入比特顺序和伴随式遍历的比特顺序不一致。编码器按 MSB-first 取数据比特而我在构造测试码字时按字节的 LSB-first 填了数据两个约定一冲突多项式除法的每一步输入都被打乱了余数自然不对。这个问题在文档里往往被一笔带过因为每个实现都默认读者和自己用同一个位序。实际工程里数据可能来自DMA、FIFO、不同的总线宽度位序约定很容易漂移。我的解决办法很朴素但有效在编译码模块的头文件顶部写死一条注释所有算法接口统一使用MSB-first位序然后在唯一的数据入口处做转换算法内部一律不再关心端序。这样即使外部数据是LSB-first只需要改入口处一个函数。5.3 坑三缩短码的位置偏移我从 BCH(1023, 1013) 缩短成实际码长200比特时出现了一个怪现象注入一个已知位置的错误译码器能成功纠错但纠正的位置总是差了固定偏移。原因在于缩短码的数学本质——BCH(200, 190) 并不是一个独立的BCH码而是把 BCH(1023, 1013) 的码字前面若干位强制置0后省略传输。Chien搜索遍历的仍然是以 α 为根的完整循环群所以找到的指数位置是相对于原始码长的不是相对于实际传输长度的。修正方法是在 Chien 搜索的位置映射上做一个偏移调整。我当时写了一个参数code_offset表示缩短掉的高位数量最终错误位置 chien搜索到的位置 - code_offset而且搜索范围要限制在有效码长内不要让译码器去处理那些从来没有传输过的位置。这个坑建议在设计参数结构时就预留字段不要等出了问题再打补丁。5.4 坑四超过纠错能力时的误纠最后一个坑最有意思。我故意注入 t1 个错误期望译码器报告失败结果它返回成功并且纠出来的码字竟然是合法的——也就是说错误的码字被纠正成了另一个合法码字伴随式计算为0BM和Chien搜索也都正常工作。这不是bug是BCH码本身的判决域问题。BCH(15,7,5) 的最小距离是5任意两个合法码字之间至少有5个比特不同。当错误数达到3个时接收码字可能恰好落在另一个合法码字的判决域里译码器会自信地把它纠正成那个合法码字。t位纠错能力只是保证错误在t以内必然纠对并不保证错误超过t必然报错。工程上的应对措施有三层。第一设计时留余量比如需要纠5位就选t8的码牺牲一点编码效率换取误判概率的指数级下降第二在BCH外层加CRC先让CRC判断数据是否有概率出错再决定是否信任BCH的纠错结果第三译码器对纠出的码字再做一次编码用编码结果和接收数据比对不一致就报失败——这个方案会增加一些计算量但能拦住大部分明显误纠的场景。我自己在闪存项目里用的是方案一加方案二CRC开销很小效果稳定。6. 性能优化与工程化让BCH在嵌入式里跑得稳6.1 查询表全部静态化乘法内联BCH编译码的耗时大头在GF乘法。每次 gf_mul 要做三次数组访问和一次加法如果作为普通函数调用编译器不一定能内联。我的做法是把 gf_mul 和 gf_inv 都声明成static inline并且放在头文件里定义确保所有调用点直接展开。域表本身是只读的gcc和大多数嵌入式编译器都能放在flash的rodata段不会占用宝贵的RAM。对于码长比较长的场景还可以进一步预计算幂次表。比如把 α^(i*j) 的结果做成一个 (2t1) x n 的二维表伴随式计算就退化为纯查表。我当时在BCH(1023, t5)下用这个方案伴随式计算时间从几毫秒降到几百微秒效果显著代价是ROM占用增加。6.2 实时性迭代次数固定天然适合硬实时BCH译码和LDPC的一个显著区别是计算量确定性。BM迭代固定 2*t 轮Chien搜索固定 n 次全程没有直到收敛的循环。这让BCH非常适合用在中断处理函数或实时性要求高的场景因为最坏执行时间可以精确计算。我在文档里给每个函数都写了周期数估算表编码、伴随式、BM、Chien各占多少周期方便做任务调度预算。需要注意控制栈空间。BM算法中临时数组 C[32]、B[32]、T[32] 如果是局部变量在 t16 时大约占用 3322192 字节加上其他函数调用链栈用量可能接近400字节。对于某些栈很小的MCU这需要提前评估。我的做法是把大的临时数组统一放到一个结构体里由调用者传入避免在中断上下文里爆栈。6.3 测试向量与回归流程最后说说测试。不要把无差错译码通过当作完成信号那只是万里长征第一步。我的测试方案分三层无差错测试编码 - 直接译码断言伴随式全0输出与输入完全一致。纠错能力内测试对每个位置逐一注入1个错误再对随机位置注入2到t个错误断言译码成功且纠后码字与原码字一致。超能力测试注入t1到t3个错误不要求译码成功但绝对不能出现程序崩溃、死循环或越界写。测试向量不要自己手算用参考工具生成。我习惯用Python的galois库或者Matlab的Communications Toolbox生成一组已知码字导出成二进制文件让C测试程序读取并逐项比对。每次修改GF表、位序约定或生成多项式后这一整套回归必须全量跑一遍。我在BCH上踩过的坑几乎都在回归测试里被提前暴露过没有一次例外。这套实现后来在项目里跑了小半年最满意的一点是每一层都可以单独插入测试点。BCH不像RS那样要处理错误值也不像LDPC那样有收敛问题数学逻辑相对直白非常适合作为学习纠错码的第一个工程。如果你正打算给项目加ECC我建议先把纠错余量留足再去优化性能——纠错能力不够带来的返工远比你想象中大得多。本文还有配套的精品资源点击获取
返回列表