简介面向电力系统自动化与微电网领域的开发、运维及科研人员基于IEC104协议的主站客户端程序提供了一套可落地的远程监控与数据采集实现。程序围绕遥信遥测解析、遥控遥调命令下发两条业务主线展开使用多线程Socket通信机制应对多连接与实时数据流并借助高并发数据库操作保证历史数据写入和状态刷新的及时性采用Java编写具备良好的跨平台与网络编程特性便于在不同环境部署调试。资源包共107个文件压缩包整体约3.62MB文件类型覆盖23个Java源文件与30个class文件、9个JAR依赖包、10个JSON配置、22张PNG截图以及properties、xml、docx、md等配置说明和工程辅助文件基本包含源码、依赖、界面展示和文档说明几个维度。对于正在开展智能电网、微电网或电力远动系统课题的学生与工程师可直接导入项目梳理客户端-主站交互流程也可参考其多线程通信和数据库并发设计包内附带的说明与配置资料还能辅助安装部署和二次开发。目前已有143人学习下载适合作为电力系统远程监控方向的学习与工程参考资料。1. 引言IEC104主站客户端微电网远程监控的起点微电网的并网柜、储能变流器、光伏逆变器大多通过IEC104规约把电压、电流、开关位置送上主站。我之前用组态软件做过一套光伏监控但到了微电网项目客户要求把数据接到自己的能量管理平台还得把遥控指令下发给就地装置。组态软件封装太死授权也不便宜领导让我直接用Java写一个IEC104主站客户端。这个项目当时从零开始最终交付了一个能同时跑几十路Socket连接、实时解析遥信遥测、下发遥控遥调并且把海量测点异步写入数据库的独立进程。这套代码最大的价值不是“读了协议”而是把协议解析、多线程通信、控制状态机、高并发写库串成一条稳定链路。适合正在做电力监控、微电网能量管理、IEC104接入的Java工程师参考尤其适合想自己掌控协议细节又不想被商用中间件绑死的团队。2. IEC104协议解析与Socket多线程架构从字节流到业务数据的完整链路2.1 先读懂IEC104的报文骨架APCI与ASDUIEC104是TCP承载的规约报文的底层骨架是APCI应用协议控制信息固定6个字节起始后面跟着ASDU应用服务数据单元。APCI里的前两个字节是启动字符68H和APDU长度后面的四个字节是控制域用来区分I帧信息帧、S帧监视帧、U帧控制帧。I帧负责携带ASDUS帧用于确认接收U帧用于启停和测试。真正让业务跑起来的是ASDU里的类型标识、公共地址、信息对象地址和时标。例如遥测是类型标识13M_ME_NC遥信是类型标识1M_SP_NA。解析时先读类型标识再决定后面数据结构的大小和含义。我第一次对接时看到加热器的温度变成65535原因就是没有按小端序读浮点值。IEC104里大部分整型、单精度浮点都是低位在前Java的ByteBuffer默认是大端必须显式设置order(ByteOrder.LITTLE_ENDIAN)。下面这段代码是我当时采集遥测的解析骨架public class AsduParser { public static Telemetry parseTelemetry(byte[] frame, int offset) { // frame 为完整I帧报文offset 指向ASDU起始位置 ByteBuffer buf ByteBuffer.wrap(frame, offset, frame.length - offset) .order(ByteOrder.LITTLE_ENDIAN); int typeId buf.get() 0xFF; // 类型标识13表示遥测 if (typeId ! 13) return null; buf.get(); // 可变结构限定词 int transmitCause buf.get() 0xFF; // 传送原因 buf.getShort(); // 公共地址 int infoObjAddr buf.getInt() 0xFFFFFF; // 信息对象地址3字节转int float value buf.getFloat(); // 小端浮点测点值 byte quality buf.get(); // 品质描述符 Telemetry t new Telemetry(infoObjAddr, value); t.setOverflow((quality 0x01) ! 0); // 品质位第1位为溢出 t.setValid((quality 0x80) 0); // 最高位为无效标志 return t; } }这段代码的核心是先锚定ASDU起始位置再用LITTLE_ENDIAN格式读取。getInt() 0xFFFFFF是把3字节的信息对象地址无损转成int。品质字节的每个位代表不同状态比如溢出、被替换、无效这些位直接影响数据是否入库。很多早期版本只取了值忽略品质导致坏数据进入历史库。2.2 Socket多线程让主站同时管理几十路站端微电网项目里站点数量不多但每站可能有多个网关设备最少需要同时保持几十路TCP连接。每一路连接都在不停接收数据、定时发送心跳、响应总召如果用单线程轮询所有Socket一旦某站网络抖动或者报文卡住整个采集线程就阻塞了。所以我采用“每站一读线程每站一写线程共享业务队列”的架构。读线程只负责从Socket读取字节流按照帧头68H和长度字段拆出完整APDU然后扔进一个BlockingQueue由独立的协议解析线程消费。写线程专门处理下行报文包括总召、遥控、遥调和时间同步。心跳逻辑用ScheduledExecutorService周期发送U帧测试报文同时监控最后接收时间超时就把连接标记为中断并触发重连。这种生产者-消费者模式的好处是读写分离解析线程和业务线程互不拖泥带水。public class Iec104Connection implements Runnable { private final Socket socket; private final BlockingQueuebyte[] frameQueue new LinkedBlockingQueue(1000); private final ScheduledExecutorService scheduler Executors.newScheduledThreadPool(2); private volatile long lastReceiveTime System.currentTimeMillis(); private static final int T0_TIMEOUT 20000; // 连接超时 public void run() { try (DataInputStream in new DataInputStream(socket.getInputStream())) { scheduler.scheduleAtFixedRate(this::sendHeartbeat, 5, 15, TimeUnit.SECONDS); while (!Thread.currentThread().isInterrupted()) { socket.setSoTimeout(T0_TIMEOUT); int len in.readUnsignedShort(); // 第一个字节是68H已由前置读取 byte[] apdu new byte[len 2]; // 这里取决于拆帧策略完整实现见源码包 if (System.currentTimeMillis() - lastReceiveTime T0_TIMEOUT) { // 超时未收到任何报文主动断链重连 break; } frameQueue.put(apdu); } } catch (Exception e) { // 网络异常进入重连流程 } finally { scheduler.shutdownNow(); reconnect(); } } private void sendHeartbeat() { // 发送U帧TESTFR激活 (68 04 43 00 00 00) } }这里关键参数有三个setSoTimeout决定单次读阻塞的最长时间避免TCP半开状态下线程永远卡死队列容量设1000是为了让解析线程跟不上时多余的帧能产生背压而不是无脑堆积导致内存炸掉心跳间隔15秒我习惯对标T3参数站端认为连接超时的时间心跳周期不要超过T3的一半。实际部署时每一路连接还配了独立的线程池避免因为某一站异常而耗尽全局IO线程。2.3 总召与时钟同步主站启动后的第一件事主站与站端建立连接后第一件事不是等数据而是发送总召U帧或I帧类型标识100让站端把所有遥信遥测主动上送一遍。总召完成后才进入正常循环接收。钟同步也很关键微电网数据要用于电费结算和故障分析时间戳偏差必须小于1秒。主站通过类型标识6下发时钟同步报文站端校时后返回确认。这里常见的坑是总召命令的传送原因必须为6激活否则站端不响应。我一般会在连接建立后的1秒内发送总召随后每10分钟自动同步一次时钟保证数据时标一致性。3. 遥控遥调命令下发与高并发数据库操作3.1 遥控命令的状态机选择、执行、撤销遥控IEC104里不是发一包就完事而是有严格的状态流。常规遥控步骤是先发送“选择”命令等待站端返回选择确认再发送“执行”命令再等待执行确认。每一项命令都有唯一的控制序号COT站端收到后必须原样返回这个序号主站才能确认命令没有丢失。若中途超时还要发送“撤销”命令取消控制。我在项目里用ConcurrentHashMap维护每个站当前的控制状态键是站号信息对象地址值是枚举SELECTED、EXECUTED、CONFIRMED。当遥调数值需要下发给逆变器时流程一样只是类型标识不同。下面这段代码演示了如何构造一个遥控选择帧public byte[] buildSelectCommand(int publicAddr, int infoObjAddr, boolean on) { ByteBuffer buf ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN); buf.put((byte) 0x68); // 启动字符 buf.put((byte) 0x0E); // APDU长度14字节 buf.put((byte) 0x12); // 控制域 I帧发送序号低字节 buf.put((byte) 0x00); // 接收序号 buf.put((byte) 0x5A); // 类型标识45C_SC_NA遥控 buf.put((byte) 0x01); // 可变结构限定词单个对象 buf.put((byte) 0x06); // 传送原因6激活 buf.put((byte) 0x00); // 公共地址低位 buf.put((byte) publicAddr); // 公共地址高位 buf.put((byte) infoObjAddr); // 信息对象地址低字节 buf.put((byte) (infoObjAddr 8)); buf.put((byte) (infoObjAddr 16)); buf.put((byte) (on ? 0x81 : 0x01)); // 遥控值S/E位组合表示选择 buf.put((byte) 0x00); // 品质描述保留 return buf.array(); }这段代码的注意点有两个一是“遥控值”字节的位7是S/E标志置1表示选择置0表示执行二是信息对象地址三字节的排列顺序。我遇到过一个很迷惑的现象选择命令发出去后站端毫无反应后来发现公共地址的字节序反了站端配置的是0x0102我发出去的是0x0201。规约里公共地址也是小端必须和站端配置文件严格核对。遥调、设点命令类似类型标识是50数据部分是单精度浮点以4字节小端追加在信息对象地址后。注意遥控和遥调的差别在于遥控只传0/1状态遥调传连续数值。微电网场景里功率限值、电压无功调节曲线基本都用设点命令需要额外处理浮点转换。3.2 高并发数据库批量写入与连接池主站采集频率通常是遥测每帧包含几十个测点一秒钟几十帧算下来每秒上千条记录要写历史库。如果逐条insert基本上一分钟就能把数据库连接打满。我的做法是先用内存队列缓冲满100条或积压1秒再批量提交。批量提交不是循环执行addBatch而是真正利用JDBC的executeBatch一次往返发送给数据库。public void batchInsert(ListHistoryRecord records) { try (Connection conn dataSource.getConnection()) { String sql INSERT INTO telemetry_history (station, info_addr, value, ts, quality) VALUES (?,?,?,?,?); try (PreparedStatement ps conn.prepareStatement(sql)) { for (HistoryRecord r : records) { ps.setInt(1, r.getStation()); ps.setInt(2, r.getInfoAddr()); ps.setFloat(3, r.getValue()); ps.setTimestamp(4, r.getTs()); ps.setByte(5, r.getQuality()); ps.addBatch(); } ps.executeBatch(); } } catch (SQLException e) { // 记录失败批次等待重试 retryQueue.addAll(records); } }这里有个被很多人忽略的细节连接池必须开启rewriteBatchedStatementstrueMySQL否则驱动会把批量语句拆成单条执行性能提升完全体现不出来。连接参数我一般配成maximumPoolSize20connectionTimeout30000maxLifetime1800000。对于历史库写入我习惯单独用一个写线程池只做数据库写入避免把SocketIO线程占用。另外品质位里的无效标记直接映射成数据库的quality字段查询时用于过滤永远不要在写入时直接丢弃无效数据——后期分析故障时有它才能还原现场。3.3 数据补传与重连恢复站端可能因为网络波动导致中间一段数据缺失。IEC104规约支持带品质和时间戳的ASDU主站可以在重连后发起“召唤带时标的数据”站端把缓存的历史帧上送。这个功能对微电网的负荷分析非常关键。我实现了一个HistoryCache把最近5分钟的遥测帧存在内存里如果数据库写入失败或者站端断链这些帧可以重新插入。内存队列的容量不能无限大我采用RocketMQ类似的理念——本地队列满了就丢弃最老的数据同时记录丢弃数量到监控日志。通常5分钟以内网络恢复后都能把缺口补上。4. 避坑指南主站开发中的高频故障4.1 Socket连接半开线程却认为还活着现象主站程序运行两三天后某些站的数据不再更新但线程没有退出日志里也看不到错误。检查服务器端口连接发现TCP状态是ESTABLISHED但站端那边其实早就断电了。原因电力系统里的网络设备经常静默丢包没有正常发FIN包关闭连接。主站只靠读线程阻塞在read()方法上没有超时机制站端断线后Socket就一直挂在半开状态读写永远阻塞。解决在read之前设置Socket.setSoTimeout(15000)让每次读阻塞最多15秒超时抛出异常后进入重连流程。同时每15秒发一次U帧心跳并记录lastReceiveTime。主线程用锁或原子变量定时检查若超过T020秒没有收到任何帧就主动关闭连接并重新拨号。从那以后我所有Socket都强制套用“读超时心跳最后接收时间”三件套。4.2 浮点数值解析出错数据偶尔溢出现象遥测中电流、功率等测点偶尔出现极大值或负数比如电压显示-32768但站端用调试软件看是正确的。原因IEC104的浮点数是IEEE754小端序而Java的ByteBuffer.getFloat()默认按大端解析。我最初写代码时只对整数部分用了LITTLE_ENDIAN浮点部分没有改导致每个字节都反了解析出来的值完全不是站端发送的。解决整个报文统一ByteBuffer.order(ByteOrder.LITTLE_ENDIAN)不要局部单独设置。另外品质描述符里溢出位也必须检查有时站端本身就会把异常值标记成溢出主站如果忽略品质位把溢出的采样点写进历史库后续分析就全乱了。我在代码里加了过滤品质位溢出或无效时该条数据只记录到原始表不进统计表。4.3 遥控命令发出去了站端就是不执行现象通过主站界面点击“合闸”日志显示选择帧已发出但站端没有回应也没有任何动作。用抓包软件看报文已经到达站端。原因最常见的是公共地址不匹配站端配置的是2字节公共地址0x0001主站下发时填了0x0002。还有一类原因是控制方向位DIR置错了IEC104规定控制方向DIR0表示由站端到主站DIR1表示主站到站端下行命令必须置1。此外有些站端需要先选后执但如果选择帧的S/E位没有置1站端不认为这是选择直接丢弃。解决用抓包工具对比一个能正常工作的站端和故障站端的报文逐字节比对公共地址、类型标识和S/E位。我写了一个CommandValidator在下发前先把序列化好的报文解析回可读字段确认公共地址、类型标识、控制序号正确后才真正发送到Socket。这个习惯让我少跑了好几个现场。4.4 数据库连接池被打满系统响应越来越慢现象系统运行一小时后所有遥测入库开始延迟SQL日志多为连接获取超时后台抛出HikariPool-1 Connection is not available。原因数据写入用了同步方式每条记录都从连接池拿连接写完了再归还。当站端短时间内上送大量数据时写线程执行不完连接被占满。更糟糕的是我原先把事务边界开太大一次批量插入包含几千条记录事务执行期间连接一直不释放后续操作全部排队。解决把写库操作全部改为异步批量线程本地Buffer大小设为1000条或按1秒刷一次。事务控制在每条批次内不要跨批次。连接池的maximumPoolSize不用太大20个足够关键是连接获取要快。我还加了写队列深度监控队列超过2000条时报警而不是靠数据库堆死来反映。推荐在写库前增加一个环形缓冲区消费慢时主动丢弃时间戳过老的帧保证实时数据优先入库。5. 进阶验证用模拟站端把自己主站跑通再交付主站开发完最怕什么现场设备跟不上、规约版本有差异、调试窗口只有半天。所以我在本地用Python写了一个最小的IEC104模拟站端用它来验证主站的连接建立、总召、遥测上送、遥控确认和时钟同步。这个模拟器不追求完整实现只做三件事监听端口、响应U帧心跳、按配置循环上送遥测帧。下面这段代码就是模拟器的核心骨架# 模拟IEC104站端验证主站通道 import socket, time, struct def build_telemetry(addr, value): # 类型13 M_ME_NC公共地址0x0011 asdu bytes([13, 1, 3, 0x11, 0x11]) # 类型、限定词、传送原因、公共地址 asdu struct.pack(IHfB, addr, 0, value, 0x00) # 信息对象地址值品质 apdu bytes([0x68, len(asdu) 4]) bytes([0x10, 0, 0, 0]) asdu return apdu srv socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.bind((127.0.0.1, 2404)) srv.listen(1) conn, _ srv.accept() while True: data conn.recv(6) # 接收至少6字节然后按长度拆帧 if not data: break # 这里简单处理无论收到什么都回送一帧遥测 conn.sendall(build_telemetry(0x1111, 220.5)) time.sleep(1)主站启动后连接本机的2404端口如果能看到遥测值220.5写进数据库基本就说明一大半链路是通的。遥控验证时我模拟器里收到类型45的帧后会原样回一个确认帧主站必须能正确匹配确认状态。我会故意制造异常场景模拟器收到选择命令后不回确认看主站是否在超时后撤销并重新选择。这一步能活生生逼出状态机里的很多时序问题。每次交付前我固定跑一遍完整流程主站启动→连接模拟器→总召→等待遥测入库→下发遥控→等待确认→断开网络看重连恢复。这套验证流程总共不到十分钟却帮我避开了至少五次现场废水。从那以后不管是改协议解析还是调数据库并发我都会先把模拟器挂上回归一遍再打包发环境。希望这个方法也能帮到你。本文还有配套的精品资源点击获取