在工业通信和嵌入式开发里泡久了,谁都躲不开“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。发送方校验通过,错误被漏检了。这种“两个错误刚好抵消”的情况在随机噪声里并不少见。
CRC(Cyclic Redundancy Check,循环冗余校验)解决的问题就是校验和太弱。它的思路是把整串数据当成一个很大的二进制数,或者说一个多项式,然后用一个约定的生成多项式去除,得到的余数作为校验值附在数据后面。接收方收到数据后,把“数据+CRC”合在一起做同样的除法;如果余数不为0,说明数据被改动过。任何一个比特发生变化,最终算出的余数和原来一致的概率极低,设计得当的话,多位错误也能抓出来。
可能有人会把CRC和MD5、SHA这些哈希混为一谈。它们解决的问题不完全一样:MD5/SHA属于密码学哈希,目标是防止恶意篡改,抗碰撞是硬指标;CRC是通信和存储场景下的完整性校验,计算极快、硬件实现简单,但它的目标不是对抗恶意攻击,只是检测意外的比特错误。这个边界一定要清楚,后面我还会专门展开。
1.1 CRC在哪些地方悄悄工作
CRC不是只在教科书里存在的东西。以太网帧末尾那4个字节就是CRC-32;Modbus RTU报文末尾两个字节是CRC-16;PNG图片文件内部块校验用的是CRC;ZIP压缩包里也有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运算最大的特点是:加法和减法都等于异或,没有进位也没有借位。1+1=0,1-1=0,1+0=1。你可以把它想象成两个开关的“异或状态”,而不是你小学学的竖式加减法。
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^8+x^2+x+1),初始值0x00,不做反射,输出不异或。现在计算一个字节0xB0(二进制10110000)的CRC。
按照逐位算法:
- 先把CRC寄存器初始化为0,把待处理字节0xB0与寄存器异或,得到0xB0。
- 循环8次,每次看寄存器最高位bit7:
- 如果bit7为1,就把寄存器左移一位,再与多项式0x07异或;
- 如果bit7为0,就只左移一位,不需要异或。
完整过程如下表所示:
| 步骤 | 当前寄存器值 | bit7 | 操作后值 |
|---|---|---|---|
| 初始 | 0xB0 (10110000) | - | - |
| 第1次 | 0xB0 | 1 | (10110000<<1) XOR 0x07 = 0x67 |
| 第2次 | 0x67 | 0 | 0x67<<1 = 0xCE |
| 第3次 | 0xCE | 1 | (0xCE<<1) XOR 0x07 = 0x9B |
| 第4次 | 0x9B | 1 | (0x9B<<1) XOR 0x07 = 0x31 |
| 第5次 | 0x31 | 0 | 0x31<<1 = 0x62 |
| 第6次 | 0x62 | 0 | 0x62<<1 = 0xC4 |
| 第7次 | 0xC4 | 1 | (0xC4<<1) XOR 0x07 = 0x8F |
| 第8次 | 0x8F | 1 | (0x8F<<1) 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标准。它的核心参数是:
| 参数 | 值 |
|---|---|
| 多项式(poly) | 0x8005 |
| 初始值(init) | 0xFFFF |
| 输入反转(refin) | 是 |
| 输出反转(refout) | 是 |
| 输出异或(xorout) | 0x0000 |
| 标准测试值(check) | 0x4B37 |
“反射”这个词很多初学的人不理解。简单说,反射就是按位反转:原先是高位在左,现在变成低位在左,整个寄存器字节序反过来。由于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里自己算CRC
S7-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_out(U16类型)。运行一遍,输入Modbus标准帧01 03 00 00 00 01,crc_out应该是0x0A84。如果你的公式节点语法版本不支持直接声明uint16_t,也可以用U16类型声明,LabVIEW的公式节点支持C语法子集,声明时写成crc = 0xFFFF即可。
在实际项目里,我一般会在上位机的接收线程里对每一帧完整报文做一次校验:先把“数据+CRC”拼接完整,或者分别传入,校验结果返回到界面。如果校验失败,界面上的帧计数器立即加1,同时把原始字节记录到日志文件,这对分析通信质量特别有用。
5. 参数决定一切:CRC变体到底怎么选
CRC不是只有一个样子的。同样是CRC-16,Modbus、XMODEM、CCITT-FALSE、IBM等变体参数完全不同。选型时你只需要关注五个参数:多项式、初始值、输入反射、输出反射、输出异或。这五个参数定了,CRC算法就定了。
几个常见变体的参数对照如下:
| 名称 | 多项式 | 初始值 | 输入反射 | 输出反射 | 输出异或 | 标准测试值 |
|---|---|---|---|---|---|---|
| CRC-8/ROHC | 0x07 | 0xFF | 是 | 是 | 0x00 | 0xD0 |
| CRC-16/MODBUS | 0x8005 | 0xFFFF | 是 | 是 | 0x0000 | 0x4B37 |
| CRC-16/XMODEM | 0x1021 | 0x0000 | 否 | 否 | 0x0000 | 0x31C3 |
| CRC-32/以太网 | 0x04C11DB7 | 0xFFFFFFFF | 是 | 是 | 0xFFFFFFFF | 0xCBF43926 |
怎么选?我的建议是:
- 工业总线协议,看协议指定的是哪种,比如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这玩意儿,掌握原理之后你会发现它特别可靠,但前提是五个参数都核对清楚。希望这篇文章能让你少花点时间在这个“简单但爱坑人”的环节上。