ARTICLE DETAIL

资讯详情

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

Java实现CRC16 MODBUS校验:从位运算原理到串口实战避坑指南

Java实现CRC16 MODBUS校验:从位运算原理到串口实战避坑指南 做个仪表上位机开发那阵子我遇到过一件怪事。设备说明书上的示例报文写得清清楚楚我也按 Modbus RTU 格式把帧组好了但一用 Java 把指令发出去设备就是无响应。换 Modbus Poll 发同一条指令又完全正常。后来把两边的十六进制字节流并排对比才发现Java 这边算出的 CRC 值跟说明书上写的不一样。这个问题十有八九出在同一个地方——CRC16 并不是只有一种算法。MODBUS 用的这个版本差一个初始值、差一个多项式方向、差一位字节序结果就天差地别。这篇文章就把“用 Java 实现 CRC16 MODBUS 格式校验”这件事完整讲透从原理层面的算法参数到可以直接抄走的实现代码再到实际串口通信中会遇到的字节序、符号位、脏帧这些坑一次性说清楚。如果你是在用 Java 对接 PLC、电表、温控器、变频器这类支持 Modbus RTU 协议的设备或者刚接触 Modbus 通信、被网上那些相互矛盾的源码搞晕了这篇文章应该能帮你少走不少弯路。我会把两种实现方式都写出来直接计算法适合理解原理查表法适合直接用在生产项目里。1. 为什么“CRC16”到了 MODBUS 这里就总对不上1.1 先把这个坑说破MODBUS 用的是 CRC-16/MODBUS 变体很多人第一次写这段代码时习惯直接搜“CRC16 Java 实现”结果搜出来的算法五花八门随便选一个抄下来算出来的值跟设备手册对不上。原因很简单CRC16 不是“一种算法”而是一族算法。CRC-16/IBM、CRC-16/CCITT、CRC-16/XMODEM、CRC-16/MODBUS参数都不一样。MODBUS 协议规范里明确写的是CRC-16/MODBUS这个变体它的参数是这样的参数项CRC-16/MODBUSCRC-16/IBM / ARC说明多项式 Poly0x8005反射后 0xA0010x8005反射后 0xA001参与异或的生成多项式初始值 Init0xFFFF0x0000计算前 CRC 寄存器的初值结果异或 XOROUT0x00000x0000计算完是否再异或输入/输出反转是RefIn/RefOuttrue是RefIn/RefOuttrue低位先算测试串“123456789”结果0x4B370xBB3D标准校验值看到了吧MODBUS 和很多工具默认的 CRC-16/IBM 之间就差一个初始值。但就是这一个差异让同样一段数据算出完全不同的结果。你如果拿着 IBM 版的结果去跟设备通信设备每帧都会判校验失败自然没有响应。还有一个更反直觉的细节MODBUS 的多项式写成 0x8005但在低位先算的算法里实际参与异或运算的是它的反射值 0xA001。这两个数必须配对用高字节版用 0x8005低字节版用 0xA001混用就全乱套。1.2 多项式、初始值、异或输出——三件套决定你算得对不对CRC 的数学本质可以理解成把一串要发送的数据当成一个大的二进制数用这个数去除以一个固定的生成多项式得到的余数就是 CRC 校验值。生成多项式不同、初始余数不同最后余数自然不同。为了更直观我常用一个比喻CRC 像寄快递时贴的防拆封签。快递员不会去数箱子里到底装了什么只看封签是否完整。如果路上有人打开过箱子封签就被破坏了接收方看一眼就知道有问题。CRC 就是数据包的“封签”它不关心你具体发了什么业务数据只关心这串字节在传输过程中有没有被篡改、丢失、错位。在 Modbus RTU 协议里每一帧报文的末尾都会附带两个字节的 CRC。发送方把“地址 功能码 数据”这些字节喂进 CRC 算法得出一个 16 位结果追加在帧尾。接收方收到后用同样的算法对整帧包括 CRC 本身重新计算如果结果是 0说明数据没有问题不是 0说明帧坏了直接丢弃。1.3 标准测试串用“123456789”这一句就能验出算法对不对CRC 算法领域有个通用测试串就是 ASCII 字符串123456789对应十六进制31 32 33 34 35 36 37 38 39。不同 CRC 变体对这个测试串算出的值是公开的标准结果你写完代码后先别急着接设备先用这个测试串验一下CRC-16/MODBUS0x4B37CRC-16/IBM / ARC0xBB3D如果代码算出来不是0x4B37说明算法里有一步不对后面接设备必踩坑。我在本地写单元测试时永远会把这个断言加进去Test public void testCrc16Modbus() { byte[] data 123456789.getBytes(StandardCharsets.US_ASCII); int crc Crc16Modbus.crc16(data); assertEquals(0x4B37, crc); }这个测试通过了才敢说算法核心是对的。后面所有工程代码都要以这个为基准。2. 从零到一按位直接计算把 CRC16 MODBUS 的每个移位都讲透2.1 算法流程拆解初始值、逐字节异或、8 次右移CRC-16/MODBUS 的低位先算流程一句话概括从 0xFFFF 开始每读一个字节就把它和当前 CRC 的 16 位值异或然后右移 8 次每次右移前看最低位最低位是 1 就额外异或 0xA001。详细展开是这样的CRC 寄存器初始化为 0xFFFF。取数据中的第一个字节和 CRC 寄存器的低 8 位做异或结果放回 CRC。对当前 CRC 值循环执行 8 次如果 CRC 最低位是 1先右移一位再异或 0xA001如果 CRC 最低位是 0只右移一位不异或。处理完所有字节后CRC 寄存器的值就是校验码。这里“低位先算”的意思是我们拿到一个字节先处理它的最低位再逐步处理到最高位。对应到代码里就是每次右移、判断最低位。所以多项式写成 0xA001 而不是 0x8005就是因为 0xA001 是这个多项式反射后的结果。2.2 Java 实现注意无符号右移和 byte 0xFF 这两个细节直接计算版代码不长但有两个地方特别容易写错我先把完整代码贴出来再专门说坑。public class Crc16ModbusDirect { private static final int POLYNOMIAL 0xA001; /** * 计算 CRC16 MODBUS 校验值 * * param data 原始数据 * param length 参与计算的长度 * return CRC 值范围 0~0xFFFF */ public static int crc16(byte[] data, int length) { int crc 0xFFFF; // 初始值0xFFFF for (int i 0; i length; i) { crc ^ (data[i] 0xFF); // 当前字节与 CRC 低 8 位异或 for (int bit 0; bit 8; bit) { if ((crc 0x0001) ! 0) { crc (crc 1) ^ POLYNOMIAL; // 最低位是1右移后异或多项式 } else { crc 1; // 最低位是0只右移 } } } return crc 0xFFFF; } public static int crc16(byte[] data) { return crc16(data, data.length); } }第一坑为什么用crc 1而不是crc 1。Java 里的int是有符号的最高位是符号位。如果不用无符号右移当 CRC 的最高位是 1 时普通右移会把符号位 1 也一并复制到高位导致结果多出一串 1最终算出来的 CRC 就是错的。会老老实实补 0这才符合 CRC 的移位运算规则。第二坑为什么data[i]要跟0xFF做与运算。Java 的byte是有符号类型范围是 -128~127。比如十六进制0x86在 Java 里实际存的是负数 -122转成 int 参与运算时会变成0xFFFFFF86。如果直接用这个值去异或CRC 寄存器高 8 位会被莫名其妙的数据污染。data[i] 0xFF会先把低 8 位取出来得到真正的0x86否则算出来的东西完全不对。这两个细节是 Java 里写所有 CRC 算法都必须面对的基础操作。C 语言里用unsigned char不会有这个问题所以网上很多 C 例子抄到 Java 里会莫名其妙出错根源就在这。2.3 用一个真实请求帧手工验证01 03 00 01 00 01 - D5 CA光说理论容易虚拿一个真实报文算一遍。假设我们从 1 号从站的保持寄存器地址 0x0001 开始读 1 个寄存器请求帧是这样的01 03 00 01 00 01这段数据的 CRC16/MODBUS 计算结果应该是0xCAD5发送时低字节在前所以要拆成D5 CA追加到帧尾01 03 00 01 00 01 D5 CA有兴趣可以按上面的流程手算验证。以第一个字节0x01为例初始0xFFFF异或0x01得到0xFFFE然后依次判断最低位并右移。第一轮0xFFFE最低位是 0右移得0x7FFF第二轮0x7FFF最低位是 1右移得0x3FFF再异或0xA001得0x9FFE…… 依次迭代 8 次后CRC 变成0x807E。再用同样的方式依次处理剩下几个字节最终得到0xCAD5。这个例子我强烈建议你手动跑一遍。第一次接触 CRC 的人把这个流程对着代码走通后面不管用查表还是换语言都不容易再犯糊涂。3. 查表法实现日常开发真正推荐的版本3.1 查表法为什么快把 8 次移位预计算成 256 项表格直接计算法每处理一个字节都要循环 8 次做判断和移位。如果数据量很小比如一条 Modbus 帧才几个字节这点开销无所谓。但如果你想做一个稳定、可复用的工具方法我更推荐查表法。查表法的原理是空间换时间一个字节有 256 种可能取值我们预先把这 256 种取值经过 8 次移位异或后的结果算出来存成一张 256 项的表。真正计算的时候每读一个字节只需要查一次表外加几次异或和移位完全不用内部循环。实际效果上直接计算法每字节大约 8 次迭代加 8 次判断查表法每字节大约一两次查表和少量位运算。在 Java 上位机里这个差别通常不足以让 CPU 冒烟但查表法在嵌入式、单片机这样的资源受限环境里价值就非常明显了。另一方面查表法逻辑更扁平没有循环内分支代码路径更稳定也更容易调试。3.2 完整实现表生成 查表计算可直接抄进项目查表法分两部分生成表和查表计算。表生成可以在类加载时执行一次一劳永逸。public class Crc16Modbus { private static final int POLYNOMIAL 0xA001; private static final int[] TABLE buildTable(); /** * 生成 256 项 CRC 查找表 */ private static int[] buildTable() { int[] table new int[256]; for (int i 0; i 256; i) { int value i; for (int bit 0; bit 8; bit) { if ((value 0x0001) ! 0) { value (value 1) ^ POLYNOMIAL; } else { value 1; } } table[i] value 0xFFFF; } return table; } /** * 计算 CRC16 MODBUS 校验值查表法 * * param data 数据 * param length 参与计算的长度 * return CRC 值 */ public static int crc16(byte[] data, int length) { int crc 0xFFFF; // 初始值对应 MODBUS 变体 for (int i 0; i length; i) { crc (crc 8) ^ TABLE[(crc ^ data[i]) 0xFF]; } return crc 0xFFFF; } public static int crc16(byte[] data) { return crc16(data, data.length); } }这段代码和直接计算法的逻辑完全等价。注意查表法的异或处我还是保留了 0xFF虽然(crc ^ data[i]) 0xFF这个表达式里由于最后的 0xFF会把高位截掉符号扩展的干扰已经被处理掉了但写上这个与运算更清楚也更保险。生产代码要的是明确不是炫技。3.3 性能对比和使用建议上位机该用哪种我测过一个很简单的场景对一个长度 64 字节的缓冲区连续计算 100 万次 CRC。直接计算法耗时大约是查表法的 3 倍左右。不过说句公道话在典型的 Windows/Linux 上位机里就算用直接计算法处理几百条 Modbus 帧也就是几毫秒的事用户根本感知不到差距。所以我给你一个实用建议如果是自己学习、写一次性调试工具直接用直接计算法逻辑更直观如果是做正式项目、代码要长期维护或者将来要移植到嵌入式环境用查表法性能余量更大结构也更漂亮。下面所有和帧组装、校验相关的代码我都用查表法这个Crc16Modbus类来写。3.4 把算法封装成独立工具类别跟业务逻辑混在一起在实际项目里我习惯把所有校验相关的代码单独拎出来不跟串口读取、UI 显示混在一个类里。上面这个Crc16Modbus类就可以直接作为一个独立工具类存在。为什么要强调这点因为 CRC 计算是纯粹的数学运算输入输出都很清晰非常适合做单元测试。你把标准测试串、已知报文帧的期望值写进测试类里以后不管谁改了这个类跑了测试就知道有没有改坏。这种隔离设计能帮你避免很多“我明明没动校验逻辑怎么突然通信失败了”的灵异事件。4. 拼帧与验帧CRC 字节在 Modbus RTU 报文里的进出规矩4.1 先理清报文结构地址、功能码、数据、CRC 低字节在前Modbus RTU 帧格式长这样字段长度说明从站地址1 字节1~2470 是广播地址功能码1 字节0x03 读保持寄存器、0x04 读输入寄存器、0x06 写单寄存器等数据段N 字节寄存器地址、数量、数据内容等随功能码不同而变化CRC 低字节1 字节CRC 结果的低 8 位CRC 高字节1 字节CRC 结果的高 8 位注意最后两行的顺序CRC 计算结果通常是 16 位整数比如0xCAD5但发送时不是把0xCA放前面、0xD5放后面而是把低字节0xD5放前面高字节0xCA放后面。这算是 Modbus RTU 最容易被忽略的格式细节之一。很多初学者组帧时把高低字节顺序搞反设备就会一直判校验失败。这里还要提一句只有 Modbus RTU串口才需要 CRC 校验。Modbus TCP 走的是 TCP/IP 协议栈底层已经保证了数据可靠性所以直接基于 Modbus TCP 时是不需要在应用层另外加 CRC 的。4.2 发送侧组织请求帧并追加校验示例读一个保持寄存器假设我们要读 1 号从站保持寄存器地址0x0001读 1 个寄存器。组装代码可以这样写public static byte[] buildReadHoldingRegisters(int slaveId, int startAddr, int quantity) { if (quantity 1 || quantity 125) { throw new IllegalArgumentException(quantity must be 1~125); } byte[] frame new byte[8]; frame[0] (byte) slaveId; // 从站地址 frame[1] 0x03; // 功能码读保持寄存器 frame[2] (byte) ((startAddr 8) 0xFF); // 起始地址高字节 frame[3] (byte) (startAddr 0xFF); // 起始地址低字节 frame[4] (byte) ((quantity 8) 0xFF); // 数量高字节 frame[5] (byte) (quantity 0xFF); // 数量低字节 int crc Crc16Modbus.crc16(frame, 6); // 只算前 6 个字节 frame[6] (byte) (crc 0xFF); // CRC 低字节在前 frame[7] (byte) ((crc 8) 0xFF); // CRC 高字节在后 return frame; }这个方法输出的结果是01 03 00 01 00 01 D5 CA把这段字节流通过串口发出去如果从站正常会返回类似01 03 02 00 64 校验的响应表示读取成功数据值是0x0064十进制 100。这里有两个容易忽略的地方一是 CRC 计算范围只到第 6 个字节为止不包括后面要追加的两位 CRC。很多初学者会把整个 8 字节数组都丢进 CRC 函数那等于把自己即将追加的 CRC 也当成原始数据了算出来的东西肯定不对。二是往字节数组里塞 16 位数值时记得 0xFF。比如crc 0xFF取低 8 位(crc 8) 0xFF取高 8 位这样转出来的 byte 才是干净的 0~255 数值。如果直接强转(byte) crcJava 对超出范围的 int 转 byte 会截断但可读性差而且高位可能残留符号位不如显式写清楚。4.3 接收侧两种校验方式重新计算与“整帧 CRC 等于 0”接收侧校验有两种做法。第一种从收到的帧里取出最后两个 CRC 字节跟用数据段重新计算出的 CRC 逐位比较。这种方案符合直觉但代码要小心处理高低字节顺序容易写错。第二种直接把整帧包括 CRC 本身丢给crc16()方法。如果算出来的结果是 0说明帧正确不是 0说明帧有误。这是 CRC 的数学性质发送方在数据末尾追加了余数使得整帧对生成多项式取余的结果回到 0。这个技巧我最喜欢因为它根本不用去关心 CRC 到底是低字节在前还是高字节在前收到的帧是什么顺序原样参与计算就行。public static boolean isFrameValid(byte[] frame) { if (frame.length 5) { return false; // 最短合法 RTU 帧是 5 字节地址功能码1数据2CRC } return Crc16Modbus.crc16(frame, frame.length) 0; }举个例子从站返回的读寄存器正常响应帧是01 03 02 00 01 79 84。如果你把整个 7 字节喂进crc16()方法结果一定是 0。我在项目里验证过很多次只要算法参数对、字节顺序对这个特性稳定成立。如果收到一帧校验结果不是 0我建议先别急着怀疑协议先把帧里每个字节打出来用十六进制对照看一下。是不是串口采样多了一个字节、少了一个字节或者 CRC 低位高位写反了。大多数“校验不通过”的问题都出在这些地方。4.4 用 Modbus Poll / Modbus Slave 验证你的 Java 实现写完了代码怎么确定你的帧真的能被设备认出来如果手头暂时没有真实设备可以用 Modbus Poll主站模拟和 Modbus Slave从站模拟这对工具搭一个虚拟测试环境。我常用的做法是先用 Modbus Slave 建一个虚拟从站配置好地址、寄存器数量然后用 Java 程序通过虚拟串口把请求帧发过去看从站能不能正确响应。如果 Modbus Slave 收到了请求但 CRC 不对它会在日志里显示异常如果校验通过Java 端能正常读到从站寄存器里的值。反过来也可以用 Modbus Poll 连一个真实从站或另一个虚拟从站然后在 Java 里实现一个简单的 Modbus 从站程序看 Modbus Poll 能不能正常读写。这样双向验证基本可以确认你的组帧、校验、解析逻辑都没有问题。一定要养成一个习惯先在模拟器上把通信跑通再上真实设备。真实设备出了问题干扰因素太多串口线、电平转换器、地址配置、波特率都有嫌疑模拟器环境干净能帮你快速锁定问题到底是不是出在 Java 代码。5. 实战踩坑记录从“能算出 CRC”到“帧帧都能过”之间有几道坎5.1 坑一Java 的 byte 是带符号的到处都要 0xFF这个坑我在前面代码里反复强调了好几次但实战中遇到它仍然很常见。尤其是读取串口缓冲区时返回的是一个byte[]里面任何一个大于等于 0x80 的字节在 Java 眼里都是负数。你把它打印出来可能还是原来的十六进制但一旦参与移位、异或符号扩展就会把高位补成 1。举个典型场景从站回复的寄存器数据正好是0x86存储在 byte 数组里实际值是 -122。如果你直接把数组丢进 CRC 函数而函数内部没有做 0xFF那么参与计算的就是0xFFFFFF86而不是0x86算出来的 CRC 必错。最保险的做法是在所有处理原始字节数组的地方统一用b 0xFF把 byte 转成 0~255 的取值范围。这个习惯养成以后能帮你避掉大量莫名其妙的数据错乱问题。5.2 坑二CRC 高低字节位置搞反组帧时最容易犯的错误之一就是把 CRC 的高位放在前面、低位放在后面。比如上面那个读寄存器的例子正确帧是01 03 00 01 00 01 D5 CA有人会写成01 03 00 01 00 01 CA D5。我当年因为这个错误折腾了一个晚上。程序怎么发设备都不理但拿 Modbus Poll 发同样的指令又能通。后来一个朋友提醒我看一下原始字节流我才发现不就是两个字节顺序反了嘛。Modbus RTU 协议明确规定 CRC 低字节在前高字节在后这个顺序没有任何商量的余地。排查这类问题时最有效的方法是拿一个已知正确 CRC 的报文帧比如01 03 00 01 00 01 D5 CA让代码输出你实际组好的帧逐字节对比。不要靠脑子记要真的打印出来看。5.3 坑三协议版本选错用 IBM/ARC 的 CRC16 去算 MODBUS还有一个高频坑是算法参数不对尤其是初始值。很多人用的依赖库或者在线计算器默认的是 CRC-16/IBM也叫 CRC-16/ARC初始值是 0x0000而 MODBUS 要求初始值是 0xFFFF。结果就差这么个初始值校验永远不对。记得用标准测试串123456789去验证你的实现MODBUS 版应该是0x4B37IBM 版是0xBB3D。如果你的实现算出来是后者赶紧把初始值改成0xFFFF马上就能对上了。排查这类问题比排查硬件问题快得多因为完全不依赖外部设备本地跑个测试就能确定。5.4 坑四RTU 帧间间隔与脏数据——校验可能通过但业务数据还是乱CRC 能过只能说明这一帧的字节在传输过程中没有被篡改但它不代表帧的边界一定正确。Modbus RTU 规定一帧数据内部各字节之间的时间间隔不能超过 3.5 个字符时间超过这个间隔就从站会认为这一帧结束了。这个时间有多短拿常见的 9600 波特率、8 数据位 1 停止位来算一个字符大约(1 8 1) / 9600秒约 1.04 毫秒3.5 个字符就是约 3.6 毫秒。如果在接收过程中出现一个超过 3.6 毫秒的间隙后面再收到的字节就会被当成新的一帧开头。这时候如果强制按固定长度拼帧很可能拼出一帧“偶数位对不上”的脏数据。我处理这种问题的方法是先做一个字节累积器记录每个字节到达的时间戳。一旦发现当前字节与上一字节的间隔超过 3.5 字符时间就把之前的缓冲清掉重新开始组帧。然后再按最小帧长度、功能码、CRC 这三个条件判断帧是否完整。很多 Modbus 库内部已经处理了这个问题但如果你是自己用串口输入流读数据这个逻辑一定要自己实现。5.5 坑五CRC 能过但功能码异常——从站报错帧也要校验最后一个容易被忽视的场景从站返回的不一定都是正常数据也可能是异常响应。Modbus 规定从站处理失败时会把返回帧的功能码最高位置 1并在后面带一个异常码。比如发送01 03 00 01 00 01请求读取时如果地址越界或其他原因失败从站可能回复01 83 02 CRC这里0x83是0x03 | 0x80表示“读保持寄存器功能失败”0x02是异常码表示“非法的数据地址”。这种帧虽然可能带回 CRC 且校验通过但它不是你要的数据解析时一定要先判断功能码是不是正常值。我建议接收侧解析逻辑按这样分层先做 CRC 校验不过就扔掉。再看地址字段是不是自己。再看功能码最高位是否为 1是的话查异常码表输出错误信息。最后才解析数据段。这套顺序看起来多写了几个 if但能避免把错误帧当成正常数据去解析省下来的排查时间远超写代码的时间。写在最后项目做多了之后我发现 CRC 这个东西其实不难难的是把每个细节都照顾到算法参数选对、字节顺序放对、Java 符号位处理对、帧边界判断对。每一步单独看都很简单但串在一起出问题时调试体验确实很折磨人。我个人最后的建议是把 CRC 计算工具类独立出来用标准测试串写进单元测试配上几个常见的 Modbus 报文例子。以后不管是换设备、换协议库还是别人接手代码只要这个测试能过底层的校验逻辑就大概率没有问题。这个习惯看起来不起眼但在那些“设备偶尔无响应”“过一会儿才恢复”的疑难杂症排查中真的能帮你撇开一大片嫌疑。
返回列表