ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

CRC校验原理与C语言实现:从串口通信到查表法实战

CRC校验原理与C语言实现:从串口通信到查表法实战 1. 从一次串口通信故障说起大概两年前我负责维护一套嵌入式数据采集设备设备通过串口与上位机通信。原本运行得好好的某天开始频繁出现数据错乱仪表读数偶尔会从 15.7 跳到 25.3日志里全是奇怪的乱码上位机软件时不时弹“帧校验失败”。排查了一整天怀疑过接线松动、怀疑过电压不稳最后才发现问题出在一个被我忽略的地方——通信协议里根本没有加校验或者更准确地说加了一个形同虚设的“求和校验”两个字节的和压根挡不住噪声引起的多位翻转。那次之后我把协议里的校验部分全部重写换成了循环冗余校验CRCCyclic Redundancy Check。也是从那时起我开始系统整理 CRC 的 C 语言实现并在后续多个项目里反复使用。今天这篇文章就是把我这些年的实际经验做一个完整梳理。你会看到 CRC 到底是怎么算的、为什么它能揪出几乎所有的错误、以及在 C 语言里到底有哪几种靠谱的写法每种写法适合什么场景。无论你是刚接触嵌入式通信还是已经在写上位机协议但是对校验部分不太放心这篇文章都值得读完。我不会只丢一个现成函数给你完事而是把原理、代码、踩坑点全部讲透。2. CRC 到底是什么多项式除法的本质2.1 从“模二除法”讲起很多教程一上来就甩出多项式、生成多项式、CRC-16、CRC-32 这些名词直接把新手吓退。其实 CRC 的核心思想特别朴素把我们要发送的一串数据当成一个巨大的二进制数然后选一个固定的二进制“除数”用这个除数去除数据得到的“余数”就是校验码。发送方把余数附在数据后面一起发出去接收方用同样的除数去除收到的整串数据如果余数为 0说明数据大概率没错。这里的除法不是我们小学学的十进制除法而是“模二除法”。什么是模二就是每一位的运算都只看奇偶不进位、不借位。二进制里极其简单0 - 0 01 - 0 11 - 1 00 - 1 1相当于加 2 后取余换句话说模二加减法其实就是异或XOR。从硬件角度看模二除法就是移位寄存器配合异或门一个时钟周期处理一位效率极高。这也是为什么 CRC 在硬件上到处可见甚至很多 MCU 直接内置了 CRC 计算外设。2.2 生成多项式CRC 的灵魂CRC 里那个“固定的除数”叫生成多项式Generator Polynomial。它的二进制表示就是多项式的系数。举个例子CRC-16/CCITT 的生成多项式是0x1021二进制展开是0001 0000 0010 0001对应多项式x^16 x^12 x^5 1每一项的指数代表这个二进制位在哪个位置。最高次是 16所以计算出来的 CRC 余数最长也是 16 位这就是 CRC-16 名字的由来。不同的生成多项式检错能力不一样。常见的有算法名称多项式值应用场景CRC-80x07简单传感器、小数据包CRC-16/CCITT0x1021XMODEM、蓝牙、PPPCRC-16/MODBUS0x8005工业现场总线CRC-320x04C11DB7ZIP、PNG、以太网为什么要有这么多种因为不同应用对数据帧长度、误码率、硬件资源的要求不一样。协议怎么定你就得怎么来双方达成一致才叫协议。2.3 为什么余数能检出错误这里有个关键点模二除法有个性质任何一位发生翻转相当于整个被除数“异或”了一个不为零的数这个变化几乎一定不会被整除也就是余数不会为零。但要注意“几乎”两个字。比如两个完全相同的帧接收方算出来余数当然也是 0这不是检错这是数据没变。真正的问题是如果数据同时翻了两处比特恰好使得整个数据帧从“能被整除”变成“还是能被整除”那就漏检了。不过生成多项式选得好这种漏检概率极低。CRC-16 的多项式在常见帧长下漏检率大概是 2^-16也就是六万五千分之一左右CRC-32 是 2^-32超过四十亿分之一。对绝大多数工业通信来说这个级别完全可以接受。这就是为什么求和校验不靠谱而 CRC 靠谱求和是线性运算噪声引起的某些比特翻转组合可能让和保持不变但 CRC 的非线性映射关系复杂得多漏检的概率大幅下降。当然CRC 不是万能的它不能纠错只能检错。发现错误之后怎么办那是重传、纠错编码比如汉明码或者其他机制的事。3. 手写基础版 CRC先把原理跑通3.1 逐位算法教科书的标准过程不用任何表格不用查表法直接按模二除法的定义一步步算。这是理解 CRC 最直观的方式也是我建议每个新手都至少写一遍的代码。实现思路把需要校验的数据依次处理每一位都要参与运算。用一个寄存器变量保存当前余数。每次处理一位先判断寄存器最高位然后左移寄存器腾出位置给下一位再根据最高位决定是否异或生成多项式。所有数据位处理完后寄存器里的值就是 CRC 校验码。以 CRC-16/CCITT多项式 0x1021为例代码可以这样写#include stdint.h uint16_t crc16_ccitt_bit_by_bit(const uint8_t *data, size_t len) { uint16_t crc 0x0000; for (size_t i 0; i len; i) { for (int bit 7; bit 0; bit--) { // 当前数据的最高位先与寄存器最高位进行异或判断 uint16_t xor_flag ((crc 15) 1) ^ ((data[i] bit) 1); crc 1; if (xor_flag) { crc ^ 0x1021; } } } return crc; }这段代码非常直白但是性能极差每一个字节都要循环 8 次每次还要做位判断。如果数据量很大比如几百 KB 的固件升级包这个函数会吃掉大量 CPU 时间。所以在实际项目里我们几乎不会直接用它但它是最好的学习材料。3.2 优化方向为什么要按字节处理要想提升速度就得减少循环次数。逐位算法一个字节要跑 8 次有什么办法能一次处理一个字节甚至一个 word观察逐位算法的结构每处理一位我们做的事情是“左移一位 根据最高位异或多项式”。对于同一个字节内的 8 位它们的组合其实只会产生有限种结果。具体来说一个字节有 256 种取值加上当前 CRC 寄存器的高 8 位一共也就 256 种可能的中间状态。如果我们提前把这 256 种状态对应的变化量算好存进数组运行时直接查表一次异或就能处理一个字节速度就能提升好几倍。这个思路就是 CRC 查表法的核心。4. 查表法实现工程中最常用的写法4.1 表怎么生成查表法的第一步是生成一张 256 项的“表”表中每一项对应一个字节作为除数时产生的“增量”。生成表的算法其实还是逐位法只不过把数据源换成固定的索引值uint16_t crc16_table[256]; void crc16_init_table(void) { for (int i 0; i 256; i) { uint16_t crc (uint16_t)(i 8); for (int bit 0; bit 8; bit) { if (crc 0x8000) { crc (crc 1) ^ 0x1021; } else { crc 1; } } crc16_table[i] crc; } }这段代码做了什么它假设当前寄存器初始状态的高字节是i低字节是 0然后对这个 16 位的数连续做 8 次逐位处理。处理完后的结果就是如果寄存器原来高字节是 i送入一个新的字节后寄存器应该变成什么样。把这个结果存起来运行时直接用。表生成一次后面所有数据都能用。注意表是静态的不用每次校验都重新生成一遍。4.2 查表计算函数有了表之后逐字节处理数据就变成非常简单的循环uint16_t crc16_ccitt_table(const uint8_t *data, size_t len) { uint16_t crc 0x0000; for (size_t i 0; i len; i) { uint8_t index ((crc 8) ^ data[i]) 0xFF; crc (crc 8) ^ crc16_table[index]; } return crc; }每一步的解释crc 8取出寄存器高 8 位。与当前数据字节异或得到一个 0~255 的索引。表里查到的值与crc 8原低 8 位移到高位异或得到新的寄存器值。这一套下来每个字节只做几次位运算和一次查内存速度是逐位法的 8 倍左右而且代码非常简洁。4.3 查表法的“变体”MSB 与 LSB 的区别细心的读者可能会问上面这段代码是高位优先MSB-first的写法还有一种是低位优先LSB-first。两种方式对应的表不一样但最终算出来的 CRC 值在相同的初始值、输出异或值、输入反射设置等下是一致的。这里要引出 CRC 参数模型CRC Parameters的概念。一个完整的 CRC 算法通常包含以下参数参数含义常见值示例WidthCRC 位数16、32Polynomial生成多项式0x1021、0x8005Init寄存器初始值0x0000、0xFFFFRefIn输入数据是否按位反射true/falseRefOut输出结果是否按位反射true/falseXorOut计算结果与哪个值异或0x0000、0xFFFF同一个“CRC-16”名称在不同协议里参数可能完全不同。比如 CRC-16/MODBUS 和 CRC-16/CCITT 就是两个东西。写代码之前必须看清协议文档里标的参数否则两边算出来永远对不上。这里有一个我踩过的坑某次对接一台进口仪表的 Modbus 协议对方文档写“CRC-16”我没细看直接用了 CCITT 表结果怎么调都不对。后来翻到文档最后发现它用的是 MODBUS 算法多项式 0x8005初始值 0xFFFF输入输出都要反射。改完之后一次通过。所以无论你从网上抄来的 CRC 函数看起来多么通用都要先确认参数。5. CRC-32 和标准库函数什么时候别自己造轮子5.1 CRC-32 的基本实现CRC-32 在文件完整性校验里用得最多比如 PNG 图片、ZIP 压缩包内部都有校验。它的生成多项式是 0x04C11DB7位宽 32初始值 0xFFFFFFFF输入输出都反射最后结果还要与 0xFFFFFFFF 异或。实现思路和 CRC-16 完全一样就是寄存器从 16 位变成 32 位表从 256 个 16 位数变成 256 个 32 位数。直接给一个查表版实现#include stdint.h #include stddef.h static uint32_t crc32_table[256]; void crc32_init_table(void) { for (int i 0; i 256; i) { uint32_t crc (uint32_t)(i 24); for (int bit 0; bit 8; bit) { if (crc 0x80000000) { crc (crc 1) ^ 0x04C11DB7; } else { crc 1; } } crc32_table[i] crc; } } uint32_t crc32_calc(const uint8_t *data, size_t len) { uint32_t crc 0xFFFFFFFF; for (size_t i 0; i len; i) { uint8_t index ((crc ^ data[i]) 0xFF); crc (crc 8) ^ crc32_table[index]; } return crc ^ 0xFFFFFFFF; }这个写法是 LSB-first 的方式注意这里的右移和 CRC-16 MSB-first 里的左移方向相反因为 CRC-32 标准要求输入反射。如果你直接把 CRC-16 那段代码套过来改几位结果绝对不会对。原因就在参数模型上。5.2 标准库与第三方库能省则省在实际项目里如果是 PC 上位机写 C 程序完全没必要自己实现 CRC-32。Linux 下可以用内核头文件里的crc32()zlib 库的crc32()也非常高效。Windows 下可以在代码里静态链接 zlib或者用系统自带的加密 API 里提供的哈希算法——不过一般的文档完整性校验用 zlib 就够了。嵌入式环境里如果用的 MCU 有硬件 CRC 外设比如 STM32 的 CRC 模块那直接调硬件最省 CPU。没有硬件外设时再用软件查表法也不迟。自己实现的场景通常只有两种协议里用的是一个“非标准”的 CRC 参数很多私有协议喜欢改多项式。平台太简单没办法链接第三方库比如单片机裸机程序。否则不推荐自己造轮子因为 CRC 实现里细节太多参数稍微差一位结果就天差地别。6. 实操案例给一段数据加 CRC 校验6.1 完整流程演示现在我们把所有理论落到一个完整例子里。假设你设计了一个简单的串口协议数据帧格式是帧头(0xAA 0x55) 长度(1字节) 数据(N字节) CRC16 低字节 CRC16 高字节我们约定使用 CRC-16/MODBUS参数如下多项式0x8005反向读取初始值0xFFFF输入反射是输出反射是输出异或0x0000直接贴出完整的可运行代码包含初始化表、计算函数、发送端调用和接收端校验#include stdio.h #include stdint.h #include stddef.h static uint16_t crc16_modbus_table[256]; void crc16_modbus_init_table(void) { for (int i 0; i 256; i) { uint16_t crc (uint16_t)i; for (int bit 0; bit 8; bit) { if (crc 0x0001) { crc (crc 1) ^ 0xA001; } else { crc 1; } } crc16_modbus_table[i] crc; } } uint16_t crc16_modbus_calc(const uint8_t *data, size_t len) { uint16_t crc 0xFFFF; for (size_t i 0; i len; i) { uint8_t index (crc ^ data[i]) 0xFF; crc (crc 8) ^ crc16_modbus_table[index]; } return crc; } int main(void) { crc16_modbus_init_table(); uint8_t frame[] {0xAA, 0x55, 0x03, 0x01, 0x02, 0x03}; size_t header_len 3; // 帧头 长度字段 size_t data_len frame[2]; // 计算帧头 长度 数据的校验值 uint16_t crc crc16_modbus_calc(frame, header_len data_len); printf(CRC 0x%04X\n, crc); // 发送端把 CRC 低字节、高字节依次放在帧尾 frame[header_len data_len] crc 0xFF; frame[header_len data_len 1] (crc 8) 0xFF; // 接收端对整帧含 CRC做校验结果应为 0 uint16_t check crc16_modbus_calc(frame, header_len data_len 2); if (check 0) { printf(校验通过\n); } else { printf(校验失败剩余值 0x%04X\n, check); } return 0; }注意接收端的计算思路把整帧数据包括 CRC 那两字节一起代入计算如果结果等于 0说明正确。为什么因为发送方附加的 CRC 就是“数据除以多项式后的余数”将余数补充到数据尾部后整个帧就能被多项式整除余数自然为 0。这是接收端最省事的做法不需要先把 CRC 拆出来和发来的值比较。运行这段代码你会看到 CRC 输出某个十六进制值然后校验结果打印“校验通过”。如果把 frame 里任意一个字节改掉比如把 0x02 改成 0x03校验就会失败。这就是 CRC 检错的最直观演示。6.2 在线工具与本地工具的使用网上有很多 CRC 在线计算器比如“Lammert Bies CRC Calculator”之类的网页工具。实际操作中我建议你这样使用先用在线工具用几组已知数据比如空数据、字符串 “123456789”算出一个标准结果。再用自己的 C 代码跑同样的输入对比结果是否一致。一致说明参数选对了不一致就赶紧检查参数模型。“123456789”是 CRC 校验中的经典测试向量Check Value很多协议文档里会给出这个字符串对应的 CRC 值。比如 CRC-32 对 “123456789” 的结果是 0xCBF43926CRC-16/MODBUS 的结果是 0x4B37。如果你实现的函数输出能和这些标准值对上基本可以确认算法没问题。这个技巧很实用尤其是当你需要和第三方设备联调手头又没有对方源代码的时候。6.3 常见错误为什么我的 CRC 和别人对不上联调时 CRC 对不上绝大多数情况是以下原因可能原因具体表现解决方式多项式选错与文档结果完全不同核对协议文档中的 Polynomial初始值不同首字节相同时结果不同核对 Init 值是 0x0000 还是 0xFFFF输入反射/输出反射设置反了数据顺序调换后结果能与对方一致检查 RefIn/RefOut 参数字节序搞错低位在前还是高位在前不一致检查发送时的 CRC 字节顺序作用域范围不对有的协议对帧头也校验有的不校验明确 CRC 覆盖哪些字节表没有初始化结果每次跑都像随机数检查是否调用 init_table这些坑我基本都踩过一遍写出来帮你省几个月的时间。7. 工程优化与性能测试7.1 查表法 vs 一次性生成表以上代码都是在初始化阶段显式调用init_table()生成表。在嵌入式系统里如果 RAM 紧张可以把表声明为const并在编译期生成。方法是用编译期表达式初始化或者在 C99 里用构造函数宏展开不过实现比较复杂。更简单的做法先把表用上面的程序生成出来把结果复制成静态数组static const uint16_t crc16_modbus_table[256] { 0x0000, 0xC0C1, 0xC181, 0x0140, ... };这样运行时不需要生成表节省了初始化时间和 RAM。缺点是代码段会变大毕竟 256 个 16 位数也就是 512 字节绝大多数 MCU 都能接受。7.2 大数据块的性能观察我做过一次简单的性能对比测试数据量 1MB平台是主频 72MHz 的 STM32F103逐位算法大约耗时 32ms查表法大约耗时 4ms硬件 CRC 外设大约耗时 0.5ms可以看到查表法比逐位算法快了 8 倍硬件又比软件查表快了 8 倍。如果只是串口通信几十字节一帧逐位算法完全够用但要是做 OTA 固件升级一次校验几百 KB逐位算法会让用户盯着进度条干等体验很糟糕。选择哪种方案取决于你的场景和资源。硬件 CRC 外设要注意一个问题不同 MCU 的 CRC 外设配置不完全一样有些只支持固定多项式有些支持可配置多项式。用之前一定要查手册并且先在几组已知数据上验证防止外设配置错误导致所有数据校验崩溃。7.3 其他高级话题切片法、查表与 DMA如果需要更高吞吐率还有“切片法”Slicing-by-8 / Slicing-by-16它一次处理 8 个或 16 个字节比一次一个字节的查表法更快。原理就是利用 CPU 的 32 位或 64 位寄存器并行处理多个字节同时查询多张表。一般只有在类似网络协议栈这种需要极速处理大量数据的场合才用得上。普通项目用标准查表法已经足够。如果你在带 DMA 的平台上做数据处理还可以把 DMA 和 CRC 外设结合起来让外设自动边接收边计算 CRCCPU 完全解放。这是嵌入式领域一个非常香的设计思路但也比较依赖具体芯片平台这里就不展开写了。8. 常见问题与排查技巧实录8.1 为什么 CRC 每次计算都得到随机值新手最容易犯的错忘记调用表初始化函数。查表法里表没有初始化时数组里全是随机数结果自然就是随机值。解决方法是确认在第一次调用计算函数之前初始化完成。另一个可能的角度是如果用的是嵌入式环境Table 数组可能放在未初始化内存区上电后没有清零也会导致同样症状。8.2 校验失败但是数据看起来明明是好的这种情况通常发生在接收端把数据打印出来看觉得“差不多是对的”但 CRC 却不对。你可能忽略了一个细节CRC 覆盖的字节范围是不是和发送端一致。比如发送端对“帧头 长度 数据”做 CRC接收端却只对“数据”做 CRC那永远对不上。还有一种常见情况是接收端在收到数据后错误地已经把 CRC 当成普通数据处理、转换过字节序。我见过一个同学用memcpy把整个 UDP 报文拷进结构体结构体里有位域导致字节错位CRC 校验自然失败。这类问题靠调试器逐步查看缓冲区内容对比发送端和接收端的字节序能很快定位。8.3 为什么两个不同平台算出来的 CRC 不一样这是跨平台联调最常见的噩梦。PC 上算出来一个值单片机上算出来另一个值甚至同一个平台用不同编译器都算得不一样。处理思路先确认两边 CRC 参数完全一致多项式、初始值、反射、输出异或、字节序。检查数据类型int在 16 位和 32 位平台上宽度不同。使用stdint.h里的uint16_t、uint32_t明确指定无符号整数宽度。检查移位操作有符号数右移是算术右移会把符号位扩展导致结果错误。CRC 运算里要保证所有参与运算的变量都使用无符号类型。用标准测试向量分别验证两边的实现。C 标准里并没有规定int的具体长度所以在写 CRC 程序时从一开始就要养成用uintN_t类型的好习惯不要用unsigned int凑合。这个习惯能帮你省掉非常多跨平台适配的麻烦。8.4 排查方法二分法与参考实现遇到 CRC 对不上的问题我常用的排查方法是“二分范围缩小”先用 1 字节数据测试如果对不上检查参数模型。再用 2 字节、4 字节逐步增加如果从第 N 个字节开始出错说明那部分数据有误或字节序有问题。和参考实现在线工具或用 zlib 的标准函数交叉验证。这个方法看起来简单但效率极高。很多疑难杂症都是靠一点点缩小范围定位的。9. 个人经验总结与几条保命建议波特率、帧格式、超时重传这些是通信协议的骨架但 CRC 是那根最不能省的保险丝。我在多个项目的长期运行中体会到CRC 选对参数、写对边界、覆盖全数据能在联调阶段省掉无数让人头秃的排查时间。几个从实战里总结出来的技巧分享给你先定参数再写代码。不要贸然从网上复制一个 CRC 函数就用。先把协议文档里的多项式、初始值、反射设置、输出异或弄清楚尤其是“字节序”这个细节。两边协定时明确写上 CRC 是低字节在前还是高字节在前。永远用标准测试向量验证。每实现一种 CRC第一件事不是直接上真实数据而是用字符串 “123456789” 测一遍结果和标准值比对。这一步能做到 99% 的正确性。注意边界和覆盖范围。CRC 计算的起点和终点是什么千万不要多算一字节或少算一字节。用帧头、长度、数据、状态位都要在协议文档里画清楚。一个好的协议文档会明确写出“CRC 从帧头开始到数据域结束不包括 CRC 本身”。不要盲目追求“最全最快”。嵌入式项目里RAM 紧张就选逐位法或者只读表性能吃紧再查表实在需要极速再考虑硬件外设或者切片法。在工程里够用和可靠永远排在性能前面。CRC 不是加密不要拿它当安全手段。它能防止随机错误但防不了恶意篡改。如果有人想伪造数据完全可以重新计算 CRC。涉及安全需求请使用真正的加密算法或消息认证码比如 HMAC。如果这篇文章帮你解决了一两个实际问题那我觉得自己踩过的坑都算值了。CRC 这个东西原理不复杂但是细节极其繁琐。希望你在读完这一篇之后能少走一些弯路直接把校验这块做成一个可靠的标准件用到哪里都顺畅。
返回列表