ARTICLE DETAIL

资讯详情

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

CRC-8嵌入式实现:从逐位算法到查表法,详解SAE J1850校验

CRC-8嵌入式实现:从逐位算法到查表法,详解SAE J1850校验 1. CRC-8到底解决什么问题项目里第一次接触SAE J1850时CRC-8让我踏踏实实卡了两天。协议文档写得很简单多项式0x1D初值0xFF结果异或0xFF标准测试向量算出0x4B。可真到写代码才发现网上资料要么只讲查表法要么只给一个计算脚本很少把这两套实现的关系讲清楚。我后来做车载总线相关项目经常要在单片机上处理这种短消息校验干脆把自己验证通过的思路重新整理一遍先用纯粹的逐位计算把每一步看穿再用查表法把速度提上去最后在工程里落地。这篇文章适合刚接触CRC、想自己实现一遍的人也适合那些需要把代码直接搬到实际工程里的老手。1.1 嵌入式通信为什么靠CRC而不是累加和在CAN、LIN、J1850这类总线上数据从A发到B中间必然经过物理介质。线束老化、接插件松动、电磁干扰都可能导致某个bit从0翻成1或从1翻成0。接收端如果完全不做校验一个错误的数据帧可能被当成正常数据执行轻则仪表显示错误重则控制逻辑异常。所以协议里都会加一个校验字段接收方算一遍对不上就丢帧或者请求重发。最简单的做法是把所有字节相加得到一个校验和比如校验和8位就取累加结果低8位。但这种方法有个很明显的问题两个bit错误如果恰好一加一减抵消比如一个字节多了1另一个字节少了1累加和看不出任何变化更不用说单字节内的位翻转很容易在累加时被吞掉。CRC的思路比这严谨得多它把整个数据流当成一个很长的二进制数用多项式除法去除以一个固定的生成多项式最终得到的余数就是校验值。数据里任意一个bit变化经过除法后的余数大概率都会跟着变所以检测突发错误的能力比普通校验和强不少。SAE J1850选择的是CRC-8对于它那类短消息格式来说足够用了。J1850本身是低速半双工总线常见速率10.4 kbps或41.6 kbps一帧通常只有几到十几个字节带8位CRC开销也不大。如果换成CRC-16或者CRC-32校验能力确实更强但对这种短帧和低速率场景意义不大反而是额外带宽成本和计算成本。这里需要特别明确一点CRC只能检错不能纠错更不能当加密哈希用。它面向的是信道噪声、随机干扰这类错误如果有人恶意篡改消息并重新计算CRC那它一点办法都没有。心里有这个边界后面看协议文档时就不会把CRC的作用范围理解错。1.2 SAE J1850里的CRC-8参数三件套很多人纠结“CRC-8不就是一个多项式吗”其实同一句话非常容易误导人。同样是CRC-8多项式0x07、0x1D、0x31、0x2F算出来的结果完全不一样就算多项式一样初值、结果异或、数据是否反射结果也完全不一样。所以描述一个CRC算法时至少要交代多项式poly、初值init、结果异或值xorout最好连refin/refout也一起带上。SAE J1850的参数如下参数取值多项式poly0x1D初值init0xFF结果异或xorout0xFF数据方向MSB first非反射标准校验值0x4B这里先解释一下0x1D的来历。完整生成多项式是x^8 x^4 x^3 x^2 1换算成二进制是1 0001 1101最高位x^8固定存在所以实现的时候只保留低8位就是0x1D。写代码时用来异或的那个数就是0x1D。初值0xFF的意思是寄存器最开始填全1结果异或0xFF的意思是算出原始CRC后再和0xFF异或一下才作为最终输出。从校验角度看初值全1可以让“消息前头多几个0字节”和“消息本身以0开头”产生不同的CRC特征不会出现寄存器一直是0、算啥都是0的尴尬。结果异或算是标准规定协议文档这么写实现就必须跟着做。“标准校验值0x4B”这句话很关键。CRC领域有个通用测试向量对ASCII字符串“123456789”进行计算得到的结果如果和标准值一致说明算法实现正确。SAE J1850对应的校验值就是0x4B。后面写代码用来验证就靠它了。2. 从逐位计算看CRC-8的数学本质2.1 移位寄存器和模2除法要理解逐位实现先抛开查表回到CRC最原始的运算模2除法。普通除法有借位、有进位模2除法不做进位也不做借位加法减法都用异或代替。一个8位CRC寄存器就像一个小型除法器数据bit从一侧移入寄存器不断左移每移出一位根据移出的bit决定要不要对寄存器异或多项式。整个过程等价于用数据比特流对应的多项式去除以生成多项式最后剩下的余数就是CRC。可以打一个生活化的比方。把数据流看成一大串二进制数字CRC生成多项式看成除数计算CRC就是把这一大串数字除以除数取余数。发送方把余数附加在数据后面发出去接收方用同样的除数做除法正常情况下余数应该符合约定数据在传输中发生了位翻转商和余数都会变接收方就能识别出来。这个比喻虽然不严谨但能让人很快理解CRC的定位。逐位算法里那句“if (crc 0x80)”不是玄学。8位寄存器左移一次最高位会从左侧溢出这个溢出的bit决定了当前这一步是否要异或多项式。如果溢出的bit是0表示这轮除法不需要减去除数直接左移如果溢出bit是1就说明高位产生了需要消除的项用异或多项式来把它清零。这是所有左移型CRC的核心。记得我刚把这段逻辑用C实现时总觉得“为什么不是和数据bit直接异或”后来才想明白寄存器当前值已经包含了历史数据的全部影响新进来的数据字节必须先和寄存器当前值合并然后再整体做一次除法推进。寄存器里的每一位都是过去所有数据bit经过模2运算后的残留状态这也是CRC被称为“循环冗余校验”的原因——它本质上是在一个有限域里不断做多项式长除。2.2 一个字节进寄存器后发生了什么逐位处理一个字节实际上分两步先把当前寄存器值和这个字节异或得到一个新的“初状态”再连续做8次移位和条件异或。这8次移位每次处理一个bit最后寄存器里的值就是该字节对CRC的贡献。之所以要先异或再移位是因为数据字节要和寄存器里已有的状态一起参与除法而不是单独算一个字节的CRC再叠加。处理过程可以拆成下面几步crc ^ data[i]让数据字节和原寄存器状态合并。循环8次检查最高位crc 0x80。如果最高位为1crc (crc 1) ^ poly。否则crc crc 1。处理完整个数据序列后crc ^ xorout得到最终校验值。这里有一个新手最容易忽略的细节crc是uint8_t但左移时C语言会先把它提升为int左移结果可能是9位甚至更多位。所以一定要用类似(uint8_t)((crc 1) ^ poly)的写法否则某些编译器警告不断极端情况下还会因为截断时机不对导致结果错误。我在下面代码里统一做了强制转换目的就是让每一步都明确只保留低8位。2.3 逐位计算代码逐位法的代码很短直接看#include stdint.h #include stddef.h #define CRC8_POLY 0x1D #define CRC8_INIT 0xFF #define CRC8_XOROUT 0xFF uint8_t crc8_bitwise(const uint8_t *data, size_t len) { uint8_t crc CRC8_INIT; for (size_t i 0; i len; i) { crc ^ data[i]; for (uint8_t bit 0; bit 8; bit) { if (crc 0x80) { crc (uint8_t)((crc 1) ^ CRC8_POLY); } else { crc (uint8_t)(crc 1); } } } return (uint8_t)(crc ^ CRC8_XOROUT); }代码很短逻辑也很直白。用“123456789”实测输出应该是0x4B。这里有个细节test数组我特意定义成uint8_t而不是char就是避免符号扩展问题。字符串字面量里虽然只有9个可见字符但后面其实还跟着一个\0所以调用函数时len必须写9不能直接sizeof(test)否则会多算一个字节。这个函数没有任何全局状态可以直接被多任务或者中断上下文调用只要data指向的数据在有效生命周期内就行这正是后面做协议栈时我最喜欢它的原因。3. 查表法把8次循环变成一次查表3.1 表里到底存了什么逐位法好懂但每个字节都要走8次循环。数据短还好如果一帧几十上百字节在低主频单片机上这部分耗时就会变得扎眼。查表法的思路很朴素内层那8次移位和条件异或本质上是一个固定映射输入是某8位状态输出是8次处理后的8位状态。这个映射只有256种可能干脆提前算好存成一张表运行时用一次查表代替8次循环。表里的每一项table[i]的含义是初始寄存器状态为i不额外异或输入数据直接做8次逐位处理后得到的寄存器值。注意这里是把“i”当作CRC寄存器的当前状态而不是当作输入数据。所以建表代码和逐位代码看起来很像区别只是把输入固定成了索引。具体到init_table里的循环做的事情是这样的对i从0到255crc i;执行8次逐位移位若crc 0x80crc (crc 1) ^ poly否则crc 1把结果存到crc8_table[i]。这样一张表只有256个uint8_t也就是256字节对绝大多数单片机Flash来说完全可以接受。如果Flash特别紧张也可以把表直接定义成const uint8_t crc8_table[256]放在只读区而不是RAM省掉RAM占用。用const表还有个好处初始化函数只用于生成临时表在代码发布前可以预先打印出来填进源码运行期连建表循环都不需要。3.2 建表函数与查表函数查表法实现分成建表和计算两段。代码如下#include stdint.h #include stddef.h #define CRC8_POLY 0x1D #define CRC8_INIT 0xFF #define CRC8_XOROUT 0xFF static uint8_t crc8_table[256]; void crc8_init_table(uint8_t poly) { 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) ^ poly); } else { crc (uint8_t)(crc 1); } } crc8_table[i] crc; } } uint8_t crc8_table_calc(const uint8_t *data, size_t len) { uint8_t crc CRC8_INIT; for (size_t i 0; i len; i) { crc crc8_table[crc ^ data[i]]; } return (uint8_t)(crc ^ CRC8_XOROUT); }查表计算函数比逐位函数更短每处理一个字节只需要一次异或、一次数组寻址、一次赋值内层8次循环被完全省掉。很多工程里为了省去初始化表的麻烦还会直接把这张表做成const数组放Flash里连crc8_init_table都不用跑。两个函数放在一起一个负责准备一个负责计算职责很清晰。3.3 为什么查表公式是crc table[crc ^ data[i]]这句话值得单独讲。不少人在网上看到过16位CRC的查表公式是crc (crc 8) ^ table[(crc ^ data) 0xFF]于是套到8位CRC上就懵了。原因是寄存器位宽不同。16位或32位CRC的寄存器比数据字节宽需要把数据处理成“高8位和当前字节异或”再和低位移出去的部分组合而8位CRC的寄存器宽度和数据字节完全一致处理一个字节时当前crc和data[i]异或之后整个8位状态就是要做8次逐位处理的初始状态所以查表索引就是crc ^ data[i]查表结果直接就是新的crc。这里的“初始状态”和逐位法中crc ^ data[i]之后的寄存器状态完全对应所以两种实现结果必然一致。注意这个简洁公式只适用于非反射、MSB first的CRC。如果协议参数要求LSB first也就是refintrue则逐位实现要用右移查表实现也要配套右移的建表方式此时索引仍然可以是crc ^ data[i]但表的内容和查表方向不同。判断依据永远是“查表实现是否与你的逐位实现共享同一套参数和位方向”而不是机械背公式。我做调试的时候专门验证过这个等价性把同一段数据分别用crc8_bitwise和crc8_table_calc计算当表是按照0x1D建立时两者输出完全一致。所以如果你改了多项式一定要重新建表如果只改了初值或xorout不需要重建表只需要改CRC8_INIT和CRC8_XOROUT两个宏。这个边界很容易踩坑提前说清楚能省不少排查时间。4. 两种实现方式的实测对比与选型4.1 复杂度对比表实现每字节操作存储开销代码量典型速度逐位法8次循环 条件分支几乎为0很小慢查表法1次异或 1次查表256字节更小快5-8倍严格说查表法代码量也不大甚至比逐位法还短一点真正多出来的是256字节表。对大多数现代MCU来说256字节Flash根本不算什么所以工程上普遍倾向于查表法。但在Flash只有几百字节的极简单片机里这256字节可能就比较肉疼这时候逐位法才是合适选择。运行时间方面逐位法每字节固定8次循环假设要处理4096字节就是32768次内层迭代查表法依然是4096次外层迭代。实际测试中查表法一般能快5倍以上具体取决于单片机的Flash访问速度、编译器优化和分支预测等。8位CRC本身计算量不大只有数据量很大、主频很低、中断延迟敏感时才值得刻意追求查表。4.2 我实际怎么选我做J1850协议栈时帧一般只有几到十几个字节接收中断里需要快速判断消息是否完整但又不能拖太久。这种场景逐位法其实也能胜任毕竟一帧总共几十次移位微秒级就完成了。不过协议栈里多个通道都要调用CRC累计起来还是有点开销我最后还是选了查表法表放在const区不占RAM运行期零初始化成本。如果你在做一个多用途通信库希望同一个函数能适配多种CRC参数可以把poly、init、xorout打包成一个结构体函数内部按参数动态建表。这样代码灵活性高代价是要多张表或者每次切换参数后重建表。单片机上如果Flash紧张就保留逐位法参数运行时传入每次调用再逐位算。我个人的经验是先写逐位法因为它最容易调试逻辑一眼到底功能验证通过后再根据实际性能瓶颈决定要不要上查表法。不要一开始就抄一个查表法结果连表怎么来的都说不清后面出了问题很难排查。两套代码都保留用同一个测试向量验证然后才放心。4.3 完整测试程序下面是一份可以直接编译运行的完整示例#include stdio.h #include stdint.h #include stddef.h #define CRC8_POLY 0x1D #define CRC8_INIT 0xFF #define CRC8_XOROUT 0xFF uint8_t crc8_bitwise(const uint8_t *data, size_t len) { uint8_t crc CRC8_INIT; for (size_t i 0; i len; i) { crc ^ data[i]; for (uint8_t bit 0; bit 8; bit) { if (crc 0x80) { crc (uint8_t)((crc 1) ^ CRC8_POLY); } else { crc (uint8_t)(crc 1); } } } return (uint8_t)(crc ^ CRC8_XOROUT); } static uint8_t crc8_table[256]; void crc8_init_table(uint8_t poly) { 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) ^ poly); } else { crc (uint8_t)(crc 1); } } crc8_table[i] crc; } } uint8_t crc8_table_calc(const uint8_t *data, size_t len) { uint8_t crc CRC8_INIT; for (size_t i 0; i len; i) { crc crc8_table[crc ^ data[i]]; } return (uint8_t)(crc ^ CRC8_XOROUT); } int main(void) { const uint8_t test[] 123456789; size_t len 9; crc8_init_table(CRC8_POLY); uint8_t c1 crc8_bitwise(test, len); uint8_t c2 crc8_table_calc(test, len); printf(bitwise: 0x%02X\n, c1); printf(table : 0x%02X\n, c2); return 0; }运行结果两行都是0x4B说明两套实现和标准参数对齐了。如果你编译后看到的第一行是0x4B、第二行不是0x4B问题基本锁定在表建立或者查表公式上如果两行都不是0x4B那就先把逐位函数和参数宏核对一遍。main里为什么len不写成sizeof(test)因为字符串字面量自带结尾0x00而标准测试向量要求计算9个ASCII字符多算一个字节结果就完全不一样。5. 调试排错CRC对不上时我做了什么5.1 最容易踩的四个坑第一个坑是char类型符号扩展。很多通信缓冲定义成char buf[]如果char在编译器里默认是signed char那么0x80以上的字节参与crc ^ data[i]时data[i]会先被符号扩展成负数异或结果就全错了。解决方法是把data指针类型明确为uint8_t*或者在异或时写crc ^ (uint8_t)data[i]。我用调试器看寄存器值时才反应过来那一次卡了整整半天。第二个坑是忘记xorout。先别急着怀疑算法写错SAE J1850标准里明确要求输出前和0xFF异或。如果你只做了init和poly没做xorout结果会和0x4B差一个固定的异或值非常容易发现把0x4B和实际输出异或一下如果得到0xFF那基本就是xorout漏了。第三个坑是表内容和查表公式不配套。比如表用右移方式生成查表却用左移公式或者poly改成了别的值表没重新生成。这种问题表现很诡异有些字节数据能对上有些对不上。排查方式很简单拿一个单字节数据分别跑逐位法和查表法对不上就说明表或查表公式有问题。第四个坑是数组边界和len。查表法里crc ^ data[i]的结果一定在0到255之间因为两边都是uint8_t但data如果是signed char*且高位为1符号扩展后索引可能变成负数直接数组越界。这也再次印证了第一点建议所有协议解析层的buffer尽量用uint8_t不要在中间层混用char。5.2 一套靠谱的验证流程调试CRC最好有一套固定动作不要瞎试。我一般是这么做的先用协议标准指定的测试向量“123456789”跑一遍确认结果等于标准值0x4B。如果不对先用逐位法版本代替查表法因为逐位法依赖的运算少更容易肉眼检查。用一个明确的小报文比如单字节0x00、0x01、0x80、0xFF把每一步crc打印出来和手动推演或在线工具对比。查表法引入后单字节对比逐位法输出。两者一致了再测长报文。最后接入真实总线收端先拿模拟报文验证再上真实设备。这套流程能过滤掉90%以上的实现错误剩下的就是协议参数本身的问题比如标准里poly写作0x1D但有人按0x1E理解这只能靠文档核对。多花十分钟跑一遍测试向量比上线后抓耳挠腮强太多。5.3 和在线CRC计算器对不上怎么办在线CRC计算器很多但每个工具的配置项不一样。有的默认初值是0有的默认不异或输出有的把多项式理解成完整形式。所以对不上时先检查工具界面上的CRC-8参数有没有和SAE J1850完全一致poly0x1D、init0xFF、xorout0xFF、refinfalse、refoutfalse。如果工具没有refin/refout选项至少确认它是MSB first还是LSB first。另外有些计算器在输入“123456789”时会把字符串末尾的结束符也算进去导致结果始终差一位数据。输入时要明确给它9字节内容不带结尾的0x00。因为这个原因我后来习惯直接在自己代码里打印每个字节而不是依赖网页工具。网页工具只是参考最终以标准文档和标准测试向量为准。6. 把CRC-8放进真实数据链路中6.1 组帧、发送、接收校验的工程姿势实际项目里CRC很少单独拿来练手它通常是协议帧的一部分。以J1850为例一帧消息包含优先权字节、目标地址、源地址、数据字节和CRC校验字节。发送端把所有数据字节按协议规则算出一个CRC放在帧尾接收端收到后对同样的数据字节调用同一个CRC函数把算出来的值和帧里的CRC字节比对。一致就认为数据有效不一致就说明帧被干扰直接丢弃。注意这里是“对数据域算CRC再和收到的CRC比对”而不是把收到的CRC也一起重新计算再判断结果等于某个特殊值后者虽然在某些参数设定下也能工作但不同协议差异很大容易绕晕。组帧时还有一个容易被忽略的点发送顺序。SAE J1850数据是MSB first而我们这套逐位/查表实现也默认按MSB处理所以不需要额外反转。如果协议是LSB first你可能需要在发送前把每个字节反转或者直接用配套的右移算法。两种做法都行关键是收发双方保持一致。6.2 当协议参数不完全是SAE J1850时怎么办CRC-8的应用远不止J1850比如一些私有协议会用poly0x07还有一些会用反射型poly0x31。如果协议文档给了不同的参数不要硬套上面的代码。只要refin/refout都是false把三个宏改掉即可poly改成文档值init和xorout也照文档改。如果标准文档里没有写清init和xorout那这个协议定义是不完整的必须找原作者确认。如果协议要求LSB first就得写右移版本。逐位右移的核心是判断最低位if (crc 0x01) crc (crc 1) ^ reflected_poly否则crc 1。这里的reflected_poly是把原多项式按bit反转之后的值比如0x1D反转后是0xB8。查表法建表时也要用右移逻辑表的内容会完全不一样。不少人在网上看到右移代码后直接替换左移代码结果跑出来和标准值对不上原因就在这里。另外代码封装时我强烈建议不要在多个文件里各写一份CRC。把参数、函数声明放到一个crc8.h头文件里函数实现放crc8.c工程里到处include就好。CRC这种算法逻辑简单但细节多重复实现很容易出现一个文件用0x1D、另一个文件用0x07的版本混乱。6.3 可重入性与中断注意事项上面两个CRC函数都是无状态函数没有静态可变变量查表法里的表在初始化后只读所以天然可重入。在RTOS任务里调用、在多个中断里调用都没问题只要保证同一时刻不会有人去修改表中的数据。如果你用动态建表方式表被多个函数共享初始化时最好由主程序完成不要中断里初始化。查表法虽然快但如果在非常高频的中断里处理大量数据还是建议先拷贝到缓冲区再在任务上下文里计算。中断里要做的是尽快响应而不是长时间占用CPU。逐位法代码小但耗时更多通常只适合数据量小或对Flash占用极度敏感的场景。最后分享一个我自己的习惯不论哪种实现最终都用标准测试向量验证一次。CRC这个东西看起来小但参数一多就容易出错而且出错方式往往很隐蔽。只要保证代码库里常备一份“123456789 - 0x4B”的测试用例后面改来改去都有底气。反正我自己后来不管写哪个CRC第一件事永远是跑通标准测试向量再谈别的。做过三轮项目之后你会发现CRC-8这类小算法再也不会成为卡住你的点。
返回列表