简介: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的帧结构不算复杂,整个报文就是下面这个固定套路:
| 帧起始符 | 地址域 | 帧起始符 | 控制码 | 数据域长度 | 数据域 | 校验码 | 结束符 |
|---|---|---|---|---|---|---|---|
| 68H | A0~A5 | 68H | C | L | DATA | CS | 16H |
| 1字节 | 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); }写数据这类下行报文,在真实环境里最容易埋雷。因为写操作可能实际已经生效,但应答帧在传输中丢了,主站超时后重发,等于把同样的事情做了两遍。所以写数据的下发通道一定要做幂等控制,至少要在业务层记录上一次写入的操作码和校验值,避免重复下发的副作用。
广播校时是另一种特殊报文,控制码0x08,DATA固定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); } }这里的start+7是地址域6个字节之后的第二个68H,start+9是L字段的位置,totalLen等于12加DATA长度。这个解析器每次收到新数据就调用一次,能自然地处理粘包和半包问题:数据不够就等下一批,够一帧就切一帧出来。
3.2 校验CS,确保帧没有被污染
CS校验是整个解析流程里最不能省的一步。实际项目中,串口线路受到干扰、设备本身发送异常、网络中间设备多跳转发,都可能导致帧内容被篡改。如果不做CS校验,把错帧当成有效数据解析,轻则抄到错误数据,重则直接污染业务库里的用电量,影响计费。
CS算法前面已经说了,从起始符68H到DATA区最后一个字节,所有字节累加取低8位。解析端算出来的CS和帧里带的CS比对,不一样就丢弃整帧,并回到找帧头的状态。这里有个容易忽略的点:参与CS计算的范围不含CS自身,也不含结束符16H。我第一次写的时候图省事,对整个帧数组求了和,后来对不上才仔细回去看规范。
3.3 解析控制码:正常应答还是异常应答
拿到完整帧之后,第一步不是解析DATA,而是先看控制码。控制码在帧里的位置是start+8。判断逻辑就三行:
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前导字节这几个关键点控住,整个采集链路基本就稳了一大半。
本文还有配套的精品资源,点击获取