扒拉一下CRC原理与实现
用了7年CRC了,一直没关注过其原理,最近突然想起了这技术债,就顺便研究了下。
网上好多教程都说是用POLY除需要校验的数据,得到的余数就是CRC校验的结果,但是这个除要是无进位除,初看这概念有点懵,无进位加减乘其实都好理解,直接把加减符号当成异或就好了,但是这个无进位除法着实有点反直觉,毕竟1000可以无进位除以1111,但是0111 却不能无进位除以1111,前者说是不用在意大小,后者却说最高位的1要对齐。。。反正在我看来so weird
硬啃后往底层再深入一下,直接理解其目的去看这件事,一切就变得make sense了:CRCx就是在校验数据后补x个bit0,然后通过一个x+1位的多项式POLY,从最高位开始遍历,用异或的方式把需要校验的数据消到有效位数最少(小于等于x位)。
先提几个异或的特征(不知道是不是定理,反正最差都能枚举证明)
1.a ^ b = b ^ a
2.a ^ b ^ c = a ^ (b ^ c)
3.a ^ a = 0
4.0 ^ a = a
5.(~0) ^ a = ~a
消除过程本质就是利用第3点,拿个1xxx...xxx的多项式,对齐校验数据最高有效位的1,一做异或,同为1的最高位就被干掉了。
然后一般CRCx模型都有个初始值,这个东西就是先把数据高位对齐去异或这个Init,然后再开始做消除操作。
下面看具体例子:

以CRC16-CCITT FALSE为例,计算0xAA55的校验值,0xAA55会先和CRC16-CCITT FALSE里规定好的Init值0xFFFF做一个异或,得到0x55AA;然后再进行上述的消位操作,绿橙为底色的就是多项式,蓝黄为底色的就是实时的数据。可以看到,随着多项式往低位移动的过程中,数据的有效位数逐渐变少,最终变为16位的0xE5EA,对比网页结果,结果一致,bingo。

原理好像懂了,看下代码,手头有一个CRC16-MODBUS的CRC代码,这是一个多项式为0x8005,初始值为0xFFFF的16位CRC模型。
1 uint16_t CRC_GetCRC16_MODBUS(const uint8_t* data, uint32_t len) 2 { 3 uint16_t CRCFull = 0xFFFF; 4 int8_t CRCLSB; 5 uint32_t i, j; 6 7 for (i = 0; i < len; i++) 8 { 9 CRCFull = (uint16_t)(CRCFull ^ data[i]); 10 for (j = 0; j < 8; j++) 11 { 12 CRCLSB = (int8_t)(CRCFull & 0x0001); 13 CRCFull = (uint16_t)((CRCFull >> 1) & 0x7FFF); 14 15 if (CRCLSB == 1) 16 { 17 CRCFull = (uint16_t)(CRCFull ^ 0xA001); 18 } 19 } 20 } 21 22 return CRCFull; 23 }
WTF???短短23行就有3个看不懂的地方啊
1.数据为啥不是像 CRCFull = ((CRCFull << 1 ) | ((nextData >> n) & 0x1)); 这样一个bit一个bit补进CRC计算buffer里,而是按byte为单位的接入,还要是通过异或的形式?
2.为啥是右移?不是左移
3.多项式应该是0x8005才对,这个0xA001又是什么鬼?


用这个函数去算下0xAA55的校验值和网页得到的做对比,他又是对的。那只能说又触及了我知识盲区,又要探索了。
先看第一个问题,这种问题一看就是纯数学问题,推一下应该就好,没思路时候,直接枚举,按理解的来,还是以左移为例

枚举后,结合前面说的异或特征1.2.4点,就很容易就看出究竟什么情况了。首先,以高位开始第二字节,即8-15位为例,在round 0时候每一round加一个bit的操作,和在round 7结束后round 8开始前加整个byte的操作其实是没差别的,因为round 8到round 15才会开始消除对应的bit,而如果最开始不加bit,最终的变化会由 bitn ^ dummy 变为 0 ^ dummy ^ bitn,其中dummy为过程中对应需要异或的位的异或表达式。因为第2个特征 a ^ b ^ c = a ^ (b ^ c),所以dummy可以被认为是该表达式最终的结果;由于第4个特征 0 ^ a = a ,所以0 ^ dummy ^ bitn等于dummy ^ bitn;又基于第1点 a ^ b = b ^ a ,所以dummy ^ bitn = bitn ^ dummy,即每round插入bit数据和1 round插入byte数据结果等价。第一个问题解决。
然后看第2、3个问题,这俩确实疑惑,跟原理对不上,也可以明确绝对不是数学运算的问题。懵逼之际,把网页往下滚了下,发现CRC还有挺多奇奇怪怪的参数。

对比了下CRC16-MODBUS和CRC16-CCITT FALSE,两者除了多项式外,还有俩小勾的区别,REFIN和REFOUT,输入及输出的按位反转,开始按字面意思以为又是第5个特征这种1变0,0变1的反转,后面查了下,原来是bit[0:7]变为bit[7:0]这种。
知道了所有参数效果,自己尝试写个程序看下
1 uint16_t CRC_GetCRC16_MODBUS_LeftShift(const uint8_t* data, uint32_t len) 2 { 3 uint16_t CRCFull = 0xFFFF; 4 uint16_t Poly = 0x8005; 5 uint8_t tmpData = 0; 6 7 for(uint32_t i = 0;i < len;i++) 8 { 9 tmpData = data[i]; 10 InvertUint8(&tmpData,&tmpData); 11 12 CRCFull ^= (tmpData << 8); 13 14 for(int j = 0;j < 8;j++) 15 { 16 if(CRCFull & 0x8000) 17 { 18 CRCFull = (CRCFull << 1) ^ Poly; 19 } 20 else 21 { 22 CRCFull = CRCFull << 1; 23 } 24 } 25 } 26 27 InvertUint16(&CRCFull,&CRCFull); 28 return CRCFull; 29 }

