ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

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

2026/10/7 21:56:59 拓冰建站 浏览量
不再被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当成“后面的全部字节数”结果计算起点偏差一个字节解析出来全是垃圾。我一般拿到一个帧先做两个断言首字节必须等于0x68LEN必须等于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个字节且LEN4别惊讶这是纯控制帧根本没有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位是信息体个数。SQ0表示信息体地址各自独立每个元素都带完整3字节地址SQ1表示地址连续只在元素头给一个起始地址后面的元素自动加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字节若LEN4说明是纯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字节。SQ0时每个点前面都带着3字节信息体地址SQ1时只有第一个点有地址后续点地址自动加1。这里的实现策略是先用一个方法把信息体地址读出来再按类型标识决定读几个字节的数据。public static ListPointValue decodePayload(DecodedAsdu asdu) { ListPointValue 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 { // SQ1时后续地址自动加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部分类型标识0x0DVSQ0x01表示一个信息体且SQ0传送原因0x14 0x00表示站召唤响应公共地址0x01 0x00表示地址1信息体地址3字节品质0x00归一化值0x0064即十进制100。public static void main(String[] args) { // 68 14 帧头 LEN实际LEN18即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; } ListPointValue 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 Listbyte[] push(byte[] chunk) { buffer.write(chunk, 0, chunk.length); byte[] all buffer.toByteArray(); Listbyte[] 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_NA1字节当成M_ME_NB3字节读信息体边界错位。解决把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后台线程池负责消费。这样既能平滑突发流量又方便做帧序去重和持久化。BlockingQueuePointValue queue new LinkedBlockingQueue(5000); // 接收线程 queue.offer(pv, 100, TimeUnit.MILLISECONDS); // 消费线程 PointValue pv queue.poll(200, TimeUnit.MILLISECONDS);队列满时offer超时退回不要阻塞接收线程——丢一条遥测远好于整个通道假死。5.3 报文回放留档的十六进制日志就是后悔药我最看重的习惯是把现场的原始报文以十六进制文本形式落盘。遇到厂站上报异常直接把这十几秒的日志丢给解析器跑一遍定位问题的时间能缩短到原来的十分之一。配合上面的测试帧构造方法还能把抓到的非标帧转成固定单测防止后续改代码时把修好的功能再改坏。验证解包器是否合格的标准很简单拿现场真实报文回放解析结果和厂站监控后台看到的值一致且连续跑24小时没有数组越界和未捕获异常。我现在的做法是每接一个新通道前三天强制开报文回放把每天发现的解析差异整理成用例固化下来。这个习惯救过我不少次——手动改一个字节就要重新验证的感觉试一次就明白。希望帮到你。本文还有配套的精品资源点击获取