简介:面向工业自动化、电力采集与能源管理领域的 Java 开发者,这份 IEC104 规约主站程序围绕 IEC 60870-5-104 2002 版实现,可用于读写符合西门子 IEC104 协议的电表与网关,帮助解决主站侧通信建立、报文交互、数据采集与设备联调等实际问题。压缩包共 253 个文件,约 10.17MB,包含 86 个 java 源码与 97 个 class 文件,便于查看实现并直接运行验证;22 个 jar 提供依赖库,db 与 sql 文件对应数据存储脚本,xml、properties、springbeans、conf 等承载工程与协议参数配置,bat、exe 及 dll 则用于启动部署和本地组件调用,整体构成较完整的主站工程。页面显示已有 4176 人学习下载,说明其在 IEC104 主站开发与电表网关对接场景中具有一定参考价值。读者可据此梳理主站程序结构、理解规约报文处理逻辑,并在此基础上进行二次开发、接口调试与现场问题排查,也可作为规约学习与联调对照材料。
1. 工业通信里的 IEC104 主站:为什么值得单独写一个 Java 版本
做过电力、能源、轨交这类上位系统的开发者大概都有过类似经历:现场给了一份 IEC104 规约点表,要求三天内把主站侧跑通,结果手头只有 C++ 或 C# 的组态软件,Java 后端根本接不进去。IEC104 主站程序要处理的就是这一类任务——通过 TCP 连上 RTU 或子站,完成总召唤、遥测遥信上送解析、遥控下发、时钟同步这些动作,本质上是一套带明确报文格式的异步通信中间层。Java 版本的价值不在于重造轮子,而在于能直接嵌进 Spring 服务、能把规约层和业务层拆干净。这份资源面向的就是需要把 IEC104 接入自有平台的 Java 开发者。
2. 项目定位与技术选型:为什么用纯 Socket 手撕 APDU 而不是套框架
IEC104 在协议栈上并不复杂:应用层是 APDU,下面挂 ASDU,再往下是 TCP 承载。真正让人头疼的是它的“状态机”特性——序号 N(S)、N(R) 必须严格自增和确认,t1/t2/t3 三个定时器管着发送确认、接收确认和空闲心跳,u 帧负责 STARTDT/STOPDT/TESTFR。这些如果用现成框架包一层,调试时出了错你会非常被动,报文对不对、序号有没有跳、超时是谁触发的,全都藏在黑匣子里。
2.1 技术栈构成与分层思路
这个项目走的是轻量路线,核心依赖很少,基本可以按下面这套理解:
| 层次 | 承担职责 | 常见技术选择 |
|---|---|---|
| 传输层 | TCP 长连接、断线重连 | JDKSocket/SocketChannel |
| 帧解析层 | 拆 APDU、区分 I/S/U 帧 | 自写字节解析工具 |
| ASDU 层 | 类型标识、可变结构限定词、信息体解析 | 枚举 + 工厂方法 |
| 调度层 | 总召唤、定时心跳、超时重发 | ScheduledExecutorService |
| 业务层 | 点位映射、事件回调 | 接口回调 / 观察者 |
选 JDK 原生 Socket 而不是 Netty,理由很实际:IEC104 是单连接、低频次、报文短小的场景,一个链路每秒撑死几百帧,Netty 那套 EventLoop 在这里属于过度设计,还会让字节缓冲区的读写变得绕。用DataInputStream配ByteArrayOutputStream反而直观,出问题时打个断点就能看清每一个字节。
2.2 关键参数与规约默认值
规约里这些参数不是随便定的,现场调试翻车十有八九出在这里:
| 参数 | 含义 | 常用取值 |
|---|---|---|
| t0 | 连接建立超时 | 30s |
| t1 | 发送后等确认超时 | 15s |
| t2 | 收到 I 帧后发确认时限 | 10s(须小于 t1) |
| t3 | 链路空闲发 TESTFR 的周期 | 20s |
| k | 未确认 I 帧最大数量 | 12 |
| w | 收到 w 个 I 帧后必须确认 | 8 |
约束关系是t2 < t1、w <= 2k/3这类经验配比。很多现场设备只认默认值,你改之前最好先问清楚对端配置,否则会出现“我这边发得好好的,对方一直不认”的玄学现象。
2.3 帧结构先认清楚,后面才不会乱
IEC104 的 APDU 只有两种长度:控制域 4 字节的 I/S 帧,和只有 6 字节的 U 帧。起始字节固定0x68,第二个字节是长度(最大 253)。判型看控制域第 1 字节的最低位:
// 依据控制域首字节低两位判断帧类型 // I帧: bit0=0 S帧: bit0=1,bit1=0 U帧: bit0=1,bit1=1 public static FrameType parseType(byte ctrl1) { int v = ctrl1 & 0x03; if (v == 0x00) return FrameType.I; if (v == 0x02) return FrameType.S; return FrameType.U; }这段判断是整条链路的地基。注意 I 帧的发送序号占 2 字节、接收序号占 2 字节,都要& 0xFFFF后处理,且有 15 位有效(最高位固定 0),序号到 32767 要回绕到 0。这一点在长时间挂机测试时最容易暴露:跑几小时后突然全乱了,多半是回绕处理漏了。
3. 目录结构与核心模块拆解:一条报文从进来到落库的完整链路
拿到代码先看目录,能省一半时间。主站程序按职责切分得比较干净,典型结构长这样:
iec104-master/ ├── src/main/java/ │ ├── net/ # 连接管理、心跳、重连 │ ├── frame/ # APDU 编解码、帧类型 │ ├── asdu/ # 类型标识解析、信息体 │ ├── session/ # 会话状态机、序号管理 │ ├── service/ # 总召唤、遥控、对时 │ └── handler/ # 业务回调接口 └── src/main/resources/ └── points.yaml # 点位映射表3.1 连接管理与链路建立
链路建立顺序是有讲究的,不能上来就发总召唤:TCP 连上 → 发 STARTDT_ACT → 收到 STARTDT_CON → 才开始数据传输。少一步对端直接丢弃你的 I 帧。
public void connect() throws IOException { socket = new Socket(); socket.connect(new InetSocketAddress(host, port), T0); // t0 连接超时 socket.setTcpNoDelay(true); // 关掉 Nagle,否则小报文会攒包 in = new DataInputStream(socket.getInputStream()); out = new DataOutputStream(socket.getOutputStream()); sendUFrame(UType.STARTDT_ACT); // 请求启动数据传输 state = LinkState.WAIT_STARTDT_CON; scheduler.scheduleAtFixedRate(this::idleCheck, T3, T3, TimeUnit.SECONDS); }setTcpNoDelay(true)是血泪经验,默认开启 Nagle 会让 6 字节的 U 帧在缓冲区里等,对端等不到心跳就判你链路超时。idleCheck负责 t3 周期内没发过帧时补一个 TESTFR_ACT,对端回 TESTFR_CON 即认为链路活。断线重连不要用while(true) { connect() }死循环猛冲,容易把对端连接队列打满,退避 1s、2s、5s、10s 递增更稳。
3.2 APDU 编解码:I 帧的拼装与拆解
发送 I 帧时,控制域里塞的是发送序号和接收序号,后面跟整个 ASDU。这里最容易写错的是字节位:
public byte[] buildIFrame(int sendSeq, int recvSeq, byte[] asdu) { ByteArrayOutputStream bos = new ByteArrayOutputStream(); bos.write(0x68); // 启动字符 bos.write(asdu.length + 4); // 长度 = 控制域4 + ASDU // 发送序号:低8位在前,高8位左移1位(最高位保留) bos.write(sendSeq & 0xFF); bos.write((sendSeq >> 8) & 0x7F); // 接收序号:同样低位在前 bos.write(recvSeq & 0xFF); bos.write((recvSeq >> 8) & 0x7F); bos.write(asdu, 0, asdu.length); return bos.toByteArray(); }参数说明:sendSeq每次发完自增取模 32768,recvSeq是当前已收到的最后一个 I 帧序号。第二个字节写asdu.length + 4,别写成+6,否则对端按长度切包会把下一帧的头部吃进去,表现出的现象是“偶尔丢帧、越跑越乱”。
接收侧反过来走:先读 2 字节定长头,校验0x68,再按长度读剩余部分,交给parseType分流。注意读流要做“攒包”处理,TCP 是字节流,一次read不一定拿到完整一帧。
3.3 ASDU 解析与总召唤
总召唤是主站上线后第一个业务动作,发的是 C_IC_NA_1(类型标识 100),传送原因 6(激活)。子站回一帧确认,然后连续上送类型标识 1/3/9/11/13 等遥信遥测数据,最后一帧带传送原因 10(激活终止)。
// 构造总召唤 ASDU:类型标识100,可变结构限定词1,传送原因6 byte[] buildGeneralInterrogation(int commonAddr) { ByteArrayOutputStream b = new ByteArrayOutputStream(); b.write(100); // 类型标识 C_IC_NA_1 b.write(0x01); // SQ=0, 信息体个数=1 b.write(0x06); // 传送原因低字节,6=激活 b.write(0x00); // 传送原因高字节 b.write(commonAddr & 0xFF); // 公共地址低字节 b.write((commonAddr >> 8) & 0xFF); b.write(0x00); // 信息体地址低 b.write(0x00); b.write(0x00); // 信息体地址高 b.write(0x14); // QOI=20 站总召唤 return b.toByteArray(); }解析回包时,可变结构限定词 SQ 位决定信息体是“连续寻址”还是“每个都带地址”。SQ=0 时每个信息体前都有 3 字节地址,SQ=1 时只有第一个带地址,后续按地址递增。这块写错的话,点位会整体错位三字节,遥测值全部变成天文数字——典型踩坑现场。点位映射建议单独放points.yaml,用信息体地址当 key 去查业务点位号,规约层和业务层就彻底解耦了。
3.4 遥控下发与超时重发
遥控走的是 SELECT(传送原因 6)→ 执行(传送原因 7)两步,中间等子站确认,t1 内不回就得重发。遥控这种带副作用的操作千万别重发两次执行帧:
public void control(int ioa, boolean on) { sendIFrame(buildControlAsdu(ioa, on, Cause.SELECT)); // 先选择 pending = ControlCtx.of(ioa, on); // 记下待确认 timeoutTask = scheduler.schedule(() -> { if (pending != null) { pending = null; log.warn("选择超时 ioa={}", ioa); } }, T1, TimeUnit.SECONDS); }收到选择确认后才发执行帧,执行帧超时只记录告警、不自动重发,交人工确认。这条规则建议写进代码注释里,否则后来接手的人一优化就把重发加上了。
4. 跑起来:本地联调与用模拟子站验证
规约类项目最怕的就是只有主站没有子站,压根没法验。务实做法是本地先起一个模拟子站,或者用抓包对照。
4.1 编译与启动
# 编译打包,跳过测试加快首次启动 mvn clean package -DskipTests # 指定对端地址和端口启动 java -jar target/iec104-master.jar --host=127.0.0.1 --port=24042404 是 IEC104 的注册端口,本地联调时对端也起在 2404 即可。启动后日志里应该依次出现:TCP connected → STARTDT_ACT sent → STARTDT_CON received → General Interrogation sent。四步齐了才算链路正常,缺哪步就查哪段。
4.2 用 Wireshark 对报文
规约调试绕不开抓包。在过滤器里输入tcp.port == 2404,把“解码为”设成 IEC60870-5-104,抓到的帧会被拆成类型标识、传送原因、公共地址、信息体地址,一目了然。我一般会重点看三处:总召唤的激活终止帧有没有来、遥测值跟你点表能否对上、S 帧的接收序号是不是每次都加对了。抓包比读日志可靠得多,尤其是序号回绕这类问题,日志根本看不出来。
提示:抓包建议在链路两端的机器上同时抓,主站侧看到的发送序号和子站侧收到的序号对不上的话,问题多半在中间网络设备做了拆包重装。
4.3 点位表配置与首轮验证
把points.yaml里的点位跟现场点表逐条核对,重点看信息体地址和数据类型(归一化值、标度化值、短浮点)。首轮验证别一上来就跑全站,先挑三五个遥信、两个遥测,确认变位能上送、总召唤值正确,再放全量。见过太多项目全量一挂上去,日志刷屏把真正的解析异常淹掉了。
5. 避坑与排查:五条现场踩出来的记录
5.1 总召唤发出去没反应
现象:日志显示已发送总召唤,对端没有任何回包。原因:九成是 STARTDT 没完成就发了 I 帧,对端处于 STOPDT 状态直接丢弃。解决:把链路状态机做成严格串行,只有LinkState.RUNNING状态才允许发业务帧,其余状态进来的发送请求一律排队。
5.2 遥测值持续偏大或为负
现象:某几个点的值总是不对,量级差几千倍。原因:类型标识对应的信息体长度算错,短浮点按 2 字节读了,或者归一化值没除以 32767 就转成工程量。解决:按类型标识建一张长度表,解析前先查表;归一化值统一转 double 后再做量程换算。
5.3 跑几小时后链路自己断
现象:前两小时一切正常,之后子站主动断开。原因:接收序号回绕没处理,或者 t3 心跳被业务线程阻塞没发出去。解决:序号统一% 32768处理;心跳任务用独立的单线程调度器,别和业务解析共用线程池。
5.4 遥控执行了两次
现象:现场开关动作两次,非常危险。原因:执行帧超时后代码里做了自动重发。解决:执行帧绝不自动重发,超时只告警;同时在业务侧加幂等,同一 IOA 在短时间内只接受一次执行结果。
5.5 并发下报文字节错乱
现象:偶发解析异常,报文字节对不上。原因:多个线程同时往同一个DataOutputStream写,帧被交叉拼接了。解决:发送方法统一加锁,或者用单线程队列串行化所有发送动作,这是最常见也最容易被忽略的一条。
6. 二次开发:把规约层改成能嵌入业务平台的形态
这套代码真正上手改的时候,第一刀应该切在解耦上。规约层只负责“收到什么、发出什么”,业务层只关心“点位变了、遥控结果如何”,中间用接口隔开:
// 业务回调:规约层不感知任何业务实体 public interface Iec104Listener { void onData(int ioa, Object value, long timestamp); // 遥测遥信上送 void onControlResult(int ioa, boolean success, String reason); void onLinkState(LinkState state); }把这个接口实现成 Spring 的 Bean,规约层的会话对象通过构造注入拿到它,点位映射表也从points.yaml换成数据库配置,现场改点就不用重新打包了。第二刀切在数据模型上:信息体解析出来的是原始值,加一层PointConverter做量程和单位换算,别让×0.1这种魔法数散落在解析代码里。
验证方法建议做两套:单元测试用构造好的字节数组喂给解析器,覆盖 SQ=0/1、各类型标识、序号回绕;联调环境用模拟子站跑长稳,至少挂 12 小时观察序号和内存。我一般会在onData里打个采样日志,每分钟统计一次收到帧数和最大序号,回绕问题一眼就能看出来。
还有个实用技巧:把收发原始报文做成可开关的 hex dump 日志,用-Diec104.dump=true控制。现场出问题的时候,让运维把这个开关打开抓十分钟,比远程连上去猜半天强得多。改这块的时候顺手把日志里的敏感信息(公共地址、具体点位含义)脱敏,交出去的日志才不会惹麻烦。
从那以后我每次接这类规约项目,都强制先把链路状态机和收发日志跑通、抓包对上,再动业务解析——顺序反了,后面全是猜。希望帮到你。
本文还有配套的精品资源,点击获取