
简介一份面向电能表数据采集与远程抄表场景的Java协议解析源码适用于需要对接DL/T645-2007串口通信设备的开发人员。源码覆盖数据帧拆分、地址域与控制域解析、CRC校验、应答机制及串口读写调用逻辑可帮助读者快速掌握电能表通信协议并直接用于二次开发或系统集成。压缩包共11个文件包含3个Java源代码、3个编译后的class文件、2个properties配置文件、1个XML配置、1个工程描述文件及1个JSP页面包体仅20KB结构轻量清晰便于在现有项目中引入与调整。目前已吸引2458人学习下载。引入该源码后开发者可省去从零编写底层通信解析的重复劳动更快搭建起支持DL/T645-2007协议的电能表通信模块实现电量读取、参数设置与异常处理等常见功能。 做过能源数据采集的朋友对 DL/T 645-2007 这个电力行业标准应该都不陌生。它是国内电能表和采集设备之间最常用的串口通讯协议项目里一旦涉及“抄表”“用电信息采集”“能耗平台”基本绕不开这套协议。Java 环境下做 DLT645-2007 协议解析代码本身不算复杂但要把串口数据流正确切成帧、处理各种异常应答、再避开数不清的细节坑就不是“读几个字节”那么简单了。最近我重构了一个老项目里的 Java 版 DLT645-2007 串口解析模块趁这次把协议解析源码怎么设计、串口参数怎么配、现场容易踩哪些坑完整梳理一遍。如果你正要写电表采集服务、能源网关或者毕业设计需要参考一份 DLT645-2007 解析源码这篇可以直接当作业抄一半。1. 动手前必须搞懂的几个协议基础1.1 DLT645-2007 帧结构就长这样DL/T 645-2007 是主从问答式协议主站发请求帧电表回应答帧。无论请求还是应答帧外壳是同一套核心区别只在控制码。帧结构不算复杂但必须背下来后面所有解析代码都围绕这张表展开字段长度字节说明帧起始符1固定 0x68地址域6从站通讯地址BCD 码低字节在前传送帧起始符1固定 0x68控制码1区分方向、应答状态和功能数据域长度 L1数据域实际字节数不含长度字节本身数据域 DATAL数据标识、密码、操作者代码、业务数据等校验和 CS1从第一个 0x68 到数据域末尾逐字节累加取低 8 位结束符1固定 0x16这里最容易犯迷糊的是“地址域”。地址域实际上就是电表的表号但不是把表号字符串原样发出去而是先把表号按 BCD 码拆成 6 个字节同时把字节顺序颠倒。举个例子一台 12 位表号的电表表号是123456789012按两位一组拆成 BCD 字节就是12 34 56 78 90 12发送时地址域变成12 90 78 56 34 12。很多初学者第一次遇到“倒序”都会被绕晕其实是协议要求先低字节后高字节传输。如果你的表号不足 12 位也要先在高位补 0 凑满 12 位再拆字节倒序。1.2 控制码和长度域要区分清楚控制码看起来就是一字节实际上每个 bit 都有含义最高位表示传输方向0 是主站发给从站1 是从站回给主站D6 区分请求还是应答D5 区分正常应答还是异常应答低 5 位是功能码。实际开发中需要牢记的核心值就三个主站读请求控制码 0x11从站正常应答控制码 0x91从站异常应答控制码 0xD1。如果收到 0xD1说明电表明确告诉你“这条命令有问题”它的数据域里会带一个错误信息字常见错误值有命令格式错、密码错、通信速率不能改变、电池欠压等。调试时看到 0xD1 不要慌先把错误字读出来再判断。数据域长度 L 是一个单字节表示后面数据域实际有几个字节。比如一个只带 4 字节数据标识的读请求L 就等于 4。计算整帧长度时用L 12其中 12 来自帧结构里除了“数据域长度数据域”之外的所有字节。这个公式在切帧时非常关键。2. Java 串口方案选型与工程骨架2.1 我为什么把 RXTX 换成了 jSerialCommJava 操作串口老牌方案是 RXTX但它带了一个历史包袱必须在系统里安装对应的本地动态库Windows 是 dllLinux 是 so换一台机器就要重新配置项目维护成本很高。我现在更推荐 jSerialCommMaven 直接引入依赖跨平台API 也简单不需要额外安装 native 库适合快速落地。如果你的项目还在维护老代码必须继续用 RXTX 也不是不行。协议解析部分和串口库其实是解耦的建议把所有串口读写抽象到一层上层只处理字节流这样底层切到什么库都不会影响解析逻辑。Maven 依赖很简单dependency groupIdcom.fazecast/groupId artifactIdjSerialComm/artifactId version2.10.4/version /dependency2.2 串口参数别乱配默认 2400 8E1DL/T 645-2007 的物理层参数有明确规定默认通讯速率是 2400bps数据位 8 位停止位 1 位偶校验无流控。不过很多集中器或采集终端会把波特率改成 9600所以接设备前一定要先确认电表或者采集器的实际配置否则接收端全是乱码。初始化串口的代码大致如下SerialPort serialPort SerialPort.getCommPort(COM3); serialPort.setBaudRate(2400); serialPort.setNumDataBits(8); serialPort.setNumStopBits(1); serialPort.setParity(SerialPort.EVEN_PARITY); serialPort.setFlowControl(SerialPort.FLOW_CONTROL_DISABLED); serialPort.setComPortTimeouts(SerialPort.TIMEOUT_READ_SEMI_BLOCKING, 100, 0); serialPort.openPort();setComPortTimeouts这行是很多人会漏的。串口如果不设置超时readBytes可能一直阻塞请求线程就卡死了。我给的是 100ms 超时实际项目里可以按抄表周期调整。3. 解析源码怎么写完整拆分一帧数据3.1 从串口流里“切”出一个个完整帧串口没有消息边界它本质就是一个字节流。一次readBytes拿到的数据可能包含半帧、一帧、甚至好几帧所以必须先做“分帧”。最简单可靠的办法是先找起始符 0x68读到长度域后算出L 12个字节的完整帧长再判断校验和和结束符。下面这段代码是一个比较典型的切帧逻辑我把核心算法抽出来了public static ListDlt645Frame parseBuffer(byte[] buffer, int size) { ListDlt645Frame frames new ArrayList(); int pos 0; while (pos size) { if ((buffer[pos] 0xFF) ! 0x68) { pos; continue; } if (pos 10 size) { break; } int len buffer[pos 9] 0xFF; int frameLen len 12; if (pos frameLen size) { break; } int sum 0; for (int i pos; i pos 10 len; i) { sum (sum (buffer[i] 0xFF)) 0xFF; } if (sum ! (buffer[pos 10 len] 0xFF)) { pos; continue; } if ((buffer[pos frameLen - 1] 0xFF) ! 0x16) { pos; continue; } Dlt645Frame frame new Dlt645Frame(); frame.setRaw(Arrays.copyOfRange(buffer, pos, pos frameLen)); frame.setAddress(Arrays.copyOfRange(buffer, pos 1, pos 7)); frame.setControl(buffer[pos 8]); frame.setData(Arrays.copyOfRange(buffer, pos 10, pos 10 len)); frames.add(frame); pos frameLen; } return frames; }这里的思路是“滑动窗口”式寻找帧头。如果一帧校验失败我不会直接返回而是从当前 0x68 的下一个字节重新找起。因为数据域里也可能碰巧出现 0x68如果死认第一个 0x68很容易被误帧带偏。3.2 校验和计算范围一个字节都不能错校验和是协议里最容易出错的地方。它的计算范围是“从第一个帧起始符 0x68 开始一直到数据域最后一个字节结束”把这一串字节逐字节累加最终保留低 8 位。很多新手把范围理解错漏掉起始符或者把校验和字节本身也加进去结果永远对不上。上面代码里循环的结束条件是pos 10 len正好覆盖了起始符、地址、第二个 0x68、控制码、长度域和数据域最后一个字节是数据域的结尾。因为 Java 的byte是有符号的每个字节都要先 0xFF再累加否则负数的累加结果会让你怀疑人生。3.3 数据域还原BCD 转字符串、加密位和符号位拿到数据域以后通常前 4 个字节是数据标识 DI0~DI3后面才是真正的业务数据。业务数据大部分是 BCD 码一个字节可以表示两位十进制数。比如电表的电压值0x02 0x35按 BCD 解码就是235如果协议约定该值带一位小数那么实际电压就是23.5V。电能数据一般最后两位是小数位读取后要除以 100例如 BCD 串0000123456表示1234.56 kWh。转字符串的代码很简单public static String bcdToString(byte[] data, int offset, int len) { StringBuilder sb new StringBuilder(); for (int i offset; i offset len; i) { int v data[i] 0xFF; int high (v 4) 0x0F; int low v 0x0F; if (high 9 || low 9) { // 说明不是普通 BCD 码可能是有符号数或补码 } sb.append((char) (0 high)); sb.append((char) (0 low)); } return sb.toString(); }另一种情况是某些老电表或者特殊负荷数据会返回“补码”最高字节的 bit7 是符号位。如果 BCD 解码后出现0x99、0x98这类非法 BCD 数字就要考虑是不是补码表示法这时候需要先判断符号位再做补码还原。具体负值规则要以电表厂家的协议补充说明为准。加密这块也简单提一下。DL/T 645-2007 里如果电表启用了密码保护发送写命令时数据域会做加 0x33H 处理。普通抄读一般不带密码数据域就是明文。如果你们要写参数或者远程费率记得在组帧前按规范对指定部分加 0x33H解析时要反向减掉。4. 请求帧也要自己拼给电表下一条读指令4.1 拼一个读数据命令解析只是收到数据后的下半场上半场是拼请求帧。主站读请求的控制码是 0x11数据域一般是 4 字节数据标识。组帧时同样要算校验和补结束符然后通过串口发出去。下面这段代码可以直接复用public static byte[] buildReadFrame(String meterNo, byte[] di) { byte[] addr meterNoToAddress(meterNo); int dataLen di.length; byte[] frame new byte[dataLen 12]; frame[0] (byte) 0x68; System.arraycopy(addr, 0, frame, 1, 6); frame[7] (byte) 0x68; frame[8] (byte) 0x11; frame[9] (byte) dataLen; System.arraycopy(di, 0, frame, 10, dataLen); int sum 0; for (int i 0; i 10 dataLen; i) { sum (sum (frame[i] 0xFF)) 0xFF; } frame[10 dataLen] (byte) sum; frame[frame.length - 1] (byte) 0x16; return frame; } static byte[] meterNoToAddress(String meterNo) { String no (000000000000 meterNo); no no.substring(no.length() - 12); byte[] bcd new byte[6]; for (int i 0; i 6; i) { int v Integer.parseInt(no.substring(i * 2, i * 2 2), 16); bcd[5 - i] (byte) v; } return bcd; }以表号123456789012为例如果我想读某项电量数据假设数据标识是00 01 00 00最终发送的帧长这样68 12 90 78 56 34 12 68 11 04 00 01 00 00 XX 16其中XX是计算出来的校验和。注意数据标识各个厂家可能有差异我建议你接到真实电表后先用厂家提供的通讯协议文件核对一遍再写死。4.2 收到应答后怎么把数值抠出来电表正常应答时控制码是 0x91异常应答是 0xD1。收到帧以后先看控制码再取数据域。一般应答帧的数据域结构是数据标识4字节 业务数据也就是说去掉前 4 字节以后才是你要的物理值。比如抄一个“当前组合有功总电能”业务数据是 4 字节 BCD 码转成字符串后还要根据协议精度把小数点移动两位。我习惯在解析层直接返回 BigDecimal不让业务层用 double 去处理浮点否则电压、电能这些值经过浮点运算后很容易出现 90.0000001 这种问题。public BigDecimal parseEnergyValue(Dlt645Frame frame) { byte[] data frame.getData(); if (data.length 4) { return null; } byte[] value Arrays.copyOfRange(data, 4, data.length); String bcd bcdToString(value, 0, value.length); return new BigDecimal(bcd).movePointLeft(2); }如果数据标识对应的是瞬时量比如电压、电流精度规则又不一样。所以封装解析函数时最好把精度参数也传进来或者按数据标识做一个映射表。5. 现场实战中经常踩的坑和排查手段5.1 串口层收不到数据先别怀疑协议串口收不到数据或者收到乱码大概率不是协议问题。我通常会先检查三件事串口权限、波特率、RS-485 接线方向。Linux 下使用串口运行 Java 进程的用户必须在dialout这样有串口访问权限的用户组里否则openPort()可能成功了但一读就超时。Windows 下则要确认 USB 转 485 的驱动装好了设备管理器里能找到COM3。接线也同样关键RS-485 的 A/B 两条线接反以后电表不会回应任何东西。接反的时候用串口调试助手去读往往只有一片空数据这不是代码能解决的问题。协议调试阶段我建议先用串口调试助手手动发一条 0x68 开头的报文确认电表能回“一个像样的 0x68 帧”再回来抓 Java 串口的问题。5.2 协议层校验和、粘包和“假 0x68”收到数据但解析失败最常见原因有三个。第一个是校验和范围算错。很多自学的同学对帧结构理解不深把起始符或者数据标识排除在累加范围外结果必然失败。养成习惯校验和一定是从第一个 0x68 开始到数据域最后一个字节结束。第二个是粘包。电表回应可能不会刚好一个帧一次readBytes读完数据流可能被 TCP 缓冲、串口缓冲切割成好几段。所以解析器必须做成“攒数据、切帧”的增量模式不能指望一次读回完整帧。第三个是被数据域里的 0x68 干扰。协议规定帧起始符是 0x68但数据域里也可能出现 0x68如果简单地从第一个 0x68 开始读L12个字节很可能把一帧切错。正确做法是切完一帧后必须校验 CRC校验不过就丢弃当前帧头往后继续找下一个 0x68 重新尝试。还有一个细节地址域倒序。报错时建议先手动把电表表号拆成 BCD 再倒序对比你组帧时填的地址域是否一致。这个错我见过太多往往调试半天才发现是表号拆错了。5.3 并发和线程安全同一个串口不能同时发多条命令串口是半双工同一时刻只能有一个请求在等待应答。如果一个抄表服务开了多线程每个线程都直接去读写同一个串口必然出现“你发的请求被别人应答收割”的混乱局面。实际项目里我会为每个串口维护一个单线程的写队列所有抄表指令都排成队列逐个发送然后同步等待应答。发送后的等待超时也要处理。我一般设置 300ms 的重试间隔单帧等待 500ms 没应答就超时同一帧最多重试 3 次。有些多功能电表在处理复杂度比较高的数据块时响应会稍慢但绝大多数不会超过 500ms。如果现场抄表链路较长比如中间隔着采集器这个超时可以适当放宽到 1 秒。6. 源码怎么组织更好复用协议解析代码不要和业务代码搅在一起。我最终采用的模块划分很简单只有三层Dlt645Frame帧实体负责保存原始字节、地址、控制码、数据域Dlt645Decoder纯解析工具输入byte[]输出ListDlt645Frame不依赖任何串口类SerialChannelService串口读写层负责 jSerialComm 的打开关闭、读写、超时重发。这样设计的好处是Dlt645Decoder可以脱离串口做单元测试直接把抓包报文转成byte[]喂进去就能验证解析逻辑。以后换串口库甚至把串口通信改成 TCP 透传改的也只是最外层那一层。调试时我习惯在日志里输出完整的十六进制收发内容并且带一个开关。协议问题一旦出现对比发送帧和接收帧基本一眼就能锁定是表号倒序、校验和错还是数据标识错。这个习惯在联调阶段帮我省了大量时间。最后再分享一个小技巧。电表返回的业务数据到了业务层以后一定要明确单位。DLT645-2007 里不同数据标识对应的小数位数不一样有的值要除 10有的要除 100有的还是整数。我建议在做数据标识映射时把每个标识的单位和精度都写进配置里解析后用 BigDecimal 统一处理。别看这是一个不起眼的细节项目上线后很多“数据偏差大”的问题追到最后其实都是精度没有按照协议文档就绪。本文还有配套的精品资源点击获取