简介面向需要在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² 1CRC-16 的另一支 0x1021 展开是 x¹⁶ x¹² x⁵ 1。位宽决定寄存器宽度也决定掩码和移位量——16 位算法掩码是 0xFFFF32 位就是 0xFFFFFFFF。选型时别自己发明多项式。多项式直接决定检错能力能检出全部单比特和双比特错误能检出长度不超过位宽的突发错误奇数个比特错在带 (x1) 因子的多项式下也能检出。常见做法是直接抄标准表里的组合跟对端一致比「更优」重要得多。真要在新协议里自选CRC-32C 那一支在通用 CPU 上有专门指令支持吞吐能拉开一个数量级这是少数值得主动挑的场景。2.2 初值、反射、异或输出把四个开关摆到台面上初值的作用是避免「数据前面一串 0」时校验值恒为 0。全 0 初值对前导零不敏感全 1 初值0xFFFF、0xFFFFFFFF能把这段空白区分开所以很多现场总线协议都用全 1。反射refin/refout是最容易翻车的一项。串口是低位先发硬件移位寄存器天然按 LSB 方向走算法描述里为了省事就规定「输入字节反转」「输出寄存器反转」。两个开关大多数时候同开同关但标准表里确实存在只反转一半的组合比如 CRC-16/IBM-SDLC 就是反射 输出异或 0xFFFF。异或输出xorout则是为了把「全零数据算出全零校验」这种情况掰开常见取值 0x0000 和 0xFFFF、0xFFFFFFFF。算法宽度polyinitrefin/refoutxoroutcheck(123456789)CRC-880x070x00false0x000xF4CRC-16/ARC160x80050x0000true0x00000xBB3DCRC-16/MODBUS160x80050xFFFFtrue0x00000x4B37CRC-16/CCITT-FALSE160x10210xFFFFfalse0x00000x29B1CRC-16/XMODEM160x10210x0000false0x00000x31C3CRC-16/IBM-SDLC160x10210xFFFFtrue0xFFFF0x906ECRC-32/ISO-HDLC320x04C11DB70xFFFFFFFFtrue0xFFFFFFFF0xCBF43926CRC-32C320x1EDC6F410xFFFFFFFFtrue0xFFFFFFFF0xE3069283这张表建议直接抄进项目里的参数定义文件别放在脑子里。参数只差一项结果就完全对不上而肉眼看十六进制很难发现少了哪个 F。2.3 已知答案向量用九个字节反查自己的参数标准测试串是 ASCII 的123456789九个字节覆盖了 0x31 到 0x39 九个不同位型几乎能把参数错误全部暴露出来。上面那张表的最后一列就是各家标准公布的 check 值行业里叫 check 或 residue是比对参数的唯一硬标准。手上的现场资料往往只有一句「CRC16」既没写多项式也没写初值。这时候的排查思路是反查而不是猜拿一段确定的报文和它对应的两个字节校验值把表里八组参数挨个代进去跑跑出匹配的就锁定。如果一段都对不上先怀疑校验范围——是不是把帧头排除了是不是把校验字段自己也算进去了。提示设备手册写「CRC16 校验」时八成指的是 CRC-16/MODBUSpoly 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(ReadOnlySpanbyte 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(ReadOnlySpanbyte 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 Dictionarystring, 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, ReadOnlySpanbyte 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 C5CRC 字段要写成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(ReadOnlySpanbyte 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」。原因基本是参数不同。同一个 0x1021init 取 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, 123456789u8) 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本文还有配套的精品资源点击获取