简介:本资源面向电力自动化、工控通信方向的Java开发者与学习者,围绕电网101规约(DL/T634.5101-2002)与104规约(DL/T634.5104-2009)提供一套可运行的解析与组包代码,解决规约报文内容解析、发送报文生成等实际开发问题,适合具备一定Java基础、需要对接电力通信场景的中高级开发者参考。压缩包共112个文件,约3.68MB,以34个java源码与34个class字节码为核心,辅以11个xml配置、9个sample示例及head、docx说明文档等,覆盖ASDU、连续与非连续地址构建、遥测、传输原因等关键模块,目录结构清晰便于按功能检索。目前已有1136人学习下载。读者可借此理解101/104规约的报文结构与字段含义,掌握解析与组装流程,并直接复用相关类完成实际场景中的报文生成与调试,为二次开发与排错提供参考。
1. 电网104规约解包(Java):从字节流到遥测值的落地路径
很多做电力物联网采集的同行,第一次拿到 104 规约的抓包文件时都会愣一下——一串十六进制里既有68开头的启动字符,又有07 81这种看着像端口号的字节,还有一堆00 00 00 00的填充。它不像 Modbus 那样一眼能看出寄存器地址,也不像 MQTT 那样有明文主题。104 规约(IEC 60870-5-104)本质上是把 IEC 60870-5-101 的链路层搬到 TCP 之上,用 APCI(应用规约控制信息)做帧定界,用 ASDU(应用服务数据单元)承载遥测、遥信、遥控这些业务数据。解包要干的事,就是把 TCP 流里粘在一起的 APDU 切开,再按类型标识把 ASDU 里的信息体地址和值还原成「某测点在某时刻的值是多少」。这套东西在变电站辅控、配电房监测、光伏电站数据采集里天天用,Java 又是这类后台采集服务的主力语言,所以「电网104规约解包(Java)」这个方向,值得花时间把细节抠清楚。下面按我实际做过的顺序,从帧结构讲到能跑通的解析代码,再到踩过的坑。
2. 104 帧结构与 Java 字节序处理:先把 APCI 拆明白
2.1 APDU 的三段式结构和启动字符的坑
一个完整的 104 APDU 由三部分组成:启动字符0x68、长度域(1 字节,表示从第二个长度字节之后到 APDU 结束的字节数)、以及 APCI 控制域四字节。控制域决定了这是 I 帧、S 帧还是 U 帧。I 帧带发送序号和接收序号,承载 ASDU;S 帧只做确认;U 帧做链路启停和测试。很多人第一次解包失败,就是没区分帧类型,把 S 帧也当 ASDU 去解析,结果类型标识读出来是个乱七八糟的值。
长度域有个容易翻车的点:它统计的是「控制域 4 字节 + ASDU 长度」,不包含启动字符和长度域自身。也就是说,如果长度域是0x0E(14),那后面实际要读 14 个字节,其中前 4 个是控制域,剩下 10 个才是 ASDU。我见过有人直接用长度域去读 ASDU,导致每次少读或多读 4 字节,粘包处理全乱。
2.2 Java 里处理无符号字节和字节序的正确姿势
Java 的byte是有符号的,0x80读出来是 -128,直接参与位运算会出问题。标准做法是先和0xFF做与运算转成 int。104 规约的字节序是大端(网络字节序),但 ASDU 里的信息体地址是 3 字节,归一化值、标度化值又各有各的编码方式,不能一律用ByteBuffer默认的大端读完了事。
// 把有符号 byte 转成 0~255 的 int public static int u8(byte b) { return b & 0xFF; } // 读 2 字节无符号整数(大端),用于长度、序号低位等 public static int u16(byte[] buf, int pos) { return ((buf[pos] & 0xFF) << 8) | (buf[pos + 1] & 0xFF); } // 读 3 字节信息体地址(大端),104 里 IOA 是 3 字节 public static int ioa(byte[] buf, int pos) { return ((buf[pos] & 0xFF) << 16) | ((buf[pos + 1] & 0xFF) << 8) | (buf[pos + 2] & 0xFF); } // 读 4 字节有符号整数,用于短浮点数前的整型值 public static int i32(byte[] buf, int pos) { return ((buf[pos] & 0xFF) << 24) | ((buf[pos + 1] & 0xFF) << 16) | ((buf[pos + 2] & 0xFF) << 8) | (buf[pos + 3] & 0xFF); }这几个函数是后面所有解析的基础。u8解决符号问题,u16处理长度和序号,ioa专门读 3 字节地址,i32读 4 字节整型。注意ioa的移位顺序,先移 16 位的是最高字节,这是大端。如果抓包工具显示的是小端,那说明抓包工具做了转换,实际线上字节流一定是大端。
2.3 控制域四字节怎么判断 I/S/U 帧
控制域第一个字节的最低位决定帧类型:bit0 为 0 是 I 帧,bit0 为 1 且 bit1 为 0 是 S 帧,bit0 和 bit1 都为 1 是 U 帧。I 帧的发送序号在控制域第 1、2 字节的低 15 位,接收序号在第 3、4 字节的低 15 位。S 帧只有接收序号。U 帧用控制域第一个字节的高 6 位表示功能(STARTDT、STOPDT、TESTFR)。
public static String frameType(byte[] apci) { int b0 = u8(apci[0]); if ((b0 & 0x01) == 0) { return "I"; } else if ((b0 & 0x03) == 0x01) { return "S"; } else { return "U"; } } // 解析 I 帧的发送序号和接收序号 public static int sendSeq(byte[] apci) { return ((u8(apci[0]) & 0xFE) << 7) | (u8(apci[1]) >> 1); } public static int recvSeq(byte[] apci) { return ((u8(apci[2]) & 0xFE) << 7) | (u8(apci[3]) >> 1); }序号是 15 位循环的,从 0 到 32767 再回 0。做长连接采集时,如果发现序号不连续,要么是丢包,要么是对端重启了。这个序号在排查链路问题时非常有用,比看日志时间戳还准。
3. ASDU 类型标识解析:遥测、遥信、遥控各读哪几个字节
3.1 类型标识和可变结构限定词的含义
ASDU 的第一个字节是类型标识(TypeID),决定这条报文是什么业务数据。常见的有:0x01单点遥信、0x03双点遥信、0x09归一化遥测、0x0B标度化遥测、0x0D短浮点遥测、0x2D单点遥信带时标、0x2E短浮点遥测带时标、0x64总召唤、0x67时钟同步。第二个字节是可变结构限定词(VSQ),最高位 SQ 表示信息体地址是否连续,低 7 位是信息体个数。
SQ 这个位很关键。SQ=0 时,每个信息体都带自己的 3 字节地址;SQ=1 时,只有第一个信息体带地址,后面的地址依次加 1。很多解析库在这里写错,导致 SQ=1 的报文只解出第一个点,后面全丢。
3.2 短浮点遥测的完整解析代码
短浮点遥测(TypeID=0x0D)是最常见的遥测类型,值用 IEEE 754 单精度浮点表示,4 字节大端。下面这段代码解析一条不带时标的短浮点遥测 ASDU。
public static List<Measurement> parseFloatTelemetry(byte[] asdu) { List<Measurement> list = new ArrayList<>(); int typeId = u8(asdu[0]); int vsq = u8(asdu[1]); boolean sq = (vsq & 0x80) != 0; int count = vsq & 0x7F; // 传送原因 2 字节,公共地址 2 字节,共 4 字节 int cause = u16(asdu, 2); int commonAddr = u16(asdu, 4); int pos = 6; int lastIoa = 0; for (int i = 0; i < count; i++) { int ioa; if (sq && i > 0) { ioa = lastIoa + 1; } else { ioa = ioa(asdu, pos); pos += 3; } lastIoa = ioa; // 短浮点值 4 字节,大端 int raw = i32(asdu, pos); float value = Float.intBitsToFloat(raw); pos += 4; // 品质描述词 QDS 1 字节 int qds = u8(asdu, pos); pos += 1; list.add(new Measurement(ioa, value, qds, commonAddr, cause)); } return list; }逻辑说明:先读 TypeID 和 VSQ,拆出 SQ 和个数。传送原因占 2 字节,公共地址占 2 字节,所以信息体从第 6 字节开始。循环里根据 SQ 决定地址是读还是自增。短浮点值用Float.intBitsToFloat还原,注意这里i32读出来的 int 直接转 float 位模式,不能做数值转换。品质描述词 QDS 的 bit0 是溢出、bit1 是无效、bit2 是取代、bit3 是闭锁、bit4 是抖动,实际业务里通常只关心无效位。
参数说明:count最大 127,但实际一条报文很少超过 30 个信息体,因为 TCP 包大小和实时性限制。commonAddr是公共地址,一个 104 连接上可能挂多个子站,用这个区分。cause是传送原因,总召唤响应是 20,突发上送是 3,周期上送是 1。
3.3 带时标的遥测怎么处理时间
带时标的短浮点遥测 TypeID=0x2E,在 QDS 之后多 7 字节时标。时标格式是:毫秒 2 字节、分钟 1 字节、小时 1 字节、日 1 字节、月 1 字节、年 1 字节。分钟字节的高 2 位是无效标志,低 6 位才是分钟值。年字节是相对于 2000 年的偏移,比如 24 表示 2024 年。
public static long parseCp56Time2a(byte[] buf, int pos) { int ms = u16(buf, pos); int min = u8(buf, pos + 2) & 0x3F; int hour = u8(buf, pos + 3) & 0x1F; int day = u8(buf, pos + 4) & 0x1F; int month = u8(buf, pos + 5) & 0x0F; int year = 2000 + (u8(buf, pos + 6) & 0x7F); Calendar c = Calendar.getInstance(); c.set(year, month - 1, day, hour, min, ms / 1000); c.set(Calendar.MILLISECOND, ms % 1000); return c.getTimeInMillis(); }这里用Calendar是为了兼容老代码,新项目建议用java.time.LocalDateTime。注意月份从 1 开始,Calendar从 0 开始,所以要减 1。时标解析错会导致历史数据对不上,做曲线展示时特别明显。
4. TCP 粘包拆包与 104 解包器的工程实现
4.1 为什么 104 必须自己处理粘包
104 跑在 TCP 上,TCP 是字节流协议,不保留消息边界。一次read可能读到半条 APDU,也可能读到三条半。104 的帧定界靠启动字符0x68和长度域,所以解包器必须维护一个缓冲区,不断尝试从缓冲区里切出完整 APDU。常见错误是每读到一段数据就当成一条完整报文去解析,结果长度域对不上,直接抛异常。
4.2 一个可复用的缓冲区拆包实现
public class Iec104Decoder { private final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); public List<byte[]> feed(byte[] data) { buffer.write(data, 0, data.length); byte[] all = buffer.toByteArray(); List<byte[]> frames = new ArrayList<>(); int pos = 0; while (pos + 2 <= all.length) { // 找启动字符 if (u8(all[pos]) != 0x68) { pos++; continue; } int len = u8(all[pos + 1]); int total = 2 + len; if (pos + total > all.length) { break; // 半包,等下次数据 } byte[] frame = new byte[total]; System.arraycopy(all, pos, frame, 0, total); frames.add(frame); pos += total; } // 保留未消费的尾部 buffer.reset(); if (pos < all.length) { buffer.write(all, pos, all.length - pos); } return frames; } }逻辑说明:把所有到达的字节追加到缓冲区,然后从头扫描。遇到0x68就尝试读长度域,算出整帧长度。如果缓冲区剩余不够整帧,跳出循环等下次数据。如果当前字节不是0x68,说明是脏数据或半包错位,跳过一字节继续找。最后把已消费的部分丢掉,未消费的尾部保留。
参数说明:len是长度域,范围 4 到 253。total是整帧字节数,等于 2 加len。这个实现每次feed都会把整个缓冲区复制一遍,数据量大时有性能开销,生产环境可以用环形缓冲区优化,但逻辑上这个版本最清晰,适合先跑通再优化。
4.3 解包器与业务处理的解耦
拆出完整 APDU 后,先判断帧类型。I 帧才需要解析 ASDU,S 帧和 U 帧只更新链路状态。解析出的Measurement对象丢到队列里,由业务线程做入库或转发。不要在 IO 线程里做数据库写入,否则一个慢查询会把整个采集链路拖死。我一般用Disruptor或ArrayBlockingQueue做缓冲,队列满了就丢最老的数据,保证实时性。
5. 104 解包避坑清单:五个让我加班到凌晨的坑
5.1 长度域算错导致整条链路错位
现象:解析第一条报文正常,第二条开始类型标识变成随机值,后面全乱。原因:长度域统计的是控制域加 ASDU,有人误以为包含启动字符和长度域自身,读多或读少 2 字节。解决:记住公式整帧长度 = 2 + 长度域值,长度域值 = 4 + ASDU 长度。写单元测试时用固定抓包数据验证。
5.2 SQ=1 时地址自增方向搞反
现象:总召唤响应里一批遥测只解出第一个点,后面地址全是 0 或重复。原因:SQ=1 时后续信息体地址是前一个地址加 1,有人写成减 1 或者忘了自增。解决:在循环里维护lastIoa,SQ 为真且非第一个时用lastIoa + 1。注意地址是 3 字节,自增后不要越界。
5.3 短浮点值用错转换方法
现象:遥测值解出来是1.4E-45这种极小值,或者直接是NaN。原因:把 4 字节整型直接强转 float,或者字节序搞反。解决:用Float.intBitsToFloat做位模式转换,确保读出来的 int 是大端顺序。如果抓包工具显示的值和解析值差一个字节序,检查i32的移位顺序。
5.4 时标年份偏移没加 2000
现象:历史数据时间显示成 1924 年或 2000 年之前。原因:CP56Time2a 的年份是相对 2000 年的偏移,有人直接当年份用。解决:解析时year = 2000 + (b & 0x7F)。月份和分钟的高位有标志位,必须用掩码去掉。
5.5 公共地址和传送原因读错位置
现象:多子站场景下数据串站,A 站的数据挂到 B 站。原因:ASDU 里传送原因 2 字节、公共地址 2 字节,顺序是原因在前、地址在后,有人记反了。解决:固定偏移,TypeID 和 VSQ 之后第 2 字节开始是原因,第 4 字节开始是公共地址。写代码时用常量代替魔法数字。
6. 从解包到可用数据:校验、压测与一个提效技巧
解包代码跑通只是第一步,真正上线前我会做两件事:用历史抓包做回归校验,用模拟总召唤做压测。回归校验是把抓包文件按字节喂给Iec104Decoder,把解析结果和抓包工具里人工核对的值逐条比对,重点看边界情况——SQ=1 的批量报文、带时标的报文、品质位为无效的报文。压测是写一个模拟子站,按 104 协议响应总召唤,每秒发 200 条遥测,看解包器的 CPU 占用和 GC 频率。如果每秒 200 条就频繁 Full GC,说明每条报文都创建了大量临时对象,需要把Measurement改成可复用对象或者用原始类型数组。
一个提效技巧:把类型标识和解析逻辑做成策略模式,用Map<Integer, AsduParser>注册。新增类型时只加一个解析器,不改主流程。我一般会先实现0x01、0x03、0x09、0x0B、0x0D、0x2E这六种,覆盖遥信、遥测、带时标遥测,基本够辅控和配电房场景用。遥控和总召唤的发送逻辑另说,解包侧先把上行数据吃透。
最后说个血泪教训:别信抓包工具里显示的「解析后值」,一定要自己用代码从原始字节算一遍。我有次排查一个遥测跳变问题,抓包工具显示值正常,自己代码解出来也正常,最后发现是品质位 QDS 的无效位被业务层忽略了,无效数据当有效值入库。后来我在Measurement里强制带上 QDS,业务层必须显式判断有效性才能使用。这个习惯帮我省了很多后悔药。希望帮到你。
本文还有配套的精品资源,点击获取