ARTICLE DETAIL

资讯详情

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

CRC-16校验从原理到代码:参数、查表法与参数化封装

CRC-16校验从原理到代码:参数、查表法与参数化封装 串口丢包、Flash 数据读出来对不上、Modbus 从站偶尔返回错值——这类问题查到最后十有八九是校验环节没做对。CRC-16 和 CRC 校验算法在嵌入式、工业总线、存储校验里几乎是标配但真正让人头疼的不是“要不要用”而是“用哪一种、参数怎么填、代码抄过来为什么算出来不一样”。我见过太多人把网上的 16 位校验代码直接贴进项目跑出来的结果和设备的期望值差了十万八千里最后靠一句“加个取反试试”蒙对了下次换个芯片又崩。这篇就把 CRC-16 从数学原理到通用代码实现整个捋一遍包括逐位算法、查表法、半字节表法的取舍以及参数化封装怎么写才能真正做到一套接口跑通所有变体。不管你是刚接触单片机的学生还是天天和通信协议打交道的老手看完应该都能直接拿去用。1. CRC-16 到底在防什么错从一次串口丢包说起1.1 一个真实调试现场早年做一套基于串口的采集板主机发一条 8 字节的查询指令从机回 20 多字节的数据。逻辑用示波器看完全正常帧头帧尾都在可主机偶尔就是解析失败一天下来错个三五次复现还特别难。一开始怀疑是波特率偏差把晶振换了、分频重算了一遍现象依旧。后来把原始字节抓出来对比发现出错的那几帧里某一位数据被“翻”了过去——本来是 0x0A收到的是 0x0B差的正是最低位。线缆长、旁边有变频器这种偶发位翻转太正常了。这个场景点出了 CRC-16 的核心价值它不是一个用来“修复”错误的机制而是一个用来“发现”错误的机制。工业现场、汽车电子、存储介质这些地方数据在物理层被干扰是常态指望物理层绝对干净不现实。CRC 做的事是让接收方用极小的计算代价判断这一帧数据到底有没有被污染。没有它你可能把一个被翻转的指令当成合法指令执行下去那后果就不是“报错”而是“误动作”了。所以 CRC-16 适合所有对数据完整性有要求、又不想引入太重协议开销的场合比如传感器读取、固件升级包校验、参数存储校验。1.2 为什么是 CRC-16 而不是和校验、奇偶校验最朴素的校验是奇偶校验只能发现奇数个比特翻转偶数个翻转直接漏掉稍微好一点的是累加和校验实现简单但它对字节顺序完全不敏感两个字节互换位置它发现不了而且一旦有进位回绕错误很容易被“抵消”掉。CRC 则是把整段数据当成一个二进制多项式做模 2 除法取余数这个余数对数据的任何一位变化都极其敏感。CRC-16 之所以是“16”是因为它的校验码长度是 16 位。长度直接决定了检错能力一位错误、两位错误、奇数位错误、长度不超过 16 位的突发错误CRC-16 都能 100% 检出更长突发的漏检概率大约是 2 的负 16 次方也就是六万五千分之一左右。这个能力对串口通信、Modbus、以及大多数嵌入式场景已经绰绰有余。再往上还有 CRC-32检错能力更强但计算量和帧开销都大了不少像以太网这种高吞吐链路才值得上 CRC-32。所以选 16 位本质是在“检错能力”和“计算/带宽开销”之间找一个甜点。2. 拆开 CRC-16 的数学骨架2.1 模 2 除法与生成多项式CRC 的数学基础是“模 2 除法”也叫异或除法。普通除法有借位模 2 除法没有借位减法就是异或。比如 1101 除以 101第一位对齐后异或得到的结果再往下对齐直到余数位数少于除数位数。这个余数就是 CRC 的原始值。这里的“除数”对应到一个多项式叫生成多项式。CRC-16 的生成多项式位数是 17 位16 次幂的最高项 16 个低位但最高位固定为 1所以实际记录成 16 位。比如经典的 0x8005 代表 x^16 x^15 x^2 10x1021 代表 x^16 x^12 x^5 1。多项式在 CRC 里不是随便选的它要满足一定的代数性质才能保证前面说的检错能力。选错多项式检错能力会明显下降这也是为什么业界用的都是那几组经过验证的固定值。理解了模 2 除法你就能明白 CRC 的一个反直觉特性计算的过程是“数据左移边移位边异或”而不是先把整个大数算出来再除。这种流式处理正好对上硬件的移位寄存器也解释了为什么 CRC 可以用一个寄存器加几行代码循环搞定。2.2 五个参数决定一切很多人算不出正确结果根子就在参数没对齐。任何一个 CRC-16 变体都由下面五个参数完全确定参数含义常见取值举例Poly生成多项式模 2 除法的除数0x8005、0x1021、0x3D65Init初始值寄存器启动时的值0x0000、0xFFFFRefIn输入反射每个字节是否按位反转后再算true / falseRefOut输出反射最终余数是否按位反转true / falseXorOut结果异或输出前与固定值异或0x0000、0xFFFF这五个参数里最容易出问题的就是 RefIn 和 RefOut。它们不是“可选优化”而是某些标准强制的位序约定。如果 RefIn 为真意味着数据在进入算法前要按位倒序处理对应的算法方向从 MSB-first 变成 LSB-first并且多项式也要用反射后的形式。Init 和 XorOut 则常常成对出现比如 Modbus 用 Init0xFFFF、XorOut0x0000而 USB 用 Init0xFFFF、XorOut0xFFFF。参数错一个结果必然全错而且错得毫无规律压根没法靠肉眼猜。2.3 常见 CRC-16 变体对照表下面这张表我平时调协议就贴在显示器边上直接查参数比自己回忆靠谱名称PolyInitRefInRefOutXorOut123456789 结果CRC-16/IBM (ARC)0x80050x0000truetrue0x00000xBB3DCRC-16/MODBUS0x80050xFFFFtruetrue0x00000x4B37CRC-16/USB0x80050xFFFFtruetrue0xFFFF0xB4C8CRC-16/MAXIM0x80050x0000truetrue0xFFFF0x44C2CRC-16/CCITT-FALSE0x10210xFFFFfalsefalse0x00000x29B1CRC-16/XMODEM0x10210x0000falsefalse0x00000x31C3CRC-16/KERMIT0x10210x0000truetrue0x00000x2189CRC-16/X-250x10210xFFFFtruetrue0xFFFF0x906ECRC-16/AUG-CCITT0x10210x1D0Ffalsefalse0x00000xE5CCCRC-16/DNP0x3D650x0000truetrue0xFFFF0xEA82CRC-16/T10-DIF0x8BB70x0000falsefalse0x00000xD0DBCRC-16/TELEDISK0xA0970x0000falsefalse0x00000x0FB3注意 0x1021 和 0x8005 这两组多项式被大量标准复用区别全在 Init、反射和 XorOut 上。所以当你手上有一份“CRC-16 代码”但对不上结果时先别怀疑代码先去核对目标设备用的是表里哪一行。3. 手工推导一遍不写代码也能算对3.1 单字节 0x80 的完整计算过程光看公式容易飘我拿一个最小例子把每一步都摊开。用 CRC-16/CCITT-FALSE 的参数Poly0x1021Init0xFFFF不反射XorOut0。数据就一个字节 0x80。第一步把寄存器初值 0xFFFF 与 0x80 左移 8 位后的值异或0xFFFF ^ 0x8000 0x7FFF。接下来循环 8 次每次看最高位是 1 就左移一位再异或多项式是 0 就只左移。第 1 次0x7FFF 最高位为 0左移得 0xFFFE第 2 次0xFFFE 最高位为 1左移得 0xFFFC异或 0x1021 得 0xEFDD第 3 次0xEFDD 最高位为 1左移得 0xDFBA异或 0x1021 得 0xCF9B第 4 次0xCF9B 最高位为 1左移得 0x9F36异或 0x1021 得 0x8F17第 5 次0x8F17 最高位为 1左移得 0x1E2E异或 0x1021 得 0x0E0F第 6 次0x0E0F 最高位为 0左移得 0x1C1E第 7 次0x1C1E 最高位为 0左移得 0x383C第 8 次0x383C 最高位为 0左移得 0x7078。最终结果 0x7078因为 XorOut 为 0 就不再处理。这个数你可以直接用后面第 4 节的代码验证一模一样。手工推一遍的意义在于你能亲眼看到“最高位为 1 才异或”这个判断是怎么来的——它就是模 2 除法里“够除就异或”的移位版本。3.2 位反射MSB-first 与 LSB-first 的分水岭反射这个概念绕但必须吃透。MSB-first 是“高位优先”数据从最高位开始进算法寄存器向左移判断条件是最高位是否为 1。LSB-first 正好相反数据从最低位开始进算法寄存器向右移判断条件是最低位是否为 1。关键在于一旦切换到 LSB-first多项式也必须换成“反射形式”否则算出来的一定是错的。反射 16 位的操作很直白把 0x8005 的 16 个比特反过来。0x8005 是1000 0000 0000 0101反序后是1010 0000 0000 0001也就是 0xA001。同理 0x1021 反射后是 0x8408。做 Modbus 这类 RefIn 为真的协议时代码里用的多项式常量应该是 0xA001 而不是 0x8005这是新手最容易踩的坑。我把反射函数写出来方便你在代码里做参数转换uint16_t reflect16(uint16_t v) { uint16_t r 0; for (int i 0; i 16; i) { if (v 1) r | (uint16_t)(1 (15 - i)); v 1; } return r; }有了它你可以在初始化时把标准的 Poly 自动转成反射形式而不用手写两套常量减少出错。顺便说一句RefIn 和 RefOut 如果不一致意味着算法方向和处理方向对不上最终结果需要再反射一次这个逻辑在参数化封装里必须处理后面第 4 节会给完整写法。4. 通用 16 位 CRC 校验代码实现4.1 逐位实现最笨但最不容易错先给最原始的逐位版本非反射MSB-firstuint16_t crc16_bitwise(const uint8_t *data, uint32_t len, uint16_t poly, uint16_t init, uint16_t xorout) { uint16_t crc init; for (uint32_t i 0; i len; i) { crc ^ (uint16_t)data[i] 8; for (int b 0; b 8; b) { if (crc 0x8000) crc (uint16_t)((crc 1) ^ poly); else crc (uint16_t)(crc 1); } } return crc ^ xorout; }反射版本LSB-first注意多项式传入前已经是反射形式uint16_t crc16_bitwise_ref(const uint8_t *data, uint32_t len, uint16_t poly_ref, uint16_t init, uint16_t xorout) { uint16_t crc init; for (uint32_t i 0; i len; i) { crc ^ data[i]; for (int b 0; b 8; b) { if (crc 0x0001) crc (uint16_t)((crc 1) ^ poly_ref); else crc (uint16_t)(crc 1); } } return crc ^ xorout; }逐位法的优点是逻辑透明、改参数方便、不占内存缺点是慢。按每秒百万条指令估一位一位算处理一个字节就是 8 次循环加判断1KB 数据要 8192 次迭代在 8 位单片机上明显吃力。所以它适合两种情况一是数据量很小、实时性要求不高二是作为“金标准”用来验证优化版本的输出是否正确。我平时写新优化算法第一步一定是拿逐位法跑一组测试数据对拍。4.2 查表法256 字节表怎么生成真正工程里用得最多的是查表法。它的思路是把“一个字节 8 次循环”的结果提前算好存成 256 项的 16 位表运行时每个数据字节只需一次异或加一次查表。非反射表生成void crc16_table_gen(uint16_t *table, uint16_t poly) { for (int i 0; i 256; i) { uint16_t crc (uint16_t)(i 8); for (int b 0; b 8; b) { if (crc 0x8000) crc (uint16_t)((crc 1) ^ poly); else crc (uint16_t)(crc 1); } table[i] crc; } } uint16_t crc16_table(const uint8_t *data, uint32_t len, const uint16_t *table, uint16_t init, uint16_t xorout) { uint16_t crc init; for (uint32_t i 0; i len; i) { crc (uint16_t)((crc 8) ^ table[((crc 8) ^ data[i]) 0xFF]); } return crc ^ xorout; }反射表生成注意这里是拿i直接作为低字节循环里向右移void crc16_table_gen_ref(uint16_t *table, uint16_t poly_ref) { for (int i 0; i 256; i) { uint16_t crc (uint16_t)i; for (int b 0; b 8; b) { if (crc 1) crc (uint16_t)((crc 1) ^ poly_ref); else crc 1; } table[i] crc; } } uint16_t crc16_table_ref(const uint8_t *data, uint32_t len, const uint16_t *table, uint16_t init, uint16_t xorout) { uint16_t crc init; for (uint32_t i 0; i len; i) { crc (uint16_t)((crc 8) ^ table[(crc ^ data[i]) 0xFF]); } return crc ^ xorout; }这里有两个细节值得说。第一表必须加const让编译器把它放到 Flash 或 ROM而不是占用宝贵的 RAM。256 项 16 位是 512 字节对资源紧张的单片机来说这可不是小数目不加 const 很容易直接把内存挤爆。第二表的生成既可以运行时算一次也可以离线生成后写死成常量数组。项目固定用一种协议我更倾向于离线把表打印出来固化省掉启动时的计算。4.3 半字节表法小资源单片机的折中不是所有芯片都舍得拿 512 字节存表。这时候半字节表法就派上用场了表只有 16 项总共 32 字节代价是每个字节要查两次表。void crc16_nibble_gen(uint16_t *table, uint16_t poly_ref) { for (int i 0; i 16; i) { uint16_t crc (uint16_t)i; for (int b 0; b 4; b) { if (crc 1) crc (uint16_t)((crc 1) ^ poly_ref); else crc 1; } table[i] crc; } } uint16_t crc16_nibble(const uint8_t *data, uint32_t len, const uint16_t *table, uint16_t init, uint16_t xorout) { uint16_t crc init; for (uint32_t i 0; i len; i) { crc ^ data[i]; crc (uint16_t)((crc 4) ^ table[crc 0x0F]); crc (uint16_t)((crc 4) ^ table[crc 0x0F]); } return crc ^ xorout; }给出三种方法的对比方便按资源选型方法表大小每字节操作数适用场景逐位法0约 8 次循环数据量小、作为参考实现半字节表法32 字节2 次查表Flash/RAM 紧张的 8 位机全字节表法512 字节1 次查表通用嵌入式、数据量较大如果对速度要求更高还有“一次处理 4 字节、8 字节”的 Slice-by-N 变体表更大但吞吐量成倍提升通常用在软件实现的高速网络校验上一般嵌入式项目用全字节表就够了。4.4 参数化封装一套接口跑通所有变体实际项目里经常要同时对接好几种协议为每个协议各写一份 CRC 太蠢。正确的做法是把五个参数打包成配置结构体用同一套接口处理。typedef struct { uint16_t poly; /* 标准形式的多项式如 0x8005 */ uint16_t init; uint16_t xorout; uint8_t refin; uint8_t refout; } crc16_cfg_t; uint16_t crc16_calc(const uint8_t *data, uint32_t len, const crc16_cfg_t *cfg) { uint16_t poly cfg-refin ? reflect16(cfg-poly) : cfg-poly; uint16_t crc cfg-init; for (uint32_t i 0; i len; i) { if (cfg-refin) { crc ^ data[i]; for (int b 0; b 8; b) crc (crc 1) ? (uint16_t)((crc 1) ^ poly) : (uint16_t)(crc 1); } else { crc ^ (uint16_t)data[i] 8; for (int b 0; b 8; b) crc (crc 0x8000) ? (uint16_t)((crc 1) ^ poly) : (uint16_t)(crc 1); } } if (cfg-refout ! cfg-refin) crc reflect16(crc); return crc ^ cfg-xorout; }这里处理了三个容易忽略的点一是refin为真时多项式自动反射调用方只需要填标准值二是refout和refin不一致时补一次反射这是很多简化实现漏掉的分支三是 XorOut 放在最后统一处理。这段代码不追求速度但胜在绝对通用适合做协议对接的“万能参考”。确认参数无误后再把对应变体换成查表版本上生产。5. 新协议参数怎么定从零到可用5.1 选参数的三条经验规则如果是从零设计一个私有协议参数其实可以自由选但我一般按这三条来第一能用现成的就绝不自己造。直接用 CRC-16/CCITT-FALSE 或 CRC-16/IBM工具链、在线计算器、各种语言库全都有现成实现省掉一切沟通成本。私有协议最大的问题是别人对接你的时候还得重新理解一遍出错了你俩都痛苦。第二Init 优先选 0xFFFF。原因很实际如果 Init 是 0x0000那数据前导零字节不会改变寄存器状态一段纯零数据算出来的 CRC 也是零接收端如果不检查长度就可能被空帧蒙混过去。用 0xFFFF 能规避这个边界情况。第三注意异或输出的语义。如果协议希望“CRC 全零表示数据全零”这种特性就把 XorOut 设成 0如果希望接收端在比对时用“整体算完结果为固定非零常量”来判断那就用 0xFFFF。这两种风格各有拥趸关键是发送端和接收端必须统一。5.2 用脚本做交叉验证参数定完之后千万别只靠一段代码自证。我习惯用 Python 写一份独立实现作为“金标准”两边对拍。因为 C 代码里的溢出、类型提升、有符号右移这些坑肉眼很难发现而对拍能一次性暴露。def reflect(v, bits): r 0 for i in range(bits): if v 1: r | 1 (bits - 1 - i) v 1 return r def crc16(data, poly, init, refin, refout, xorout): crc init if refin: poly reflect(poly, 16) for byte in data: if refin: crc ^ byte for _ in range(8): crc (crc 1) ^ poly if crc 1 else crc 1 else: crc ^ byte 8 for _ in range(8): crc ((crc 1) ^ poly) 0xFFFF if crc 0x8000 else (crc 1) 0xFFFF if refout ! refin: crc reflect(crc, 16) return crc ^ xorout print(hex(crc16(b123456789, 0x8005, 0xFFFF, True, True, 0x0000)))这段跑出来应该是 0x4b37也就是 Modbus 的结果。你把它换成任意一组参数再把 C 代码的输出打出来对比全一致了再上板子。这个流程我坚持了很多年能省掉大量“为什么板子上和电脑上结果不一样”的调试时间。6. 实测验证与排查手册6.1 权威测试向量123456789行业里约定俗成用 ASCII 字符串 123456789 这 9 个字节做测试向量因为它长度适中、字节值分散。任何一个 CRC 实现的正确性第一关就是能不能算出对照表里那个值。我一般把这些校验值直接写进单元测试/* 期望值摘自标准参数表 */ assert(crc16_calc((uint8_t *)123456789, 9, cfg_modbus) 0x4B37); assert(crc16_calc((uint8_t *)123456789, 9, cfg_ccitt) 0x29B1); assert(crc16_calc((uint8_t *)123456789, 9, cfg_xmodem) 0x31C3);如果 Modbus 那行过不了先检查多项式是不是用了 0xA001如果 CCITT 过不了但 Modbus 过了检查反射标志是不是传反了。这种断言能把问题定位到“参数”还是“算法”两个层面非常高效。6.2 常见问题速查表现象最可能的原因处理办法结果总差一个固定值XorOut 没处理补上末尾异或结果高位低位字节顺序反了输出字节序问题交换高低字节后再发算出来像镜像值RefIn/RefOut 设置反了检查反射配置短数据对长数据错表生成或索引写错核对表生成循环电脑对、板子错类型提升或移位溢出用 uint16_t 显式截断首字节被漏掉指针或长度传参错误打印长度和首字节确认不同编译器结果不同char 符号性差异统一用 uint8_t表格法与逐位法不一致反射表和算法方向不匹配表生成与算法方向对齐6.3 我踩过的几个坑第一个坑是 Modbus 的字节序。CRC 算出来是 16 位但发到线上时低字节在前、高字节在后和平时读寄存器的高字节在前的习惯正好相反。我第一次对接 Modbus 时因为这一条抓包抓了半天明明算法没错、值也没错就是对不上。后来养成了习惯算完先看协议文档里那帧报文的 CRC 字段长什么样。第二个坑是 8 位单片机上移位溢出。有位同事在 8051 上写 CRC用int存寄存器结果crc 1时高 8 位被丢了算出来的结果和 PC 上永远不一致。8051 是 8 位机int虽然通常是 16 位但很多编译器对 16 位移位的处理并不理想。解决办法是全程用uint16_t并在每次移位后显式截断到 16 位不要图省事用int让它自然溢出。第三个坑是表在 RAM 里把栈挤爆。有个项目表加了几百字节没加const加上其他缓冲区直接导致栈溢出、程序跑飞现象是偶发死机查了好几天。加const之后表进了 Flash问题立刻消失。这个教训很值钱现在只要定义常量表我第一件事就是检查有没有const。第四个坑是增量计算。有些场景希望分段喂数据比如先算包头、再算载荷。这时候不能简单地把两段的 CRC 异或起来因为带 Init 的 CRC 不具备这种可加性。正确做法是让接口接收一个“续算的 CRC 值”作为初值把上一次的结果传进去还要注意 XorOut 只在最后一步做一次中间不能做。7. 不同场景下的落地取舍7.1 8 位单片机与应用处理器的选择差异同样是 CRC-16在 8051 这类 8 位单片机上和在 Cortex-M、应用处理器上选型思路完全不同。8 位机的痛点是 RAM 和 Flash 都紧主频还低这时候优先考虑半字节表法或者干脆逐位法虽然慢一点但省下的几百字节内存可能是决定性的。而且很多 8 位机做的是低速传感器通信数据量就几个字节到几十字节逐位法的开销完全可以接受。到了 32 位 MCU 或者带硬件 CRC 外设的芯片上思路就变了。很多 STM32 系列自带 CRC 外设可以硬件加速但那类外设通常只支持固定的多项式或有限的参数组合用之前一定要看清手册里支持的配置别想当然以为填个参数就能跑。如果不支持你想要的变体还是老老实实软件查表配合 Slice-by-4 之类的优化性能也够用。我的经验是数据量小就用逐位法图省心数据量大就查表法内存实在紧张就半字节表法别为了炫技把代码复杂化。7.2 Modbus、XMODEM 等协议里的实际用法Modbus RTU 是 CRC-16 用得最广的场景。它的参数是 Poly0x8005、Init0xFFFF、RefIn/RefOut 均真、XorOut0。计算时特别注意CRC 只覆盖从站地址到数据域的最后一个字节不包括任何帧头帧尾算完低字节先发。接收方重新算一遍和收到的两个字节比对不一致就丢帧不响应。XMODEM 协议用的是 Poly0x1021、Init0x0000、不反射、XorOut0这个组合实现简单不需要反射操作很适合用在固件升级包校验上。它和 Modbus 的参数完全不一样所以如果你同时维护这两套东西参数化封装的价值就体现出来了一套接口改改配置结构体就行。实际用的时候还有一个容易忽略的点接收方判定 CRC 的方式有两种。一种是重算后和收到的校验值比对另一种是把收到的校验值也当成数据一起算看最终结果是否等于某个固定值通常是 XOR 后的结果。两种方式都正确但前提是发送方和接收方的约定必须完全一致。我见过因为这两套判定方式混用导致“单独测都对、联调就全错”的情况排查起来非常浪费时间。最后说一句我自己的体会CRC 这玩意儿看着简单参数一多就容易翻车。真正省时间的做法不是把代码写得多么花哨而是把测试向量、参数表、对拍脚本这三样东西固化下来每次改动代码都跑一遍。我现在所有项目的 CRC 模块第一行注释就是参数配置第二个函数就是测试向量断言时间长了你会发现这个习惯能帮你躲掉九成以上的“算出来不一样”的扯皮。至于再往下扩展可以把通用接口包一层支持 CRC-8、CRC-32 甚至任意宽度的通用 CRC思路和这里完全一样核心还是那五个参数。
返回列表