尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Java实现IEC104主站客户端:微电网多线程Socket通信与遥测解析实战

Java实现IEC104主站客户端:微电网多线程Socket通信与遥测解析实战 简介基于IEC104协议的微电网管理系统主站客户端程序是一份面向电力系统自动化及智能电网学习者的完整Java实现。通过多线程Socket通信完成远程监控与数据采集支持遥信遥测解析、遥控遥调命令下发并在高并发环境下处理海量数据入库适合需要开发主站程序或理解IEC104协议栈的工程师参考。压缩包共107个文件约3.62MB包含30个class编译文件、23个java源码、22个png示意图片以及json配置、jar依赖、properties配置与XML说明等类型覆盖源码、配置、依赖与文档便于直接阅读源码和部署运行。目前已有143人学习下载。除可运行程序外还能通过源码了解ASDU消息组装、遥控遥调命令下发、SQLite/MySQL等高并发数据库写入策略并借助图片资源快速理解模块流程适合作为电力系统通信开发的实践范例。1. 微电网和 IEC104为什么主站客户端比想象中更依赖“通信工程”微电网调试现场最容易遇到的情况是光伏逆变器、储能 PCS、并网柜、环境监测仪各说各话Modbus 轮询一圈要几十秒某个从站一卡整条链路都开始排队。换用基于 IEC104 协议的主站客户端后从站主动上送遥信遥测主站只管收数、解析、落库和下发控制实时性直接从“分钟级”变成“秒级”。这个标题里的项目就是一套 Java 实现的主站客户端多线程 Socket 收集几十个从站解析遥信遥测下发遥控遥调再把高频采样值高并发地写进数据库。适合正要搭微电网或储能 SCADA 主站、想直接复现这套链路的 Java 工程师。本篇按“协议理解→线程模型→报文解析→踩坑→落库验证”的顺序把能直接抄作业的部分全写出来。2. IEC104 协议与主站选型先把四类信息体对象和工程边界立住2.1 微电网里为什么默认翻 IEC104 这张牌微电网管理系统的主站要面对的不只是“读寄存器”这个动作。常规 Modbus 是请求-响应模型主站必须逐个从站、逐个寄存器去问。从站数量一多轮询周期就被拉长碰到通信抖动还需要超时重试整体实时性很难看。IEC104 完全不同它跑在 TCP 长连接上默认端口 2404从站可以主动上送变化数据、突发数据、循环数据。开关变位、保护动作、SOE 时标这些事件不再等主站来问而是从站一有变化就推上来。在微电网场景里分布式电源的出力波动、储能 PCS 的 SOC 和功率、并网柜的断路器状态都是需要秒级刷新的对象。IEC104 的“变化上送 站召唤 时钟同步 选择执行”机制恰好覆盖了这些需求。尤其是遥控断路器、遥调功率设定这类操作IEC104 在主站侧有明确的返校机制比 Modbus 写寄存器要安全得多。标题里明确写了“主站客户端程序”也就是说这套 Java 程序作为 IEC104 主站主动去连接各个从站RTU、远动网关、综保装置。微电网项目里常见做法是每台设备或每个子站通过一台远动网关把内部协议转换成 IEC104主站只管和网关打交道。这样做的好处是主站侧协议统一设备更换不影响主站程序。2.2 遥信、遥测、遥控、遥调四类对象先分清楚再写代码IEC104 的应用层叫 ASDU应用服务数据单元核心就是四类对象。主站客户端所有解析和下发的代码本质都是对这四类对象的处理。先看一个表格把类型标识、含义和典型场景对齐类别类型标识含义常见场景遥信单点1单点状态信息断路器位置、隔离开关状态、告警信号遥信双点3双点状态信息双位置开关用两位组合表达确定/中间/故障态遥测归一化9int16 / 32768老式 RTU 的功率、电压标幺值遥测标度化11int16 × 系数温度、压力等带量纲的测量值遥测短浮点13IEEE754 浮点现代逆变器、PCS 的有功/无功/SOC遥控单点45单点命令分/合闸控制遥控双点46双点命令双位置开关控制遥调归一化设点50归一化设定值老设备有功/无功设定遥调短浮点设点51浮点设定值储能 PCS 功率调度、电压无功目标解析 ASDU 时脑子里要有一条固定流水线逐个字节读入先判断类型标识再读可变结构限定词然后读传送原因、公共地址、信息体地址最后按信息体地址逐条解析数据。主站程序里最容易写错的就是类型标识。这类代码在微电网项目里几乎就是“协议翻译器”。比如光伏逆变器通过网关把实时有功功率用短浮点类型 13上送如果代码里按标度化类型 11解析读出来的数值就会完全不对。这类问题后面避坑章节会专门讲。2.3 为什么用 Java并发生态和工程化能力更适合主站侧标题里给的答案是 Java这在实际工程里也讲得通。主站客户端的核心矛盾是要同时保持几十条 Socket 长连接每条连接都在高频收发报文同时还要把解析出来的数据实时写库。Java 在并发这块有三个现成的东西可以直接用线程池ThreadPoolExecutor、阻塞队列BlockingQueue、并发集合ConcurrentHashMap。这些原语刚好对应“多线程 Socket 通信 高并发数据库操作”两个需求。另一个现实因素是运维。微电网主站大多跑在工控机或内网服务器上Java 打成一个 jar 包配一个启动脚本就能跑Windows 下还能注册成服务比 C 的部署成本低不少。数据落库方面JDBC、MyBatis、HikariCP、时序数据库驱动都有成熟方案。至于为什么不用 Netty我的观点是如果从站数量在几十个这个量级原生 Socket 线程池完全够用代码也更直观出了问题好排查。Netty 的 Reactor 模型确实能扛更高的并发但引入之后调试复杂度明显上升。标题既然写的是“多线程 Socket 通信”那我们就顺着 Java 原生 Socket 的路子把方案做扎实。等以后点位规模上万了再考虑把通信层替换成 Netty业务层不用动。3. 多线程 Socket 通信线程模型与生产者消费者队列的 Java 落地3.1 一个从站一条读线程几十个连接怎么管微电网主站的从站数量通常没有电网调度那种量级几十个已经算多的。这时候最适合的模型是每个从站一条独立 Socket分配一条读线程持续收报文命令下发通过独立的发送队列异步处理。不要试图用一条线程处理所有连接的读操作那样一旦某个连接阻塞整个主站都会被拖住。线程模型可以这样拆主线程启动时读取从站配置表逐个建立 TCP 连接。读线程每连接一条负责 readFully 读完整帧把原始字节交给解析队列。发送线程每连接一条从写队列取命令帧写 Socket。业务处理线程池消费原始帧队列做 ASDU 解析、入库、告警判断。心跳线程全局一个定时任务定期发送 TESTFR 测试帧处理重连。连接的建立要放到独立线程里做不能阻塞主线程。从站网关偶尔会出现监听端口没就绪的情况主站侧要做指数退避重连第一次等 1 秒第二次等 2 秒第三次等 4 秒封顶 30 秒避免一轮重连风暴把主站 CPU 打满。连接类骨架可以这样写public class DeviceConnection implements Runnable { private final String host; private final int port; private Socket socket; private volatile boolean running; private BlockingQueuebyte[] frameQueue; public DeviceConnection(String host, int port, BlockingQueuebyte[] frameQueue) { this.host host; this.port port; this.frameQueue frameQueue; } public void connect() throws IOException { socket new Socket(); socket.connect(new InetSocketAddress(host, port), 3000); socket.setSoTimeout(30000); socket.setKeepAlive(true); } Override public void run() { while (running) { try { byte[] frame Iec104FrameReader.readFrame(socket.getInputStream()); frameQueue.put(frame); // 生产者只负责收帧入队 } catch (IOException e) { running false; // 触发重连逻辑 } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } } }这段代码里有两个关键参数connect超时设 3 秒读超时设 30 秒。读超时时间不能小于心跳周期否则从站正常但超过 30 秒没有数据上送时主站会误判断链。frameQueue是连接层和生产消费者的解耦点。3.2 生产者消费者模式为什么解析和入库不能放在读线程里标题里提到“高并发数据库操作”这是微电网主站最容易翻车的点。如果读线程收到一帧直接同步解析再同步写数据库会出现一个典型问题某台从站的数据量稍微大一点或者数据库连接池刚好忙读线程阻塞在 insert 上Socket 接收缓冲区堆积最终导致 TCP 窗口填满、从站侧发送超时、链路断开。正确做法是把链路拆成三段接收线程只负责“把帧放进队列”业务线程池负责“取帧解析 组织数据点”数据库线程池负责“批量落库”。这个模型就是经典的生产者消费者模式。接收线程是生产者业务线程是消费者队列天然起到削峰填谷的作用。这里用LinkedBlockingQueue就够了注意设置一个有界容量BlockingQueuebyte[] rawFrameQueue new LinkedBlockingQueue(2000); ThreadPoolExecutor parseExecutor new ThreadPoolExecutor( 4, // 核心线程 8, // 最大线程 60, TimeUnit.SECONDS, // 空闲回收 new LinkedBlockingQueue(500), // 任务队列 new ThreadPoolExecutor.CallerRunsPolicy() );队列容量 2000 对应的是“突发流量”的缓冲能力。微电网从站在线路故障时可能瞬间上送大量 SOE 帧队列太小时会直接丢帧或者触发拒绝策略太大则占用内存。2000 帧大约几十 KB在可接受范围。线程池核心线程数按从站数量除以 4 左右设置不必设太大。生产者和消费者的节奏天然不同步从站上送是突发性的数据库写入是持续性的。队列就像一个蓄水池让两端解耦。这是整个主站程序里最值得花时间设计的一层。3.3 TCP 粘包与半包IEC104 帧怎么读才算完整IEC104 虽然跑在 TCP 上但 TCP 是字节流协议没有消息边界。从站连续发送多帧时一次 read 可能读到半帧、一帧半、或者多帧粘在一起。IEC104 的帧结构里有一个天然的长度字段可以解决这个问题第 1 字节0x68启动字符第 2 字节长度表示除启动字符和长度字节本身外后面还有多少个字节后续字节4 字节控制域 ASDU读帧时先读 2 个字节确认 0x68 和长度再按长度读完整的一帧。下面是常用写法public class Iec104FrameReader { public static byte[] readFrame(InputStream in) throws IOException { int start; // 循环找 0x68跳过链路中的脏数据 do { start in.read(); if (start -1) { throw new EOFException(); } } while (start ! 0x68); int len in.read(); if (len 4) { throw new IOException(非法帧长度: len); } byte[] frame new byte[len]; readFully(in, frame); byte[] result new byte[len 2]; result[0] (byte) 0x68; result[1] (byte) len; System.arraycopy(frame, 0, result, 2, len); return result; } private static void readFully(InputStream in, byte[] buffer) throws IOException { int read 0; while (read buffer.length) { int n in.read(buffer, read, buffer.length - read); if (n -1) { throw new EOFException(); } read n; } } }逻辑说明read()读不到 0x68 就不断跳过这对应链路同步丢失后的恢复。应用层收到数据后这段代码保证一定返回一个完整 APDU。长度不足 4 说明控制域都不完整直接视为非法帧。readFully是循环读因为一次read不保证能读满len字节这在 TCP 编程里是基本功。3.4 心跳与链路恢复TESTFR 和超时重连怎么配合IEC104 链路空闲时主站要定期发 TESTFR 测试帧确认链路活着。TESTFR 是一个 U 帧内容固定是68 04 43 00 00 00。从站收到后会回一个确认帧主站不需要做额外解析只需要确认“有数据回来链路正常”。心跳周期和读超时的配合是玄学最多的部分。如果把读超时设成 30 秒但心跳周期也是 30 秒两边刚好在边界上偶发抖动就会误断链。我一般把心跳设为 15 秒读超时 30 秒。这样即使从站没有主动上送数据主站也能在超时前收到 TESTFR 确认链路判定稳定。重连逻辑用一个单独的定时任务管理private void reconnectLoop(DeviceConnection conn) { int delaySeconds 1; while (!conn.isConnected()) { try { Thread.sleep(delaySeconds * 1000L); conn.connect(); conn.start(); break; } catch (Exception e) { delaySeconds Math.min(delaySeconds * 2, 30); } } }指数退避里要注意重连成功后要重置延迟到 1 秒否则下次断链会从 30 秒开始等待。另一个细节是重连时旧连接的读线程要安全退出否则会出现两个线程同时读同一个 Socket 的竞争导致报文错乱。我一般会给每个连接生成一个“代次”编号重连后旧线程检测到代次不匹配自动退出。4. 遥信遥测解析与遥控遥调下发ASDU 这一层的 Java 实现4.1 从一帧字节到业务数据控制域和 ASDU 头怎么拆完整的一帧进来后先拆控制域再拆 ASDU。IEC104 的控制域有 4 个字节用来区分 I 帧编号信息帧、S 帧编号确认帧、U 帧未编号控制帧。主站做数据采集时大部分有效数据都在 I 帧里。解析 ASDU 头部的代码可以这样写public class AsduHeader { public int typeId; public int cot; // 传送原因 public int commonAddr; // 公共地址 public int ioa; // 信息体地址 } public static AsduHeader parseHeader(byte[] asdu) { AsduHeader h new AsduHeader(); h.typeId asdu[0] 0xFF; int vsq asdu[1] 0xFF; h.cot asdu[2] 0xFF; h.commonAddr (asdu[3] 0xFF) | ((asdu[4] 0xFF) 8); // 信息体地址在 ASDU 的第 6 个字节起3 字节小端 h.ioa (asdu[5] 0xFF) | ((asdu[6] 0xFF) 8) | ((asdu[7] 0xFF) 16); return h; }这里注意三个参数约定vsq的高位表示“信息体数目是否连续”低位表示数量cot的 3 表示突发/变化6 表示循环上送20 表示响应站召唤ioa是三字节小端不是大端。这些细节一旦出错解析出来的数值会错得莫名其妙。控制域里还藏着帧序号。I 帧的发送序号和接收序号要严格递增主站侧需要用状态变量维护。如果从站发现序号跳变会直接丢弃帧并要求重发。所以主站程序里不要轻易重发同一帧重发时序号要重新封装。4.2 遥测解析短浮点、标度化、归一化三条路径分开写遥测是微电网主站里最核心的数据路径。光伏功率、储能 SOC、母线电压、频率全是遥测。类型 13 的短浮点在现代设备里最常用按 IEEE754 大端序存储类型 11 是带符号 16 位整数乘系数类型 9 是归一化值除以 32768 得到标幺值。public static float parseTelemetry(byte[] infoElement, int typeId) { switch (typeId) { case 13: // 短浮点4字节大端 int bits ((infoElement[0] 0xFF) 24) | ((infoElement[1] 0xFF) 16) | ((infoElement[2] 0xFF) 8) | (infoElement[3] 0xFF); return Float.intBitsToFloat(bits); case 11: // 标度化2字节 short raw (short) (((infoElement[0] 0xFF) 8) | (infoElement[1] 0xFF)); return raw * scaleFactor; // scaleFactor 从点表配置读取 case 9: // 归一化2字节 short normalized (short) (((infoElement[0] 0xFF) 8) | (infoElement[1] 0xFF)); return normalized / 32768.0f; default: throw new IllegalArgumentException(不支持的遥测类型: typeId); } }参数说明字节转 short 时用了(short)强转避免 Java 符号位扩展把负数变成正数。乘以或除以系数后float 精度对微电网监控足够。解析遥测时还有一个必须处理的字节品质描述符。每个测量值后面跟着一个品质字节bit0 置 1 表示数据无效。微电网里逆变器通讯闪断、网关数据缓冲溢出都可能导致品质位被置位。主站侧要过滤掉无效数据不要直接写库否则历史数据里会混入“负功率”“超量程电压”这类脏值。4.3 遥信解析双点信息的四态组合和时标处理遥信是状态量比遥测简单但双点信息是常见的“隐形坑”。双点遥信用两个 bit 表达四种组合00中间态01确定状态 A通常为“合”10确定状态 B通常为“分”11故障态解析时只认 01 和 1000 和 11 不能直接当作“不知道”就完事而是要按点表的实际含义处理。在微电网并网柜里断路器位置如果长期显示中间态一般意味着辅助触点接触不良需要在界面上给出告警而不是默默忽略。时标用 CP56Time2A 格式占 7 个字节前两个是毫秒小端第三是分钟第四是小时第五是日第六是月第七是年偏移。主站侧解析后要转成LocalDateTime时区统一用系统默认时区不要混用 UTC 和本地时间否则 SOE 事件排序会乱。4.4 遥控与遥调下发先选后执命令帧怎么封遥控下发如果不是“选择-执行”两步走在微电网里属于高危操作。正确流程是先下发选择命令从站返校确认这个对象可以被控制主站收到返校后再下发执行命令。这个机制能在误操作时多一道拦截。构造单点遥控命令的代码public static byte[] buildSingleCommand(int ioa, int value, int commonAddr, boolean select, int sendSeq) { byte[] asdu new byte[12]; asdu[0] 0x2D; // 类型标识 45单点遥控 asdu[1] 0x01; // 信息体数量 1 asdu[2] (byte) 0x06; // 传送原因激活 asdu[3] (byte) (commonAddr 0xFF); asdu[4] (byte) ((commonAddr 8) 0xFF); asdu[5] (byte) (ioa 0xFF); asdu[6] (byte) ((ioa 8) 0xFF); asdu[7] (byte) ((ioa 16) 0xFF); asdu[8] (byte) (value 0x01); // SCS0分1合 asdu[9] select ? (byte) 0x00 : (byte) 0x80; // 限定词S/E asdu[10] 0x00; asdu[11] 0x00; return wrapWithApci(asdu, sendSeq); }关于select ? 0x00 : 0x80这里要做个说明IEC104 的选择/执行限定词在不同厂家设备里有差异有的用高位置 1 表示执行有的要求低位的 QU 同时带脉冲宽度。我在参数表里把这些值做成可配置项联调时先用模拟从站抓帧确认。wrapWithApci方法负责把 ASDU 包上控制域和长度头并维护发送序号。序号要连续从站才会接受。命令下发后主站要启用一个超时定时器比如 5 秒内收不到返校帧就在界面里提示“遥控返校超时”而不是无限等待。5. 避坑微电网 IEC104 主站最常见的 5 个故障排查5.1 现象从站偶尔断链日志里出现 Read timed out原因读超时设得太短。早期我把setSoTimeout设成 1000 毫秒觉得反正从站 200 毫秒就发一帧遥测。结果某个从站的网关在内部轮询子设备时偶尔会出现 2 秒空窗主站直接判断断链重连后状态要重新召唤现场画面闪断。解决读超时和心跳周期拉开差距。心跳 15 秒一发读超时 30 秒。即使从站长时间没数据上送也能靠 TESTFR 确认帧维持链路判断。同时不要因为一次超时立刻断链连续 2 次超时才进入重连流程能过滤掉大部分瞬断。5.2 现象Windows 开发机上模拟从站反复重启报“通常每个套接字地址只允许使用一次”原因这是 Windows 下 TCP TIME_WAIT 的经典问题。模拟从站作为服务端监听 2404频繁重启时旧连接的 TIME_WAIT 状态还没结束新实例绑定同一端口就冲突了。解决模拟从站的ServerSocket开启setReuseAddress(true)并把该选项放到bind之前。还有一种更省事的方式是主站侧改连临时端口不在监听侧纠结。开发环境里这是纯联调问题不影响现场部署但遇到了会卡半天提前写进启动脚本能省很多时间。5.3 现象遥测解析出来的值是“跳变”的乱值或者个别点恒定错误原因八成是类型标识判断错了。现场接过一个项目网关把储能 SOC 按短浮点上送点表里写的也是短浮点但实际报文里用的是标度化。主站这边按浮点解析读出来一会儿 500 多一会儿 -2000 多。翻网关配置才发现“短浮点”只是界面上显示的单位换算底层寄存器还是整数。解决联调时不要把点表当唯一依据。先从从站抓原始报文用 Wireshark 看类型标识字节再和点表对。解析代码里加个保护浮点值的合理范围校验超出范围的丢弃并告警。另外品质描述符里 invalid 位一定要过滤不然通讯中断瞬间的数据一样会进数据库。5.4 现象遥控下发返回成功但从站不动作或者一直提示返校超时原因没有走“选择-执行”流程是最常见的原因直接发执行帧会被从站拒收。还有一种情况是命令的限定词和从站明白的不一致比如从站要求选择帧限定词是 0x01执行帧限定词是 0x81而主站按教科书发的是 0x00 和 0x80。解决先确认从站说明书里“遥控返校”和“命令限定词”两节再把选择/执行的限定词做成配置文件不要写死在代码里。出问题先用模拟从站回环验证主站封帧逻辑再连真实设备。另外常见排查点信息体地址偏移。点表里写的是 1报文里可能是 0逐个点比对后统一加偏移。5.5 现象从站数量一多数据库连接池满Socket 接收线程卡死原因这是架构问题不是参数问题。早期为了省事直接在 Socket 收帧的下一个方法里insert每个数据点一条 SQL。30 个从站、每从站 20 个遥测点、刷新周期 1 秒每秒就是 600 次插入。MySQL 默认配置下连接池很快耗尽接收线程等连接链路超时倒逼重连然后重连后再来一轮整个主站雪崩。解决把“收帧—解析—入库”拆成三段。收帧只放队列解析线程消费队列并组装数据点落库线程把数据点攒成批再写入具体做法下一章讲。这是整个主站里最重要的一次重构改完之后同样的硬件条件吞吐量翻了十倍都不夸张。6. 高并发数据库写入攒批落库与整站验证的落地技巧6.1 攒批写入把几百次 insert 变成一次 batch数据库写入不能一条一条来微电网遥测每秒产生几百上千条记录单条 insert 不仅慢还容易让连接池成为瓶颈。常见做法是消费端攒批解析线程把数据点放到一个待入库队列入库线程取到一定数量或一定时间后批量执行。public void flushLoop() { ListDataPoint buffer new ArrayList(200); while (running) { DataPoint dp dataQueue.poll(200, TimeUnit.MILLISECONDS); if (dp ! null) { buffer.add(dp); } if (buffer.size() batchSize || dp null !buffer.isEmpty()) { dao.batchInsert(buffer); buffer.clear(); } } }参数说明batchSize设置在 100 到 500 之间比较合理太小没效果太大会造成单次事务过长。poll超时 200 毫秒用来兜底保证低流量时段数据也能及时落库。数据库连接池用 HikariCP 的话maximumPoolSize设 10 到 20 就够minimumIdle设 5 左右autoCommit关掉。6.2 整站验证模拟从站和四步压测落地后不要直接拿真实设备联调先用 Java 写一个模拟从站按 50 毫秒周期主动上送遥测每 5 秒变一次遥信。验证按四步走先跑单从站 1 小时确认无断链、无队列积压再把从站数翻倍到目标数量观察 CPU 和内存然后随机杀掉模拟从站进程确认重连和数据连续性最后跑一整晚第二天看历史数据是否有空洞。这套流程走完主站客户端才算真正可用。第一次做这个项目时我把解析和入库放在同一个线程里从站一多直接翻车后来才明白通信程序和业务程序的边界要划得足够干净。希望帮到你。本文还有配套的精品资源点击获取
返回列表