嵌入式通信CRC选型与实现:CRC16还是CRC32?STM32+W5500实战复盘 做嵌入式的小伙伴应该都遇到过这种场景一个挂在以太网上的温湿度传感器要定时把温度、湿度上报给上位机或者PLC中间隔了交换机、网线、协议栈谁都不敢保证每个字节都分毫不差。我在这个项目里用的是STM32 W5500传感器是SHT30按自定义帧格式封装成私有UDP报文。上位机同事拿着协议文档劈头就问一句这段数据用CRC16还是CRC32多项式多少初值多少RefIn、RefOut开不开当时我就意识到CRC校验远不是写个函数那么简单。这篇文章就把我在以太网温湿度传感器通信里关于CRC16和CRC32的选型逻辑、代码实现、对端联调、踩坑复盘整个过程整理一遍。适合正在做嵌入式通信、需要给链路加校验或者被“同一个CRC16咋算出来不一样”这类问题折磨过的开发者。内容不预设你已经精通CRC我会从帧格式讲到参数配置再讲到可以直接抄的代码最后把实际项目中遇到的坑一个个拆开。1. 选型之前先搞清楚温湿度通信里的CRC到底在防什么1.1 一个真实的以太网温湿度上报帧先看我在项目里用的一帧数据这是一个典型的私有以太网温湿度上报帧AA 55 00 08 01 02 20 13 03 10 12 34 CRCH CRCLAA 55两字节帧头用来让接收端快速找到帧起始位置。00 08两字节长度表示后面还有8个字节。01设备ID现场挂多个传感器时用来区分设备。02命令字表示“上报温湿度数据”。20 13温度数据高位在前比如这里代表0x2013换算成实际温度需要乘以分辨率。03 10湿度数据。12 34应用层序列号用来检测丢包、乱序和重复。CRCH CRCLCRC校验值覆盖从“长度字段”到“序列号”之间的所有字节不包括两字节帧头。接收端拿到一帧数据后先把CRC算出来和报文末尾的CRCH/CRCL比对一致才认这帧有效。有人可能会说以太网不是已经有FCS帧校验了吗网卡底层不是有CRC32吗怎么还要自己做CRC道理很简单底层以太网FCS负责的是“物理链路到物理链路”的完整性而我们的传感器数据要经过TCP/IP协议栈、多级缓冲、应用层拷贝任何一个中间环节出现错位、截断、覆盖底层FCS都管不到。应用层再做一道CRC属于端到端的完整性保障两个校验解决的是不同层面的问题。1.2 为什么不用校验和与奇偶校验早期很多嵌入式协议喜欢用简单的累加校验和就是把要保护的所有字节加起来取低16位或者取反。这种方案实现起来确实简单但问题也明显两个字节互换位置时累加结果不变一个字节多了0x01、另一个字节少了0x01时结果也可能互相抵消数据位发生偶发翻转时漏检率相对较高。奇偶校验更弱基本只能用在UART等单字节链路场景对一帧几十字节的数据包来说定位能力约等于零。CRC不一样。它本质上是把整段二进制数据当作一个多项式用另一个约定好的生成多项式去做模2除法最后的余数就是CRC。数据里哪怕只有一位变化最终余数也大概率不同如果变化发生在某些特定位置CRC的汉明距离又能保证一定抓得住。打个比方校验和像是把所有行李重量加起来丢了一件、捡到一件没准数字还对得上CRC更像是给整段数据做了一道格式固定的除法题商和余数对不上代码立刻就能发现。对于温湿度传感器这种可靠性要求较高的通信场景选CRC而不是简单校验和是值得的。1.3 CRC16与CRC32的本质差异CRC16和CRC32最直观的区别是校验值宽度不一样一个16位一个32位。宽度不同生成多项式的规模不同错误检测能力也不同。但这不是说CRC32就一定比CRC16“高级”而是要看数据长度和协议需求。CRC16对短帧数据非常友好像温湿度上报这种一帧只有十几个字节的场景它能检测出所有1位、2位、3位错误以及所有奇数位错误和长度不超过16位的突发错误。CRC32检测能力更强对几十字节到几KB的数据也有很好的汉明距离同时漏检率更低。缺点是寄存器更宽、查表内存更大、代码里出错时更不好排查。另一个需要考虑的因素是“协议栈本身用的是什么”。以太网物理帧尾部那4字节FCS用的就是CRC-32/IEEE所以如果只说“以太网通信里反正有CRC32了”那确实说的是底层。但我们要保护的应用层温湿度数据用的是自己的CRC配置。实际上我在项目里最后选择的是CRC-16/MODBUS因为后续还要兼容Modbus工具链和部分PLC协议。CRC本身没有绝对好坏关键是要和你的帧长、误码率要求、上位机协议保持统一。2. CRC选型的核心考量帧长、性能、协议兼容性2.1 帧长越短CRC16越够用帧长越长CRC32越稳妥选CRC之前先数一数你要保护的数据有多长。温湿度传感器最典型的上报帧大概十几到几十字节后面如果要扩展成多通道数据、历史记录批量上传、固件升级分包帧长会涨到几百字节甚至几KB。CRC能保证检测多少位错误用汉明距离HD来描述。对某个CRC算法HD4意味着它能检测出任意1位、2位、3位错误超过3位的错误就不能打包票全部检出。CRC16在数据长度不超过一定范围时汉明距离可以保持4CRC32在同样长度下常常能到8甚至更高。换句话说如果数据帧短CRC16的检测能力已经完全覆盖温湿度传感器的可靠性需求如果数据帧动不动上百字节又对可靠性抠得细那CRC32更让人放心。我当时做了一个很简单的判断温湿度每秒钟报一次每次一帧20字节左右链路误码率在没有CRC时已经是小概率事件CRC16能拦住几乎全部可能出现的翻转错误。如果以后要在这个协议上跑固件升级一包512字节我就会把CRC32作为升级通道的校验方式。选型不是拍脑袋而是先问“我最长的一帧有多长、我能容忍的漏检率是多少”。2.2 计算开销逐位法、查表法、硬件CRC怎么选CRC实现方式大概有三条路逐位计算、查表法、芯片内置硬件CRC外设。很多初学者觉得CRC很复杂其实占用的计算资源远没有想象中那么大。逐位法每处理一个字节要做8次位移和异或逻辑最简单代码只有十几行不用额外内存但速度最慢。查表法预先算出256项查找表每处理一个字节只需要做一次异或、一次查表、一次移位CRC-16查表表长256项占512字节CRC-32单表占1024字节如果用slicing-by-4或者slicing-by-8优化表会到4KB甚至8KB但速度更快。硬件CRC外设比如STM32系列里的CRC单元可以做到一个周期或者几个周期算完一个数据块但参数配置和不同芯片的兼容性需要仔细对照手册。放到温湿度传感器这个具体场景里MCU是72MHz的STM32F103一帧20字节就算用最慢的逐位法算一遍也就几十微秒而上报周期动不动就是100ms甚至1秒。说直白点CRC计算根本不会成为瓶颈。选哪种实现更多取决于代码整洁度和个人习惯。我建议只要RAM不紧张就用查表法代码统一、性能充足、逻辑也容易验证。2.3 协议兼容性别让上位机问你“你的CRC是哪个版本”这是CRC选型里最容易掉坑的地方。很多工程师想当然地认为CRC16就应该是某一种固定算法结果对上位机联调时发现两边算出来的校验值完全对不上。原因很简单CRC是一族算法不是一种算法。同一个CRC16有CRC-16/MODBUS、CRC-16/CCITT、CRC-16/XMODEM、CRC-16/IBM等一堆变种多项式、初值、输入输出反射、结果异或各有不同。同一个CRC32也有CRC-32/IEEE、CRC-32/MPEG-2、CRC-32C等。如果项目对接的是Modbus设备或者PLC直接用Modbus RTU那套CRC-16/MODBUS是最省心的整个工控圈子都认这个。如果协议本来就是私有协议那就必须在协议文档里明确写出五个关键参数多项式、初值、RefIn、RefOut、XorOut顺便写清楚计算范围和字节序。我这个项目最开始就是吃了这个亏。上位机同事用在线CRC计算器算出来的结果和我们设备算出来的不一致两边差点吵起来。后来拉了个表格逐项对参数才发现他用的计算器默认是CRC-16/CCITT而协议文档里根本没写我们用哪个。把文档补齐之后半小时内联调就通了。协议兼容性不是说“功能上能通就行”而是要让上下游看到同一个CRC定义时心里想的完全是同一个东西。3. CRC16与CRC32代码实现从原理到工程落地3.1 五个参数决定你用的是“哪一种CRC”正式写代码前必须先把五个参数吃透。现在随便打开网上任意一个CRC计算工具都会让你填这几个值。这里我用最快的方式解释一遍Poly多项式CRC算法的生成多项式通常省略最高位。比如CRC-16/MODBUS的完整多项式是0x18005代码里常简写为0x8005。CRC-32/IEEE的完整多项式是0x104C11DB7代码里简写为0x04C11DB7。Init初值计算前CRC寄存器的起始值常见0x0000、0xFFFF、0xFFFFFFFF。RefIn输入反射数据字节进入算法前是否按位反转即最低位先参与计算。RefOut输出反射最终结果是否按位反转。XorOut结果异或输出或反射后再与一个固定值异或。参数不同结果天差地别。为了验证算法是否写对行业里有一个通用测试向量对ASCII字符串“123456789”计算CRC结果必须是一个固定值。常用CRC参数列在下面这张表里方便你对照算法名称PolyInitRefInRefOutXorOut对“123456789”的校验值CRC-16/MODBUS0x80050xFFFFtruetrue0x00000x4B37CRC-16/XMODEM0x10210x0000falsefalse0x00000x31C3CRC-16/CCITT-FALSE0x10210xFFFFfalsefalse0x00000x29B1CRC-32/IEEE0x04C11DB70xFFFFFFFFtruetrue0xFFFFFFFF0xCBF43926CRC-32/MPEG-20x04C11DB70xFFFFFFFFfalsefalse0x000000000x0376E6E7写完CRC代码后先拿“123456789”算一遍对不上表里这个值说明参数或者代码有错不用急着拿去联调。3.2 CRC-16/MODBUS逐位法与查表法实现我在温湿度传感器项目里最终用的就是CRC-16/MODBUS原因前面说过兼容Modbus生态。先看逐位法实现理解它有助于排查问题#include stdint.h uint16_t crc16_modbus_bit(uint8_t *data, uint32_t len) { uint16_t crc 0xFFFF; // Init 0xFFFF for (uint32_t i 0; i len; i) { crc ^ data[i]; for (uint8_t bit 0; bit 8; bit) { if (crc 0x0001) { crc (crc 1) ^ 0xA001; // 反射多项式 } else { crc 1; } } } return crc; // XOROUT 0x0000 }这里稍微解释一下0xA001是怎么来的。CRC-16/MODBUS原始多项式是0x8005逃不掉RefIn和RefOut都开于是要把多项式按位反转。0x8005按16位逐位反转后就是0xA001。RefIntrue意味着数据字节最低位先进算法RefOuttrue意味着算完后的结果还要做一次16位反转。上面的代码因为从一开始就用反射后的多项式在右移算法里算相当于把RefIn和RefOut都隐含进去了所以不需要最后再额外反转。这个写法是Modbus圈子里最常见的实现。逐位法虽然好懂但在工程上我更推荐查表法。查表法依然从0xFFFF开始每处理一个字节把当前CRC的低8位和要处理的数据异或作为查表索引然后CRC右移8位和表中值异或。查表生成一次以后每次调用都是固定开销uint16_t crc16_modbus_table[256]; void crc16_modbus_init_table(void) { for (uint16_t i 0; i 256; i) { uint16_t crc i; for (uint8_t j 0; j 8; j) { if (crc 1) { crc (crc 1) ^ 0xA001; } else { crc 1; } } crc16_modbus_table[i] crc; } } uint16_t crc16_modbus_fast(uint8_t *data, uint32_t len) { uint16_t crc 0xFFFF; for (uint32_t i 0; i len; i) { uint8_t idx (uint8_t)((crc ^ data[i]) 0xFF); crc (crc 8) ^ crc16_modbus_table[idx]; } return crc; }用查表法之后20字节的温湿度帧算一遍大概几微秒到十几微秒在STM32F103这种72MHz的平台上开销可以忽略。这张表总共512字节RAM完全放得下。写完后别忘了用0x4B37这个测试向量验证一下。3.3 CRC-32/IEEE逐位法与查表法实现CRC-32/IEEE是Ethernet、ZIP、PNG等场景最常见的一种CRC32也是以太网FCS的那一套。在温湿度传感器项目里如果你决定上CRC32多半会选这个。逐位法代码如下uint32_t crc32_ieee_bit(uint8_t *data, uint32_t len) { uint32_t crc 0xFFFFFFFF; // Init for (uint32_t i 0; i len; i) { crc ^ data[i]; for (uint8_t bit 0; bit 8; bit) { if (crc 1) { crc (crc 1) ^ 0xEDB88320; // 反射多项式 } else { crc 1; } } } return crc ^ 0xFFFFFFFF; // XorOut }0xEDB88320就是0x04C11DB7按32位逐位反转后的结果。因为CRC-32/IEEE的RefIn和RefOut都是true所以用一个反射多项式就能写进右移算法最终再异或0xFFFFFFFF。查表法会更实用直接生成一版单表实现uint32_t crc32_ieee_table[256]; void crc32_ieee_init_table(void) { for (uint32_t i 0; i 256; i) { uint32_t crc i; for (uint8_t j 0; j 8; j) { if (crc 1) { crc (crc 1) ^ 0xEDB88320; } else { crc 1; } } crc32_ieee_table[i] crc; } } uint32_t crc32_ieee_fast(uint8_t *data, uint32_t len) { uint32_t crc 0xFFFFFFFF; for (uint32_t i 0; i len; i) { crc (crc 8) ^ crc32_ieee_table[(crc ^ data[i]) 0xFF]; } return crc ^ 0xFFFFFFFF; }这段代码对字符串“123456789”算出来必须是0xCBF43926。需要注意的是单表版速度已经不错但追求极致性能的会在查表时一次性处理4个字节也就是slicing-by-4表会变大到4KB。温湿度上报场景根本用不到这种优化单表足够。3.4 在STM32W5500温湿度传感器上接入CRC校验具体到项目里传感器数据链路是这样的STM32通过I2C读取SHT30的温湿度数据组装成前面说的私有帧再通过SPI接口发给W5500W5500负责发UDP包。发送端在组帧的最后一步追加CRC接收端在帧解析前先做校验。发送端的代码结构大致如下uint8_t frame[128]; uint16_t frame_len build_sensor_frame(frame, temp, humi); // 计算CRC范围从frame[2]开始到frame[frame_len-1]结束 // 即长度字段、设备ID、命令、温湿度数据、序列号 uint16_t crc crc16_modbus_fast(frame[2], frame_len - 2); // 把CRC追加到帧尾这里采用低字节在前 frame[frame_len] (uint8_t)(crc 0xFF); frame[frame_len] (uint8_t)((crc 8) 0xFF); w5500_send_udp(frame, frame_len);接收端的校验逻辑是反过来的。收到一帧UDP数据后先根据帧头帧尾和长度字段确认帧边界然后取出末尾的CRC和重新计算的CRC比较uint16_t recv_crc (uint16_t)frame[frame_len - 1] 8 | frame[frame_len - 2]; uint16_t calc_crc crc16_modbus_fast(frame[2], frame_len - 4); if (recv_crc calc_crc) { update_temp_humi(frame); } else { sensor_stat.crc_error_cnt; }这里要特别提醒CRC高低字节的组装顺序必须和发送端完全一致。发送端如果低字节在前接收端组合时也要低字节在前。很多联调失败最后都出在这种对称性上发送端写了低前高后接收端却按高前低后去解析一上来就CRC失败还以为是算法错了。4. 联调踩坑复盘与排查速查4.1 坑位一参数不匹配算法本身并没有错遇到最典型的问题就是“设备算的CRC和上位机算的CRC对不上”。这个绝大多数情况不是代码写错而是两边用的CRC参数不是同一套。网络上有大量在线CRC计算器默认算法各不相同有人默认CRC-16/CCITT有人默认CRC-16/MODBUS直接拿着默认值去比对很容易怀疑自己。解决这个问题最有效的方法就是使用“123456789”标准测试向量。写一个自测函数上电后对固定字符串算一遍CRC和已知值比对。CRC-16/MODBUS必须是0x4B37CRC-32/IEEE必须是0xCBF43926。自测过了之后再和上位机联调时就把这五个参数截图或者写进文档谁都不用猜。4.2 坑位二计算范围多一字节、少一字节CRC算不对除了参数问题还有计算范围问题。你算了一帧的CRC但文档里没说清从哪里开始、到哪里结束结果发送端把帧头一起算进去了接收端从长度字段开始算两边肯定对不上。我在排查这种问题时会先在发送端和接收端各自把参与CRC计算的字节范围打印出来逐字节对比。比如先打印“calc start 2, calc len 12”再打印参与计算的每一字节基本一眼就能看出差在哪里。协议文档里也应该明确写“CRC计算范围从长度字段第一个字节开始到序列号最后一个字节结束不含帧头不含CRC本身。”如果包结构后续加了版本号、时间戳之类的字段记得同步更新文档。4.3 坑位三TCP粘包/半包导致CRC被错误计算如果你用的是UDP每个报文天然有边界一个send对应一个recvCRC校验相对简单。但如果你为了业务方便把温湿度传感器接成了TCP那就要小心粘包和半包问题。TCP是字节流没有边界概念一次读到的数据可能只有半帧也可能是两帧粘在一起直接在字节流里从某个固定位置取CRC来校验大概率会失败。处理办法不复杂但必须做帧同步。用状态机解析先找帧头AA 55然后读长度字段知道这帧完整需要多少字节再往下收数据直到凑满一帧才做CRC校验。同时加一个超时机制如果收到半包后迟迟等不到剩余字节就清空缓存重新找帧头避免缓存区里一直留着坏数据。我之前在项目里就因为没有做“长度字段合理性检查”遇到一个被干扰的0xFFFF长度值接收缓存差点溢出CRC自然怎么算都不对。4.4 坑位四STM32硬件CRC外设与软件结果不一致有的朋友为了省CPU时间会用到STM32内置的CRC外设。这里要特别提醒不同STM32系列的硬件CRC外设能力差别很大。老一点的F1系列CRC外设参数几乎是写死的计算出来的结果和上面这份软件CRC-32/IEEE代码不一定完全一致F4/H7系列支持配置多项式、初值、输入输出反射但也要能正确配置配套寄存器才行而且不是所有芯片的参数都一样。最稳妥的做法是先把软件CRC调通用“123456789”验证结果再尝试用同一个算法去配置硬件外设同样跑一遍向量做对照。硬件外设算出来的值如果和软件不一样不要本能地怀疑其中一边先查数据手册确认它是否支持你需要的RefIn/RefOut和XorOut配置。温湿度上报这种低频场景CPU其实完全算得过来硬件CRC外设更多是大数据量传输时的优化选项不是必选项。4.5 排查流程与避坑工具箱最后把我常用的排查流程整理成一份速查表照着走能省很多时间现象优先排查项验证方法CRC结果和工具不一致算法参数不匹配用“123456789”标准向量验证收发两端CRC不一致计算范围不一致打印首字节索引和参与计算的数据偶尔CRC失败大部分正常粘包/半包、干扰、缓冲区错位开启帧头长度解析添加超时清缓冲接入硬件CRC外设后不一致外设参数不支持软件配置对照手册回退软件CRC验证上位机显示乱码但CRC通过字节序或数据解析错误确认高前低后打印原始数据项目里我还习惯把CRC错误次数、最近一帧错误时间戳、原始错误帧数据都保存下来便于事后分析。CRC校验通过不奇怪校验失败才是定位协议问题的最好线索。这个项目做下来我最大的体会是CRC16和CRC32没有绝对的好坏只有适不适合当前场景。温湿度传感器这种短周期、低频、小帧的通信任务CRC-16/MODBUS查表法已经提供了足够的可靠性如果哪天要跑批量历史数据或者固件升级分包我才会考虑把通道切到CRC-32/IEEE并且保留双算法兼容。另外把算法参数和校验范围写进协议文档比调通代码本身更能避免返工。最后再分享一个小技巧固件里一定要留一个CRC自测函数上电后对“123456789”算一遍和标准值比对错了就直接报警。这个习惯帮我抓过不止一次编译器优化选项错误、Flash表被意外修改、内存越界覆盖CRC表这类隐藏问题。