拓冰建站拓冰建站
首页 / 资讯中心 / 正文

基于Java的电网规约101/104解析组装工具:从APDU到报文实战

简介基于Java实现DL/T 634.5101-2002101规约与DL/T 634.5104-2009104规约报文的解析和组装面向电力自动化及计算机专业适合毕设、课设或二次开发。压缩包共64个文件含54个Java源码、XML配置、Markdown说明、xlsx规约细则和txt文档整体147KB目录含pom.xml与iec104-core模块按Maven工程导入即可。已有81人学习下载。资源覆盖报文组装、解析及帧结构分析附规约解析细则和项目说明可快速理解101/104规约通信机制并依据文档运行。对于需要可运行代码支撑课设/毕设的开发者是一份实用且易上手的参考能节省编码时间。1. 基于Java的电网规约101与104解析组装工具一个能直接跑起来的规约包做过变电站远动接入或者电力调度数据网调试的人都有同感DL/T 634.5104-2009也就是常说的104规约是绕不过去的一道坎。面试里它是java八股文固定题现场调试时它又经常以“黑匣子”的姿态出现——明明报文发出去了主站就是不回确认抓包看半天也看不出所以然。这个基于Java语言的电网规约101和104解析组装工具包把这两套最常用的远动规约从帧结构到Java对象的转换完整串了一遍既能解析收到的遥测、遥信、SOE报文也能按规范组装总召唤、遥控命令下发给从站。它适合正在做电网规约课程设计或毕业设计的学生也适合要快速接入调度主站的java工程师拿来当解析底子省去从零啃协议文本的重复劳动。2. 规约报文骨架先把104的APDU、ASDU与控制域读熟写代码前得先把帧结构“背”下来。104规约虽然跑在TCP/IP上但它的报文格式继承自IEC 60870-5-101本质上还是一个字节一个字节拼出来的二进制协议不是XML也不是JSON。如果上来就写解析循环大概率会在控制域和ASDU结构上卡住。2.1 104规约的APDU结构启动符、长度域与控制域104规约的报文单元叫APDU结构固定为启动符、长度域、控制域、ASDU四段。启动符固定是0x68占1字节长度域占1字节表示后面“控制域ASDU”的总字节数控制域占4字节是区分I帧、S帧、U帧的关键ASDU是应用服务数据单元承载具体业务数据。字段字节数说明启动符1固定0x68长度域1后续字节数即控制域4字节 ASDU长度控制域4区分I帧 / S帧 / U帧携带序号ASDU不定类型标识、传送原因、公共地址、信息体要注意的是这个长度域不包含启动符和长度域自身。比如一帧只有控制域没有ASDU像U帧长度域就是0x04。计算帧总长度时要在长度域基础上加2这个细节在粘包拆包时特别容易错。2.2 控制域I帧、S帧、U帧与序号机制控制域4字节的语义完全由第一个字节的低两位决定bit0为0是I帧携带ASDU数据用于上送遥测遥信或下发命令bit0为1且bit1为0是S帧只带接收序号用来确认对端I帧不带ASDUbit0和bit1都为1是U帧负责启动传输、停止传输、测试链路同样不带ASDU。I帧的发送序号N(S)和接收序号N(R)各占15位但它们在控制域里的排布有点反直觉发送序号低7位放在第1字节的bit1到bit7高8位放在第2字节接收序号低7位放在第3字节的bit1到bit7高8位放在第4字节。也就是说每个序号起始位的bit0都被帧类型标志占用了。帧类型控制域第1字节含义I帧bit00发送序号低7位位于bit1~bit7S帧0x01接收序号低7位位于第3字节bit1~bit7U帧0x03/0x07/0x13/0x43启动、停止、测试命令U帧最常用的是0x07STARTDT act和0x0BSTARTDT con分别表示启动传输请求和确认。TCP连接建立后必须先发STARTDT act收到从站返回的STARTDT con才能开始收发I帧。2.3 ASDU结构类型标识与信息体ASDU是业务数据的真正载体。它从第7字节开始第7字节是类型标识第8字节是可变结构限定词第9、10字节是传送原因第11、12字节是公共地址从第13字节开始才是信息体。整个过程里信息体地址始终是3字节、低位在前这个方向千万别记反。类型标识业务含义信息体数据区1单点遥信1字节SIQ3双点遥信1字节DIQ9归一化遥测2字节值 1字节QDS13短浮点遥测4字节float 1字节QDS31带时标单点遥信SIQ 7字节时标45单点遥控1字节SCO100总召唤3字节信息体地址传送原因也很关键0x01是周期上送0x03是突发0x06是激活0x07是激活确认0x0A是终止激活。解析时如果没按COT分类处理会把周期帧和突发帧混在一起导致状态刷新逻辑出问题。2.4 101与104的差异串口帧与网络帧的分野101规约是串口或调制解调器通道上的远动规约物理帧分固定帧长0x10开头和可变帧长0x68开头两类帧内还包含链路地址、帧计数位、功能码这些链路层字段。104规约把它映射到TCP/IP上端口固定是2404链路层职责由TCP承担所以帧里不再有101那种复杂的帧计数位公共地址直接放进ASDU的固定位置。阅读这个工具包的源码时建议先看101的固定帧长和可变帧长解析怎么写的再看104的APDU解析。两者在ASDU层的结构完全一致差别集中在帧头和控制域上。理解了这种同构关系后续不管是做101转104的规约转换还是做104主站接入脑子里的报文模型都不会乱。3. Java解析端落地从TCP字节流到遥测遥信对象的完整链路解析端是整个工具包里最见功力的部分。网上很多demo只教你从“已经得到完整一帧”开始解但真实TCP流里根本没有“完整一帧”这种好事半包、粘包、脏字节才是常态。这一章我把解析链路的四个环节拆开讲缓冲区积攒、控制域识别、ASDU信息体分流、线程模型。3.1 缓冲区设计半包粘包与软超时TCP是字节流协议一次read可能读到半帧也可能读到两帧粘在一起。常见的做法是维护一个累积缓冲区每次收到数据先追加再循环从中提取完整帧。提取的依据就是0x68启动符和长度域。private final ByteArrayOutputStream buf new ByteArrayOutputStream(); public Listbyte[] pollFrames(byte[] incoming) { buf.write(incoming, 0, incoming.length); byte[] data buf.toByteArray(); Listbyte[] frames new ArrayList(); int pos 0; while (data.length - pos 6) { // 找不到启动符逐字节滑动丢弃脏字节 if ((data[pos] 0xFF) ! 0x68) { pos; continue; } int len data[pos 1] 0xFF; // 长度域合法范围控制域4字节 ASDU最多253 if (len 4 || len 253) { pos; continue; } // 缓冲区里的数据还不够一整帧等下一个TCP段 if (data.length - pos len 2) { break; } byte[] frame Arrays.copyOfRange(data, pos, pos len 2); frames.add(frame); pos len 2; } byte[] remain Arrays.copyOfRange(data, pos, data.length); buf.reset(); buf.write(remain, 0, remain.length); return frames; }这段代码的逻辑分三层第一data.length - pos 6保证至少能读启动符和长度域第二len用 0xFF转成无符号数避免长度超过127时被判成负数第三len 2才是帧的总长度因为长度域不包含启动符和长度自身。每次提取完把剩余字节重新写回缓冲区下次数据到达时接着拼。我一般还会给这个缓冲区加一个软超时机制如果缓冲区里有数据但一直凑不齐一帧超过3秒就强制清空重同步。因为这种情况多半是帧头错位继续等下去只会浪费内存。3.2 控制域解析识别帧类型与序号拿到完整帧后第2字节到第5字节是控制域。解析控制域就一个关键点15位序号的位拼接。发送序号低7位藏在第2字节的bit1到bit7里高8位在整个第3字节所以要先把第2字节右移一位再和第3字节左移7位的结果按位或。public ControlMeta parseControl(byte[] apdu) { int b0 apdu[2] 0xFF; if ((b0 0x01) 0) { // I帧 int sendSeq ((b0 1) 0x7F) | ((apdu[3] 0xFF) 7); int recvSeq ((apdu[4] 1) 0x7F) | ((apdu[5] 0xFF) 7); return new ControlMeta(FrameType.I_FRAME, sendSeq, recvSeq); } else if ((b0 0x03) 0x01) { // S帧只有接收序号第2字节固定是0x00 int recvSeq ((apdu[4] 1) 0x7F) | ((apdu[5] 0xFF) 7); return new ControlMeta(FrameType.S_FRAME, -1, recvSeq); } else { // U帧命令码在bit2之后 return new ControlMeta(FrameType.U_FRAME, -1, -1); } }U帧的识别不需要算序号但要根据第2字节的值区分具体命令0x07是启动传输、0x0B是启动确认、0x13是停止传输、0x23是停止确认、0x43是测试帧。解析时建议把这些值做成枚举收到0x43必须回0x83测试确认否则从站会在超时后断开连接。3.3 ASDU信息体解析按类型标识分流业务数据ASDU从帧的第6字节开始也就是控制域之后。先读类型标识、结构限定词、传送原因、公共地址再根据结构限定词决定信息体地址的读取方式。可变结构限定词的低7位表示信息体个数其中0仍然代表1个这是IEC规约里的特殊约定最高位SQ为1时表示信息体地址连续只有第一个信息体带完整3字节地址后面的地址自动加1。public void dispatchAsdu(byte[] asdu) { int typeId asdu[0] 0xFF; int sq (asdu[1] 0x80) 7; int count asdu[1] 0x7F; int cot (asdu[2] 0xFF) | ((asdu[3] 0xFF) 8); int commonAddr (asdu[4] 0xFF) | ((asdu[5] 0xFF) 8); int infoCount count 0 ? 1 : count; int pos 6; int firstAddr 0; boolean hasFirstAddr false; for (int i 0; i infoCount; i) { int infoAddr; if (sq 0 || !hasFirstAddr) { infoAddr (asdu[pos] 0xFF) | ((asdu[pos 1] 0xFF) 8) | ((asdu[pos 2] 0xFF) 16); pos 3; if (!hasFirstAddr) { firstAddr infoAddr; hasFirstAddr true; } } else { infoAddr firstAddr i; } switch (typeId) { case 1: // 单点遥信 int yx asdu[pos] 0x01; pos 1; break; case 3: // 双点遥信两位有效 int yx2 asdu[pos] 0x03; pos 1; break; case 13: // 短浮点遥测4字节float 1字节QDS float yc ByteBuffer.wrap(asdu, pos, 4) .order(ByteOrder.LITTLE_ENDIAN).getFloat(); pos 5; break; case 31: // 带时标单点SOESIQ 7字节时标 int soeYx asdu[pos] 0x01; pos 8; break; default: break; } } }遥测值必须用ByteOrder.LITTLE_ENDIAN读取104规约里所有多字节数值都是低位在前这一点和日常写Java的思维正好相反。短浮点遥测的信息体是“3字节地址4字节float1字节QDS”解析完浮点后记得把QDS也跳过单点遥信只有“3字节地址1字节SIQ”SIQ的低bit是遥信值bit2到bit4是品质位。3.4 线程模型解析与业务分发分离这套工具里还有一个容易忽略但很实用的设计解析不在IO线程里做。TCP接收线程只负责把数据塞进缓冲区并调用pollFrames拿到完整帧后丢给一个固定大小的线程池处理。原因是ASDU解析里涉及Float转换、哈希映射、业务回调如果放在IO线程里一个点表数量很大的站会造成TCP窗口阻塞后面的帧全部堆在系统缓冲区里。private final ExecutorService parserPool Executors.newFixedThreadPool(4); private final BlockingQueueDataEvent eventQueue new ArrayBlockingQueue(1024); void onFrame(byte[] frame) { parserPool.submit(() - { ControlMeta meta parseControl(frame); if (meta.type FrameType.I_FRAME) { byte[] asdu Arrays.copyOfRange(frame, 6, frame.length); dispatchAsdu(asdu); } // 解析结果投递到业务队列 eventQueue.offer(new DataEvent(frame, meta)); }); }线程池大小按通道数量配置我一般一个通道4个线程就够。重点是事件队列要设上限防止从站疯狂上送时内存被打满队列满了之后最合理的策略是丢弃旧事件而不是阻塞解析线程因为电网数据实时性要求高旧点表的处理晚一两个周期问题不大但解析链路卡死会导致TCP连接被对端判定超时断开这是更严重的故障。4. 组装与下发把点表数据编码成DL/T 634.5104-2009报文解析是读组装是写两条路上的坑完全不同。解析时你面对的是已经存在的字节流错了最多解析失败组装时一旦字节序或长度域出错主站直接不理你而且没有错误提示。组装的三个核心点是通用I帧构造、总召唤组织、遥控命令编码。4.1 通用APDU组装序号维护与长度计算组装I帧时最容易被忽略的是发送序号的位拼接。很多人在代码里直接apdu[2] sendSeq 0xFF写出来的报文控制域完全错位。正确做法是把发送序号低7位放进第2字节的bit1到bit7高8位放进第3字节同时保证第2字节bit0为0。private AtomicInteger senderSeq new AtomicInteger(0); private volatile int lastRecvSeq 0; public byte[] buildIFrame(int typeId, int cot, int commonAddr, byte[] infoBody) { ByteArrayOutputStream asdu new ByteArrayOutputStream(); asdu.write(typeId); asdu.write(0x00); // SQ0count0表示1个信息体 asdu.write(cot 0xFF); asdu.write((cot 8) 0xFF); asdu.write(commonAddr 0xFF); asdu.write((commonAddr 8) 0xFF); asdu.write(infoBody, 0, infoBody.length); int sendSeq senderSeq.getAndIncrement() % 32768; ByteArrayOutputStream apdu new ByteArrayOutputStream(); apdu.write(0x68); apdu.write(asdu.size() 4); apdu.write((sendSeq 0x7F) 1); apdu.write((sendSeq 7) 0xFF); apdu.write((lastRecvSeq 0x7F) 1); apdu.write((lastRecvSeq 7) 0xFF); apdu.write(asdu.toByteArray(), 0, asdu.size()); return apdu.toByteArray(); }这段代码里长度域asdu.size() 4是控制域4字节加上ASDU全部字节发送序号先取模32768再拆位保证15位序号翻转后还能自洽接收序号用最近一次从对端收到的I帧序号如果还没收到过I帧就保持0。组装完后建议把这帧传给parseControl自解一遍确认序号拆装没有写反这个小习惯能省下大量抓包时间。4.2 总召唤报文的组织与触发时机总召唤是主站上电或链路恢复后必须做的一件事目的是让从站把全部遥测遥信快照上送一遍。类型标识是1000x64传送原因是6激活信息体地址固定为0x000000ASDU结构限定词0x00表示1个信息体。public byte[] buildTotalCall(int commonAddr) { byte[] infoBody new byte[3]; // 信息体地址 00 00 00 return buildIFrame(100, 6, commonAddr, infoBody); }总召唤的触发时机比报文本身更值得注意链路建立完成并收到STARTDT con之后才发运行期间每3到5分钟周期发一次保证从站重启或通道闪断后数据能自动恢复。总召唤发出后从站会先回一帧COT为7的激活确认然后才开始批量上送所以收到确认后要进入“总召唤收集中”状态等所有点表到齐再更新画面不要逐帧乱刷。4.3 遥控命令组装SCO与传送原因的选择遥控是组装里最容易出事故的环节。单点遥控的类型标识是45信息体是“3字节地址1字节SCO”。SCO的bit0是遥控输出值1表示合、0表示分。下发时机分成预置和执行两步但104规约里这两步都通过COT6激活来区分站端用COT7激活确认来应答。public byte[] buildSingleRemoteCtrl(int commonAddr, int infoAddr, boolean on) { byte[] body new byte[4]; body[0] (byte) (infoAddr 0xFF); body[1] (byte) ((infoAddr 8) 0xFF); body[2] (byte) ((infoAddr 16) 0xFF); body[3] (byte) (on ? 0x01 : 0x00); return buildIFrame(45, 6, commonAddr, body); }真实项目里遥控流程最稳的做法是先发一次COT6的SCO报文当预置收到COT7确认后再发一次相同报文当执行第二次的COT同样是6。有些简化实现的厂家直接一次激活就执行但这种做法在接入第三方主站时容易被退因为规约里遥控是有状态机要求的。另外遥控响应用超时时间是关键参数我一般设5秒超过后要发COT8停止激活终止本次遥控否则操作员界面的遥控按钮会一直卡在“执行中”。5. 避坑指南规约解析与组装里的五个经典翻车现场规约这种东西看协议文本永远觉得“不就那回事”一到现场连调就翻车。这一章写五个我实际遇到过的坑每一个都是当时查了半天才定位的希望你不用再走一遍。5.1 控制域序号取模翻车32767翻转后站端拒收现象连调刚开始一切正常发了上百帧之后从站突然不回S帧确认抓包发现自己的发送序号从0x7E FF翻到了0x00 00站端把后续I帧全部丢弃。原因N(S)是15位序号最大值32767翻转后从站期望的接收序号和你新发的序号差了一圈如果比较逻辑用的是a b而不是模32768的差值窗口校验就通不过。解决序号比较一律用(a - b) % 32768差值为0或正数才允许通过发送序号溢出后回到0继续发但要在日志里打警告方便排查链路长时间运行后的隐性丢包。同时检查重发逻辑从站发S帧带N(R)要求重传时要能从指定序号重发I帧而不是只发最新帧。5.2 半包粘包把长度域读成负数现象运行一段时间后解析器突然一串帧解析失败日志里出现长度异常值比如-91、-45缓冲区越长越大最后OOM。原因字节转int时没做无符号处理。Java的byte是有符号的apdu[1]的值超过127时直接放进int会变成负数后续所有的数组截断和循环条件全部错位。解决所有字节读取统一用 0xFF转无符号长度域还要加合法范围判断小于4大于253的一律丢弃。这条我在pollFrames里已经写进去了但业务侧如果直接拿frame[1]去算剩余长度还会踩同样的坑建议封装一个readUnsignedByte工具方法全局只走这一个入口。5.3 信息体地址低位在前整站点号错位现象遥测值能解析出来也能源源不断上送但画面上遥测1显示的是遥测4的数据遥控也对不上点号像整体位移了几个点。原因104规约的信息体地址是3字节、低位在前按习惯高位在前解析后地址值完全错乱点号自然全偏了。解决解析和组装统一用同一套地址转换逻辑我一般写成两个静态方法readInfoAddr(byte[] buf, int pos)和writeInfoAddr(byte[] buf, int pos, int addr)所有代码只调这两个方法。这样至少能保证解析和组装两个方向上的地址字节序是一致的不会出现下发对、上送错这种最难查的怪问题。5.4 双点遥信当布尔用状态判断反了现象双点遥信类型3在画面上显示“合”但现场隔离开关实际是“分”倒闸操作直接做反。原因双点遥信不是简单的0/1布尔它是两位编码00表示中间状态01表示分10表示合11表示故障。很多代码只取asdu[pos] 0x01把分和合搞反还会把故障态误判成某个固定位置。解决先用 0x03取出低两位再按映射表转成枚举状态。更重要的是品质位SIQ里的IV位为1时这个遥信值是无效的不能参与任何状态判断和逻辑闭锁。处理双点遥信前先判品质是电力自动化里一条铁律。5.5 不先启动传输就直接发总召唤从站不理你现象TCP连接建立后立刻发总召唤等了几分钟没有激活确认。抓包看报文确实发出去了TCP也正常但从站就是不应答。原因104规约的会话不是TCP连接建立就能直接传数据的必须先发U帧STARTDT act从站回STARTDT con后双方才进入传输状态。很多按101思路开发的人会漏掉这个握手。解决在代码里维护一个会话状态机连接建立后进入STARTING状态收到STARTDT con才置为RUNNING所有I帧的发送都强制检查状态。顺带把TESTFR测帧响应也加进去收到0x43测试帧必须回0x83否则从站会因为收不到测试响应主动断开TCP这个在长连接场景里比想象中更容易触发。6. 回环自测用断言确认组装的报文字节级正确拿到这套工具后我做的第一件事不是找模拟主站而是写了一个回环自测把自己组装的报文喂给解析器再断言每个字节是否符合预期。解析器和组装器只要各自有边界错误这种自测一瞬间就能暴露。public void loopbackTest() { byte[] frame buildTotalCall(1); // 断言关键字节 assertEquals(0x68, frame[0] 0xFF); assertEquals(0x0D, frame[1] 0xFF); assertEquals(0x00, frame[2] 0xFF); // I帧发送序号0 assertEquals(0x00, frame[3] 0xFF); assertEquals(0x00, frame[4] 0xFF); // 接收序号0 assertEquals(0x00, frame[5] 0xFF); assertEquals(0x64, frame[6] 0xFF); // 类型标识100 assertEquals(0x06, frame[8] 0xFF); // COT6激活 assertEquals(0x01, frame[10] 0xFF); // 公共地址1 // 再走一遍解析链路 Listbyte[] frames pollFrames(frame); assertEquals(1, frames.size()); ControlMeta meta parseControl(frames.get(0)); assertEquals(FrameType.I_FRAME, meta.type); byte[] asdu Arrays.copyOfRange(frames.get(0), 6, frames.get(0).length); dispatchAsdu(asdu); // 验证解析出的公共地址、类型标识与组装的输入一致 }这段自测里有几个值得注意的断言值长度域0x0D等于控制域4字节加上ASDU的9字节9字节里包含类型标识、结构限定词、2字节COT、2字节公共地址、3字节信息体地址公共地址从ASDU固定偏移第4、5字节读出来这个位置和从帧头数到的偏移很容易混。特别是信息体地址全0这是总召唤报文特有的换成常规遥测点必须写实际点号。从那以后我每次改完解析逻辑都会强制把这一整套回环测试完整跑一遍而且顺手把几种典型报文复制成测试用例放在工程里比如单点遥信、短浮点遥测、带时标SOE、单点遥控各来一条。这套回归用例救过我很多次最玄学的一次是改公共地址高低位时只改了解析侧组装侧还保持旧逻辑回环测试立刻报错。希望我的这些习惯和经验能帮到你少走点弯路。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门