简介:面向 C# 开发者的 CRC 校验实现源码包,围绕 CRC32 算法演示如何在数据传输与存储中检测错误。资源内置完整的 Visual Studio 解决方案,包含窗体界面、核心 CRC 类、程序入口等源码,以及编译后的可执行文件和调试符号,打开工程即可直接运行。压缩包内共 26 个文件,整体大小仅 43KB,以 C# 源代码为主体,另有窗口资源、调试符号和项目配置等文件,结构简洁清晰,无冗余代码,适合刚接触校验算法的初学者快速上手。代码实现采用查表法,先预生成 CRC 查找表,再对每个字节进行异或查表更新,完整展示了寄存器初始化、数据处理和最终结果返回的细节;窗体支持输入文本并实时显示校验结果,配合调试信息可通过断点观察计算全过程,便于对照源码理解每一步逻辑。已有 455 人学习下载,这套小巧而完整的示例可直接参考或集成到自己的 C# 项目中,为通信、存储等场景下的数据完整性保障提供实用工具。
1. CRC(循环冗余校验)是协议联调里最值得先搞定的基本功
CRC(循环冗余校验)是串口联调、固件升级、文件完整性校验里最常用的一类算法:发送端对数据算出一个固定校验值,接收端重算一遍,不一致就说明数据在被传输的途中悄悄变了样。C#写上位机时几乎绕不开它——协议解析要校验、固件包要校验、网络传输的可信度也要靠它把最后一道关。下面把CRC的数学原理压缩到够用为止,给你C#里可以照抄的逐位运算和查表法两套实现,再把参数模型和联调踩坑点讲透,适合正在做协议联调、移植CRC代码或者排查“校验值莫名其妙对不上”的开发者。读者读完能自己写、自己验证、自己定位问题。
2. CRC原理先立住:它算的是余数,不是哈希
2.1 把数据想象成二进制多项式:模2除法怎么得到校验值
CRC全称是循环冗余校验,名字里的“循环”来自它的数学实现——模2除法。所有参与运算的二进制数都可以被看成一个多项式:比如二进制1011可以写成x^3 + x + 1,每一位对应一个幂次。发送方拿原始数据这个“大多项式”去除以生成多项式,除法的余数就是校验码,接收方用同样的方法重算,比较余数是否一致。
模2除法与普通除法的区别在于,每一位的相减都用异或代替,没有进位也没有借位。举一个最简例子:数据1010,生成多项式11(二进制,对应多项式x+1),做模2除法:第一步,最高位1对齐,1异或1得0,下一位0直接拉下来,当前被除数变成010;第二步,0不够除,再拉下一位,变成10,首位1异或1得0,余数0。整个过程没有“借位”这个概念,每一位只做异或。最终除到最后,余数的位数一定比生成多项式小,也就是小于等于位宽减1。
这个数学性质直接决定了CRC的工程特征。CRC的值域是固定的:8位CRC输出范围0~255,16位CRC输出0~65535,32位CRC输出0~4294967295。数据只要和多项式确定,余数就确定。也正是因为这点,CRC计算可以被拆成分步的“状态机”:处理完一个字节后,寄存器的内容就是截至当前数据的余数,这个中间状态可以直接保存下来,用于后续继续处理追加数据。这一点在流式传输场景特别有用,我放在第6章展开。
在C#具体实现前,先记住CRC计算的通用骨架:
- 准备好一根“生成多项式”,注意它和“反向多项式”的关系——很多查表法代码里用的是反向多项式,这个细节最容易翻车
- 给寄存器赋初始值,常见有0x0000或0xFFFF
- 对每个字节,按位或按表把数据“揉”进寄存器
- 全部处理完后,对寄存器做一次异或输出,得到最终CRC值
骨架里的第2、3、4步,在不同协议版本里取值完全不同。这就是为什么你觉得“都叫CRC-16”,写出来的代码一个天上一个地下。我在第4章专门讲参数模型。
2.2 位宽怎么选:CRC-8守住串口小帧,CRC-32守住文件完整性
CRC-8、CRC-16、CRC-32,这个数字代表校验值占用的比特位数。位宽越大,可表示的校验值空间越大,两个不同数据撞出同一个校验值的概率就越低。以CRC-16为例,校验值只有65536种,假设数据随机分布,两个不同数据产生相同校验值的概率约为1/65536;CRC-32是1/2^32,约等于23亿分之一,量级完全不一样。
但位宽不能只看冲突率,还要看附加开销和计算成本。C#跑在PC上,算32位CRC几乎无感,但在单片机上,每多一个字节的校验字段就多占一个字节的传输时间。所以实际选型是综合考虑:
- CRC-8:1字节校验,适合单字节传感器、内存受限的嵌入式小帧,实时性优先
- CRC-16:2字节校验,串口通信和工业总线的主流选择,几十字节的帧长下冲突率足够低
- CRC-32:4字节校验,文件下载、固件包、网络协议大包,出错重传代价高,值得多花2字节
这里放一张速查表,方便抄作业:
| 位宽 | 校验值大小 | 典型冲突率量级 | 常见场景 |
|---|---|---|---|
| CRC-8 | 1字节 | 约1/256 | 温度/湿度传感器单帧上报 |
| CRC-16 | 2字节 | 约1/65536 | 串口帧协议,工业总线 |
| CRC-32 | 4字节 | 约1/2^32 | 文件完整性、固件包校验 |
还有一个最容易被忽略的选型依据:协议文档到底怎么写的。如果文档里只有“CRC16校验”四个字,千万别直接开写——CRC-16有MODBUS、CCITT、IBM等多个变体,多项式、初始值、字节序都不一定相同,算出来的结果大概率对不上。我自己的经验是,拿到一份没有指定具体变体的协议,先去找文档里的示例帧:一般协议文档都会给一组“数据+CRC”的样例,拿这个样例当测试向量,反推参数。反推的方法在第4章写。
如果协议完全没有指定,我只凭场景推荐:串口小帧直接选CRC-16/MODBUS,因为工业现场大量设备都在用,踩坑案例多、参考资料也多;文件校验选CRC-32;传感器单字节选CRC-8。选定之后把参数写进固定配置,不要一会换一个。
3. 用C#实现CRC:从位运算到查表法,一步步落到代码
3.1 位运算法:最直观的CRC-16/MODBUS实现
先看最容易被读懂的写法。下面这段代码完整实现了CRC-16/MODBUS,采用逐位运算方式:
public static class Crc16Modbus { private const ushort PolyReverse = 0xA001; // 0x8005按位反转后的多项式 public static ushort Compute(byte[] data, int offset, int length) { if (data == null) throw new ArgumentNullException(nameof(data)); if (offset < 0 || length < 0 || offset + length > data.Length) throw new ArgumentOutOfRangeException(nameof(offset)); ushort crc = 0xFFFF; // MODBUS算法初始值 for (int i = offset; i < offset + length; i++) { crc ^= data[i]; // 先把新字节异或进寄存器的低8位 for (int bit = 0; bit < 8; bit++) { if ((crc & 1) != 0) // 看最低位,决定是否要异或多项式 { crc = (ushort)((crc >> 1) ^ PolyReverse); } else { crc >>= 1; } } } return crc; } }代码逻辑:外层循环遍历字节,内层循环处理每个字节的8个比特。每次移位前先检查crc的最低位,如果是1,就把右移后的结果异或上反向多项式0xA001,相当于做了一次模2除法;如果是0,直接右移。处理完所有字节后,crc里存的就是余数。
这里的两个关键参数要解释清楚。第一,PolyReverse是0x8005按位反转的结果。为什么用反向多项式?因为MODBUS算法的RefIn和RefOut都是true,即输入和输出都要做比特反射,逐位运算时把多项式反转后,就可以在右移的过程中自然完成反射,省掉单独写反射步骤。如果你照着某些资料用0x8005直接算,会发现结果完全不同。第二,初始值0xFFFF也是MODBUS的标准要求,换成0x0000就变成另一个变体了。
调用方式如下:
byte[] frame = { 0x01, 0x03, 0x00, 0x00, 0x00, 0x0A }; ushort crc = Crc16Modbus.Compute(frame, 0, frame.Length); // 输出时通常把crc拆成两个字节,注意MODBUS要求低位在前 byte[] crcBytes = new byte[] { (byte)(crc & 0xFF), (byte)(crc >> 8) };这段代码展示了输出字节序的处理:MODBUS协议规定CRC的低字节在前、高字节在后,所以先把低8位放进第一个字节。很多开发者在联调时发现数据对不上,就是在这里高低位搞反了。
3.2 查表法:把256个中间结果缓存起来,省掉逐位循环
逐位运算法容易理解,但内层循环每字节要跑8次,数据量大时CPU开销明显。查表法的思路很直接:先把一个字节的所有可能取值(0~255)对应的CRC增量结果预先算出来,存成一张256长度的表,实际计算时用查表代替逐位运算。
public static class Crc16ModbusTable { private static readonly ushort[] Table = BuildTable(); private static ushort[] BuildTable() { var table = new ushort[256]; for (int i = 0; i < 256; i++) { ushort value = (ushort)i; for (int bit = 0; bit < 8; bit++) { value = (value & 1) != 0 ? (ushort)((value >> 1) ^ 0xA001) : (ushort)(value >> 1); } table[i] = value; } return table; } public static ushort Compute(byte[] data) { ushort crc = 0xFFFF; foreach (byte b in data) { byte index = (byte)(crc ^ b); // 高8位与新字节异或后,作为查表索引 crc = (ushort)((crc >> 8) ^ Table[index]); // 低8位右移后,再异或查到的表值 } return crc; } }查表法中,crc的低8位暂时“退出”本轮计算,高8位和新字节异或后作为索引取表值,再与右移后的crc异或。这个过程本质上和逐位运算是等价的,但因为把8次循环变成了1次数组访问,速度能快好几倍。
使用查表法有两个必须注意的地方。第一,表必须是静态只读字段,只初始化一次。如果每次调用Compute时都重新BuildTable,C#的数组分配和256次循环的开销会抵消查表带来的性能优势,甚至更慢。第二,这张表对应的是“反向多项式0xA001+右移”的一套参数,如果换用正向多项式0x8005+左移,表的生成逻辑也要同步改变,不能只换一个数字。
这段代码为什么用foreach而不是for加索引?两个版本性能差别不大,但foreach在后续换成ReadOnlySpan优化时更顺手。如果你处理的是流式大文件,可以把Compute的入参改成ReadOnlySpan ,内部循环逻辑不变,只是多了一层性能免检。Span版本的细节我在第6章提一下,这里先不展开。
到这里第3章已经有一个可用的实现了。验证方法很简单:输入字母数字字符串“123456789”的ASCII字节,标准CRC-16/MODBUS结果是0x4B37,如果你的Compute返回这个值,说明参数对上了。这个测试向量我后面还会提。
4. CRC参数模型:同样叫CRC-16,为什么结果天差地别
4.1 六个参数决定一个CRC变体:多项式/初始值/反射/异或输出
很多开发者第一次移植CRC代码时,都会遇到“明明算法名一样,算出来就是不对”的问题。根源在于,CRC的完整定义由一组参数共同决定,任何一项不同,输出就不同。这组参数在工程界通常被称为CRC参数模型,包含:
| 参数 | 作用 | 典型取值 |
|---|---|---|
| Width | 校验值位宽 | 8 / 16 / 32 |
| Poly | 生成多项式 | 0x8005(CRC-16/MODBUS) |
| Init | 寄存器初始值 | 0xFFFF / 0x0000 |
| RefIn | 输入是否按位反射 | true / false |
| RefOut | 输出前是否按位反射 | true / false |
| XorOut | 输出前异或的值 | 0x0000 / 0xFFFF |
以三个最常见的变体为例:
| 算法 | Poly | Init | RefIn | RefOut | XorOut |
|---|---|---|---|---|---|
| CRC-16/MODBUS | 0x8005 | 0xFFFF | true | true | 0x0000 |
| CRC-16/CCITT | 0x1021 | 0xFFFF | false | false | 0x0000 |
| CRC-32 | 0x04C11DB7 | 0xFFFFFFFF | true | true | 0xFFFFFFFF |
从表格可以看到,所谓“CRC-16”至少有MODBUS和CCITT两大家族,多项式不一样,算出的校验值自然不一样。Poly决定除法用的生成多项式;Init决定第一字节进入运算前寄存器的初态,这个值对前几个字节的校验结果影响很大;RefIn和RefOut控制数据在输入和输出时是否做比特级反转,它们直接决定代码应该用右移还是左移、用正向多项式还是反向多项式;XorOut则是对最终结果再做一次异或,防止全0数据算出的CRC也是0。
还有一个容易被忽略的参数是字节序。它不属于CRC数学定义,但在工程输出时必须明确:计算结果是一个16位或32位的整数,写入缓冲区时是高位在前还是低位在前,由协议决定。MODBUS是低位在前,很多网络协议是高位在前。字节序不同,接收方拿到的字节流就不同,校验结果也不会匹配。
4.2 拿到陌生协议的CRC,怎么反推它的参数
接手老设备的串口协议时,经常遇到文档没说清CRC用哪套参数的情况。我的做法是三步反推:
第一步,在文档或配套例程里找到一组“已知数据+已知CRC”的样例。协议文档几乎都会给,比如“发送 01 03 02 01 02,CRC为 0x0C 0x44”这种。找到一个能直接用的测试向量。
第二步,把常见变体逐一套进C#实现里跑。常见做法是写一个小型测试程序,把CRC-16/MODBUS、CRC-16/CCITT、CRC-16/IBM等几个变体都实现一遍,输入同样数据,看哪个输出能和文档的CRC对上。
第三步,确认字节序。如果CRC整数本身一致但字节序相反,说明算法参数对了,只是输出时高低位顺序反了,这时候调整字节序即可,不用改算法。
如果在第一步就找不到测试向量,还有一个办法:从代码反推。C++或C的参考实现里,如果看到crc异或byte之后用右移,说明RefIn为true;看到初始化是0xFFFF,基本可以排除Init=0的变体;看到最后有return crc异或0xFFFF,说明XorOut为0xFFFF。这些代码特征和参数模型的关系已经列在4.1的表格里,对着查就行。
提示:反推参数时不要只依赖一组测试向量,换两三组不同长度、不同内容的数据交叉验证,才能确认参数模型没有猜错。
更省事的做法是直接用一个支持自定义参数的CRC计算工具,把Poly、Init、RefIn、RefOut、XorOut依次填进去,对照测试向量调。工具算出来的结果如果和文档一致,说明参数确认了,再把参数填进C#代码里的常量即可。调试时不要靠猜,拿样例数据说话。
5. CRC校验踩坑记录:5个让你抓狂的问题和排查方法
5.1 联调时CRC和文档示例对不上,但算法代码看着没问题
现象:照着文档抄的CRC算法,用文档给的示例数据跑,结果CRC和文档值差好几个字节。
原因:八成是字节序。CRC算出来是一个16位整数,文档里写的是“0x0C 0x44”,代码输出却是“0x44 0x0C”,看起来只差一个交换,接收方却会认为校验失败。
解决:确认文档规定的高低位顺序。MODBUS系列是低位在前,很多自定协议也抄了这个习惯;但如果你在联调网络协议,很可能要高位在前。在输出缓冲区时明确转换:
// MODBUS风格:低字节在前 buffer[0] = (byte)(crc & 0xFF); buffer[1] = (byte)(crc >> 8);// 网络风格:高字节在前 buffer[0] = (byte)(crc >> 8); buffer[1] = (byte)(crc & 0xFF);调试思路:先把CRC整数打印出来,和文档对比整数是否一致,再检查字节序。如果整数都不一致,才去查参数模型。
5.2 把C语言示例移植到C#,结果全乱
现象:参考代码是C语言的,逻辑原样翻译成C#,算出的CRC和C跑出来的完全不一致。
原因:C语言里常用unsigned int或unsigned short,这是无符号的;C#默认的int和short是有符号的。CRC运算大量使用右移和异或,有符号整数右移时,高位会补符号位而不是补0,一旦crc的最高位为1,右移结果就变成负数,后续异或全乱。还有一个隐藏问题:C语言里char可能是signed char,直接参与异或也会引入符号位。
解决:C#里全部使用无符号类型byte、ushort、uint,计算中间不出现有符号类型。我的移植习惯是:先确认C代码里所有参与CRC运算的变量类型,再映射为C#的无符号对应类型,不要用var,避免自动推断带来意外。
5.3 查表法反而比逐位运算慢,越调越卡
现象:数据量大时,查表法版本CPU占用反而高,吞吐量比逐位运算还低。
原因:查表法把建表循环写在了Compute方法里,每次调用都new一个256长度的数组并循环256次。数据量越大,重复建表的次数越多,纯计算开销被建表开销掩盖。
解决:把表改成静态readonly字段,在静态构造器或字段初始化器中只建一次。这个在第3.2已经展示过,关键是理解“表属于算法级配置,不属于单次计算”。
5.4 明明CRC整数一致,校验却总是失败,怀疑设备端有问题
现象:上位机算出的CRC和设备端上报的CRC整数一致,但设备端仍然回错误帧。
原因:计算范围问题。设备端的校验范围是从帧头(可能包含地址、命令)到数据末尾,但上位机把整个帧包括CRC字段本身也算进去了。CRC计算不能包括已经追加的校验字段,否则会把“校验位”作为数据参加运算,两边范围不一致,结果当然对不上。
解决:明确协议中的“CRC计算覆盖范围”,通常是从帧起始字节到CRC前一字节。调用Compute时传入正确的offset和length:
// 假设帧结构: 地址(1) 命令(1) 数据长度(1) 数据(N) CRC(2) int crcOffset = 0; int crcLength = 1 + 1 + 1 + dataLength; // 从地址到数据末尾 ushort crc = Crc16Modbus.Compute(frame, crcOffset, crcLength);5.5 校验全0的一帧数据,CRC算出来也是0,是不是算法错了
现象:发一串全0的数据,CRC结果是0,怀疑是初始值或多项式配置错误。
原因:如果Init=0、XorOut=0、RefIn/RefOut=false,全0输入计算CRC,寄存器和数据都为0,结果必然是0。数学上这是合理的,不是错误。但很多协议的CRC变体设计了Init=0xFFFF或XorOut=0xFFFF,就是为了避免全0数据的CRC为0,因为全0的数据在网络空闲时常见,CRC为0会与“无数据”状态混淆。
解决:先用“123456789”这种非全0标准向量验证算法实现本身正确,再单独测试全0输入;如果协议文档明确要求全0数据的CRC不能用0,那就检查Init和XorOut参数是否按文档设置。注意不要为了“看起来正确”随意改参数,CRC算法必须严格匹配协议。
6. 进阶:流式CRC计算与标准向量验证
6.1 大文件或分块传输场景:保存中间状态继续算
CRC的数学特性决定了它是一个可继续运算的状态机。处理完一帧的前半段后,寄存器里的crc值就是“截至当前数据”的余数;只要把crc变量保存下来,下一帧数据到达时继续调用同一个计算循环,效果等同于一次算完一整段。C#里可以这样实现:
// 用实例字段保存中间CRC状态,每来一个字节就增量更新 public class CrcStream { private ushort _crc = 0xFFFF; public void Append(byte[] chunk) { foreach (byte b in chunk) { _crc ^= b; for (int bit = 0; bit < 8; bit++) { _crc = (_crc & 1) != 0 ? (ushort)((_crc >> 1) ^ 0xA001) : (ushort)(_crc >> 1); } } } public ushort GetResult() => _crc; }注意,一旦开始增量计算,就不能在中间重置_crc;只有收到完整帧后才调用GetResult并复位。这个模式很适合大文件分块读取或TCP流式收包。
6.2 用标准测试向量验证你的CRC实现
无论你是自己写的还是网上抄的CRC代码,拿到手第一件事是验证。最通用的标准测试向量是ASCII字符串“123456789”:
- CRC-16/MODBUS:0x4B37
- CRC-16/CCITT:0x29B1
- CRC-32:0xCBF43926
把这段字符串转成字节数组,跑一遍你的Compute,如果结果和上面对得上,说明参数模型配对了;对不上,先检查参数模型再检查字节序,不要盲目改多项式。
我自己的习惯是,每接一个新协议的CRC,先建一个只放测试向量的最小测试工程,把文档给出的示例数据跑通后再接入真实逻辑。这个习惯省了很多联调时间——因为校验类问题一旦错了,你很难判断到底是数据错了还是算法错了,而标准向量可以先把算法锁死。希望帮到你。
本文还有配套的精品资源,点击获取