基于Java的101规约与104规约解析组装工具实战

📅 发布时间:2026/10/7 18:13:51
基于Java的101规约与104规约解析组装工具实战
简介面向电力自动化工程实践的Java实现聚焦电网101规约DLT634.5101-2002与104规约DLT634.5104-2009的报文解析与组装适合毕业设计、课程设计以及工业通信协议二次开发场景。压缩包共64个文件其中54个Java源文件构成核心解析与组包逻辑另含pom.xml工程配置、Markdown项目说明、Excel规约解析细则及txt辅助文档整体仅147KB下载与阅读都很便捷。目前已有81人学习浏览说明其在相关专业中有一定参考价值。项目按iec104-core模块化组织覆盖帧解析、报文组装、测试用例等环节配合规约细则表格与使用文档可帮助理解DLT634.5104-2009的帧结构、控制域和ASDU处理流程作者表示代码难度适中、易上手遇到运行问题可通过私信获得支持或远程指导尤其适合计算机、电力相关专业学生与程序员学习使用学霸亦可在此基础上二次扩展实现更多规约功能。1. 从一块“死画面”说起Java工具怎么啃下101规约与104规约变电站接入调度主站的时候最怕的不是设备坏而是画面“死”了遥信不翻、遥测不动、下发遥控没反应。抓包一看TCP 连接是好的报文也到了但应用层就是解析不出来——这时候十有八九是栽在 101规约 和 104规约 的报文细节上。DL/T 634.5104-2009 是 104规约 在国内电力行业的正式标准版本对应 IEC 60870-5-104:2006而 101规约 走串口、104规约 走网络两者在 ASDU 层高度相似却在链路层和传输方式上各有一套。标题里的“基于Java语言的电网规约101规约和104规约DLT634.5104-2009的解析和组装工具”说白了就是把“收到报文→解析成点位数据”和“下发命令→组装成报文”这两步做成一套可复用的 Java 组件。这东西适合谁电力后台开发、SCADA 集成工程师、刚接触规约的 Java 程序员——凡是需要和调度端、变电站后台联调的人都能靠它把摸黑排查变成一眼定位。2. 规约概览与选型DL/T 634.5104-2009 到底管什么2.1 101规约和104规约一个串口一个网络在动手写代码之前得先把两套规约的关系理清。101规约 的全称是 IEC 60870-5-101设计目标是低速串行通道物理层普遍走 RS232 或 RS485链路层用的是 FT1.2 帧格式。它的特点是一个字节一个字节地从串口流里抠帧帧头、长度、CRC 全部要自己处理还得面对串口干扰、波特率不匹配这类物理层问题。而 104规约 是在 101 的 ASDU 基础上把传输层换成了 TCP/IP默认端口 2404帧结构简化成“启动符 长度 控制域 ASDU”。DL/T 634.5104-2009 就是 104 的电力行业标准版本明确了 104 在调度主站和厂站之间的应用规则。核心区别落在两层链路层和传输规则。101 的链路层要处理固定帧长、可变帧长两种帧还有地址字段、CRC-16 校验字节104 把这些全部丢掉改为 TCP 流上一个 6 字节的头部启动符、长度加 4 字节控制域。但往上一层101 和 104 的 ASDU应用服务数据单元结构几乎一致类型标识、可变结构限定词、传送原因、公共地址、信息体地址和信息体元素。所以工程上成熟的做法是把 ASDU 的解析和组装做成一套公共代码101 和 104 各写各自的链路层适配。这个判断直接决定工具怎么拆模块也是后面代码复用的基础。2.2 为什么用 Java 写这类工具市面上确实有 C/C 写的规约库性能和指针操作都占优势但在电力系统后台这个环境里Java 反而是更务实的选型。电力调度主站、变电站监控后台大量业务系统是 Java 技术栈集成 Spring、Netty 这类框架毫无压力。104 基于 TCP天生适合 Netty 的 ChannelPipeline 模型101 的串口在 Java 里可以靠 jSerialComm 或 SerialPort 类搞定也踩不到 JNI 那种坑。再一个实际原因这类工具要长期维护团队里不一定都是规约专家Java 的面向对象建模和显式类型比 C 的手工内存管理容易看懂得多。面试时背一堆 java八股文不如写一个能解析真实报文的规约解析器学得多——后者至少不用靠背。选 Java 还有一个隐性好处解析和组装大概率要跟点表配置打交道。点表一般存在数据库或 XML 里Java 在这块生态太成熟了MyBatis、Jackson 都是现成的。规约解析完的原始字段比如信息体地址、品质字节要映射到业务的“点位号”“系数”“越限值”这套映射逻辑用 Java 写跟后台系统对接几乎零成本。如果选其他语言做规约本身没问题但跟业务系统的胶水代码反而成了大头。2.3 工具的整体结构解码与编码两条链路拿到这个工具我习惯先看它是不是把“解析”和“组装”拆成了两条独立的链路。解析方向是从网络上收到字节流经过链路层确认、ASDU 拆解最终输出一个个点位对象遥信状态、遥测值、SOE 时间等组装方向则相反从业务下发命令开始生成 ASDU再包上控制域和帧头变成可以发出去的字节数组。两条链路在中间点表处汇合。一个清晰的模块划分大概是这样链路层适配器104 用 Netty 的 ByteToMessageDecoder/Encoder101 用串口读取线程加帧同步器ASDU 公共层类型标识派发器按 Type ID 路由到对应的点解析器点表映射层把信息体地址映射成测点号和系数对象模型遥信对象、遥测对象、遥控对象、时钟同步对象、总召唤对象对外接口解析完的 List 和编码前的 RemoteCommand。这样设计后最明显的好处是 101 和 104 的应用层代码不写第二遍。后面章节提到的所有 ASDU 解析和组装在 101 里原样复用。如果拿到手的工具没有做这层抽象解析 101 和解析 104 是两套独立逻辑那一旦标准理解错了就要改两遍维护成本直接翻倍。3. 解析方向104规约的接收帧逐字段拆3.1 104的帧结构从0x68开始的三个层面104规约 的帧在 TCP 字节流里是连续排布的一帧的起点固定是 0x68。以一次典型的遥信变位上送为例主站收到的裸报文大概是这样的十六进制序列68 0E 08 00 00 00 01 01 06 00 01 00 01 00 00 00逐个字节拆第一个 0x68 是启动字符第二个 0x0E 是长度数值 14表示后面从控制域开始到帧结束一共 14 个字节——注意它不计入 0x68 和长度字节本身。紧接着 4 个字节08 00 00 00是控制域从第六个字节开始就是 ASDU类型标识 0x01 表示单点遥信0x01 是可变结构限定词06 00是传送原因0x0006 表示激活01 00是公共地址最后01 00 00 00部分包含 3 字节信息体地址和 1 字节信息体元素。这个结构贯穿所有 104 报文解析器的骨架就是按“启动符 → 长度 → 控制域 → ASDU 头部 → 信息体”逐层剥开。解码器设计的关键是绝不能假设一次 TCP read 正好拿到完整一帧。TCP 是流协议可能一次读进来半帧也可能连着三四个帧粘在一起。所以解析入口必须先做“按长度截帧”再进入真正的字段解析。3.2 接收入口粘包半包与长度校验用 Netty 写 104 解码器最常见落法是继承 ByteToMessageDecoder在 decode 里手工处理长度。这里最容易被忽略的是读取长度后要判断缓冲区里剩余字节够不够不够就得等下一次数据到达时再继续处理。代码实现如下public class Iec104Decoder extends ByteToMessageDecoder { Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, ListObject out) throws Exception { if (in.readableBytes() 2) { return; // 连长度字节都没凑齐直接等下一包 } in.markReaderIndex(); int start in.readUnsignedByte(); if (start ! 0x68) { in.resetReaderIndex(); in.readByte(); // 丢一个字节重新找帧头 return; } int len in.readUnsignedByte(); if (len 4) { in.resetReaderIndex(); in.readByte(); return; // 104帧长度最小是4纯控制域帧小于4是脏数据 } if (in.readableBytes() len) { in.resetReaderIndex(); // 半包回退读指针等下一个TCP包 return; } byte[] frame new byte[len 2]; in.resetReaderIndex(); in.readBytes(frame); out.add(frame); } }这段逻辑里有两个参数值得细看。第一是“长度最小为 4”104 里最短的合法帧是纯控制域的 S 帧或 U 帧长度为 4 字节如果长度字段小于 4说明这一帧不可能是规约报文直接丢弃重新同步。第二是in.resetReaderIndex()处理半包因为已经读了两个字节不重置直接返回下次解码就从半帧的中间开始了帧头永远找不回来。这是所有流式解码器最容易翻车的地方。3.3 控制域与ASDU头部判断帧类型是关键分支拿到完整 frame 数组后第一步看控制域第一个字节的最低两位决定这是 I 帧、S 帧还是 U 帧。I 帧承载应用数据S 帧是接收确认帧U 帧是链路控制帧比如测试链路、启动/停止数据传输。控制域的解析代码public class Iec104Frame { public int frameType; // 0I帧, 1S帧, 2U帧 public int sendSeq; // I帧发送序号 public int recvSeq; // I帧/S帧接收序号 public int uCommand; // U帧命令 public byte[] asdu; // ASDU区域 public static Iec104Frame parse(byte[] frame) { Iec104Frame f new Iec104Frame(); int b0 frame[2] 0xFF; if ((b0 0x01) 0) { f.frameType 0; // I帧 f.sendSeq (frame[2] 0xFF) | ((frame[3] 0xFF) 8); f.recvSeq (frame[4] 0xFF) | ((frame[5] 0xFF) 8); f.asdu Arrays.copyOfRange(frame, 6, frame.length); } else if ((b0 0x03) 0x01) { f.frameType 1; // S帧 f.recvSeq (frame[4] 0xFF) | ((frame[5] 0xFF) 8); } else { f.frameType 2; // U帧 f.uCommand b0; } return f; } }I 帧的发送序号和接收序号都是两字节小端各自只用到低 15 位超过 32767 后从 0 重新轮回所以工程上用 int 接收、按位与 0x7FFF 处理序号循环更稳妥。注意 S 帧里没有 ASDU接收方唯一要做的就是把接收序号记录到链路状态里用来组装自己的回执。U 帧常见值是 0x43TESTFR 激活、0x83TESTFR 确认、0x07STARTDT 激活、0x0BSTOPDT 激活这些都只占 4 字节控制域没有 ASDU。3.4 遥信Type1和遥测Type9/13信息体里装着真数据I 帧的 ASDU 区域才是有业务价值的部分首先拆头部六个字节int typeId asdu[0] 0xFF; int vsq asdu[1] 0xFF; int cot (asdu[2] 0xFF) | ((asdu[3] 0xFF) 8); int ca (asdu[4] 0xFF) | ((asdu[5] 0xFF) 8); int num vsq 0x7F; // 低7位是对象个数最高位表示是否连续寻址 int ioaSize (vsq 0x80) 0x80 ? 3 : ???;这里有个很容易错的概念可变结构限定词的低 7 位表示信息体数量最高位表示后面信息体地址的排列方式。常见实现是无论连续还是不连续信息体地址都是 3 字节。量测值这种多对象场景如果 SQ1信息体地址只出现一次后面的对象地址依次加一如果 SQ0则每个对象都带完整的信息体地址。举两个最常解析的类型。单点遥信 Type 1 的信息体结构是“3 字节信息体地址 1 字节 SIQ”SIQ 的 bit0 是当前状态0 分 1 合bit4 是无效位IV。解析时务必要先提状态再提品质for (int i 0; i num; i) { int offset 6 i * 4; int ioa (asdu[offset] 0xFF) | ((asdu[offset 1] 0xFF) 8) | ((asdu[offset 2] 0xFF) 16); int siq asdu[offset 3] 0xFF; int status siq 0x01; boolean invalid (siq 0x10) ! 0; // 输出一个遥信变位对象点号ioa状态status有效位!invalid }归一化遥测 Type 9 的信息体是“3 字节地址 2 字节数值 1 字节品质”。数值是有符号 short要转成 int 再乘以点表系数才是工程值品质字节 QDS 的 bit0 是溢出位bit4 是无效位。短浮点遥测 Type 13 则把数值换成 4 字节 IEEE 754 单精度浮点。这里最常见的坑是字节序104 里短浮点也是小端存储不能直接 ByteBuffer.getFloat() 按大端读必须先反转字节再转 float或者用Float.intBitsToFloat((b0 0xFF) | ((b1 0xFF) 8) | ...)。一个遥测值差个几万多半就栽在字节序上。4. 组装方向把下行命令编码成规约字节流4.1 总召唤与时钟同步两个最常用的主站命令解析负责把报文变成业务数据组装负责把业务意图变成报文。主站侧最常用的就是总召唤和时钟同步。总召唤是调度员点一下“召测”后主站下发的第一条命令它要求子站把所有遥信、遥测当前值上送一遍。组装代码并不复杂难在对长度的精确控制。下面是总召唤命令的完整编码public static byte[] encodeTotalCall(int commonAddress, int sendSeq, int recvSeq) { ByteBuffer buf ByteBuffer.allocate(15); buf.put((byte) 0x68); buf.put((byte) 13); // len 控制域4 ASDU9 buf.put((byte) (sendSeq 0xFF)); // 发送序号低字节 buf.put((byte) ((sendSeq 8) 0xFF)); buf.put((byte) (recvSeq 0xFF)); // 接收序号低字节 buf.put((byte) ((recvSeq 8) 0xFF)); buf.put((byte) 100); // 类型标识100总召唤 buf.put((byte) 0x01); // VSQ1个对象 buf.put((byte) 0x06); // COT6激活 buf.put((byte) 0x00); buf.put((byte) (commonAddress 0xFF)); buf.put((byte) ((commonAddress 8) 0xFF)); buf.put((byte) 0x00); // IOA0总召唤的信息体地址固定为0 buf.put((byte) 0x00); buf.put((byte) 0x00); return buf.array(); }总召唤 ASDU 没有信息体元素只有头部九个字节所以整帧长度是 4控制域 9ASDU 13长度字段填 0x0D。这个数字一旦填错子站直接把整帧丢弃现场表现就是“召唤发出去了子站一点反应都没有”。发送序号和接收序号必须由链路上一个状态机维护不能每次发命令都填 0序号不连续主站侧可能直接断链。时钟同步对时稍微长一点。类型标识 103传送原因 6激活信息体地址 0信息体元素是 7 字节的二进制时间2 字节毫秒小端1 字节分1 字节时1 字节日1 字节月1 字节年后 7 位是年份2000 年偏移。注意毫秒的范围是 0~59999超过就给秒进位很多实现在闰秒或毫秒进位处理上出过问题。4.2 遥控命令Type45选择与执行两段式遥控是所有下行命令里跟安全关系最密切的所以规约规定了一控一确认先发“选择”Select确认对象正确再发“执行”Execute真正动作。Type 45 是单点遥控ASDU 信息体是“3 字节信息体地址 1 字节 DCO”。DCO 低两位 QU 控制选择还是执行00 选择、01 执行bit6S/E 位在有些实现里也参与区分为了兼容性工程上一般直接按 QU 处理。组装遥控命令public static byte[] encodeRemoteCommand(int commonAddress, int pointAddr, boolean select, boolean on, int sendSeq, int recvSeq) { ByteBuffer buf ByteBuffer.allocate(16); buf.put((byte) 0x68); buf.put((byte) 14); // len 4控制域 10ASDU buf.put((byte) (sendSeq 0xFF)); buf.put((byte) ((sendSeq 8) 0xFF)); buf.put((byte) (recvSeq 0xFF)); buf.put((byte) ((recvSeq 8) 0xFF)); buf.put((byte) 45); // 类型标识45单点遥控 buf.put((byte) 0x01); // VSQ1个对象 buf.put((byte) 0x06); // COT6激活 buf.put((byte) 0x00); buf.put((byte) (commonAddress 0xFF)); buf.put((byte) ((commonAddress 8) 0xFF)); buf.put((byte) (pointAddr 0xFF)); buf.put((byte) ((pointAddr 8) 0xFF)); buf.put((byte) ((pointAddr 16) 0xFF)); byte dco (byte) (select ? 0x00 : 0x01); // 00选择, 01执行 dco | (byte) (on ? 0x80 : 0x00); // 高位表示合/分工程常见 buf.put(dco); return buf.array(); }这里有一个参数值得单独提醒DCO 的 bit7 在标准文本里定义为“合/分”控制输出状态0 分 1 合但不同厂家对 QU 位的定义有差异。我一般做法是把“选择/执行”和“合/分”拆成两个独立字段组装时按配置文件决定是否置位 bit7而不是写死在代码里。遥控命令发出去后子站会回一个类型 45 的激活确认帧调度端要确认这个返回后才能在界面上提示“遥控成功”不能只看到 TCP 层 ACK 就当成功。4.3 S帧与U帧链路保活与序号回执下行命令里还有两类不带 ASDU 的纯控制帧。S 帧是接收确认它只携带接收序号用来告诉对端“你发的 I 帧我已经收到哪一帧了”。如果主站连续发了一串 I 帧每发一帧都等对方 S 帧链路会很慢实际是连续发收到 S 帧后才知道对方接收进度。S 帧组装代码很短public static byte[] encodeSFrame(int recvSeq) { return new byte[]{ 0x68, 0x04, 0x01, 0x00, // S帧标志 (byte) (recvSeq 0xFF), (byte) ((recvSeq 8) 0xFF) }; }U 帧用于链路控制比如周期性的测试链路 TESTFR。这类帧与序号无关控制域四个字节固定。主站和子站之间如果长时间没有数据业务链路会自动发 TESTFR 激活帧对端回 TESTFR 确认帧才算链路活着。U 帧拼装时要注意整帧就是 6 个字节长度字段永远填 4别画蛇添足补 ASDU。很多联调问题都是在这里心跳只发不收或者收到 TESTFR 不回确认结果主站侧链路状态被判定为中断随后把 TCP 连接断开。5. 101规约实现与常见问题排查从串口到FT1.2帧5.1 串口参数与FT1.2帧格式101规约 从应用层看跟 104 很亲但从链路层看完全是另一套思路它是为串口设计的数据是一个字节一个字节来的没有 TCP 帮你分帧每一帧必须有明确的帧边界和校验。FT1.2 帧格式分固定帧长和可变帧长两种。固定帧长用于链路层的控制应答共 6 个字节0x68 4 个字节内容 0x16结束符可变帧长才承载 ASDU格式是0x68 L L 控制域 地址(1字节) ASDU CRC_LO CRC_HI 0x16注意长度 L 重复出现两次内容是“控制域 地址 ASDU 长度”CRC 计算范围从控制域开始到 ASDU 结束。101 常见串口参数是波特率 9600 或 19200、8 数据位、1 停止位、偶校验但每个项目不一定一样联调前第一件事是核对现场的参数表而不是照抄上一个项目的配置。5.2 101的CRC-16与ASDU复用前面讲 104 时提到 ASDU 可以复用101 的 ASDU 和 104 的 ASDU 只在公共地址宽度上有差异101 里公共地址可以是 1 字节也可以是 2 字节取决于当地规约规定解析时不能写死。帧级代码主要工作量在 CRC-16 上FT1.2 用的生成多项式是 x16 x15 x2 1实现如下public static int crc16(byte[] data, int start, int end) { int crc 0xFFFF; for (int i start; i end; i) { crc ^ (data[i] 0xFF); for (int j 0; j 8; j) { if ((crc 0x0001) ! 0) { crc (crc 1) ^ 0xA001; } else { crc 1; } } } return crc 0xFFFF; }逻辑说明CRC 初始值 0xFFFF每字节先异或再逐位右移多项式 0xA001 是 0x8005 的反转形式。算完后低位字节在前、高位字节在后填入帧尾。很多 101 解析翻车不是因为 ASDU 结构看不懂而是 CRC 算法里的初始值或轮转方向不对导致明明报文是对的却永远报 CRC 错。遇到这种情况拿一帧已知正确的报文把 CRC 算一遍比对一下现场设备打印的帧尾字节立刻能定位是加减顺序的问题还是多项式方向的问题。101 的控制域不像 104 分 I/S/U而是用几个 bit 表示方向、是否启动、需要确认等状态。常见值里0x53 表示“带数据的启动帧”0x13 表示“带数据的确认帧”0x43 表示“不带数据的启动帧”调试时可以先把控制域打出来看它是主站召唤、从站数据还是链路测试链路状态一目了然。5.3 现场最容易踩的四个坑坑一串口参数不对报文全是花帧。现象是串口收到的字节流里 0x68 都找不到更别说解析出 ASDU。原因几乎都是波特率或校验位配置跟现场设备不一致上位机设 9600 偶校验设备端实际是 19200 无校验两边都在“正常收发”但内容全是错的。解决方法是不要急着看协议先把串口参数当面和设备铭牌或后台参数表核对一遍再用串口调试助手抓原始字节流确认能收到 0x68 开头的数据再进规约逻辑。坑二TCP 一次收到多帧解析错位。现象是 104 连接建立后前几分钟一切正常数据多起来后突然开始连续丢帧。原因是一帧 104 报文包含的 ASDU 可能只有几十个字节一次网络上送的 TCP 段可能拼了六七帧解码器如果读完一帧后没有正确“消费”缓冲区下一帧就会从残段的中间开始读帧头判定失败后越丢越多。解决方式就是 3.2 里写的按长度循环截帧每读一帧指针精确前进 len2绝不靠“找 0x68”来分帧。坑三掉线之后不重启链路状态不同步。现象是主站和子站网络中断后又恢复TCP 连接重连成功了但双方还是不发数据。原因是在 104 里 TCP 连接建立不等于数据传输被激活双方要重新走 STARTDT 激活流程U 帧里发 0x07收 0x0B 确认后才能传 I 帧。解决方法是把“TCP 建立 → STARTDT → 应用层总召唤”做成一个重启状态机TCP 断开后全部状态清空绝不允许连接重连后沿用旧的发送序号。坑四公共地址宽度不一致整帧错位解析。现象是同一个工具在 A 站好用换到 B 站后遥信全乱、遥测全乱但抓包报文看着没问题。原因是 101 的地址字段在规范里允许按 1 字节或 2 字节配置有的厂家默认 1 字节有的默认 2 字节104 虽然后来统一按 2 字节处理但也存在老设备按 1 字节实现的情况。解决方法是把公共地址宽度做成配置项addrSize1/2解析时按配置推进偏移量不要藏在代码里面改常量。6. 怎么验证解析与组装是对的回环自测和抓包对照6.1 回环自测把组装结果喂给解析器工具写完不能只靠现场联调来验证对错。最省事的验证方法是回环测试用组装函数生成一帧报文再把这帧报文喂给解析函数断言解析结果和原始结构一致。这个方法能同时验证编码和解码两侧的字节序、长度字段、类型标识有没有写反。Test public void testTotalCallRoundTrip() { byte[] frame Iec104Encoder.encodeTotalCall(1, 2, 3); Iec104Frame parsed Iec104Frame.parse(frame); assertEquals(100, parsed.asdu[0] 0xFF); // 类型标识仍是100 assertEquals(13, frame[1] 0xFF); // 长度字段应等于ASDU4 }注意回环测试通过只能说明“自己编的自己能解”不能说明跟真实厂站设备互认。真正要过的是抓包对照用 Wireshark 挂着 TCP 2404 端口把组装的报文发出去在抓包里找对应的帧展开所有字段跟标准文本逐项比对。经验是标准里对不上的地方优先怀疑自己的字节序再怀疑长度字段最后才怀疑类型定义。6.2 模拟子站与真实链路压测更贴近现场的验证是搭一个模拟子站起一个 ServerSocket 监听 2404收到总召唤后按照点表把一个假遥信、一个假遥测自动回给主站。这样不用真去变电站拉网线就能把主站侧从连接建立、STARTDT 激活到总召唤应答的全流程跑通。如果这个模拟过程中出现“主站收到了数据但点位不显示”问题多半不在规约而在点表映射信息体地址没对上、公共地址填错、品质字节里无效位没剔除。最后留个习惯每改一次字节序或序号处理逻辑都要把旧的抓包文件保留下来改完跑一遍回环、抓一次包对比不要只靠眼睛盯着控制台十六进制输出。这套工具真正帮我省时间的地方不是把规约文档读懂而是把“调试时反复手算长度字段”这种劳务活机器化。我踩过的最大一坑是写 104 时漏了 S 帧回执导致主站侧正常收数据但总在几分钟后被断开——当时排查半天还以为是防火墙在作怪回头看就是序号没回。希望这些经验和代码能帮你在联调路上少走几段弯路。本文还有配套的精品资源点击获取