☰
Java实现IEC104主站客户端:微电网数据采集与协议解析实战
2026/10/20 18:38:31 网站建设 项目流程

简介:本资源为基于Java实现的IEC104协议主站客户端程序,属于微电网管理系统的一部分,面向从事电力自动化、微电网监控及工业通信开发的工程师与学习者。程序围绕IEC104协议展开,涵盖U帧、S帧、I帧三种帧格式的处理逻辑,以及遥信、遥测上行数据与遥控、遥调下行命令的应答式传输机制,可帮助读者理解主站客户端如何与从站服务端完成数据交互。压缩包共105个文件,包含30个class、23个java源码、22个png图示、10个json配置、9个jar依赖及properties、xml等辅助文件,整体约3.59MB,源码与编译产物并存,便于直接运行与二次开发。目前已有734人学习下载。通过研读Asdu、Client、PVDigitalQuantityData等核心类,读者可掌握IEC104报文解析、数据单元封装与客户端通信流程,为微电网管理系统的协议对接与调试提供可复用的参考实现。

1. 从一次微电网数据对接翻车说起:这个 IEC104 主站客户端到底能干什么

去年帮一个做园区微电网的朋友排查数据采集问题,现场三台规约转换器,后台 Java 服务每隔几分钟就丢一批遥测点,日志里全是t1 timeout和k 值超限。折腾两天才发现,他们用的那个采集库把 IEC104 的k/w窗口当成摆设,确认帧乱发,一问才知道是随手找的 demo 改的。那次之后我就留了个心眼,凡是微电网、储能、光伏这类场景要接 IEC104 的,先看主站客户端实现得扎不扎实。这次拆的IEC104_microgrid就是干这个的——一个用 Java 写的 IEC104 协议主站客户端,定位是微电网管理系统里的通信前置模块,负责跟站控层设备、规约转换器、RTU 打交道,把遥测、遥信、遥调、遥控这些数据拉回来交给上层业务。它适合两类人:一类是正在做微电网/储能 EMS 后台、需要自己实现 104 主站的 Java 开发者;另一类是接手了别人半成品采集程序、想搞清楚 IEC104 握手和窗口机制到底怎么落地的维护者。下面我按「能干什么 → 怎么跑 → 怎么改」的顺序,把这个项目从目录结构到核心代码拆一遍。

2. 项目结构与技术选型:一个 Java 主站客户端该有的骨架

2.1 目录结构:从包名就能看出分层意图

拿到源码先别急着跑,先看目录。这个项目的包结构是典型的通信前置模块分层,我按实际拆解整理成下面这张表,方便你对照自己手上的版本:

目录/包职责关键类(常见命名)
protocol/IEC104 报文编解码、APCI/ASDU 结构ApduDecoder、AsduParser
session/TCP 连接管理、握手、超时重连Iec104Session、ConnectionManager
transport/Socket 读写、字节缓冲SocketChannelWrapper
model/遥测/遥信/遥调/遥控数据对象MeasuredValue、SinglePoint
handler/收到报文后的业务分发DataHandler、CommandHandler
config/站址、端口、公共地址、超时参数Iec104Config
task/定时总召唤、心跳、窗口维护GeneralInterrogationTask

这个分层的好处是协议解析和业务处理解耦,你换一套上层业务系统,protocol和session基本不用动。我一般拿到这类项目会先确认session和protocol是不是分开的,如果揉在一个类里,后面改窗口参数会非常痛苦。

2.2 技术选型:为什么是 Java 原生 Socket 而不是 Netty

项目用的是 Java 原生Socket+ 线程池,没有引入 Netty。这一点很多人会问,我的判断是:IEC104 是长连接、低并发、强时序的协议,一个主站通常就管几十个站,每个站一条 TCP 连接,用 Netty 的收益不大,反而增加理解成本。原生 Socket 配合一个专门的读线程和一个写线程,逻辑更直白,出问题也好看堆栈。

核心依赖一般就三块:日志用 SLF4J + Logback,配置用 Properties 或 YAML,测试用 JUnit。没有花哨的东西,这对二次开发是好事——你不需要先学一个框架才能改协议。

2.3 配置项:这些参数不改,连上也会掉线

IEC104 的很多问题不是代码 bug,是参数没配对。项目里Iec104Config通常包含下面这些字段,我列出来并说明每个的实际影响:

