4、差错控制机制:奇偶校验、累加和校验、CRC校验原理与查表法实现
做嵌入式通信,最怕什么?
我最怕数据传过去,对方收到的是乱码。尤其是做工业控制的时候,一个bit的翻转,可能让电机转错方向,或者让阀门错误关闭。这可不是闹着玩的。
所以,差错控制是通信协议栈里必不可少的一环。今天我们就聊聊三种最常见的校验方式:奇偶校验、累加和校验,还有CRC校验。我会重点讲讲CRC的查表法实现,这是我在实际项目中用得最多的方案。
4.1 奇偶校验:最简单的保护
奇偶校验,说白了就是数一下数据里1的个数。
它的原理很简单:发送方在数据后面加一个bit,让整个数据(包括这个bit)里1的个数是奇数(奇校验)或者偶数(偶校验)。接收方收到后,再数一遍,如果1的个数不对,那就说明数据出问题了。
举个例子:
假设我们要发送 0b10110010(二进制),里面1的个数是4个(偶数)。
- 如果采用偶校验,校验位就是0,保持1的总数为偶数。
- 如果采用奇校验,校验位就是1,让1的总数变成奇数。
我在项目里用过UART通信,很多MCU的硬件UART模块都自带奇偶校验功能。你只需要配置一下寄存器就行,硬件自动帮你算好。
注意:奇偶校验只能检测奇数个bit的错误。如果两个bit同时翻转,奇偶校验就检测不出来了。说白了,它只能防小错,大错它就扛不住了。
4.2 累加和校验:工程中最常用的土办法
累加和校验,我习惯叫它「求和校验」。做法很简单:把要发送的所有字节加起来,取低8位或者低16位作为校验值。
你想想看,如果数据在传输过程中某个字节变了,累加和大概率也会变。接收方重新算一遍,跟收到的校验值对比,不一样就说明数据有问题。
代码实现也很简单:
// 累加和校验 - 发送端
uint8_t calc_checksum(uint8_t *data, uint16_t len)
{
uint8_t sum = 0;
for (uint16_t i = 0; i < len; i++) {
sum += data[i];
}
return sum;
}
// 接收端验证
uint8_t verify_checksum(uint8_t *data, uint16_t len, uint8_t recv_checksum)
{
uint8_t calc_sum = calc_checksum(data, len);
return (calc_sum == recv_checksum);
}
我曾经在一个温控器项目里用过这个方案。当时MCU资源很紧张,RAM只有256字节,CRC的查表法放不下。累加和校验就成了我的首选。虽然它检测能力不如CRC,但对于温控器这种对实时性要求高、对误码率要求不极端苛刻的场景,完全够用。
我的经验:累加和校验有个变种叫「异或和校验」,就是把加法换成异或运算。异或运算在硬件上更快,有些MCU甚至一条指令就搞定了。如果你追求极致速度,可以试试这个。
4.3 CRC校验:工业级的可靠性
CRC(循环冗余校验)是今天的主角。它的检测能力比前两个强太多了。
CRC的原理说起来有点绕,我尽量用大白话讲清楚。
CRC的本质,是把数据看作一个很大的二进制数,然后除以一个固定的「生成多项式」。除法的余数就是CRC校验值。接收方用同样的多项式去除收到的数据,如果余数为0,说明数据大概率没问题。
为什么会这样?因为CRC的数学基础是多项式除法,它比简单的加法或者奇偶校验更能捕捉到数据中的错误模式。说白了,CRC能检测出几乎所有常见的传输错误,包括奇数个bit错误、偶数个bit错误、突发错误等等。
4.4 查表法实现:速度与资源的平衡
CRC的原始实现是逐bit计算的,速度很慢。你想想看,一个字节有8个bit,如果数据有100个字节,就要循环800次。这在高速通信中根本扛不住。
所以,工程中常用的是查表法。思路很简单:提前算好所有可能的字节对应的CRC值,存成一张表。计算的时候,每次处理一个字节,查表得到结果,再跟之前的CRC值做异或和移位操作。
以CRC-8为例,查表法的实现如下:
// 生成CRC-8查表
// 生成多项式:0x07 (x^8 + x^2 + x + 1)
void crc8_init_table(uint8_t *table)
{
for (uint16_t i = 0; i < 256; i++) {
uint8_t crc = i;
for (uint8_t j = 0; j < 8; j++) {
if (crc & 0x80) {
crc = (crc << 1) ^ 0x07;
} else {
crc = crc << 1;
}
}
table[i] = crc;
}
}
// 查表法计算CRC-8
uint8_t crc8_calc(uint8_t *data, uint16_t len, uint8_t *table)
{
uint8_t crc = 0;
for (uint16_t i = 0; i < len; i++) {
crc = table[crc ^ data[i]];
}
return crc;
}
你看,查表法的核心代码只有一行:crc = table[crc ^ data[i]];。每次处理一个字节,只需要一次查表、一次异或、一次赋值。速度比逐bit计算快了8倍。
性能对比(基于STM32F103 @72MHz):
| 实现方式 | 处理100字节耗时 | RAM占用 | ROM占用 |
|---|---|---|---|
| 逐bit计算 | 约120μs | 0字节 | 约100字节 |
| 查表法(256字节表) | 约15μs | 256字节 | 约50字节 |
从表格可以看出,查表法用256字节的RAM换来了8倍的速度提升。对于大多数MCU来说,这个交换是值得的。
4.5 避坑指南:我踩过的CRC坑
做CRC的时候,有几个坑我踩过,说出来给大家提个醒。
坑一:多项式选错。不同的协议标准使用不同的多项式。比如Modbus用CRC-16(多项式0x8005),而一些无线协议用CRC-CCITT(多项式0x1021)。选错了,两边算出来的CRC值对不上,通信就废了。
坑二:初始值和异或值。有些CRC算法在计算前会把CRC寄存器初始化为0xFFFF,计算完后再跟0xFFFF异或。这就是所谓的「CRC-16 Modbus」标准。如果你只算多项式,忘了初始值和异或值,结果肯定不对。
坑三:字节序。有些协议要求高位在前,有些要求低位在前。查表法里,表的生成方式跟字节序密切相关。我曾经在一个项目里,发送端和接收端用的表不一样,查了三天才找到原因。
我的建议:如果你不确定CRC的具体参数,最好用现成的库或者在线工具验证一下。比如,你可以用Python的crcmod库算一遍,跟你的嵌入式代码对比结果。确保一致了再烧录到芯片里。
4.6 如何选择校验方式
最后,我总结一下三种校验方式的选择建议:
- 奇偶校验:适合硬件支持、对资源要求极低的场景。比如UART通信,硬件自带,不占CPU。但检测能力弱,只能防小错。
- 累加和校验:适合资源紧张、对实时性要求高的场景。代码简单,RAM占用几乎为零。但检测能力一般,无法检测出某些特定的错误模式。
- CRC校验:适合对可靠性要求高的场景。比如工业控制、汽车电子、医疗设备。查表法实现后,速度和资源都能接受。强烈推荐作为通信协议栈的标配。
我个人习惯,只要MCU的RAM不低于1KB,我都会用CRC查表法。256字节的表换来8倍的速度提升和极高的检测能力,这笔买卖太划算了。
好了,这一章就到这里。下一章我们聊聊数据链路层的帧结构设计,包括帧头、帧尾、转义处理这些实战内容。到时候见。