Java实现DL/T645-2007电表协议:报文解析与踩坑实践
简介Java实现DL/T645-2007协议报文下发与上行解析的完整工程源码面向智能电表集抄系统开发、主站与终端数据采集调试人员。代码基于485转USB串口通信实现了下行报文的组织封装、上行报文的解析还原与电表数据展示可帮助读者快速掌握国网645规约的报文结构、帧格式及Java串口编程思路。资源包共34个文件包含Java源码、编译后的class文件、NetBeans工程配置xml、properties配置文件、GUI窗体form文件、示例jar包及说明文档压缩后仅173KB结构完整便于阅读与二次开发。目前已有958人学习下载。通过该工程使用者可以拿到可运行的下发/解析示例结合项目说明与示例代码理解DLT645-2007协议在串口通讯中的实际落地过程适用于电力数据采集相关课程设计、毕业设计或工业现场调试参考。 做电表数据采集绕不开DL/T645-2007。它是电力行业用电信息采集系统里最常见的通信协议我最早接触它是接手一个用Java写的采集服务要对接一屋子不同厂商的电表。老实说只看协议文档的时候觉得特别简单一帧报文就那么几个字节组帧、拆帧而已。真正写代码、联调、上线之后才发现协议简单不代表实现简单字符编码、报文时序、校验、粘包拆包任何一个点没处理好现场就是一整片抄不到数。这篇文章把我在Java里实现DL/T645-2007下行报文下发和上行报文解析的完整思路、代码和踩坑经验整理出来。适合正要写采集服务、或者已经被电表报文折腾得头疼的同学参考读完至少能少走我当初走过的那几段弯路。1. 先搞懂DL/T645-2007的报文格式1.1 一帧报文由哪些部分组成DL/T645-2007的帧结构不算复杂整个报文就是下面这个固定套路帧起始符地址域帧起始符控制码数据域长度数据域校验码结束符68HA0~A568HCLDATACS16H1字节6字节1字节1字节1字节0~254字节1字节1字节68H是帧起始符相当于一句话的“开头引号”16H是结束符相当于句号。地址域6个字节存放电表的通信地址也就是表号控制码C表示这条报文是干什么的L是DATA区的字节数CS是从帧头到DATA结束所有字节累加和的低8位用来保证传输过程没被干扰。用生活化的方式理解整帧报文就像寄快递68H是快递单的起始标记地址域是收件人门牌号控制码是“我要干嘛”的指令DATA是实际要带的货CS是快递员核对的重量16H是签收字据。所以组帧、拆帧本质上就是把这些字段按顺序拼起来或拆开。1.2 控制码、地址域和数据标识怎么理解控制码C是1个字节但里面藏了三层信息。高三位用二进制位来表示传输方向和应答状态低五位表示具体功能码。实际项目中最常用的几个控制码就这些方向控制码含义主站下发0x11读数据主站下发0x14写数据主站下发0x08广播校时从站应答0x91读数据正常应答从站应答0xD1读数据异常应答从站应答0x94写数据正常应答判断应答时先看0x80位0表示主站下发1表示从站应答再看0x40位0表示正常应答1表示异常应答。异常应答的DATA区里会带一个错误信息字节比如功能码不支持、数据标识不存在、数据越界之类。地址域6个字节是最容易被搞反的地方。电表表号比如“123456789012”从左往右每两位一组最高两位放在A5最低两位放在A0发送顺序是 A0、A1、A2、A3、A4、A5也就是“12 90 78 56 34 12”。很多刚接触协议的人直接把表号字符串转成字节顺序发出去结果电表理都不理你。数据标识DI0~DI3一共4个字节用来定位到某个具体的数据项比如当前正向有功总电能、电压、电流、冻结数据。具体每个标识对应什么值不同厂家的电表会有细微差别项目启动时一定找厂家要一份数据标识点表这是最靠谱的依据。我常用的是“02 01 01 00”读正向有功总电能发送时按DI0、DI1、DI2、DI3的顺序填即“00 01 01 02”。2. 下发报文构造从业务需求到字节流2.1 先准备三个工具方法Hex转换、BCD处理、CS校验Java里处理这种二进制报文第一步就是准备字节和十六进制字符串互转的工具否则调试的时候报文没法看。我常用的Hex工具长这样public class HexUtil { private static final char[] HEX 0123456789ABCDEF.toCharArray(); public static String toHex(byte[] data) { StringBuilder sb new StringBuilder(data.length * 3); for (byte b : data) { sb.append(HEX[(b 4) 0x0F]).append(HEX[b 0x0F]).append( ); } return sb.toString().trim(); } public static byte[] fromHex(String hex) { String clean hex.replaceAll([\\s:], ); if (clean.length() % 2 ! 0) { throw new IllegalArgumentException(hex长度必须是偶数); } byte[] out new byte[clean.length() / 2]; for (int i 0; i out.length; i) { out[i] (byte) Integer.parseInt(clean.substring(i * 2, i * 2 2), 16); } return out; } }BCD码是DL/T645里数据的基本编码方式。BCD的本质是一个字节拆成两个4位每个4位表示一个十进制数字0~9。比如0x12就表示十进制12。电表返回的电能量、时间、表号几乎全是BCD编码。把BCD字节数组还原成字符串的代码很简单public static String bcdToStr(byte[] data) { StringBuilder sb new StringBuilder(); for (byte b : data) { sb.append((b 4) 0x0F); sb.append(b 0x0F); } return sb.toString(); }CS校验的实现也很直接从第一个68H开始到DATA区最后一个字节为止把所有字节累加取低8位。注意CS和结束符16H不参与累加。我在组帧时已经把前面所有字段放进一个字节数组所以直接对这个数组求和即可。2.2 组一帧读数据报文读数据是最常见的操作。以读“当前正向有功总电能”为例数据标识是“00 01 01 02”按DI0到DI3顺序核心组帧代码如下public class Dl645FrameBuilder { public static byte[] readRequest(String meterNo, byte[] dataId) { byte[] addr meterNoToAddr(meterNo); byte[] data new byte[]{dataId[0], dataId[1], dataId[2], dataId[3]}; return buildFrame(addr, (byte) 0x11, data); } private static byte[] buildFrame(byte[] addr, byte ctrl, byte[] data) { int len data null ? 0 : data.length; ByteArrayOutputStream head new ByteArrayOutputStream(); head.write(0x68); head.write(addr, 0, 6); head.write(0x68); head.write(ctrl 0xFF); head.write(len 0xFF); if (data ! null) { head.write(data, 0, data.length); } byte[] headBytes head.toByteArray(); int cs 0; for (byte b : headBytes) { cs b 0xFF; } ByteArrayOutputStream frame new ByteArrayOutputStream(); frame.write(headBytes, 0, headBytes.length); frame.write(cs 0xFF); frame.write(0x16); return frame.toByteArray(); } private static byte[] meterNoToAddr(String meterNo) { byte[] addr new byte[6]; for (int i 0; i 6; i) { int start meterNo.length() - (i 1) * 2; String part meterNo.substring(start, start 2); addr[i] (byte) Integer.parseInt(part, 16); } return addr; } }这里有个细节值得说meterNoToAddr里用Integer.parseInt(part, 16)表面上是在按十六进制解析实际上这正好是BCD转字节的标准技巧。表号字符串“12 90 78 56 34 12”会被依次转成字节0x12、0x90、0x78、0x56、0x34、0x12。因为这个技巧表号里如果出现字母A到F也能正确转换虽然真实表号基本是纯数字。调用方式就是byte[] request Dl645FrameBuilder.readRequest(123456789012, new byte[]{0x00, 0x01, 0x01, 0x02}); System.out.println(HexUtil.toHex(request));输出应该类似68 12 90 78 56 34 12 68 11 04 00 01 01 02 CS 16。2.3 写数据和广播校时报文怎么组写数据比读数据多一步DATA区由“数据标识 待写入的数据”组成。比如要把某个表计的参数写进去调用方式是这样的public static byte[] writeRequest(String meterNo, byte[] dataId, byte[] value) { byte[] addr meterNoToAddr(meterNo); byte[] data new byte[4 value.length]; System.arraycopy(dataId, 0, data, 0, 4); System.arraycopy(value, 0, data, 4, value.length); return buildFrame(addr, (byte) 0x14, data); }写数据这类下行报文在真实环境里最容易埋雷。因为写操作可能实际已经生效但应答帧在传输中丢了主站超时后重发等于把同样的事情做了两遍。所以写数据的下发通道一定要做幂等控制至少要在业务层记录上一次写入的操作码和校验值避免重复下发的副作用。广播校时是另一种特殊报文控制码0x08DATA固定6个字节顺序是“秒 分 时 日 月 年”每个字段都是BCD。比如当前时间是2025年4月3日15时30分20秒DATA区就是 20 30 15 03 04 25。这个顺序和日常习惯完全相反我第一次写的时候按年月日时分秒组帧结果校时完成之后电表时间全乱后来翻规范才发现是秒分时日月年。广播校时不需要应答发完即止整体非常简单。2.4 下发之后的等待与重发策略报文发出后主站要等从站应答。这个等待不是单纯的Thread.sleep我一般用CompletableFuture配ScheduledExecutorService来实现超时控制。基础流程是发送前先往一个pendingMap里放一个Future收到对应地址域的应答帧后complete它同时启动一个超时任务默认800毫秒没等到就触发重发。重发次数通常设置3次超过3次就判定这条命令通信失败。串口场景下还需要额外注意帧间延时。DL/T645-2007规范里建议主站发送前一帧和后一帧之间要保持一定延时实际项目里我通常留50到100毫秒给电表内部处理留出时间。太短的话电表可能来不及响应太长又影响整体采集效率。这个值我没有用固定参数而是做成了配置项不同厂商的电表容忍度不一样。3. 上行报文解析把字节流还原成业务数据3.1 字节流预处理丢掉FE定位帧头下行报文是主动组出来的字节顺序自己可控但上行报文是从串口或网络接口源源不断流过来的原始字节里面什么都有。DL/T645允许在正式帧前附加若干字节的FE作为前导字节很多电表应答时确实会带一两个FE如果不处理直接按固定偏移找68H就会找错。我的处理思路是先忽略所有FE然后逐字节寻找0x68。找到第一个68H之后还不能立刻认为这就是帧头因为数据域里也可能出现68H。真正的确认方法是校验结构找到68H后检查它后面第7个字节也就是第二个帧起始符位置是不是也是68H。如果第二个68H也对并且后续的L字段能凑齐整个帧长、结束符是16H那基本就稳了。public class Dl645Parser { private byte[] buf new byte[2048]; private int len 0; public ParseResult onData(byte[] data, int size) { System.arraycopy(data, 0, buf, len, size); len size; return parse(); } private ParseResult parse() { // 跳过FE前导字节 int start -1; for (int i 0; i len; i) { int b buf[i] 0xFF; if (b 0xFE) continue; if (b 0x68) { start i; break; } } if (start 0 || len - start 10) { return ParseResult.NEED_MORE; } if ((buf[start 7] 0xFF) ! 0x68) { return ParseResult.RESYNC; } int dataLen buf[start 9] 0xFF; int totalLen dataLen 12; if (len - start totalLen) { return ParseResult.NEED_MORE; } if ((buf[start totalLen - 1] 0xFF) ! 0x16) { return ParseResult.RESYNC; } int cs 0; for (int i start; i start totalLen - 2; i) { cs buf[i] 0xFF; } if ((cs 0xFF) ! (buf[start totalLen - 2] 0xFF)) { return ParseResult.CS_ERROR; } return new ParseResult(start, totalLen); } }这里的start7是地址域6个字节之后的第二个68Hstart9是L字段的位置totalLen等于12加DATA长度。这个解析器每次收到新数据就调用一次能自然地处理粘包和半包问题数据不够就等下一批够一帧就切一帧出来。3.2 校验CS确保帧没有被污染CS校验是整个解析流程里最不能省的一步。实际项目中串口线路受到干扰、设备本身发送异常、网络中间设备多跳转发都可能导致帧内容被篡改。如果不做CS校验把错帧当成有效数据解析轻则抄到错误数据重则直接污染业务库里的用电量影响计费。CS算法前面已经说了从起始符68H到DATA区最后一个字节所有字节累加取低8位。解析端算出来的CS和帧里带的CS比对不一样就丢弃整帧并回到找帧头的状态。这里有个容易忽略的点参与CS计算的范围不含CS自身也不含结束符16H。我第一次写的时候图省事对整个帧数组求了和后来对不上才仔细回去看规范。3.3 解析控制码正常应答还是异常应答拿到完整帧之后第一步不是解析DATA而是先看控制码。控制码在帧里的位置是start8。判断逻辑就三行boolean fromSlave (ctrl 0x80) ! 0; boolean abnormal (ctrl 0x40) ! 0; int func ctrl 0x1F;如果是读数据请求主站发的控制码是0x11那么正常应答是0x91异常应答是0xD1。这里0x91和0xD1的低5位都是0x11所以直接通过低5位来判断业务类型是读数据、写数据还是其他操作再通过0x40位判断这是不是一次异常应答是最稳妥的。遇到异常应答时DATA区第一个字节是错误码。常见的错误码包括00表示其他错误01表示无请求数据02表示数据标识不存在03表示写数据无效。把这些错误码翻译成可读的日志信息能在联调阶段节省大量时间。我在日志框架里加了一个枚举映射解析到错误码直接输出中文描述现场排查效率高很多。3.4 从数据域中解析出电能量值数据域解析是上行解析的最后一环也是最容易出数值偏差的地方。以上行读正有功总电能应答为例DATA区结构是前4个字节是数据标识之后是4个字节的BCD电能量值。比如返回的BCD是 00 00 12 34那么还原出来的字符串是“00001234”再根据电表的量纲配置通常是小数点后两位最终结果是123.34 kWh。这里有一个隐含的坑BCD数据在报文里是大端排列也就是高字节在前低字节在后。要把4个字节拼成一个数字串直接从第一个字节到最后一个字节依次展开就行千万别反过来我见过有同事把字节反转后解析得到的数据直接就反了。对于带符号的数据项比如功率、电流这些可能为负的值DL/T645用补码表示。判断方法是看数据域第一个字节的最高位如果是1就说明是负数处理时要对整个数据做取反加一还原成原码后再按BCD解析最后加上负号。这个逻辑我封装成了一个方法public static BigDecimal bcdToDecimal(byte[] data, int scale) { byte[] raw data.clone(); boolean negative false; if ((raw[0] 0x80) ! 0) { negative true; for (int i 0; i raw.length; i) { raw[i] (byte) (~raw[i] 0xFF); } for (int i raw.length - 1; i 0; i--) { int v (raw[i] 0xFF) 1; raw[i] (byte) v; if (v 0xFF) break; } } StringBuilder sb new StringBuilder(); for (byte b : raw) { sb.append((b 4) 0x0F); sb.append(b 0x0F); } BigDecimal val new BigDecimal(sb.toString()).movePointLeft(scale); return negative ? val.negate() : val; }scale参数表示小数位数不同数据项对应不同小数位这个信息一般也在厂家点表里。实际项目里我把scale做进了数据标识点表的配置字段解析时直接查表拿scale值避免在业务代码里写死。4. 实战中容易踩的坑与排查套路4.1 高频问题速查表这几年跟DL/T645打交道遇到的现场问题其实高度集中我把高频问题整理成了一个速查表问题现象可能原因处理方式电表完全不应答表号地址域顺序反了检查地址域是否A0在最低位偶发不应答帧间延时太短增大到80~100毫秒CS校验一直失败CS计算范围错了确认不含CS和16H解析出数据不对BCD大小端搞反先按顺序展开再判断量纲带FE的帧找不到帧头没有跳过FE前导字节解析前丢弃0xFE收到错误码02数据标识不存在核对厂家数据点表写数据重复执行应答超时重发导致业务层做幂等控制最容易让人崩溃的是第一种精心组好的帧发出去电表一个字都不回。我排查这类问题时的第一反应就是抓报文看HEX把要发送的字节流和协议规范里的示例一行一行比对八成都是地址域顺序的问题或者是FE处理逻辑把正常帧头跳过了。4.2 我习惯用的排查步骤遇到解析类问题我通常不直接看业务代码而是先把原始HEX报文完整存到日志里。整个流程分三步第一步打开HEX日志确认收发的报文格式正确。主站发的帧和从站回的帧必须都能完整看到不能只记录解析后的业务数据。第二步手工用工具或脚本算一遍CS确认到底是不是校验问题。如果是校验问题优先怀疑是不是把FE或者额外字符算进去了。第三步如果报文和CS都正常再把解析后的数据转成十进制跟电表液晶屏或者厂家调试软件上显示的值对一遍确认是不是量纲或者取错字节。这个流程看起来简单但非常有效。现场80%的问题靠这三步就能定位。切记不要一上来就怀疑通信链路先把自己这端的报文格式管好再往链路和对手设备那边查。5. 代码结构上的几个建议5.1 通信层与协议层分离写采集服务的同学容易犯一个毛病就是把串口读取、帧解析、业务处理全部写在一个大类里。最开始确实方便但一旦要同时对接网络通道和串口通道或者要切换通信方式就非常痛苦。我的做法是拆成三层通信层只负责收发字节流不关心数据内容协议层只负责组帧、拆帧、CS校验、数据标识映射业务层只关注抄表任务、数据入库和异常处理。这样通信方式从串口切换到网络只需要替换通信层协议层和业务层几乎不用动。5.2 做一个简单的状态解析器而不是到处复制粘贴DL/T645解析逻辑可能会在多个地方用到主动抄表、被动接收上报、甚至调试工具里。如果一个项目里出现好几份类似的解析代码维护起来就是灾难。建议把帧解析器做成一个独立的类内置缓冲区和状态判断对外暴露onData和ParseResult两个方法。这样无论是串口回调、Socket读取还是测试代码都能复用同一套解析逻辑。我在前面的代码示例就是这个思路实际项目中还可以在这个类里加上帧率统计和异常计数器方便监控。5.3 日志打全模拟器跑起来最后说两个能大幅提升开发效率的习惯。一是日志必须打全HEX千万不要只记录解析后的业务字段万一数据不对没有原始报文根本没法排查。二是在没有真实电表的时候自己用Netty或普通Socket写一个电表模拟器收到主站请求后按协议规范回一帧固定数据。我到现在还在本地留着这个模拟器协议升级或者重构解析代码时先跑一遍模拟器的回归用例确认所有数据标识都能正确解析再上真实设备省下的联调时间非常可观。如果你也在做DL/T645相关的东西建议把这个协议当成一个典型的“小而杂”的报文协议来做帧结构简单但实际工程里的坑一个都不少。把地址域顺序、BCD编码、CS校验范围、FE前导字节这几个关键点控住整个采集链路基本就稳了一大半。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →