ARTICLE DETAIL

资讯详情

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

Modbus校验算法详解:CRC16与LRC原理、实现及排错指南

Modbus校验算法详解:CRC16与LRC原理、实现及排错指南 如果你搞过带串口的工业设备或者调试过 PLC、仪表、变频器这类东西Modbus 这三个字基本绕不开。它可能是你接触的第一个工业总线协议也是很多嵌入式工程师从点亮一个 LED 进阶到工业通信的必经门槛。而这个协议里最不起眼、却最容易出事的一环就是校验RTU 模式用 CRC16ASCII 模式用 LRC。别小看这两段几行代码就能写完的算法我在项目里见过因为初始值写错、字节序发反、查表表生成错误导致整条产线偶尔通讯失败的案例而且这种问题很难复现排查起来相当折磨人。这篇文章就把 CRC16-Modbus 和 LRC 从原理到代码一次讲透包含手算过程、查表法、逐位法、ASCII 模式细节和排错经验适合正在写 Modbus 从站或主站、或者想彻底搞懂校验算法底层逻辑的开发者。1. 为什么 Modbus 需要 CRC 和 LRC 两种校验1.1 RTU 和 ASCII 是两套完全不同的帧格式很多新手第一次看 Modbus 协议文档最容易混淆的就是 RTU 和 ASCII。这两个不是同一个协议的两个版本而是 Modbus 串行链路上两种完全不同的帧封装方式它们对数据的编码方式、结束符、校验算法全部不同。RTU 模式是二进制传输地址、功能码、数据全部以原始字节发送帧尾跟两个字节的 CRC16 校验码没有固定结束符靠“静默时间”来隔帧一般规定帧内字节间隔超过 3.5 个字符时间就算一帧结束。ASCII 模式则是把每一个字节拆成两个十六进制字符再以 ASCII 码发送帧首是冒号:0x3A帧尾是回车换行 CR LF校验用的是 LRC。同样一帧数据ASCII 模式比 RTU 模式多出一倍多的字节传输效率低一些但它对字符间隔不敏感调试时在超级终端里肉眼就能看懂早期很多设备支持它就是为了方便维护人员手动输入报文。这里有一个关键点两种模式不能混用。同一台设备要么配置成 RTU要么配置成 ASCII主站和从站必须一致否则收端按错误模式解析第一关就过不去。工程上绝大多数设备默认用 RTUASCII 多见于老设备或现场手持调试终端。1.2 CRC16 和 LRC 的定位差异RTU 用 CRC16ASCII 用 LRC这不是随手选的。CRC16 是多字节的循环冗余校验多项式运算会扩散到整个数据区能检测出多位错误、突发错误对长度较小的工业报文来说检错能力已经足够强。LRC 本质上就是一个 8 位纵向校验和算法极度简单只有“累加取反加一”三个动作但它只能对付单字节或偶发性的错误检错能力远不如 CRC16。为什么 ASCII 模式甘愿用这么弱的校验因为 ASCII 模式本身就把数据变成了可见字符链路层可读性高调试成本低适用于波特率不高、干扰不强的场合而 RTU 以二进制形式高效传输但一旦发生错帧后果更隐蔽所以需要更强的校验兜底。协议这样设计是权衡了检错能力、实现成本和应用场景。实际开发中我强烈建议新项目一律用 RTU CRC16LRC 了解原理即可除非你明确要兼容老的 ASCII 设备。2. CRC16-Modbus 协议参数与原理剖析2.1 CRC 到底在算什么多项式与模2除法CRC 的底层逻辑可以理解为“模 2 除法”也就是按位异或运算下的除法。发送端把数据看成一个二进制数用这个数去除以一个固定的“生成多项式”得到的余数就是校验码。接收端用同样的多项式去除整个数据加校验码如果余数为 0说明传输没错。Modbus 使用的生成多项式是 0x8005展开成二进制是1000 0000 0000 0101对应的代数式是 x^16 x^15 x^2 1。这个 16 次多项式意味着计算出来的是 16 位余数也就是 CRC16。不过 Modbus 协议里有个特殊之处它用的不是标准多项式 0x8005 本身直接参与移位而是它的“反射”形式 0xA001二进制是1010 0000 0000 0001。这是因为 Modbus 规定数据在移位寄存器里是从最低位开始进位的也就是输入数据反射、输出结果也反射很多资料里写作 refintrue、refouttrue。这里先记住结论逐位代码里每次判断最低位条件异或的是 0xA001不是 0x8005。这一点写错算出来的结果全盘皆错。另外两个关键参数初始值必须是 0xFFFF最终结果不额外异或等价于异或 0x0000。初始值设成全 1 有一个实际意义如果数据块开头有连续 0x00初始值为 0 时会把这些前导零“吃”掉导致校验结果无法区分不同位置的前导零初始值全 1 就排除了这个问题。CRC16-Modbus 的完整参数可以总结为宽度 16、多项式 0x8005反射用 0xA001、初始值 0xFFFF、输入输出反射、结果异或 0x0000。2.2 一个字节的完整手算过程理解 CRC 最直接的办法是拿一个真实字节从手算走一遍。我们算单字节 0x01 的 CRC16-Modbus只用这一个字节做输入。第一步把 16 位寄存器的初始值 0xFFFF 和 0x01 做异或得到 0xFFFE。然后开始 8 次循环每轮看寄存器最低位是 0 还是 1第 1 轮最低位 0寄存器右移一位0xFFFE 变 0x7FFF第 2 轮最低位 1右移后得到 0x3FFF再异或 0xA001得到 0x9FFE第 3 轮最低位 0右移一位0x4FFF第 4 轮最低位 1右移后 0x27FF 异或 0xA001得到 0x87FE第 5 轮最低位 0右移一位0x43FF第 6 轮最低位 1右移后 0x21FF 异或 0xA001得到 0x81FE第 7 轮最低位 0右移一位0x40FF第 8 轮最低位 1右移后 0x207F 异或 0xA001得到 0x807E。所以单字节 0x01 的 CRC16-Modbus 结果是 0x807E。你可以用这个值去验证自己写的函数只要输入一个字节 0x01输出应该是 0x807E就算基本正确。多字节情况下每个字节做完 8 轮循环后再处理下一个字节寄存器里的中间值不断累积最终值就是对整条数据区做的 CRC 指纹。3. CRC16-Modbus 代码实现3.1 逐位法逻辑最清楚、占用最少逐位法严格按照上一节手算的流程来写没有任何奇技淫巧最适合用来理解原理和做功能验证。代码里我习惯把初始值放在外部传入而不是函数内部固定成 0xFFFF这样方便分帧累计计算比如接收一帧长数据时可以边收边算。uint16_t crc16_modbus_bit(uint16_t crc, const uint8_t *data, uint32_t len) { 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; }调用时直接crc16_modbus_bit(0xFFFF, buf, len)。这个函数在单片机上的资源消耗极小不需要额外表格逻辑一目了然。它的缺点是每个字节要执行 8 次循环如果是在资源紧张的 8 位单片机上做大量数据的 CRC会占用不少 CPU 时间。但对于 Modbus 这种一帧最多 256 字节的协议来说115200 波特率下一帧也才 20 来毫秒逐位法完全够用很多成熟产品里用的就是它。3.2 查表法工程中最常用的高性能方案查表法的思路是逐位法里每个字节那 8 次循环本质上是有固定规律的映射输入是 0~255 的某个值输出是 16 位结果。那我们干脆把一个字节可能的 256 种结果全部预先算出来存成一张表运行时直接查表每个字节只需要做一次查表和几次异或速度提升明显。这张表的生成本质上就是把“从初始 CRC 为 0 开始单独处理一个字节 i”的 8 轮循环结果全部算出来。生成函数如下void crc16_modbus_table_init(uint16_t table[256]) { for (uint16_t i 0; i 256; i) { uint16_t crc i; // 初始 CRC 直接用 i模拟字节进入时的异或 for (uint8_t bit 0; bit 8; bit) { if (crc 0x0001) { crc (crc 1) ^ 0xA001; } else { crc 1; } } table[i] crc; } }有了这张表查表计算函数就变得非常简洁uint16_t crc16_modbus_table(uint16_t crc, const uint8_t *data, uint32_t len) { for (uint32_t i 0; i len; i) { crc (crc 8) ^ table[(crc ^ data[i]) 0xFF]; } return crc; }这里(crc ^ data[i]) 0xFF取的是当前 CRC 的低字节和新数据字节异或后的值用它做索引去查表crc 8是把上一轮的 CRC 高字节移下来两者异或得到新一轮结果。这正好对应逐位法里“低字节负责异或和移位高字节等待被下一次低字节状态影响”的过程。这两种写法算出的结果是完全一致的查表法只是把内部循环的 8 步合并成了一次查表。工程里这张表可以运行时调用table_init生成一次然后长期复用也可以把生成结果写成静态 const 数组固化在 ROM 里彻底省去初始化时间。个人建议在资源允许时直接固化表因为 256 个 uint16_t 也就 512 字节绝大多数单片机都放得下。4. LRC 算法原理与代码实现4.1 LRC 的数学本质二进制补码LRC 全称 Longitudinal Redundancy Check纵向冗余校验。它的计算过程可以概括为把要校验的数据区所有字节累加得到一个 8 位和然后对这个和取反加一也就是求二进制补码。说得更直白一点LRC 就是“数据区所有字节和的负数”。为什么要求补码而不是简单地取反因为补码有一个非常好的性质一个数加上它的负数结果是 0。接收端收到数据后把从地址到 LRC 的所有字节包括 LRC 本身全部累加起来低 8 位结果应该恰好是 0x00。如果不为 0说明传输过程中有字节出错。这种自洽的校验设计让接收端不需要单独记住发送端算出来的 LRC 值只要看累加结果是否为 0 就能判断对错硬件实现和软件逻辑都非常简单。用公式表达就是LRC (uint8_t)(~sum 1)也等于 (uint8_t)(0x100 - (sum 0xFF))。注意这里必须把 sum 限制在 8 位范围内因为 Modbus ASCII 帧中 LRC 只占一个字节累加时自然溢出丢弃高位即可。4.2 LRC 代码与 ASCII 模式发送LRC 的代码实现比 CRC 简单太多uint8_t lrc8_modbus(const uint8_t *data, uint32_t len) { uint8_t sum 0; for (uint32_t i 0; i len; i) { sum data[i]; } return (uint8_t)(~sum 1); }需要注意计算范围LRC 只覆盖地址字节、功能码字节、数据区字节不包含帧首冒号、不包含 LRC 自身、更不包含结尾的 CR LF。ASCII 模式下发送端要先把每个字节拆成高 4 位和低 4 位分别转成对应的 ASCII 十六进制字符再加 LRC 的两个字符最后补回车换行。比如数据区求和后算出 LRC 是 0xF2发送时不是发 0xF2 这一个字节而是发两个字节字符F0x46和字符20x32。收端则是反向操作把四个 ASCII 字符还原成两个字节再解析。写协议解析的时候千万别忘了这一层转换很多人第一次写 ASCII 从站LRC 算了半天对不上就是因为把原始字节当成了字符。5. 完整实战从零构造一帧 Modbus 报文5.1 RTU 读保持寄存器请求帧计算以最常见的“读取从站 1 的保持寄存器起始地址 0x0000读取 10 个寄存器”为例RTU 请求帧结构是从站地址 0x01、功能码 0x03、起始地址高字节 0x00、起始地址低字节 0x00、寄存器数量高字节 0x00、寄存器数量低字节 0x0A也就是01 03 00 00 00 0A共 6 个字节。CRC 需要对这 6 个字节计算结果再追加到帧尾。我按上一章逐位法人工算一遍最终值初始寄存器 0xFFFF处理完01 03后变成 0x2140继续处理两个 0x00变成 0xD8F1最后处理 0x0A得到 0x8399。所以完整请求帧是01 03 00 00 00 0A 99 83其中 0x99 是 CRC 的低字节0x83 是高字节。注意 Modbus RTU 规定 CRC 必须低字节先发、高字节后发这也是区别于很多其他协议习惯大端先发校验码的一个大坑。用代码构造这个请求帧可以写成下面这样参数化便于扩展uint16_t modbus_rtu_build_read_holding(uint8_t slave, uint16_t addr, uint16_t qty, uint8_t *frame) { frame[0] slave; frame[1] 0x03; // 功能码读保持寄存器 frame[2] (addr 8) 0xFF; // 地址高字节在前 frame[3] addr 0xFF; frame[4] (qty 8) 0xFF; // 数量高字节在前 frame[5] qty 0xFF; uint16_t crc crc16_modbus_table(0xFFFF, frame, 6); frame[6] crc 0xFF; // CRC 低字节先发 frame[7] (crc 8) 0xFF; // CRC 高字节后发 return 8; }函数返回帧长度 8。可以看到寄存器地址和数量都按“高字节在前”放置这是 Modbus 协议对多字节数据段的规定叫大端字节序。如果你把地址高低字节写反从站会认为你要读 0x0000 的地址或者直接返回非法数据地址异常这是新手特别容易踩的坑。5.2 标准校验向量确认代码正确写完 CRC 函数第一件事不是接设备而是用标准校验向量验证。CRC-16/MODBUS 在国际上有公认的校验值对 ASCII 字符串123456789也就是字节序列31 32 33 34 35 36 37 38 39正确的 CRC 输出应该是 0x4B37。这个值不是我拍的是 CRC 算法标准里定义的 Check Value几乎所有在线 CRC 计算器都认这个结果。我建议你在工程里留一个自检函数上电时跑一次把计算值和 0x4B37 对比对不上就报警。这样能提前排除多项式方向、初始值、表生成错误等问题不用等到现场连不上设备才排查。LRC 也可以用类似方式自检随便构造一组字节算出 LRC 后把整组含 LRC累加低 8 位必须为 0。5.3 ASCII 模式报文组装示例同样一条“读保持寄存器”命令如果走 ASCII 模式就要把01 03 00 00 00 0A这 6 个字节先转换。0x01 变成0和10x03 变成0和3依此类推一共 12 个字符。然后对这 6 个原始字节计算 LRC 得到 0xF2再把 0xF2 变成F和2追加到末尾最后在帧首加:帧尾加\r\n。完整 ASCII 请求帧就是:01030000000AF2\r\n这里按字符展示实际发送的字节序列则是3A 30 31 30 33 30 30 30 30 30 30 30 41 46 32 0D 0A。刚开始写的时候容易把 LRC 的 0xF2 直接发成字节 0xF2导致接收端解析出不可见字符整个帧判定异常。记住ASCII 模式下所有业务数据都是可见十六进制字符LRC 也不例外。6. 常见错误与调试排障6.1 CRC 结果对不上的七宗罪我总结过 CRC 校验失败的高频原因按出现频率排个序错误类型具体表现原因多项式写错全帧 CRC 完全不对用了 0x8005 而不是反射后的 0xA001初始值写 0前导 0x00 多的帧校验错协议要求初始值 0xFFFFCRC 字节序发反通信偶发失败设备偶发响应异常Modbus 要求低字节先发很多人习惯高字节先发计算范围多算把 CRC 本身也参与计算只算地址功能码数据区字节序搞反寄存器地址、数量不符Modbus 数据段多字节是大端表生成错误只有特定字节值时出错表初始化函数里的移位逻辑有问题帧间隔不对收端把一帧拆成两帧或把两帧并一帧RTU 模式靠 3.5 字符时间静默隔帧不能用固定延时替代排查时不要瞎猜先打印原始 HEX 和算出的 CRC一步一步对。CRC 算法是确定性的输入相同输出必然相同如果对不上九成是代码问题不是设备问题。6.2 现场调试三步法第一步用串口助手直接发固定报文比如01 03 00 00 00 0A 99 83看从站是否有响应。没响应先查接线、串口参数、从站地址别急着怀疑 CRC。第二步用 Modbus Poll 或类似调试工具作为主站让它自己生成 CRC连接从站测试如果工具能通而自己的代码不通重点对比工具发出的 CRC 和自己算的 CRC 差异。第三步如果还是查不出来用逻辑分析仪或支持抓包的工具抓 RS485 总线电平实际看发送的字节顺序很多时候问题出在代码把高低字节搞反了。串口参数也容易忽略Modbus RTU 通常配置为 8 数据位、无校验、1 停止位也有的设备用 8E1主从必须一致。奇偶校验不一致时收端会出现随机字节错误CRC 自然过不了但现象又像偶发故障极具迷惑性。6.3 项目中的一些心得最后分享一点我自己的习惯。代码里我总是把 CRC 计算范围写成一个单独的函数输入是“缓冲区指针”和“长度”而不是在发送函数里散着异或和移位这样方便单元测试。调试 Modbus 通信时我习惯先写一个收数据回显程序把所有接收到的字节原样打印成 HEX 串先确认字节层面没问题再谈协议解析。CRC 算错这个问题最气人的是它不会 100% 出错偶尔最后一个字节算错导致设备每隔几秒丢一帧特别难定位。所以我把标准校验向量自检放在代码初始化里每次上电都跑能拦截掉绝大多数低级错误。至于查表法和逐位法的选择我自己在普通串口通信里都用逐位法代码小、容易看懂只有在做固件升级、需要频繁校验大块 Flash 数据时才用查表法。不要在 Modbus 这种低频小数据帧场景里为了性能炫技稳定、可读、好维护才是第一位。LRC 也不要在 RTU 模式里用协议不支持就是不支持哪怕你校验值算对了从站也不认。把这些边界想清楚Modbus 通信这块基本就不会再出什么幺蛾子了。
返回列表