public class Iec104Config { private String host; // 站控设备 IP private int port = 2404; // IEC104 默认端口 private int commonAddress = 1; // 公共地址,必须和从站一致 private int originatorAddress = 0; // 源发地址,主站侧一般填 0 private int t0 = 30; // 连接建立超时,秒 private int t1 = 15; // 发送或测试 APDU 后等待确认的超时,秒 private int t2 = 10; // 无数据报文时确认的超时,秒,必须小于 t1 private int t3 = 20; // 长时间空闲后发送测试帧的间隔,秒 private int k = 12; // 未确认 I 帧的最大数量 private int w = 8; // 收到 w 个 I 帧后必须发确认 }

这里最容易翻车的是t2 < t1和w <= k这两条约束。很多现场掉线就是t2设得比t1大,导致确认帧还没发出去,对端已经判定超时断开。k和w是滑动窗口,k是发送方未确认上限,w是接收方确认阈值,配反了会出现「数据能收但总召唤卡死」的玄学现象。

提示:改配置前先跟从站厂家确认他们的k/w和超时值,主从两侧窗口不匹配是 104 对接最常见的坑。

3. 核心链路拆解:从 TCP 握手到遥测入库的完整流程

3.1 建立连接与 STARTDT 握手

IEC104 的连接分两层:TCP 层连上之后,还要发STARTDT_ACT激活数据传输,收到STARTDT_CON才算真正可用。项目里这段逻辑在Iec104Session.connect()里,我按常见实现还原一下:

public void connect() throws IOException { socket = new Socket(); socket.connect(new InetSocketAddress(config.getHost(), config.getPort()), config.getT0() * 1000); in = new DataInputStream(socket.getInputStream()); out = new DataOutputStream(socket.getOutputStream()); // 发送 STARTDT_ACT,U 帧格式固定为 0x07 0x00 0x00 0x00 byte[] startDtAct = new byte[]{0x07, 0x00, 0x00, 0x00}; out.write(startDtAct); out.flush(); // 等待 STARTDT_CON,超时按 t1 处理 byte[] resp = readApdu(config.getT1() * 1000); if (resp == null || (resp[0] & 0x0F) != 0x0B) { throw new IOException("STARTDT 激活失败,收到: " + bytesToHex(resp)); } startReaderThread(); // 握手成功后才启动读线程 }

逻辑说明:0x07是 U 帧里STARTDT_ACT的固定控制域首字节,0x0B对应STARTDT_CON。参数上t0管 TCP 连接,t1管握手确认,两个超时要分开看。失败时先抓包确认对端有没有回0x0B,如果回了但代码没识别,多半是控制域判断写错了位运算。

3.2 发送序号与接收序号的维护

I 帧里带两个 15 位序号:发送序号 N(S) 和接收序号 N(R)。这是 104 最核心也最容易写错的地方。项目里通常用一个SequenceManager维护:

public class SequenceManager { private int sendSeq = 0; // N(S),每发一个 I 帧加一 private int recvSeq = 0; // N(R),期望收到的下一个序号 private int unackedCount = 0; // 已发未确认数量,用于 k 窗口 public synchronized int nextSendSeq() { if (unackedCount >= config.getK()) { throw new IllegalStateException("k 窗口已满,需等待确认"); } int current = sendSeq; sendSeq = (sendSeq + 1) % 32768; // 15 位循环 unackedCount++; return current; } public synchronized void onReceiveI(int ns) { if (ns != recvSeq) { // 序号不连续,常见于丢包或对端重启 log.warn("序号跳变,期望 {} 实际 {}", recvSeq, ns); } recvSeq = (ns + 1) % 32768; if (++receivedSinceAck >= config.getW()) { sendAck(); // 达到 w 阈值,回 S 帧确认 } } }

参数说明:32768是 15 位序号回绕点,别写成 65536。unackedCount到k就必须停发,否则对端会直接断链。receivedSinceAck到w要主动发 S 帧,不能等。我见过有人把w当成「收到 w 个才处理」,结果数据延迟特别大,其实是确认机制理解错了。

3.3 总召唤与 ASDU 解析

连接激活后第一件事通常是总召唤,把从站当前所有遥测遥信拉一遍。总召唤是 I 帧,ASDU 类型标识0x64(C_IC_NA_1),传送原因0x06(激活):

public void generalInterrogation() { byte[] asdu = new byte[]{ (byte) 0x64, // 类型标识:总召唤 (byte) 0x01, // 可变结构限定词:SQ=0,信息体个数 1 (byte) 0x06, // 传送原因:激活 (byte) 0x00, (byte) config.getCommonAddress(), // 公共地址 (byte) 0x00, // 信息体地址低字节 (byte) 0x00, (byte) 0x00, // 信息体地址高字节,总召唤固定 0 (byte) 0x14 // 召唤限定词:站总召唤 }; sendIFrame(asdu); }

收到从站回的0x64且传送原因为「激活确认」后,会陆续收到0x09(归一化遥测)、0x0B(标度化遥测)、0x01(单点遥信)等 ASDU。解析时要注意信息体地址是 3 字节小端,很多解析错误都是把地址当 2 字节读了。项目里AsduParser一般会按类型标识分发到不同DataHandler,遥测走MeasuredValue,遥信走SinglePoint,最后交给上层业务入库。

3.4 遥控与遥调的下行链路

遥控(0x2D,C_SC_NA_1)和遥调(0x30,C_SE_NA_1)是下行命令,走的是「选择-执行」两阶段。项目里CommandHandler通常这样组织:

public void sendControl(int ioa, boolean select, boolean on) { byte[] asdu = new byte[]{ (byte) 0x2D, // 单点遥控 (byte) 0x01, (byte) (select ? 0x06 : 0x06), // 选择/执行都走激活,靠 S/E 位区分 (byte) 0x00, (byte) config.getCommonAddress(), (byte) (ioa & 0xFF), (byte) ((ioa >> 8) & 0xFF), (byte) ((ioa >> 16) & 0xFF), (byte) (select ? 0x80 : 0x00), // S/E 位:1 选择,0 执行 (byte) (on ? 0x01 : 0x00) // 合/分 }; sendIFrame(asdu); }

参数说明:ioa是信息体地址,3 字节小端;S/E位在限定词最高位,选择阶段置 1,执行阶段置 0。实际项目里必须等选择确认回来才能发执行,否则从站会拒绝。这块的坑是很多人把选择和执行合并成一步发,现场设备直接不动作。

4. 跑起来:编译、配置与联调验证

4.1 环境准备与编译

项目是标准 Maven 结构,JDK 8 及以上都能跑。我一般用 JDK 8 或 11,太新的版本有些老依赖会有反射警告。编译命令:

# 编译打包 mvn clean package -DskipTests # 产物在 target 下,通常是一个可执行 jar 或带依赖的 fat jar java -jar target/iec104-microgrid-1.0.jar --config=config/iec104.properties

如果项目没有用 Spring Boot 打包,那就用mvn exec:java指定主类启动。启动前确认config目录下的配置文件路径对,很多「启动就报空指针」都是配置没加载到。

4.2 配置文件怎么写

配置文件一般长这样,我按字段说明:

# 从站连接信息 iec104.host=192.168.1.100 iec104.port=2404 iec104.commonAddress=1 iec104.originatorAddress=0 # 超时参数,单位秒 iec104.t0=30 iec104.t1=15 iec104.t2=10 iec104.t3=20 # 窗口参数 iec104.k=12 iec104.w=8 # 总召唤周期,单位秒 iec104.giInterval=300

commonAddress必须和从站配置一致,不一致的表现是连接正常但总召唤无响应。giInterval是总召唤周期,微电网场景一般 5 到 15 分钟一次,太频繁会给从站压力。

4.3 用模拟从站验证

没有真实设备时,可以用开源的 104 模拟器当从站。启动模拟器后监听 2404 端口,配置好公共地址和点表,然后启动主站客户端。验证顺序建议这样:

  1. 看日志有没有STARTDT_CON成功,没有就是握手问题;
  2. 看总召唤有没有发出,从站有没有回激活确认;
  3. 看遥测遥信有没有持续上来,序号有没有跳变;
  4. 发一条遥控选择,确认从站回选择确认后再发执行。

注意:模拟器默认点表可能和你的ioa对不上,收到数据但解析出来是 0,先核对点表地址。

4.4 抓包定位问题

IEC104 联调离不开抓包。Wireshark 直接过滤tcp.port == 2404就能看到 APDU。重点看三类帧:U 帧(握手)、S 帧(确认)、I 帧(数据)。如果看到大量重传或RST,基本是窗口或超时参数问题。我一般会把抓包和日志时间戳对齐,能快速定位是主站没发还是从站没回。

5. 避坑与常见问题排查

5.1 连上了但总召唤没数据

现象:TCP 连接成功,STARTDT也激活了,但总召唤发出去没有任何遥测回来。原因通常是公共地址不匹配,或者总召唤限定词写错。解决:抓包确认 ASDU 里的公共地址和从站一致,限定词站总召唤用0x14,组召唤用对应组号。另外确认从站是否要求先发「召唤命令」再发「总召唤」,有些厂家实现有差异。

5.2 运行一段时间后自动断开

现象:跑十几分钟后连接被对端关闭,日志显示t1 timeout。原因多半是t3空闲测试没发,或者k窗口满了没等确认继续发。解决:确认t3定时器在空闲时发TESTFR_ACT,收到TESTFR_CON才算心跳正常;检查unackedCount是否到k后还在发 I 帧。这两个是 104 长连接保活的关键。

5.3 遥测值解析出来全是 0 或乱码

现象:数据帧收到了,但解析出的遥测值不对。原因通常是类型标识判断错,或者信息体地址字节序读反。归一化值0x09是 2 字节有符号数,标度化0x0B也是 2 字节,短浮点0x0D是 4 字节 IEEE754。解决:先打印原始字节,对照 104 规约逐个字段核对,地址一定按 3 字节小端读。

5.4 遥控选择成功但执行不动作

现象:选择确认回来了,执行发出去从站没反应。原因可能是执行帧的S/E位没清零,或者选择和执行之间超时了。解决:确认执行帧限定词最高位是 0,且选择到执行的间隔在从站允许范围内(一般几秒内)。有些从站还要求选择和执行使用相同的命令值。

5.5 多从站场景下序号串了

现象:接多个从站时,某个站的数据序号错乱。原因是一个SequenceManager被多个连接共享了。解决:每个Iec104Session必须持有独立的序号管理器和窗口计数,不能做成单例。这个坑在多站微电网项目里特别常见。

6. 二次开发:把主站客户端接进你自己的微电网系统

这个项目最值得改的地方,是它把协议层和业务层分开了,所以二次开发的核心思路是「不动协议,换业务」。我一般会从三个方向下手。

第一个方向是数据出口改造。项目默认把解析后的遥测遥信交给DataHandler,你可以实现自己的DataHandler,把数据推到 Kafka、写入时序库或者直接调上层 EMS 的接口。改造时注意别在回调线程里做阻塞 IO,读线程被拖住会导致确认帧发不出去,进而触发对端超时。常见做法是DataHandler里只做入队,另起消费线程处理落库。

第二个方向是增加规约扩展。微电网里除了标准 104,经常还要处理厂家自定义的 ASDU 类型,比如储能 SOC、PCS 状态。你可以在AsduParser里加一个类型标识到解析器的映射表,新增类型只加一个解析类,不用改主流程。下面是一个可扩展解析器的骨架:

public interface AsduTypeParser { Object parse(byte[] infoElements, int count); } public class AsduParserRegistry { private final Map<Integer, AsduTypeParser> parsers = new HashMap<>(); public void register(int typeId, AsduTypeParser parser) { parsers.put(typeId, parser); } public Object parse(int typeId, byte[] data, int count) { AsduTypeParser parser = parsers.get(typeId); if (parser == null) { log.warn("未注册的类型标识: 0x{}", Integer.toHexString(typeId)); return null; } return parser.parse(data, count); } }

这样加新类型就是registry.register(0x88, new SocParser())一行的事,比在switch-case里堆代码好维护得多。

第三个方向是连接治理。生产环境里从站会重启、网络会抖动,重连策略要能区分「TCP 断开」和「STARTDT 失效」。我的习惯是给每个站维护一个状态机:DISCONNECTED → TCP_CONNECTED → STARTDT_ACTIVE → DATA_TRANSFER,只有到DATA_TRANSFER才允许发总召唤和遥控。重连退避用指数退避,别用固定 1 秒,否则从站重启期间会被反复冲击。

验证改造是否成功,我一般跑三步:先用模拟器验证协议交互没退化,再用真实从站跑 24 小时看有没有内存泄漏和序号回绕问题,最后压测多站并发确认线程池和连接数匹配。有一次我图省事跳过了 24 小时长跑,结果序号回绕到 32768 时判断写成了>而不是>=,跑了大半天才暴露,从那以后我每次改完序号相关逻辑都强制走一遍回绕测试。希望这些能帮到你,源码拿下去先跑通握手和总召唤,再按自己的业务改出口,基本就能落地了。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询