扒拉一下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];

 

posted @ 2025-05-21 12:07  蓝bleu  阅读(205)  评论(0)    收藏  举报