简介:面向需要在C#项目中加入数据完整性校验的开发者,这套CRC32校验源码包提供可参考的算法实现与可视化演示工程,适用于通信、存储及网络传输场景下的差错检测。压缩包共26个文件、约43KB,以7个cs源码文件为核心,配合resx资源、csproj项目文件与sln解决方案组织工程,另含少量exe、pdb等编译调试产物,目录结构紧凑,便于对照学习算法如何落地到桌面程序。已有455人学习下载。代码覆盖CRC查找表构建、寄存器初始化、逐字节查表更新以及结果位反转等关键环节,读者可据此理解校验值生成流程,并将校验逻辑迁移到自己的数据收发、文件校验或协议解析模块中;遇到校验结果不一致时,也可结合演示工程定位初值、字节序或反转处理等细节问题。适合具备一定C#基础、希望快速掌握CRC32实现思路的开发者参考。
1. 一个字节位置搞错,C# CRC 校验就永远对不上
某开发者接手一台老设备的串口通讯,上位机是 C# 写的,读温控表的实时值。协议文档只写了四个字:CRC 校验。他随手抄了一段网上的代码,十帧里对八帧,剩下两帧对不上,重发又能过。后来把多项式、初值、反射、异或输出四个参数逐个对齐,问题当场消失。这类现场我见过太多次,九成不是算法写错,而是参数模型没对上——同一个多项式,初值差一个 0xFFFF,结果就是两个完全不同的值。这份 C# CRC 校验资源包做的事很朴素:把宽度、poly、init、refin/refout、xorout 抽象成数据,一套引擎覆盖 CRC-8/16/32/64 常见变体,附逐位基准实现、查表实现和一组已知答案向量。适合写上位机、做设备对接、需要和第三方系统核对校验值的 C# 开发者。
2. 先把参数模型立住:同一串数据为什么能算出四个结果
CRC 的本质是一个带反馈的移位寄存器,数据一位一位进去,寄存器里的余数就是校验值。听上去简单,但落到工程上,同一个 0x8005 多项式能派生出四个互不兼容的算法,原因全在寄存器外面那几个开关上。这一章不讲数学推导,只把「哪些参数会改变结果」摆清楚,后面写代码时才有依据。
2.1 生成多项式与位宽:0x8005 和 0x1021 差在哪
多项式决定寄存器的反馈抽头。工程文档里写的都是省略最高位的简写形式:CRC-16 的多项式 0x8005 展开是 x¹⁶ + x¹⁵ + x² + 1,CRC-16 的另一支 0x1021 展开是 x¹⁶ + x¹² + x⁵ + 1。位宽决定寄存器宽度,也决定掩码和移位量——16 位算法掩码是 0xFFFF,32 位就是 0xFFFFFFFF。
选型时别自己发明多项式。多项式直接决定检错能力:能检出全部单比特和双比特错误,能检出长度不超过位宽的突发错误,奇数个比特错在带 (x+1) 因子的多项式下也能检出。常见做法是直接抄标准表里的组合,跟对端一致比「更优」重要得多。真要在新协议里自选,CRC-32C 那一支在通用 CPU 上有专门指令支持,吞吐能拉开一个数量级,这是少数值得主动挑的场景。
2.2 初值、反射、异或输出:把四个开关摆到台面上
初值的作用是避免「数据前面一串 0」时校验值恒为 0。全 0 初值对前导零不敏感,全 1 初值(0xFFFF、0xFFFFFFFF)能把这段空白区分开,所以很多现场总线协议都用全 1。
反射(refin/refout)是最容易翻车的一项。串口是低位先发,硬件移位寄存器天然按 LSB 方向走,算法描述里为了省事就规定「输入字节反转」「输出寄存器反转」。两个开关大多数时候同开同关,但标准表里确实存在只反转一半的组合,比如 CRC-16/IBM-SDLC 就是反射 + 输出异或 0xFFFF。异或输出(xorout)则是为了把「全零数据算出全零校验」这种情况掰开,常见取值 0x0000 和 0xFFFF、0xFFFFFFFF。
| 算法 | 宽度 | poly | init | refin/refout | xorout | check("123456789") |
|---|---|---|---|---|---|---|
| CRC-8 | 8 | 0x07 | 0x00 | false | 0x00 | 0xF4 |
| CRC-16/ARC | 16 | 0x8005 | 0x0000 | true | 0x0000 | 0xBB3D |
| CRC-16/MODBUS | 16 | 0x8005 | 0xFFFF | true | 0x0000 | 0x4B37 |
| CRC-16/CCITT-FALSE | 16 | 0x1021 | 0xFFFF | false | 0x0000 | 0x29B1 |
| CRC-16/XMODEM | 16 | 0x1021 | 0x0000 | false | 0x0000 | 0x31C3 |
| CRC-16/IBM-SDLC | 16 | 0x1021 | 0xFFFF | true | 0xFFFF | 0x906E |
| CRC-32/ISO-HDLC | 32 | 0x04C11DB7 | 0xFFFFFFFF | true | 0xFFFFFFFF | 0xCBF43926 |
| CRC-32C | 32 | 0x1EDC6F41 | 0xFFFFFFFF | true | 0xFFFFFFFF | 0xE3069283 |
这张表建议直接抄进项目里的参数定义文件,别放在脑子里。参数只差一项,结果就完全对不上,而肉眼看十六进制很难发现少了哪个 F。
2.3 已知答案向量:用九个字节反查自己的参数
标准测试串是 ASCII 的123456789,九个字节覆盖了 0x31 到 0x39 九个不同位型,几乎能把参数错误全部暴露出来。上面那张表的最后一列就是各家标准公布的 check 值,行业里叫 check 或 residue,是比对参数的唯一硬标准。
手上的现场资料往往只有一句「CRC16」,既没写多项式也没写初值。这时候的排查思路是反查而不是猜:拿一段确定的报文和它对应的两个字节校验值,把表里八组参数挨个代进去跑,跑出匹配的就锁定。如果一段都对不上,先怀疑校验范围——是不是把帧头排除了,是不是把校验字段自己也算进去了。
提示:设备手册写「CRC16 校验」时,八成指的是 CRC-16/MODBUS(poly 0x8005、init 0xFFFF、反射、xorout 0),但工业现场确实存在只写「CRC16」却用 CCITT 参数的老设备。别默认,先拿单帧数据验一遍。
3. 在 C# 里落地:三种实现怎么选,从三十行到一张表
C# 做 CRC 有三个层次的实现:逐位算、查表算、以及按吞吐需求再往上走的分片与硬件指令。逐位版不是给生产环境用的,它是标尺——其他实现算错了,拿它一比就知道错在哪。查表版是绝大多数上位机和网关的落点,一张 256 项的静态表,单字节成本从八次循环降到一次查表加一次异或。
3.1 逐位实现:三十行代码当标尺
参数模型的第一个好处是逐位实现可以写成通用的,不用为每个算法复制一份。下面这版用ulong承载寄存器,靠Width动态算掩码,8 到 64 位通吃。
// CrcParameters.cs —— 把 CRC 的五个参数当成数据,而不是当成代码 public sealed record CrcParameters( string Name, int Width, // 位宽:8 / 16 / 32 / 64 ulong Poly, // 生成多项式,省略最高位的 1,例如 0x8005 ulong Init, // 寄存器初值 bool RefIn, // 输入字节是否按位反转 bool RefOut, // 输出寄存器是否按位反转 ulong XorOut); // 结果异或值public static class CrcBitwise { public static ulong Compute(ReadOnlySpan<byte> data, CrcParameters p) { ulong mask = p.Width == 64 ? ulong.MaxValue : (1UL << p.Width) - 1; ulong top = 1UL << (p.Width - 1); ulong crc = p.Init & mask; foreach (byte b in data) { // 反射算法等价于把每个输入字节先倒过来,再走 MSB-first 主循环 byte cur = p.RefIn ? Reverse8(b) : b; crc ^= (ulong)cur << (p.Width - 8); for (int i = 0; i < 8; i++) { crc = (crc & top) != 0 ? ((crc << 1) ^ p.Poly) & mask : (crc << 1) & mask; } } if (p.RefOut) crc = Reverse(crc, p.Width); return (crc ^ p.XorOut) & mask; } private static byte Reverse8(byte v) { v = (byte)(((v & 0xF0) >> 4) | ((v & 0x0F) << 4)); v = (byte)(((v & 0xCC) >> 2) | ((v & 0x33) << 2)); return (byte)(((v & 0xAA) >> 1) | ((v & 0x55) << 1)); } private static ulong Reverse(ulong v, int width) { ulong r = 0; for (int i = 0; i < width; i++) { r = (r << 1) | (v & 1); v >>= 1; } return r; } }关键点有三个。mask保证寄存器不会越界,16 位算法算完必须截断到 0xFFFF,否则高位的脏数据会一直往上溢。top是判断「最高位是否为 1」的探针,等价于crc & 0x8000这种写死位宽的写法,但能跟着位宽走。反射处理放在进循环之前和出循环之后,中间主循环永远按 MSB-first 跑——这是 Rocksoft 模型的标准形式,好处是同一段代码能吃下反射和不反射两套参数。
这段代码慢,16 位算法一个字节要跑八次移位加判断。但它的价值在于可读性和正确性,任何新参数组合先在这里跑通 check 值,再去优化。
3.2 查表实现:0xA001 这张表是怎么生成的
查表法的思路是把「一个字节的八次迭代」提前算好存起来。反射算法的表用反射多项式生成,CRC-16/0x8005 反射之后就是 0xA001——Modbus 圈子里到处出现的那个魔数,就是从这来的。CRC-32 的 0x04C11DB7 反射之后是 0xEDB88320,同样眼熟。
// 反射版查表:适用于 RefIn == RefOut == true 的算法(Modbus、CRC-32/ISO-HDLC) private static ushort[] BuildReflectedTable16(ushort polyReflected) { var table = new ushort[256]; for (int i = 0; i < 256; i++) { ushort crc = (ushort)i; for (int j = 0; j < 8; j++) crc = (crc & 1) != 0 ? (ushort)((crc >> 1) ^ polyReflected) : (ushort)(crc >> 1); table[i] = crc; } return table; } // 0x8005 反射后是 0xA001 private static readonly ushort[] Table16 = BuildReflectedTable16(0xA001); public static ushort ComputeModbus(ReadOnlySpan<byte> data) { ushort crc = 0xFFFF; // init foreach (byte b in data) crc = (ushort)((crc >> 8) ^ Table16[(crc ^ b) & 0xFF]); return crc; // xorout = 0x0000,不用再异或 }(crc ^ b) & 0xFF取的是「当前寄存器低字节与输入字节异或」的结果,作为表的索引;crc >> 8把高字节降到低位,再与表项异或。这个更新式只对反射算法成立,不反射的算法是crc = (crc << 8) ^ table[((crc >> 8) ^ b) & 0xFF],方向完全相反,这点下一章会展开说。
性能账很好算:256 项的 ushort 表占 512 字节,常驻 L1 缓存毫无压力;单字节从八次循环变成一次查表,实测在普通 x64 上快 5 到 8 倍。表用static readonly加静态构造函数生成,避免多个线程同时初始化写出半张表。如果项目对启动时间敏感,也可以把 256 个值直接写死成数组字面量,代价是代码难看。
3.3 参数表驱动的统一引擎:一次封装覆盖全部变体
真实项目很少只用一个 CRC。设备 A 走 Modbus,设备 B 的私有协议用 CCITT,日志文件用 CRC-32,配置文件用 CRC-8。如果每种都写一个类,维护成本会失控。做法是把参数当数据注册进去,引擎按名字取。
public static class Crc { private static readonly Dictionary<string, CrcParameters> Registry = new() { ["MODBUS"] = new("CRC-16/MODBUS", 16, 0x8005, 0xFFFF, true, true, 0x0000), ["CCITT-FALSE"] = new("CRC-16/CCITT-FALSE", 16, 0x1021, 0xFFFF, false, false, 0x0000), ["XMODEM"] = new("CRC-16/XMODEM", 16, 0x1021, 0x0000, false, false, 0x0000), ["IBM-SDLC"] = new("CRC-16/IBM-SDLC", 16, 0x1021, 0xFFFF, true, true, 0xFFFF), ["CRC32"] = new("CRC-32/ISO-HDLC", 32, 0x04C11DB7, 0xFFFFFFFF, true, true, 0xFFFFFFFF), ["CRC32C"] = new("CRC-32C", 32, 0x1EDC6F41, 0xFFFFFFFF, true, true, 0xFFFFFFFF), }; public static ulong Of(string name, ReadOnlySpan<byte> data) => CrcBitwise.Compute(data, Registry[name]); }Registry的键用短名而不是标准全名,是因为现场调试时经常要在日志里打算法名,短名更容易塞进固定宽度的字段。参数对象是不可变的record,注册之后没人能改,省掉一类「谁把初值改了」的排查。
这里有个取舍要讲清楚:统一引擎走的是ulong通用路径,比专用的ushort查表版慢一截,大概两三倍。对每秒几十帧的串口通讯完全够用,对文件校验这种几十兆的输入就不合适了。我的习惯是通用路径做正确性基准和低频场景,热点路径单独写专用实现,两者用同一组 check 值互相验证。
3.4 分片与硬件加速:什么量级才值得上
数据量上了兆字节,才轮到分片查表(slicing-by-8/16)出场。原理是把一张表扩成八张,一次吞八个字节,吞吐能再提三到四倍,代价是表从 512 字节涨到 4KB,还在 L1 范围内。
// slicing-by-8 的表构造:T[0] 是普通表,T[k] 由 T[k-1] 右移八位再查 T[0] 得到 private static uint[][] BuildSlicing8(uint polyReflected) { var t = new uint[8][]; t[0] = new uint[256]; for (uint i = 0; i < 256; i++) { uint crc = i; for (int j = 0; j < 8; j++) crc = (crc & 1) != 0 ? (crc >> 1) ^ polyReflected : crc >> 1; t[0][i] = crc; } for (int k = 1; k < 8; k++) { t[k] = new uint[256]; for (int i = 0; i < 256; i++) t[k][i] = (t[k - 1][i] >> 8) ^ t[0][t[k - 1][i] & 0xFF]; } return t; }主循环一次读四字节异或进寄存器,再拿八张表拼出新的寄存器值,尾部不足八字节的退回单字节循环。这段代码写起来不难,难在验证——必须保证任意长度输入都和逐位版结果一致,包括 0 字节、1 字节、7 字节这些边界。
再往上就是硬件指令:较新的运行库里自带的 Crc32 类型已经用上了 SSE4.2 的 CRC32 指令和 ARM 的 CRC 扩展,几 GB/s 的量级,代价是只支持固定那几种参数。判断标准很简单:单次校验超过 64KB 且每秒调用次数上千,才值得为它引入硬件路径;否则查表版完全够。
4. 接进串口报文:校验范围、字节序和增量计算
算法写对只是前半程,真正让人熬夜的是「算法没错但校验值对不上」。这一层的问题不在 CRC 内部,而在调用方——算哪些字节、字节怎么排、分包怎么累积,三件事任意一件错位,结果都表现为「校验失败」。
4.1 校验范围:从哪个字节算到哪个字节
Modbus RTU 的规则是从站地址开始算到数据域最后一个字节,功能码算在内,CRC 字段本身不算。这条规则听起来明确,实际上有两个高频误解:一是以为从功能码开始(把地址排除了),二是异常响应时以为格式不同(异常响应是地址 + 功能码|0x80 + 异常码,照样三个字节全算)。
| 场景 | 参与计算的字节 | 校验字段位置 | 备注 |
|---|---|---|---|
| Modbus RTU 请求 | 从站地址到数据域末字节 | 帧尾 2 字节,低字节在前 | 功能码在内 |
| Modbus RTU 响应 | 从站地址到数据域末字节 | 帧尾 2 字节,低字节在前 | 异常响应同样三段 |
| Modbus ASCII | 不用 CRC,用 LRC | 帧内,CRLF 之前 | 冒号开头,字符化传输 |
| Modbus TCP | 不用 CRC | 无校验字段 | 交给传输层 |
| 私有串口协议 | 通常为帧头之后到载荷结束 | 帧尾 | 帧头、长度域是否参与必须跟对端逐字节对齐 |
私有协议这一行是重点。做过一个项目,对端把两字节长度域排除在 CRC 之外,文档里没写,只能拿三段报文反推——分别试「含长度域」「不含长度域」「含帧头不含长度域」三种,只有一种能连中三帧。这种反推比读文档快。
4.2 字节序:寄存器大端,CRC 低字节在前
Modbus 里有两套字节序同时存在,这是新手最容易混淆的地方。寄存器地址和数据是高字节在前(大端),CRC 是低字节在前。同一个0xCDC5,寄存器字段要写成CD C5,CRC 字段要写成C5 CD。
using System.Buffers.Binary; // 组一帧 Modbus RTU 读保持寄存器请求:01 03 00 00 00 0A + CRC byte slave = 0x01, func = 0x03; ushort start = 0x0000, count = 0x000A; var frame = new byte[8]; frame[0] = slave; frame[1] = func; BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(2, 2), start); // 寄存器字段:大端 BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(4, 2), count); ushort crc = Crc.ComputeModbus(frame.AsSpan(0, 6)); // 只算前 6 字节 BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(6, 2), crc); // CRC 字段:小端 // 结果:01 03 00 00 00 0A C5 CD用BinaryPrimitives而不是手写(byte)(x >> 8),好处是字节序写在方法名里,半年后回看不用重新推一遍。WriteUInt16BigEndian和WriteUInt16LittleEndian在同一段代码里并排出现,谁大谁小一眼能看出来,这比注释靠谱。
解析回包时反过来:先把前len - 2字节算一遍 CRC,再读末尾两字节做小端解析,两者比较。别直接拿收到的字节和算出来的 ushort 比,先把它们拉成同一个字节序再比,否则 0xCDC5 和 0xC5CD 会一直不相等而你看不出原因。
4.3 拼帧与拆帧:Span、BinaryPrimitives 和增量计算
串口读上来的不是一个完整帧,是若干碎片。常见做法是维护一个缓冲区,按协议的长度域或固定帧长切分,切出完整帧再算 CRC。这里有个隐蔽的坑:如果每收到一块就对整块算一次 CRC,那算的是「这一块」的校验,不是「整帧」的。分包到达时结果必然错。
正确的做法是让 CRC 状态跟着帧走,分块喂进去,最后取一次值。
// 流式 CRC:状态保存在实例里,适合分包或大文件分块读取 public sealed class Crc16ModbusStream { private const ushort Init = 0xFFFF; private ushort _crc = Init; public void Append(ReadOnlySpan<byte> chunk) { foreach (byte b in chunk) _crc = (ushort)((_crc >> 8) ^ Table16[(_crc ^ b) & 0xFF]); } // 取最终值时才做 xorout;本例 xorout 为 0,直接返回 public ushort Value => _crc; public void Reset() => _crc = Init; }Reset()不能省。粘包场景下,一帧解析失败要丢掉缓冲区重新同步,这时候 CRC 状态必须跟着清掉,否则下一帧会带着上一帧的残留计算,出现「偶发错、重发就好」的玄学。Value做成属性而不是方法,是为了强调它没有副作用——多次读取结果一致,不会因为多读一次就把状态改掉。
4.4 自测:把已知答案写进单元测试
CRC 这类纯函数最适合用已知答案向量锁死。代码库一进来第一件事就是把 2.3 节那张表的最后一列抄成断言,八条用例跑通,后面怎么重构都有底气。
[Theory] [InlineData("MODBUS", 0x4B37UL)] [InlineData("CCITT-FALSE", 0x29B1UL)] [InlineData("XMODEM", 0x31C3UL)] [InlineData("IBM-SDLC", 0x906EUL)] [InlineData("CRC32", 0xCBF43926UL)] [InlineData("CRC32C", 0xE3069283UL)] public void CheckValue_Matches(string algo, ulong expected) { var data = Encoding.ASCII.GetBytes("123456789"); Assert.Equal(expected, Crc.Of(algo, data)); }123456789这九个字节要按 ASCII 编码,别用Encoding.UTF8顺手带过——虽然这九个字符 UTF-8 编出来和 ASCII 一样,但习惯一旦养成,换成中文测试串就会踩编码坑。断言写全六种算法而不是只写当前用的那一种,是为了将来加参数时有个回归网。
注意:check 值表里的数字是十六进制的,写成
0x4B37而不是4B37。曾经有人把0x906E写成十进制906,排查了两小时才发现是断言写错了而不是算法错了。
5. 排查:CRC 对不上的五类现场
这一类问题有个共同特征:代码看着没错,结果就是不对,而且往往是「有时候对有时候不对」。下面五条是从实际项目里攒出来的,按出现频率排,每条都按现象、原因、解决三段写,遇到对不上的时候从上往下过一遍,基本能收敛。
5.1 现象:同一串数据,两台机器算出不同值
现象很典型:本机算出来一个值,对端设备日志里是另一个值,两边都声称自己用的是「标准 CRC16」。
原因基本是参数不同。同一个 0x1021,init 取 0x0000 是 XMODEM,取 0xFFFF 是 CCITT-FALSE,再叠上反射和 xorout 还能再分出 IBM-SDLC,一共四种结果。文档只写「CRC16」的时候,光看名字无法确定。
解决方式是把 2.2 节那张表当排查工具用:拿一段确定的报文和它对应的校验值,把表里所有 16 位组合挨个代进去,看哪个能命中。命中之后把参数记进项目的CrcParameters注册表,注释里写上「对端设备型号 + 手册页码」,下次换人接手不用再推一遍。
5.2 现象:校验值长度对、位置对,但每一位都是错的
现象是能算出一个 16 位的值,长度、位置、字节序都正确,就是跟对端不一致,而且错得很「彻底」——不是差一两位,是整体不相关。
原因是查表方向和算法方向反了。反射算法必须配反射表,更新式是右移;不反射的算法配正向表,更新式是左移。这两种表长得一模一样(都是 256 项 ushort),混用不会报错,只会算出错误结果。血泪经验:混用之后 check 值一定对不上,所以单元测试是唯一可靠的识别手段。
解决方式是让表的生成跟着参数走——传进来的poly和refin一起决定用哪个构造函数,别手工写BuildReflectedTable16(0xA001)这种硬编码。表建成static readonly,配一个Debug.Assert(Crc.Of("MODBUS", "123456789"u8) == 0x4B37)在启动时自检,错了直接崩,别让它带着错跑。
5.3 现象:短帧全对,超过某个长度就开始错
现象很有意思:三五字节的小帧校验一直正确,一旦报文超过 8 字节或者 16 字节,校验值就开始飘,而且不是随机错,是同一种长度的帧稳定错。
原因通常是查表实现在处理尾部时下标越界或漏算。比如分片循环按 8 字节步进,剩下的 1 到 7 字节忘了退回单字节循环;或者Span切片时用了data.Slice(i, 8)而不是data.Slice(i),最后一块不足 8 字节直接抛异常被上层吞掉,返回了默认值 0。
解决方式是补边界测试:长度为 0、1、7、8、9、15、16、17 的输入全部跑一遍,跟逐位实现对比。逐位实现慢但绝对可靠,是这类问题的后悔药。另外Span上的循环别用i < data.Length配合内部再切 8 字节,用i + 8 <= data.Length
本文还有配套的精品资源,点击获取