ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

汉明码纠错原理与C语言实现:从(7,4)到(12,8)及ECC内存

汉明码纠错原理与C语言实现:从(7,4)到(12,8)及ECC内存 做嵌入式通信和存储的同学大概率都遇到过这种诡异情况数据在链路上跑一圈回来某个字节悄无声息地变了最常用的奇偶校验却只告诉你“出错了”至于哪一位出错一脸茫然。汉明码就是专门解决“单比特翻转”这个问题的一套方案它不光能发现错误还能精确定位错误位置并自动纠正代价是每个数据块里塞进几个额外的校验位。我最早接触它是在给一个低功耗无线传感器做链路层误码保护实测下来信噪比边缘的随机单比特错误基本都能救回来。这篇文章我会从最朴素的校验思想讲起手把手带你算一个(7,4)码再给出可直接复用的(12,8)C语言实现顺便把ECC内存背后的原理也串起来。适合想彻底搞懂纠错码、或者要在项目里实际用汉明码的嵌入式、通信、存储方向开发者。1. 汉明码纠错原理为什么它能“定位”错误1.1 单比特奇偶校验的最大痛点我们最熟悉的奇偶校验本质上是把所有数据位做一次异或得到一个校验位。发送端保证整个码字里“1”的个数为偶数或者奇数接收端重新数一遍如果对不上就认为出错。这个方案最大问题有两个第一它只能告诉你有错不能告诉你错在哪第二如果恰好有偶数个比特翻转比如两位同时从0变成1校验结果依然是正常的错误直接漏检。在实际通信链路里单个比特随机翻转是最常见也最朴素的一种错误模型——噪声尖峰、电源抖动、辐射导致存储单元状态翻转。这时候如果只知道“有错”接收端唯一的办法是丢弃数据、请求重传。对于实时性要求高的场合重传代价很大。汉明码的价值就在于它通过多个校验位的组合把“是否有错”升级为“错在第几位”然后就地翻转纠正根本不用重传。汉明码是Richard Hamming在1950年提出的当年他在贝尔实验室做早期计算机的数值计算机器运行过程中经常因为继电器或存储单元的单个bit错误导致整个程序白跑。他发现奇偶校验只能发现错误却救不回来于是开始思考有没有一种编码能让计算机自动找到出错的那一位然后自己改回来这个思路后来成了整个纠错码领域的起点。1.2 重叠分组让每个校验位管一组“成员”汉明码的核心设计思想我习惯叫它“重叠分组”。打个比方小区里每个住户同时加入了多个不同的邻里微信群每个群都有一个楼长。某天群里突然有人说“我家数据不对”楼长一核对发现好几个群同时报警。通过观察“哪些群报警、哪些群没报警”就能唯一确定是哪一户出了问题。汉明码的每个校验位就是这样一个楼长它负责监督一组特定位置的位。具体规则是这样的把最终码字的所有位置从1开始编号第i个校验位放在编号为2的幂次方的位置上也就是位置1、位置2、位置4、位置8……然后每个校验位负责监督所有“位置编号的二进制表示中第i位为1”的位。比如P1放在位置1它负责编号二进制末位为1的位置1、3、5、7、9、11P2放在位置2它负责编号二进制第二位为1的位置2、3、6、7、10、11P3放在位置4负责二进制第三位为1的位置4、5、6、7、12P4放在位置8负责二进制第四位为1的位置8、9、10、11、12。这里有个关键点位置编号为什么从1开始不从0开始因为0的二进制表示全是0没有任何一个校验位分组会包含它也就无法通过校验位组合去定位它。而且syndrome后面会详细讲为0必须表示“没有错误”这个特殊状态如果从0开始人就不知道0号位出错该怎么办了。所以汉明码的位置编号天然是1-based这一点刚上手时最容易绕晕。每个校验位对它负责的那一组位置做偶校验也就是把组内所有位的值异或起来结果为0。发送的时候校验位的值要根据组内其他数据的异或结果来设置保证整组异或为0。接收的时候重新算一遍整组的异或如果结果不为0说明这一组里至少有一位出错。多个校验位同时异或出1时它们的编号组合起来就能直接指向那个出错的位置。1.3 校验位数量是怎么算出来的现在的问题变成要纠1位错到底需要多少个校验位假设总码长为n位其中数据位k位校验位r位那么n k r。接收端拿到码字后每个校验位都会给出一个“正常/报警”的二值结果这r个结果可以组成一个r位的二进制数也就是syndrome。syndrome为0表示没有错误非零值至少要能表示所有n个可能出错的位置所以必须满足2^r ≥ n 1把n k r代入就是2^r ≥ k r 1这就是汉明界Hamming bound也是汉明码能达到的最优下界。举个例子4位数据需要r3因为2^38而4318刚好满足8位数据需要r4因为2^41684113满足16位数据需要r52^532165122满足。可以看到数据位越多校验位的占比越低编码效率越高。汉明码的最小汉明距离是3。所谓汉明距离就是两个合法码字之间逐位比较不同位的个数。最小距离为3意味着任意两个合法码字至少相差3个bit所以当发生1位错误时错误的码字距离原始合法码字是1距离其他任何合法码字至少是2接收端可以唯一确定原始码字同时它也能发现2位错误因为2位错误后的码字已经不再是合法码字只是无法确定应该纠正成哪一个离两个合法码字的距离都是1。这个“距离”概念在后面理解SEC-DED扩展码时很有用。2. 手算汉明码从(7,4)到(12,8)的完整拆解2.1 (7,4)汉明码用手算搞懂分组逻辑先看最经典的(7,4)码4位数据D1、D2、D3、D43个校验位P1、P2、P3总码长7位。数据位放在位置3、5、6、7校验位放在位置1、2、4。分组情况是P1负责位置1、3、5、7即D1、D2、D4P2负责位置2、3、6、7即D1、D3、D4P3负责位置4、5、6、7即D2、D3、D4假设要发送的数据是D11, D20, D31, D41也就是二进制的1011。计算校验位P1 D1 ⊕ D2 ⊕ D4 1 ⊕ 0 ⊕ 1 0 P2 D1 ⊕ D3 ⊕ D4 1 ⊕ 1 ⊕ 1 1 P3 D2 ⊕ D3 ⊕ D4 0 ⊕ 1 ⊕ 1 0所以最终码字从位置1到位置7依次是0、1、1、0、0、1、1。这个码字里所有1的数量位置2、3、6、7总共4个1偶校验成立说明编码正确。现在模拟传输中出错假设位置6也就是D3发生了翻转从1变成0。接收端收到的码字是0、1、1、0、0、0、1。接收端重新计算三个校验组P1组 位置1 ⊕ 位置3 ⊕ 位置5 ⊕ 位置7 0 ⊕ 1 ⊕ 0 ⊕ 1 0和收到的P10一致说明P1组没问题。 P2组 位置2 ⊕ 位置3 ⊕ 位置6 ⊕ 位置7 1 ⊕ 1 ⊕ 0 ⊕ 1 1和收到的P21不一致说明P2组报警。 P3组 位置4 ⊕ 位置5 ⊕ 位置6 ⊕ 位置7 0 ⊕ 0 ⊕ 0 ⊕ 1 1和收到的P30不一致说明P3组报警。“报警”的校验位是P2和P3对应二进制的第2位和第3位组合起来syndrome就是110也就是二进制6。这个6正好就是出错的位置编号——位置6。确定错误位置后把这一位翻转回来数据就恢复成1011了。整个过程不需要发送端参与接收端自己就完成了纠错。2.2 从(7,4)到(12,8)校验位翻倍的变化实际工程里4位一组太少了大多数场景都按字节处理8位数据配4位校验位形成(12,8)汉明码。校验位还是在位置1、2、4、8数据位依次填入位置3、5、6、7、9、10、11、12。分组规则我们用一张表列清楚校验位位置负责的位位置P111, 3, 5, 7, 9, 11P222, 3, 6, 7, 10, 11P344, 5, 6, 7, 12P488, 9, 10, 11, 12我拿一个实际字节来走一遍。假设数据是0x3C二进制是0011 1100注意这里我把最低位记为d0最高位记为d7那么d00, d10, d21, d31, d41, d51, d60, d70。把这些数据位摆到码字对应位置位置3放d00位置5放d10位置6放d21位置7放d31位置9放d41位置10放d51位置11放d60位置12放d70然后算校验位。P1组是位置3、5、7、9、11异或结果为0⊕0⊕1⊕1⊕00P2组是位置3、6、7、10、11异或结果为0⊕1⊕1⊕1⊕01P3组是位置5、6、7、12异或结果为0⊕1⊕1⊕00P4组是位置9、10、11、12异或结果为1⊕1⊕0⊕00。所以完整码字从位置1到位置12依次是0、1、0、0、0、1、1、0、1、1、0、0。如果写成12位十六进制就是0x362。2.3 接收端的纠错魔法syndrome直接告诉你位置上一节算出来的码字0x362如果传输中位置10发生翻转对应数据位d5接收端收到的就是0x162。怎么知道错在第10位接收端做两件事第一从接收到的码字里重新计算4个校验位的值第二把计算结果和接收到的校验位逐位异或得到4位syndrome。回到例子。收到的码字位置10从1变成0那么P1组 位置3⊕位置5⊕位置7⊕位置9⊕位置11 0⊕0⊕1⊕1⊕0 0和收到的P10一致syndrome第1位为0。P2组 位置3⊕位置6⊕位置7⊕位置10⊕位置11 0⊕1⊕1⊕0⊕0 0和收到的P21不一致syndrome第2位为1。P3组 位置5⊕位置6⊕位置7⊕位置12 0⊕1⊕1⊕0 0和收到的P30一致syndrome第3位为0。P4组 位置9⊕位置10⊕位置11⊕位置12 1⊕0⊕0⊕0 1和收到的P40不一致syndrome第4位为1。所以syndrome 1010二进制十进制的10正好等于出错位置10。这个性质非常漂亮syndrome的非零值不需要查表直接就是出错位置的编号。如果syndrome等于1、2、4、8说明是校验位自己出错了如果是其他值就是数据位出错。纠正动作也极其简单把码字异或上1 (syndrome - 1)就等于把出错的位翻转一次。3. C语言实现(12,8)汉明码编码、解码与纠错3.1 先把位序约定这件事说清楚写代码之前必须先定好位序约定否则后面全是坑。我的约定是码字用uint16_t存储第12位对应bit 11第1位对应bit 0也就是位置N对应1 (N-1)。数据字节的bit 0是最低有效位d0它放进码字的位置3依此类推。这个约定完全符合汉明码的经典1-based编号方式。很多人在这一步出问题是把“位置编号”和“数组下标”混为一谈。比如位置5在代码里是1 4不是1 5。我建议在代码里直接建一张“位置-bit掩码”的映射表而不是到处写位移表达式这样即使以后改成(22,16)码也不容易改错。还有一个更隐蔽的坑发送端和接收端的位序必须对齐。如果发送端把码字按低位在前发接收端按高位在前解析那整个汉明码的定位能力直接报废。所以工程上要么在协议里明确字节序和比特序要么在代码里用统一的结构体封装码字避免各写各的。3.2 编码函数数据位就位校验位登场编码函数输入一个uint8_t原始数据输出12位码字。先把8个数据位摆到对应位置再算4个校验位。直接看代码#include stdio.h #include stdint.h /* * (12,8) 汉明码编码 * 码字用 uint16_t 存储位置 N1~12对应 bit N-1 * 位置 1,2,4,8 是校验位 P1,P2,P3,P4 * 位置 3,5,6,7,9,10,11,12 是数据位 d0,d1,d2,d3,d4,d5,d6,d7 */ uint16_t hamming_encode(uint8_t data) { uint16_t code 0; /* 数据位映射d0-位置3, d1-位置5, ... */ static const uint8_t pos[8] {3, 5, 6, 7, 9, 10, 11, 12}; for (int i 0; i 8; i) { if (data (1 i)) { code | (1 (pos[i] - 1)); } } /* 计算校验位 */ int p1 ((code 2) 1) ^ ((code 4) 1) ^ ((code 6) 1) ^ ((code 8) 1) ^ ((code 10) 1); int p2 ((code 2) 1) ^ ((code 5) 1) ^ ((code 6) 1) ^ ((code 9) 1) ^ ((code 10) 1); int p3 ((code 4) 1) ^ ((code 5) 1) ^ ((code 6) 1) ^ ((code 11) 1); int p4 ((code 8) 1) ^ ((code 9) 1) ^ ((code 10) 1) ^ ((code 11) 1); code | (p1 0); code | (p2 1); code | (p3 3); code | (p4 7); return code; }这里用异或而不是加法是因为偶校验本质上就是模2加法异或天然等价。逐个(code n) 1取位的写法虽然啰嗦但一眼能看出它对应表格里哪一组数据不容易出错。如果你想写得紧凑一点可以把组内位置写成一个数组循环处理不过代码可读性会下降调试时不方便。3.3 解码函数syndrome值就是出错位号解码函数接收12位码字返回syndrome同时通过指针输出纠正后的原始数据。syndrome为0表示无错或无法纠正的偶数错非零值表示出错位置并且函数内部已经完成纠正。代码如下/* * (12,8) 汉明码解码与纠错 * 返回值syndrome0 表示无错非零表示出错位置1~12 * 如果发生单比特错误函数会自动纠正并输出正确数据 */ int hamming_decode(uint16_t code, uint8_t *data) { /* 重新计算校验位并与收到的校验位比较 */ int p1 (code 1) ^ ((code 2) 1) ^ ((code 4) 1) ^ ((code 6) 1) ^ ((code 8) 1) ^ ((code 10) 1); int p2 ((code 1) 1) ^ ((code 2) 1) ^ ((code 5) 1) ^ ((code 6) 1) ^ ((code 9) 1) ^ ((code 10) 1); int p3 ((code 3) 1) ^ ((code 4) 1) ^ ((code 5) 1) ^ ((code 6) 1) ^ ((code 11) 1); int p4 ((code 7) 1) ^ ((code 8) 1) ^ ((code 9) 1) ^ ((code 10) 1) ^ ((code 11) 1); int syndrome (p4 3) | (p3 2) | (p2 1) | p1; if (syndrome ! 0) { /* syndrome 的值就是出错位置翻转这一位完成纠错 */ code ^ (uint16_t)(1 (syndrome - 1)); } /* 从码字中提取8位数据 */ *data ((code 2) 1) | (((code 4) 1) 1) | (((code 5) 1) 2) | (((code 6) 1) 3) | (((code 8) 1) 4) | (((code 9) 1) 5) | (((code 10) 1) 6) | (((code 11) 1) 7); return syndrome; }注意解码里的p1和编码里的p1位置不同解码时code 1是收到的P1本身异或结果如果为1说明P1这组数据存在不一致。这个设计让syndrome天然成为出错位置不需要额外判断。3.4 验证程序所有12个位置都翻一遍写代码是一回事确认代码正确是另一回事。我的习惯是写一个小main函数枚举所有12个位置逐一翻转验证每次都能纠正回原始数据。这比只测一两个随机位要可靠得多。int main(void) { uint8_t data 0x3C; uint16_t code hamming_encode(data); printf(原始数据 : 0x%02X\n, data); printf(编码码字 : 0x%03X\n, code); /* 故意翻转第10位模拟传输错误 */ uint16_t corrupted code ^ (1 9); printf(翻转第10位后码字: 0x%03X\n, corrupted); uint8_t recovered; int syndrome hamming_decode(corrupted, recovered); printf(syndrome : %d\n, syndrome); printf(纠正后数据: 0x%02X\n, recovered); /* 全位置穷举测试每个位置翻一遍都应恢复原始数据 */ int ok 1; for (int pos 1; pos 12; pos) { uint16_t bad code ^ (1 (pos - 1)); uint8_t out; int syn hamming_decode(bad, out); if (out ! data || syn ! pos) { printf(位置 %2d 测试失败syndrome%d, data0x%02X\n, pos, syn, out); ok 0; } } printf(全位置穷举测试: %s\n, ok ? PASS : FAIL); return 0; }预期输出中0x3C编码得到0x362翻转第10位后syndrome是10纠正后数据仍是0x3C。穷举测试会遍历位置1到12每个位置都能正确纠正这才算真正验证了解码逻辑。4. 实战中的坑与排查技巧4.1 syndrome为0也不一定绝对安全汉明码只能保证纠正1位错误如果一次传输中翻转了2位每个校验组里的两个错误会互相抵消syndrome会变成0看起来就像没出错一样。比如(7,4)码中位置5和位置7同时翻转P1组里两个数据位各翻转一次异或结果不变P3组里也各翻转一次同样不变接收端完全察觉不到。所以使用汉明码前先判断你的信道错误模型是否符合“单比特随机翻转”。如果信道里经常发生多比特错误纯汉明码是不够的。一种常见做法是把一个字节拆进多个独立码块再通过交织让错误分散化另一种做法是用SEC-DED扩展码牺牲一点冗余换取双比特错误检测能力。后面会展开讲SEC-DED。4.2 单比特纠错扛不住突发误码所谓突发误码指的是连续多个bit连续损坏比如无线信道里的干扰脉冲、汽车电子里的接触抖动、NAND Flash里的某条位线失效。汉明码对这种场景基本无能为力一个(12,8)码块里如果中间连续4位全坏至少2位会被翻转纠错直接失效。解决突发误码的经典手段是交织。原理很简单发送前把多个码块按“位交织”的方式重新排列比如第1个码块的第1位、第2个码块的第1位、第3个码块的第1位先发然后各码块的第2位一起发。这样突发4位损坏时实际影响的是4个不同码块的各自1位每个码块都在汉明码的纠错能力之内接收端再做反交织把位还原回各自码块每个码块都能独立纠正。4.3 ECC内存里的汉明码变种SEC-DED你可能听说过ECC内存DDR服务器内存条上比普通内存多几颗颗粒靠的就是纠错码。现代ECC内存用的是SEC-DED全称是Single Error Correction, Double Error Detection也就是纠1位错、检2位错。它和普通汉明码的关系非常直接在汉明码的基础上对这12或更长位整体再额外加一个全校验位让整个码字包含的所有位包括校验位本身也满足偶校验。加了这个全校验位之后接收端的判断逻辑变成四类syndrome为0且全校验位正常表示无错syndrome非0且全校验位异常说明发生了单比特错误syndrome直接给出位置可以纠正syndrome非0但全校验位正常说明发生了偶数个错误最常见是双比特错此时无法定位只能上报不可纠正错误syndrome为0但全校验位异常属于不可纠正的复杂错误同样只能上报。这个能力让ECC内存在处理随机单bit软错误时非常可靠同时能及时给系统报警防止数据被静默污染。标准ECC内存条上64位数据配8位校验码走72位总线。其中7位是汉明码校验位满足2^7128 ≥ 647172再加1位就是覆盖所有72位的全校验位。所以你可以看到ECC内存并没有用非常高深的数学它底子就是汉明码外加一个全校验位。4.4 选型建议什么时候用汉明码最划算我把日常会遇到的几种场景摆在一起对比能省去很多纠结场景推荐方案原因随机单比特翻转为主允许少量冗余汉明码(12,8)或更长的(22,16)纠错成本低实现简单只需要检测错误允许重传CRC16/CRC32检错能力强冗余少不需要定位信道上连续突发错误居多交织 汉明码或RS码/BCH码把突发错误打散到各码块后再纠错数据安全要求高不能静默出错SEC-DED扩展汉明码能检测双比特错并触发系统报警大容量存储介质坏块修复Reed-Solomon、LDPC需要纠多位错误汉明码能力不够如果只是为了检测错误然后重传不要用汉明码CRC更轻量、检错更强。汉明码的核心优势是“不重传、当场自愈”这个优势只在单比特错误成为主要矛盾时才能发挥出来。一旦错误模型偏离汉明码反而变成负担。最后分享一点我自己的实测体会我在那个低功耗无线传感器项目里用(12,8)汉明码给遥测帧做保护网包误码率从千分之几直接降到了几乎观测不到。但后来遇到一次现场强干扰连续几个bit被吞掉汉明码就救不回来了。最后是把码块做交织之后再发送把单次突发摊薄成多个码块的各1位错误才算彻底稳住。这个经历给我的教训很直接没有一种纠错码是万能的先搞清楚信道的错误模型再决定怎么编码。调试时还有一个非常实用的小技巧把syndrome打印出来它本身就是出错位置。做编码验证时人为翻转某一位看解码函数返回的syndrome是否等于翻转位置如果对得上整个编码解码逻辑基本就对了对不上先查位序映射八成是1 (pos - 1)和1 pos搞混了。汉明码这个东西原理弄通之后反而觉得很简单难的都是这些看似不起眼的约定和边界。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进