4、CRC校验实战:CRC-8/CRC-16/CRC-32原理、查表法实现、在理疗仪协议中的应用

各位同学,咱们今天聊点实在的——CRC校验。

说实话,我在做理疗仪通信协议那会儿,最头疼的就是数据传着传着就错了。你说病人正做着理疗呢,突然来一个误码,功率参数变了,那可不是闹着玩的。所以CRC校验,说白了就是给数据包加一把锁,确保它从A到B一路平安。

4.1 CRC到底是什么?

CRC的全称是循环冗余校验(Cyclic Redundancy Check)。名字挺唬人,其实原理不复杂。

你想想看,我们发送一串数据,比如“10110010”。接收方拿到后,怎么知道这串数据有没有被干扰过?CRC的做法是:发送方根据数据算出一个固定长度的校验码,附在数据后面一起发出去。接收方用同样的算法再算一遍,如果结果一致,说明数据没问题。

这个“算法”的核心,就是多项式除法。嗯,别被“多项式”三个字吓到,其实就是二进制除法,只不过把每一位当成多项式的系数。

核心思想:将数据看作一个二进制多项式,用约定的生成多项式去除,余数就是CRC校验码。

我在项目中遇到过一种情况:有人直接用累加和做校验,结果数据里同时翻转两位,累加和竟然没变。CRC就不会有这个问题,因为它对数据的变化非常敏感。

4.2 CRC-8、CRC-16、CRC-32的区别

这三种CRC,说白了就是校验码的长度不同,生成多项式也不同。我一般这么选:

类型 校验码长度 生成多项式(常见) 适用场景
CRC-8 8位(1字节) 0x07(x⁸ + x² + x + 1) 小数据包、传感器数据
CRC-16 16位(2字节) 0x8005(x¹⁶ + x¹⁵ + x² + 1) 理疗仪通信帧、Modbus协议
CRC-32 32位(4字节) 0x04C11DB7 文件校验、网络传输

我个人习惯,理疗仪这种嵌入式设备,CRC-16就够用了。为什么?因为理疗仪的数据包一般不超过256字节,CRC-16的碰撞概率已经非常低。用CRC-32反而浪费带宽和计算时间。

我的经验:在理疗仪协议中,我通常用CRC-16/CCITT(多项式0x1021),因为它的硬件实现简单,很多MCU都有硬件CRC模块支持。

4.3 查表法实现——为什么快?

直接按位计算CRC,效率太低了。你想想看,每处理一个字节就要循环8次,处理100个字节就是800次循环。在低功耗MCU上,这简直是灾难。

查表法的思路很简单:提前把0~255这256个字节对应的CRC值算好,存成一张表。计算时直接查表,一个字节只需要一次查表操作。

我曾经在一个项目里,用直接计算法,CRC校验花了2毫秒。换成查表法后,0.1毫秒就搞定了。嗯,差距就是这么大。

下面我给出一个CRC-16查表法的实现,这是我在理疗仪项目里实际用过的代码:

// 生成CRC-16查表(多项式0x8005)
uint16_t crc16_table[256];

void crc16_init_table(void) {
    uint16_t i, j, crc;
    for (i = 0; i < 256; i++) {
        crc = i << 8;
        for (j = 0; j < 8; j++) {
            if (crc & 0x8000)
                crc = (crc << 1) ^ 0x8005;
            else
                crc = crc << 1;
        }
        crc16_table[i] = crc;
    }
}

// 查表计算CRC-16
uint16_t crc16_calc(uint8_t *data, uint16_t len) {
    uint16_t crc = 0x0000;  // 初始值
    uint16_t i;
    for (i = 0; i < len; i++) {
        crc = (crc << 8) ^ crc16_table[((crc >> 8) ^ data[i]) & 0xFF];
    }
    return crc;
}

注意:不同CRC标准,初始值可能不同。有的用0x0000,有的用0xFFFF。还有的输出结果要异或0xFFFF。一定要确认你用的协议标准。

4.4 在理疗仪协议中的应用

好,咱们来点实战的。理疗仪的通信协议,我一般这样设计数据帧:

帧头 地址 命令 数据长度 数据 CRC-16 帧尾
0xAA 0x55 1字节 1字节 1字节 N字节 2字节 0x0D 0x0A

CRC校验的范围是从“地址”到“数据”结束,不包括帧头和帧尾。为什么?因为帧头和帧尾是固定的,不需要校验。我曾经见过有人把帧头也算进去,结果帧头变了CRC也跟着变,反而增加了误判的概率。

下面是一个完整的发送和校验示例:

// 发送数据帧(带CRC)
void send_frame(uint8_t addr, uint8_t cmd, uint8_t *data, uint8_t len) {
    uint8_t buffer[256];
    uint16_t crc;
    uint8_t i, idx = 0;

    // 帧头
    buffer[idx++] = 0xAA;
    buffer[idx++] = 0x55;
    // 地址、命令、长度
    buffer[idx++] = addr;
    buffer[idx++] = cmd;
    buffer[idx++] = len;
    // 数据
    for (i = 0; i < len; i++) {
        buffer[idx++] = data[i];
    }
    // 计算CRC(从地址开始到数据结束)
    crc = crc16_calc(&buffer[2], len + 3);
    buffer[idx++] = (crc >> 8) & 0xFF;
    buffer[idx++] = crc & 0xFF;
    // 帧尾
    buffer[idx++] = 0x0D;
    buffer[idx++] = 0x0A;

    // 发送
    uart_send(buffer, idx);
}

// 接收校验
uint8_t check_frame(uint8_t *buffer, uint16_t len) {
    uint16_t crc_recv, crc_calc;
    // 检查帧头帧尾
    if (buffer[0] != 0xAA || buffer[1] != 0x55) return 0;
    if (buffer[len-2] != 0x0D || buffer[len-1] != 0x0A) return 0;

    // 提取接收到的CRC
    crc_recv = (buffer[len-4] << 8) | buffer[len-3];
    // 计算CRC(从地址到数据结束)
    crc_calc = crc16_calc(&buffer[2], len - 6);
    return (crc_recv == crc_calc);
}

避坑指南:我曾经犯过一个错误——CRC计算时把帧尾也包含进去了。结果接收方怎么算都对不上。后来排查了半天才发现,帧尾的0x0D和0x0A被当成数据参与了CRC计算。记住:CRC只校验有效数据部分。

4.5 实际调试中的小技巧

调试CRC的时候,我一般会先用一个已知的数据做测试。比如发送“0x01 0x02 0x03”,用在线CRC计算器算好预期的CRC值,然后对比程序算出来的。如果对不上,八成是多项式或者初始值搞错了。

还有一个坑:字节序。有些MCU是大端,有些是小端。CRC计算时,数据字节的顺序一定要和协议定义一致。我习惯统一用小端,因为ARM Cortex-M系列默认就是小端。

我的建议:在理疗仪项目中,把CRC校验函数做成一个独立的模块,用单元测试覆盖。这样换MCU或者改协议时,只需要改这个模块,其他地方不用动。

好了,CRC校验这块就讲这么多。说白了,它就是数据通信的“安全员”,虽然不起眼,但少了它,整个系统就不可靠了。下一节咱们聊聊更高级的校验方式,以及如何在多机通信中避免冲突。