
1. 数值与编码的底层逻辑为什么计算机需要这么多“码”1.1 从一段真实的调试经历说起前阵子帮一个朋友排查他做的智能电表采集程序现象很诡异电表通过串口往主控板发数据偶尔会解析出完全离谱的电压值比如220V变成380V重启一下又正常了。他一开始怀疑是电表固件的问题换了三块表都一样。后来我让他把原始报文打出来看发现是CRC校验没做程序直接把噪声当数据用了。加上CRC校验之后问题立刻消失。这件事特别典型。很多人学计算机组成原理的时候觉得数值与编码这一章就是一堆公式和表格背完考试就完了。但真正到了嵌入式、通信、存储这些领域BCD码、奇偶校验、海明码、CRC这些东西是每天都要打交道的。你写的每一行串口通信代码、每一次从Flash读数据、每一个网络包的处理背后都有这些编码方案在兜底。这篇文章我打算把这几类编码从“为什么需要”到“怎么算”再到“怎么用”完整讲一遍。不是教科书那种定义堆砌而是按照一个从业者实际会遇到的场景来组织。如果你正在学计算机组成原理或者在做嵌入式通信、数据存储相关的工作这篇内容应该能帮你把这块知识真正落地。1.2 核心概念速览四类编码各自解决什么问题在展开细节之前先用一张表把这四类编码的定位说清楚。很多人学的时候容易混淆是因为没搞明白它们各自要解决的核心问题不同。编码类型核心解决的问题典型应用场景是否可纠错BCD码二进制与十进制之间的高效转换数码管显示、电表读数、金融计算否奇偶校验检测单比特错误串口通信、内存校验仅检错不能纠错海明码检测并纠正单比特错误ECC内存、卫星通信可纠错CRC校验检测突发错误网络通信、存储校验、Modbus仅检错不能纠错这张表建议先记住。BCD解决的是“人机交互”问题奇偶校验和海明码解决的是“传输可靠性”问题CRC解决的是“数据完整性”问题。它们不是互相替代的关系而是在不同层面各司其职。我见过不少初学者把CRC和海明码搞混觉得都是校验码应该差不多。实际上它们的数学基础完全不同海明码基于线性分组码理论CRC基于多项式除法。这个后面会详细展开。1.3 学习路径建议先理解“为什么”再记“怎么算”这一章的内容有个特点公式多、表格多、计算步骤繁琐。如果上来就死记硬背很容易学了后面忘了前面。我的建议是按照这个顺序来先理解每种编码要解决的实际问题然后理解它的设计思路最后才是具体的计算步骤。比如海明码你先要知道它是为了在内存里自动纠正单比特错误而设计的然后理解“校验位放在2的幂次位置”这个设计的原因最后再去算具体的校验值。这样学下来即使公式忘了你也能自己推导出来。下面进入正题从BCD码开始。2. BCD码让二进制和十进制和平共处2.1 BCD码的本质用二进制“模仿”十进制BCD的全称是Binary-Coded Decimal中文叫二-十进制编码。它的核心思想特别简单用4位二进制来表示1位十进制数字。比如十进制数25用BCD表示就是0010 0101高4位表示2低4位表示5。为什么要这么做因为二进制和十进制之间的转换是有成本的。一个8位二进制数能表示0到255但如果你要把它显示在数码管上或者打印出来给人看就需要做除法取余。除法在硬件里是很耗资源的操作。而BCD码直接把每一位十进制数字用4位二进制存起来显示的时候只需要查表译码不需要做除法。这就是BCD码存在的根本原因在需要频繁进行十进制显示的场合用存储空间的浪费换取转换效率的提升。一个4位二进制能表示16个值但BCD只用了其中10个0000到1001剩下6个1010到1111是无效的。这就是BCD码的“浪费”。但在这个场景下这种浪费是值得的。2.2 压缩BCD与非压缩BCD两种存储格式的选择BCD码在实际使用中有两种格式这个在写底层驱动的时候经常遇到需要分清楚。压缩BCD一个字节存两位十进制数高4位存十位低4位存个位。比如十进制数42压缩BCD就是0100 0010。这种格式节省空间一个字节顶两个十进制位。非压缩BCD一个字节存一位十进制数低4位是数值高4位通常是0000或者1111取决于具体规范。比如十进制数42非压缩BCD需要两个字节0000 0100和0000 0010。在x86汇编里非压缩BCD的高4位在加法运算后可能是任意值需要用AAA指令调整。压缩BCD用DAA指令调整。这些指令现在用得少了但理解它们有助于理解BCD的本质。实际做嵌入式开发的时候选择哪种格式取决于你的需求。如果存储空间紧张用压缩BCD如果处理方便优先用非压缩BCD。我个人的经验是在STM32这类MCU上做电表数据存储压缩BCD用得更多因为EEPROM空间有限。2.3 BCD码的运算调整为什么需要“加6修正”BCD码的加法有个坑直接用二进制加法器算结果可能不是合法的BCD码。举个例子十进制8 7 15。用BCD表示8是10007是0111二进制相加得到1111。但1111不是合法的BCD码合法范围是0000到1001。这时候就需要修正因为结果大于9需要加60110进行调整。1111 0110 1 0101进位1低4位是0101正好是15的BCD表示。这个“加6修正”的原理是4位二进制有16个状态BCD只用了10个中间跳过了6个状态。当结果落在1010到1111这个无效区间时加6就能把它“推”回有效区间并产生正确的进位。减法类似如果低位向高位借位需要减6修正。注意做BCD运算的时候一定要在每一步运算后都做调整不能等所有运算做完再统一调整。因为中间结果可能已经溢出了合法范围后续运算会基于错误的值继续算。2.4 BCD码在实际项目中的应用要点我在电表项目里用BCD码比较多分享几个实操经验。第一BCD码和ASCII码的转换。很多电表协议里数据是用ASCII字符表示的比如字符2的ASCII是0x32字符5是0x35。要转成压缩BCD需要把每个字节的高4位去掉然后合并。这个转换在串口接收中断里做要注意字节序。第二BCD码的合法性检查。从外部设备收到的BCD数据一定要检查每一位是否在0到9之间。我遇到过电表在异常情况下发出0xFA这种非法BCD值如果不检查直接当数据用算出来的电压值会完全错误。第三BCD码的显示。驱动数码管的时候BCD到七段码的转换通常用查表法。表的大小是10个字节0到9查表比实时计算快得多。如果用的是共阴极数码管段码表大概是这样的const uint8_t bcd_to_7seg[] { 0x3F, // 0 0x06, // 1 0x5B, // 2 0x4F, // 3 0x66, // 4 0x6D, // 5 0x7D, // 6 0x07, // 7 0x7F, // 8 0x6F // 9 };这个表在实际项目里直接抄就行省得每次重新算。3. 奇偶校验最简单也最容易被忽视的检错手段3.1 奇偶校验的基本原理一个比特的代价奇偶校验的思路极其简单在数据后面加一个校验位使得整个数据包括校验位中1的个数为奇数奇校验或偶数偶校验。比如数据1011001里面有4个1。如果用偶校验校验位填0总共4个1偶数如果用奇校验校验位填1总共5个1奇数。接收方收到数据后重新计算1的个数如果不符合约定的奇偶性就知道传输过程中出错了。这个方案的优点是极其简单只需要一个异或门就能实现。缺点是只能检测奇数个比特错误。如果有2个比特同时翻转1的个数变化是偶数奇偶性不变就检测不出来。3.2 奇偶校验在串口通信中的实际配置串口通信是奇偶校验最常见的应用场景。配置串口的时候你会看到这样的选项数据位8位、停止位1位、校验位None/Odd/Even。这里的Odd就是奇校验Even就是偶校验。在STM32的HAL库里配置奇偶校验的代码大概是这样huart1.Init.Parity UART_PARITY_EVEN; // 偶校验 huart1.Init.WordLength UART_WORDLENGTH_9B; // 注意带校验位时数据长度要设为9位这里有个坑启用奇偶校验后数据位要设为9位。因为校验位占用了第9位。很多人配置的时候忘了改这个结果数据解析全乱。还有一个经验在电磁干扰比较严重的工业现场奇偶校验的检错能力其实不太够。我做过一个RS485的温湿度采集项目现场有变频器干扰大的时候奇偶校验经常报错但偶尔也会有漏检的情况。后来换成了CRC校验才稳定下来。所以如果你的通信环境比较恶劣建议直接上CRC别在奇偶校验上省事。3.3 奇偶校验的硬件实现一个异或门就够了奇偶校验在硬件上实现特别简单。发送方把所有数据位做异或运算结果就是偶校验位如果1的个数是奇数异或结果是1补上这个1就变成偶数个1。接收方把所有数据位和校验位一起做异或如果结果是0偶校验或1奇校验说明没有错误。这个特性使得奇偶校验在早期硬件中被广泛使用。比如老式的DRAM内存每个字节配一个奇偶校验位成本增加12.5%但能检测出大部分单比特错误。现在服务器上用的ECC内存其实是海明码的简化版本能纠错而不只是检错。这个后面讲海明码的时候会详细说。3.4 奇偶校验的局限性与适用边界奇偶校验最大的问题是检错能力有限。它能检测所有奇数个比特错误但检测不出偶数个比特错误。在突发错误连续多个比特出错的场景下奇偶校验基本没用。那它为什么还在用因为简单、便宜、快。在短距离、低干扰的通信场景下比如芯片之间的I2C通信奇偶校验够用了。而且有些场景下错误检测不是目的只是提供一个“大概靠谱”的信号真正的可靠性由上层协议保证。我的建议是如果是板级通信同一块PCB上的芯片之间奇偶校验可以用如果是板间通信或者现场总线直接上CRC。这个选择在项目初期就要定好后期改协议成本很高。4. 海明码能自动纠错的校验码4.1 海明码的核心思想用多个校验位定位错误奇偶校验只能告诉你“出错了”但不能告诉你“哪里错了”。海明码的突破在于通过多个校验位的组合不仅能检测错误还能定位到具体是哪一位出错从而自动纠正。海明码的设计非常巧妙。它把校验位插入到数据位的特定位置2的幂次位置第1、2、4、8、16...位每个校验位负责校验一组特定的数据位。当错误发生时所有校验失败的校验位的位置编号相加就是出错数据位的位置。举个例子如果第1、2、4位的校验都失败了1247说明第7位出错了。把第7位取反就完成了纠错。这个设计的数学基础是每个数据位的位置编号都可以表示为若干个校验位位置编号的和。比如第7位 第1位 第2位 第4位所以第7位的数据会参与第1、2、4位校验位的计算。4.2 海明码的编码步骤从数据位到校验位下面用一个具体例子来演示海明码的编码过程。假设要传输的数据是10114位采用偶校验。第一步确定校验位的数量。校验位数量r需要满足2^r 数据位数量 r 1。对于4位数据2^38 4318所以需要3位校验位。总长度是437位。第二步确定校验位的位置。校验位放在第1、2、4位2的幂次位置数据位放在其余位置。位置1234567类型P1P2D1P4D2D3D4值??1?011第三步确定每个校验位负责的数据位。规则是位置编号的二进制表示中从右往左第k位为1的数据位由第2^(k-1)个校验位负责。P1位置1负责所有位置编号二进制最低位为1的位3, 5, 7P2位置2负责所有位置编号二进制次低位为1的位3, 6, 7P4位置4负责所有位置编号二进制第三位为1的位5, 6, 7第四步计算校验位的值偶校验。P1 D1 ⊕ D2 ⊕ D4 1 ⊕ 0 ⊕ 1 0P2 D1 ⊕ D3 ⊕ D4 1 ⊕ 1 ⊕ 1 1P4 D2 ⊕ D3 ⊕ D4 0 ⊕ 1 ⊕ 1 0最终传输的码字是0 1 1 0 0 1 1位置1到7。4.3 海明码的纠错过程定位并翻转错误位假设接收方收到的码字是0 1 1 0 1 1 1第5位从0变成了1。接收方重新计算三个校验组G1 P1 ⊕ D1 ⊕ D2 ⊕ D4 0 ⊕ 1 ⊕ 1 ⊕ 1 1失败G2 P2 ⊕ D1 ⊕ D3 ⊕ D4 1 ⊕ 1 ⊕ 1 ⊕ 1 0通过G4 P4 ⊕ D2 ⊕ D3 ⊕ D4 0 ⊕ 1 ⊕ 1 ⊕ 1 1失败失败的是G1和G4位置编号相加145。第5位出错取反即可纠正。这个过程的精妙之处在于校验失败的位置编号之和直接指向错误位。不需要查表不需要复杂的计算硬件实现就是一个异或网络加一个加法器。4.4 海明码的扩展SEC-DED与ECC内存基本的海明码只能纠正单比特错误SECSingle Error Correction。如果同时发生两个比特错误海明码可能会误判为另一个单比特错误导致纠错后结果更错。为了解决这个问题实际应用中通常会增加一个总的奇偶校验位形成SEC-DEDSingle Error Correction, Double Error Detection码。这个额外的校验位能检测出双比特错误但只能纠正单比特错误。服务器上的ECC内存用的就是SEC-DED方案。每64位数据配8位校验位能纠正单比特错误检测双比特错误。这就是为什么ECC内存比普通内存贵——多出来的8位存储芯片和更复杂的控制器。在做高可靠性嵌入式系统的时候如果MCU支持ECC内存建议开启。我做过一个工业网关的项目现场电磁环境复杂开启ECC后系统稳定性明显提升之前偶尔出现的莫名死机问题也消失了。5. CRC校验通信领域最常用的检错方案5.1 CRC的数学基础多项式除法CRC的全称是Cyclic Redundancy Check循环冗余校验。它的核心思想是把数据看作一个多项式用一个约定的生成多项式去除余数就是CRC校验值。比如数据1101可以看作多项式x^3 x^2 1。生成多项式比如x^3 x 1对应1011。做多项式除法得到的余数就是CRC值。实际计算的时候用的是模2除法异或运算不借位。这个在硬件上特别容易实现一个移位寄存器加几个异或门就够了。CRC的检错能力很强特别是对突发错误。一个r位的CRC能检测出所有长度不超过r的突发错误以及大部分更长的突发错误。这就是为什么网络通信、存储系统都大量使用CRC。5.2 常见CRC参数CRC-8、CRC-16、CRC-32怎么选CRC有很多变种区别在于生成多项式的位数和具体参数。常见的有类型生成多项式校验位长度典型应用CRC-8x^8x^2x18位单总线、I2CCRC-16/Modbusx^16x^15x^2116位Modbus、串口通信CRC-16/CCITTx^16x^12x^5116位X.25、蓝牙CRC-32标准32位多项式32位以太网、ZIP、PNG选择哪种CRC主要看两个因素数据长度和错误检测要求。数据越长需要的CRC位数越多。一般来说CRC位数应该大于等于数据长度的对数。对于几十字节的串口数据CRC-16够用了对于几KB的网络包CRC-32更合适。Modbus协议用的是CRC-16生成多项式是0x8005x^16x^15x^21初始值0xFFFF结果异或0x0000输入输出都不反转。这个参数组合在工业现场用了很多年可靠性经过验证。5.3 CRC-16/Modbus的完整计算过程下面用具体例子演示CRC-16/Modbus的计算。假设要发送的数据是0x01 0x03 0x00 0x00 0x00 0x01Modbus读保持寄存器的请求帧。方法一按位计算适合理解原理uint16_t crc16_modbus(uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; for (uint16_t i 0; i len; i) { crc ^ data[i]; for (uint8_t j 0; j 8; j) { if (crc 0x0001) { crc 1; crc ^ 0xA001; // 0x8005的反转 } else { crc 1; } } } return crc; }计算结果是0x840A。在Modbus帧中CRC低字节在前高字节在后所以发送时附加0x0A 0x84。方法二查表法适合实际项目按位计算每次要循环8次对于高速通信来说太慢了。实际项目里用查表法预先算好256个值每次处理一个字节只需要一次查表和一次异或。const uint16_t crc16_table[256] { /* 预计算的值 */ }; uint16_t crc16_modbus_fast(uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; for (uint16_t i 0; i len; i) { crc (crc 8) ^ crc16_table[(crc ^ data[i]) 0xFF]; } return crc; }这个表可以用Python脚本生成也可以在网上找现成的。我一般用这个脚本生成def generate_crc16_table(): table [] for i in range(256): crc i for _ in range(8): if crc 1: crc (crc 1) ^ 0xA001 else: crc 1 table.append(crc) return table5.4 CRC在实际项目中的避坑指南坑一字节序搞反。Modbus的CRC是低字节在前但有些协议是高字节在前。我见过一个项目发送方和接收方对CRC字节序的理解不一致导致所有帧都校验失败。调试的时候把CRC值打印出来对比一眼就能看出来。坑二初始值和结果异或搞错。不同的CRC变种初始值可能是0x0000、0xFFFF或其他值结果可能异或0x0000或0xFFFF。用在线计算器验证的时候一定要把参数设对。我习惯用“CRC计算器”这类工具先验证算法确认无误后再写代码。坑三数据范围搞错。CRC计算应该包含哪些字节Modbus是包含从站地址到数据末尾的所有字节不包括CRC本身。有些协议只对数据部分计算CRC不包括帧头。这个一定要看协议文档确认。坑四查表法的表生成错误。如果表生成脚本的多项式写错了整个表都是错的。建议生成表之后用几个已知输入验证一下。比如CRC-16/Modbus对空数据长度为0的结果应该是0xFFFF。提示调试CRC问题的时候最有效的方法是把发送方和接收方的原始数据、计算的中间过程都打印出来逐字节对比。不要只看最终结果中间过程往往能暴露问题。6. 四类编码的联合应用与选型建议6.1 一个完整的通信帧里可能同时用到多种编码在实际的通信协议中这几种编码经常是配合使用的。以一个智能电表的通信帧为例电表读数用BCD码存储方便显示和抄读帧头用固定字节用于同步数据区可能用奇偶校验做字节级保护整帧用CRC-16做完整性校验这种分层保护的设计思路是每一层解决不同粒度的问题。BCD解决数据表示问题奇偶校验解决字节传输问题CRC解决整帧完整性问题。它们不是互相替代而是互相补充。6.2 选型决策表什么场景用什么编码场景推荐方案理由数码管显示BCD码译码简单无需除法短距离板级通信奇偶校验简单快速够用工业现场总线CRC-16抗干扰强检错率高网络通信CRC-32数据量大需要强检错高可靠存储海明码/ECC需要纠错能力金融计算BCD码避免浮点误差这个表不是绝对的实际选型还要考虑成本、功耗、开发周期等因素。比如有些低成本的MCU没有硬件CRC模块用软件算CRC会占用CPU时间这时候可能就用奇偶校验凑合了。6.3 硬件加速什么时候该用硬件CRC很多现代MCU都带硬件CRC模块比如STM32的CRC外设。硬件CRC的优势是速度快、不占CPU。但硬件CRC的灵活性差通常只支持固定的多项式。我的经验是如果通信速率高比如1Mbps以上或者数据量大比如每帧几百字节用硬件CRC。如果通信速率低、数据量小软件CRC完全够用而且更灵活。STM32的硬件CRC配置很简单hcrc.Instance CRC; hcrc.Init.DefaultPolynomialUse DEFAULT_POLYNOMIAL_ENABLE; hcrc.Init.DefaultInitValueUse DEFAULT_INIT_VALUE_ENABLE; hcrc.Init.CRCLength CRC_POLYLENGTH_32B; HAL_CRC_Init(hcrc);但要注意STM32的硬件CRC默认多项式是CRC-32和Modbus的CRC-16不一样。如果需要CRC-16要么用软件实现要么用支持可配置多项式的型号。7. 常见问题速查与调试技巧7.1 校验码计算常见错误速查表现象可能原因排查方法CRC校验一直失败字节序反了交换高低字节再试CRC校验偶尔失败初始值或异或值不对用在线工具对比海明码纠错后更错发生了双比特错误增加总校验位做SEC-DEDBCD码显示乱码收到非法BCD值检查每一位是否≤9奇偶校验漏检偶数个比特翻转换用CRC校验查表法结果不对表生成多项式错误用已知输入验证表7.2 调试CRC问题的三个实用技巧技巧一用已知向量验证。找一个标准的测试向量比如CRC-16/Modbus对字符串123456789的结果是0x4B37。如果你的代码算出来不是这个值说明参数有问题。技巧二分步打印中间结果。在循环里把每次异或和移位的结果打印出来和手工计算的结果对比。虽然麻烦但能精确定位到哪一步出错。技巧三用Python快速验证。写个几行的Python脚本算CRC和C代码的结果对比。Python的binascii库自带CRC-32但CRC-16需要自己实现。网上有很多现成的脚本可以抄。7.3 海明码的硬件实现思路如果要在FPGA或者ASIC里实现海明码思路是这样的编码器把数据位和校验位按位置排好用异或网络计算每个校验位的值。异或网络可以用LFSR线性反馈移位寄存器实现面积很小。译码器重新计算校验组把失败的位置编号相加得到错误位置然后用一个译码器把该位翻转。整个过程是组合逻辑延迟很低。对于SEC-DED还需要一个总的奇偶校验位。如果总校验失败但海明校验通过说明是双比特错误只能报错不能纠错。7.4 一个真实的Modbus CRC调试案例最后分享一个我实际踩过的坑。有一次调试Modbus RTU通信发送方用STM32接收方用PC上的串口助手。STM32发的帧串口助手显示CRC错误。但用示波器看波形数据完全正确。排查了半天发现是串口助手的CRC设置不对。它默认用的是CRC-16/CCITT而Modbus用的是CRC-16/Modbus。两个多项式不同算出来的CRC当然不一样。把串口助手的CRC类型改成Modbus问题解决。这个案例的教训是调试通信问题的时候先确认双方的协议参数完全一致。不光是CRC类型还包括波特率、数据位、停止位、校验位、字节序。这些参数任何一个不一致都会导致通信失败。我现在的习惯是在代码注释里把这些参数都写清楚换人维护的时候不容易搞错。BCD码、奇偶校验、海明码、CRC这四类编码本质上都是为了解决“数据在存储和传输过程中可能出错”这个问题。不同的编码方案是在检错能力、纠错能力、实现复杂度、额外开销之间做权衡。理解了这个权衡逻辑具体用哪种方案就是查表的事了。