ARTICLE DETAIL

资讯详情

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

CRC16查表法详解:从原理到C语言实现与串口校验实战

CRC16查表法详解:从原理到C语言实现与串口校验实战 前几天调试一块板子通信协议里要求对每个数据帧做CRC16校验网上找了两段代码跑出来的结果居然一个对一个错折腾半天才搞清楚是多项式方向不一致的问题。今天就把CRC16查表法这件事一次性讲透。CRC16是一种16位循环冗余校验查表法是在单片机这类资源紧张的平台上最常见也最高效的实现方式。这篇内容会从校验原理、查表思路、完整C语言代码到调试排查一路写下来适合正在用C语言做串口协议、传感器数据采集、Bootloader升级程序或者任何需要保证数据完整性的朋友。1. 为什么数据校验要用CRC16而不是累加和1.1 累加和为什么会漏掉错误很多人在做串口通信时第一反应是加一个校验和把所有字节相加取低8位或者取低16位。这个方案在简单场景下能用但它有一个很明显的问题如果两个字节同时发生错误并且它们的错误量刚好相加为零累加和根本发现不了。举个最典型的例子一个字节从0x01变成了0x02另一个字节从0x03变成了0x02累加和不变但数据已经坏了。另一个常见场景是突发错误。通信链路受到干扰时往往不是单字节出错而是一段连续数据被噪声污染。累加和对这种短周期的突发错误几乎没有防御能力因为高位进位丢失后多个错误组合很容易让最终校验值保持不变。说实话如果是调试用的临时协议用累加和能省不少事但一旦设备要长期运行在工业现场、室外采集端或者电池供电的无线链路上累加和的风险就变得不可接受了。CRC16这类循环冗余校验和累加和的本质差别在于它把整个数据当作一个长二进制数通过模2除法的方式让每一位输入数据都以一种“扩散”的方式影响最终校验值。任何一个bit翻转理论上都会让结果产生明显变化。这也是为什么Modbus、PPP、USB、蓝牙等大量通信协议都选择CRC作为底层校验手段而不是简单累加。1.2 CRC16的工程定位与选型思路CRC16生成一个16位两个字节的校验值这个长度在工程上是个很合适的折中。比8位校验强得多又不像CRC32那样在单片机上有较大的计算开销。对于几百字节以内的数据帧CRC16的漏检率足够低而且实现简单内存占用也很小。STM32、AVR、51系列这些常见MCU跑一个查表法CRC16基本无压力。选型的时候我一般先问三个问题协议里是否已经规定了具体的CRC变体数据帧最长大约多少字节CPU主频和实时性要求如何如果协议是自己定的我建议直接选CRC-16/MODBUS或者CRC-16/CCITT-FALSE因为网上工具最多调试验证方便。如果通信双方是现成设备那只能老老实实按协议手册来特别是Modbus-RTU这类工业总线必须用CRC-16/MODBUS的反射多项式0xA001顺序和初始值都不能改。后面我会把常见的几种变体参数列成表方便对照排查。2. 查表法的核心原理先算好再查表2.1 从逐位计算到查表的思路转换CRC16的逐位计算过程本身不复杂。以非反射的多项式为例每处理一个字节先把这个字节异或到CRC寄存器的高8位然后连续做8次移位和异或运算。每移位一次如果最高位是1就和多项式异或一步。这种算法在代码里只有几行但它的代价是“每处理一个字节就要循环8次”如果数据量是几百字节CPU时间就相当可观。查表法做的事情其实是把“一个字节进来后要连续做8次移位运算”这一整段过程提前算好256种结果存进一张表。因为对一个字节做8次移位运算结果只取决于这个字节的取值和之前的数据没有关系。那么运行时每拿到一个字节只需要做一次查表、一到两次异或和一次移位就把原来8次循环压缩成了几条指令。代价是多占用512字节的RAM或Flash来存这张16位宽、256项的查找表对绝大多数单片机来说完全负担得起。这里有个工程上的经验如果是在Flash空间非常紧张的老平台上也可以把表声明成const数组让编译器把它放到只读段算完就不用占RAM。如果连512字节都不想给就退回去用逐位算法只是在主频很低的MCU上处理长帧时要评估一下耗时是否可接受。2.2 正序与反射一张表还是两张表CRC的实现里有几个容易让人头晕的概念多项式正着写还是反着写、输入数据是否需要反射refin、输出结果是否需要反射refout。以最常见的CRC-16/MODBUS为例它的标准多项式写作0x8005但实际代码实现里用的是反转后的0xA001。原因是这个变体要求LSB first也就是每个字节先处理最低位于是寄存器的移位方向也反过来了。理解这个“反转”并不难。无反射的算法从最高位往低位处理适合大端风格的移位反射算法从最低位往高位处理适合小端风格的移位。Modbus协议栈跑在大量不同架构的处理器上统一采用反射方式可以利用很多处理器对低位优先操作的友好指令。查表法里反射多项式要按照反射方向生成表运行时查表公式也和无反射版本不同。无反射查表是crc (crc 8) ^ table[((crc 8) ^ byte) 0xFF]反射查表是crc (crc 8) ^ table[(crc ^ byte) 0xFF]两个公式很容易记混我后面会结合代码一步步说明。2.3 让理论落到代码先说清CRCTable的物理意义很多人写查表法时只是把网上的代码抄下来一旦结果不对就完全不知道从哪改。所以我建议先理解这张表的物理意义。对于无反射多项式table[i]的含义是一个16位CRC寄存器初始值为i 8也就是把i放在高8位低8位清零然后连续进行8次移位和多项式异或之后的CRC值。对于反射多项式table[i]的含义稍微不同CRC寄存器的初始值就是i本身然后按反射方向连续做8次移位和异或得到的结果存进表里。这正是两个方向查表公式不同的根本原因。明白了这一点以后不管是换多项式还是换初始值你都能自己重新生成表而不是到处求代码。3. 完整C语言实现从建表到计算3.1 无反射查表法以CRC-16/XMODEM为例先看一个最简单、也最适合用来理解原理的无反射变体CRC-16/XMODEM。它的多项式是0x1021初始值是0x0000输入输出都不反射也不做结果异或。下面是完整的建表和计算代码。#include stdint.h #define CRC16_POLY_1021 0x1021 static uint16_t crc16_table_1021[256]; void crc16_table_1021_init(void) { 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_POLY_1021; } else { crc crc 1; } } crc16_table_1021[i] crc; } } uint16_t crc16_xmodem(const uint8_t *data, uint16_t len) { uint16_t crc 0x0000; while (len--) { crc (crc 8) ^ crc16_table_1021[((crc 8) ^ *data) 0xFF]; } return crc; }使用这段代码前要先调用crc16_table_1021_init()生成一次表。有人会问为什么建表循环里crc i 8? 因为无反射算法中一个字节进入CRC寄存器后是先占据高8位再做8次移位所以建表时就把i放在高8位来模拟这个过程。计算函数里的(crc 8) ^ *data拿到的是当前CRC高8位和输入字节的异或结果用它查表正好替代了8次循环。整体跑起来每处理一个字节只有几次整数运算比逐位循环快很多。为了验证代码正确性可以拿标准测试字符串123456789算一下CRC-16/XMODEM的预期结果是0x31C3。如果程序输出和这个值对不上先确认你用的多项式、初始值是否和我上面的完全一致。3.2 反射查表法以CRC-16/MODBUS为例工业现场最常见的CRC-16/MODBUS是反射方式的代表。它的标准多项式写作0x8005但实际参与运算的多项式是0xA001。初始值是0xFFFF输入输出都反射结果不需要额外异或。实现代码如下。#include stdint.h #define CRC16_POLY_A001 0xA001 static uint16_t crc16_table_a001[256]; void crc16_table_a001_init(void) { for (uint16_t i 0; i 256; i) { uint16_t crc i; for (uint8_t bit 0; bit 8; bit) { if (crc 0x0001) { crc (crc 1) ^ CRC16_POLY_A001; } else { crc crc 1; } } crc16_table_a001[i] crc; } } uint16_t crc16_modbus(const uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; while (len--) { crc (crc 8) ^ crc16_table_a001[(crc ^ *data) 0xFF]; } return crc; }这里要注意反射查表的循环是crc 8不是左移建表时判断的是最低位crc 0x0001不是最高位0x8000。这两处非常容易抄错。反射方式的查表公式中(crc ^ *data) 0xFF是先取CRC低8位和输入字节异或再查表然后和CRC右移8位的结果异或。整体逻辑和无反射版本正好左右对称理解了对称关系就不容易混淆。用123456789验证CRC-16/MODBUS预期结果是0x4B37。这个数字在很多Modbus调试工具里都能直接看到如果你用串口助手或在线计算器对上了基本可以确定代码没问题。3.3 常见CRC16变体参数对比与统一封装思路实际项目里可能同时存在多个CRC变体比如设备上行协议用MODBUS内部Flash校验又用了CCITT。为了不让自己看晕我建议维护一张参数表把每个变体的初始值、多项式、反射方式、结果异或值都写清楚。下表是目前工程中最常见的几种CRC16变体测试字符串同样是123456789。CRC变体多项式(标准写法)初始值输入反射输出反射结果异或校验值CRC-16/XMODEM0x10210x0000否否0x00000x31C3CRC-16/CCITT-FALSE0x10210xFFFF否否0x00000x29B1CRC-16/MODBUS0x80050xFFFF是是0x00000x4B37CRC-16/USB0x80050xFFFF是是0xFFFF0xB4C8CRC-16/IBM0x80050x0000是是0x00000xBB3D需要说明的是CRC-16/CCITT-FALSE虽然和XMODEM用的是同一个多项式0x1021但初始值不同一个必须初始化为0xFFFF另一个是0x0000算出来的结果完全不一样。USB变体比MODBUS多了最后一步结果异或0xFFFF。工程上最常见的坑就是把初始值和结果异或值搞混导致两边永远对不上。如果项目里要支持多种变体可以给计算函数加参数把表生成单独拆成交互接口但要注意运行时切换表会带来一定的指令缓存压力。如果是单片机我更建议把几种需要的表全部编译进Flash用const数组保存避免在运行时反复初始化。下面是一个典型的封装示意。typedef struct { uint16_t poly; uint16_t init; uint16_t xorout; uint8_t refin; uint8_t refout; } crc16_config_t; static uint16_t crc16_run(const crc16_config_t *cfg, const uint8_t *data, uint16_t len) { uint16_t crc cfg-init; while (len--) { crc crc ^ *data; for (uint8_t bit 0; bit 8; bit) { if (cfg-refin ? (crc 0x0001) : (crc 0x8000)) { crc cfg-refin ? (crc 1) : (crc 1); crc ^ cfg-poly; } else { crc cfg-refin ? (crc 1) : (crc 1); } } } return crc ^ cfg-xorout; }这段通用代码适合做功能验证或者对速度不敏感的场合但它里面还包含内层8次循环性能比不上查表。真正常年量产的项目我还是推荐用前面那种直接查表的具体实现不同变体复制成独立函数就行。单片机C语言编译器通常不会对太复杂的循环做激进优化与其依赖编译器不如把表提前算死。4. 实战在串口通信与数据包校验中的应用4.1 发送端计算并填充CRC字段在真实的串口协议里CRC字段通常放在数据末尾发送方算好CRC后填进帧尾再一起发出去。这里有一个细节CRC字节的发送顺序取决于协议约定Modbus-RTU是先发低字节再发高字节而有些自定义协议是先发高字节。我曾见过两套设备因为高低字节顺序反了在示波器前排查了一整天才发现问题。以下是一个典型的发送端代码片段。假设帧格式是帧头0xAA功能码1字节数据N字节CRC16低字节CRC16高字节。uint8_t tx_buffer[64]; uint16_t tx_len 0; tx_buffer[tx_len] 0xAA; tx_buffer[tx_len] func_code; for (uint16_t i 0; i data_len; i) { tx_buffer[tx_len] data[i]; } uint16_t crc_val crc16_modbus(tx_buffer, tx_len); tx_buffer[tx_len] crc_val 0xFF; // 低字节 tx_buffer[tx_len] (crc_val 8) 0xFF; // 高字节这个方法有个小地方值得注意计算CRC时只算到数据区末尾不要包含CRC自身。有些新手会把整个缓冲区包括预留的CRC字节传进函数结果算出来的校验值当然不对。发送端算完后接收端要对“包括CRC在内的完整帧”重新计算一遍如果结果等于0就说明帧没问题。这个“冗余码能被整除”的特性正是循环冗余校验的精髓。4.2 接收端校验与错误帧处理接收端处理数据帧时我习惯先把完整数据收进缓冲区再统一做CRC校验而不是一边收一边算。因为串口中断环境下逐字节算CRC会让中断函数变长影响其他实时任务。等收到完整的一帧后在应用层调用一次校验函数逻辑清晰调试也方便。接收端校验有两种常见写法。第一种是只对数据和功能码部分重新算一遍然后和收到的两个CRC字节比较第二种是把CRC字节也一起算进去最后结果应当等于0x0000。第二种代码更简洁而且对Modbus这类协议是标准做法。// 假设已从串口收到完整一帧到 rx_bufferrx_len 是帧总长度包含CRC字节 uint16_t expected_crc crc16_modbus(rx_buffer, rx_len); if (expected_crc 0x0000) { // 帧合法解析数据 } else { // 帧错误丢弃或者请求重发 }实测中我发现在通信线缆比较长或者现场有电机干扰的环境下CRC校验不过的帧大约有一半以上是某个字节在传输中被改了一位。这种错误累加和经常看不出来但CRC16能稳定识别。如果错误率很高不要先去怀疑CRC算法而是优先查串口波特率误差、接地和屏蔽。4.3 项目中的另一个应用传感器采样数据完整性CRC16不只在通信协议里用。我在做温度采集项目时单片机读取外部ADC或数字温度传感器后会把原始采样值和计算出来的CRC一起存到Flash里。下次上电时读取这些数据先做CRC校验如果发现校验失败说明数据在掉电保存过程中可能被写坏就回退到上一次的成功数据。这个方法特别适合那些不能频繁出错的场景比如校准参数、设备序列号、误差补偿系数。这类数据通常只有几十字节但一旦出错设备可能直接罢工。相比之下CRC16查表法只需几十个时钟周期就能检查完一批数据代价很低。查表思想在工程里还有一个常见应用是热敏电阻ADC值转温度也是预先把采样值和温度对应关系做成表运行时直接查但那和CRC查表完全是两码事别把两个“查表”混在一起理解。5. 常见问题与排查技巧5.1 校验结果和在线计算器对不上这是后台问得最多的问题。代码写好了算出来的值和网上某个在线CRC计算器不一样第一反应是不是算法错了。但绝大多数情况不是代码逻辑问题而是参数不一致。在线计算器通常会提供Polynomial、Initial Value、Final Xor Value、Reflect In、Reflect Out这些选项你先把这些参数调到和目标变体一致再看结果。我自己调试时有一个固定做法先用123456789这个标准测试字符串跑一遍和公开的校验值比对。如果对不上就一项一项检查。先确认多项式最左侧的1是否被省略了很多在线工具写的0x1021实际参与运算时是0x1021也有些工具会要求你填完整的0x11021这会造成误解。再检查初始值XMODEM是0x0000CCITT-FALSE是0xFFFF差一个初始值结果天差地别。有一个很容易忽略的地方不同工具对“反射多项式”的展示方式不同。有的工具直接让你填0x8005内部自动改成0xA001有的工具则需要你自己填0xA001。如果你在工具里看到的“Poly”一栏是0xA001说明工具已经替你做过反射了别再额外反转一次。5.2 大小端和字节序问题CRC16结果是一个16位整数但你在串口上看到的是两个字节。很多新手在这个环节被坑。同一个CRC值0x4B37Modbus要求发送0x37、0x4B即低字节在前如果你的设备协议手册写的是“CRC_H, CRC_L”那就是高字节在前。这不是算法问题是协议规定问题必须严格按手册来。建议在代码里把CRC填充封装成一个独立函数而不是散落在各处。这样如果以后协议改字节序只需要改一个地方。在接收端比对时也建议先把收到的两个字节拼成一个16位整数再和本地算出来的CRC比较尽量避免在字节比较的环节引入顺序错误。5.3 查表法的性能与内存权衡查表法虽然快但也不是没有代价。一张CRC16表需要256个16位整数正好512字节。对大多数单片机来说没有压力但如果你用的是Flash只有几KB的超小芯片就要想想值不值得。我自己的经验是数据帧长度超过16字节时查表法基本都值得用如果每次只校验三五个字节用逐位计算反而更省空间。还有一个小技巧表可以生成一次后单独导出成头文件数组编译时直接编译进去。这样运行时不需要调初始化函数也避免因为忘记初始化导致查表全零、结果全错的尴尬。把表定义成const uint16_t crc16_table[256]让它躺在Flash里还能省RAM。5.4 一个很容易踩的坑把CRC算进了自己发送端算CRC时绝对不能把CRC自身包含进去接收端校验时如果采用“全帧再算一遍结果为0”的方式则必须包含CRC。这两种说法看似矛盾其实对应两个不同阶段。发送端是先算后填接收端是填完后整体校验。我在日志台见过无数次这样的bug发送端把CRC预留字节先清零算了一次然后再把结果填回去如果清零不彻底或者把旧值留在里面结果就永远不对。最后分享一下我个人在实际操作中的体会。CRC16查表法本身并不难难点几乎全在参数对齐上。每次拿到一个新的协议第一件事不是急着写代码而是先把多项式、初始值、反射、结果异或、字节序这五个参数写进协议笔记里。有了这四个变体对照表和123456789的基准验证方法整个调试周期能缩短一大半。我现在写CRC相关代码都习惯先跑一遍标准测试字符串再跑真实数据两个都对上了才敢把代码提交到工程里。这个习惯帮我在后续项目里省下了大量联调时间。
返回列表