
简介面向电力系统自动化与计算机相关专业的毕设、课设学生及开发人员这份Java项目实现电网101规约DLT634.5101-2002与104规约DLT634.5104-2009报文的解析与组装涵盖帧格式识别、报文编码、解码及链路处理等核心模块可用于电力调度与远动通信等实际场景。压缩包共64个文件约147KB主体为54个Java源文件和4个XML工程配置另含2个Markdown说明文档、2个规约解析细则Excel表格及2个文本说明便于对照规约字段与运行说明。项目代码完整、结构清晰难度适中符合“易上手”的优质项目定位尤其适合需要完成毕设或课设的在校学生。资源目前已有81人学习下载读者可直接运行演示也可围绕规约细节做二次开发借此深入理解101/104规约的帧结构、报文编码与解码流程。1. 电网规约解析工具困住你的不是协议而是“流”电力调度自动化的终端接入领域人手一份基于Java语言开发的101规约和104规约DLT634.5104-2009解析与组装工具包本该是拿来即用的加速器。可真上手后你会发现规约本身只有几十页难的是把TCP字节流按时序切干净、把串口比特流按帧对齐再把ASDU里的位域塞进Java对象。这套工具解决的就是“解析入站、组装出站”两件事覆盖遥信、遥测和遥控适合做调度主站前置机、配电站终端协议栈的人。这里把原理和代码路径拆开让你拿到类似的工具包时不至于黑匣子式调参也能照着思路重写一版自己的。2. 101和104的关系一个面向串口一个面向TCP2.1 DLT634.5104-2009里的APDU结构为什么是“一条流对应多种帧”104规约的全称是DL/T 634.5104-2009它把101规约的链路层搬到TCP/IP上保留下“APCI ASDU”的分层结构。APCI固定6字节起始字符0x68、APDU长度、四个控制域字节。很多人一上手只盯着类型标识却忽略了控制域决定了当前是I格式、S格式还是U格式。I帧用来带序号的数据传输比如遥信变位、遥测越限S帧不带ASDU只做确认U帧用于链路启动、停止和测试。所以每条TCP连接的消息不是“有ASDU就是遥信”还要先看控制域的最低两位。我一般会把控制域拆成两个计数器发送序号Ns和接收确认号Nr一个实例维护一份否则遥信上下行混在一个解析器里很容易串。抓一条实物报文立即能对应上。比如十六进制68 0E 00 00 00 00 01 03 03 01 00 00 00 010x68是起始字符0x0E表示APDU总长度是14字节接着00 00 00 00是控制域最低位为0说明是I帧且Ns0、Nr0从第7字节开始是ASDU。类型标识0x01是单点遥信第二个0x03是可变结构限定词表示此时只有1个信息体第三个0x03是传送原因“突发”公共地址0x01信息体地址00 00 00最后的0x01就是遥信值。这个索引顺序就是解析代码里最重要的“位移地图”。2.2 101规约同样的ASDU不同的链路壳与方向状态101规约是串口时代的链路层常见三种帧形态单字符E5用于链路确认固定长度帧用于查询链路状态可变长度帧用于传输数据。链路层控制域不再是Ns/Nr这种计数而是PRM、FCB、DFC、ACD加一个功能码。主站发出的帧PRM1从站应答PRM0非平衡模式下主站必须一问一答从站不能主动上报平衡模式两边才能互发。这直接决定了解析器必须维护“当前链路方向是主站还是从站”的状态否则功能码根本没法解读。Java要同时处理101和104我建议建两套独立的帧解析器而不是合并成一个超级大工具类。ASDU部分可以抽成公共层但链路层差异会把状态机、超时策略、确认逻辑全部带偏。用面向对象编程Java的思路就是把“帧壳”和“业务数据”两个层次分开基站侧实现Encoder/Decoder接口业务侧只处理ASDU。工具包里如果只有一个类硬撑两种协议后面补链路复位和召唤时序时维护成本会成倍上涨。3. 用Java解析104规约从TCP粘包到ASDU对象3.1 基于Netty的APDU拆包器解决“半包和粘包”这个老毛病104规约最常见的翻车点就是TCP粘包。APDU长度字段在第2字节表示从控制域第一个字节到ASDU结束的总长度。所以完整APDU帧长 2字节起始长度 apduLen。拆包规则很清楚先读一字节必须等于0x68再读长度然后等满apduLen 2字节。但Netty里的ByteToMessageDecoder有个陷阱如果数据不够必须恢复读索引否则下一包数据到来时已经丢了一个字节。import io.netty.buffer.ByteBuf; import io.netty.channel.ChannelHandlerContext; import io.netty.handler.codec.ByteToMessageDecoder; import java.util.List; public class ApduDecoder extends ByteToMessageDecoder { Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, ListObject out) { // 至少要有“起始字符长度”两个字节 if (in.readableBytes() 2) { return; } in.markReaderIndex(); if (in.readByte() ! (byte) 0x68) { // 简单实现直接丢弃错位字节真实场景应向前搜索0x68 return; } int apduLen in.readUnsignedByte(); int frameLen apduLen 2; if (in.readableBytes() frameLen - 2) { // 半包数据还没到齐恢复读索引等下一段 in.resetReaderIndex(); return; } // 把完整的APDU数据交给下一个handler ByteBuf apdu in.readRetainedSlice(frameLen - 2); out.add(apdu); } }这个拆包器把链路字节流切成了多条完整APDU。最关键的参数是readUnsignedByteJava里byte是带符号的0x9C这样的值会变成负数如果直接转成int去算长度后面的切片全部错位所以必须有 0xFF或readUnsignedByte操作。3.2 ASDU映射成Java对象四个必须无符号化的字段ASDU头部包含类型标识、可变结构限定词、传送原因、公共地址、信息体地址。这些字段有些是1字节有些是2字节而且低字节在前。网上找的很多样例代码都把公共地址当成1字节写死遇到地市级大地址马上翻车。下面是单点遥信的典型解码片段。public class SinglePointInfo { public static final int TYPE_SINGLE_POINT 1; public void decode(byte[] asdu) { if ((asdu[0] 0xFF) ! TYPE_SINGLE_POINT) { throw new IllegalArgumentException(not single point info); } int vsq asdu[1] 0xFF; int numObjects vsq 0x7F; int isContinuous (vsq 7) 0x01; int causeOfTransfer asdu[2] 0x3F; boolean hasPac ((asdu[2] 0x80) ! 0); int commonAddress (asdu[3] 0xFF) | ((asdu[4] 0xFF) 8); // 信息体地址占用3字节低字节在前 int infoAddr (asdu[5] 0xFF) | ((asdu[6] 0xFF) 8) | ((asdu[7] 0xFF) 16); // 单点遥信的数据在信息体元素最低位 int value asdu[8] 0x01; System.out.printf(addr%d value%d cause%d%n, infoAddr, value, causeOfTransfer); } }注意这里的索引是基于“公共地址2字节、传送原因1字节”写的。实际协议里传送原因也可能是2字节早期设备甚至用1字节公共地址。我把ASDU解码都改成带“偏移游标”的写法每读一个字段就移动游标这样接口对接时遇到厂商定义差异只需要调整游标偏移不用重写整个类。这也是Java基础里“面向对象封装”的典型场景把变化点封进解析器而不是散落在main里。3.3 组装一条遥信变位I帧从对象到网线字节组装报文是解析的逆过程但序号处理更考验基本功。I帧的12位发送序号被拆到控制域第1字节和第2字节接收确认号Nr则横跨第2字节和第3字节。正确的位运算是这样。import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import io.netty.channel.Channel; public class Iec104Assembler { public ByteBuf buildSinglePointChange(int ns, int nr, int cause, int commonAddr, int infoAddr, int value) { // 创建一个ASDU足够大的缓冲区 ByteBuf asdu Unpooled.buffer(10); asdu.writeByte(1); // 类型标识单点遥信 asdu.writeByte(0x01); // VSQ: 一个对象 asdu.writeByte(cause 0x3F); // 传送原因 asdu.writeByte(commonAddr 0xFF); asdu.writeByte((commonAddr 8) 0xFF); asdu.writeByte(infoAddr 0xFF); asdu.writeByte((infoAddr 8) 0xFF); asdu.writeByte((infoAddr 16) 0xFF); asdu.writeByte(value 0x01); ByteBuf apdu Unpooled.buffer(asdu.readableBytes() 10); apdu.writeByte(0x68); apdu.writeByte(asdu.readableBytes() 4); // 控制域4字节 // I帧控制域编码 int ctrl1 ((ns 0x7F) 1); // Ns低7位左移1位 int ctrl2 ((ns 7) 0x0F) // Ns高位4位 | ((nr 0x0F) 4); // Nr低4位 int ctrl3 (nr 4) 0xFF; // Nr高8位 apdu.writeByte(ctrl1); apdu.writeByte(ctrl2); apdu.writeByte(ctrl3); apdu.writeByte(0); apdu.writeBytes(asdu); asdu.release(); return apdu; } }这里最容易抄错的是ctrl2低4位是Ns的高位高4位是Nr的低位中间没有空洞。很多现成代码把nr 0xFF直接塞进去导致主站一收到确认位错乱的帧就回S帧要求重发。组装完一定要用抓包工具比对别靠肉眼看十六进制。4. Java实现101规约链路层串口状态机与校验4.1 用串口读可变帧跳过垃圾字节识别0x68起始101规约走串口时没有TCP的Length字段帧与帧之间可能有随机的空闲间隔也可能因为干扰出现错位。所以串口读取必须写成状态机平时空闲遇到0x68才开始收纳收到0x16当作结束标志。这里用Java的jSerialComm举例核心是pushByte方法。import java.util.ArrayList; import java.util.List; public class Iec101Decoder { private final ListByte buffer new ArrayList(); private boolean started false; private int lastLength -1; public void pushByte(int b, Listbyte[] frames) { int unsigned b 0xFF; if (!started) { if (unsigned 0x68) { buffer.clear(); buffer.add((byte) unsigned); started true; } return; } buffer.add((byte) unsigned); // 第二个字节和第三个字节是长度L两者应该一致 if (buffer.size() 2) { lastLength unsigned; } else if (buffer.size() 3) { if (unsigned ! lastLength) { // 长度对不上说明前面的0x68可能是数据里的误触发 started false; buffer.clear(); } } // 到了结束字符0x16并且已经收够一个完整帧的最小长度 if (unsigned 0x16 buffer.size() 6) { byte[] frame new byte[buffer.size()]; for (int i 0; i frame.length; i) { frame[i] buffer.get(i); } frames.add(frame); started false; buffer.clear(); } } }这段代码不依赖具体长度公式靠起始符和结束符圈定帧边界之后再在校验阶段判别要不要丢弃适合应对串口线上的脏比特。如果收到的数据里碰巧有0x68长度校验会把它拦下来。现在很多终端设备把101封装成TCP透明传输底层字节流不变这个状态机可以直接复用只需要把来源从串口流改成网络流。4.2 组装101可变帧控制域功能码和校验位101从站上送遥信变位时要组装一条可变帧。控制域里PRM位从站固定为0ACD表示还有数据DFC表示接收忙功能码0x0A表示“响应召唤”。下面是组装可变帧的基础方法校验和用最常用的字节异或算法正式项目要以具体厂商的规约附录为准。import java.nio.ByteBuffer; public class Iec101FrameBuilder { public static byte[] buildDataFrame(int linkAddr, byte[] asdu, boolean acd, boolean dfc, int funcCode) { // 帧体控制域 链路地址2字节 ASDU int control 0x00; if (!acd !dfc) { control funcCode 0x0F; } else { control funcCode 0x0F; if (acd) control | 0x20; if (dfc) control | 0x10; } // 从站到主站 PRM0所以不需要PRM位 ByteBuffer body ByteBuffer.allocate(1 2 asdu.length); body.put((byte) control); body.put((byte) (linkAddr 0xFF)); body.put((byte) ((linkAddr 8) 0xFF)); body.put(asdu); byte[] bodyArr body.array(); // 校验和帧体字节逐个异或 byte cs 0; for (byte b : bodyArr) { cs ^ b; } // 完整帧 68 L L 帧体 CS 16 ByteBuffer frame ByteBuffer.allocate(1 2 bodyArr.length 2); frame.put((byte) 0x68); frame.put((byte) bodyArr.length); frame.put((byte) bodyArr.length); frame.put(bodyArr); frame.put(cs); frame.put((byte) 0x16); return frame.array(); } }这里面最坑的是链路地址长度。有的地区用1字节地址有的用2字节控制域后面如果按错位解析整个ASDU全错。我一般把地址长度做成构造参数传进来不要在方法里写死。校验算法也一样异或和CRC-16两种都留接口现场对接时用一个工厂方法返回不同校验器比改公共方法安全得多。5. 规约对接避坑从调坏5个厂站设备总结出的经验5.1 现象104链路一启动就反复“总召超时”现象主站一直发U帧STARTDT激活从站也回了STARTDT确认但总召唤命令发出后主站迟迟收不到从站的“总召唤确认”超时后链路反复断开重连。原因总召唤的ASDU类型标识一般是100总召唤但很多从站要求召唤命令里的信息体地址必须是0并且传送原因必须是6激活。如果组装工具把信息体地址写成了某个具体遥信地址或传送原因填成了突发3从站会直接把报文丢弃。解决用抓包工具比对标准样例确认召唤命令的低六位传送原因为6信息体地址为0。另外总召唤命令要带I帧发送序号且发送后必须缓存这条报文直到收到从站以相同序号回应的确认或超时重发。5.2 现象ASDU公共地址总解析成负数或遥信地址错位现象解析遥信时输出公共地址为-510之类的大负数信息体地址也经常忽大忽小。原因Java byte是有符号的。公共地址如果是0x9C直接赋值给int会变成-100再和左移字节做或运算高字节符号位把整个地址污染了。很多从站地址高位正好超过0x7F所以问题不是偶发而是必然。解决所有字节读进Java后第一件事就是 0xFF。我通常封装一个readU8(byte[] buf, int offset)内部把byte转成int这样ASDU解析阶段看不到任何裸byte。同理int转byte时要用 0xFF截断否则超过127的数会被截成错误位。5.3 现象101串口帧每次卡在最后一个字节收不完现象串口调试工具能看到完整报文但程序里的状态机一直等数据永远到不了结束字符。原因串口流被当成“按字节读一次回调一次”但有些状态机会在缓冲区长度等于理论帧长时提前截断而101可变帧结尾的0x16还没到。更隐蔽的原因是字节丢失串口波特率较高且没有流量控制时驱动缓冲区溢出丢掉结尾字节。解决状态机不要依赖“攒够长度就截断”而是把0x16当作结束触发的必要条件收满最小长度后再判断结尾。同时把串口读取线程的缓冲区改成至少256字节用jSerialComm的readBytes循环读确保没有数据残留在操作系统的串口缓冲里。5.4 现象发送序号和确认号接不上主站狂发S帧现象从站日志里能看到主站不断发S帧确认但自己的应用层一直没有收到新的遥信一旦收到遥信主站又回复“序号不对”断开链路。原因I帧的Ns是12位超过4095必须归零。如果发送方只把序号存在int里没在超过4095时归零或者帧里写入时只写了低8位接收方算出来的预期序号永远是错位的。还有一种情况是发送失败重传时没有保持原序号而是又生成新序号导致接收方窗口滑动错乱。解决封装一个SequenceCounter类每次nextSend()返回当前值后自增并且getAndIncrement()内部对4096取模。重传报文时使用旧序号不重新生成。收到确认帧时用窗口左边界和右边界判断序号是否合法不要无条件更新。6. 让规约工具能抗住无人值守站自检与回环验证规约工具交付不是“能跑通一次就算完”我做现场验收前会加三个自检步骤用来把问题留在机房而不是真站点。第一个是内存录像机把每一条收到的原始APDU按时间戳写进本地滚转日志同时保存解析后的业务值。调设备时不用反复问厂商“你发的什么报文”直接对比原始字节和解析值。第二个是回环测试启动两个进程一个模拟主站一个模拟从站跑1000次遥信变位人为注入乱序、丢失和重复验证序号窗口能自愈。第三个是压力测试用Netty压到每秒2000帧观察有没有堆外内存泄漏以及解码器是否在异常帧后失去同步。public class ApduSanityCheck { public static boolean checkSync(byte[] apdu) { if (apdu.length 6) return false; if ((apdu[0] 0xFF) ! 0x68) return false; int len apdu[1] 0xFF; return apdu.length len 2; } }这个checkSync看起来简单但它能挡住很多“解析器吃过脏数据后吐垃圾对象”的连锁故障。我在接入侧加了一个计数连续出现3次不同步包直接断开TCP连接重新启动链路而不是吞掉错误继续解析。因为104是面向连接的重连代价比处理半个脏状态小得多。这行的血泪经验是协议解析代码最后70%的字数都花在“处理不应该出现的情况”上。常规路径谁都能写真正的价值在于面对半包、乱序、脏字节和序号翻转时工具还能装傻装对。每次把厂商设备搞到挂起我都会先怀疑自己的解析器是不是把校验位吃掉了而不是去怀疑远方设备。希望帮到你。本文还有配套的精品资源点击获取