第4章:CRC循环冗余校验——CRC-16-Modbus算法

说到Modbus RTU,就绕不开CRC校验。我刚开始接触Modbus时,总觉得CRC是个很神秘的东西。后来做项目多了才发现,它其实就是个数学游戏,只不过这个游戏玩不好,设备之间就没法好好说话。

说白了,CRC就是给数据包加个「指纹」。发送方算出一个16位的校验码,接收方用同样的方法再算一遍。对得上,数据就大概率没问题;对不上,那肯定是在传输过程中出了岔子。

4.1 CRC-16-Modbus算法原理

CRC-16-Modbus是Modbus RTU协议指定的校验方式。它生成一个16位的校验值,附加在报文末尾。低字节在前,高字节在后——这个顺序我经常搞反,后来干脆写了个小工具帮我算。

它的数学本质是多项式除法。生成多项式是:

x^16 + x^15 + x^2 + 1

对应的二进制是:0x8005(去掉最高位的x^16,实际参与计算的是0x8005)。

嗯,这里要注意:Modbus的CRC算法和标准CRC-16不一样。标准CRC-16的多项式是0x8005,但初始值和结果异或值不同。Modbus的初始值是0xFFFF,结果不做异或处理。我见过有人拿标准CRC-16的库来算Modbus,结果死活对不上——这就是踩坑了。

关键参数:

  • 多项式:0x8005(x^16 + x^15 + x^2 + 1)
  • 初始值:0xFFFF
  • 结果异或值:0x0000
  • 输入输出反转:是(LSB first)

4.2 手算CRC——理解原理的最好方式

虽然实际项目中没人手算CRC,但手算一遍能帮你彻底理解算法。我当年就是手算了一个字节,才真正明白移位和异或到底在干什么。

假设我们要计算单字节 0x02 的CRC:

  1. 初始化CRC寄存器为0xFFFF
  2. 将0x02与CRC低字节异或:0xFF xor 0x02 = 0xFD
  3. CRC寄存器变为0xFFFD
  4. 右移1位,高位补0:0x7FFE。检查移出的最低位:是1
  5. 因为移出的是1,与多项式0xA001(反转后的0x8005)异或:0x7FFE xor 0xA001 = 0xDFFF
  6. 重复步骤4-5,共8次(一个字节8位)

最终结果就是0x2C8E。你可以用在线工具验证一下。

我的小技巧:手算时别贪心,一次只算一个字节。算完一个字节后,CRC寄存器的值就是中间结果。多字节报文就重复这个过程,每个字节都这么算一遍。

4.3 程序实现——查表法 vs 直接计算法

实际写代码时,有两种主流实现方式。我个人更推荐查表法,尤其是在嵌入式系统里。

4.3.1 直接计算法(适合理解原理)

uint16_t crc16_modbus(uint8_t *data, uint16_t len)
{
    uint16_t crc = 0xFFFF;
    uint16_t i, j;
    
    for (i = 0; i < len; i++)
    {
        crc ^= data[i];
        for (j = 0; j < 8; j++)
        {
            if (crc & 0x0001)
            {
                crc >>= 1;
                crc ^= 0xA001;  // 反转后的多项式
            }
            else
            {
                crc >>= 1;
            }
        }
    }
    return crc;
}

这段代码很直观,但效率不高。每个字节要循环8次,如果报文有100个字节,就是800次循环。在低端MCU上,这可能会成为性能瓶颈。

4.3.2 查表法(推荐用于实际项目)

// 预生成的CRC-16-Modbus查表(256个值)
const uint16_t crc16_table[256] = {
    0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241,
    // ... 此处省略中间值,完整表可在网上找到
    0x8201, 0x42C0, 0x4380, 0x8341, 0xA100, 0x61C1, 0x6081, 0xA040
};

uint16_t crc16_modbus_fast(uint8_t *data, uint16_t len)
{
    uint16_t crc = 0xFFFF;
    uint16_t i;
    uint8_t index;
    
    for (i = 0; i < len; i++)
    {
        index = (crc ^ data[i]) & 0xFF;
        crc = (crc >> 8) ^ crc16_table[index];
    }
    return crc;
}

查表法把256种可能的计算结果提前算好存起来。运行时只需要查表+异或,每个字节只需要3条指令。我在一个STM32项目里用过,处理100字节的报文只需要几十微秒。

注意:查表法虽然快,但会占用512字节的ROM。在ROM极度紧张的场景(比如某些OTP单片机),你可能得用直接计算法。我曾经在一个4KB ROM的项目里就不得不这么干。

4.4 常见校验错误排查

CRC校验失败是Modbus通信中最常见的问题。我总结了几种典型情况:

错误现象 可能原因 排查方法
偶尔校验失败 电气干扰、波特率不匹配 用示波器看波形,检查RS485的A/B线差分电压
总是校验失败 CRC算法实现错误 用已知数据验证:0x02的CRC应为0x2C8E
特定设备失败 字节序问题(大小端) 检查CRC低字节是否在前
报文长度变化时失败 漏算或重复计算了某些字节 确认CRC计算范围是否包含所有数据字节

我曾经遇到过一个特别坑的问题:一台变频器总是随机返回CRC错误。查了两天,最后发现是RS485总线上有终端电阻匹配问题,导致信号反射。加上120欧姆终端电阻后,问题消失。所以CRC校验失败,不一定是算法的问题,物理层的问题更常见。

调试小工具:我习惯在调试串口里同时打印原始报文和计算出的CRC值。如果发送方和接收方算出的CRC不一样,就逐字节对比,很快就能定位问题。另外,网上有很多CRC在线计算器,可以用来验证你的实现是否正确。

4.5 本章小结

CRC-16-Modbus是Modbus RTU的「守门员」。它不复杂,但细节很多:多项式、初始值、字节序、反转……任何一个地方出错,通信就会失败。

我的建议是:

  • 先用查表法实现,效率高且不易出错
  • 用已知数据验证你的实现(比如0x02的CRC是0x2C8E)
  • CRC校验失败时,先检查物理层,再检查算法

下一章我们会讲Modbus RTU的报文结构,到时候你会看到CRC在整个报文里是怎么安放的。嗯,先把这个基础打牢了。