news 2026/10/7 21:56:38

不再被104规约难倒:Java解包帧结构、字节序与粘包全攻略

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
不再被104规约难倒:Java解包帧结构、字节序与粘包全攻略

简介:针对电网101/104规约解析与组装的Java工具包,面向电力自动化、调度系统研发及规约调试人员。101/104规约是电力远动通信的核心协议,本项目围绕DL/T634.5101-2002与DL/T634.5104-2009标准,可实现遥测、遥信、遥控等报文内容的解析与生成,支持实际场景中发送报文的拼装,适合需要处理规约报文编解码、二次开发或协议学习的工程师参考。压缩包共112个文件,以Java源文件与编译后的class文件为主,辅以XML配置、sample示例、head头文件、docx说明文档及Git相关配置等,整体大小3.68MB,目录结构清晰,便于按模块检索。已有1136人学习下载。通过源码可掌握ASDU、传输原因、参数预置、遥测解析等关键模块的实现思路,理解连续/非连续地址构建方式,为基于104规约的项目研发提供可直接参考的代码基础。

1. 电网104规约解包:先读懂字节流,再谈Java解析

电网104规约解包,就是把变电站和调度主站之间走的那条专线里的IEC 60870-5-104报文,从一长串十六进制字节还原成能直接入库的遥信、遥测、遥控对象。做电力接入、配电自动化或者新能源远动通信的工程师,迟早会撞上这个格式:对方丢给你一个“电网104规约解包(java).rar”,解压一看,里面有控制域判断、ASDU拆解、信息体循环,逻辑写得很满,但真要接进自己的采集框架时,往往卡在字节序、TCP粘包和类型标识表这三道坎上。这篇文章不假装有现成源码可抄,而是把这类Java解包包最常见的实现方式和踩坑点完整讲一遍,适合刚接手104通道的集成工程师,也适合要把解包器重写一遍的电力平台开发者。

2. 104规约的帧结构拆解:从68H帧头到ASDU信息体

2.1 APCI四要素:帧头、长度、控制域和边界

104规约跑在TCP/IP之上,默认端口2404。和101规约那种串口帧不同,104取消了重复帧保护和校验码,因为TCP已经帮你做了可靠传输和流控。但代价是TCP流里没有“消息边界”,你收上来的一段数据可能包含半个帧、一整帧甚至好几个帧。所以解包的第一步永远是先“切帧”,而不是先“解字段”。

一份完整的104帧长这样:

字节位置内容长度
0启动符68H1字节
1帧长度LEN1字节
2~5控制域4字节
6~nASDULEN-4字节

LEN指的是从控制域开始到帧尾的总长度,也就是“控制域4字节 + ASDU长度”。这句话说起来轻巧,但很容易搞混:经常有人把LEN当成“后面的全部字节数”,结果计算起点偏差一个字节,解析出来全是垃圾。我一般拿到一个帧先做两个断言:首字节必须等于0x68,LEN必须等于buf.length-2。两个条件都满足才继续,否则就是粘包/半包,直接进缓冲区等待下一个TCP段。

控制域4字节的值决定帧类型。低位在传输时在前,所以直接看第一个控制字节的低两位即可:bit0和bit1为00是I帧(数据帧),01是S帧(确认帧),11是U帧(控制帧)。I帧带发送序号N(S)和接收序号N(R),S帧只带接收序号,U帧携带STARTDT/STOPDT等启停命令。

# 抓包里最常见的三帧开头 68 04 07 00 00 00 # U帧 STARTDT act 68 04 0B 00 00 00 # U帧 STARTDT con 68 14 08 00 00 00 00 00 0D 01 ... # I帧,带ASDU

如果你看到一帧只有6个字节且LEN=4,别惊讶,这是纯控制帧,根本没有ASDU,解析时直接放行,不进入业务处理。

2.2 ASDU五件套:类型标识、结构限定词、传送原因、公共地址、信息体

帧切完了,ASDU才是真正承载业务数据的地方。从第6个字节开始,依次是类型标识、可变结构限定词(VSQ)、传送原因、公共地址、信息体地址加信息元素。五个字段缺一不可,但坑在细节里。

类型标识用1个字节表示,是解包的“总开关”。常见值必须背下来:

类型标识含义信息体长度
1 (0x01)M_SP_NA 单点遥信1字节
3 (0x03)M_SP_TB 带时标单点遥信7字节
9 (0x09)M_ME_NA 归一化遥测3字节
13 (0x0D)M_ME_NB 带品质归一化遥测3字节
15 (0x0F)M_IT_NA 累计量(电度)3字节
45 (0x2D)C_SC_NA 单点遥控1字节

