
简介面向电网自动化领域的Java开发资源围绕101规约DL/T634.5101-2002与104规约DL/T634.5104-2009实现报文解析与组帧封装可覆盖遥测、遥信、遥控等实际场景中发送报文的生成需求解决规约报文人工组包易错、调试耗时的问题。压缩包共112个文件、约3.68MB以34个Java源文件及对应class字节码为核心辅以11个XML配置、样例报文、head头描述、工程配置文件及docx说明文档等源码结构完整便于对照学习或集成到现有调度系统。目前已有1136人学习下载。通过这份源码读者可深入掌握ASDU、传输原因、连续/非连续地址构建等关键报文构成逻辑理解104规约的链路层与应用层封装流程同时可直接复用解析与组帧工具类快速完成规约报文的组装与拆解减少从零编写协议栈的工作量适合电力自动化协议开发初学者、运维人员及需要集成调度通信功能的工程师。1. 电网104规约解包为什么电力调度从业者都卡在 APDU 边界上104规约即 IEC 60870-5-104是国内电网调度自动化系统里主站与厂站之间使用最广的运动规约。无论做变电站综自、配网自动化还是调度数据网接入拿到手的原始数据都是 TCP 分段字节流怎么把它还原成“哪一路遥信变位、哪一路遥测多少、SOE 何时发生”就是解包要解决的。这份“电网104规约解包(java)”资源核心是一套可直接运行的 Java 解包实现覆盖 APDU 切割、APCI 校验、ASDU 分发和常用类型标识的字段还原。它适合刚接手电力后台的 Java 工程师也适合想对协议自查的采集从业者。下文按这套实现思路结合真实报文把每一步讲透。2. 从 TCP 字节流到 APDU104规约的帧结构与判别逻辑2.1 启动字符、长度域与控制域先把 I/S/U 帧分清IEC 60870-5-104 的报文在 TCP 里以 APDUApplication Protocol Data Unit应用规约数据单元为单位传输。一个完整的 APDU 由 APCI 和 ASDU 组成APCI 固定 6 字节ASDU 是实际业务数据。TCP 本身是流式传输没有消息边界所以解包的第一步永远是从字节流里把 APDU 的边界切出来。先看 APCI 前两个字节。第一个字节是启动字符 0x68固定不变负责在字节流里寻找帧头第二个字节是 APDU 长度注意它只表示从第三个字节到帧尾的长度不包含 0x68 和长度域自身。比如长度值是 14那整帧就是 2 14 16 字节。这个“长度域不算自己”的细节实测中坑过不少人直接拿 TCP 包长去切帧碰上粘包必错。控制域占四个字节。第一字节低两位是帧类型标志bit00 且 bit10 是 I 帧信息传输帧bit01 且 bit10 是 S 帧监视帧bit01 且 bit11 是 U 帧控制帧。I 帧承载业务数据S 帧是对 I 帧的确认U 帧负责 STARTDT、STOPDT、TESTFR 这类链路控制。三种帧的控制域字段含义完全不同。帧类型bit1bit0控制域含义典型用途I 帧00发送序号 接收序号传输遥信遥测遥控业务数据S 帧01仅接收序号确认已收到的 I 帧U 帧11功能位 确认位STARTDT、STOPDT、TESTFRI 帧控制域的四个字节分成两个 12 位序号发送和接收各占一个序号后两位强制为 0。S 帧只有接收序号U 帧则没有序号概念。解析时如果拿读 I 帧序号的办法去读 U 帧得到的一定是错值所以判断帧类型必须排在读序号之前。2.2 ASDU 结构类型标识、可变结构限定词与传送原因ASDU 是 APDU 里真正装数据的部分头部固定六字节类型标识TypeId、可变结构限定词VSQ、传送原因COT两字节、公共地址两字节后面才是信息体。类型标识决定这个 ASDU 是什么数据类型。常用配置里单点遥信是 1双点遥信是 3带时标的单/双点遥信按厂家定义可能是 9、11 或 30、31归一化遥测 13短浮点遥测 21 或 45。资源里把类型标识统一收在一个常量类里类似这样public final class TypeId { public static final int M_SP_NA_1 1; // 单点遥信 public static final int M_DP_NA_1 3; // 双点遥信 public static final int M_SP_TB_1 9; // 单点遥信带时标 public static final int M_DP_TB_1 11; // 双点遥信带时标 public static final int M_ME_NC_1 13; // 归一化遥测 public static final int M_ME_TF_1 21; // 短浮点遥测 public static final int C_IC_NA_1 100; // 总召唤 public static final int C_CS_NA_1 103; // 时钟同步 }这段没有复杂逻辑但它是整个解包器分发的钥匙。少一个常量后面所有 switch 和注册表都会漏判。我一般会对照协议文档里的类型表逐条核对重点看总召唤 100 和时钟同步 103 这类控制类型有没有漏掉因为控制类型走的是另一条解析链路漏配了会在运行时抛未知类型。可变结构限定词 VSQ 一个字节低 7 位是信息体个数最高位是 SQ 标志。SQ0 表示信息体地址不连续每条都带完整信息体地址SQ1 表示地址连续只有第一个信息体带地址后续按地址递增推算。传送原因 COT 两个字节低 6 位是原因值1 周期、2 突发、3 总召唤高两位是 P/N 和 T 标志。这个字段经常被厂家改得不太规范解析时建议只取低 6 位高位在调试日志里看别当成原因值用。3. 用 Java 拆解 APDU解包器的骨架设计与核心代码3.1 从 Socket 缓冲区切出完整帧解包器的大前提是拿到可靠的字节流。用 Netty 就继承 ByteToMessageDecoder 自定义解码器直接用 Socket 读流就维护一个累积缓冲区。常见做法是不断从缓冲里找 0x68 当帧头读长度域算帧长够一帧就切一帧不够就等下一个包。下面这段是资源里切帧逻辑的简化版public class ApduFrameDecoder { private final ByteArrayOutputStream buffer new ByteArrayOutputStream(); public Listbyte[] decode(byte[] incoming) { buffer.write(incoming, 0, incoming.length); byte[] data buffer.toByteArray(); Listbyte[] frames new ArrayList(); int i 0; while (i data.length) { if (data[i] ! (byte) 0x68) { // 先找启动字符 i; continue; } int apduLen data[i 1] 0xFF; // APDU 长度无符号 int totalLen apduLen 2; // 0x68 和长度域各占一字节 if (i totalLen data.length) { // 半包等下一次数据 break; } byte[] frame Arrays.copyOfRange(data, i, i totalLen); frames.add(frame); i totalLen; } if (i data.length) { buffer.reset(); buffer.write(data, i, data.length - i); // 保留残余数据 } return frames; } }注意两个细节。第一个是data[i 1] 0xFF把长度域转成无符号整数否则长度超过 127 时 Java 会当负数处理totalLen 直接算错切帧全乱。第二个是半包时break而不是continue因为当前缓冲区里没有完整帧可切继续循环只会无效空转。粘包靠 while 循环天然解决只要长度正确帧一帧一帧被消费干净。3.2 APCI 控制域解析与序号管理拿到完整 APDU 后先解析 APCI。启动字符 0x68 校验失败就丢弃长度域校验通过后再看控制域。帧类型判断和序号提取都用位运算完成这一段也是很多 java 面试题里爱考的位操作场景。看下面这个解析工具类public class Apci { public static final int FRAME_I 0; public static final int FRAME_S 1; public static final int FRAME_U 2; public static int frameType(int ctrl0) { int lo ctrl0 0x03; // 只取低两位 if (lo 0x03) return FRAME_U; if (lo 0x01) return FRAME_S; return FRAME_I; } public static int sendSeq(byte[] apci) { return ((apci[2] 0xFF) | ((apci[3] 0xFF) 8)) 2; } public static int recvSeq(byte[] apci) { return ((apci[4] 0xFF) | ((apci[5] 0xFF) 8)) 2; } }sendSeq 和 recvSeq 取的是 12 位序号。((apci[2] 0xFF) | ((apci[3] 0xFF) 8)) 2这段先把低字节和高字节拼成一个 16 位整数右移两位丢掉低位的控制域标志剩下的 12 位就是序号。很多厂家的发送序号从 0 开始每发一帧加 1接收方靠序号判断丢帧和顺序错乱。实际调试时有一个容易晕的点S 帧控制域里只有接收序号没有发送序号。如果拿 sendSeq 去读 S 帧读到的是接收序号加一些杂散位数值没有意义。所以解析顺序必须是“先判帧类型再按类型取序号”顺序反过来S 帧和 U 帧全会被解析错。3.3 ASDU 分发器按类型标识路由到不同解析器ASDU 固定头解析出来后解包进入分发环节。分发器读类型标识查表找对应解析器把信息体交给它处理。这里我惯用 Map 做注册表而不是写一长串 if-else好处是新增类型标识不碰主流程只加一个实现类注册进去代码好维护得多。资源里的分发逻辑简化如下public class AsduDispatcher { private final MapInteger, AsduParser parsers new HashMap(); public void register(int typeId, AsduParser parser) { parsers.put(typeId, parser); } public void dispatch(byte[] asdu, int offset, int len) { int typeId asdu[offset] 0xFF; AsduParser parser parsers.get(typeId); if (parser null) { System.err.println(unknown typeId typeId); return; } parser.parse(asdu, offset 6, len - 6); } }dispatch 里的offset 6是跳过 ASDU 固定头类型标识 1 字节、VSQ 1 字节、传送原因 2 字节、公共地址 2 字节。如果某个子站配置的公共地址是 3 字节这个偏移量要改成头长度不能写死。资源里把头部长度做成了可配置项我建议你也这么做因为各厂家对公共地址的处理确实有差异。分发器遇到总召唤100或时钟同步103这类控制类型时会转发给对应的命令处理器而不是进遥信解析器。控制类报文的响应流程是另一套逻辑例如收到总召唤要先回确认再全量上送必须在分发器层面就分开否则类型 100 的信息体被当遥信点处理接出来的数据全是脏的。4. 实战解析遥信、遥测与 SOE 时标的 Java 实现4.1 单点遥信与双点遥信的 bit 位还原遥信是最常见的业务数据类型。单点遥信一个信息体占一字节0 表示分闸1 表示合闸2 和 3 是无效值。双点遥信每个信息体用两 bit 表示状态00 分闸、01 合闸、10 中间状态、11 无效。实际报文里双点遥信经常做 bit 打包传输一个字节挤 4 个双点遥信这时候必须按位拆。下面这段是按位还原双点遥信的代码public static ListInteger parseDoublePointYx(byte b) { ListInteger states new ArrayList(4); for (int i 0; i 4; i) { int val (b (i * 2)) 0x03; // 每两 bit 一组 states.add(val); // 0分闸, 1合闸, 2中间, 3无效 } return states; }一个字节拆出 4 个双点状态每个状态的取值落在 0 到 3。写这类代码最怕把 (i * 2)写成 i那样取到的 bit 组会串位点位全部对错。另一个常见错误是丢掉 0x03高位的 bit 污染了状态值。我一般会在单元测试里把 0x00、0x55、0xAA、0xFF 四个边界字节全测一遍点位和状态值对应上再往下走。单点遥信的解析相对简单但如果遇到带品质描述字节的类型还要把品质位拆出来。品质字节里 bit0 到 bit3 分别表示无效、非当前值、溢出、被取代这些标志位在调度告警里很关键。资源里把品质位的解析单独抽了一个方法我觉得这个设计是对的因为不同厂家的品质位定义可能做扩展单独维护方便后续适配。4.2 带时标报文CP56Time2a 转换成 LocalDateTimeSOE 和带时标的遥信在 104 报文里用的是 CP56Time2a 时标一共 7 字节。这个时标排列很反直觉毫秒占两字节且低字节在前分钟、小时、日期、月份、年份各占一字节年份是从 2000 年起算的偏移量。把它转成 Java 8 的 LocalDateTime是这类资源里被复用最多的工具方法public static LocalDateTime parseCp56Time2a(byte[] data, int offset) { int ms (data[offset] 0xFF) | ((data[offset 1] 0xFF) 8); int minute data[offset 2] 0x3F; int hour data[offset 3] 0x1F; int day data[offset 4] 0x1F; int month data[offset 5] 0x0F; int year data[offset 6] 0x7F; if (month 1 || month 12 || day 1 || day 31) { return null; // 无效时标交上层容错 } return LocalDateTime.of(2000 year, month, day, hour, minute, 0) .withNano((ms % 1000) * 1_000_000); }毫秒字段是 ms 累计值1900 表示 1 秒 900 毫秒所以秒位直接取 ms / 1000 就是下一秒毫秒位取 ms % 1000。年份是相对 2000 的偏移0 到 99。月份和日期要从字节里去掉高位保留位这就是 0x0F、 0x1F的来历。我在这段工具方法上吃过亏第一次把原始字节直接传给 LocalDateTime.of月份大于 12 直接抛异常。后来把有效性检查收进方法里返回 null 而不是抛异常因为采集链路里坏数据是常态一条坏报文不该打断整个采集线程。4.3 遥测值的归一化与标度转换归一化遥测值占用两个字节是有符号的整数范围 -32768 到 32767实际工程值需要乘一个标度因子这个因子在远动装置的参数表里配置。短浮点遥测则直接是 IEEE 754 单精度浮点数四个字节小端序。解析归一化遥测的典型做法是取出有符号短整数再乘以标度系数public static double parseNormMeasure(byte[] data, int offset, double scale) { short raw (short) ((data[offset] 0xFF) | ((data[offset 1] 0xFF) 8)); return raw * scale; }这里用 short 接收拼出来的 16 位是因为归一化遥测的值本身是有符号的。如果图省事用 int 拼出来再减 65536容易在边界值上出错。标度因子 scale 怎么来取决于厂站的点表有的用 0.001有的用 0.01有的通道系数完全自定义。资源里的做法是把 scale 配置在点表里每个遥测点一条配置解析时按点号查表带入。浮点遥测的解析要注意字节序四个字节低字节在前。Java 里没有直接按小端读 float 的现成方法需要先按小端拼 int 再转成 float 位模式public static float parseFloatMeasure(byte[] data, int offset) { int bits (data[offset] 0xFF) | ((data[offset 1] 0xFF) 8) | ((data[offset 2] 0xFF) 16) | ((data[offset 3] 0xFF) 24); return Float.intBitsToFloat(bits); }Float.intBitsToFloat是这个场景最该记住的 JDK 方法把字节拼成的 int 位模式直接解释成 float不必自己去手动算指数和尾数。我见过有人在项目里手写浮点还原算了半天还不对换这个方法一步到位。5. 避坑指南104解包最常见的五个坑5.1 粘包与半包长度域不是字节数现象解析出的报文明明长度对但字段全乱点号对不上值也离谱。原因TCP 是流式传输多个 APDU 可能粘在一个 TCP 包到达或一个 APDU 被拆成两个包。拿到 input.read 的返回长度直接当一帧长度必然出问题。更隐蔽的是把 APDU 长度当成整个 TCP 包长度来切碰到粘包时一帧里混进了两条 APDU解析必错。解决累计缓冲区加循环切帧。先找 0x68再读长度域用“缓冲区剩余字节数是否够一帧”判断是否半包不够就 break 等下一个数据包够了就切出去继续循环。切帧完成前不碰任何字段级解析这是铁律。5.2 字节序高字节在前还是低字节在前现象遥测数值不对100.5 读出来成了负数或一个毫无关系的大数。原因104 规约里传送原因、公共地址、信息体地址、遥测值统统一律低字节在前。有人习惯了网络序或其他规约的高字节在前习惯直接按大端去拼值数值自然错乱。解决统一封装小端读取方法所有多字节字段都走同一个入口public static int readUInt16(byte[] data, int offset) { return (data[offset] 0xFF) | ((data[offset 1] 0xFF) 8); }整个解包器里不要出现手写位移拼值的地方最多在性能敏感的地方用 ByteBuffer 配 LITTLE_ENDIAN。信息体地址是三字节小端也单独封一个 readUInt24 方法避免每次都要想偏移。5.3 时标字段的世纪与时区问题现象SOE 时间显示 2000 年或 2099 年有的对端差 8 小时。原因CP56Time2a 年份是相对 2000 的偏移有的装置上报 0x00 表示 2000 年有的给 0x7F 表示未知年份。时区方面有的厂家上报的是 UTC 时间有的是本地时间直接把 7 字节时标当本地时间解析南北方的厂站都可能差 8 小时。解决年份偏移做2000 (b 0x7F)把 0 和 0x7F 当无效值返回 null。时区问题靠配置解决加一个 timezoneOffset 参数上线时按厂家点表填。国内大部分厂站上报本地时间但如果主站统一按 UTC 存储写入前要加 8 小时。5.4 可变结构限定词 SQ1不要把它当成固定长度现象第二条遥测的地址和值对不上第三条直接错位。原因VSQ 的 SQ 位为 1 时只有第一个信息体带完整地址后续信息体按地址加 1 推导。解析器没判 SQ 位把每个信息体都当成带头地址的完整结构去读数据字段就被当成地址消费掉整个信息体流错位。解决解析前先取 VSQ 高位的 SQ 位。SQ1 时第一个信息体读地址后续地址在上一条基础上加 1SQ0 时每条独立读地址。地址间隔不是 1 的厂站要强制对端把 SQ 置 0靠自动加地址去适配间隔是算不出来正确点号的。5.5 总召唤与时钟同步这类控制帧别当遥信处理现象界面上冒出一堆 100、103 这样的“遥信点号”或者总召唤流程根本没走起来。原因类型标识 100 和 103 的控制域 ASDU 是命令帧信息体含义和测点数据完全不同。分发器没把控制类型单独归类100 被送进遥信解析器出来的点号和值全是垃圾。解决分发器里把控制类型单独分组100、103 等走命令链路不进遥信遥测解析。总召唤是有确认的流程主站发总召唤厂站回激活确认再上送全量数据最后回激活结束。这个时序如果没实现调度端查起来直接判规约不合格。资源里对这块给出了简单的状态机处理照着扩展就能用。6. 验证解包结果报文样本回放与结构比对解包器写完第一步不是接真机而是用报文样本做回放。资源里如果带了十六进制报文样本用法是按行读入一行是一条十六进制帧交给切帧器得到 APDU 列表然后验证每个 APDU 的类型标识、长度和信息体个数。没有现成样本的话我自己会构造三条测试报文一条单点遥信、一条带时标的双点遥信、一条归一化遥测。每条报文手工算好长度和序号跑完解析器后核对输出。构造的报文一定要覆盖粘包场景把三条帧拼成一个字节数组一次性送入解码器看切出来的帧数和原始条数是否一致。这个用例能同时暴露切帧、长度域、残余缓冲三块逻辑的问题。验证时重点关注一点I 帧发送序号从 0 开始逐帧递增解析器里应该维护一个变量做连续性检查发现跳号就记日志。跳号意味着链路丢帧调度主站判断链路质量全靠这个信号。另一个检查点是 VSQ 信息体个数与实际解析出的信息体数量是否一致这个数量对不上说明解析器在某个字段上多读或少读了字节这种错位问题最容易出现在带时标的报文类型上。从写完这个 Java 解包器到现在我每次改字段位移或者新增一个类型标识都会强制把十六进制回放跑一遍再拿厂家抓包样本做交叉比对。这条习惯帮我拦下过好几次字节序改动引起的回归也帮我快速定位过一次公共地址偏移量配错的问题。如果你也准备拿这份资源做二次开发建议把回放测试脚本留在工程里每次改动顺手跑一遍别等接上真机才暴露问题。希望这一套解包实现和避坑经验能帮你在电网 104 报文面前少走几步弯路。本文还有配套的精品资源点击获取