做嵌入式或是通信协议相关开发的人,对CRC校验这个词应该都不陌生。串口收发、传感器数据读取、升级包校验、Modbus报文交互,几乎只要能想到的可靠传输场景,背后大概率都挂着一层CRC。我刚接触查表法是很多年前调一个Modbus网关项目,协议栈要求一帧报文末尾带上CRC16,当时图省事直接逐位硬算,几十个字节的一帧数据算下来,主循环直接肉眼可见地卡顿。那时候我才认真把查表法从头到尾啃了一遍,才明白一张256项的查找表背后,其实只是一次空间换时间的常规操作。
这篇文章就围绕"CRC校验查表法"展开,适合刚接触校验算法、想把表用明白的初学者,也适合已经会用表但不太清楚表是怎么来的、不同CRC参数模型怎么适配的实战工程师。下面我用尽量少的术语、尽量多的推算过程,把从位逐算法到查表法的来龙去脉讲清楚。
1. CRC校验到底在算什么:先理解余数,再理解查表
1.1 从一个字节开始看模2除法
CRC的英文全称是Cyclic Redundancy Check,循环冗余校验。名字很绕,但本质可以理解成"除法取余数"。发送方在数据后面添上若干个校验位,这些校验位是原始数据除以某个固定生成多项式的余数;接收方拿到带校验位的数据后,再除以同一个多项式,如果余数为0,说明数据在传输过程中没有被改过。
关键点在于,这里的除法不是我们小学学的十进制除法,而是模2除法。模2除法有两个特点:第一,加减法全部用异或代替,不进位也不借位;第二,每一步除法看被除数最高位,最高位是1就上1并做异或,最高位是0就上0并跳过。最终得到的余数就叫做CRC校验值。
为什么用异或?因为CRC的计算过程对应多项式环上的运算,在二进制里,项与项之间的加法本质上就是异或。这就是为什么你在网上看到的所有CRC代码里,最核心的运算符号永远是^。
1.2 位逐算法:校验的"朴素实现"
要理解查表法,得先把最朴素的位逐算法写出来,这条主线其实非常简单。以CRC-8为例,选定一个生成多项式,比如0x07,也就是二进制0000 0111,实际参与运算时省略最高位的1,但在概念上多项式是x^8 + x^2 + x + 1。
对每个输入字节,把它和当前的CRC寄存器异或,然后一个bit一个bit地处理。每处理一个bit,判断寄存器最高位是1还是0,是1就左移一位再异或多项式,是0就只左移。伪代码如下:
uint8_t crc8_bitwise(uint8_t *data, size_t len, uint8_t poly) { uint8_t crc = 0x00; for (size_t i = 0; i < len; i++) { crc ^= data[i]; for (int bit = 0; bit < 8; bit++) { if (crc & 0x80) crc = (crc << 1) ^ poly; else crc <<= 1; } } return crc; }这段代码逻辑上没有毛病,但它每次只处理一个bit,处理一个字节就要循环8次,处理一帧1024字节的数据就是8192次循环。在资源紧张的单片机里,这个开销非常可观。查表法要解决的就是这个问题,把内层8次循环直接砍掉,一次循环处理一个完整字节。
2. 查表法为什么快:把8次位循环换成1次数组访问
2.1 性能瓶颈在哪
位逐算法的内层循环作用是:把当前CRC异或输入字节后的8位数据,逐个bit地做模2除法。换个角度想,这个过程其实已经是一个确定的函数了:给定一个8位的输入值,经过8次固定的运算,必然得到同一个8位输出值。
既然输入只有8位,总共就只有256种可能,那我干脆提前把这256个结果全部算好,存到一张表里。真正计算的时候,直接把异或后的值当成数组下标,读出表里的结果,一次查表就能替代原来的8次循环。这就是查表法的核心思想。
这种空间换时间的思路,其实在各个领域都能见到。就像以前没有浮点协处理器的单片机里,有人把三角函数表也做成数组,用角度做下标去查sin、cos的近似值。CRC查表法本质上是同一个套路,只是这里的"函数"不是三角函数,而是"对1字节数据做8次模2除法"的小算子。
2.2 表和查表的关系:以CRC-8为例
对于CRC-8,处理流程从位逐版本变成了这样:
uint8_t crc8_table_driven(uint8_t *data, size_t len) { uint8_t crc = 0x00; for (size_t i = 0; i < len; i++) { crc = crc8_table[crc ^ data[i]]; } return crc; }看上去简洁,但有三个点必须说透。
第一,为什么是crc ^ data[i]?因为CRC-8的寄存器宽度是8位,处理新字节时,新字节要和寄存器当前值异或,这一步在表驱动版本里仍然需要手动完成。异或之后得到8位索引,对应这一字节对校验值的全部贡献。
第二,为什么直接查表就能得到新的crc?因为表的定义就是"输入8位数据,输出经过8次模2除法后的结果"。crc ^ data[i]是一个8位数,从这个表里查出来的值,就是位逐版本处理完这个字节后寄存器的状态。
第三,如果字节流有多个字节,为什么每次直接取上一轮的crc做异或?这正是模2除法的性质决定的,CRC计算是逐字节迭代的,当前字节处理完后的余数,会作为下一轮计算的初始状态继续参与运算,跟手工竖式除法逐步取余的过程一模一样。
2.3 CRC-16和CRC-32的查表演进
CRC-8因为是8位寄存器,表项直接就是8位值,看起来特别直观。CRC-16的寄存器宽度变成了16位,处理一个字节时,异或后的位置也变成16位寄存器中的高8位或低8位,具体取决于多项式方向。但查表的基本结构不变,只是表项从uint8_t变成uint16_t,查表次数从每字节1次还是1次,只是每次查表后还要额外做一次16位寄存器的移位异或。
CRC-32同理,表项变成4字节。无论CRC是8位、16位还是32位,查表法都只需要一张256项的表,因为每条数据总是逐字节处理,每个字节只有256种可能。一张表服务所有字节,不存在需要为每个字节单独建表的情况。
3. 一张表是怎么造出来的:手工推导CRC-8表
3.1 生成表的核心逻辑与代码
查表法的前提是先有一张正确的表。这张表不是网上随便抄来的,自己也可以生成。生成表的逻辑和位逐算法几乎一样,区别在于:对0到255的每一个数,都把它当作输入,执行一遍8次模2除法,把结果存到表里。
uint8_t crc8_table[256]; void crc8_init_table(uint8_t poly) { for (int i = 0; i < 256; i++) { uint8_t crc = i; for (int bit = 0; bit < 8; bit++) { if (crc & 0x80) crc = (crc << 1) ^ poly; else crc <<= 1; } crc8_table[i] = crc; } }注意生成表的时候,初始值直接就用i本身,不需要额外做异或。因为表的定义就是"输入这个字节、不做任何前置处理后得到的校验变换结果"。
3.2 手工推一遍表里的值
以多项式0x07为例,手工推两个表项,你就能彻底明白整个过程。
先看table[0x00]。输入是0,无论做多少次移位和异或,0始终是0,所以table[0x00] = 0x00。
再看table[0x01]。初始crc为0x01,开始8次循环:
- 第1次:0x01最高位为0,左移得0x02
- 第2次:0x02最高位为0,左移得0x04
- 第3次:0x04最高位为0,左移得0x08
- 第4次:0x08最高位为0,左移得0x10
- 第5次:0x10最高位为0,左移得0x20
- 第6次:0x20最高位为0,左移得0x40
- 第7次:0x40最高位为0,左移得0x80
- 第8次:0x80最高位为1,左移后异或0x07,
0x00 ^ 0x07 = 0x07
所以table[0x01] = 0x07。这也印证了前面位逐版本里处理单字节0x01的结果。
再推table[0x02]。初始为0x02,经过前几次左移:0x02 -> 0x04 -> 0x08 -> 0x10 -> 0x20 -> 0x40 -> 0x80,然后和上面一样,最高位为1后异或多项式得0x07,继续左移一次得0x0E,所以table[0x02] = 0x0E。
从这两个例子能看出来,表里的每一项就是"用这个字节独立走完8次位运算"的输出。查表版本每处理一个字节,其实就是在重复这个动作,只是因为之前算过,所以直接取结果。
3.3 表生成后再套流程验证一遍
表生成完之后,可以用位逐算法和查表算法分别跑同一段数据,对比结果是否一致。比如说用{0x01, 0x02}跑一遍:
位逐算法:
- 初始crc=0x00,异或0x01得0x01,处理8次得到0x07
- 再异或0x02得0x05,处理8次得到0x1B,最终crc=0x1B
查表算法:
- 初始crc=0x00,查表
table[0x00 ^ 0x01] = table[0x01] = 0x07 - 再查表
table[0x07 ^ 0x02] = table[0x05],如果表生成正确的话,table[0x05]应该等于0x1B
这一步验证非常重要,我在实际项目中每次换CRC模型,都会同时保留一个位逐版本做对照,确认表驱动版本结果一致后,再把位逐版本放到测试代码里注释掉。这个习惯帮我避免了多次因为表抄错、方向搞反导致的诡异问题。
4. 不同协议族的CRC参数模型:一张表不能通吃
4.1 Poly、Init、RefIn/RefOut、XorOut分别是什么
直接用上面的CRC-8查表,会发现和某些协议里算出来的CRC对不上。原因是真实工程里的CRC约定不止多项式一项。一个完整的CRC算法模型,通常需要下面几个参数:
- Poly:多项式,省略最高位的值,比如CRC-16/Modbus是0x8005
- Init:寄存器初始值,有的协议起始是全1,有的是0
- RefIn:输入数据按位反射(也叫反序、LSB first),比如0x01变为0x80
- RefOut:输出结果在返回前是否再按位反射
- XorOut:最终结果异或的掩码,常见的是0xFFFF或0xFFFFFFFF
这些参数放在一起,就定义了一个具体的CRC变体。比如CRC-16/Modbus的完整配置是poly=0x8005, init=0xFFFF, refin=true, refout=true, xorout=0x0000,而CRC-16/CCITT是poly=0x1021, init=0xFFFF, refin=true, refout=true, xorout=0x0000。所以不能看到CRC16三个字就觉得所有协议算出来一样,参数差一个bit,结果完全不同。
4.2 反射场景与右移表
RefIn和RefOut为true,等于把数据、寄存器全都当作镜像来处理。在查表法里,如果强行使用前面那种左移查表逻辑,就必须在处理每个字节前把数据按位反转,代价很大。实际工程中更常见的做法是:把多项式本身也做镜像反转,用一张右移版查表,这样查表循环里连反转都不用做,速度最快。
还是以CRC-16/Modbus为例。把0x8005按16位反转,会得到0xA001。右移版本的表生成方式是:
uint16_t crc16_modbus_table[256]; void crc16_modbus_init_table(void) { const uint16_t poly = 0xA001; // 0x8005的镜像 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) ^ poly; else crc >>= 1; } crc16_modbus_table[i] = crc; } }这里判断从最高位0x80变成了最低位0x01,移位从向左变成向右,多项式用镜像值。整个计算过程就像是把原来的寄存器左右翻转了一次,所以在查表循环里可以完全忽略RefIn和RefOut的影响,反正右移的每一步天然就在做反射。
4.3 可直接复用的CRC-16/Modbus查表完整实现
表生成后,查表计算就非常简单了:
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 ^= data[i]; crc = (crc >> 8) ^ crc16_modbus_table[crc & 0xFF]; } return crc; }注意这里初始值直接用了0xFFFF,这对应Modbus协议的Init。由于用了右移表,RefIn和RefOut天然满足,最后的XorOut是0,所以不需要再额外异或。如果你想换成CRC-16/CCITT,只要把多项式改成0x1021的镜像0x8408,并且按照CCITT的Init和XorOut调整首尾就行。主体查表循环几乎不用动。
这里多说一句:表里每一项都是16位值,查表时为什么只用crc & 0xFF来当下标?因为右移版本每次处理完一个字节后,参与下一轮查表的是当前CRC的低8位,高8位经过右移后变成下一轮的高位基础。如果你习惯看左移版本,那里用的是((crc >> 8) ^ data[i]) & 0xFF。两个版本结构正好镜像,别弄混。
4.4 CRC-32查表往里套就行
CRC-32的查表代码结构和CRC-16几乎一模一样,只是表项从uint16_t变成uint32_t,多项式用0xEDB88320(这是0x04C11DB7的镜像),初始化用0xFFFFFFFF,结果异或0xFFFFFFFF。一条核心查表语句:
crc = (crc >> 8) ^ crc32_table[(crc ^ data[i]) & 0xFF];很多完整实现里还会有update、finalize之类的分层,但底层都是这一句。一张256项的uint32_t表,占1KB内存,在绝大多数单片机上都能接受。
5. 工程实战经验:校验、验证、避坑
5.1 表、算法、参数配置三者必须匹配
查表法最大的坑,不是表不会生成,而是表、算法、协议配置三者对不上。很多人从网上复制一段表格文件,然后随便配了一个查表函数,结果算出来和协议栈要求的值完全不一致,最后只能把锅甩给CRC太难。
实际上问题通常是:表是左移版还是右移版,查表函数是左移循环还是右移循环,Init和XorOut有没有按协议配置,这三件事必须同时正确。一个简单判定办法:如果是右移版查表,多项式必须是镜像值;如果是左移版查表,多项式是原始值。一旦表方向和循环方向不匹配,整个计算就是错的。
5.2 用位逐版本当"标尺"验证表
我强烈建议在工程代码里保留一个位逐版本的CRC函数,平时可以不开,但测试的时候一定打开,用它和查表版本逐字节对比。位逐版本虽然慢,但逻辑最简单,不容易出错,可以作为验证查表版本正确性的标尺。
更高效的验证方式是准备一组标准测试向量。很多协议规范里会给出"输入一段固定字符串,CRC应该是多少"的官方例子。比如你可以拿"123456789"这9个字节去跑不同模型的标准CRC值,把这份向量固化到单元测试里。只要查表版本算出来的结果和官方向量一致,基本可以确定整个链路没问题。千万不要懒,这一步跳过了,后面联调出错你很难定位到底是对端问题还是自己CRC实现问题。
5.3 查表法的实际取舍:表大小、内存、速度
查表法最常见的形态是256项单表。CRC-8表占用256字节,CRC-16表占用512字节,CRC-32表占用1KB。对于RAM只有几KB的老单片机,1KB的表有时还是有点负担。这时候可以考虑半字节查表,也就是把8位字节拆成高4位和低4位,各查一次16项的表,表内存会小很多,速度比位逐快,比完整查表慢。
对于追求极限速度的场景,还有人用两个256项的表组成双表,一次循环处理两个字节,本质上还是查表思想,只是把吞吐量再往上翻。我个人的建议是:CRC-8和CRC-16用256项单表足够,CRC-32在资源紧张时再用半字节目不变应万变,尽量别为了省几百字节把代码复杂度提上去。
5.4 我整理过的几个排查点
在实际联调过程中,我发现CRC对不上时,90%都出在下面几个位置:
第一,初始值。有些模块代码里对crc变量初始化写的是0,而上位机软件按协议要求初始化为0xFFFF,那结果肯定不对。第二个,结果异或。有些协议算完CRC后还要整体异或一下,或者高低字节交换后再放到帧里,如果发送端和接收端对这个后处理理解不一致,也会互相校验失败。第三,字节序。很多协议把CRC16低字节放前面、高字节放后面,你在调试助手里看到的数据和实际发到线路上的字节顺序可能是相反的。第四,裁剪问题。有些人用别人封装好的CRC类,直接喂整帧数据,但正确的用法是只喂"数据区",不包含CRC本身,否则等于把校验值也算进去了。
排查的时候,先用逻辑分析仪或者串口抓一帧实际数据,再用协议里约定的模型在PC上跑一遍,对比每个字节的实时CRC状态,基本能快速锁定问题出在哪个环节。
把查表法用成习惯
查表法这个技巧本身不难,难的是理解它背后的原理和参数变化后如何适配。希望大家拿到任何一套CRC协议参数时,能够先判断是左移还是右移,再决定表怎么生成、循环怎么写,而不是靠运气抄代码。我个人在实际操作中的体会是:每次写CRC相关代码时,第一件事永远是把协议参数列成一张清单,然后先用位逐算法跑通,再换查表优化。顺序对了,这个算法基本不会再出问题。