结构限定词只有1字节,高1位是SQ标志,低7位是信息体个数。SQ=0表示信息体地址各自独立,每个元素都带完整3字节地址;SQ=1表示地址连续,只在元素头给一个起始地址,后面的元素自动加1。这一步的解析直接决定循环次数和跳过字节。

传送原因占2字节,且是低位在前。最常见的有2(周期上送)、3(突发上送)、20(站召唤响应)、12(总召唤)。调试时看到类型对、地址对、但值不对,先查传送原因是不是和你预期的不一致——很多从站把变化数据用周期帧上送,靠传送原因才能识别。

公共地址也占2字节,低位在前。一台前置机可能接入多个厂站,公共地址是区分厂站的唯一手段。解析时要把编码过的高字节加到低字节前面,不能直接按大端读。

2.3 多字节字段低位在前:和Java习惯拧着来的字节序

104规约里凡是超过1字节的整数,全部是低字节在前(Little-Endian)。这意味着2字节的传送原因、2字节的公共地址、3字节的信息体地址,存储顺序都是“反着的”。

Java工程师最容易在这里翻车。用ByteBuffer默认的大端模式读2字节,读出来的是低字节在高位,256和1直接颠倒。正确的做法是手动移位:

int commonAddr = (buf[idx] & 0xFF) | ((buf[idx + 1] & 0xFF) << 8);

这段代码的含义是:先取低字节,再取高字节左移8位,按位或拼起来。这里的& 0xFF是为了把byte的符号位去掉,否则Java会把你当成有符号数,255会变成-1。凡是解析各类网络二进制协议,这三个字符都是保平安的。

3字节的信息体地址同理,但要注意最多只有3字节,也就是地址范围最大到0xFFFFFF。很多点表里地址是十进制四位五位数,转成十六进制后刚好落在这3字节里。解析时按三字节移位拼好,再和点表比对。

字节序的坑不解决,后面所有的地址、原因、序号都是错的,而且是那种“偶尔对、常常错”的玄学状态。

3. 用Java实现104规约解包:从最小解析器到完整帧处理

3.1 最小可运行的解码器:解析APCI和ASDU头部

先写一个能跑通的最小实现。目标是把一帧byte[]解析成类型标识、信息体个数、传送原因、公共地址和信息体数组。先把ASDU头部的解析独立出来,这部分是后续所有功能的地基。

