简介:本资源面向智能电表串口通讯开发人员与数据采集主站开发者,提供Java版DL/T645-2007规约的完整实现,重点解决下行报文的组织下发与上行报文的解析问题。通过485转USB接口即可向电表下发指令并解析回传数据,适用于主站与数据终端的数据采集场景,方便开发人员调试智能电表通讯。压缩包共34个文件,约173KB,包含3个java源码、10个class编译文件、5个xml与5个properties配置、3个jar依赖库及若干txt说明文档,另附NetBeans工程文件与GUI窗体资源,结构完整可直接导入运行。目前已有962人学习下载。读者可从中获取报文组帧与解析的核心方法、串口通讯调试思路以及电表数据展示的参考实现,适合作为645协议开发入门的实践样例。
1. 电表通信里的"黑匣子":为什么Java工程师也要啃DL/T645-2007
做能源管理平台、充电桩后台或者园区抄表系统的Java开发者,迟早会撞上一件事:现场电表不跟你讲HTTP,也不讲JSON,它只认一串十六进制的串口报文。这串报文遵循的就是DL/T645-2007规约。很多人第一次拿到抓包数据时是懵的——68 12 34 56 78 90 12 68 11 04 33 33 34 33 8A 16,这玩意儿到底哪几位是地址、哪几位是数据、校验怎么算?更麻烦的是,下发和上行是两套逻辑:下发要自己拼帧、算校验、等回复;上行要拆帧、验校验、解数据域,还要处理BCD编码和异常应答。
这篇文章面向的是需要在自己Java服务里直接对接电表或采集器的后端工程师。我会把DL/T645-2007的帧结构拆开,给出可复用的下发组帧和上行解析代码,把校验、地址、控制码、数据标识这几个最容易翻车的地方讲透。读完你应该能独立写出一个不依赖第三方库、能跑在Spring Boot里的645协议编解码模块。
2. 拆开一帧645报文:字节位、控制码与数据标识
2.1 帧结构逐字节对照
DL/T645-2007的帧格式是固定的,不管下行还是上行,骨架都一样。先把这张表刻在脑子里:
| 字段 | 字节数 | 说明 |
|---|---|---|
| 起始符 | 1 | 固定0x68 |
| 表地址 | 6 | BCD码,低位在前 |
| 起始符 | 1 | 固定0x68 |
| 控制码 | 1 | 决定读/写/应答 |
| 数据域长度 | 1 | 数据域字节数 |
| 数据域 | N | 数据标识+数据 |
| 校验码 | 1 | 从第一个0x68到数据域末的算术和 |
| 结束符 | 1 | 固定0x16 |
关键点:表地址是低位在前。比如表号是123456789012,实际字节序是12 90 78 56 34 12。这个顺序搞反,现场就是"表不回复",而且没有任何报错,纯靠抓包才能发现。
2.2 控制码:下行和上行的分水岭
控制码决定了这帧是干什么的。常用的几个:
0x11:读数据(主站下发)0x91:读数据应答(表回复),最高位D7=1表示从站应答0x14:写数据(主站下发)0x94:写数据应答0x08:广播校时0xD8:异常应答,D7=1且D6=1
判断方向有个简单规则:D7位为0是主站发出,为1是表端应答。解析上行报文时,先看控制码D7位,能立刻区分这是正常应答还是异常。
2.3 数据标识:你要读的到底是哪个量
数据标识是4字节,采用压缩BCD编码。比如:
00 00 00 00:当前正向有功总电能02 00 00 00:当前反向有功总电能01 01 00 00:A相电压02 01 00 00:A相电流
注意数据标识的字节顺序也是低位在前。读电压下发时,数据域写的是01 01 00 00,但你在文档里看到的是"01010000"。这个反序是新手第一个大坑。
2.4 校验码:算术和,不是CRC
645-2007用的是算术和校验,不是CRC16。计算范围是从第一个起始符0x68开始,到数据域最后一个字节,全部相加取低8位。很多人习惯性写CRC,结果表永远不回复。
// 计算校验码:从帧头到数据域末尾的算术和 public static byte calcChecksum(byte[] frame, int dataLen) { int sum = 0; // 帧结构:68 + 6字节地址 + 68 + 控制码 + 长度 + 数据域 int end = 1 + 6 + 1 + 1 + 1 + dataLen; for (int i = 0; i < end; i++) { sum += frame[i] & 0xFF; } return (byte) (sum & 0xFF); }参数说明:frame是完整帧数组,dataLen是数据域长度。end的计算是1(起始符)+6(地址)+1(第二起始符)+1(控制码)+1(长度字节)+数据域长度。循环里& 0xFF是为了把byte当无符号数加,否则负数会算错。
3. 用Java拼一帧读电压指令:组帧、校验、发送
3.1 地址BCD编码与反序处理
下发前第一步是把表号转成6字节BCD。表号通常是12位十进制字符串,每两位压一个字节,然后整体反序。
// 表号字符串转6字节BCD,低位在前 public static byte[] encodeAddress(String meterNo) { if (meterNo.length() != 12) { throw new IllegalArgumentException("表号必须12位"); } byte[] bcd = new byte[6]; for (int i = 0; i < 6; i++) { int high = meterNo.charAt(i * 2) - '0'; int low = meterNo.charAt(i * 2 + 1) - '0'; bcd[i] = (byte) ((high << 4) | low); } // 反序:低位在前 byte[] result = new byte[6]; for (int i = 0; i < 6; i++) { result[i] = bcd[5 - i]; } return result; }逻辑说明:先按字符串顺序压BCD,得到12 34 56 78 90 12,然后反序成12 90 78 56 34 12。参数meterNo必须是纯数字12位,实际项目里建议先做正则校验,避免现场表号带字母导致- '0'算出负数。
3.2 组帧完整流程
以读A相电压为例,数据标识01 01 00 00,控制码0x11。
public static byte[] buildReadFrame(String meterNo, byte[] di) { byte[] addr = encodeAddress(meterNo); // 数据域 = 数据标识4字节 int dataLen = di.length; byte[] frame = new byte[1 + 6 + 1 + 1 + 1 + dataLen + 1 + 1]; int idx = 0; frame[idx++] = 0x68; System.arraycopy(addr, 0, frame, idx, 6); idx += 6; frame[idx++] = 0x68; frame[idx++] = 0x11; // 读数据 frame[idx++] = (byte) dataLen; System.arraycopy(di, 0, frame, idx, dataLen); idx += dataLen; // 校验 int sum = 0; for (int i = 0; i < idx; i++) { sum += frame[i] & 0xFF; } frame[idx++] = (byte) (sum & 0xFF); frame[idx++] = 0x16; return frame; }参数说明:di是数据标识字节数组,注意传入时已经是低位在前的顺序。比如读电压传new byte[]{0x01, 0x01, 0x00, 0x00}。frame长度计算:1+6+1+1+1+dataLen+1+1,最后两个是校验和结束符。
3.3 串口发送与超时等待
Java侧通常用jSerialComm或RXTX。核心是设置超时,645表响应慢,建议1500ms起步。
SerialPort port = SerialPort.getCommPort("COM3"); port.setBaudRate(2400); // 645常用2400,部分表支持9600 port.setComPortParameters(2400, 8, SerialPort.ONE_STOP_BIT, SerialPort.NO_PARITY); port.setComPortTimeouts(SerialPort.TIMEOUT_READ_SEMI_BLOCKING, 1500, 0); port.openPort(); byte[] cmd = buildReadFrame("123456789012", new byte[]{0x01,0x01,0x00,0x00}); port.getOutputStream().write(cmd); port.getOutputStream().flush(); byte[] buf = new byte[64]; int len = port.getInputStream().read(buf);注意:645-2007默认偶校验、8数据位、1停止位,但很多现场采集器配的是无校验。这个参数不对,表现是能发出去但收不到任何回复。建议做成配置项,现场调试时逐个试。
4. 上行报文解析:从字节流到可读数值
4.1 帧同步与长度判定
串口读到的数据不一定是一帧完整的,可能粘包或半包。稳妥做法是先找0x68,再根据长度字节判断整帧长度。
public static byte[] extractFrame(byte[] buf, int len) { for (int i = 0; i < len - 12; i++) { if ((buf[i] & 0xFF) == 0x68 && (buf[i + 7] & 0xFF) == 0x68) { int dataLen = buf[i + 9] & 0xFF; int total = 1 + 6 + 1 + 1 + 1 + dataLen + 1 + 1; if (i + total <= len && (buf[i + total - 1] & 0xFF) == 0x16) { byte[] frame = new byte[total]; System.arraycopy(buf, i, frame, 0, total); return frame; } } } return null; }逻辑说明:先定位两个0x68的位置(间隔7字节),再读长度字节算出整帧长度,最后验证结束符0x16。三个条件都满足才认为是一帧。参数buf是原始缓冲区,len是实际读到的字节数。
4.2 校验验证与异常应答识别
解析前必须验校验,不通过直接丢弃。
public static boolean verifyChecksum(byte[] frame) { int dataLen = frame[9] & 0xFF; int end = 1 + 6 + 1 + 1 + 1 + dataLen; int sum = 0; for (int i = 0; i < end; i++) { sum += frame[i] & 0xFF; } return (byte)(sum & 0xFF) == frame[end]; }校验通过后看控制码。如果是0xD8,说明表返回异常,数据域第一个字节是错误码。常见错误码:0x01其他错误、0x02无请求数据、0x04密码错。这时候不要继续解析数据,直接抛业务异常。
4.3 BCD解码与数据域还原
正常应答的数据域格式是:数据标识4字节 + 数据N字节。数据部分通常是BCD码,需要还原成数值。
// BCD字节数组转字符串,再转数值 public static String bcdToString(byte[] data, int offset, int len) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < len; i++) { int high = (data[offset + i] >> 4) & 0x0F; int low = data[offset + i] & 0x0F; sb.append(high).append(low); } return sb.toString(); }以电压为例,数据域返回01 01 00 00+XX XX,电压值格式是XXX.X,单位V。假设返回字节22 02,BCD解码得2202,按1位小数还原为220.2V。这里的小数位是规约固定的,不同数据标识小数位不同,必须查表,不能统一处理。
4.4 完整解析方法
public static Map<String, Object> parseResponse(byte[] frame) { Map<String, Object> result = new HashMap<>(); if (!verifyChecksum(frame)) { throw new RuntimeException("校验失败"); } int ctrl = frame[8] & 0xFF; if (ctrl == 0xD8) { throw new RuntimeException("表异常应答,错误码:" + (frame[11] & 0xFF)); } int dataLen = frame[9] & 0xFF; // 数据域从第11字节开始(0基) int dataStart = 10; // 数据标识4字节 String di = bcdToString(frame, dataStart, 4); // 实际数据 int valueLen = dataLen - 4; String raw = bcdToString(frame, dataStart + 4, valueLen); result.put("di", di); result.put("raw", raw); return result; }参数说明:frame是完整帧。dataStart=10是因为前面有1+6+1+1+1=10字节。valueLen是数据域去掉4字节数据标识后的长度。返回的raw是BCD字符串,业务层再根据数据标识查小数位做转换。
5. 避坑与排查:现场最容易翻车的5个点
5.1 表不回复,抓包发现地址反了
现象:下发指令后串口无任何返回,换表也不行。原因:表地址字节序搞反。645要求低位在前,很多人按字符串顺序直接压BCD。解决:用encodeAddress做反序,或者抓一帧表的主动上报(如果有)对照地址字节。
5.2 校验总是错,因为用了CRC
现象:自己算的校验和表返回的对不上,或者表收到指令不响应。原因:645-2007是算术和,不是CRC16。解决:确认校验范围是从第一个0x68到数据域末尾,取低8位。注意累加时用& 0xFF。
5.3 串口参数不匹配,能发不能收
现象:write成功但read超时。原因:波特率、校验位、停止位和表不一致。645默认2400偶校验,但现场很多是9600无校验。解决:把串口参数做成配置,现场用调试工具逐个试。常见组合:2400/8/E/1、9600/8/N/1。
5.4 数据标识反序导致读错数据
现象:读电压返回的是电流值,或者表返回"无请求数据"。原因:数据标识字节序反了。文档写01010000,实际下发要01 01 00 00。解决:所有数据标识统一按低位在前处理,建一个常量表,不要每次手写。
5.5 粘包导致解析错位
现象:偶尔解析出乱码,或者校验失败。原因:串口一次读到多帧或半帧。解决:用extractFrame做帧同步,先找0x68再按长度截取,不要假设一次read就是一帧。缓冲区建议128字节以上,读完后循环提取。
6. 进阶:把645模块做成可配置的Spring Boot组件
前面给的是裸方法,实际项目里我一般会封装成一个MeterCodec组件,把串口参数、超时、重试都做成配置。核心思路是:组帧和解析是纯函数,不依赖串口;串口通信单独一层,方便单元测试。
@Component public class MeterCodec { @Value("${meter.baud:2400}") private int baudRate; @Value("${meter.timeout:1500}") private int timeout; public byte[] readVoltage(String meterNo) { byte[] di = new byte[]{0x01, 0x01, 0x00, 0x00}; return buildReadFrame(meterNo, di); } public double parseVoltage(byte[] frame) { Map<String, Object> map = parseResponse(frame); String raw = (String) map.get("raw"); // 电压格式XXX.X,1位小数 return Integer.parseInt(raw) / 10.0; } }这样测试时可以直接构造字节数组调parseVoltage,不用连真实串口。现场调试时只改配置,不动代码。
验证方法上,我习惯用两个手段:一是用串口调试工具手动发一帧,确认表能回;二是写一个main方法,把抓到的真实报文硬编码进去,跑解析看结果对不对。真实报文是最好的测试用例,比任何模拟数据都可靠。
最后说个血泪经验:645协议里所有多字节字段几乎都是低位在前,地址、数据标识、数据值无一例外。我刚开始做的时候按大端思维写,现场调了整整两天才发现是字节序问题。后来养成习惯,拿到任何645报文先问一句"这个字段反序了吗"。希望帮到你。
本文还有配套的精品资源,点击获取