搞嵌入式的,尤其是做串口通信、传感器数据采集、Modbus协议这类活儿的,几乎没有不认识CRC16的。以前我刚接触CRC16的时候,第一反应就是找现成的查表代码,网上拷贝一段,能用就行。直到有一次调试一个温度采集模块,数据死活对不上,我才痛下决心把那256个字节的表彻底搞明白。这篇文章就当是给自己做个总结,也顺便帮还在“会用但不懂”阶段的兄弟们把这块补上。咱们就完全围绕CRC16查表法原理来拆,从数学原理到代码实现,再到怎么用查表法把温度算出来,我尽量用大白话讲清楚。
这个内容适合谁看?如果你正在写单片机的通信协议、需要校验传感器数据、或者面试时被问到CRC的原理但只会背答案,那这篇就是给你准备的。看了之后,你能自己生成CRC16查找表、能看懂网上任何一份查表代码、能根据需求选对CRC参数模型,最关键的是,出问题的时候知道怎么排查,而不是瞎换一张表碰运气。
1. 先搞清楚:CRC到底在做什么,查表法化解了什么痛点
1.1 CRC16不是算出来的“校验和”,是算出来的“余数”
要说清楚CRC16查表法,得先回到CRC的本源。CRC全称是Cyclic Redundancy Check,循环冗余校验。很多人第一次学的时候,理解的CRC就是“把数据累加一下,得到个和,取低16位”,那是校验和,不是CRC。校验和是加法思想,CRC是除法思想。
怎么理解这个除法?把你要发送的数据看成一个巨大的二进制数,然后除以一个固定的二进制除数,除完得到的余数就是CRC16的校验值。这个“固定的二进制除数”在CRC里叫生成多项式。你发送数据的时候,把这个余数附在数据末尾一起发出去。接收端拿到完整的数据(数据+余数),再做一次同样的除法,如果余数不为0,说明数据在传输过程中被篡改了。核心是除法,是求余,不是求和。
这里要强调一个细节:这种除法不是普通算数除法,是模2除法,可以理解为一种不带进位的二进制除法,每一位的减法都用异或代替。所以整个CRC计算过程,你看到的操作全是异或和移位,没有借位也没有进位,硬件实现和软件实现都非常干净。这也是CRC在通信领域地位极高的原因之一,逻辑简单,容易做成芯片级电路,而且检测错误的能力非常强。
1.2 逐位计算虽然直观,但慢得让人着急
既然核心是除法求余,那最朴素的做法就是按bit去算。假设寄存器16位,对每一个数据bit,把寄存器左移一位,移出去的那个bit如果等于1,寄存器就异或一下多项式。写成代码,核心循环就是:每来一个bit,判断最高位,决定要不要异或。一个字节要跑8次循环,一帧数据几百个字节,哪怕主频跑到上百MHz的MCU,这个循环次数也是不小的开销,更别说那些还在用8MHz内部时钟的小单片机。
我在STM32F103上调过9600波特率的Modbus通信,数据量不大时逐位算法还勉强凑合。可一旦把波特率提上去,或者报文超过100个字节,逐位算法的耗时就会明显拖累整个主循环。有个项目用一颗非常便宜的8位MCU同时采集两路传感器并通过串口转发数据,逐位算法算CRC的耗时占了整个处理时间的将近四分之一,查表法一换,这个占比直接降到可以忽略不计。查表法的意义就在这儿:用空间换时间,把耗时的计算量提前“预计算”好,运行时只做查表和几次异或。
2. 查表法的数学本质:它为什么能从“循环8次”变成“查一次表”
2.1 从一个字节驱动的8次移位,推导出查表的公式
查表法的核心思想,是把逐位算法中对每一个bit做的操作,打包成一个字节一轮的处理。怎么理解这个“打包”?我们看一个关键事实:CRC寄存器有16位,我们对一个数据字节做CRC处理时,是把寄存器的高8位和这个数据字节做异或,然后在这个结果上做8次左移和异或运算。这8次运算的结果,只取决于两个东西:异或之后的那个8位值和多项式本身。和寄存器原来的低8位有关系吗?有,但因为移位关系,低8位最终会被移入高8位参与运算,不过整个过程的线性叠加性质决定了,我们可以把高8位异或后的值单独拿出来,预先把“它经过8次移位异或后变成什么”计算好,存到一张256长度的表里。
这个推导过程的数学基础是模2除法的线性性。你可以把整段数据看成多个字节的多项式之和,CRC计算结果是线性的,所以每个字节对最终结果的贡献可以独立计算再叠加。查表法正式利用了这一点:对于每个可能的字节值(0~255),提前计算它作为“高8位异或结果”时,经过完整的8次迭代后对16位寄存器产生的“增量”,存成表项。计算的时候,当前寄存器高8位和当前数据字节异或得到索引,从这个索引取出的表项,和寄存器低8位左移8位后的值再异或,就得到新一轮的寄存器值。
我换个更直白的方式说。逐位算法里,每处理一个字节,要执行8次“左移+判断+异或”。而查表法把待处理的字节和寄存器组合成一个8位的索引,用这个索引一次取出8次运算的最终结果,直接把8次循环压缩成了“一次查表+一次异或”。这就是查表法的原理精髓。理解了这一点,你就能明白为什么表必须是256项,为什么表项是16位的值,也就能自己推导表的生成算法了。
2.2 表是怎么生成的?自己手写一遍,胜过背十遍代码
表的生成其实非常直白。对每个0到255之间的数i,把i放到16位寄存器的最高8位,低8位清零,然后像逐位算法一样跑8次移位异或,跑完之后寄存器里的值就是表项[i]。用C语言写出来,表生成函数长这样:
#include <stdint.h> #define CRC16_POLY 0xA001 // 多项式去掉最高位,注意这是低位反转形式 uint16_t crc16_table[256]; void crc16_table_init(void) { for (uint16_t i = 0; i < 256; i++) { uint16_t crc = i << 8; // 把这个字节放到高8位 for (uint8_t bit = 0; bit < 8; bit++) { if (crc & 0x8000) { crc = (crc << 1) ^ CRC16_POLY; } else { crc = crc << 1; } } crc16_table[i] = crc; } }仔细看这段代码里的多项式0xA001,它不是常规的0x8005倒过来吗?对,在计算机里,有人习惯用“MSB先移”的正常多项式0x8005,有人习惯用位反转后的多项式0xA001,这其实就是不同流派,本质一样。我在生成表的时候用了0xA001,配合右移算法风格;如果你在别处看到的代码用的是0x8005配合左移,那是同一套数学原理的两种表达方式,都能生成正确的表,但使用时对应的查表代码必须匹配。这个坑在后面的常见问题里我会重点说。
2.3 查表计算和逐位计算,代码上怎么对照理解
现在我们把逐位计算改成查表计算。逐位算法里,外层循环遍历每一个字节,内层循环处理8个bit。查表算法直接把内层循环去掉,变成下面这样:
uint16_t crc16_update_table(uint16_t crc, uint8_t byte) { uint8_t idx = (uint8_t)(crc >> 8) ^ byte; // 高8位与数据字节异或 return (crc << 8) ^ crc16_table[idx]; } uint16_t crc16_compute_table(const uint8_t *data, size_t len) { uint16_t crc = 0xFFFF; // 初始值,取决于你用的CRC模型 for (size_t i = 0; i < len; i++) { crc = crc16_update_table(crc, data[i]); } return crc; }这个函数看着简单,但每一步都有严格的数学推导。idx为什么是crc >> 8异或byte?因为下一次迭代中,寄存器的高8位即将被移出,而这8位正好和当前数据字节参与第一次异或。crc << 8意味着低8位被移到高8位的位置,然后异或表项。表项低8位其实是“这8次运算中,低8位被高8位影响后产生的结果”。这样一轮操作,完全等价于逐位算法处理一个字节的全部8次移位。
为了让你更直观地看到两者的对应关系,我来对比一下处理同一个数据时,逐位和查表两种方法的关键操作:
| 对比维度 | 逐位算法(Bit-by-Bit) | 查表算法(Table-Driven) |
|---|---|---|
| 内层循环 | 每个bit都要移位判断,一个字节循环8次 | 无内层循环,一个字节一步完成 |
| 核心操作 | 左移/右移 + 位判断 + 条件异或 | 高8位异或取索引 + 查表 + 异或表项 |
| 时间复杂度 | O(N×8) | O(N),N为字节数 |
| 存储开销 | 无额外内存 | 256×2字节,即512字节RAM/ROM |
| 适用场景 | MCU内存极小且数据量少的场合 | 绝大多数嵌入式通信场景 |
512字节的存储,在任何现代MCU上都可以忽略不计,哪怕是只有2KB RAM的8位单片机,省一省也能放得下。查表法这点代价换来的性能提升太明显了。很多老工程师把CRC计算放在中断里直接算,用查表法才敢这么干,逐位算法在中断里跑会挤占其他任务的实时性,那风险就大了。
3. 查表法计算温度:传感器数据校验和转换里的实战场景
3.1 为什么校验里会牵扯到“查表法计算温度”
你可能好奇,热搜词里出现了“查表法计算温度”,这和CRC16查表法有什么关系?其实在这个场景里,“查表”出现了两种完全不同的含义。第一种是CRC16查表校验,用来判断传感器传上来的数据帧有没有被干扰。第二种是温度查表,比如NTC热敏电阻的ADC采样值换算成温度,或者热电偶的毫伏电压查温度表,本质上是因为这些传感器输出与温度的关系是非线性的,直接用公式算很复杂,不如提前算好一张“ADC值-温度”对照表,用的时候直接查。
很多工业采集模块正是把这两件事放在一起的:单片机先通过CRC16查表法校验温度传感器的数据帧是否完整、是否有错,校验通过了,再用查表法把传感器原始读数换算成实际温度。所以“crc16查表法计算温度”这个词,把两件围绕“查表”的核心工作串联在了一起。
我举个例子。有一个项目用DS18B20温度传感器,DS18B20的9-12位分辨率模式会返回两个字节的温度数据,再加上CRC校验字节。主机读取数据时,先对前面9个字节做CRC16或者CRC8校验,确认无误了,再把那两字节的温度数据进行位运算拼成一个有符号数,得到温度。而用NTC的场景更典型:NTC的阻值和温度之间是近似指数关系,想要在全温度范围都保持精度,要么用Steinhart-Hart公式做浮点运算,要么直接用查表法。查表法的好处是快、没有浮点库也能跑、还省电,几百个点的表放Flash里,查到相邻两点后线性插值一下,精度完全够。
3.2 一次完整的温度采集+校验:代码层面怎么走
假设你手头有一颗NTC热敏电阻,通过一个分压电路接到单片机的ADC引脚,同时你还有一颗温湿度传感器通过UART上报带有CRC16校验的数据帧。你需要在同一颗MCU上完成两项任务:解析传感器帧用CRC16查表校验,把ADC值通过温度表换算成温度。
先写CRC16校验部分:
#define CRC16_INIT 0xFFFF uint16_t crc16_modbus_update(uint16_t crc, uint8_t byte) { uint8_t idx = (uint8_t)(crc >> 8) ^ byte; return (crc << 8) ^ crc16_table[idx]; } uint8_t sensor_frame_verify(const uint8_t *frame, uint8_t len) { uint16_t crc = CRC16_INIT; for (uint8_t i = 0; i < len - 2; i++) { crc = crc16_modbus_update(crc, frame[i]); } uint16_t recv_crc = frame[len - 1] << 8 | frame[len - 2]; // 低字节在前 return (crc == recv_crc); }这里用了Modbus标准里的CRC16参数模型,初始值0xFFFF,输出无额外异或,字节序是低字节在前。这也是工业通信里最常见的配置。
再看温度表的部分。温度表通常不会把每一个ADC值都存一遍,而是每隔一定间隔存一个关键点,比如每5℃或者每10℃存一个ADC值。在得到当前ADC值后,先在表里找到它落在哪两个点之间,然后用线性插值算出当前温度。伪代码如下:
typedef struct { uint16_t adc_value; int16_t temperature; // 单位0.1℃ } temp_table_t; const temp_table_t temp_table[] = { { 3900, -300 }, { 3600, -200 }, { 3200, -100 }, // ... 省略中间大量点 { 800, 800 }, }; #define TEMP_TABLE_SIZE (sizeof(temp_table) / sizeof(temp_table[0])) int16_t lookup_temperature(uint16_t adc_value) { for (uint16_t i = 0; i < TEMP_TABLE_SIZE - 1; i++) { if (adc_value >= temp_table[i].adc_value && adc_value <= temp_table[i+1].adc_value) { // 线性插值计算 int32_t temp = temp_table[i].temperature + (int32_t)(adc_value - temp_table[i].adc_value) * (temp_table[i+1].temperature - temp_table[i].temperature) / (temp_table[i+1].adc_value - temp_table[i].adc_value); return (int16_t)temp; } } return temp_table[0].temperature; // 超出边界处理 }要注意的是,这里ADC值和温度往往是单调关系,但方向可能是反的。温度升高,NTC阻值下降,分压点电压也跟着变化。所以表的排列需要保证ADC值是递增或递减的,插值公式里的符号也要对得上。我实际做的时候发现,最简单的自查方式是把表的首尾两个点的方向关系搞清楚之后再写循环,免得插值出来温度符号反过来。
3.3 查表法在温度计算中的几个关键取舍
温度查表法看起来简单,但用起来有几个经验细节。第一个是表的密度。表越密精度越高,但Flash占用也越大。温差要求0.5℃以内的话,5℃一档然后线性插值基本够;如果要求0.1℃分辨率,可能得每1℃一个点。第二个是边界处理,ADC值超出表范围时要返回一个固定错误码,而不是强行插值,否则会把错误温度直接上报出去,整个控制逻辑就乱了。第三个是滤波配合,ADC采样值本身有噪声,直接查表会看到温度跳动,可以在查表之前做一个简单的滑动平均或者一阶低通滤波。
在嵌入式系统里,把CRC16校验和温度查表放在同一个报文处理任务中是一种非常常见的架构。数据进来,先过CRC校验,检验失败的帧直接丢弃,不参与任何后续计算。只有校验通过的帧,才被解析成原始读数,再进入温度转换流程。这一步“先校验后转换”的顺序千万别搞反。我见过一个项目,先做了温度转换再校验,发现校验失败的那几帧温度换算结果全是乱跳的极大值,后来调整顺序,问题直接消失。
4. 手把手实现:一张CRC16查找表和一个完整的查表计算单元
4.1 表生成函数、查表函数和模型选择
我们直接给出一整套可以直接嵌入工程代码的实现。这一节我同时给出表生成函数和查表更新函数,并补充不同的CRC16参数模型,方便你按需选用。
#include <stdint.h> #include <stddef.h> typedef struct { uint16_t poly; uint16_t init; uint16_t xor_out; uint8_t ref_in; uint8_t ref_out; } crc16_model_t; // 几个常用模型 const crc16_model_t CRC16_MODBUS = { 0x8005, 0xFFFF, 0x0000, 1, 1 }; const crc16_model_t CRC16_CCITT = { 0x1021, 0xFFFF, 0x0000, 1, 1 }; const crc16_model_t CRC16_XMODEM = { 0x1021, 0x0000, 0x0000, 0, 0 }; const crc16_model_t CRC16_USB = { 0x8005, 0xFFFF, 0xFFFF, 1, 1 }; static uint16_t crc16_table[256]; static uint16_t crc16_table_poly; static uint8_t crc16_table_refin; void crc16_table_generate(uint16_t poly, uint8_t refin) { crc16_table_poly = refin ? (uint16_t)reflect16(poly) : poly; crc16_table_refin = refin; for (uint16_t i = 0; i < 256; i++) { uint16_t crc = i << 8; for (uint8_t bit = 0; bit < 8; bit++) { if (crc & 0x8000) { crc = (crc << 1) ^ crc16_table_poly; } else { crc = crc << 1; } } crc16_table[i] = crc; } } uint16_t crc16_compute(const uint8_t *data, size_t len, const crc16_model_t *model) { uint16_t crc = model->init; // 这里假设表已经按model生成,且匹配model for (size_t i = 0; i < len; i++) { uint8_t byte = model->ref_in ? reflect8(data[i]) : data[i]; crc = (crc << 8) ^ crc16_table[((uint8_t)(crc >> 8)) ^ byte]; } if (model->ref_out) { crc = reflect16(crc); } return crc ^ model->xor_out; }这里我加入了反射(ref_in/ref_out)的概念。反射就是把一个字节的bit顺序颠倒,比如0x01变成0x80。很多标准算法为了保证和硬件移位寄存器实现一致,输入输出都需要做位序反转。这也是网上代码流派分歧的根源之一。理解这一点之后,你就不会再犯“拿着CCITT的表去算Modbus的CRC”这种错误。
4.2 如何验证一张表是否生成正确
写完表生成函数,第一件事不是直接上板子,而是用已知数据做验证。我给你一个很经典的基准测试向量:输入数据是字符串“123456789”,注意是ASCII字符,不含结束符,9个字节。不同的CRC16标准,这个输入的校验值是有公认标准答案的:
| 标准 | 多项式 | 初始值 | 输入反射 | 输出反射 | 结果异或 | “123456789”计算结果 |
|---|---|---|---|---|---|---|
| CRC-16/MODBUS | 0x8005 | 0xFFFF | 是 | 是 | 0x0000 | 0x4B37 |
| CRC-16/CCITT-FALSE | 0x1021 | 0xFFFF | 否 | 否 | 0x0000 | 0x29B1 |
| CRC-16/XMODEM | 0x1021 | 0x0000 | 否 | 否 | 0x0000 | 0x31C3 |
| CRC-16/USB | 0x8005 | 0xFFFF | 是 | 是 | 0xFFFF | 0xB4C8 |
拿这些值一对比,只要对上了,你的表和计算函数就是可靠的,可以放心集成。对不上,就要从表生成、反射处理、初始值、结果异或这几个环节逐项排查。我以前调试的时候就碰到过一次,表是对的,初始值也对,结果死活差了0xFFFF,后来才发现是结果异或那一步没做。这种基准测试向量在网上有很多,我建议凡是涉及CRC的代码,第一件事先把这套用例跑通,比什么都管用。
4.3 表放RAM还是放Flash?嵌入式环境的存储规划
表生成有两种落地方式:一种是上电后用软件跑一遍生成函数,把表算出来放在RAM里,适合那些对Flash空间极度敏感、不想为表单独开辟一个const数组的工程;另一种是直接把表定义成const数组,编译时烧录在Flash里,运行时零初始化开销。绝大多数工程我都建议第二种,因为512字节的Flash占用实在不值得用上电时间去换,而且const数组还可以被编译器优化放到只读数据段,不会浪费宝贵的RAM。
只有一种情况我建议用运行时生成,那就是你的MCU Flash空间小到了极致,连512字节都挤不出来的场景。但这种场景也太极端了,真碰到的话,不妨直接退回逐位算法,把Flash省下来换性能,反而更合理。还是那句话,空间换时间是有代价的,但这个代价在绝大多数场景下都微不足道,选型时心里有个数就行。
5. 实战中踩过的坑:CRC16查表法常见问题与排查技巧
5.1 表、多项式、初始值不匹配,最容易翻车
所有CRC16查表法的问题里,出现频率最高的是多项式/初始值/结果异或和通信协议对不上。Modbus的CRC16多项式是0x8005,初始值0xFFFF,输入输出都反转;而CCITT的CRC16多项式是0x1021,初始值可能是0x0000或0xFFFF,里面还分好几种派别。你从网上找的代码可能本身正确,但使用场景和你的协议完全不是一回事。
遇上这种问题,先把“对方协议用的是什么CRC16标准”搞清楚,然后严格按照标准里的参数模型生成表。很多时候,排查到最后不是算法问题,而是需求和定义没对齐。
5.2 字节序:CRC发送时的高低字节顺序
CRC16计算完,结果是个16位数据,发送到总线上时到底是高字节在前还是低字节在前?这完全取决于协议定义。Modbus协议是低字节在前,传统串口自定义协议也常常低字节在前。如果你把高低字节弄反了,接收端算出来自然不匹配。
我的习惯是,在代码里定义一个明确的结构体或宏,把发出去的字节顺序写清楚,同时接收端校验的时候也从协议角度明确“我这个帧尾哪一字节是高位哪一个是低位”,避免两套代码各猜各的。最好在调试助手里直接对比“自己算出来的CRC”和“通信对方帧尾带的CRC”的十六进制值,一眼就能看出高低字节是否反了。
5.3 初始值没重置,静态变量引发的“灵异问题”
查表函数本身是无状态的,每次输入数据算一遍。但如果你把它写成一个带内部静态变量的库函数,上一次调用结束时的CRC值被留作下一次调用初始值用,那就会出大问题。比如一次校验正好一帧数据,第一次调用没问题,第二次调用因为初始值里残留了上次的尾巴,整个结果全错。
解决办法是封装成“先复位,再逐个字节更新,最后取结果”三段式API,或者在入口处显式把参数crc设成init值。说白了就是别嫌麻烦,每次调用前都必须确保初始CRC是模型的init值,否则再怎么查表也是错。
另外还有个小细节:用指针遍历数据时要注意数据长度。拿strlen去算数据长度时,如果数据里含0x00字节,在字符串语境下会被截断,导致CRC只算了一半。我踩过一次很深的坑,自定义协议的数据包里有一个字段值为0x00,刚好在有效数据中间,用C字符串函数处理就出问题了。正确做法是始终把长度作为参数传进去,不要依赖任何隐式长度计算。
5.4 温度查表法里的特殊坑:ADC抖动和表方向搞反
回到温度查表场景,最常见的坑首先是表格方向搞反。NTC的B值特性导致温度越高、ADC值越小,如果你心里想着“温度高ADC大”去建表,那插值就全反了。建表前先根据硬件电路把实测或者理论计算的ADC值序列跑一遍,确认单调性和方向。
其次是ADC抖动。ADC转换结果本身会有几个LSB的波动,尤其电源不稳的时候。如果不做任何滤波,查表得到的温度就会在一个区间内跳来跳去,控制器收到了可能误报警。我习惯是在查表前做滑动平均,比如取最近8次ADC采样值的平均数,再用平均数去查表。这样既平滑了噪声,计算量也很小。
最后有一个我自己常用的排除方法:把“原始ADC值”和“查表后的温度值”同时通过串口打印出来。出现异常时直接对比这两个数,就能快速定位问题出在采样、滤波还是查表环节。这套办法我屡试不爽,强烈推荐。
6. 从16位CRC说开去:查表法的通用优化思路和我的心得体会
6.1 查表法不只适用于CRC16,也适用于CRC8、CRC32
学会了CRC16查表法,CRC8和CRC32就是改参数的事。CRC8表只有256个字节,CRC32表是256个32位值,占用1024字节。同样的表生成逻辑、同样的查表计算逻辑,只要把类型从uint16_t换成uint8_t或uint32_t,多项式按标准填对就行。理解原理之后,整个算法族在你眼里都是同一套思想。这也是我为什么在上面花了那么大篇幅讲推导过程,会套用和能推导是两码事,会推导的人遇到任何参数模型都能快速落地。
6.2 半字节查表和全字节查表,性能与存储的再平衡
除了256项的全字节查表,还有16项的半字节查表,每次处理4个bit,查一次4位表。这种方案表只占32字节,速度是逐位算法的两倍,但比全字节查表慢一倍。在存储和性能都要妥协的极端场景下是个不错的选择。还有一种优化是使用“无初始值冗余”的变体,通过把初始值预计算进第一轮查表的索引里来再省去一次异或,但对新手来说没有必要,因为代码可读性会明显下降。
从工程实践看,256字节全字节查表是综合收益最高的方案,这条经验适用绝大多数嵌入式平台。
6.3 我个人的几点体会
做了这么多年嵌入式,我的体会是,CRC16查表法不是那种“面试考完就忘”的知识点,它在你调试通信、写驱动、做协议栈的时候反复出现。你理解了它的原理,就不再依赖从网上复制粘贴一份代码;你能够根据波形和协议自己推导出正确参数,这就和那些只会背代码的工程师明显拉开了差距。
另一个体会是,把校验和数值转换放在一起思考非常有必要。一次完整的数据采集流程,从原始数据到最终可用的物理量,中间恰好是“先校验、再解码、再转换、再滤波”这几个阶段。排查任何一个环节的问题,都应该沿着这条链路逐段确认。CRC16查表法是链路第一道关,温度查表是最后一公里,把这两头的原理都吃透了,整个数据链路的底子就算打牢了。
最后再分享一个小技巧:做测试的时候可以故意把接收帧里的某一个bit翻转,然后再去跑CRC校验,你会发现大概率(不是100%)能查出错误。CRC16的检错能力虽然不完美,但对常见干扰来说已经非常可靠。要是你还想进一步降低漏检率,可以把CRC32拉出来,原理不变,表更大一点而已。技术这东西,越往底层挖,越有意思。