C# 学习研究CRC校验
最近一直在研究CRC校验,网上搜了一堆资料,研究了老半天终于算是搞明白了,下面全是基于我自己的理解,如有错误,望指出!
1.定义
CRC(Cyclic Redundancy Check)即【循环冗余校验】,是一种数据检验算法,被广泛的用来验证数据传输前后的一致性,
2.原理
其原理就是把整个字节流数据当作一个【被除数】,再设定一个【除数】,然后使用【模二除法】运算求其余数,这个余数就是CRC校验码,
其中的【除数】比较特殊,其最高位和最低位必须为1,理论上可以自己设定除数,但是其取值貌似有一定的讲究,网络上有很多大家都在用的除数,比如CRC16中的0x18005,0x13D65等等,
模二除法是一种二进制算法,它不考虑进位和错位,使用异或运算替代每一步的减法,具体如下图,

CRC校验的的时候,通常会在数据的尾部加上余数长度的比特位,用来存放余数,
3.模二除法编程
原理搞清楚了,接下来我们就可以构思编程逻辑了(CRC校验时只需要余数,所以下面编程的时候将不再关心商):
1,判断被除数的最高位,如果是0,直接左移一位,
2,如果是1,就把除数和被除数高位对其后进行异或计算,运算后最高位必然为0,左移一位,
3,循环1和2,直到将被除数的所有位数走完,最终得到的数据就是余数,余数会比除数少一位,
举例,假如【被除数】(即数据)为两个字节 0xC981,二进制0b1100_1001_1000_0001,【除数】为0x1A001,二进制0b0001_1010_0000_0000_0001,则计算过程如下,

