ARTICLE DETAIL

资讯详情

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

循环冗余校验(CRC)完全指南:原理、参数与工程实战

循环冗余校验(CRC)完全指南:原理、参数与工程实战 你有没有遇到过这种情况从网上下载一个大型压缩包解压到一半突然蹦出“CRC校验错误”调试Modbus RTU从站时报文怎么发主机都不认或者自己写了个校验函数算出来的结果跟协议文档里的不对。其实这些都是同一个东西在背后起作用——CRC全称循环冗余校验Cyclic Redundancy Check。我最早被CRC折磨是在做串口通信协议时那时候文档里写着一个公式和一张256项的查找表没人告诉我为什么要这样算、初始值为什么要写成0xFFFF更没人告诉我不同协议里的CRC算出来根本不能通用。这篇文章我准备把CRC彻底讲透它解决什么问题、数学上的核心思路是什么、工程中常见的CRC-8/CRC-16/CRC-32参数怎么区分、怎么手写一个可靠的CRC计算程序以及你在Modbus、S7-200 SMART、文件校验、压缩包解压这些场景里踩过的坑该怎么排查。适合正在做嵌入式、上位机开发、网络协议调试或者只是下载文件时想搞明白“校验”到底校验了什么的读者。我不打算堆公式给你看我会把每一步都拆开用能直接抄作业的方式讲清楚。1. 先搞清楚CRC到底在解决什么问题1.1 数据从A到B为什么非要加校验码任何数据在传输或者存储过程中都可能被干扰。比如一根屏蔽做得不好的RS485线旁边过一辆叉车电平就可能抖动两下比如U盘在拔出瞬间写入数据文件系统里的位就可能翻转比如WiFi环境里路由器发送的帧在电磁噪声干扰下可能有一两个bit变错。如果接收方不做任何检查轻则解压出的图片花掉重则PLC执行了错误的控制指令代价相当高。所以通信协议设计者从很早开始就给数据附加“校验信息”。最简单的思路是校验和Checksum把所有字节加起来取一个字节或几个字节作为校验值。比如按字节求和取低8位实现起来非常容易一二十行代码就搞定。但它的缺陷也很明显字节顺序换一下或者一个字节增加1而另一个字节减少1和值不变接收方照样认为数据是好的。CRC跟校验和最本质的区别在于它不把数据当成普通整数去求和而是把整段数据看成一个二进制多项式用固定的“生成多项式”去做循环移位和异或这样产生的校验码对数据每一位的变化都非常敏感哪怕只有一位翻转算出来的CRC都会有很大概率不同。一个更直白的类比校验和像把一堆发票金额加起来记个总数如果一张多了100块另一张少了100块总额对不上也发现不了CRC则像给每张发票编号后再按某种规则把所有编号交织在一起生成一个签名任何一个编号变了签名就会跟着变。1.2 CRC能干什么、不能干什么CRC是检错机制不是纠错机制。它只能告诉接收方“数据可能坏了”而不能像RS纠删码或ECC那样告诉你具体是哪几个bit坏了、怎么修。在以太网、串口通信、压缩包校验这些场景里我们的目标通常都是“坏了就重传、就重新下载”而不是现场修复所以CRC已经足够用。但CRC也绝对不是什么“加密校验”。它不是哈希函数不能用来验证文件内容是不是某人恶意篡改过的完整版本CRC的计算函数是公开的、确定性的也没有设计抗碰撞性。如果有人故意构造另一个文件让它的CRC32和原文件一样理论上是做得到的。所以你在做文件完整性验证时如果需要对抗恶意修改应该选SHA-256或MD5这种摘要算法而不是CRC32。这个边界很多人混淆后面第五章我会再展开。1.3 一套专业的CRC术语多项式、初值、异或输出、字节序我见过太多人抄了一段CRC代码换了场景就不好使根本原因是没搞懂CRC这堆“参数”。一个完整的CRC算法必须明确以下内容生成多项式Poly决定校验码生成规则的核心常数比如CRC-16/Modbus用0x8005以太网CRC32用0x04C11DB7。初始值Init计算开始时寄存器里的初值常见有0x0000、0xFFFF。输入/输出反转RefIn/RefOut数据字节是否需要按位反转处理最终结果是否需要按位反转。结果异或值XorOut算完以后对最终结果再异或一个常数。这些参数哪怕有一个不同同一个输入数据算出来的CRC就完全不同所以在工程里你会看到CRC-8、CRC-16/CCITT、CRC-16/Modbus、CRC-32这些名字。它们不是简单的位数不同而是一整套参数组合不同。理解这一点比背十段代码都有用。2. CRC的核心原理用除法思路做校验2.1 模2除法一种只有异或和移位的“除法”CRC的数学本质是把二进制数据当成多项式然后用生成多项式去做“模2除法”最后把余数作为校验码。这里的“模2”指的就是二进制运算里不进位、不借位加法和减法都退化成异或乘法没意义除法则只判断最高位能不能被除尽。举个例子生成多项式0x07二进制看就是0000 0111对应x²x1是CRC-8里最常用的生成多项式之一。如果用0x07作为poly去计算一个字节0xA5看起来就是数据0xA5二进制为10100101把它左移8位后面补8个0用10100101 00000000这个整体按模2规则除以00000111每一步看当前剩余数字的最高位是不是1是1就异或除数然后左移一位继续最后得到的8位余数就是CRC-8的校验码。你可能好奇为什么非要用除法因为除法的数学性质决定了最终余数对输入里每一位变化的敏感性几乎是均匀分布的连续多位出错也容易暴露这正是检错需要的特征。而校验和那种简单加法错位抵消的概率太高就不够看。在实际代码中你不会真的去实现“除法”而是把除法过程等价转换成逐字节异或加移位这就是为什么你看到的CRC代码里全是“if (crc 0x80) crc (crc 1) ^ poly; else crc 1;”这种写法。2.2 手把手算一次CRC-8一个字节的完整推演从原理跳到代码中间最容易断档的就是手动计算。我拿最经典的CRC-8参数来演示多项式0x07初始值0x00输入不反转输出不异或。现在计算一个字节0xA5步骤如下初始值crc 0x00。 第一步crc crc XOR 0xA5 0xA5二进制10100101。 之后开始8轮移位判断最高位是1左移得01001010异或0x07得01001101即0x4D。最高位是0左移得10011010即0x9A。最高位是1左移得00110100异或0x07得00110011即0x33。最高位是0左移得01100110即0x66。最高位是0左移得11001100即0xCC。最高位是1左移得10011000异或0x07得10011111即0x9F。最高位是1左移得00111110异或0x07得00111001即0x39。最高位是0左移得01110010即0x72。最后crc 0x72。也就是说对0xA5这个字节在这个CRC-8参数下校验码是0x72。这8步就是整个位运算算法的全部后面的所谓“按字节查表”只是把这8步用一张表替代掉。手动算一遍你再去读任何CRC代码都不会觉得它是什么黑魔法只是把判断最高位、移位、异或多项式这几个动作循环若干次而已。2.3 为什么异或和移位能检测错误浅谈多项式选择的意义一个CRC算法能不能可靠检错很大程度上取决于生成多项式的阶数和选择。阶数决定CRC结果的位数比如8位多项式的最高次幂是8对应的校验码就是8位同理16位多项式对应16位校验码。位数越多两个不同数据算出相同CRC的概率越低粗略理解就是校验空间里能容纳的“签名”更多。为什么CRC对突发错误特别敏感因为移位的过程相当于把每一位的影响“扩散”到后续位连续多位翻转的突发错误很难被完全抵消。而多项式如果选得好比如标准中常见的0x1021、0xA001、0x04C11DB7能保证在很长一段连续错误以内几乎百分之百被检测出来。我建议你在设计自定义协议时不要去发明一个多项式直接采用业界验证过的标准参数。用现成标准的好处除了可靠性还有生态你可以用在线计算器、现成库、其他工程师的代码直接交叉验证调试效率会高非常多。3. 工程里的CRC参数同样是CRC-16结果可能完全不同3.1 常见变体对照CRC-8、CRC-16、CRC-32一表看懂工程里常用的CRC变体就那几个我整理成一张表供你直接参考平时写代码前先对着这张表确认参数。算法名称多项式Poly初始值Init输入反转RefIn输出反转RefOut结果异或XorOut校验“123456789”CRC-80x070x00falsefalse0x000xF4CRC-8/MAXIM0x310x00truetrue0x000xA1CRC-16/CCITT-FALSE0x10210xFFFFfalsefalse0x00000x29B1CRC-16/MODBUS0x80050xFFFFtruetrue0x00000x4B37CRC-320x04C11DB70xFFFFFFFFtruetrue0xFFFFFFFF0xCBF43926注意表格最右边那列就是用ASCII字符串“123456789”这9个字节算出来的标准校验值业内叫“Check Value”用来验证你的代码实现是否正确。我每次写一个新平台的CRC代码第一件事就是拿这个标准向量跑一遍对得上再往下做协议对接。你可以看到CRC-16/CCITT-FALSE和CRC-16/MODBUS的位数和多多项式相关常数看起来只差在“是否反转”上但算出来的结果完全不同。这就是为什么很多人在网上随便找到一段CRC16代码后在Modbus协议里怎么用都不对的原因——很可能是拿成了CCITT的算法根本就不是Modbus参数。3.2 初始值与字节序最容易踩的两个坑先说初始值。为什么很多CRC算法初始值要设成0xFFFF而不是0x0000因为0xFFFF的初始状态相当于在真正数据前面预补了16个1这样可以防止数据前面出现连续多个0时CRC结果不变。数据如果开头有一串0而初值是0x0000前几个0字节计算时只是在空转不会改变CRC值导致“原数据”和“前面加了一大串0的数据”算出来的CRC一样。这是校验算法的边界漏洞工程上靠约定初值来消掉。再说字节序。CRC计算结果是16位或32位数但通信线路上字节有先后顺序。比如Modbus RTU协议约定CRC16低字节在前、高字节在后也就是结果0xD8F1发送时先发0xD8再发0xF1。而某些XMODEM等协议则约定高字节在前。如果你把发送顺序搞反接收端校验怎么都过不了而且这个问题光看算法代码看不出来必须在协议文档里确认。我在实际项目里为了省事会在协议结构体边上直接写注释CRC16, little-endian, low byte first。这个习惯帮我避免过好几次返工。3.3 查表法用空间换时间的经典优化按位计算有一个肉眼可见的问题每处理一个字节要循环8次如果数据有几百KB性能就不太好看。查表法把常用的“处理一个字节”的8轮结果提前计算成一张256项的表格运行时一次查表就完成了原本8次循环的全部工作。为什么是256项因为一个字节有8位总共256种取值。计算时把当前CRC的低8位或根据反转规则处理后的对应部分和新的数据字节异或得到一个0到255的索引用这个索引去查表取出处理后应该变成的16位结果再跟CRC的高位部分拼起来这就是一次查表计算一个字节的完整逻辑。这两者的关系我用两句话概括查表法是位运算的预处理版本位运算是查表法的生成原理。你理解了按位版本查表法就能看懂你理解了查表法就明白为什么很多成熟库里会放一张256项的常量表。工程上如果内存紧张就再考虑按位计算否则查表法几乎总是更优的选择。4. 实战手写一个CRC-16/MODBUS计算程序4.1 按位计算的原始版本代码少但思路清晰我以工业通信里最常见的Modbus RTU为例给你一份可以直接用的Python代码。它的参数是Poly0x8005Init0xFFFFRefIntrueRefOuttrueXorOut0x0000。因为RefIn和RefOut都是true算法实现通常不会直接往左移再异或0x8005而是往右移再异或0xA001。没错0xA001就是对0x8005按位反转后的实际运算常数这是Modbus实现里最反直觉的一个点。def crc16_modbus_bitwise(data: bytes) - int: crc 0xFFFF for byte in data: crc ^ byte for _ in range(8): if crc 0x0001: crc (crc 1) ^ 0xA001 else: crc 1 crc 0xFFFF return crc # 演示Modbus RTU读取保持寄存器的经典请求报文 frame_head bytes([0x01, 0x03, 0x00, 0x00, 0x00, 0x02]) crc_value crc16_modbus_bitwise(frame_head) print(hex(crc_value)) # 结果 0xd8f1 # 按Modbus协议发送时CRC低字节在前 tx_frame frame_head bytes([crc_value 0xFF, crc_value 8]) print(tx_frame.hex()) # 010300000002d8f1为什么我在位运算判断里看的是最低位而不是最高位因为RefIntrue要求数据字节按位反转往右移等效于先反转再按最高位处理。这也是为什么Modbus实现里大家都喜欢直接写0xA001右移算法的原因。如果你非要用左移版本、直接用0x8005那你要么先对输入字节反转要么对结果再反转代码易错得多。我建议你写通信程序时以这份位运算代码为基准理解然后换成下一节的查表版本用于真实项目。4.2 查表版本一张表替代8次循环查表版本的好处是每个字节只需一次查表和少数几次异或运算代码跑起来明显更快。下面是完整的Python实现包括表生成逻辑和计算函数。def generate_modbus_table() - list: table [] for i in range(256): crc i for _ in range(8): if crc 0x0001: crc (crc 1) ^ 0xA001 else: crc 1 table.append(crc 0xFFFF) return table MODBUS_TABLE generate_modbus_table() def crc16_modbus_table(data: bytes, crc: int 0xFFFF) - int: for byte in data: crc (crc 8) ^ MODBUS_TABLE[(crc ^ byte) 0xFF] return crc # 验证 print(hex(crc16_modbus_table(bytes([0x01, 0x03, 0x00, 0x00, 0x00, 0x02]))))这版代码最关键的一行是(crc ^ byte) 0xFF。因为crc是16位取它的低8位和当前数据字节异或得到查表索引然后高8位直接右移下来再异或查表结果。整个过程不需要逐位判断运行速度比位运算版本快很多。内存占用也很小就是256个16位整数总共512字节MCU上完全放得下。如果你做的是STM32或者PLC里嵌入的C语言版本这同样适用把数组生成好放在常量区就行。4.3 和现成工具互相验证一个实用核对流程写好了代码怎么确认它不是“自嗨”我给你一套我自己一直在用的验证流程第一步算标准向量“123456789”。CRC-16/MODBUS的公开Check Value是0x4B37如果你用表代码跑出来是0x4B37说明核心算法基本正确。第二步拿协议文档里的真实报文核对。比如Modbus RTU报文01 03 00 00 00 02后面应该跟D8 F1如果你的程序算出来是这个说明字节序也对了。第三步用在线CRC计算工具或者上位机调试助手的加校验功能再交叉验证一遍。我遇到过一种很隐蔽的情况代码功能没问题但有的现成工具算出来跟你不一样原因往往在于工具把输入当成ASCII字符串而不是十六进制字节。你输入的“0103”到底是两个字节0x01、0x03还是四个字节0x30、0x31、0x30、0x33这一步错了后面全错。所以交叉验证时一定要先确认输入格式是按hex字节还是按ASCII字符处理。5. 你在哪些地方天天用CRC5.1 工业通信场景Modbus RTU和S7-200 SMART里的CRC工业通信里CRC几乎是标配。Modbus RTU的报文结构就是“地址字节功能码数据区CRC16”接收方收到整帧后计算除CRC以外的所有字节得到的结果应该与报文末尾的CRC逐字节相等。很多PLC工程师写S7-200 SMART的自定义通信程序时都会在程序块里放一段CRC计算子程序因为SMART系列本身不带现成的CRC指令必须自己算。这时候最常见的错误就是参数不对或者高字节低字节顺序不对导致从站和主站之间永远校验失败。做这类对接我建议你先把发端的CRC码打印出来对照文档里的示例报文确认无误后再去排查接收端。这样可以先把问题二分掉不会两边同时猜。5.2 文件与压缩包ZArchiver提示CRC错误是怎么回事ZIP压缩包内部每个文件都存了一个CRC32值用来在解压时判断文件内容是否完好。你用手机上的ZArchiver解压一个网络上下载的压缩包中途蹦出“CRC错误”其实就是在解压某个文件时解压后的数据和压缩包头部记录的CRC32对不上说明这个文件在下载、传输或存储过程中损坏了。这种时候不要反复尝试解压同一个包因为多半是源文件已经坏了。更常见的坑是下载工具显示100%完成但文件其实在下载过程中有部分数据写错。所以重要文件下载完之后建议顺手用MD5或SHA-256校验工具和发布方提供的校验值比对这也是为什么很多开源软件官网会同时给SHA256值。MD5、SHA-256和CRC32都叫“校验”但定位完全不同。CRC32检测随机损坏很快但对抗故意篡改不行MD5现在也被认为不够安全建议用SHA-256做安全校验。文件比较、压缩包完整性、下载验证这些场景直接用系统自带工具或知名校验软件即可。5.3 网络协议与表单校验的边界别把CRC当规则校验以太网帧尾部有一个FCS字段它就是CRC32由网卡硬件计算并添加接收方网卡如果算出来不匹配就直接丢帧不会被上层协议栈看到。这也是为什么你用Wireshark抓包时通常看不到这个字段——硬件已经把它剥离或验证完了。而在一些扮戏模式的串口协议里CRC会作为明确字节出现你可以在抓取的报文中直接看到末尾的校验字节这也是“报文观察CRC校验”的实际含义。这里我想特别澄清一个容易混的概念搜索热词里经常出现“表单校验规则”“schema校验”“rules校验”这些和CRC没关系。表单校验、JSON Schema校验是业务层面的“格式对不对、字段是否必填、类型是否匹配”的规则检查CRC是物理层面的“数据有没有在传输或存储中变坏”的完整性检查。两者层次完全不同不能互相替代。你不能拿CRC去判断用户输入的邮箱地址格式也不能拿表单校验去保证文件内容没有比特翻转。6. 常见问题与排查技巧实录6.1 “我算的CRC和别人不一样”排查清单这句话差不多是我在技术交流里被问过最多的问题每次排查顺序基本固定。先列个清单确认算法参数Poly、Init、RefIn、RefOut、XorOut全部对齐不能只对位数。确认输入范围CRC到底算哪些字节比如Modbus就是不含CRC本身的所有字节有些协议会把地址字节排除有些协议又把两个CRC字节包含进去再算规则千奇百怪。确认字节序结果是否要低字节在前发送顺序是否按协议要求。确认输入格式十六进制字节还是ASCII文本这是工具对比时的常见坑。确认前导字节和尾随字节有些协议在CRC前会加转义字符或长度字段会导致你算的输入范围和协议文档不一致。我见过最离谱的一次是有人把CRC16结果存成了int类型负数导致后面拼接字节时高位全变0xFFFFFF。所以C语言里建议用uint16_t、uint32_t这样的无符号类型或者赋值后立刻与0xFFFF、0xFFFFFFFF做位与运算。6.2 通过标准向量快速判断算法类型如果你拿到一段现成代码不知道它到底属于哪种CRC变体最快的方法是用“123456789”去跑一遍然后跟公开的Check Value对照。这张表在很多资料里都有我这里只列出你大概率会遇到的几个核心值CRC-8是0xF4CRC-16/CCITT-FALSE是0x29B1CRC-16/MODBUS是0x4B37CRC-32是0xCBF43926。这种方法我在逆向协议时用过很多次。别人给你一个DLL里的CRC函数你不知道实现细节输入“123456789”看输出值马上就定位到它是标准CRC-16/MODBUS还是CRC-16/CCITT剩下只需要确认字节序就行。比自己猜参数库节省大量时间。6.3 调试和抗风险心得我踩过的几个坑做CRC校验相关的项目这几年我有几条比较深的体会分享给你参考。第一CRC代码一定要带注释写明参数。哪怕是临时用的调试脚本也写上“Poly0x8005, Init0xFFFF, RefIn/Outtrue, MODBUS”这样三个月后你自己回来看还能直接判断这段代码能不能复用到新项目。第二尽量用成熟库而不是每次手写。Python里有crcmod、C里有Boost.CRC嵌入式里也有现成实现保证没必要每做一个新平台就重新发明一次。手写只适合学习原理不适合交付产品。第三重要数据可以做“双保险”。如果通信链路可靠性要求很高协议设计里可以同时用CRC和序列号或长度计数防止帧重复、丢帧这类CRC本身管不了的问题。CRC只保证“内容有没有坏”不保证“消息有没有丢掉或者重复”这需要靠协议层别的机制去处理。第四一点迷信心态也要有不要以为CRC都能帮你兜住。数据传输可靠性是一个系统工程屏蔽、接地、重传机制、超时处理每一步都可能出问题。CRC只是最后一道不太可能漏检的防线别把宝全押在它身上。
返回列表