
在工业通信和嵌入式开发里泡久了谁都躲不开“CRC”这三个字母。我最早认真研究CRC是早年调Modbus RTU协议时被逼出来的。明明上位机指令发出去了从站就是没反应换地址、换波特率、换线折腾了一下午。最后翻手册才发现报文末尾那两个字节是CRC校验而我那会儿连CRC是什么都不知道。后来自己拿笔算一帧报文算半小时才算对那种感觉估计很多老工程师都有过。这篇文章就把CRC校验这件事彻底讲透它为什么能发现数据错误多项式、模2除法、反射、初始值这些概念到底在说什么以及拿到实际项目里该怎么算。覆盖场景包括通用CRC-8/CRC-16/CRC-32、Modbus RTU报文、S7-200 SMART里的PLC通信以及LabVIEW上位机里的CRC16校验。我会尽量少讲抽象数学多讲“这个东西到底是什么、怎么用、坑在哪”并且给出可以直接抄的代码和思路。准备搞串口协议、写PLC程序、做上位机工具的人这篇应该能让你少走不少弯路。1. 为什么需要CRC数据不是永远可靠的数据在传输和存储过程中会受到干扰。串口线贴着变频器走网线穿过强电桥架U盘在写入时被拔掉这些情况在工业现场都太常见了。干扰会造成比特翻转你发出去的是0x01对方收到可能变成0x03。接收方最核心的问题之一就是我怎么知道这帧数据还是不是完整的最简单的方法是校验和Checksum把所有字节加起来取低8位或者取反作为校验值。这种方案成本极低但有一个致命弱点错误会互相抵消。比如原始数据是0x01和0x03校验和是0x04传输过程中其中一个字节从0x01变成0x02另一个从0x03变成0x02总和还是0x04。发送方校验通过错误被漏检了。这种“两个错误刚好抵消”的情况在随机噪声里并不少见。CRCCyclic Redundancy Check循环冗余校验解决的问题就是校验和太弱。它的思路是把整串数据当成一个很大的二进制数或者说一个多项式然后用一个约定的生成多项式去除得到的余数作为校验值附在数据后面。接收方收到数据后把“数据CRC”合在一起做同样的除法如果余数不为0说明数据被改动过。任何一个比特发生变化最终算出的余数和原来一致的概率极低设计得当的话多位错误也能抓出来。可能有人会把CRC和MD5、SHA这些哈希混为一谈。它们解决的问题不完全一样MD5/SHA属于密码学哈希目标是防止恶意篡改抗碰撞是硬指标CRC是通信和存储场景下的完整性校验计算极快、硬件实现简单但它的目标不是对抗恶意攻击只是检测意外的比特错误。这个边界一定要清楚后面我还会专门展开。1.1 CRC在哪些地方悄悄工作CRC不是只在教科书里存在的东西。以太网帧末尾那4个字节就是CRC-32Modbus RTU报文末尾两个字节是CRC-16PNG图片文件内部块校验用的是CRCZIP压缩包里也有CRC记录。这些细节大多被协议栈和软件封装了普通使用者根本感知不到。真正需要自己动手算CRC的场景通常集中在以下几种单片机项目里自己拼Modbus、自定义串口协议需要生成CRC字段并在接收端校验。PLC通信时库指令没有覆盖到自定义报文需要自己算校验。做上位机工具需要核对从站返回的报文是否正确。做文件处理工具需要计算或验证文件的CRC32值。我见过不少人在这些场景里直接用网上找的代码算出来和协议文档对不上也不知道哪里出了问题。根本原因往往不是代码本身而是对CRC参数不熟悉多项式一样但初始值、反射、输出异或不同结果就完全不同。这个后面会细讲。1.2 “校验”不是一回事在搜索热词里能看到“表单校验规则”“schema格式校验”“Android服务器证书校验”这些“校验”跟CRC完全是两码事。表单校验查的是数据格式是否符合约定schema校验查的是JSON/XML结构是否合法证书校验查的是通信对方身份是否可信。CRC只回答一个问题这份数据在到达时比特内容跟发送时是否一致。它不管数据“应该长什么样”也不管格式合法不合法更不管服务器是否可靠。理解这个边界非常重要否则你会拿着CRC去干它干不了的事。2. 从“补零除法”到“逐位异或”理解CRC的数学内核2.1 把数据看成一个大整数要理解CRC先建立一个概念任何一段二进制数据都可以看作一个很长的二进制数。比如字节0xB0就是二进制10110000它本质上是十进制176但在CRC的语境里我们更愿意把它看成一个多项式系数序列。好这里的“多项式”听起来吓人实际上就是个位串。比如10110000可以写成系数从高到低排列的多项式最高位是1代表x^7存在第二位0代表x^6不存在。生成多项式也一样CRC-8常用多项式0x07它代表的是x^8 x^2 x 1真正的除数位串是100000111共9位其中最高位的那个“1”是隐含的我们通常只在代码里记低8位0x07。为什么要用多项式而不是普通整数因为CRC使用的运算不是普通整数乘法除法而是一种叫“模2运算”的异或逻辑在这种运算体系里多项式的形式化表达会让很多性质变得清晰。不过对实际写代码的人来说记住“CRC就是把数据当一个位串跟一个约定的位串做除法取余”就够了。2.2 模2除法没有进位、没有借位的除法模2运算最大的特点是加法和减法都等于异或没有进位也没有借位。1101-10101。你可以把它想象成两个开关的“异或状态”而不是你小学学的竖式加减法。CRC的计算过程本质上就是模2除法。以CRC-8为例先在原始数据后面补8个0然后从头开始按照模2规则用多项式去“除”这个加长的位串一路异或下去最后剩下的余数就是CRC字节。如果你在纸上试一次会发现整个操作看起来就是“对着位串做异或每次把最高位消掉”跟手算普通除法很像只是没有借位。这个“补0的个数”就等于CRC的位数。CRC-8补8个0得到8位余数CRC-16补16个0得到16位余数CRC-32补32个0得到32位余数。所有CRC变体的核心都是这一个过程差别只在于初值怎么设、位序是否反转、最后要不要再异或一次。2.3 更贴近底层实现的视角移位寄存器手工做模2除法看起来很啰嗦芯片和软件里通常不是这么干的。更常见的思路是维护一个寄存器每次移入一个数据位检查移出的最高位是否为1如果是就跟生成多项式异或一次。这个过程等价于“边读数据边做除法”。你可以在脑子里过一遍这个画面一个8位寄存器初始为0一位一位地把数据塞进去每塞进来一位寄存器整体左移溢出的那一位如果是1就用多项式把寄存器“修正”一下。等全部数据都处理完寄存器里的值就是CRC。至于“补0在哪补”其实不用关心因为在循环里那8个0也会被当作数据继续送到寄存器里。3. 手算一遍再给出能直接用的代码3.1 完整手算单字节0xB0的CRC-8光讲概念太虚我们算一个具体的。选CRC-8多项式0x07即生成多项式x^8x^2x1初始值0x00不做反射输出不异或。现在计算一个字节0xB0二进制10110000的CRC。按照逐位算法先把CRC寄存器初始化为0把待处理字节0xB0与寄存器异或得到0xB0。循环8次每次看寄存器最高位bit7如果bit7为1就把寄存器左移一位再与多项式0x07异或如果bit7为0就只左移一位不需要异或。完整过程如下表所示步骤当前寄存器值bit7操作后值初始0xB0 (10110000)--第1次0xB01(101100001) XOR 0x07 0x67第2次0x6700x671 0xCE第3次0xCE1(0xCE1) XOR 0x07 0x9B第4次0x9B1(0x9B1) XOR 0x07 0x31第5次0x3100x311 0x62第6次0x6200x621 0xC4第7次0xC41(0xC41) XOR 0x07 0x8F第8次0x8F1(0x8F1) XOR 0x07 0x19最终结果是0x19。你可以找任意CRC在线计算工具验证选CRC-8/ROHC的同类参数多项式07初值00不出名反射不异或输入0xB0输出应该就是19。手算这一遍的意义在于让你明白CRC不是魔法就是“移位判断异或”这三个动作来回重复。3.2 最简逐位算法的Python和C实现把上面这个流程写成代码很简单。Python版本def crc8(data: bytes, poly: int 0x07, init: int 0x00) - int: crc init for byte in data: crc ^ byte for _ in range(8): if crc 0x80: crc ((crc 1) ^ poly) 0xFF else: crc (crc 1) 0xFF return crc print(hex(crc8(bytes([0xB0])))) # 输出 0x19C语言版本注意类型转换防止溢出uint8_t crc8(const uint8_t *data, uint16_t len) { uint8_t crc 0x00; for (uint16_t i 0; i len; i) { crc ^ data[i]; for (uint8_t j 0; j 8; j) { if (crc 0x80) { crc (uint8_t)((crc 1) ^ 0x07); } else { crc (uint8_t)(crc 1); } } } return crc; }这段代码处理多字节数据时按顺序对每个字节先异或进寄存器再做8次移位。对于帧长几十字节的RS-485通信这个速度完全够用单片机上跑每次调用大概几百个周期不会有压力。3.3 查表法把“算”变成“查表”逐位算法每处理一个字节要循环8次性能敏感或者数据量大的时候可以用查表法。查表法思路很直接既然CRC运算只跟当前寄存器低字节和下一个数据字节有关那就可以提前计算好“字节与当前寄存器异或后得到的结果状态”对应的CRC表一次查表处理一个字节把8次循环压缩成一次查表和三次异或。构造CRC-8表的核心代码uint8_t crc8_table[256]; void crc8_init_table(void) { for (int i 0; i 256; i) { uint8_t crc i; for (int j 0; j 8; j) { if (crc 0x80) { crc (uint8_t)((crc 1) ^ 0x07); } else { crc (uint8_t)(crc 1); } } crc8_table[i] crc; } } uint8_t crc8_fast(const uint8_t *data, uint16_t len) { uint8_t crc 0x00; for (uint16_t i 0; i len; i) { crc crc8_table[crc ^ data[i]]; } return crc; }对照一下逐位算法原来每个字节8次移位现在每个字节一次查表。CRC-16/CRC-32的查表表项是16位/32位寄存器状态对应的数组原理一模一样。实际项目中查表法在PC上位机上处理大文件时优势明显几十兆字节的数据很快就能算完。CRC32文件校验工具基本都用查表法。4. 实战Modbus CRC-16与PLC场景4.1 Modbus RTU的CRC-16到底怎么算Modbus协议是我见过的最典型的CRC应用场景。Modbus RTU的每个报文在“数据区”之后会附加两个CRC字节算法模型是CRC-16/MODBUS在很多工具库里也叫MODBUS标准。它的核心参数是参数值多项式poly0x8005初始值init0xFFFF输入反转refin是输出反转refout是输出异或xorout0x0000标准测试值check0x4B37“反射”这个词很多初学的人不理解。简单说反射就是按位反转原先是高位在左现在变成低位在左整个寄存器字节序反过来。由于Modbus用的是反射算法实际代码里异或的多项式不是0x8005而是0x8005按位反转后得到的0xA001而且每一步用的是右移而不是左移。常见的Modbus CRC计算代码是这样的uint16_t modbus_crc16(const 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 (crc 1) ^ 0xA001; } else { crc 1; } } } return crc; }以Modbus报文 01 03 00 00 00 01 为例用这个函数算出来是0x0A84。发送时先发低字节0x0A再发高字节0x84所以完整报文是 01 03 00 00 00 01 0A 84。这是我最常用来验证代码对错的例子记住它你排查问题时能少走很多弯路。这里有个高频坑Modbus报文里CRC的字节序是“低字节在前、高字节在后”。很多人从网上抄代码算出来结果是对的但发送字节序排反了从站照样不理你。我当年踩的就是这个坑。4.2 S7-200 SMART里自己算CRCS7-200 SMART自带的MODBUS库指令比如MBUS_MASTER、MBUS_SLAVE会自动处理CRC字段大多数用户不需要关心。但如果自己做自定义协议或者用自由口通信就必须自己生成CRC。PLC里的循环移位效率不如单片机那么直观但在S7-200 SMART上做Modbus长度的报文计算用循环子程序就够了不需要查表。一般来说我会在PLC程序里划分一个专门的子程序接口设计成输入参数数据缓冲区起始地址VB编号、数据长度输出参数CRC16结果两个字节低字节、高字节具体梯形图逻辑不展开写所有网络只说实现细节把CRC初始化为十六进制FFFF逐字节处理每个字节内循环8次判断寄存器最低位决定是否与十六进制A001异或然后右移一位。这一步操作在S7-200 SMART里用STL写会简洁很多比如// 伪代码示意CRC循环核心 LD 数据 Byte 异或 CRC 低字节 FOR 每字节内的8位循环 LD CRC 最低位 AENO 右移 CRC 1位 // 如果移出的位是1则 CRC 异或 A001 LD crc 最低位 XOR 16#A001需要提醒的是PLC里做循环程序时索引地址和临时变量很容易被重复使用导致计算结果时对时错。我建议把CRC子程序里的临时变量全部用局部变量表L区不要用全局V区。V区一旦被其他逻辑占用你排查起来会非常痛苦。4.3 LabVIEW中的CRC16校验实现LabVIEW是很多做上位机测试和产线工具的人熟悉的平台。在LabVIEW里实现CRC16校验我推荐用“公式节点”结合C语言片段比用函数面板一层层搭建循环框图要清晰也更容易跟其他开发者核对逻辑。在LabVIEW的公式节点里写uint16_t crc 0xFFFF; for (int i 0; i len; i) { crc ^ data[i]; for (int j 0; j 8; j) { if (crc 0x0001) { crc (crc 1) ^ 0xA001; } else { crc 1; } } } crc_out crc;公式节点的输入是字节数组data和长度len输出是crc_outU16类型。运行一遍输入Modbus标准帧01 03 00 00 00 01crc_out应该是0x0A84。如果你的公式节点语法版本不支持直接声明uint16_t也可以用U16类型声明LabVIEW的公式节点支持C语法子集声明时写成crc 0xFFFF即可。在实际项目里我一般会在上位机的接收线程里对每一帧完整报文做一次校验先把“数据CRC”拼接完整或者分别传入校验结果返回到界面。如果校验失败界面上的帧计数器立即加1同时把原始字节记录到日志文件这对分析通信质量特别有用。5. 参数决定一切CRC变体到底怎么选CRC不是只有一个样子的。同样是CRC-16Modbus、XMODEM、CCITT-FALSE、IBM等变体参数完全不同。选型时你只需要关注五个参数多项式、初始值、输入反射、输出反射、输出异或。这五个参数定了CRC算法就定了。几个常见变体的参数对照如下名称多项式初始值输入反射输出反射输出异或标准测试值CRC-8/ROHC0x070xFF是是0x000xD0CRC-16/MODBUS0x80050xFFFF是是0x00000x4B37CRC-16/XMODEM0x10210x0000否否0x00000x31C3CRC-32/以太网0x04C11DB70xFFFFFFFF是是0xFFFFFFFF0xCBF43926怎么选我的建议是工业总线协议看协议指定的是哪种比如Modbus就用CRC-16/MODBUS。数据量不大检错要求中等用CRC-8占用的CPU和资源最少。数据帧长度几十字节到几百字节用CRC-16绝大多数场景够用。以太网、文件完整性验证用CRC-32碰撞概率已经很低。关于漏检概率说个直观数字CRC-32在随机位错误场景下漏检率接近2^-32也就是大约四十亿分之一。CRC-16大约是六万分之一。这里说的都是设计良好的参数组合如果你的参数选错检错能力会大打折扣甚至形同虚设。CRC的检错能力和生成多项式有强关系。好的多项式在1到2个位错误、突发错误、奇数个位错误等场景下都有良好的覆盖率。所以尽量不要自己发明多项式直接从标准库里挑这是无数人趟过坑后得出的结论。6. 常见问题与排查技巧实录6.1 为什么在线工具算出来的结果跟代码不一样大概率不是工具的问题是参数不一致。同一份数据用CRC-16/MODBUS算和用CRC-16/XMODEM算结果完全不同。排查时先明确五个参数再用“标准测试值”验证。把字符串“123456789”的ASCII码依次送入算法如果算出的结果等于上表里的check值说明算法实现正确。6.2 初始值忘了处理最容易踩的坑之一。CRC-16/MODBUS初始值是0xFFFF不是0x0000。如果初值设成0x0000数据开头有连续0x00字节时CRC会一直是0看起来“好像没问题”实际全错。这也是为什么在数据前面加几个0x00就能检验初值处理的原因。6.3 反射到底什么时候用反射参数由协议文档决定。Modbus明确是LSB first也就是低比特在前所以要反射。很多移植过来的代码没注意这个把左移改成右移、多项式从0x8005改成0xA001就算掌握了反射的精髓。判断是否需要用反射好的办法是参考你正在移植的crc model名称别只看多项式一样就套用。6.4 CRC字节序搞反前面提到的Modbus报文01 03 00 00 00 01计算结果是0x0A84发送顺序是0A 84。如果你发的顺序是84 0A从站会直接报错。注意这是协议层面的顺序要求跟算法反射没有必然关系。在接收端做校验时同样要把收到的两个字节按低字节在前拼接后再整体校验。6.5 别用CRC做防篡改CRC是完整性校验不是安全校验。它能检测到随机的传输错误但防不了有人故意篡改并重新计算CRC。任何认真设计的数据安全体系都要用HMAC或数字签名而不是CRC。如果你听到“用CRC校验文件防止恶意修改”这种说法要意识到这是认知误区。6.6 现场排查顺序如果你确认算法没问题但通信还是失败我的排查顺序是用标准帧验证代码算法是否正确先排除代码层问题。用逻辑分析仪或示波器抓串口波形确认实际发送字节序。检查RS-485收发器方向切换时间很多时序问题出在硬件切换。检查从站是否同样是CRC校验模式有些设备支持配置CRC高低字节顺序。这套顺序我用了很多年每次都能精准定位问题。我个人在实际项目里的习惯是把所有跟CRC相关的代码封装成独立模块参数表写进注释还留一个自检函数跑一遍标准测试向量。这样项目交接的时候后面的人不会因为参数不透明而重新踩坑。CRC这玩意儿掌握原理之后你会发现它特别可靠但前提是五个参数都核对清楚。希望这篇文章能让你少花点时间在这个“简单但爱坑人”的环节上。