
简介本资源面向电力自动化、工控通信方向的Java开发者与学习者围绕电网101规约DL/T634.5101-2002与104规约DL/T634.5104-2009提供一套可运行的解析与组包代码解决规约报文内容解析、发送报文生成等实际开发问题适合需要对接电力主站或终端设备的工程场景。压缩包共112个文件约3.68MB以34个java源码与34个class字节码为核心辅以11个xml配置、9个sample示例及head、docx说明文档等覆盖ASDU、连续/非连续地址构建、遥测、传输原因等模块结构完整便于二次开发。目前已有1136人学习下载。读者可据此理解101/104规约的报文结构与字段含义掌握解析与组装流程并直接复用示例代码完成报文生成与调试为电力通信项目开发提供可参考的实现思路与排错基础。1. 从一次变电站联调翻车说起这份 Java 解包资源到底能干什么去年帮一个做配电自动化的朋友排查问题现场装置上送遥测数据主站侧收到的数值全是乱码抓包一看报文结构对不上。折腾了一下午才发现问题出在 104 规约的 APDU 解包环节——帧头识别对了但信息对象地址和时标解析的字节偏移算错了。这种坑在电力自动化开发里太常见了IEC 60870-5-104 规约本身不复杂但真到 Java 里手写解包逻辑字节序、类型标识、可变帧长这几个地方随便哪个出问题数据就全废。这份「电网104规约解包(java).rar」就是冲着这个场景来的。它是一套用 Java 实现的 104 规约报文解析代码核心解决的是把装置上送的原始字节流按 IEC 60870-5-104 的帧格式拆成可读的遥测、遥信、遥控等数据对象。适合两类人一是做电力 SCADA 或配电主站开发、需要自己实现规约解析的 Java 工程师二是做电力设备测试、想拿一份能跑的代码对照报文结构验证理解的技术人员。如果你只是调第三方规约库的 API这份资源可能偏底层但如果你想搞清楚每一帧里每个字节到底代表什么它值得拆开看。2. 104 规约帧结构拆解从字节流到信息对象的映射逻辑2.1 三种帧格式的识别与区分IEC 60870-5-104 的 APDU 分三种帧I 帧信息传输、S 帧确认、U 帧控制。解包第一步就是判断帧类型判断依据是控制域第一个字节的最低两位。I 帧最低位为 0S 帧最低两位是 01U 帧最低两位是 11。这个逻辑看着简单但实际抓包时经常遇到粘包——多个 APDU 连在一起如果只按固定长度读第二帧开始就全错位了。常见做法是先读 2 字节的启动字符 0x68 和帧长度再根据长度读后续字节。启动字符固定是 0x68帧长度是 APDU 剩余部分的字节数不含启动字符和长度域本身。代码里一般会维护一个缓冲区循环检查是否凑够一帧再解析。// 帧类型判断控制域第一个字节的低两位 public FrameType getFrameType(byte[] apdu) { // apdu[0]0x68, apdu[1]length, apdu[2]开始是控制域 int ctrl1 apdu[2] 0xFF; if ((ctrl1 0x01) 0) { return FrameType.I; // I帧信息传输 } else if ((ctrl1 0x03) 0x01) { return FrameType.S; // S帧确认 } else { return FrameType.U; // U帧控制启动/停止/测试 } }这段代码的关键在 0xFF——Java 的 byte 是有符号的直接参与位运算会出负数必须先转成无符号整数。参数上apdu[2]是控制域首字节I 帧的判断条件是 bit00S 帧是 bit01 且 bit10U 帧是 bit01 且 bit11。实际项目里我一般会把帧类型判断和长度校验放在一起长度不对直接丢弃避免后续解析越界。2.2 信息对象地址与类型标识的解析I 帧里承载的是 ASDU应用服务数据单元结构是类型标识1字节 可变结构限定词1字节 传送原因2字节 公共地址2字节 信息对象地址3字节 信息元素集。类型标识决定信息元素的格式比如 0x09 是归一化遥测值0x0B 是标度化遥测值0x01 是单点遥信。可变结构限定词的最高位表示是否连续寻址低 7 位是信息对象个数。信息对象地址是 3 字节小端序这个在 Java 里要特别注意。很多人习惯用ByteBuffer的默认大端序读结果地址全反了。正确做法是手动按小端拼或者用ByteBuffer.order(ByteOrder.LITTLE_ENDIAN)。// 解析信息对象地址3字节小端序 public int parseObjectAddress(byte[] asdu, int offset) { // offset指向信息对象地址起始位置 int addr (asdu[offset] 0xFF) | ((asdu[offset 1] 0xFF) 8) | ((asdu[offset 2] 0xFF) 16); return addr; } // 解析归一化遥测值类型标识0x092字节有符号 public double parseNormalizedValue(byte[] asdu, int offset) { short raw (short) ((asdu[offset] 0xFF) | ((asdu[offset 1] 0xFF) 8)); // 归一化值范围-1到1对应-32768到32767 return raw / 32768.0; }parseObjectAddress里三个字节按低位在前拼接parseNormalizedValue把 2 字节有符号短整型除以 32768 得到归一化值。参数 offset 需要根据 ASDU 头部长度动态计算类型标识 1 字节 可变结构限定词 1 字节 传送原因 2 字节 公共地址 2 字节 6 字节所以信息对象地址从 ASDU 第 7 字节开始。如果可变结构限定词表示有多个信息对象每个对象的地址和值要循环解析循环次数就是低 7 位的值。2.3 时标解析与 CP56Time2a 格式带时标的遥测类型标识 0x1E 等会在信息元素后面跟 7 字节的 CP56Time2a 时间。格式是毫秒2字节小端 分钟1字节低6位有效 小时1字节低5位有效 日1字节低5位有效 月1字节低4位有效 年1字节低7位表示 2000 年起的偏移。这个格式的坑在于每个字节都有保留位直接当整数读会带上高位垃圾。// 解析CP56Time2a时标 public LocalDateTime parseCP56Time2a(byte[] data, int offset) { int ms (data[offset] 0xFF) | ((data[offset 1] 0xFF) 8); int minute data[offset 2] 0x3F; // 低6位 int hour data[offset 3] 0x1F; // 低5位 int day data[offset 4] 0x1F; // 低5位 int month data[offset 5] 0x0F; // 低4位 int year 2000 (data[offset 6] 0x7F); // 低7位2000 return LocalDateTime.of(year, month, day, hour, minute, ms / 1000, (ms % 1000) * 1_000_000); }毫秒字段是 0 到 59999表示当前分钟的毫秒偏移所以秒和纳秒要从毫秒值换算。 0x3F、 0x1F这些掩码操作就是去掉保留位。实际调试时如果时间对不上先检查掩码有没有漏再确认装置侧时区设置——104 规约默认用 UTC但很多国内装置直接发本地时间这个差异会导致几小时的偏差。3. 在 Java 项目里跑通解包从环境到第一个遥测值3.1 工程结构与依赖选择拿到这个 rar 包后先看目录结构。常见做法是解压后得到一个 Maven 或普通 Java 工程核心代码在src下可能按frame、asdu、util分包。如果只有.java文件没有构建配置我一般会手动建一个 Maven 工程把源码拷进src/main/java然后在pom.xml里加最少的依赖。104 解包本身不需要 Netty 或 Spring纯 JDK 就能跑但如果你要接 TCP 收流加一个commons-net或者直接用java.net.Socket也行。!-- pom.xml 最小依赖 -- dependencies !-- 日志方便看解析过程 -- dependency groupIdorg.slf4j/groupId artifactIdslf4j-simple/artifactId version2.0.9/version /dependency !-- 单元测试验证解析结果 -- dependency groupIdorg.junit.jupiter/groupId artifactIdjunit-jupiter/artifactId version5.10.0/version scopetest/scope /dependency /dependencies依赖版本不用照抄选你项目里已有的稳定版就行。关键是别引入规约相关的第三方库否则就失去拆解学习的意义了。编译前确认 JDK 版本代码里用了LocalDateTime至少 JDK 8。如果源码里有var或switch表达式那就得 JDK 11 以上。3.2 构造测试报文与单元测试没有真实装置的时候自己造一帧报文来验证解析逻辑是最快的方式。比如构造一个单点遥信类型标识 0x01的 I 帧启动字符 0x68长度按实际算控制域 4 字节ASDU 里类型标识 0x01、可变结构限定词 0x01一个对象、不连续、传送原因 0x03 0x00突发、公共地址 0x01 0x00、信息对象地址 0x01 0x00 0x00、遥信值 0x01。Test public void testSinglePointTeleindication() { // 构造一帧单点遥信报文 byte[] frame new byte[]{ 0x68, 0x0E, // 启动字符 长度 0x00, 0x00, 0x00, 0x00, // 控制域4字节I帧 0x01, // 类型标识单点遥信 0x01, // 可变结构限定词1个对象 0x03, 0x00, // 传送原因突发 0x01, 0x00, // 公共地址 0x01, 0x00, 0x00, // 信息对象地址 0x01 // 遥信值合 }; AsduParser parser new AsduParser(); AsduResult result parser.parse(frame); assertEquals(1, result.getObjects().size()); assertEquals(1, result.getObjects().get(0).getAddress()); assertEquals(true, result.getObjects().get(0).getValue()); }这个测试用例的价值在于它把一帧完整报文拆成了可验证的字段。跑通之后你可以改类型标识和值测试遥测、遥控等不同场景。注意长度字段 0x0E 是 14表示后面还有 14 字节。如果长度算错解析器会在读 ASDU 时越界抛ArrayIndexOutOfBoundsException。我一般会在解析入口加一个长度校验apdu.length ! apdu[1] 2就直接返回错误别让异常往上冒。3.3 接入 TCP 流与粘包处理真实场景里 104 是跑在 TCP 上的装置作为服务端主站作为客户端连接。用Socket收流时InputStream.read返回的字节数不固定可能一次读到半帧也可能读到两帧半。处理方式是维护一个ByteArrayOutputStream缓冲区每次读到的数据追加进去然后循环检查缓冲区里是否至少有一个完整 APDU。public void processStream(InputStream in) throws IOException { ByteArrayOutputStream buffer new ByteArrayOutputStream(); byte[] tmp new byte[1024]; int len; while ((len in.read(tmp)) ! -1) { buffer.write(tmp, 0, len); byte[] data buffer.toByteArray(); int offset 0; // 循环提取完整帧 while (offset 2 data.length) { if ((data[offset] 0xFF) ! 0x68) { offset; // 不是帧头跳过 continue; } int frameLen data[offset 1] 0xFF; if (offset 2 frameLen data.length) { break; // 帧不完整等下次数据 } byte[] apdu Arrays.copyOfRange(data, offset, offset 2 frameLen); parseAndDispatch(apdu); offset 2 frameLen; } // 保留未处理完的尾部数据 buffer.reset(); buffer.write(data, offset, data.length - offset); } }这段代码的核心是offset指针和缓冲区重置。data[offset] ! 0x68时只移动一个字节是为了处理脏数据或半帧错位。offset 2 frameLen data.length说明当前缓冲区里帧不完整跳出内层循环等下一次read。处理完的帧从缓冲区移除剩下的尾部数据留到下一轮。参数frameLen是 APDU 长度域的值实际帧总长是frameLen 2。这个逻辑跑通后粘包和半包基本不会出问题。4. 避坑与排查104 解包最容易翻车的五个地方4.1 字节序搞反导致地址全错现象解析出来的信息对象地址是预期值的 256 倍或完全对不上。原因104 规约里多字节字段是小端序但 Java 的DataInputStream.readInt()默认大端直接读就反了。解决所有多字节字段手动按小端拼接或者用ByteBuffer.order(ByteOrder.LITTLE_ENDIAN)。我习惯在工具类里封装readLeInt、readLeShort方法统一处理。4.2 可变结构限定词连续标志误判现象一帧里明明有多个信息对象但只解析出第一个。原因可变结构限定词最高位是连续标志为 1 时表示后续对象地址连续递增信息元素里不再重复地址。如果代码只按非连续模式解析就会把后续数据当垃圾。解决先判断(vsq 0x80) ! 0连续模式下地址从首地址递增信息元素按类型标识固定长度依次读取。4.3 传送原因的高位测试标志被忽略现象解析出的传送原因值偶尔异常大。原因传送原因 2 字节里bit7 是测试标志bit6 是确认标志低 6 位才是原因值。直接读 2 字节整数会把标志位算进去。解决cause (data[offset] 0x3F) | ((data[offset1] 0xFF) 8)先掩掉标志位。这个坑在联调时特别隐蔽因为大部分帧测试标志是 0偶尔来一帧带标志的就出问题。4.4 时标年份偏移算错现象解析出的时间是 1900 年或 2100 年。原因CP56Time2a 的年份是 7 位表示 2000 年起的偏移不是 1900 年。有人按 Unix 习惯加 1900 就错了。解决year 2000 (data[offset6] 0x7F)。另外注意月份低 4 位、日低 5 位掩码别写错。4.5 TCP 流里混入非 104 数据导致死循环现象程序卡在while循环里出不来CPU 跑满。原因缓冲区里有一段永远凑不成完整帧的数据offset一直不前进。解决在帧头判断失败时offset必须执行同时给缓冲区设上限超过一定长度比如 4096 字节还没找到帧头就清空。另外read返回 -1 时要跳出外层循环别死等。5. 进阶技巧用反射把 ASDU 解析做成可扩展的写到后面你会发现104 规约的类型标识有几十种每种的信息元素格式都不一样。如果全用if-else或switch堆在一个方法里代码会膨胀到没法维护。我一般会做一个简单的策略模式定义一个AsduHandler接口每个类型标识对应一个实现类用MapInteger, AsduHandler注册。这样加新类型只需要新增一个类不用动解析主流程。public interface AsduHandler { // 返回解析出的信息对象列表 ListInformationObject parse(byte[] asdu, int offset, int count, boolean continuous); } public class AsduParser { private final MapInteger, AsduHandler handlers new HashMap(); public AsduParser() { handlers.put(0x01, new SinglePointHandler()); // 单点遥信 handlers.put(0x09, new NormalizedHandler()); // 归一化遥测 handlers.put(0x0B, new ScaledHandler()); // 标度化遥测 handlers.put(0x1E, new NormalizedWithTimeHandler()); // 带时标归一化遥测 } public AsduResult parse(byte[] apdu) { int typeId apdu[6] 0xFF; AsduHandler handler handlers.get(typeId); if (handler null) { throw new UnsupportedOperationException(未支持的类型标识: typeId); } // ... 提取公共字段后交给handler } }这个结构的价值在于类型标识和解析逻辑解耦测试时可以单独测每个 handler。参数count来自可变结构限定词低 7 位continuous来自最高位。如果以后要支持自定义类型甚至可以做成配置文件加载 handler 类名用反射实例化。不过别过度设计先把常用的十几种类型覆盖了再说。验证解析结果对不对最直接的办法是拿一份已知正确的报文和期望值做对比测试。我习惯在src/test/resources下放几个.hex文件每行一帧测试时读进来逐帧解析断言关键字段。这样每次改代码跑一遍测试比手工抓包对数据快得多。从那以后我每次改解包逻辑都强制先跑一遍回归测试再上环境省得在联调现场被装置数据打脸。希望帮到你。本文还有配套的精品资源点击获取