又是一样的结果,那这种不用怀疑,又是数学问题了,数学不好,毫无灵感,直接枚举看思路,

好,这样枚举大法一写,看出来了,以CRC16为例
Inv(((CRC ^ (Inv(Data) << 8)) << 1) ^ Poly) = ((CRC ^ Data) >> 1) ^ Inv(Poly)
所以右移是对左移+输入输出反转的数学化简,0xA001为0x8005的位反转,至此,CRC的代码计算实现搞定。
我们知道原理,能通过计算的方式写出来,但实际上这个代码,要循环len * 8次,这个执行时间感官上是不友好的,那是否有办法用空间换时间呢?毕竟编译器优化选项都有speed favor,size favor和balanced拉
从数据以byte接入的方式可以联想到,数据只要是在消除前加载到了运算的CRC buffer内就可以了,所以,以16bit的CRC为例,如果提前把0-0xFFFF的移16位的值计算好,以数组形成存储,当成表,需要数据时候,提前把2字节的数据加载好,然后直接查表,每次就能省下2 * 8 = 16次循环,然而这样的操作会有两个问题:1)结束时候需要通过代码判断校验数据len是否为奇数,最后一个循环有特殊,not general,代码不好看 2)这个表的大小为 216 * 2 = 128k字节,对单片机来说是及其不友好的,甚至实际应用中很多单片机flash总共都没128k字节。基于上述两点的问题,在实际工程中,一般会选用查表来代替1个字节的循环,即8个循环,以输入输出均不反转为例,查表操作可以用以下代码表示:
1 uint8_t idxTable = (CRC >> 8) ^ data[i]; 2 CRC = (CRC << 8) ^ CRCTable[idxTable];
准备阶段把(0x00 | (byteH << 8)) (byteH = 0x00,0x01,...0xFE,0xFF)的值做8次消除操作,结果生成表;校验阶段把加载了新数据后的CRC buffer高8位作为索引去查表,再将低8位的数据移到高8位与查表值进行异或。具体的理解就是开始把校验值((byteH << 8) | byteL)拆成(byteH << 8)和byteL两部分,因为((byteH << 8) ^ byteL)与原数据相同,又基于异或的1、2特征,所以上述操作结果与目标结果一致。这样按8次消除操作生成的表,一共占用大小为28 * 2 = 512字节,实用性瞬间提升。计算和这种查8位表的,就可以认为是单片机里size favor和speed favor的CRC实现方式了。那有没balanced呢?基于上述思路下来,明显可知,当将8bit改成4bit时候,表变成24 * 2 = 32字节了,速度上一次查表代替了4次循环,算是一个balanced了。
回头看上面的代码,注意到左移的代码index在高8位,需要>> 8的操作才能得到索引,那输入输出均需要反转的右移代码会是怎样的呢
1 uint8_t idxTable = CRC ^ data[i]; 2 CRC = (CRC >> 8) ^ CRCTable[idxTable];
对比第一行发现,取索引值时候,少了移位操作。看似步骤少了,但实际上,在16位以上的单片机,这个数据类型的转换,暗含了&0xFF的操作,效率问题得具体分析了;而在8051单片机上,把写法改成 uint8_t idxTable = (uint8_t)CRC ^ data[i]; 就确实能提速。
到这里,灵机一动,以为是基于这点考量,才有前人鼓捣出来的输入输出反转。但去搜了下,百度的AI小秘表示:因为CRC算法是基于线性反馈移位寄存器(LFSR)的,而LFSR在处理数据时需要对数据进行反转操作;而BING小秘则表示输入数据反转的目的是为了适应数据传输的顺序,因为在某些情况下,数据是从低位开始传输的。通过反转输入数据,可以在不需要等待所有数据到达的情况下进行CRC计算。反正两个AI小秘没找到支撑他们说法的文献,这种东西本身就不便于考究,let it be,我们知道这些优劣就可以了。
最后谈下一些代码写法吧
1.计算的方法的话判断1和0决定是否异或POLY其实很丑,个人追求用计算来取代判断,怎么搞呢?以上面CRC16-MODBUS右移代码为例进行改编
1 uint16_t CRC_GetCRC16_MODBUS(const uint8_t* data, uint32_t len) 2 { 3 uint16_t CRCFull = 0xFFFF , CRCLSB; 4 uint32_t i, j; 5 6 for (i = 0; i < len; i++) 7 { 8 CRCFull = (uint16_t)(CRCFull ^ data[i]); 9 for (j = 0; j < 8; j++) 10 { 11 CRCLSB = (CRCFull & 0x0001); 12 CRCFull >>= 1; 13 CRCFull ^= ((0 - CRCLSB) & 0xA001); 14 } 15 } 16 17 return CRCFull; 18 }
2.查表法移来移去很麻烦,实际都是8位一组进行处理的,过程中也用不到uint16_t,那表也可以拆成CRCTableH和CRCTableL两部分,最终输出时候才将两者合并输出。
1 uint8_t idxTable = CRCLow ^ data[i]; 2 CRCLow = (CRCHigh ^ CRCTableLow[idxTable]); 3 CRCHigh = CRCTableHigh[idxTable];
浙公网安备 33010602011771号