简介:面向需要与电能表开展串口通信开发的Java工程师,这份源码实现了DLT645-2007电力行业标准协议的数据帧解析、命令编解码、CRC校验与应答处理,适用于远程抄表、用电信息采集与设备监控等典型场景,可帮助开发者快速落地串口通信模块并缩短联调周期。压缩包共11个文件,包含3个Java源文件、3个class编译文件、2个properties配置文件、1个xml工程配置、1个iml模块文件与1个jsp页面,包大小约20KB,整体结构精简,导入工程后即可对照源码学习。目前已有2461人学习下载。通过阅读和调试这套源码,可以重点掌握协议中地址域、控制域、数据域的拆分技巧,大端与小端字节序的转换方法,以及RXTX、JSerialComm等串口库的选型与异常处理思路;同时源码中完整的命令对应关系和应答帧处理逻辑,也为移植到其他采集系统提供了便利,适合电力计量领域的Java开发者以及初次接触DLT645协议的读者作为参考。
1. Java 解析 DLT645-2007:先搞清这个协议值不值得折腾
第一次接触 DLT645-2007 的时候,我手里只有一个 USB 转 485 的调试线、一块多功能电能表和一沓打印出来的协议文档,花了一个多星期才把第一帧完整报文读出来。Java 版 DLT645-2007 电能表协议解析源码解决的核心问题,是把串口上那串十六进制字节流换算成可用的电能量、电压、电流和功率数据,适用于集中抄表、能耗监测平台、边缘采集网关这类场景。协议本身不复杂,真正耗时间的永远是串口接线、地址域字节序和超时重试这些细碎问题,代码只占三成,调试占七成。
2. DLT645-2007 帧结构:从字节流里抠出电表地址和电能数据
2.1 帧头和地址域:0x68 之后,地址是反着读的
DLT645-2007 的帧结构很固定,每帧从起始符 68H 开始,到结束符 16H 结束,中间依次是地址域、控制码、数据域长度、数据域和校验和。把帧结构列成一张表,后面所有解析代码都围绕这张表展开:
| 帧成分 | 字节数 | 说明 |
|---|---|---|
| 起始符 | 1 | 固定 0x68 |
| 地址域 | 6 | 表号(BCD 码),低字节在前 |
| 控制码 | 1 | 区分读、写、广播校时等操作 |
| 数据域长度 L | 1 | 数据域总字节数 |
| 数据域 | L | 数据标识 DI 加实际数据 |
| 校验和 CS | 1 | 从起始符到校验和前一字节累加,取低 8 位 |
| 结束符 | 1 | 固定 0x16 |
起始符 68H 在报文中会出现两次,第一次是帧起始,第二次是地址域结束后的再确认,很多解析代码直接把第二个 68 当作地址域的终止标志。地址域是最大的坑,电表铭牌上的表号是一串十进制数字,但协议要求按 BCD 码编码,并且低字节在前发送。比如表号 123456789012,先按 BCD 拆成 12 34 56 78 90 12 六个字节,再倒序放到帧里,所以报文里的地址域实际是 12 90 78 56 34 12。解析时要把这 6 字节倒过来拼字符串,才能得到人眼认识的表号。
如果表号不足 12 位,协议规定地址域高位补 AA,这一点很容易被忽略。我见过有人把补位字节当作用户数据解析,最后表号字符串里多出 4 个 A,和数据库里的台账永远匹配不上。地址域长度不足 6 字节时,按协议补 AA 而不是补 00,记住这个前提,后面排查地址对不上会省很多时间。
从报文里提取表号,代码很直接:
// 从完整报文帧中提取电表地址 public static String parseAddress(byte[] frame) { StringBuilder sb = new StringBuilder(); for (int i = 6; i >= 1; i--) { // 地址域在帧中的索引是 1-6,传输顺序低字节在前 sb.append(String.format("%02X", frame[i])); } String address = sb.toString(); // 表号不足12位时,协议规定高位补AA,解析时去掉 while (address.startsWith("AA")) { address = address.substring(2); } return address; }逻辑说明:地址域从索引 1 开始占 6 字节,倒序遍历即完成高低字节反转,String.format("%02X")把每个字节转成两位大写十六进制。因为补位只出现在正序字符串的开头,用while循环持续去掉开头的 AA,直到遇到真正的表号数字。这里注意只能判断 AA,不能去掉以数字开头的正常表号。
2.2 控制码和数据标识:C0 读数据、D0 写数据,DI 才是真正要的东西
控制码决定这一帧要干什么,主站请求时常见控制码值如下表:
| 控制码 | 功能 | 使用场景 |
|---|---|---|
| 0xC0 | 读数据 | 读取电能量、电压、电流等 |
| 0xC1 | 读后续数据 | 数据长度超过单帧上限时续读 |
| 0xD0 | 写数据 | 下发费率、表计参数 |
| 0xD6 | 广播校时 | 对全表计统一校时 |
表端正常应答时,控制码会按协议组合出应答值,常见是 0x81 开头,具体映射以协议附录为准。解析时不要写死「控制码等于 C0 才是读数据」,因为同一帧从不同厂商表计回来可能带应答位,判断读写方向要看最高位,判断功能要看低 5 位,这样才能兼容不同厂家的实现差异。
数据标识 DI 是更关键的东西,它决定数据域里装的是什么。DI 由 4 个字节组成,DI0 到 DI3,传输时同样低字节在前。常用数据标识的映射关系如下:
| 数据项 | DI0 | DI1 | DI2 | DI3 | 报文字节序 |
|---|---|---|---|---|---|
| 组合有功总电能 | 00 | 00 | 00 | 00 | 00 00 00 00 |
| 正向有功总电能 | 01 | 00 | 00 | 00 | 00 00 00 01 |
| 反向有功总电能 | 02 | 00 | 00 | 00 | 00 00 00 02 |
| A 相电压 | 00 | 01 | 01 | 02 | 02 01 01 00 |
| A 相电流 | 00 | 02 | 02 | 02 | 02 02 02 00 |
比如要读 A 相电压,DI 逻辑值是 02010100,组帧时按低字节在前,实际发送顺序是 00 01 01 02,数据长度 L 就是 4。看不懂 DI 之前,报文就是一堆十六进制数字,看懂 DI 之后,每一帧都能对应到具体业务数据,这一步绕不过去。
控制码和数据标识的解析放一起写,因为在报文中它们紧挨着,一个字节是操作类型,紧接着的 4 字节是操作对象:
public static FrameInfo parseFrameHeader(byte[] frame) { FrameInfo info = new FrameInfo(); // 帧索引: 0=起始符, 1-6=地址域, 7=第二个68, 8=控制码, 9=长度 info.controlCode = frame[8]; int length = frame[9] & 0xFF; // 数据域从索引10开始,前4字节是DI info.di0 = frame[10] & 0xFF; info.di1 = frame[11] & 0xFF; info.di2 = frame[12] & 0xFF; info.di3 = frame[13] & 0xFF; info.hasMore = (info.controlCode & 0x20) != 0; // D5位=1表示有后续帧 return info; }逻辑说明:从固定偏移量取出控制码和数据长度,再取 4 字节 DI。length & 0xFF是必须的,因为数据长度最大 255,Java 的 byte 会把它当负数。hasMore用到控制码的 D5 位,这一位在长数据拆分场景里非常关键,后面讲多帧拼接时会再次用到。
2.3 校验和 CS:从第 2 字节加到倒数第 2 字节,取低 8 位
校验和算法在协议里写得很明确,但实现时翻车率极高。规则是从帧的第一个 68H 开始,到校验和前一字节为止,所有字节做累加,结果只取低 8 位。覆盖范围包括起始符、地址域、第二个 68、控制码、数据长度、数据域,总共 9 加 L 个字节,不包含结束符 16H。
很多初写解析的人在校验和这里踩坑,核心原因是 Java 的 byte 是有符号类型。一个字节 0x80 在 Java 里转成 int 是 -128,直接累加就会把整个校验和带偏,所以每个字节参与累加前都要先做& 0xFF转成无符号整数。
// 计算校验和,frameBody 是从起始符68到数据域结束的完整区间 public static byte calcChecksum(byte[] frameBody) { int sum = 0; for (byte b : frameBody) { sum += (b & 0xFF); // 消除Java有符号字节的影响 } return (byte) (sum & 0xFF); }逻辑说明:sum是 int 类型,累加过程中可能超过 255,但只要在最后取& 0xFF,高位溢出自然丢弃,结果就是协议规定的低 8 位。返回时强转回 byte,后续和帧尾的校验和字节直接比较即可。
实际校验一帧报文时,我习惯把「算出的 CS」和「帧里的 CS」都打印出来做对比,而不是只给一个 true/false 结果。差距为 0 说明帧结构正确,差距不为 0 时打印值能直接告诉你偏了多少,排查效率高很多。这个习惯在做串口调试时极其有用,因为很多电表固件对校验和的处理有细微差别,光看布尔结果根本不知道是算错还是收错。另外要注意计算起点是帧头 68H,前面如果有 FE 前导字节,不要一起加进去。前导字节是通信时序用的,不属于帧内容,加了之后校验和必错。
3. 串口层实战:用 jSerialComm 把报文发出去并收回来
3.1 串口参数:波特率 2400、数据位 8、校验位偶校验、停止位 1
DLT645-2007 的物理层参数是协议写死的,默认 2400bps,8 数据位,偶校验 EVEN,1 停止位。这几个参数在串口通信里属于「必须一致才能通信」的硬条件,两端任何一项不一致,收到的就是乱码或者干脆没响应。有些老电表可以手动改成 9600,但现场如果没有特殊要求,保持协议默认值最稳妥。
Java 侧操作串口,我一般用 jSerialComm,比 RXTX 维护活跃、API 也更简洁,Windows 和 Linux 下都能跑。打开一个串口的完整配置如下:
import com.fazecast.jSerialComm.SerialPort; SerialPort port = SerialPort.getCommPorts()[0]; // 实际项目要按名称匹配 port.setBaudRate(2400); port.setNumDataBits(8); port.setNumStopBits(SerialPort.ONE_STOP_BIT); port.setParity(SerialPort.EVEN_PARITY); port.setReadTimeout(1000); if (!port.openPort()) { throw new RuntimeException("串口打开失败,检查权限和占用"); }参数说明:波特率 2400 是协议默认,不建议轻易改动;数据位 8、偶校验、停止位 1 三项必须和电表侧一致。openPort()返回 false 时,原因基本只有三个:端口被其他进程占用、Linux 下当前用户没有 dialout 组权限、设备不存在。Linux 下排查权限问题最快的方式是执行ls -l /dev/ttyUSB0看所属组,然后用sudo usermod -aG dialout 当前用户名把自己加进 dialout 组再重新登录。
选择串口不建议写死端口名。Windows 下 COM3 可能因为插拔换了编号,Linux 下/dev/ttyUSB0也可能变成/dev/ttyUSB1。我一般的做法是遍历SerialPort.getCommPorts(),把每个端口的名字打印出来,启动时用配置文件指定,避免每次换环境都改代码重新编译。
3.2 发送读数据命令:组帧、发送、等响应的完整代码
组帧是协议解析的第一步。完整读数据请求帧包含前导字节、起始符、地址域、第二个 68、控制码、长度、数据标识、校验和、结束符。前导字节 FE 建议发 4 个,有些表 2 个就能响应,4 个是兼容性最好的选择,但不要发太多,发多了有的表反而会判定为帧错误。
// 组一帧读数据请求,addressHex是表号字符串,di是4字节数据标识 public byte[] buildReadFrame(String addressHex, int[] di) { ByteBuffer buf = ByteBuffer.allocate(32); for (int i = 0; i < 4; i++) { buf.put((byte) 0xFE); // 前导字节 } buf.put((byte) 0x68); // 起始符 byte[] addr = new byte[6]; int addrLen = addressHex.length() / 2; for (int i = 0; i < 6; i++) { if (i < addrLen) { addr[i] = (byte) Integer.parseInt( addressHex.substring(i * 2, i * 2 + 2), 16); } else { addr[i] = (byte) 0xAA; // 不足12位补AA } } buf.put(addr); // 地址域6字节 buf.put((byte) 0x68); // 第二个起始符 buf.put((byte) 0xC0); // 读数据请求 buf.put((byte) 0x04); // L = 4,DI占4字节 for (int i = 3; i >= 0; i--) { buf.put((byte) di[i]); // DI低字节在前 } byte[] body = Arrays.copyOfRange(buf.array(), 4, buf.position()); byte cs = calcChecksum(body); // 校验和从68开始算 buf.put(cs); buf.put((byte) 0x16); byte[] frame = new byte[buf.position()]; System.arraycopy(buf.array(), 0, frame, 0, frame.length); return frame; }逻辑说明:表号字符串按两位一组解析成十六进制字节,不足 6 字节时高位补 AA。DI 数组传入的是逻辑顺序,发送时for (int i = 3; i >= 0; i--)完成低字节在前反转。校验和计算起点是整个帧的第 4 个字节,也就是起始符 68H,不包含前面的 FE 前导字节,所以这里把Arrays.copyOfRange(buf.array(), 4, buf.position())作为校验和入参,而不是从头开始。
发送和接收要配合好。发送用port.writeBytes(frame, frame.length),写入后立刻进入读响应阶段。这里有个经验:不要发送后马上 sleep 再读,而是直接进入带超时的读循环,让内核缓冲区自然积累数据。很多电表的响应时间在 20 到 200 毫秒之间,如果固定 sleep 200ms,一部分表响应慢就会漏掉。
3.3 拆帧和重发机制:超时重试的边界条件
串口读响应最大的坑是数据会分片到达。一次readBytes可能只读到半个帧,也可能读到一个半帧,绝不能假设一次 read 就是完整报文。收到数据后要先把所有字节拼进一个缓冲区,然后反复找结束符 0x16 判断帧是否完整。
public byte[] readFullFrame(SerialPort port, long timeoutMs) throws IOException { ByteArrayOutputStream buffer = new ByteArrayOutputStream(); long deadline = System.currentTimeMillis() + timeoutMs; while (System.currentTimeMillis() < deadline) { byte[] chunk = new byte[128]; int n = port.readBytes(chunk, chunk.length); if (n > 0) { buffer.write(chunk, 0, n); // 从缓冲区里找帧头68和帧尾16,帧尾存在即认为完整 byte[] all = buffer.toByteArray(); int start = findFrameStart(all); // 找第一个0x68 int end = findLastFrameEnd(all); // 找最后一个0x16 if (start >= 0 && end > start) { return Arrays.copyOfRange(all, start, end + 1); } } Thread.sleep(20); } return null; // 超时返回null,上层决定是否重发 }逻辑说明:外层用System.currentTimeMillis()做整体截止时间,内层每次最多等 20 毫秒再循环,既不会忙等导致 CPU 空转,也不会因为单次 read 超时丢数据。findFrameStart从第一个 0x68 开始找帧头,不固定读第 0 字节,因为串口缓冲区里可能有上一次的残留数据。
超时重发策略我从实践中总结出来的经验是:重试 3 次,第 1 次间隔 200ms,之后每次递增 300ms。每次重发前把接收缓冲区的残留字节读干净,不然上一次的帧尾会干扰本次的帧头识别。重发的触发条件只有两个:超时未收到完整帧、校验和错误。如果是表端返回了异常应答帧,说明通信链路是通的,问题是数据标识或命令本身,这时候重发没有意义,应该直接记录错误日志。
完整的一条读数据流程应该是:组帧、发送、读完整帧、校验和检查、解析数据域、超时则重发。把这几步封装成一个readMeterData(address, di)方法,上层调用时只需要关心表号和 DI,不用关心串口细节,后续做批量采集会省很多事。
4. 完整解析:从裸报文到业务数据的一条龙处理
4.1 数据域解析:电能量、电压电流、功率数据的标识映射
收到响应帧且校验和通过之后,真正有价值的是数据域。DLT645-2007 的数据域结构是:DI 4 字节加上实际数据,再加 1 字节数据格式标识。数据格式标识高 4 位表示小数位数,低 4 位表示倍率和单位,这一字节决定了 BCD 码数据要移动几位小数点。
以电能量数据为例,响应帧的数据域类似00 00 00 00 12 34 56 03,其中前面 4 字节是 DI,中间12 34 56是 BCD 码表示的电能量原始值 123456,最后一个字节03的高 4 位是 0,表示没有小数位?不对,高 4 位是 0 的话,说明这个表计把格式字节定义成低 4 位表示小数位,不同厂商实现有差异。为了兼容各种表计,我在解析时会把格式字节完整打印出来,先观察几帧数据再定标。
电价数据的解析我一般这样做:
// 解析BCD码数据,并处理小数位 public static BigDecimal parseBcdValue(byte[] data, int valueStart, int valueLen) { StringBuilder sb = new StringBuilder(); for (int i = valueStart; i < valueStart + valueLen; i++) { sb.append(String.format("%02X", data[i])); } // 格式字节紧跟在数据后面,高4位是小数位数 int scale = (data[valueStart + valueLen] >> 4) & 0x0F; return new BigDecimal(sb.toString()).movePointLeft(scale); }逻辑说明:先把 BCD 字节按十六进制拼成字符串,比如12 34 56拼成 "123456",再根据格式字节高 4 位移动小数点。假设格式字节是 0x03,高 4 位是 0,低 4 位是 3,表示无小数位,最终值是 123456;如果格式字节是 0x33,高 4 位是 3,那结果就是 123.456,单位 kWh。实际项目中,电压电流这类瞬时量有的表返回 2 字节 BCD,有的返回 3 字节,解析前先看数据长度 L 减去 4 再减 1,剩下才是真正的数据字节数。
电压电流这类数据不需要格式字节处理?同样需要,只是单位不同。A 相电压数据标识 02010100 对应的数据域可能是02 01 01 00 22 05 01,其中22 05是 BCD 值 2205,格式字节 0x01 最低位表示 1 位小数,解析结果就是 220.5V。不同协议版本对格式字节的编码略有差别,但「BCD 拼串移小数点」这个思路是通用的。
4.2 异常应答:表端返回的报错帧要单独处理
电表收到不认识的 DI、写操作被拒绝、表内数据异常时,返回的应答帧不是正常数据帧。这类帧的数据域第一个字节通常以 0xEE 开头,后面跟错误信息字节。如果不加判断直接走正常解析流程,结果就是解析出乱码数据,甚至把错误码当成电能量入库。
处理异常应答的代码逻辑很简单,但很多人都忘了写:
public static boolean isErrorFrame(byte[] frame) { // 响应帧数据域从索引10开始,检查第一字节是否是0xEE if (frame.length < 11) return true; return (frame[10] & 0xFF) == 0xEE; }收到的帧先过这个判断,是错误帧就直接记录表号、DI 和错误码,不进入业务解析。错误码本身也是信息,比如 0xEE 加后续字节表示不同错误原因,记录到日志里,查现场问题时会非常有用。这个判断放在校验和之后、数据域解析之前,位置别放错。
4.3 数据长度超过单帧上限时:多帧数据和后续读命令
单帧数据域长度 L 只有 1 字节,最大值 255,但实际场景里一次读几十个数据项很常见。协议的处理方式是拆帧:首帧数据域塞满后,控制码的 D5 位置 1 表示还有后续帧,主站收到后发读后续数据命令 C1,表端再返回下一段数据。
拼接逻辑的伪代码如下:
List<byte[]> frames = new ArrayList<>(); byte[] first = readMeterData(address, di); frames.add(first); while ((first[8] & 0x20) != 0) { // D5=1 说明还有后续 byte[] next = sendContinueRead(address, di); // 发C1读后续 if (next == null) break; // 超时就停 frames.add(next); first = next; // 看这帧是否还有后续标志 } // 数据域拼接:每帧去掉帧头帧尾,只拼数据域部分 ByteArrayOutputStream dataStream = new ByteArrayOutputStream(); for (byte[] f : frames) { int len = f[9] & 0xFF; dataStream.write(f, 10, len); // 从数据域开始追加 } byte[] fullData = dataStream.toByteArray();拼接时注意每帧的数据域都要去掉 DI?不对,协议规定后续帧的数据域要么带相同 DI,要么只带长度不带 DI,不同厂商实现不一样。我见过一种表,后续帧数据域从第 10 字节开始直接是数据,不带 DI,这时候每帧长度 L 比首帧少 4。稳妥的做法是:收到后续帧先看 L 和首帧 L 的差值,差 4 说明该帧没带 DI,拼接时跳过前 4 字节。
多帧拼接最容易出错的地方是字节偏移。每帧的最小长度是 12 字节,但带不同长度数据时总帧长不一样,拼接时不要把帧头地址域这些也算进数据区。建议拼完后打印一下fullData.length,和首帧 L、后续帧 L 加总的结果对一下,对不上说明偏移算错了。
5. 避坑指南:串口通信和协议实现里的五个典型翻车现场
5.1 现象:发了 FE FE FE FE 68 地址 68 C0 04 DI 之后,表完全没响应
这大概是我见过最多的现场问题。排查步骤按优先级排:先确认 RS-485 的 A、B 两根线没有接反,这是电气层最常见的低级错误,用万用表量空闲态电压,正常应该在 1.5V 到 5V 之间,接反时量出来是负值或者接近 0。然后确认波特率和校验位是不是偶校验,很多调试工具默认是 NONE 校验,而协议要求 EVEN。最后确认地址域补位字节是不是 AA,07 版协议对不足 12 位表号的规定是补 AA,不是补 00,这地方的错误会让表直接丢弃整帧。解决方式是用串口调试助手手动发一帧最简单指令,排除代码因素后再把同样的字节流放到 Java 程序里发,逐步收窄问题范围。
5.2 现象:读回来的帧总是校验和错误,而且每次错的位数还不一样
大概率是 Java 有符号数处理问题。直接sum += b会把 0x80 以上的字节当成负数累加,比如校验和实际是 0x50,代码算出来却是 0xFFFFFF50 或别的值。这是协议解析里最容易自我怀疑的问题,因为很多人会觉得自己算法写错了,反复排查帧结构,其实只是漏了& 0xFF。解决方式简单粗暴:累加前统一b & 0xFF,算完再& 0xFF截断低 8 位。建议把这个逻辑抽成工具方法,不要在每个组帧的地方重复写,漏一处就是一个 bug。
5.3 现象:地址域从报文里解析出来总是反的,对不上表号
地址域低字节在前,这个「反」是协议规定的,不是设备坏了。表号 123456789012,报文里地址域是 12 90 78 56 34 12,解析时倒序遍历才是正序。还有个隐藏坑:有的表号不足 12 位,地址域里高位补了 AA,解析后字符串开头会出现 AA,不处理就拼接进数据库,台账匹配永远失败。解决方式是在地址解析方法里加一个循环,把开头连续的 AA 全部吞掉。我见过最夸张的案例是某现场表号只有 8 位,地址域是 12 34 56 78 AA AA,倒序后变成 AA AA 78 56 34 12,处理掉 AA 后表号才是 78563412,这个顺序搞错会让你误以为换了一块表。
5.4 现象:帧结构看起来完整,但按长度字段截取数据时总多或者总少
数据域长度 L 的语义是「从 DI 开始到格式字节结束」的总长度,包含 DI 的 4 字节、数据本体和格式字节。很多人把 L 理解成「数据本体的长度」,于是截取时少截 5 字节,解析结果自然不对。另外组帧时 L 填错也会引发联动错误:L 错了,校验和覆盖范围就错了,帧尾 CS 对不上,表端直接不响应。解决方式是建立一个帧长度自检公式:总帧长等于 1 起始符加 6 地址域加 1 再确认符加 1 控制码加 1 长度加 L 加 1 校验和加 1 结束符,即 12 加 L 字节。收帧后先用这个公式验总长度,对不上就直接丢弃,不要硬解析。
5.5 现象:程序运行一段时间后偶发无响应,线程栈卡在串口读上
这是串口编程最常见的稳定性问题。jSerialComm 的 readTimeout 如果设置时机不对,或者设备被系统挂起,readBytes 会一直阻塞不返回,外层超时逻辑完全失效。我自己的处理方式是读操作不进阻塞模型,改为固定截止时间加轮询读取,配合一个看门狗线程,超过 5 秒没有任何数据就强制关闭串口重新打开。重开时注意先 closePort 再创建新对象,不要复用旧实例,旧对象的内部状态已经乱了,复用只会越搞越糟。这个兜底逻辑放在生产环境的采集程序里尤其重要,没有它,一个串口异常能让整个采集服务挂掉。
这些坑有一个共性:都在协议文档之外,靠读文档读不出来,只能在实际调试中逐个踩过才知道。把这五个现象贴到代码注释里作为排查清单,后来维护这个项目的人会感激你。这个清单现在也是我每次换新表型做适配时必查的前五项问题。
6. 进阶:广播校时和长数据读取,能顺手做的就别省
6.1 广播校时:没有业务数据的报文
广播校时是 DLT645-2007 里最特殊的报文,地址域固定为 6 个 AA,控制码 D6,数据域是 6 字节 BCD 码的日期时间,依次是年、月、日、时、分、秒,比如 20240815103045 编码成24 08 15 10 30 45。这个报文发出去后表端不回响应,主站也不需要等,直接每隔一段时间发一次即可。校时这个功能很容易被忽略,但表内时钟偏差超过一定范围后,冻结电量数据的时标会乱,后续做分时计费统计时对不上账。建议每天零点后对所有表做一次广播校时,成本极低,收益是数据时标干净。
6.2 长数据读取:C1 后续帧的边界条件
前面提到数据超过 255 字节时会拆帧,这里补充一个边界:C1 读后续命令的数据域可以只带 DI,也可以带 DI 加序号,不同厂商对序号字段的定义不同。我接触过的表里,有的表对 C1 命令必须在数据域带上首帧的 DI 才会响应,有的表不带也能响应。遇到不响应的情况,先用串口工具手动发一帧 C1 到表端,观察返回的是正常数据帧还是错误帧,再微调数据域内容,这个过程只能靠试,没有文档能覆盖所有厂商的差异。
6.3 把解析器做成无状态服务的习惯
解析代码和串口通信代码一定要拆开。通信层负责收发字节流,解析层只接收一个完整的 byte[] 帧,输出一个MeterData对象,不持有任何串口状态。这样做的最大好处是并发采集多台表时不会串数据,多线程环境下每个线程各持一个 SerialPort 实例,但解析器只有一个静态入口,帧数据不会互相污染。另外无状态的解析器很容易做单元测试,预先构造几帧正确的报文和几帧错误的报文,跑一遍用例就知道解析逻辑对不对,不用每次改代码都接真实硬件验证。从那以后我每次改协议解析代码,都强制先跑一遍「预置报文比对期望结果」的用例,通过后再碰真实串口,这个习惯帮我挡掉了至少一半的回归问题。希望这套思路对你的 DLT645 接入工作也有帮助,项目源码里把串口封装和协议解析拆成了两个独立模块,下载下来对着实际报文跑一遍,比看任何文档都直观。
本文还有配套的精品资源,点击获取