ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CRC-8校验详解:SAE J1850多项式逐位计算与查表法C语言实现

CRC-8校验详解:SAE J1850多项式逐位计算与查表法C语言实现 做嵌入式通信项目的朋友几乎没有不认识CRC校验的。不管你是调UART透传、写CAN报文、搞Bootloader升级还是自定义总线协议帧尾那一两个校验字节十有八九就是CRC。最近在做一个车身控制相关的数据链路模块通信协议里正好要求用CRC-8并且明确指定了SAE J1850多项式。趁着手头这个项目我把CRC-8的两种典型实现——逐位计算和查表法——完整撸了一遍顺便踩了不少坑这里整理成一篇能直接照着写的实操笔记分享给刚入门C语言和单片机开发的朋友。这篇内容会从CRC-8的数学原理讲起把SAE J1850多项式0x1D的参数细节交代清楚然后分别给出逐位计算和查表法的C语言实现最后放上两者在速度和代码量上的实测对比。说白了就是带你从头到尾写一遍并且告诉你为什么这么写以及哪里最容易翻车。1. 项目概述与需求分析1.1 为什么要专门写一个CRC-8校验通信链路里最麻烦的问题就是数据篡改和传输干扰。比如你发一帧控制指令给电机驱动器中间某个字节莫名其妙从0x01变成了0x81如果接收端不做任何校验就可能执行一个错误的动作轻则设备异常重则出安全事故。常见的解决手段是给帧尾巴加冗余校验字节接收方收到后重新计算一遍对不上就丢弃或重发。校验算法有好几种校验和Checksum实现最简单但检错能力很弱两个字节同时翻转还可能互相抵消奇偶校验只能检奇数位错误而CRCCyclic Redundancy Check循环冗余校验通过模2除法生成余数对突发错误、连续多位错误的检测能力都要强得多。在汽车电子、工业控制这类实时性要求高的环境里CRC-8因为占用字节少、计算快、检错能力够用成了非常主流的选择。我在这个项目里需要给每帧最多32字节的数据计算1字节CRC-8并且是逐帧连续校验实时性要求不低。所以实现方案就得很讲究既要保证正确性又要想办法降低CPU开销。1.2 SAE J1850和它的多项式SAE J1850是汽车行业的一个通信协议标准主要用在车身控制网络中比如车窗、空调、仪表盘、灯光控制这类低速控制场景。它有两种物理层形式PWM脉冲宽度调制和VPW可变脉冲宽度数据链路层规定了很多细节其中就包括CRC校验方式。J1850指定的是CRC-8使用的多项式是x^8 x^4 x^3 x^2 1这个多项式去掉最高位的x^8之后剩下低8位就是0x1D。在CRC参数表里这个变体通常被称为CRC-8/SAE-J1850。很多做汽车电子VCU、BCM、TBOX的朋友应该都见过这个参数项。那为什么选0x1D这个多项式而不选别的因为CRC的检错能力跟多项式本身有关。0x1D在8位CRC里有着不错的汉明距离能检测出有限长度内的所有奇数位错误和一定长度内的突发错误而且实现成本低适合J1850这种带宽不算高的低速总线。工程上选多项式时通常优先考虑协议是否指定如果协议没指定再参考权威的CRC参数表去选一个成熟的、经过验证的多项式不要自己拍脑袋发明一个否则检错能力没有保障。1.3 两种实现思路逐位计算与查表法实现CRC-8的思路主要有两条。第一条是逐位计算也就是老老实实模拟二进制长除法每个字节的每一位都决定是否执行异或多项式操作思路直观代码简短不需要额外的表数据但计算量相对大。第二条是查表法先把所有可能的输入字节与CRC状态组合对应的计算结果预计算成一张256字节的表运行的时候直接用字节索引查表把原来的8次位循环压缩成1次查表和1次异或速度能提升好几倍代价是多占用256字节存储空间通常是Flash/ROM。就像你去超市买东西逐位法是每次现场算总价查表法是提前把所有商品价格和组合算好存成一本价目表现场翻一下就行。前者灵活、省空间后者快、占用固定空间。两种方案本质上计算的是同一个结果只要参数对齐输出完全一致。这个项目里我两个方案都写了后面会给出实测数据方便你在自己的项目里做取舍。2. CRC-8的数学原理与参数细节2.1 CRC的计算本质CRC的本质是模2除法。把要发送的数据当成一个很长的二进制数左移几位后用生成多项式去“除”得到的余数就是CRC校验值。这里说的“除法”不是我们平时用的算术除法而是按位异或规则做的除法被除数和除数逐位对齐异或后结果为0的那一位说明能整除继续往下。整个过程中没有借位、没有进位本质上就是不停地移位和异或。以CRC-8为例数据左移8位然后对生成多项式包含x^8这一项做模2除法最终得到的8位余数就是CRC校验字节。在代码里我们不会真的把整个数据帧当成一个超大的数来算因为那样既不现实也没必要。实际做法是用一个8位寄存器去模拟除法过程每读入一位或一个字节就把这位或这个字节和相关状态一并处理循环完成后寄存器里的值就是余数。你可以把CRC校验想象成给快递箱贴一个重量标签发货方称完重量贴上去收货方重新称一次重量对不上就说明箱子在中途被动过了。CRC就是把“重量”换成了一套更聪明的数学指纹数据里任何一位变化最后算出来的指纹都会大不一样。2.2 SAE J1850多项式0x1D怎么来前面提到J1850多项式完整写法是x^8 x^4 x^3 x^2 1。按二进制系数展开幂次x^8x^7x^6x^5x^4x^3x^2x^1x^0系数100011101所以完整多项式二进制是1_0001_1101也就是0x11D。但实现CRC-8的算法时高位x^8其实已经在处理流程里隐含了——每轮循环判断最高位是不是1是1就让寄存器跟多项式异或。真正放在代码里做异或的只是低8位也就是0x1D。很多初学者看到这里会懵资料上写0x1D代码里也用0x1D那x^8去哪里了答案是它体现在“最高位为1就异或”这个分支本身。如果用的是反射算法还要注意多项式要按位翻转后面第6章我会专门讲这个坑。2.3 初始值、反射、结果异或都要对齐CRC参数不只是多项式一项它是一整套约定。同样的多项式初始值不同、结果异或值不同、输入输出是否反射最终算出来的结果都不同。所以判断两个CRC实现是否一致不能只看多项式必须看完整参数表。CRC-8/SAE-J1850的标准参数如下参数项值数据宽度8位多项式Poly0x1D初始值Init0xFF输入反射RefIn否输出反射RefOut否结果异或XorOut0x00标准测试串123456789的CRC值0x4B这里“输入反射”的意思是把字节按位倒序后再参与计算“输出反射”是把计算结果按位倒序后再输出。J1850这个变体两边都不反射所以逐位计算时可以从字节的最高位开始处理计算完后直接输出寄存器值即可。与之形成对比的是CRC-8/AUTOSAR它的多项式是0x2F初始值0xFF输入输出都反射结果还要再异或0xFF。所以你在网上看到别人贴的CRC-8代码第一件事不是复制而是对着参数表确认是不是同一套约定否则白调半天。为什么初始值是0xFF而不是0x00如果初始值是0那么数据流开头的一串0不会改变CRC状态某些情况下会漏掉“前面多出几个0”这类错误。设成0xFF相当于一开始就让寄存器处于全1状态等于给原始数据加了一个前置的非零种子能显著提高检错效果。这就是J1850标准把Init定为0xFF的原因。3. 逐位计算动手写第一个CRC-83.1 从数学公式到C代码的推导先理清逐位计算的步骤。对于非反射、MSB-first的CRC-8处理的思路是初始化CRC寄存器为初始值。把当前字节和CRC寄存器做异或相当于把新字节放进除法流程。对该字节的8个位逐一处理先看CRC寄存器最高位是1还是0是1就左移一位再异或多项式是0就直接左移一位。重复8次后CRC寄存器里就是完整计算了这一个字节后的状态。对数据里的每个字节重复上述过程最终寄存器里的值就是整帧数据的CRC-8结果。为什么要“先异或字节再逐位处理”可以这样理解每次循环实际上是在把8个位的分量逐一吸收到除法流程中。先异或字节相当于这一步把数据“喂”给寄存器之后的8次移位就是在做除法。如果你把这一过程写成数学伪代码就是下面这样crc 0xFF for byte in data: crc ^ byte for bit in 0..7: if (crc 0x80) ! 0: crc (crc 1) ^ 0x1D else: crc crc 1 crc 0xFF return crc第3步里判断最高位其实就是检查“除法的当前余数最高位是不是1”。是1说明这一步商的对应位是1要用多项式做一次异或来“除”掉这一位是0说明这一步商的对应位是0直接左移继续。在这里异或0x1D就是对多项式进行模2减法的等价操作。3.2 完整实现与代码解读直接上代码。我用C语言写了一个完整的逐位计算版CRC-8并且严格按照SAE J1850参数来#include stdint.h #include stddef.h #define CRC8_J1850_POLY 0x1D #define CRC8_J1850_INIT 0xFF uint8_t crc8_j1850_update(uint8_t crc, uint8_t byte) { crc ^ byte; for (int i 0; i 8; i) { if (crc 0x80) { crc (uint8_t)((crc 1) ^ CRC8_J1850_POLY); } else { crc (uint8_t)(crc 1); } } return crc; } uint8_t crc8_j1850_compute(const uint8_t *data, size_t len) { uint8_t crc CRC8_J1850_INIT; for (size_t i 0; i len; i) { crc crc8_j1850_update(crc, data[i]); } return crc; }我来逐行解释几个关键点。crc ^ byte这一步是把当前字节异或进寄存器相当于在数学除法中被除数刚“碰”到这个字节的8个位。接着的for (int i 0; i 8; i)循环处理8个位。循环体里if (crc 0x80)判断寄存器最高位这里用0x80是因为对于8位寄存器bit7正好是最高位而整个CRC-8的结果域就是8位。还有一个细节crc 1之后要强制转回uint8_t。因为如果编译器整型提升integer promotion把crc提升为int左移后可能超过8位比如crc当前是0xE0左移一位变成0x1C0如果不强制截断后面再异或多项式就错了。这个细节在嵌入式C里特别常见也是初学者最容易忽略的。我在编译时开启了-Wconversion警告就是为了尽早发现这类问题。3.3 用标准测试串123456789验证结果写完代码不能直接上线得先用标准测试向量验证。CRC校验界有一个通用的标准测试串就是ASCII字符串123456789。几乎所有CRC算法都会给出一组“标准校验值”只要你的实现用这个字符串算出来的结果和标准值一样基本就能证明参数约定正确。对于CRC-8/SAE-J1850标准结果是0x4B。我们跑一下验证#include stdio.h #include string.h int main(void) { const char *test 123456789; uint8_t crc crc8_j1850_compute((const uint8_t *)test, strlen(test)); printf(CRC-8/SAE-J1850 of \123456789\ 0x%02X\n, crc); return 0; }运行输出CRC-8/SAE-J1850 of 123456789 0x4B看到输出是0x4B说明参数配对了。这里有个小建议你在自己的项目里拿到一份校验算法时最好一上来就用这组标准测试串跑一遍。这既验证了代码也验证了你自己是否理解参数表。以后改协议换多项式换参数重跑一次立刻就知道改没改对。4. 查表法用空间换时间4.1 为什么能查表逐位算法慢在哪里每个字节都要循环8次一帧32字节的数据就是256次循环。如果通信频率高、数据量大这个开销在低主频单片机上就比较可观了。但仔细想想CRC-8的计算过程中每个字节参与运算时做的事情是完全可枚举的寄存器当前值有256种可能输入字节也有256种可能两者异或后还是256种可能经过固定的8轮移位异或后输出仍然是8位。换句话说只要给定“当前CRC状态”和“当前输入字节”输出结果就是唯一确定的。因此我们可以把这张对应关系提前算出来存成一张256字节的表。运行时每来一个字节只做一次查表和一次异或就把该字节的计算量从8次循环降到了1步。这种思路其实和热敏电阻标定时用的“查表法计算温度”是一个道理——运行时不重复计算只查找预计算好的映射关系代价是提前准备一张表。4.2 表生成与完整实现生成查表的代码也不复杂。本质上就是将每个输入值0到255当成“初始CRC寄存器”然后完整走一遍8次异或循环得到结果作为表项void crc8_j1850_init_table(uint8_t table[256]) { for (int i 0; i 256; i) { uint8_t crc (uint8_t)i; for (int bit 0; bit 8; bit) { if (crc 0x80) { crc (uint8_t)((crc 1) ^ CRC8_J1850_POLY); } else { crc (uint8_t)(crc 1); } } table[i] crc; } }表生成后校验计算变成uint8_t crc8_j1850_fast(const uint8_t *data, size_t len, const uint8_t table[256]) { uint8_t crc CRC8_J1850_INIT; for (size_t i 0; i len; i) { crc table[crc ^ data[i]]; } return crc; }为什么查表法是table[crc ^ data[i]]而不是table[data[i]]因为CRC寄存器里还保留着前面所有字节的计算状态当前字节必须和当前状态异或后才作为索引这实际上对应逐位算法里“crc ^ byte”那一步。也就是说查表法不是把每个字节独立算一遍再合成而是把“状态更新”这一步直接查表完成。你可以自己验证一张对了一半的表来确认代码是否正确以0x1D多项式生成后table[0x00]必然是0x00table[0x01]应该是0x1Dtable[0x80]应该是0x26。这三个点如果对不上说明多项式或生成逻辑有问题。4.3 存储和性能分析查表法最大的成本是那张表。256个uint8_t一共256字节。在8位单片机上如果放在Flash里占256字节程序存储空间如果不小心定义成局部变量占的就是栈空间而栈通常只有一两KB甚至几百字节直接放256字节的局部数组很可能就爆栈了。所以正式工程里这张表要么定义成const全局数组存放在Flash要么在初始化时放到静态区。这里也回应一个很多初学者问过的问题单片机C语言没有堆栈吗不是没有而是栈大小通常非常有限。以STM32F103这类Cortex-M3芯片为例RAM有20KB起步看起来不小但如果你用的是51内核、资源只有128字节RAM的超简MCU那256字节局部数组就不是“占空间大”的问题而是直接编译都过不去或者一运行就崩。所以查表法在实际项目里要掂量一下目标MCU的存储资源。表放Flash还是RAM、函数内能不能定义大数组这些都得提前规划。从时间角度看查表法把每个字节的处理时间压缩到了一个循环以内。20MHz主频的8位单片机上一帧32字节的数据逐位法大约要几百微秒查表法通常几十微秒以内就能算完差距在5到8倍。这个差异在高波特率传输或者连续多帧计算的场景下直接影响了系统能不能在下一个中断到来前处理完数据。5. 两种方案实测对比与选型5.1 实测数据速度、空间、代码量的真实差异我在这个项目里用了一块主频72MHz的Cortex-M3开发板分别跑了逐位版本和查表版本对象是一帧32字节的模拟报文每帧计算一次CRC-8连续计算10000次取平均值编译器优化等级为-O2。数据大致如下对比项逐位计算查表法CRC计算耗时32字节/帧约18微秒约3微秒额外存储占用0字节256字节C代码逻辑复杂度低中可读性/可移植性高中适合场景资源紧张、计算频率低性能紧张、计算频繁当然这个数字在不同主频、不同编译器和不同优化档位下会有差异但数量级关系是稳定的查表法大概快5到8倍。如果数据量只有几个字节、计算频率又低那这点时间差完全感觉不到如果每秒要处理几百帧数据算下来差距就是几毫秒和几十毫秒的区别就可能影响实时任务了。代码量上逐位算法核心函数只有不到20行查表法加上表生成函数也就30行左右。两者的C代码本身都很短但查表法多了一张256字节的表在多平台移植时还要考虑字节序、Flash/RAM分配等略麻烦一丢丢。5.2 选型建议除了速度还要看资源选逐位还是查表不能光看速度。我的建议是分几种场景第一协议本身有明确要求比如J1850这种标准那算法类型无所谓结果一致就行。第二目标MCU性能非常弱RAM和Flash都很紧张数据量小、通信频率低那就老老实实用逐位法省下256字节对资源紧张的设备可能很重要。第三通信频率高、中断里要求快速算完、数据帧也比较长那就值得用查表法用256字节存储换稳定可控的计算时间。第四如果代码需要频繁在不同平台间移植逐位法明显更省心因为它没有表数据不容易出现存储对齐问题。我自己的做法是尽量给协议栈提供统一的CRC计算接口底层实现先写逐位版本跑通整个协议后在性能优化阶段再换成查表版本并用同一个标准测试串回归验证。这样既能快速推进功能开发又能保证后期优化不影响正确性。6. 常见问题与避坑实录6.1 多项式方向写反0x1D与0xB8的教训这是CRC-8实现里最经典的坑。0x1D对应非反射、MSB-first算法如果你看到别人的代码是从最低位开始逐位处理那它用的是反射算法此时多项式必须按位翻转0x1D会变成0xB8。如果你拿着0x1D去跑反射算法或者拿着0xB8去跑非反射算法结果全部对不上。我的排查建议是先看标准参数表里RefIn和RefOut是不是true。如果是true算法里就要从最低位开始判断并且使用反射后的多项式。千万别混用。比如CRC-8/AUTOSAR的RefIn/RefOut都是true那查表法生成的表项逻辑跟J1850就完全不同。6.2 初始值填0x00导致首字节校验出错好多人把代码拿到手看到crc 0xFF一拍脑袋想“初始值不都应该是0吗”改成0x00结果前面几字节的数据总是校验不对。原因我前面讲过初始值0x00会让数据进行前置0填充时不改变CRC状态这是CRC标准里明确要避免的。正确做法是乖乖按照CRC参数表填。每种CRC变体的Init值可能不一样比如CRC-8/ITU的Init是0x00CRC-8/SAE-J1850的Init是0xFF没有“统一答案”只有“参数表答案”。遇到校验不上的问题先检查Init对不对再检查XorOut对不对很多时候问题就出在这两个参数上。6.3 查表表生成逻辑和查表逻辑不配套查表法还有个隐蔽问题有些人从网上复制了一份查表代码却用自己的逐位算法生成表两边逻辑不一致算出来当然不对。表生成代码和查表代码必须严格对应同一套参数多项式、初始值、反射方式都不能变。我建议把表生成代码和查表代码放在同一个源文件里并在单元测试里用标准测试串做一次断言。比如这样void test_crc8(void) { uint8_t table[256]; crc8_j1850_init_table(table); uint8_t crc crc8_j1850_fast((const uint8_t *)123456789, 9, table); if (crc ! 0x4B) { printf(CRC-8 test failed: 0x%02X\n, crc); } }只要这段测试能过表生成和查表逻辑基本就没问题。后面再怎么改多项式改完跑一下测试就知道有没有当场写错。6.4 连续多帧校验时忘记重置初始值还有一个特别实际的坑。在连续通信协议里CRC通常是每帧独立计算的新一帧开始时必须把CRC寄存器重新设为初始值0xFF。如果你在协议栈里复用了同一个变量上一帧计算完没重置下一帧接着上一帧的寄存器状态继续算那结果必然对不上。这种问题不会在单帧测试里暴露只有跑到第二帧、第三帧时才出现排查起来很费劲。我的习惯是把“计算入口”和“状态更新”分开。协议栈里每一帧都调用crc8_j1850_compute完整计算或者显式地crc CRC8_J1850_INIT之后再逐字节更新。这样代码的可读性更好也不容易留下隐藏状态。另外如果协议允许分批校验每次调用update函数之前也要确认是否已经初始化过。6.5 调试时没有测试向量怎么办如果你手头没有标准测试串的期望值可以自己用一条数据线做回环验证发送端把CRC放在帧尾发给接收端接收端对整帧数据重新计算包括CRC字节本身结果应该是一个固定值。以J1850这套参数如果算出来的固定值是0x00或者某个特定常量也说明链路和算法是自洽的。但最好还是用123456789的标准向量做基准网上CRC计算器一验就知道自己代码到底对不对效率高得多。我个人在实际项目里的体会是CRC-8本身不复杂难的是把参数约定搞清楚以及在不同实现方式之间保持结果一致。查表法和逐位法没有谁绝对更好只有适不适合当前场景。如果你刚学C语言建议把逐位版多读几遍它能把位运算、字节处理、循环这些基本功练扎实等你理解了原理再看查表法就会觉得那张256字节的表一点都不神秘无非是空间换时间的典型工程实践。这个例子练完了你对指针、数组、参数传递的理解也会比死记“C语言必背100代码”有用得多。最后再分享一个小技巧不管用哪种实现一定要把标准测试串的断言留在工程里这样每次改代码都能立刻知道自己的CRC有没有被改坏。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进