1. 从一次串口通信故障说起
大概两年前,我负责维护一套嵌入式数据采集设备,设备通过串口与上位机通信。原本运行得好好的,某天开始频繁出现数据错乱:仪表读数偶尔会从 15.7 跳到 25.3,日志里全是奇怪的乱码,上位机软件时不时弹“帧校验失败”。排查了一整天,怀疑过接线松动、怀疑过电压不稳,最后才发现问题出在一个被我忽略的地方——通信协议里根本没有加校验,或者更准确地说,加了一个形同虚设的“求和校验”,两个字节的和,压根挡不住噪声引起的多位翻转。
那次之后我把协议里的校验部分全部重写,换成了循环冗余校验(CRC,Cyclic Redundancy Check)。也是从那时起,我开始系统整理 CRC 的 C 语言实现,并在后续多个项目里反复使用。今天这篇文章,就是把我这些年的实际经验做一个完整梳理。你会看到 CRC 到底是怎么算的、为什么它能揪出几乎所有的错误、以及在 C 语言里到底有哪几种靠谱的写法,每种写法适合什么场景。
无论你是刚接触嵌入式通信,还是已经在写上位机协议但是对校验部分不太放心,这篇文章都值得读完。我不会只丢一个现成函数给你完事,而是把原理、代码、踩坑点全部讲透。
2. CRC 到底是什么:多项式除法的本质
2.1 从“模二除法”讲起
很多教程一上来就甩出多项式、生成多项式、CRC-16、CRC-32 这些名词,直接把新手吓退。其实 CRC 的核心思想特别朴素:把我们要发送的一串数据当成一个巨大的二进制数,然后选一个固定的二进制“除数”,用这个除数去除数据,得到的“余数”就是校验码。发送方把余数附在数据后面一起发出去,接收方用同样的除数去除收到的整串数据,如果余数为 0,说明数据大概率没错。
这里的除法不是我们小学学的十进制除法,而是“模二除法”。什么是模二?就是每一位的运算都只看奇偶,不进位、不借位。二进制里极其简单:
- 0 - 0 = 0,1 - 0 = 1,1 - 1 = 0,0 - 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-8 | 0x07 | 简单传感器、小数据包 |
| CRC-16/CCITT | 0x1021 | XMODEM、蓝牙、PPP |
| CRC-16/MODBUS | 0x8005 | 工业现场总线 |
| CRC-32 | 0x04C11DB7 | ZIP、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 算法通常包含以下参数:
| 参数 | 含义 | 常见值示例 |
|---|---|---|
| Width | CRC 位数 | 16、32 |
| Polynomial | 生成多项式 | 0x1021、0x8005 |
| Init | 寄存器初始值 | 0x0000、0xFFFF |
| RefIn | 输入数据是否按位反射 | true/false |
| RefOut | 输出结果是否按位反射 | true/false |
| XorOut | 计算结果与哪个值异或 | 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” 的结果是 0xCBF43926,CRC-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 外设结合起来,让外设自动边接收边计算 CRC,CPU 完全解放。这是嵌入式领域一个非常香的设计思路,但也比较依赖具体芯片平台,这里就不展开写了。
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 这个东西,原理不复杂,但是细节极其繁琐。希望你在读完这一篇之后,能少走一些弯路,直接把校验这块做成一个可靠的标准件,用到哪里都顺畅。