根据上述构思编写的代码:
ushort Mod2Division(byte[] data, int completePoly = 0x1A001) { int buf = 0; //因为我们想要一个16位的余数,所以除数要17位,缓冲区只能用32位的整型 completePoly = completePoly << (32 - 17); //除数高位对其 foreach (byte b in data) { //循环处理每个字节 buf = buf ^ (b << 24); //将每个字节和上一次缓冲区的中间结果异或 for (int i = 0; i < 8; i++) { //判断每个字节的每个比特 if ((buf & 0x80000000) != 0) { //判断最高位是否为1 buf = buf ^ completePoly; //和除数异或存储中间结果 buf <<= 1; } else { buf <<= 1; } } } return (ushort)(buf >> 16); //高位2个字节即为余数 }
注意!上面的代码只是单纯的实现了模二除法的逻辑,很不优雅,主要是用来理解模二除法的原理,
4.常规的CRC16算法
现实中CRC算法除了基于模二除法外,还有其他额外的因素能对结果直接造成影响,
poly:多项式,可以理解为省略了最高位的【除数】,例如上文的0x18005,其对应的多项式就是0x8005,使用多项式这个概念能让算法更加的简洁,
init:初始值,因为原始数据可能以不同位的0开头,而异或计算无法区分有多少个0,所以需要设置初始值用以区分,
refin:数据的每个字节是否按位反转,有些硬件为了方便处理,会对输入的数据的每个字节按位反转,
refout:计算出来的结果(CRC码)整体是否按位反转,
xorout:计算出来的结果再要异或的值,为0时结果不变,
根据校验码的位数,CRC校验可以分为CRC8,CRC16,CRC32,接下来咱们根据上述的若干参数实现一个常规的CRC16算法,
/// <summary> /// CRC16算法 /// </summary> /// <param name="data">数据</param> /// <param name="init">初始值</param> /// <param name="poly">多项式</param> /// <param name="refin">输入数据是否每个字节按位反转</param> /// <param name="refout">输出结果是否整体按位反转</param> /// <param name="xorout">输出结果前要异或的参数</param> /// <returns></returns> public ushort CRC16(byte[] data, ushort init = 0xFFFF, ushort poly = 0x8005, bool refin = false, bool refout = false, ushort xorout = 0) { ushort crc = init; foreach (byte b in data) { var byt = b; if (refin) { //每个字节按位反转 byt = ReverseBits_UInt8(byt); } crc ^= (ushort)(byt << 8); //高位对齐 for (int i = 0; i < 8; i++) { if ((crc & 0x8000) != 0) { crc = (ushort)((crc << 1) ^ poly); //此时多项式忽略了最高位的1,因此可以先移位 } else { crc <<= 1; } } } if (refout) { //整个结果(2个字节的短整型)按位反转 crc = ReverseBits_UInt16(crc); } crc ^= xorout; //计算结果与此参数异或后得到最终的CRC值 return crc; } /// <summary> /// 将字节按位反转 /// </summary> public static byte ReverseBits_UInt8(byte num) { byte reversed = 0; for (int i = 0; i < 8; i++) { reversed <<= 1; reversed |= (byte)(num & 1); num >>= 1; } return reversed; } /// <summary> /// 将短整型整体按位反转 /// </summary> public static ushort ReverseBits_UInt16(ushort num) { ushort reversed = 0; for (int i = 0; i < 16; i++) { reversed <<= 1; reversed |= (ushort)(num & 1); num >>= 1; } return reversed; }
上述代码需要注意一下几点,
1,多项式是忽略最高位的除数,上述代码的poly=0x8005,其完整多项式(即模二除法中的除数)为0x18005,模二除法中是拿除数参与运算,是先异或,再移位,而使用多项式参与运算时,就相当于忽略了最高位(先移位)后再运算剩下的比特位,两者的结果是一样的,也算是一定程度上简化逻辑了,而且使用多项式还能让缓冲区使用和验证码一样的比特长度,整体运算起来更加的简洁清晰和快捷,
2,如果refin为真,每个字节都要按位反转,
3,如果refout为真,得到的结果要整体按位反转,目前所有的流行的算法中refin和refout都是一致的,即要么都为true,要么都为false,
4,最后结果还要和xorout进行异或才是最终结果,不过一般xorout都是0,异或后结果不变
5.右移算法
到了这里,理论已经能够正确的计算出CRC验证码了,
但是我发现,网上很多实例代码和我的不一样,他们的代码的逻辑是低位对齐,并通过右移位来消数据的,看上去很优雅,比如下面的代码:
public ushort Modbus_crc16(byte[] data) { ushort crc = 0xFFFF; // 初始值 for (int i = 0; i < data.Length; i++) { crc ^= data[i]; for (int j = 0; j < 8; j++) { if ((crc & 0x0001) != 0) { crc = (ushort)((crc >>= 1) ^ 0xA001); // 反射多项式 } else { crc >>= 1; } } } return crc; }
研究了一番,发现原来是部分硬件设备出于实现效率、通信协议兼容性和历史设计惯性等原因,选择了refin=true,refout=true,但是人家并没有傻傻的真的把数据的每一个字节都反转一下,这样太影响性能了,他们使用了一种简单的右移算法实现了对数据的计算,得到的结果和左移是完全一样的,
即当【原数据】+【反射多项式】+【低位对其】+【右移消数据】后,其结果和【数据的每个字节按位反转】+【原多项式】+【高位对齐】+【左移消数据】+【结果数据整体按位反转】的结果是一致的,两者就像反射镜像一样,在refin和refout都为true的场景下,明显采用右移算法比左移算法更优雅,代码也更简洁,性能也更好,如下图:

6.整合优化CRC16算法
稍微整合一下,我们就能在refin和refout都为false的时候使用左移算法,都为true的时候使用右移算法了,非常的优雅
/// <summary> /// CRC16校验 /// </summary> public class CRC16 { readonly ushort _init; readonly ushort _poly; readonly ushort _xorout; /// <summary> /// 具体的算法函数,在初始化的时候确定使用哪个算法 /// </summary> readonly Func<byte[], ushort> _func; /// <summary> /// 创建一个CRC16校验对象 /// </summary> /// <param name="init">初始值</param> /// <param name="poly">多项式</param> /// <param name="refin">输入数据是否每个字节按位反转</param> /// <param name="refout">输出数据是否整体按位反转</param> /// <param name="xorout">输出数据要异或的值</param> /// <exception cref="Exception"></exception> public CRC16(ushort init = 0xFFFF, ushort poly = 0x8005, bool refin = false, bool refout = false, ushort xorout = 0) { if (refin != refout) { throw new Exception("refin和refout必须一致"); } _init = init; if (refin == false) { //数据不反转,就使用和原理一致的左移算法 _poly = poly; _func = CRC16_left; //指向左移方法 } else { //数据要反转时,反转多项式,使用右移算法 _poly = ReverseBits(poly); //反射多项式 _func = CRC16_rigth; //指向右移 } _xorout = xorout; } /// <summary> /// 对一个短整型整体按位反转 /// </summary> public static ushort ReverseBits(ushort num) { ushort reversed = 0; for (int i = 0; i < 16; i++) { reversed <<= 1; reversed |= (ushort)(num & 1); num >>= 1; } return reversed; } /// <summary> /// 对传入的字节数组进行CRC16校验,返回校验码 /// </summary> /// <param name="data">数据</param> /// <returns>校验码</returns> public ushort Check(byte[] data) { return (ushort)(_func(data) ^ _xorout); } /// <summary> /// 左移算法 /// </summary> ushort CRC16_left(byte[] data) { ushort crc = _init; foreach (byte b in data) { crc ^= (ushort)(b << 8); for (int i = 0; i < 8; i++) { if ((crc & 0x8000) != 0) { crc = (ushort)((crc << 1) ^ _poly); } else { crc <<= 1; } } } return crc; } /// <summary> /// 右移算法,适用于refin和refout都为真的时候 /// </summary> ushort CRC16_rigth(byte[] data) { ushort crc = _init; for (int i = 0; i < data.Length; i++) { crc ^= data[i]; for (int j = 0; j < 8; j++) { if ((crc & 0x0001) != 0) { crc = (ushort)((crc >> 1) ^ _poly); } else { crc >>= 1; } } } return crc; } }
7.查表法
CRC还有一种通过空间换时间的算法,也就是查表法,
CRC校验可以理解为根据数据的比特位进行有规律的异或多项式计算,由于数据是固定的,也就说具体要异或几次,以及要在哪对齐异或,这些都是已知固定的,
而且在异或运算中,异或运算的先后顺序并不会影响结果,即a^b^c=(c^a)^b,如下图,

因此,我们完全可以先算出0-255每个数值和多项式运算后的余数(在CRC16中,经过运算后,作为数据的8位会消掉,后续受影响的16位即为余数),将其存到crc表中,在校验的时候就可以直接使用数据作为索引从crc表中拿到提前计算的余数来使用,这样每次直接就能消掉1个字节,就不用每次都循环8次运算了,
根据原理,先实现一个左移查表法,
/// <summary> /// crc16左移查表法 /// </summary> internal class CRC16TableLeft { public readonly ushort Poly; readonly ushort[] crcTable; public CRC16TableLeft(ushort poly) { Poly = poly; crcTable = GenerateCRCTable(); Console.WriteLine(ShowTable(crcTable)); } /// <summary> /// 根据多项式,生成crc表 /// </summary> /// <returns></returns> ushort[] GenerateCRCTable() { var table = new ushort[256]; for (int idx = 0; idx < table.Length; idx++) { ushort crc = (ushort)(idx << 8); //每次计算单独的余数 for (int j = 0; j < 8; j++) { if ((crc & 0x8000) != 0) { crc = (ushort)((crc << 1) ^ Poly); } else { crc = (ushort)(crc << 1); } } table[idx] = crc; //该值为不受前面计算影响的余数 } return table; } /// <summary> /// 得到检验码 /// </summary> /// <param name="init"></param> /// <param name="data"></param> /// <returns></returns> public ushort Check(ushort init, byte[] data) { ushort buf = init; //2个字节的缓冲区 foreach (var p in data) { var val = (buf >> 8) ^ p; //缓冲区中的数据记录了上次运算的结果,两者高位对其后异或,得到的字节就是当前要运算的数据, var crc = crcTable[val]; //查表找到val对应的余数 buf <<= 8; //已经通过高8位的余数得到val了,可以直接消掉了 buf = (ushort)(buf ^ crc); //将当前运算的结果覆盖到缓冲区中,用于下一次运算 //buf = (ushort)((buf << 8) ^ crcTable[(buf >> 8) ^ p]); //整合代码 } return buf; //最终缓冲区中的数据就是校验码 } string ShowTable(ushort[] data) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < data.Length; i++) { if (i % 8 == 0) { sb.Append(Environment.NewLine); } sb.Append($"0x{data[i]:X4},"); } return sb.ToString(0, sb.Length - 1); } }
整合一个包括左移和右移两种方式的CRC16查表功能类
public class CRC16Table { public readonly ushort _init; public readonly ushort _poly; public readonly bool _refin; public readonly bool _refout; public readonly ushort _xorout; public readonly ushort[] _table; public CRC16Table(ushort init = 0xFFFF, ushort poly = 0x8005, bool refin = false, bool refout = false, ushort xorout = 0) { if (refin != refout) { throw new Exception("refin和refout的值必须是一致的!"); } _init = init; _poly = poly; _refin = refin; _refout = refout; _xorout = xorout; if (_refin == false) { _table = GenerateTable_Left(_poly); } else { _table = GenerateTable_Right(ReverseBits(_poly)); //反射多项式 } } public static ushort ReverseBits(ushort num) { ushort reversed = 0; for (int i = 0; i < 16; i++) { reversed <<= 1; reversed |= (ushort)(num & 1); num >>= 1; } return reversed; } static ushort[] GenerateTable_Left(ushort poly) { var table = new ushort[256]; for (int idx = 0; idx < table.Length; idx++) { ushort crc = (ushort)(idx << 8); for (int j = 0; j < 8; j++) { if ((crc & 0x8000) != 0) { crc = (ushort)((crc << 1) ^ poly); } else { crc = (ushort)(crc << 1); } } table[idx] = crc; } return table; } static ushort[] GenerateTable_Right(ushort poly) { var table = new ushort[256]; for (int idx = 0; idx < table.Length; idx++) { ushort crc = (ushort)idx; for (int j = 0; j < 8; j++) { if ((crc & 0x0001) != 0) { crc = (ushort)((crc >> 1) ^ poly); } else { crc = (ushort)(crc >> 1); } } table[idx] = crc; } return table; } public ushort Check(byte[] data) { ushort buf = _init; if (_refin) { foreach (var p in data) { buf = (ushort)((buf >> 8) ^ _table[(buf ^ p) & 0xFF]); } } else { foreach (var p in data) { buf = (ushort)((buf << 8) ^ _table[(buf >> 8) ^ p]); } } return (ushort)(buf ^ _xorout); } public string GetTableString() { var sb = new StringBuilder(); for (int i = 0; i < _table.Length; i++) { if (i % 8 == 0) { sb.Append(Environment.NewLine); } sb.Append($"0x{_table[i]:X4},"); } return sb.ToString(0, sb.Length - 1); } }
测试代码
public void Test() { byte[] data = new byte[] { 0,1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}; CRC16Table ct_left = new CRC16Table(0xFFFF, 0x8005, false, false); var crc_L = ct_left.Check(data); Console.WriteLine($"crc_L,{crc_L:X4}"); CRC16Table ct_rigth = new CRC16Table(0xFFFF, 0x8005, true, true); var crc_R = ct_rigth.Check(data); Console.WriteLine($"crc_R,{crc_R:X4}"); Console.WriteLine(ct_left.GetTableString()); Console.WriteLine(ct_rigth.GetTableString()); }
终于算是搞清楚了~

浙公网安备 33010602011771号