资讯动态

DLT645-2007协议解析:Java串口读取电表数据的完整实践指南

发布时间:2026/10/20 10:11:11 来源:尧图企业网站定制
简介Java版DLT645-2007电能表协议解析源码面向需要串口采集电能表数据的Java开发人员解决远程抄表、数据帧解析与命令交互等场景下的协议实现难题尤其适合电力采集、工业物联网项目的起步阶段。代码按DLT645-2007标准组织涵盖曼彻斯特编码、地址域/控制域/数据域拆分、CRC校验、应答处理及异常恢复等关键模块可直接接入上层业务系统也可作为协议学习的样例工程。压缩包共11个文件包含3个Java源文件与对应class字节码、2个properties配置文件、1个XML配置、1个JSP页面及工程描述文件整体仅20KB结构轻量便于快速导入IDE阅读和二次开发其中properties与XML可用于配置串口参数和协议选项JSP页面可辅助调试展示。已有2461人学习下载适合具备Java基础、正在做电力采集或工业通信的开发者参考也可作为相关课程设计的实战素材。借助该源码可快速理解协议字段定义与串口收发流程减少从零调试的踩坑成本整体结构紧凑便于按需裁剪功能模块。1. DLT645-2007协议解析Java串口怎么读电表最省心做用电信息采集时最常遇到的协议就是DLT645-2007。它规定了电表怎么被“问”、怎么“答”报文不是纯ASCII而是从0xFE前导字节到0x16结束符的二进制帧结构中间还夹着BCD码和倒序地址域。用Java走串口解析时很多人第一步就卡在乱码和半包上明明把电表接上了收到的数据却断成一截一截校验和怎么算都对不上。这篇笔记把Java版DLT645-2007串口协议解析源码的完整做法讲清楚帧怎么找、控制码怎么分、数据标识怎么查、校验和怎么算以及最容易翻车的几个细节。适合正在做能源管理平台、工业采集网关或者准备自己写电表解析模块的开发者看完至少能自己搭出一个能跑通的解析器而不是只停留在“看过协议文档”的阶段。2. DLT645-2007帧结构拆开看从FE、68到16的二进制约定DLT645-2007的报文设计很有年代感但结构非常紧凑。它不像Modbus那样用寄存器地址而是用“数据标识”体系按对象编码。做解析之前我建议先把帧结构背下来因为后面所有代码、所有踩坑本质上都在跟这一帧的二进制布局较劲。2.1 一帧报文为什么长这样帧头、地址域与控制码完整的一帧数据从0x68开始到0x16结束。中间依次是6字节地址域、第二个0x68、1字节控制码、1字节长度L、L字节数据域、1字节校验和CS。注意有两个0x68第一个表示帧起始第二个表示地址域结束很多半路接手代码的人会把第二个0x68当成帧头处理导致整帧错位。地址域是这帧里最反直觉的部分。6个字节表示一个12位的十进制表号每字节是两位BCD码而且传输时低字节在前。例如表号201104120036在报文里出现的顺序是36 00 12 04 11 20。这意味着解析时不能直接从帧里按顺序转十进制必须先反转字节顺序再做BCD解码。我见过有人在这上面排错一小时后来打印原始hex才发现地址域整个是倒的。控制码是理解通信方向的关键。主站上位机发出读数据命令时控制码是0x11读通信地址是0x14电表正常应答时会把最高位置1所以读数据正常应答是0x91读通信地址应答是0x94。如果电表认为请求非法或数据域有误会回异常应答0xD1并在数据域第一个字节放一个错误信息字。判断一帧是请求还是应答、是正常还是异常只看控制码就够了。方向控制码含义主站→电表0x11读数据主站→电表0x14读通信地址主站→电表0x12读后续数据电表→主站0x91读数据正常应答电表→主站0x94读通信地址应答电表→主站0xD1异常应答我在解析时一般先switch控制码再决定走哪条解析分支而不是每个字节都去猜。这种写法后续加写命令、广播校时命令时也只需要增加分支不用改帧同步逻辑。2.2 数据域里的门道数据标识DI3到DI0的编码规则读数据命令的数据域只有4个字节叫数据标识顺序是DI3、DI2、DI1、DI0高字节在前。电表应答时数据域前4个字节会原样返回这个标识后面才是真正的数据载荷。所以解析应答帧时永远从data[0]到data[3]取DI从data[4]开始取数值。不同DI代表不同测量对象。开发中最常用的是电能量和电压这几组我每次都会在代码里维护一个查表结构避免用一长串if-elseDI3DI2DI1DI0对象数据格式00000000组合有功总电能4字节BCDXXXX.XX kWh00000001组合有功尖费率电能4字节BCDXXXX.XX kWh00000002组合有功峰费率电能4字节BCDXXXX.XX kWh00000003组合有功平费率电能4字节BCDXXXX.XX kWh00000004组合有功谷费率电能4字节BCDXXXX.XX kWh02010100A相电压2字节BCDXXX.X V02010200B相电压2字节BCDXXX.X V02010300C相电压2字节BCDXXX.X V电流类数据标识在标准框架下常见为0201开头但不同厂家的最后一个字节定义不完全一致拿到新电表时不要照搬网上表格建议先用读通信地址命令拉一下表号再对照厂家协议册确认DI。电能量和电压这组标识在绝大多数国网表上都能通用做基础采集项目时可以先只实现这两类。数据载荷全部是BCD码不是普通二进制整数。比如电能量总电能返回4字节12 34 56 78表示的数值是123456.78 kWh而不是十进制的0x12345678。换算规则很简单每字节拆成高半字节和低半字节按十进制拼接再根据协议里定义的小数位做除法。2.3 校验和CS从第二个68到数据域的简单加法DLT645-2007的校验和算法非常朴素从第一个0x68开始到数据域最后一个字节结束把所有字节累加取和的低8位。不包括结束符0x16。这里有两个容易写错的地方。第一第二个0x68、控制码、长度L都要参与累加。我见过有人只累加地址域和数据域算出来的CS跟报文对不上。第二Java的byte是有符号类型直接相加会把负数带进结果。代码里每个字节都要先做b 0xFF再累加最后再用 0xFF截断否则一个简单的加法会变成排查半天的疑难杂症。帧同步时也不要强行依赖CS来判断帧头。更稳的做法是先找0x68按地址域、第二个0x68、控制码、L的顺序切出整帧长度读够字节后再验CS。如果CS不对说明这一帧不是从真正的68开头或者中间内容被串口缓冲切坏了这时应该丢弃整帧而不是试图从中间恢复。3. 用Java串口把一帧电表数据完整读回来串口参数、监听与状态机原理清楚了接下来就是工程实现。串口通信本身没有黑匣子无非是打开端口、配参数、读写字节。但Java生态里串口库鱼龙混杂监听机制也各有差异选型和写法直接决定解析器稳不稳定。3.1 串口选型与参数为什么我选纯Java的jSerialComm早期做串口开发常用RXTX但它的原生库版本分裂严重不同操作系统要配不同的dll部署一次踩一次坑。我后来切换到纯Java实现的jSerialComm优点是无需单独装动态库Maven引入后直接用Windows和Linux行为一致这对要跑在现场工控机上的采集网关来说很重要。串口参数必须和电表侧一致。DLT645-2007标准里传输速率可选常见的是2400和9600数据位8位停止位1位校验位偶校验。很多电表出厂默认2400上位机软件却按9600去读结果就是满屏乱码。我一般先把参数打出来再连接避免“连上了但读不到数据”的情况。import com.fazecast.jSerialComm.SerialPort; SerialPort port SerialPort.getCommPort(COM3); port.setBaudRate(2400); port.setNumDataBits(8); port.setNumStopBits(1); port.setParity(SerialPort.EVEN_PARITY); port.setFlowControl(SerialPort.FLOW_CONTROL_DISABLED); port.openPort(); port.setComPortTimeouts(SerialPort.TIMEOUT_READ_BLOCKING, 1000, 1000);setBaudRate参数要和电表铭牌或参数设置一致setParity必须用EVEN_PARITY不要用默认的NO_PARITY。setComPortTimeouts的读超时我设1000毫秒避免listen线程卡死在阻塞读上。偶校验是DLT645-2007最常见的接法但极少数老表也支持无校验现场换上不响应时可以做一个“参数枚举重连”的调试入口这个功能后期排查特别好用。3.2 半包与粘包为什么状态机比sleep拼接可靠串口的监听事件只告诉你“有数据可读”不保证一次回调就是一整帧。jSerialComm的readBytes()每次返回的字节数可能是1个也可能是几十个。常见做法是收到数据后sleep几十毫秒再拼包这在波特率低、数据量小的场景下能用但遇到485总线竞争或主站轮询多块电表时时间窗口并不稳定。我更倾向用一个逐字节喂入的状态机每次串口事件把字节取出来逐个交给解析器解析器内部维护当前状态收满一帧再吐出来。这样天然处理了半包切成几段、粘包带出多余字节的问题不需要猜时间窗口。port.addDataListener(new SerialPortDataListener() { Override public int getListeningEvents() { return SerialPort.LISTENING_EVENT_DATA_AVAILABLE; } Override public void serialEvent(SerialPortEvent event) { if (event.getEventType() SerialPort.LISTENING_EVENT_DATA_AVAILABLE) { byte[] chunk port.readBytes(); if (chunk ! null chunk.length 0) { parser.feed(chunk); } } } });getListeningEvents只监听DATA_AVAILABLE避免把断线事件也混进来变成复杂分支。chunk可能只有几个字节也可能包含多帧feed方法内部需要自己处理所有情况。这里的关键是不要在回调里做解析后的业务逻辑只把字节交给解析器让业务线程从帧队列取数据。3.3 解析器状态机从FE到16的完整状态流转状态机我按“找帧头→收地址→收第二68→收控制码→收长度→收数据域→收校验和→收结束符”的顺序设计。之所以不跳过前导FE直接找68是因为串口线上空闲时可能出现FE填充字节而帧头68之前必然有一串FE状态机直接从FE开始等待更干净。import java.util.ArrayList; import java.util.List; public class Dlt645FrameParser { private static final int STATE_WAIT_HEAD 0; private static final int STATE_ADDR 1; private static final int STATE_SECOND_HEAD 2; private static final int STATE_CTRL 3; private static final int STATE_LEN 4; private static final int STATE_DATA 5; private static final int STATE_CS 6; private static final int STATE_END 7; private final byte[] addr new byte[6]; private final ListDlt645Frame frames new ArrayList(); private int state STATE_WAIT_HEAD; private int addrIdx 0; private int ctrl; private int len; private byte[] data; private int dataIdx; private int cs; public void feed(byte[] bytes) { for (byte b : bytes) { feedByte(b); } } private void feedByte(byte b) { switch (state) { case STATE_WAIT_HEAD: if ((b 0xFF) 0x68) { state STATE_ADDR; addrIdx 0; cs 0x68; } break; case STATE_ADDR: addr[addrIdx] b; cs (b 0xFF); if (addrIdx 6) state STATE_SECOND_HEAD; break; case STATE_SECOND_HEAD: if ((b 0xFF) 0x68) { state STATE_CTRL; cs (b 0xFF); } else { state STATE_WAIT_HEAD; } break; case STATE_CTRL: ctrl b 0xFF; cs (b 0xFF); state STATE_LEN; break; case STATE_LEN: len b 0xFF; cs (b 0xFF); data new byte[len]; dataIdx 0; state STATE_DATA; break; case STATE_DATA: data[dataIdx] b; cs (b 0xFF); if (dataIdx len) state STATE_CS; break; case STATE_CS: if ((cs 0xFF) (b 0xFF)) { state STATE_END; } else { state STATE_WAIT_HEAD; } break; case STATE_END: if ((b 0xFF) 0x16) { frames.add(new Dlt645Frame(addr, ctrl, data)); } state STATE_WAIT_HEAD; break; } } public ListDlt645Frame takeFrames() { ListDlt645Frame out new ArrayList(frames); frames.clear(); return out; } }注意cs在第二个0x68处也要累加这是最容易漏的一步。STATE_SECOND_HEAD里如果等到的不是0x68说明帧头识别错了回到STATE_WAIT_HEAD重新找不要硬着头皮继续收。STATE_CS校验失败同样回退到帧头状态因为可能前一个0x68只是噪声真正的帧头还在后面。data数组按len动态创建防御极端情况下读到超大长度导致内存异常可以在 len 64 时直接复位状态机现场表计的数据域一般不会超过20字节。4. 把数据域翻译成电量、电压、电流数据标识查表与BCD解码帧数据拿到后真正的业务解析才开始。数据域是一串字节要把它变成带单位的测量值需要做两件事识别数据标识按数据格式解码BCD。这两步都不难但组合起来很容易写散建议从一开始就封装成独立的解析器组件。4.1 数据标识的key化把4字节DI压缩成一个long数据标识读进来是4个单个字节如果直接写if判断代码会越来越臃肿。我一般把DI3到DI0拼成一个long作为key放进HashMap或枚举表这样新增一个数据对象只需要加一条映射主解析流程完全不用动。public static long diKey(int di3, int di2, int di1, int di0) { return ((long) (di3 0xFF) 24) | ((di2 0xFF) 16) | ((di1 0xFF) 8) | (di0 0xFF); } MapLong, String diTable new HashMap(); diTable.put(diKey(0x00, 0x00, 0x00, 0x00), 组合有功总电能); diTable.put(diKey(0x00, 0x00, 0x00, 0x01), 组合有功尖费率电能); diTable.put(diKey(0x00, 0x00, 0x00, 0x02), 组合有功峰费率电能); diTable.put(diKey(0x02, 0x01, 0x01, 0x00), A相电压); diTable.put(diKey(0x02, 0x01, 0x02, 0x00), B相电压); diTable.put(diKey(0x02, 0x01, 0x03, 0x00), C相电压);用long而不是String拼接的好处是内存占用小、比较快而且map会输出非常直观的key值。di3是最高位所以放在24的位置。注意这里传参时我已经把单个字节当成无符号整数实际从data数组取出来的byte要0xFF之后再传入否则负数会污染key的高位。4.2 BCD码转数值从4个字节到1234.56的换算规则BCD解码是整个解析源码里最体现细节的部分。每个字节的高半字节和低半字节分别表示一个十进制位例如0x12表示十进制120x34表示34连起来就是1234。实现时先校验半字节是否大于9如果出现非法BCD说明帧错位或数据域解析错误直接返回-1比较稳妥。public static long parseBcd(byte[] bytes, int offset, int length) { long value 0; for (int i 0; i length; i) { int b bytes[offset i] 0xFF; int high (b 4) 0x0F; int low b 0x0F; if (high 9 || low 9) { return -1L; } value value * 100 (high * 10 low); } return value; }调用时offset从4开始因为应答帧数据域前4字节是DI后面才是载荷。电能量数据格式是4字节BCD、两位小数所以读出来先除以100再转成double。电压是2字节、一位小数除以10。不同对象的长度和小数位都不同这部分信息建议也放进前面的diTable对应的对象定义里而不是在业务代码里写魔法数。public static MeterPoint parseEnergyFrame(Dlt645Frame frame) { byte[] data frame.getData(); long bcd parseBcd(data, 4, 4); if (bcd 0) { return null; } double kwh bcd / 100.0; return new MeterPoint(frame.getDi3(), frame.getDi2(), frame.getDi1(), frame.getDi0(), kwh, kWh); }这里的frame.getDi3等方法需要从data前4字节解析出来。我通常会再把DI封装成MeterPoint里的字段方便后续写入时序数据库或MQTT消息时直接携带。如果返回null调用方不要静默吞掉至少要打一条warn日志因为非法BCD往往意味着帧同步出了问题不是简单跳过就完事。4.3 组装读命令把地址倒序帧发出去解析之前必须先会请求。读电表数据的命令帧结构是0x68 6字节倒序BCD地址 0x68 0x11 0x04 4字节DI CS 0x16。长度L固定为4因为数据域只有DI。发送前最重要的是确认meterAddr已经是“低字节在前”的6字节数组而不是从文本表号直接转hex。public static byte[] buildReadCommand(String meterNo, long diKey) { byte[] addr meterNoToBcdReverse(meterNo); ByteArrayOutputStream out new ByteArrayOutputStream(); out.write(0x68); out.write(addr, 0, 6); out.write(0x68); out.write(0x11); out.write(0x04); out.write((int) ((diKey 24) 0xFF)); out.write((int) ((diKey 16) 0xFF)); out.write((int) ((diKey 8) 0xFF)); out.write((int) (diKey 0xFF)); byte[] body out.toByteArray(); int cs 0; for (byte b : body) { cs (b 0xFF); } out.write(cs 0xFF); out.write(0x16); return out.toByteArray(); }meterNoToBcdReverse的实现思路是把12位表号按两位一组转成6个BCD字节再反转数组顺序。这一步建议单独写单元测试用说明书上给出的示例表号验算因为地址一旦错电表根本不会应答。发送后阻塞等待应答超时建议设800到1000毫秒485总线低速下太快会丢帧太慢会拖累轮询周期。5. DLT645串口解析避坑5个让新手翻车的细节串口解析DLT645卡住你的往往不是协议本身而是那些看起来“没什么大不了”的小地方。下面这几条是我在采集网关项目里实打实踩过的按现象、原因、解决的顺序写方便对号入座。5.1 乱码排查串口参数不一致解析什么都白搭现象打开的串口能收到字节但内容要么是一堆FE要么随机出现半个帧头偶尔完整出来一帧却校验失败。原因电表侧参数和程序设置不一致。最常见的是波特率不对或者上位机用了无校验而电表是偶校验。DLT645-2007的电表默认参数不是唯一答案不同厂家出厂值可能不同。解决先把串口参数固化成“2400/8/E/1”和“9600/8/E/1”两个候选做一个启动时自动尝试的策略。每次切换参数后发一条读通信地址命令等300毫秒能收到0x94应答就说明参数正确。我最终在项目里把这个做成配置项现场运维不用改代码就能切换。5.2 半包拆包收不到完整帧先别怀疑协议现象监听器明明收到了数据解析器却一直处于等待状态或者报长度不对。原因串口数据是流式到达的一次回调可能只包含帧的一半。如果按“读到多少处理多少”的思路写半包就会卡在状态机中间。解决用第3章的状态机逐字节喂不要把“读完一个事件”当成“读完一帧”。同时给状态机加一个“超时复位”如果200毫秒内没有到达结束符0x16就清空当前状态回到帧头等待避免脏数据残留把下一帧也带坏。5.3 校验和算不对Java的byte符号位是最大骗局现象打印CS计算值和报文CS明明逻辑上一样但代码里比较总是不相等或者差256。原因Java的byte是带符号的0x9B会被当成负数-101参与求和结果自然和电表侧无符号累加不一致。解决所有参与校验和的字节必须先 0xFF转成0~255再累加。最后结果也 0xFF。这条同样适用地址域、数据域、CRC类算法凡是协议文档里说“无符号整数相加”Java代码里每一处都要手动处理符号位。5.4 地址域倒序与BCD解码表号对不上是常态现象读通信地址应答正常帧结构也对但解出来的表号和电表外壳标签差很多。原因地址域6字节是BCD编码并且低字节在前。比如标签是201204110036报文按序收到的可能是36、00、11、04、12、20顺序完全反过来。解决解析时先把6字节数组反转再按每位BCD码拼成12位十进制。我在代码里专门写了一个bcdReverseToMeterNo(byte[])方法并用三组不同表号验证过之后再没被这个问题绊住。5.5 电表不应答从地址、接线到重试的完整检查现象命令发出后串口收不到任何字节像在跟一堵墙说话。原因可能是地址域倒序没处理、485的A/B线接反、总线上有多个主站抢权或者发送命令后没有等收发方向切换完成。解决先确认地址域没有反转错误排查接线A对A、B对B别交叉。程序侧做重试机制同一帧最多重发3次每次间隔200毫秒重试间隔可以稍微错开避免多台设备同时重试时在485总线上碰撞。另外很多USB转485模块发送完数据立刻切回接收这中间需要十几毫秒稳定时间代码里send和recv之间至少要留20毫秒别在同一个循环里连发。6. 进阶从单帧解析到按需采集的完整采集链路解析器能跑通单帧只是开始放到真实项目里还要解决“怎么采集多块表、怎么把结果交给业务层、怎么确认解析没出错”这三个问题。6.1 用BlockingQueue把解析结果交给业务层我一般会单独起一个采集线程按配置的表地址列表轮询每个表轮流读取总电能和三相电压。解析器拿到的MeterPoint不直接写数据库而是放进BlockingQueue由另一个消费线程统一入库或上报。这样采集频率和消费速度解耦电表响应慢时不会阻塞业务接口。public class MeterCollector implements Runnable { private final BlockingQueueMeterPoint queue new LinkedBlockingQueue(1000); private final SerialPort port; private volatile boolean running true; public void run() { while (running) { for (String meterNo : meterList) { boolean ok readMeter(meterNo); if (!ok) { // 连续失败3次再告警避免瞬时噪声误报 } sleep(300); } sleep(30000); } } }轮询周期我习惯设30秒一组电能量采集对实时性要求不高没必要5秒扫一次。失败时只在连续3次后才上报单次暂时性失败先静默重试这个策略在485总线上能有效减少误告警。6.2 最小验证方法抓包对比和虚拟串口对上线前先在开发机上验证解析器我建议买一对USB转485用虚拟串口对工具把两个串口桥接起来一台做模拟主站一台跑解析程序互相收发构造帧。这样不用真电表也能验证状态机、校验和和BCD解码。再把发出的命令和收到的原始hex打印出来和协议册逐字节核对比对着波形猜省力得多。真正跑起工程后我的习惯是保留一个“原始hex日志”开关平时关闭排查时打开。尤其遇到表号对不上、电量突然多一位小数这类问题原始报文比任何调试器都直白。DLT645-2007不复杂但如果跳过帧结构直接写业务代码迟早会在某个串口上栽跟头。这篇笔记里讲的坑都是别人和我自己真金白银换来的经验希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