public class Iec104Decoder { public static DecodedAsdu decodeAsdu(byte[] frame) { // 一帧最少是 6 字节:帧头1 + 长度1 + 控制域4 if (frame.length < 6) { throw new IllegalArgumentException("帧长度不足"); } if ((frame[0] & 0xFF) != 0x68) { throw new IllegalArgumentException("不是104规约帧头"); } int len = frame[1] & 0xFF; if (frame.length < 2 + len) { throw new IllegalArgumentException("实际字节数小于LEN声明"); } // 控制域4字节,若LEN=4说明是纯S帧/U帧,没有ASDU if (len == 4) { return null; } int idx = 6; DecodedAsdu asdu = new DecodedAsdu(); asdu.typeId = frame[idx] & 0xFF; idx++; int vsq = frame[idx] & 0xFF; asdu.sq = (vsq & 0x80) != 0; asdu.elementCount = vsq & 0x7F; idx++; asdu.causeOfTransfer = (frame[idx] & 0xFF) | ((frame[idx + 1] & 0xFF) << 8); idx += 2; asdu.commonAddress = (frame[idx] & 0xFF) | ((frame[idx + 1] & 0xFF) << 8); idx += 2; asdu.payload = new byte[frame.length - idx]; System.arraycopy(frame, idx, asdu.payload, 0, asdu.payload.length); return asdu; } }

len == 4的判断要放在长度校验之后,否则遇到S帧直接越界。elementCount取的是VSQ的低7位,sq取最高位,这两个标志配合后面解析信息体时使用。公共地址和传送原因都用低字节在前的方式拼接,这是前面强调的字节序落地的第一处代码。

3.2 按类型标识解析信息体:每个类型对应不同的字节布局

ASDU的payload不同,取决于类型标识。以最常见的三类为例:遥信M_SP_NA每个点1字节,带品质的遥测M_ME_NB每个点3字节,累计量M_IT_NA每个点3字节。SQ=0时,每个点前面都带着3字节信息体地址;SQ=1时,只有第一个点有地址,后续点地址自动加1。

这里的实现策略是:先用一个方法把信息体地址读出来,再按类型标识决定读几个字节的数据。

public static List<PointValue> decodePayload(DecodedAsdu asdu) { List<PointValue> result = new ArrayList<>(); byte[] p = asdu.payload; int addr = 0; int offset = 0; for (int i = 0; i < asdu.elementCount; i++) { if (!asdu.sq) { // 每个信息体都带独立地址 addr = (p[offset] & 0xFF) | ((p[offset + 1] & 0xFF) << 8) | ((p[offset + 2] & 0xFF) << 16); offset += 3; } else if (i == 0) { // 只读取起始地址,后续元素地址自增 addr = (p[offset] & 0xFF) | ((p[offset + 1] & 0xFF) << 8) | ((p[offset + 2] & 0xFF) << 16); offset += 3; } else { // SQ=1时后续地址自动加1 addr++; } PointValue pv = new PointValue(); pv.address = addr; switch (asdu.typeId) { case 1: // 单点遥信 pv.quality = p[offset] & 0xFF; pv.value = pv.quality & 0x01; // bit0是开关值 offset += 1; break; case 13: // 带品质归一化遥测 pv.quality = p[offset] & 0xFF; pv.value = (p[offset + 1] & 0xFF) | ((p[offset + 2] & 0xFF) << 8); offset += 3; break; case 15: // 累计量 pv.quality = p[offset] & 0xFF; pv.value = (p[offset + 1] & 0xFF) | ((p[offset + 2] & 0xFF) << 8) | ((p[offset + 3] & 0xFF) << 16) | ((p[offset + 4] & 0xFF) << 24); offset += 5; break; default: // 未知类型,跳到帧尾避免死循环 offset = p.length; break; } result.add(pv); } return result; }

注意累计量M_IT_NA在标准定义里是整数2字节或3字节带品质描述。这里按最常见组合写:品质1字节 + 数值4字节。接入真实厂站时,类型标识对应的长度要以对方的点表说明为准,不要拿通用代码硬套,这是后话。

品质描述字节的低4位标识值状态:bit0是遥信值/越限标志,bit1是溢出位,bit2是无效位,bit3是延时接收位。高4位一般标识电源状态和远程/本地标志。落到代码里,哪怕不解析完整品质位,也要把raw字节存下来,方便出问题时回溯。

3.3 用测试报文跑通:构造一个完整I帧并断言结果

光有解析器不算完,得有能反复跑的验证用例。下面这段构造了一个类型13的I帧:ASDU部分类型标识0x0D,VSQ=0x01表示一个信息体且SQ=0,传送原因0x14 0x00表示站召唤响应,公共地址0x01 0x00表示地址1,信息体地址3字节,品质0x00,归一化值0x0064即十进制100。

public static void main(String[] args) { // 68 14 => 帧头 + LEN(实际LEN=18即0x12,这里是修正后的帧) byte[] frame = new byte[]{ 0x68, 0x12, 0x08, 0x00, 0x00, 0x00, // 控制域 0x0D, 0x01, 0x14, 0x00, // 类型13 + VSQ + 传送原因 0x01, 0x00, // 公共地址 0x01, 0x00, 0x00, // 信息体地址=1 0x00, // 品质 0x64, 0x00 // 归一化遥测=100 }; DecodedAsdu asdu = decodeAsdu(frame); if (asdu == null) { System.out.println("纯控制帧,无ASDU"); return; } List<PointValue> points = decodePayload(asdu); System.out.println("类型标识=" + asdu.typeId); System.out.println("传送原因=" + asdu.causeOfTransfer); System.out.println("公共地址=" + asdu.commonAddress); System.out.println("信息体个数=" + asdu.elementCount); for (PointValue pv : points) { System.out.println("地址=" + pv.address + " 品质=" + pv.quality + " 值=" + pv.value); } }

输出应该是地址=1 品质=0 值=100。注意长度字段写的是0x12,也就是18,等于控制域4字节加ASDU14字节,这是正确写法。如果用0x14,帧长度会多出2字节,整个对齐直接崩掉。

4. 104规约解包的5个常见坑:从粘包到品质位

4.1 粘包和半包:TCP里没有帧边界

现象:解出来的ASDU完全错乱,有时抛数组越界,有时类型标识变成0x68。原因很简单:TCP是字节流,一次read可能收到前一帧的尾部+后一帧的头部,也可能只收到半帧。如果直接对read结果执行decodeAsdu,必然翻车。

解决:先把收到的数据放进累计缓冲区,循环切出完整帧再解析。

public class FrameAssembler { private final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); public List<byte[]> push(byte[] chunk) { buffer.write(chunk, 0, chunk.length); byte[] all = buffer.toByteArray(); List<byte[]> frames = new ArrayList<>(); int offset = 0; while (all.length - offset >= 6) { if ((all[offset] & 0xFF) != 0x68) { // 丢掉的不是帧头,说明流乱了,找下一个0x68 offset++; continue; } int len = all[offset + 1] & 0xFF; if (len < 4 || all.length - offset < 2 + len) { // 长度非法或数据不足,保留等待后续 break; } byte[] frame = Arrays.copyOfRange(all, offset, offset + 2 + len); frames.add(frame); offset += 2 + len; } byte[] remain = Arrays.copyOfRange(all, offset, all.length); buffer.reset(); buffer.write(remain, 0, remain.length); return frames; } }

这段代码把“等下一批TCP数据”这个动作封装起来。核心思想是:长度不够就跳出循环,完整帧就切走,多余则留在缓冲区继续等。轮询过程中,遇到非0x68开头时逐字节找头,属于自愈逻辑,防止一个坏帧导致整个链路解析崩溃。

4.2 字节序读反:公共地址变成256

现象:点表里公共地址是1,解析出来却是256。原因:把0x01 0x00直接当成大端整数读,低字节01被放在了高位。解决:统一用“低字节 + 高字节左移8位”的读法。

这个坑的隐蔽之处在于,公共地址如果恰好选的是高字节为0、低字节为1的厂站,反向读取不一定报错,只是对不上点表;品检的时候偶尔能解析出对的对象,就以为代码没问题。真正的排查方法是用固定测试报文跑单测,把每个多字节字段打印出来核对。

4.3 不看类型标识就用固定长度解析

现象:有的帧解析出来的遥信值乱跳,跟实际开关状态对不上。原因:解析器只看地址和数值,不判断类型标识,把M_SP_NA(1字节)当成M_ME_NB(3字节)读,信息体边界错位。解决:把switch (typeId)作为必选分支,未知类型直接跳过而不是硬读,同时输出一条warn日志。

实际调试中更常见的场景是从站厂家的私有类型标识,比如某些厂家用非标类型传递浮点遥测。这类帧如果直接按长度硬算,校验和都不一定对得上,先识别、再定制才是正道。

4.4 品质描述位不解析:坏数据直接进库

现象:遥测值偶尔出现一个异常大数,比如电压跳到12000。原因:从站维护或线路干扰时,品质字节bit2(无效位)会被置1,解析器只取了数值部分,把无效数据当成正常遥测入库。解决:在PointValue里保留quality字段,入库前判断无效位。

if ((pv.quality & 0x04) != 0) { // bit2为1,数据无效,按坏点处理 continue; }

这条判断写起来就一行,但能避免大量脏数据污染后续的告警和统计。从运维角度说,遥信品质里的“无效”位比数值本身更重要——数值错了还能靠阈值拦住,品质位错了就没有后悔药。

4.5 不校验I帧序号:重发的旧帧当新数据处理

现象:调试时发现同样一个遥信点,短时间内上送两条完全相同且时间戳间隔极短的数据。原因:TCP本身有重传,104的I帧还带有发送序号N(S)和接收序号N(R),如果对端重发了之前确认过的帧,解析器不做去重就把数据又送了一遍。解决:维护一个接收序号状态,每次收到I帧,先比较N(S)和期望值,允许相等、跳号或重置,但不允许回退到已经处理过的旧序号。

int nsRaw = (frame[2] & 0xFF) | ((frame[3] & 0xFF) << 8); int ns = nsRaw >> 1; // 去掉最低标志位 if (ns < lastNs) { // 旧帧重传,丢弃 return; } lastNs = ns;

注意这里判断的是“回退”,不是“严格递增”,因为链路重启后序号可能重新计数。调试时可以从站侧抓TCP报文,认准重传的那几条消息,看它是U帧启动上下文还是I帧重发,再决定是否要重置lastNs。

5. 把解包做成可维护的Java库:验证与回归的三个习惯

5.1 统一字节序读取工具,消灭重复移位

多字节字段的低位在前问题已经在多个地方出现,但很多代码里是每个方法各写一遍移位,难免有漏网之鱼。我一般会做一个小的ReadTool类,把u8、u16、u24、u32全部封装起来,所有解析只调用这几个方法,禁止到处写裸移位。

public final class Bytes { public static int u8(byte[] b, int ofs) { return b[ofs] & 0xFF; } public static int u16(byte[] b, int ofs) { return (b[ofs] & 0xFF) | ((b[ofs + 1] & 0xFF) << 8); } public static int u24(byte[] b, int ofs) { return (b[ofs] & 0xFF) | ((b[ofs + 1] & 0xFF) << 8) | ((b[ofs + 2] & 0xFF) << 16); } }

这样做的价值不只是少写几行移位,而是把字节序规则集中在一处。一旦某个厂站出现特殊的字节序约定,只需改这个工具类加上变体方法,业务代码不用大动。

5.2 解析与业务解耦:用队列接住解析结果

在真实前置机里,解包器往往是接收线程的一部分,如果解析完直接入库或直接触发告警,接收线程会被数据库拖死。常见做法是解析器只往一个BlockingQueue里丢PointValue,后台线程池负责消费。这样既能平滑突发流量,又方便做帧序去重和持久化。

BlockingQueue<PointValue> queue = new LinkedBlockingQueue<>(5000); // 接收线程 queue.offer(pv, 100, TimeUnit.MILLISECONDS); // 消费线程 PointValue pv = queue.poll(200, TimeUnit.MILLISECONDS);

队列满时offer超时退回,不要阻塞接收线程——丢一条遥测远好于整个通道假死。

5.3 报文回放:留档的十六进制日志就是后悔药

我最看重的习惯,是把现场的原始报文以十六进制文本形式落盘。遇到厂站上报异常,直接把这十几秒的日志丢给解析器跑一遍,定位问题的时间能缩短到原来的十分之一。配合上面的测试帧构造方法,还能把抓到的非标帧转成固定单测,防止后续改代码时把修好的功能再改坏。

验证解包器是否合格的标准很简单:拿现场真实报文回放,解析结果和厂站监控后台看到的值一致,且连续跑24小时没有数组越界和未捕获异常。我现在的做法是每接一个新通道,前三天强制开报文回放,把每天发现的解析差异整理成用例固化下来。这个习惯救过我不少次——手动改一个字节就要重新验证的感觉试一次就明白。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/7 21:54:40

C# WinForm部署PaddleOCR V3:基于ONNX Runtime的离线OCR实战

简介&#xff1a;这份C# WinForm部署PaddleOCR V3模型的完整源码工程&#xff0c;面向需要在桌面应用中集成中文OCR识别功能的.NET开发者。资源基于VS2019与.NET Framework 4.7.2开发&#xff0c;集成OpenCvSharp4.8.0以及Sdcb.PaddleInference、Sdcb.PaddleOCR等关键库&#x…

作者头像 李华
网站建设 2026/10/7 21:53:53

打印图片要会员?三招夺回Windows默认打开方式

打印个图片&#xff0c;电脑突然弹出一个"会员专享功能"的窗口&#xff1b;双击一张 JPG&#xff0c;蹦出来的不是看图软件&#xff0c;而是 WPS&#xff1b;想右键"打开方式"改回 Windows 照片查看器&#xff0c;发现列表里根本找不到……这是"电脑打…

作者头像 李华
网站建设 2026/10/7 21:53:52

易企秀源码系统对接CRM、ERP与内部数据库的实战全解析

易企秀源码系统大家应该不陌生&#xff0c;它本质上是把H5营销页面的制作、投放、数据回收能力打包成一套可私有化部署的代码。我这一年里接过好几个类似的单子——客户手里有一套易企秀源码&#xff0c;不满足于只拿它做报名页、邀请函、活动推广页&#xff0c;而是想把H5页面…

作者头像 李华
网站建设 2026/10/7 21:52:33

OSPF特殊区域实战:阻止Type-4和Type-5 LSA进入区域

接到这个需求的时候&#xff0c;我第一反应是&#xff1a;这又是一个"网络工程师每天都在做、但新手往往搞不明白"的经典操作。OSPF作为最常用的路由协议&#xff0c;Type-4和Type-5 LSA的传播控制&#xff0c;直接影响区域的LSDB规模、路由表精简和安全性。标题里提…

作者头像 李华
网站建设 2026/10/7 21:50:55

Visual Studio调试递归代码:从断点到调用堆栈的实战指南

先说一个我最近遇到的事&#xff1a;有个同事写的递归函数&#xff0c;在数据量小的时候一切正常&#xff0c;一旦数据量上来就崩&#xff0c;他在代码里加了一堆printf都没找到问题根源。我说你干嘛不用Visual Studio的调试器看看调用堆栈&#xff0c;他回了一句“我只会F5和F…

作者头像 李华
网站建设 2026/10/7 21:50:21

Spring Boot安全漏洞修复实战:从SQL注入到越权防护

Spring Boot 项目跑了大半年&#xff0c;业务倒是稳得很&#xff0c;直到某天安全扫描报告甩到眼前——SQL注入、敏感信息明文传输、越权访问&#xff0c;一个个红字标得刺眼。说是"修复漏洞"&#xff0c;其实背后牵扯出的是一整套安全检查项&#xff1a;接口设计、鉴…

作者头像 李华