☰
电网104规约Java解包实战:从TCP字节流到遥信遥测解析
2026/10/7 21:56:38 网站建设 项目流程

简介:面向电网自动化领域的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 字节流到 APDU:104规约的帧结构与判别逻辑

2.1 启动字符、长度域与控制域:先把 I/S/U 帧分清

IEC 60870-5-104 的报文在 TCP 里以 APDU(Application Protocol Data Unit,应用规约数据单元)为单位传输。一个完整的 APDU 由 APCI 和 ASDU 组成,APCI 固定 6 字节,ASDU 是实际业务数据。TCP 本身是流式传输,没有消息边界,所以解包的第一步永远是从字节流里把 APDU 的边界切出来。

先看 APCI 前两个字节。第一个字节是启动字符 0x68,固定不变,负责在字节流里寻找帧头;第二个字节是 APDU 长度,注意它只表示从第三个字节到帧尾的长度,不包含 0x68 和长度域自身。比如长度值是 14,那整帧就是 2 + 14 = 16 字节。这个“长度域不算自己”的细节,实测中坑过不少人,直接拿 TCP 包长去切帧,碰上粘包必错。

控制域占四个字节。第一字节低两位是帧类型标志:bit0=0 且 bit1=0 是 I 帧(信息传输帧);bit0=1 且 bit1=0 是 S 帧(监视帧);bit0=1 且 bit1=1 是 U 帧(控制帧)。I 帧承载业务数据,S 帧是对 I 帧的确认,U 帧负责 STARTDT、STOPDT、TESTFR 这类链路控制。三种帧的控制域字段含义完全不同。

帧类型bit1bit0控制域含义典型用途
I 帧00发送序号 + 接收序号传输遥信遥测遥控业务数据
S 帧01仅接收序号确认已收到的 I 帧
U 帧11功能位 + 确认位STARTDT、STOPDT、TESTFR

I 帧控制域的四个字节分成两个 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 标志。SQ=0 表示信息体地址不连续,每条都带完整信息体地址;SQ=1 表示地址连续,只有第一个信息体带地址,后续按地址递增推算。传送原因 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 List<byte[]> decode(byte[] incoming) { buffer.write(incoming, 0, incoming.length); byte[] data = buffer.toByteArray(); List<byte[]> 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 Map<Integer, 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 List<Integer> parseDoublePointYx(byte b) { List<Integer> 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 转换成 LocalDateTime

SOE 和带时标的遥信在 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 可变结构限定词 SQ=1:不要把它当成固定长度

现象:第二条遥测的地址和值对不上,第三条直接错位。

原因:VSQ 的 SQ 位为 1 时,只有第一个信息体带完整地址,后续信息体按地址加 1 推导。解析器没判 SQ 位,把每个信息体都当成带头地址的完整结构去读,数据字段就被当成地址消费掉,整个信息体流错位。

解决:解析前先取 VSQ 高位的 SQ 位。SQ=1 时第一个信息体读地址,后续地址在上一条基础上加 1;SQ=0 时每条独立读地址。地址间隔不是 1 的厂站,要强制对端把 SQ 置 0,靠自动加地址去适配间隔是算不出来正确点号的。

5.5 总召唤与时钟同步这类控制帧,别当遥信处理

现象:界面上冒出一堆 100、103 这样的“遥信点号”,或者总召唤流程根本没走起来。

原因:类型标识 100 和 103 的控制域 ASDU 是命令帧,信息体含义和测点数据完全不同。分发器没把控制类型单独归类,100 被送进遥信解析器,出来的点号和值全是垃圾。

解决:分发器里把控制类型单独分组,100、103 等走命令链路,不进遥信遥测解析。总召唤是有确认的流程:主站发总召唤,厂站回激活确认,再上送全量数据,最后回激活结束。这个时序如果没实现,调度端查起来直接判规约不合格。资源里对这块给出了简单的状态机处理,照着扩展就能用。

6. 验证解包结果:报文样本回放与结构比对

解包器写完,第一步不是接真机,而是用报文样本做回放。资源里如果带了十六进制报文样本,用法是按行读入,一行是一条十六进制帧,交给切帧器得到 APDU 列表,然后验证每个 APDU 的类型标识、长度和信息体个数。

没有现成样本的话,我自己会构造三条测试报文:一条单点遥信、一条带时标的双点遥信、一条归一化遥测。每条报文手工算好长度和序号,跑完解析器后核对输出。构造的报文一定要覆盖粘包场景,把三条帧拼成一个字节数组一次性送入解码器,看切出来的帧数和原始条数是否一致。这个用例能同时暴露切帧、长度域、残余缓冲三块逻辑的问题。

验证时重点关注一点:I 帧发送序号从 0 开始逐帧递增,解析器里应该维护一个变量做连续性检查,发现跳号就记日志。跳号意味着链路丢帧,调度主站判断链路质量全靠这个信号。另一个检查点是 VSQ 信息体个数与实际解析出的信息体数量是否一致,这个数量对不上说明解析器在某个字段上多读或少读了字节,这种错位问题最容易出现在带时标的报文类型上。

从写完这个 Java 解包器到现在,我每次改字段位移或者新增一个类型标识,都会强制把十六进制回放跑一遍,再拿厂家抓包样本做交叉比对。这条习惯帮我拦下过好几次字节序改动引起的回归,也帮我快速定位过一次公共地址偏移量配错的问题。如果你也准备拿这份资源做二次开发,建议把回放测试脚本留在工程里,每次改动顺手跑一遍,别等接上真机才暴露问题。希望这一套解包实现和避坑经验,能帮你在电网 104 报文面前少走几步弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询