在嵌入式开发和通信协议调试这条路上,几乎没人能绕开 CRC。前几年我做一个串口仪表项目,需要把 Modbus-RTU 报文的校验从逐位计算改成查表法,结果照抄了网上一个 CRC-16 的查表代码,换到自己的多项式上怎么都对不上。折腾了一个下午才发现:不只是多项式不同,初值、输入输出反射、结果异或,这几个参数只要有一个不一致,表就完全不是同一张表。那个时候我才意识到,网上铺天盖地的“查表法详解”,大多数只教你抄表,没教你造表。这篇文章我想把 CRC 查表法从原理到代码再到排错,完整地讲清楚,适合三种人看:一是刚接触通信校验、被各种 CRC 变体绕晕的初学者;二是能跑通逐位算法、但想优化速度的嵌入式开发者;三是经常要移植协议栈、需要自定义 CRC 参数的老手。读完之后,你能自己生成任意参数的查表代码,也能一眼看出别人代码里的表到底属于哪个 CRC 家族。
1. 查表法被误解最深的三个地方
网上关于 CRC 查表法的教程很多,但大多只给一段现成代码。我观察下来,大家最容易在三个概念上栽跟头,先拿出来讲清楚。
1.1 表格不是“查出来的”,而是“算出来的”
很多人以为那张 256 项的表是某个标准组织发布的、像 ASCII 码表一样“规定好”的数据。其实不是,表里的每一个值,都是拿生成多项式对 0 到 255 的索引值做逐位除法算出来的。它本质上是一个缓存:把 CRC 计算中反复出现的“字节参与运算后的中间结果”提前算好,运行时只需要一次查表加几次异或,替代掉原来的 8 次循环。
这个区别很重要。如果你以为表是“查出来的”,就会在多项式一变时手足无措;如果你知道表是“算出来的”,就明白真正需要理解的核心其实是移位和异或那套逻辑,表只是它的副产品。
1.2 查表不是一次算完,而是“每个字节查一次”
另一个常见误解是,查表法意味着把整段数据一次性“代入”某张表,直接得到结果。实际不是这样。CRC 是一个流式算法,每个输入字节都会改变内部状态(一个宽度固定为 width 的寄存器),查表只是让“一个字节对寄存器的影响”这一步变快,数据还是得一个字节一个字节地喂进去。
你可以把寄存器想象成一个滚动的哈希窗口,每个字节进来之后,窗口里的值都会更新。查表法是把这个“更新动作”从循环判断变成了数组索引,但数据量大了以后,还是要遍历所有字节,时间复杂度依然是 O(n),只是常数项小了很多。
1.3 拿来的代码能不能用,取决于六项参数
我发现很多人有这个经历:同一个 CRC-16,网上写 0x1021 的、0x8005 的、0xA001 的都有,代码长得都差不多,为什么结果不一样?
因为 CRC 家族不是一个算法,而是一族算法。描述一个 CRC 至少需要六个参数:宽度 width、多项式 poly、初值 init、输入反射 refin、输出反射 refout、结果异或 xorout。例如 CRC-16/MODBUS 和 CRC-16/CCITT-FALSE,poly 一个 0x8005、一个 0x1021,初值一个 0xFFFF、一个 0xFFFF,但反射方式完全不同。你把两张表互换,算出来的校验码当然对不上。后面我会专门用一节把参数体系讲透,这里你只要记住:表跟着参数走,参数不同,表就是另一张表。
2. 从逐位除法到查表:CRC计算到底在算什么
要真正理解查表法,绕不开 CRC 最原始的数学形态。很多人一听到“多项式除法”就劝退,其实把它拆开看,就是移位加异或。
2.1 CRC 在数学上等价于一次“二进制除法”
CRC 的计算对象是一串二进制数据,你可以把它看成一个很大的二进制整数。生成多项式也是一个二进制数,比如 CRC-16/MODBUS 的多项式 0x8005,写成二进制是 1000 0000 0000 0101,对应数学式 x^16 + x^15 + x^2 + 1。
CRC 计算做的事情,就是把“数据左移 width 位后拼接 width 个零”这个数,用生成多项式去模 2 除,最终得到的余数就是校验码。需要注意的是,这里的除法是模 2 除法,也叫 GF(2) 域上的除法,加减法都被异或代替,没有进位和借位。这也是为什么 CRC 的硬件实现只需要异或门和移位寄存器,不需要真正的除法器,成本极低。
2.2 逐位计算的本质:一个会自己“吞数据”的寄存器
软件里最常见的逐位 CRC 算法,其实就是模拟一个移位寄存器。以非反射(MSB-first)为例:
- 寄存器先初始化为初值;
- 取数据字节的最高位,与寄存器最高位异或;
- 寄存器左移一位,最低位补零;
- 如果刚才异或的结果是 1,就把寄存器和多项式异或;
- 重复处理数据的下一位。
这段逻辑写成 C 代码大概是:
uint16_t crc16_bitwise(uint16_t crc, const uint8_t *data, size_t len) { for (size_t i = 0; i < len; i++) { crc ^= (uint16_t)data[i] << 8; for (int bit = 0; bit < 8; bit++) { if (crc & 0x8000) crc = (crc << 1) ^ 0x8005; else crc <<= 1; } } return crc; }注意这里data[i] << 8的写法,就是模拟“把数据送入寄存器高字节”的过程。如果你看的是 LSB-first 的反射实现,代码会倒过来,判断最低位、右移、多项式也要反转,但思想完全一样。
2.3 为什么逐位计算慢:每个 bit 都要一次条件判断
逐位算法每处理一个字节,内部要循环 8 次;每循环一次都要做一次最高位判断和一次条件分支。在台式机上这没什么,但在低主频单片机上,如果通信速率高、数据帧又长,逐位算 CRC 很容易吃掉大量 CPU 时间,甚至成为通信瓶颈。
我当年在 STM32F103 上做 CAN 网关,跑 500 kbps 的 CAN 总线,每帧 8 字节数据,用逐位法算 CRC 勉强能扛住;后来换成 1 Mbps,还要转发多条总线,逐位法就不够用了,这就是我下决心改查表法的直接原因。查表法把每个字节的处理从 8 次循环变成 3 次异或加一次数组访问,速度通常能提升 4 到 8 倍。
3. 手工构建查表:用16项半字节表把原理“算”出来
不亲手造一次表,你永远不算是真正理解查表法。但直接用 256 项的 8 位表推导,过程长又容易看花眼。我在这里用一个 16 项的“半字节表”来演示,用 CRC-8 的多项式 0x07(x^8 + x^2 + x + 1)举例。理解这 16 项之后,8 位表的原理就是同样的逻辑重复 8 次而已。
3.1 表项的生成规则
对于非反射的 CRC-8,表项的生成规则是:对每个索引 i(0 到 15),先把这个索引值放到一个 8 位寄存器的最高半字节,即crc = i << 4,然后做 4 次“左移 + 条件异或”:
- 如果当前最高位是 1,就左移一位后异或多项式 0x07;
- 如果是 0,就只左移一位。
循环结束后,寄存器的值就是表项table[i]。写成伪代码:
for (i = 0; i < 16; i++) { uint8_t crc = i << 4; for (j = 0; j < 4; j++) { if (crc & 0x80) crc = (uint8_t)((crc << 1) ^ 0x07); else crc = (uint8_t)(crc << 1); } table[i] = crc; }3.2 手工推导前几项
我们手动算前几项:
- 索引 0:
crc = 0x00,连续左移 4 次还是0x00,所以table[0] = 0x00。 - 索引 1:
crc = 0x10,二进制 0001 0000。左移 3 次后变成 1000 0000,此时最高位是 1,第 4 次时(0x80 << 1) ^ 0x07 = 0x100 ^ 0x07 = 0x107,截断到 8 位得0x07,所以table[1] = 0x07。 - 索引 2:
crc = 0x20。前 2 次左移后变0x80,第 3 次异或得0x07,第 4 次最高位为 0,再左移得0x0E,所以table[2] = 0x0E。 - 索引 3:
crc = 0x30。左移 1 次变0x60,再左移变0xC0,再左移时最高位 1,得(0x180 ^ 0x07) & 0xFF = 0x87,最后一次左移最高位又为 1,得(0x10E ^ 0x07) & 0xFF = 0x09,所以table[3] = 0x09。
继续算下去,整张半字节表就是这样的:
| 索引 | 表项值 |
|---|---|
| 0 | 0x00 |
| 1 | 0x07 |
| 2 | 0x0E |
| 3 | 0x09 |
| 4 | 0x1C |
| 5 | 0x1B |
| 6 | 0x12 |
| 7 | 0x15 |
| 8 | 0x38 |
| 9 | 0x3F |
| 10 | 0x36 |
| 11 | 0x31 |
| 12 | 0x24 |
| 13 | 0x23 |
| 14 | 0x2A |
| 15 | 0x2D |
你可以拿 0x07 这个多项式写个逐位 CRC-8 函数,把单字节数据 0x01 到 0x0F 分别算一遍,和表里对应项做对比,结果完全一致。这说明表确实是“算”出来的,不是凭空来的。
3.3 从半字节表到标准 8 位表
明白半字节表的原理后,标准 8 位表就很好理解了:索引变成 0 到 255,生成时把crc = i << 4换成crc = i << 8(针对 16 位 CRC 就是crc = i << 8),循环次数从 4 次变成 8 次,得到一张 256 项的表。运行时,每次取一个字节,用寄存器的高字节和当前输入字节异或作为索引,再查表更新。这样每个字节只查一次表,速度就上来了。
4. 同一套算法不同的“方言”:初值、反射、结果异或
为什么同样是 CRC-16,会有 CRC-16/IBM、CRC-16/MODBUS、CRC-16/CCITT、CRC-16/XMODEM 一堆名字?原因是通信协议在落地时,根据自己的需求调整了几个参数,形成了几种“方言”。描述一种方言,通常用这六个参数。
| 参数 | 含义 | 常见例子 |
|---|---|---|
| width | 校验码位宽 | 8、16、32 |
| poly | 生成多项式 | CRC-16/MODBUS 为 0x8005 |
| init | 寄存器初值 | 常见 0x0000、0xFFFF |
| refin | 输入数据是否按位反转 | Modbus 和 CRC-32 为 true |
| refout | 输出前是否再按位反转 | Modbus 和 CRC-32 为 true |
| xorout | 结果与一个值异或后输出 | CRC-32 为 0xFFFFFFFF |
4.1 初值到底管什么用
初值最直接的作用是解决“数据前面补几个零”的问题。如果寄存器初值是 0,那么数据流开头无论加多少个 0,CRC 结果都是 0。很多协议希望不同长度的数据即使前导零不同也能区分,就会把初值设为全 1,比如 0xFFFF。你可以把初值理解成给寄存器一个“预热状态”。
我在实际调试中曾经遇到一个很隐蔽的现象:同一套物理层协议,A 设备计算时把校验码后 append 到报文中,B 设备计算时把校验码也纳入运算,两边用的参数一模一样,但总是校验失败。最后发现就是初值处理位置不对,一个是在追加校验码前已经把全部数据算完,另一个是把包含校验码的整帧都喂了进去。这提醒我,初值在硬件上确实存在,但在不同实现里,它作用于“有效数据之后、追加校验之前”,千万别想当然。
4.2 反射(RefIn/RefOut)在解决什么问题
反射是通信里最让人头大的概念。简单说,有些协议的数据线是 LSB 先发送的,比如 UART 异步串口;有些是 MSB 先发送的,比如 SPI。为了使硬件移位方向与协议位序一致,就出现了反射型 CRC。
反射型算法查的不是原多项式,而是反转后的多项式。比如 CRC-16/MODBUS 的多项式虽然写作 0x8005,但因为需要 LSB 先处理,代码里实际用的是它的位反转结果 0xA001。你可以用一段小程序验证:把 0x8005 的二进制 1000 0000 0000 0101 左右逐位反转,得到 1010 0000 0000 0001,就是 0xA001。
这也是好多人抄代码抄翻车的重灾区:拿着一份非反射型的表,去算一个反射型的协议,结果南辕北辙。确定你的协议是不是反射型,最快的方法是查这个协议的标准文档,或者看它例子里给出的“123456789”的校验结果。
4.3 结果异或与校验码的“对外形态”
xorout 参数会在输出结果前再和某个固定值做一次异或。像 CRC-32 的 xorout 是 0xFFFFFFFF,相当于把所有位取反。有人认为这是为了增加冗余度,让 CRC 值为 0 的概率进一步降低;也有人认为只是为了配合某些校验设备的历史习惯。不管原因如何,实现时一定要记得这个参数。
另外,还有个容易忽略的细节:把校验码追加到报文末尾时,多字节校验码到底高字节在前还是低字节在前。这属于协议层的字节序约定,不算 CRC 数学本身,但很多联调问题最后都出在这。Modbus-RTU 就是把 CRC 低字节放在前、高字节放在后,和大多数人直觉相反,我早期就在这里栽过。
5. 一套完整的CRC-16/MODBUS查表法实现
理论讲再多,不如一份能直接跑通的代码。我用 CRC-16/MODBUS 为例,给出标准参数下从建表到计算再到自检的完整 C 代码。
5.1 生成反射型查表
CRC-16/MODBUS 的参数是:poly=0x8005,init=0xFFFF,refin=true,refout=true,xorout=0x0000。因为反射,实际运行时用 0xA001。
#define CRC16_POLY_REFLECTED 0xA001 uint16_t crc16_modbus_table[256]; void crc16_modbus_init_table(void) { for (uint16_t i = 0; i < 256; i++) { uint16_t crc = i; for (uint8_t bit = 0; bit < 8; bit++) { if (crc & 1) crc = (crc >> 1) ^ CRC16_POLY_REFLECTED; else crc >>= 1; } crc16_modbus_table[i] = crc; } }这里生成表的时候,索引就是原始字节本身,不是字节左移 8 位,这是反射型表和非反射型表的一个显著区别。你去看非反射型表的生成代码,通常是crc = i << 8;反射型则是crc = i,然后向右移位。
5.2 查表计算函数
查表更新的核心逻辑是:把寄存器低字节和输入字节异或,结果作为表索引,再用寄存器右移 8 位和表项异或。写成函数:
uint16_t crc16_modbus_update(uint16_t crc, uint8_t byte) { uint8_t index = (uint8_t)(crc ^ byte); crc = (crc >> 8) ^ crc16_modbus_table[index]; return crc; } uint16_t crc16_modbus_compute(const uint8_t *data, size_t len) { uint16_t crc = 0xFFFF; for (size_t i = 0; i < len; i++) { crc = crc16_modbus_update(crc, data[i]); } return crc; }注意update之前不要手动把 crc 和0xFFFF再异或,因为 init 已经在compute里赋好了。有些代码喜欢在update里写crc ^= 0xFFFF,那是另一种实现风格,容易和 init 混淆。
5.3 用标准测试向量自检
标准测试向量是字符串"123456789"。对 CRC-16/MODBUS,已知正确结果是 0x4B37。
uint8_t test[] = "123456789"; uint16_t result = crc16_modbus_compute(test, 9); // 期望 result == 0x4B37我建议拿到任何一份 CRC 代码,第一件事就是用标准测试向量验证。不只是校验最终结果,还要验证整张表是否符合预期。很多在线计算工具也支持自定义参数,可以先在工具里算一遍,再和自己代码比对,能省大量排错时间。
5.4 封装成参数化 CRC
如果你经常要在不同协议之间切换,再往上一层封装会更省心。把 width、poly、init、refin、refout、xorout 打包成一个结构体,表生成和计算函数根据参数分支处理。虽然多了一点分支判断,但胜在一处代码到处用。我在一个多协议网关项目里就是这么做的,切换 Modbus 和 Profibus 的 CRC 校验只需要改配置,不用改代码逻辑。
6. 实战中我踩过的五个坑
光有代码还不够,调 CRC 最花时间的永远是排查“为什么两边对不上”。下面这几个坑,我基本都在真实项目里踩过,写出来给你当排错清单。
6.1 拿非反射代码套反射协议
这是最高频的坑。症状是:逐位算没问题,查表法算出来就是不对;或者小数据量对,大数据量不对。原因是表的方向性错了。解决方案不是靠肉眼猜,而是直接用标准测试向量验证。如果"123456789"算出来的结果和标准答案不一致,优先怀疑反射参数。
6.2 把 init 当作“计算前先填充”
初值不是“计算完后异或”,而是“寄存器初始状态”。两者在数学上不等价。我见过有人在 update 循环里每次进入前都执行crc ^= 0xFFFF,结果每处理一个字节都被重置一次,错误得非常离谱。正确写法是在计算整帧数据前赋一次初值,之后每个字节只是逐步更新状态。
6.3 忘记处理结果异或
有些 CRC 的校验结果不是“算完就行”,还要异或一个值。比如 CRC-32 的 xorout 是 0xFFFFFFFF,如果你只算了反射和初值,忘了最后异或,和其他设备联调时一定过不去。而且因为异或本身对这一位取反,错起来不会给你任何提示——两边算出来的数“长得挺像”,但就是完全不一样。
6.4 追加校验码时搞错字节序
这个问题不属于 CRC 计算本身,但属于“CRC 联调失败”最常见原因之一。Modbus-RTU 要求低字节在前、高字节在后,很多国产设备却习惯高字节在前。建议在协议文档里明确标注,代码里也要明显区分crc_low = crc & 0xFF和crc_high = crc >> 8。我还会在代码注释里写清楚“先发低字节”,防止自己过两个月忘掉。
6.5 用在线工具算错参数不自知
在线工具非常方便,但不同工具对参数命名不统一。有的叫 “Polynomial”,有的叫 “Poly”,有的还分 “normal” 和 “reversed”。你用工具之前,先确认工具支持的参数定义和你的协议一致。我自己习惯用 reveng 命令行工具做交叉验证,它支持直接指定六个参数,比网页工具更少歧义。
7. 查表、逐位、硬件CRC怎么选
理解了查表法,不代表所有场景都该无脑用查表。不同实现方式有自己的适用边界,我按实际工程经验做个对比。
| 实现方式 | 速度 | 内存占用 | 代码复杂度 | 适用场景 |
|---|---|---|---|---|
| 逐位计算 | 最慢 | 几乎为零 | 最低 | 数据量小、CPU 空闲、调试用 |
| 8 位查表 | 中等 | 256 项表 | 较低 | 绝大多数嵌入式场景 |
| 半字节查表 | 略慢于 8 位 | 16 项表 | 低 | 内存极紧张的单片机 |
| Slicing-by-8 | 最快 | 8×256 项表 | 较高 | 高速网络、文件校验 |
| 硬件 CRC 外设 | 极快 | 无 | 中 | 有 CRC 外设的 MCU |
我一般按这个思路选:如果 MCU 内存只有几百字节,选半字节查表或逐位;正常嵌入式项目直接用 8 位查表,表只占 256 字节或 512 字节,开销完全可以接受;跑在 PC 上的大文件校验,可以上 Slicing-by-8;如果 MCU 自带 CRC 外设,比如 STM32 系列,直接调硬件,连表都不用建。硬件 CRC 的问题是参数基本固定,如果你的协议参数和外设支持的不完全一致,最后还是得靠软件兜底。
查表法还有一个容易被忽略的好处是稳定性。逐位法在极端情况下可能因为编译器优化差异产生不同效率,查表法的执行时间基本恒定。对实时性要求高的协议栈来说,处理时间稳定,意味着你不用为最坏情况预留太多余量。
回到开头的那个 Modbus 项目,把 CRC 改成查表法之后,我的 CAN 网关 CPU 占用率下降了差不多四成。但比性能提升更值钱的,是那次踩坑逼我把 poly、init、refin、refout、xorout 这套参数彻底弄明白了。之后不管换什么协议,我都先查清楚六个参数,再写一个参数化生成器自动建表,再也没有因为 CRC 对不上而熬夜。如果你现在正被某个 CRC 变体折磨,照着这篇文章的验证流程走一遍,大概率能直接定位问题。如果还是对不上,先别急着改代码,把你手头协议的六个参数、标准测试向量、以及你实际算出的结果整理成一张表,问题往往就藏在这些参数和结果之间的某个不一致里。