
简介面向电力自动化与工业通信领域的Java开发者这份资源基于Spring Boot实现了一套电网104规约即IEC 60870-5-104的主站连接、数据监听与解析逻辑可解决从调度端或子站获取104报文并转为业务可读数据的常见需求。压缩包合计5个文件以Java源码为主附带说明文档和依赖jar包整体约105KB。源码中覆盖了Client主站连接、事件监听回调以及十进制数据转换等核心模块其中重写的toString方法已对监听数据做了字段整理便于直接提取关键信息一同上传的jar包与readme中的pom依赖可降低环境搭建成本。监听获取的数据还提供了POST请求转发客户端的示例读者可自行注释或调整灵活适配不同业务链路适合需要快速集成104协议监听能力的中高级Java工程师。目前已有1413人学习下载对电力数据接入类项目有不错的参考价值。1. 电网104规约监听真正的拦路虎不是解析而是没人理你接到一个配电自动化项目对方说“RTU 已经在跑 104 了你只要把数据监听下来就行”。等真到了现场才发现抓包工具挂着等了半小时屏幕上一条 104 报文都没有。原因很扎心电网 104 规约IEC 60870-5-104是问答制从站RTU只回答从不主动开口。所谓 JAVA104 协议监听实质上是两条路要么在旁路把主站和 RTU 的完整问答都收下来要么自己伪装成主站把链路启动、总召发一遍让 RTU 老老实实把数据“交出来”。本文就围绕这两种模式把 104 报文骨架、Java 侧监听实现、以及最容易翻车的几个边界问题一次性讲透。适合正在写电力监控采集、变电站数据接入或者被“拿不到 104 数据”卡住的 Java 开发。2. 104 规约报文解剖APCI 三类帧和 ASDU 信息结构2.1 报文怎么切的启动符 0x68 和长度字段是唯一依据104 规约跑在 TCP 之上端口固定 2404。每一个应用层报文叫 APDU结构非常简单一个字节的启动符 0x68一个字节的长度后面跟着控制域和可选的 ASDU。启动符和长度是切帧的唯一依据TCP 是流协议应用层必须靠这两个字节把一条条报文从字节流里“切”出来。长度字段表示从控制域开始到报文结束一共多少字节最小是 4只有控制域最大 253。所以一个 APDU 总长最多 255 字节数据再多也要拆成多个 APDU 发送这一条在监听时直接决定了读缓冲区的处理方式。一条典型的带 ASDU 的报文长这样以总召命令为例68 0E 00 00 00 00 64 01 06 00 01 00 00 00 00 14逐字节拆开看就是下面这张表。注意 0x0E 是十六进制的 14意思是“后面还有 14 个字节”这 14 个字节里前 4 个是控制域后 10 个是 ASDU。字节偏移取值含义00x68启动符固定不变10x0EAPDU 长度控制域 ASDU20x00控制域第一字节I 帧发送序号低位30x00控制域第二字节I 帧发送序号高位40x00控制域第三字节I 帧接收序号低位50x00控制域第四字节I 帧接收序号高位60x64TypeID 100总召唤命令 C_IC_NA70x01VSQ1 个信息对象80x06传送原因 6激活90x00传送原因高字节100x01公共地址低字节110x00公共地址高字节120x00信息体地址第一字节130x00信息体地址第二字节140x00信息体地址第三字节150x14召唤限定词 QOI 20站级总召长度字段之后的内容控制域占前 4 个字节剩下的才是 ASDU。监听程序读帧时只需要三步先读一个字节判断是不是 0x68再读长度最后把长度字段指定的字节数全部读完一条完整的 APDU 就到手了。粘包和半包问题出在这一步处理不严谨后面第 4 章专门讲。2.2 控制域三种身份I 帧、S 帧、U 帧各管什么控制域的第一个字节决定了这个报文是三种帧里的哪一种。这三种帧是 104 链路层的全部家当监听器必须能区分它们因为回包策略完全不同。帧类型判定方式长度作用I 帧bit7 0bit0 04 ASDU传数据或命令带发送/接收序号S 帧bit1 1bit0 0固定 4 字节只确认已收到的 I 帧U 帧bit1 1bit0 1固定 4 字节链路启动、停止、测试I 帧的发送序号占前两个字节的低 12 位接收序号占后两个字节。S 帧只有一个接收序号。U 帧没有序号靠高 6 位的功能码区分是 STARTDT、STOPDT 还是 TESTFR以及是激活act还是确认con。六种 U 帧控制字在实际开发中必须背下来因为监听器要回对包链路才能维持。功能激活act确认conSTARTDT启动链路0x070x0BSTOPDT停止链路0x130x23TESTFR测试链路0x430x83TCP 连接建立后主站必须先发 STARTDT act从站回 STARTDT con链路才算激活。之后主站发总召I 帧从站按顺序回总召确认、数据、总召终止。这中间 I 帧序号的管理是监听器正确性的核心收到一个 I 帧要回一个 S 帧确认确认序号是“对方已发来的最后一个 I 帧序号 1”。很多新手监听器把 S 帧当可有可无结果从站在发送窗口耗尽后直接断开后面第 4 章会写为什么会这样。2.3 ASDU 信息元素遥信、遥测、遥控在报文里的长相ASDU 是应用层数据单元前面 TypeID 告诉监听器“这条数据是什么类型”后面的信息体才是真正的数据。TypeID 是 ASDU 第一个字节也是最关键的分发依据。常用的几个类型如下。TypeID名称信息元素结构场景1M_SP_NA 单点遥信1 字节开关量 品质位断路器位置、刀闸状态11M_ME_NB 规约化值遥测2 字节整数 1 字节品质位电压、电流、功率13M_ME_NC 短浮点遥测4 字节 float 1 字节品质位最常用的遥测类型直接上送浮点值45C_SC_NA 单点遥控1 字节命令主站下发分合闸命令100C_IC_NA 总召唤信息体地址 召唤限定词主站发起全量数据召唤以 TypeID 13 的遥测为例信息体结构是“3 字节信息体地址 4 字节 IEEE 754 浮点 1 字节品质位”。其中浮点是低字节在前也就是小端序Java 里默认是大端拼 int 的时候必须手动把第一个字节放在最低位否则解析出来的电压值是天文数字。品质位的低字节 bit0 是溢出标志bit4 是无效标志实际项目里最常判断的就是这个无效位无效数据不应该入库存。后面第 3 章的解析代码会直接演示正确的拼装姿势。ASDU 里的公共地址是站地址一个主站下面挂多个 RTU 时靠它区分数据来自哪个站。信息体地址对应设备点表里的具体测点常见的是 3 字节从 1 开始编号。监听程序不负责猜测点表但要能把地址原样解析出来方便后续和设备厂家提供的点表对账。3. Java 实现 104 监听连接、启动链路、总召与数据解析3.1 两种监听部署怎么选旁路镜像 vs 主动采集动手写代码之前要先决定部署方式这决定了程序是“Server 端”还是“Client 端”。旁路镜像是把交换机镜像口的流量全部收下来监听器只读不发对现有系统零侵入。但代价是如果正式主站没有定期发总召从站不会主动上送数据旁路监听器只能看到零星的变化上送和心跳拿不到全量数据。另一个隐含风险是镜像口流量可能很大程序要能扛住突发。主动采集则是让 Java 程序自己扮演主站主动连接 RTU 的 2404 端口发 STARTDT、发总召、持续收数据。这种方式拿到的数据最完整调试也最直观但要注意部分 RTU 只允许一个主站连接如果正式调度系统已经占着链路程序会连接失败。我一般推荐调试环境用主动采集生产环境优先做旁路镜像加定期离线总召的折中方案。下面的代码全部按“主动采集”模式写因为这是最常用、最能验证协议理解是否正确的方式。3.2 最小可跑通代码连接、读帧、启动链路先写一个负责 TCP 连接和 APDU 读取的基础类。读帧的逻辑是所有后续功能的地基核心就是按“启动符 长度 读满长度字节”切帧不要把 TCP 的 read 结果和协议帧划等号。import java.io.*; import java.net.Socket; public class Iec104Client { private Socket socket; private DataInputStream in; private OutputStream out; /** 连接 RTU 的 104 端口设置 10 秒超时防止握手死等 */ public void connect(String host, int port) throws IOException { socket new Socket(host, port); socket.setSoTimeout(10_000); in new DataInputStream(socket.getInputStream()); out socket.getOutputStream(); System.out.println(TCP 已连接: host : port); } /** 读取一个完整 APDU返回值包含控制域和 ASDU 全部字节 */ public byte[] readApdu() throws IOException { int start in.read(); if (start ! 0x68) { throw new IOException(启动符异常: 0x Integer.toHexString(start)); } int len in.read(); if (len 4 || len 253) { throw new IOException(APDU 长度非法: len); } byte[] apdu new byte[len]; in.readFully(apdu); // 读满 len 个字节处理半包 return apdu; } /** 发送链路启动 STARTDT act并等待确认 */ public void startLink() throws IOException { byte[] startdtAct {0x68, 0x04, 0x07, 0x00, 0x00, 0x00}; out.write(startdtAct); out.flush(); byte[] resp readApdu(); if (resp[0] ! (byte) 0x0B) { throw new IOException(链路启动未确认, 控制字: 0x Integer.toHexString(resp[0])); } System.out.println(链路启动成功, 收到 STARTDT con); } }代码里的 setSoTimeout(10_000) 是关键参数它保证对端无响应时 readApdu 会在 10 秒后抛 SocketTimeoutException程序可以快速失败重连而不是在后台线程里死等。readFully 是 DataInputStream 自带的阻塞读会一直读到 len 个字节为止天然解决半包问题。STARTDT act 的报文固定就是 6 个字节0x68 0x04 0x07 0x00 0x00 0x00其中 0x04 表示后面 4 个字节是控制域。如果收到响应的控制域第一字节不是 0x0B说明从站拒绝了链路启动常见原因是已经有主站占用连接或者设备侧 104 服务未启用。3.3 发总召和收帧循环I/S/U 帧的分发处理链路启动成功后第一步就是发总召。总召让从站把全量数据按点表上送一遍这是“获取监听数据”最核心的动作。发送总召是 I 帧所以控制域要带上发送序号。public class Iec104Client { private int sendSeq 0; // 发送序号I 帧每发一帧自增 1 private int recvSeq 0; // 接收序号收到 I 帧后更新 /** 发送站级总召I 帧TypeID 100 */ public void sendGeneralInterrogation() throws IOException { byte[] frame { 0x68, 0x0E, // 启动符 长度 14 (byte) (sendSeq 1), 0x00, // I 帧发送序号低字节在前 0x00, 0x00, // 接收序号握手阶段固定 0 0x64, 0x01, // TypeID100 总召, VSQ1 个对象 0x06, 0x00, // 传送原因6 激活 0x01, 0x00, // 公共地址1按实际站地址修改 0x00, 0x00, 0x00, // 信息体地址0 0x14 // QOI20站级总召 }; out.write(frame); out.flush(); sendSeq; System.out.println(总召已发送, 发送序号 sendSeq); } }这里最容易写错的是发送序号的编码(sendSeq 1) 是因为 I 帧第一个字节的 bit0 固定为 0序号从 bit1 开始。发送序号超过 127 时第二个字节会参与进位正确的编码是(sendSeq 1) 0xFE和(sendSeq 6) 0xFF两个字节配合。公共地址 0x0001 是示例值实际要改成对应 RTU 的站地址否则从站会直接丢弃总召命令。收帧循环是监听程序的主干每次读到一个 APDU按控制域第一字节分发给三个处理分支。I 帧要解析数据并回 S 帧确认S 帧用于更新本地确认窗口U 帧用于链路管理。public void listenLoop() throws IOException { while (!Thread.currentThread().isInterrupted()) { byte[] apdu readApdu(); int control apdu[0] 0xFF; if ((control 0x03) 0x01) { // S 帧: 收到确认, 无需回包 int ackSeq (apdu[2] 0xFF) | ((apdu[3] 0xFF) 8); System.out.println(收到 S 帧, 确认序号 ackSeq); } else if ((control 0x03) 0x03) { // U 帧: STARTDT / TESTFR / STOPDT 的响应 System.out.println(收到 U 帧, 控制字0x Integer.toHexString(control)); if (control 0x83) { // TESTFR con链路活着 System.out.println(链路测试确认); } } else { // I 帧: 解析序号, 处理 ASDU, 回 S 帧确认 int recvSeq ((control 0xFE) 1) | ((apdu[1] 0xFF) 6); System.out.println(收到 I 帧, 序号 recvSeq); if (apdu.length 4) { parseAsdu(apdu, 4, apdu.length - 4); } sendSFrame(recvSeq 1); // 确认序号最新序号1 } } } /** 发送 S 帧, 接收序号直接放第三、四字节 */ private void sendSFrame(int ackSeq) throws IOException { byte[] sFrame { 0x68, 0x04, 0x01, 0x00, (byte) (ackSeq 0xFF), (byte) ((ackSeq 8) 0xFF) }; out.write(sFrame); out.flush(); }I 帧序号的解码公式是两个移位拼出来的控制域第一字节 bit1~bit6 是序号的低 6 位第二字节全部 8 位是序号的高 8 位。所以(control 0xFE) 1取低 6 位(apdu[1] 0xFF) 6取高 8 位并左移 6 位两者按位或就是完整 12 位序号。S 帧的确认序号不用移位直接放数值的低字节和高字节。回 S 帧的时机要准每收到一个 I 帧就回一个 S 帧不要攒多个再回。104 规约要求未确认的 I 帧数不能超过 12攒着不回会导致从站发送窗口耗尽表现为“数据收着收着就断了”。3.4 ASDU 解析把遥测值从字节流里还原出来parseAsdu 是监听结果的出口。TypeID 决定信息元素怎么拆这里把最常见的遥测13和遥信1写出来其他类型按同样套路扩展。浮点拼接是重灾区必须低字节在前。private void parseAsdu(byte[] apdu, int offset, int asduLen) { int typeId apdu[offset] 0xFF; int vsq apdu[offset 1] 0xFF; int cause (apdu[offset 2] 0xFF) | ((apdu[offset 3] 0xFF) 8); int commonAddr (apdu[offset 4] 0xFF) | ((apdu[offset 5] 0xFF) 8); int numObj vsq 0x7F; boolean continuous (vsq 0x80) ! 0; int pos offset 6; for (int i 0; i numObj; i) { int infoAddr (apdu[pos] 0xFF) | ((apdu[pos 1] 0xFF) 8) | ((apdu[pos 2] 0xFF) 16); pos 3; if (typeId 13) { // M_ME_NC 短浮点遥测 int bits (apdu[pos] 0xFF) | ((apdu[pos 1] 0xFF) 8) | ((apdu[pos 2] 0xFF) 16) | ((apdu[pos 3] 0xFF) 24); float value Float.intBitsToFloat(bits); int quality apdu[pos 4] 0xFF; pos 5; System.out.printf(遥测: 地址%d, 值%.3f, 品质0x%02X%n, infoAddr, value, quality); } else if (typeId 1) { // M_SP_NA 单点遥信 int val apdu[pos] 0xFF; int sp val 0x01; int quality (val 4) 0x0F; pos 1; System.out.printf(遥信: 地址%d, 状态%d, 品质0x%02X%n, infoAddr, sp, quality); } else { System.out.printf(未处理 TypeID%d, 信息体地址%d%n, typeId, infoAddr); break; } } }float 的拼装逻辑是104 在网络上传输浮点值时第一个字节是 IEEE 754 表示的最低 8 位。但 Java 的 byte 是有符号的直接强转 int 会把高位补 1所以每个字节都要先 0xFF转成无符号整数再按“低字节在最低位”的顺序拼成一个 int最后用Float.intBitsToFloat还原浮点值。品质字节的判断线上系统最常用的是(quality 0x10) ! 0表示该点无效无效数据不应该进入后续统计或告警逻辑。这段代码在真实项目里再补一个落库动作把解析结果写入时序库或关系库就算把“监听数据”接住了。4. 104 监听最容易翻车的四件事从粘包到品质位4.1 坑一只听不发永久静默现象程序连上 RTU 的 2404 端口建立了 TCP 连接然后什么都不做结果一帧数据都收不到。原因104 从站不是 UDP 广播不会主动把数据推给你。链路默认是停止状态必须由主站先发 STARTDT act0x07激活链路从站才接受后续命令。更重要的是即使链路激活了从站也不会自动全量上送要等主站发总召。旁路镜像场景下如果正式主站没有总召监听端同样只有零星的变化上送。解决连接成功后第一件事就是发 STARTDT act收到 0x0B 确认后立刻发总召。主动采集模式务必把这两个动作写成连接后的固定流程顺序不能颠倒也不能跳过。测试时可以用一条命令验证连上后先发68 04 07 00 00 00看有没有68 04 0B 00 00 00回来。4.2 坑二帧粘在一起拆不开现象一条in.read()读回来可能包含两三条 104 报文也可能只读到半条。直接按数组长度解析解析结果一塌糊涂。原因TCP 是字节流没有消息边界。104 报文在传输层可能被合并发送也可能被拆分。应用层如果每次 read 都当成一条完整报文必然出错。解决严格按“启动符 长度 按长度读满”三步切帧。启动符不是 0x68 就说明数据流错位要丢弃这个字节继续找下一个 0x68直到切出合法 APDU。readFully 保证半包时阻塞等待剩余字节这是最简单可靠的方案。误把多个报文拼在一起解析和误把半个报文强行解析都是这个原因代码上只要把 readApdu 写对粘包半包问题就一次性解决。4.3 坑三遥测值天文数字或全是零现象电压、电流解析出来变成 1.4E-45 之类的小得离谱的数或者变成 3.8E38 这种巨大的数个别时候全是 0。原因99% 是字节序拆错了。104 里的短浮点是低字节在前传输第一个字节是 IEEE 754 的最低 8 位。如果按 Java 大端序直接组合float 的符号位、指数、尾数全部错位解析出来的就是垃圾值。还有一小部分情况是设备上送本身就是无效数据品质字节的无效位bit4为 1。解决拼 int 时第一字节放最低位第四字节放最高位用Float.intBitsToFloat还原。同时判断品质字节无效点不要入库避免把故障数据当成真实遥测。实测里这两件事要一起做只修字节序不判品质故障瞬间会把错误数据写进库后续查数怎么查都对不上。4.4 坑四晚上断了没人知道第二天数据缺了一段现象半夜 3 点开始程序还在运行TCP 连接还 ESTABLISHED但就是不再收到任何新的 104 帧第二天查库发现缺了 4 小时数据。原因链路长时间没有交互从站的发送窗口耗尽后进入静默或者中间网络设备把空闲连接清了。TCP 连接在应用层“看起来还在”但实际上已经收不到数据。监听器如果只被动 read没有保活机制就会一直挂着等空气。解决监听循环里加定时器每 15 秒发一次 TESTFR act68 04 43 00 00 00收到 0x83 确认说明链路活着连续几次没有响应就主动重连。同时做 I 帧序号连续性检查记录上一次收到的序号如果新序号跳变说明中间丢了帧需要重新总召补齐。这两步是生产环境 104 监听的保命符缺一个都可能出现“程序没挂但数据断了”的隐蔽故障。5. 最后的监听习惯用序号检查丢帧用 TESTFR 保活链路监听程序的验收标准不只是“能收到几条数据”而是“长时间稳定、不丢帧、断了能自愈”。我最终沉淀下来的检查逻辑就三个指标每次写完代码都要先跑一遍验证。第一个指标是 I 帧序号连续性。在 listenLoop 里维护一个 expectedSeq每收到一个 I 帧把实际序号和期望值比对一致则递增不一致说明中间缺帧。缺帧时不要默默忽略打一条 ERROR 日志记下缺的区间随后发一次总召补数据。补数据用总召最简单不需要实现复杂的按地址选择召唤。第二个指标是 TESTFR 保活。单独开一个线程每 15 秒发一次 TESTFR act同时维护一个 lastReceiveTime。主线程每次收到任意帧都更新 lastReceiveTime。保活线程发现 lastReceiveTime 超过 60 秒没更新说明链路静默直接关闭 socket 重连重连后重新走 STARTDT 总召流程。这套逻辑写完后让它空跑一晚上第二天看日志里有没有心跳中断的记录。第三个指标是业务层校验。解析出来的数据点地址应该落在设备点表范围内遥测值应该在合理量程内。监听器加一个简单的过滤器地址越界或数值越界都记 WARN这些通常是点表配置错了不是协议问题。早点暴露这些问题比等数据入库后再排查高效得多。我自己在这个上面交过学费有一次交付的监听程序没有加 TESTFR 保活凌晨数据断了 4 小时没人发现早上用户拿着曲线图找过来我才意识到 TCP 连接还在不代表数据还在。从那以后凡是接手的 104 监听器第一件事就是检查三件事链路活、无丢帧、数据连续。这三个指标过关剩下的就是对点表、调阈值、适配不同厂家 RTU 的细节了。希望帮到你。本文还有配套的精品资源点击获取