☰
Java+Netty实现IEC 104协议对接:帧结构、编解码与避坑指南
2026/10/18 12:56:58 网站建设 项目流程

简介:本资源面向电力自动化、SCADA系统开发及Java网络编程学习者,提供基于Netty框架实现IEC 60870-5-104协议对接的服务端源代码,可用于理解RTU与调度中心之间的通信机制。压缩包共61个文件,以55个Java源码为核心,辅以properties配置、xml描述及jar依赖,整体约105KB,结构紧凑便于快速导入IDE调试。内容涵盖ASDU与TCU数据结构解析、信息对象地址与时间戳读取、二进制与ASCII编解码、异常检测与重传超时机制,并借助Netty异步非阻塞I/O处理高并发连接。已有134人学习,适合希望掌握电力104协议报文处理、Netty服务端搭建及协议解析排错思路的开发者参考,对理解电力系统通信与Java网络编程具有较高学习价值。

1. 电力 104 协议对接:为什么 Java + Netty 是当前最稳的落地组合

如果你正在做电力监控、配电自动化或者新能源场站的系统集成,大概率绕不开 IEC 60870-5-104 这个协议。它跑在 TCP 之上,用来在调度主站和变电站终端之间传遥测、遥信、遥控和电度数据。很多刚接触的开发者第一反应是去找现成的协议库,结果发现要么是 C 写的、要么封装太厚改不动,最后还是要自己用 Java 撸一遍。而一旦决定自己写,Netty 几乎是默认选项——它把 TCP 粘包、心跳、断线重连这些脏活都接过去了,你只需要专注在 104 的报文结构和状态机上。

这篇文章面向的是需要把 104 协议对接真正跑起来的 Java 工程师。我会从协议帧结构讲起,然后给出基于 Netty 的服务端和客户端骨架代码,再拆解几个必调参数和血泪踩坑点。整套方案适合变电站网关、边缘计算盒子、主站前置采集这类场景,新手能照着搭出可通信的最小系统,熟手能直接拿去改造成生产级实现。

2. 104 协议帧结构拆解:从 APCI 到 ASDU 的完整拼装逻辑

在动手写 Netty 代码之前,必须先把 104 的报文结构吃透。否则你会在调试时看到一堆十六进制数据却不知道哪一段出了问题。104 的帧分为 APCI(应用规约控制信息)和 ASDU(应用服务数据单元)两部分,APCI 固定 6 字节,ASDU 长度可变。

2.1 三种帧格式的识别与构造

104 的 APCI 首字节最低两位决定了帧类型:I 帧(信息传输)、S 帧(确认)、U 帧(控制)。I 帧的发送序号和接收序号各占 2 字节,S 帧只有接收序号,U 帧则用首字节的特定值表示 STARTDT、STOPDT、TESTFR 等控制命令。

// 判断帧类型的工具方法 public static FrameType parseFrameType(byte firstByte) { // 最低两位为 01 是 S 帧,11 是 U 帧,00 或 10 是 I 帧 int lowBits = firstByte & 0x03; if (lowBits == 0x01) return FrameType.S; if (lowBits == 0x03) return FrameType.U; return FrameType.I; } // 构造 U 帧:STARTDT 激活 public static byte[] buildStartDtAct() { // U 帧固定 6 字节,首字节 0x07 表示 STARTDT 激活 return new byte[]{0x07, 0x00, 0x00, 0x00, 0x00, 0x00}; } // 构造 S 帧:确认收到 N 个 I 帧 public static byte[] buildSFrame(int receiveSeq) { byte[] frame = new byte[6]; frame[0] = 0x01; // S 帧标识 frame[1] = 0x00; // 接收序号左移一位后填入低两字节 frame[2] = (byte) ((receiveSeq << 1) & 0xFF); frame[3] = (byte) ((receiveSeq << 1 >> 8) & 0xFF); frame[4] = 0x00; frame[5] = 0x00; return frame; }

上面代码里parseFrameType用位运算提取最低两位,这是 104 协议最基础的判断逻辑。buildStartDtAct构造的 U 帧是建立连接后必须发送的第一条命令,只有收到对端回复的 STARTDT 确认后,才能开始传 I 帧。buildSFrame里的序号要左移一位,因为 104 规定序号字段的第 0 位固定为 0,实际序号占 15 位。

2.2 ASDU 的类型标识与传送原因

ASDU 是真正承载业务数据的部分,结构为:类型标识(1 字节)+ 可变结构限定词(1 字节)+ 传送原因(2 字节)+ 公共地址(2 字节)+ 信息对象地址(3 字节)+ 信息元素。类型标识决定了这是什么数据,比如遥测是 0x09(归一化值)或 0x0B(标度化值),遥信是 0x01(单点)或 0x03(双点),遥控是 0x2D(单命令)或 0x2E(双命令)。

// 构造一个遥测上送的 ASDU(归一化值,类型标识 0x09) public static byte[] buildMeasuredValueAsdu(int commonAddr, int ioa, float value) { ByteBuffer buf = ByteBuffer.allocate(32); buf.put((byte) 0x09); // 类型标识:归一化遥测 buf.put((byte) 0x01); // 可变结构限定词:1 个信息对象,非连续 buf.put((byte) 0x03); // 传送原因低字节:突发(突发上送) buf.put((byte) 0x00); // 传送原因高字节 buf.putShort((short) commonAddr); // 公共地址 // 信息对象地址占 3 字节,低字节在前 buf.put((byte) (ioa & 0xFF)); buf.put((byte) ((ioa >> 8) & 0xFF)); buf.put((byte) ((ioa >> 16) & 0xFF)); // 归一化值:实际值乘以 32767 后取整,再转成 short short normalized = (short) (value * 32767); buf.put((byte) (normalized & 0xFF)); buf.put((byte) ((normalized >> 8) & 0xFF)); // 品质描述词,0x00 表示正常 buf.put((byte) 0x00); byte[] asdu = new byte[buf.position()]; buf.flip(); buf.get(asdu); return asdu; }

这段代码里commonAddr是公共地址,通常对应变电站编号;ioa是信息对象地址,每个遥测点唯一;value是实际工程值,归一化时要乘以 32767。传送原因填 0x03 表示突发上送,如果是响应总召则填 0x14(响应站召唤)。品质描述词的第 0 到 4 位分别表示溢出、闭锁、取代、非当前值和无效,调试时如果发现数据不对,先看这个字节。

2.3 总召唤与时钟同步的交互流程

总召唤是主站和终端建立连接后必须做的第一步,主站发类型标识 0x64(总召唤激活),终端回 0x64 确认,然后依次上送所有遥信、遥测、电度,最后用传送原因 0x0A(激活终止)结束。时钟同步则是主站发 0x67(时钟同步),终端回 0x67 确认。

// 构造总召唤激活帧 public static byte[] buildGeneralInterrogation(int commonAddr) { ByteBuffer buf = ByteBuffer.allocate(16); buf.put((byte) 0x64); // 类型标识:总召唤 buf.put((byte) 0x01); // 可变结构限定词 buf.put((byte) 0x06); // 传送原因:激活 buf.put((byte) 0x00); buf.putShort((short) commonAddr); buf.put((byte) 0x00); // 信息对象地址固定为 0 buf.put((byte) 0x00); buf.put((byte) 0x00); buf.put((byte) 0x14); // 召唤限定词:站召唤 byte[] asdu = new byte[buf.position()]; buf.flip(); buf.get(asdu); return asdu; }

总召唤的召唤限定词填 0x14 表示全局站召唤,如果只想召唤某一组可以用 0x15 到 0x21。实际对接时,很多终端在收到总召唤后不会一次性把所有点上送完,而是分多帧发送,每帧的传送原因都是 0x03(突发),直到最后一帧才用 0x0A。如果你在代码里只等一帧就认为总召结束,会丢数据。

3. Netty 服务端与客户端骨架:编解码器、心跳与重连的完整实现

有了帧结构的基础,接下来用 Netty 搭骨架。104 协议本质上是长连接双向通信,主站和终端都可以主动发数据,所以服务端和客户端的代码结构高度对称,区别只在于谁先发起连接。

3.1 编解码器:解决粘包与半包的核心

104 的帧长度不固定,APCI 里并没有直接给出总长度字段,但 ASDU 的长度可以从可变结构限定词和类型标识推算出来。更通用的做法是:先读 6 字节 APCI,如果是 I 帧,再根据 ASDU 的类型标识查表得到信息元素长度,乘以信息对象个数,加上固定头长度,算出剩余需要读的字节数。

// 继承 ByteToMessageDecoder,处理 104 的粘包和半包 public class Iec104Decoder extends ByteToMessageDecoder { @Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) { // 至少需要 6 字节才能判断帧类型 if (in.readableBytes() < 6) return; in.markReaderIndex(); byte firstByte = in.getByte(in.readerIndex()); int lowBits = firstByte & 0x03; if (lowBits == 0x03) { // U 帧固定 6 字节 if (in.readableBytes() < 6) { in.resetReaderIndex(); return; } byte[] frame = new byte[6]; in.readBytes(frame); out.add(frame); } else if (lowBits == 0x01) { // S 帧固定 6 字节 if (in.readableBytes() < 6) { in.resetReaderIndex(); return; } byte[] frame = new byte[6]; in.readBytes(frame); out.add(frame); } else { // I 帧:先读 APCI 4 字节(跳过首字节和发送序号),再读 ASDU if (in.readableBytes() < 6) { in.resetReaderIndex(); return; } // 读取 ASDU 的类型标识和可变结构限定词 byte typeId = in.getByte(in.readerIndex() + 6); byte vsq = in.getByte(in.readerIndex() + 7); int objCount = vsq & 0x7F; boolean isSequence = (vsq & 0x80) != 0; int elementLen = getElementLength(typeId); int asduLen; if (isSequence) { asduLen = 6 + objCount * elementLen; // 连续地址,只算一次 IOA } else { asduLen = 6 + objCount * (3 + elementLen); // 每个对象都有 3 字节 IOA } int totalLen = 6 + asduLen; if (in.readableBytes() < totalLen) { in.resetReaderIndex(); return; } byte[] frame = new byte[totalLen]; in.readBytes(frame); out.add(frame); } } // 根据类型标识返回单个信息元素的字节数 private int getElementLength(byte typeId) { switch (typeId & 0xFF) { case 0x01: return 1; // 单点遥信 case 0x03: return 1; // 双点遥信 case 0x09: return 2; // 归一化遥测 case 0x0B: return 2; // 标度化遥测 case 0x0D: return 4; // 短浮点遥测 case 0x0F: return 4; // 累计量 case 0x2D: return 1; // 单命令 case 0x2E: return 1; // 双命令 case 0x64: return 1; // 总召唤 case 0x67: return 4; // 时钟同步 default: return 1; } } }

这个解码器的核心思路是:先判断帧类型,U 帧和 S 帧固定 6 字节直接读;I 帧则根据类型标识和可变结构限定词计算总长度。getElementLength里列的是常见类型,实际项目里如果遇到自定义类型,需要扩展这个表。注意isSequence为 true 时,多个信息对象共用同一个起始地址,后续对象地址依次加一,所以每个对象只算信息元素长度,不再重复算 3 字节 IOA。

编码器相对简单,因为发送时我们已经构造好了完整的字节数组,直接写出即可。

// 编码器:把字节数组直接写入 ByteBuf public class Iec104Encoder extends MessageToByteEncoder<byte[]> { @Override protected void encode(ChannelHandlerContext ctx, byte[] msg, ByteBuf out) { out.writeBytes(msg); } }

3.2 服务端启动:绑定端口与初始化管道

服务端用 Netty 的 ServerBootstrap,管道里依次放解码器、编码器和业务处理器。业务处理器里要维护发送序号和接收序号,这是 104 协议可靠传输的基础。

public class Iec104Server { public void start(int port) throws InterruptedException { EventLoopGroup bossGroup = new NioEventLoopGroup(1); EventLoopGroup workerGroup = new NioEventLoopGroup(); try { ServerBootstrap b = new ServerBootstrap(); b.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) { ChannelPipeline p = ch.pipeline(); p.addLast(new Iec104Decoder()); p.addLast(new Iec104Encoder()); p.addLast(new Iec104ServerHandler()); } }) .option(ChannelOption.SO_BACKLOG, 128) .childOption(ChannelOption.SO_KEEPALIVE, true); ChannelFuture f = b.bind(port).sync(); f.channel().closeFuture().sync(); } finally { workerGroup.shutdownGracefully(); bossGroup.shutdownGracefully(); } } }

SO_BACKLOG设为 128 是常见值,表示等待接受的连接队列长度。SO_KEEPALIVE打开 TCP 层心跳,但 104 自己有 TESTFR 心跳机制,所以这个选项更多是兜底。实际部署时如果终端数量多,workerGroup的线程数可以调大,默认是 CPU 核数乘以 2。

3.3 业务处理器:序号管理、心跳与总召触发

业务处理器是核心,需要处理连接建立后的 STARTDT 激活、定时发送 TESTFR、维护发送和接收序号、以及触发总召唤。

public class Iec104ServerHandler extends SimpleChannelInboundHandler<byte[]> { private int sendSeq = 0; // 发送序号 private int recvSeq = 0; // 接收序号 private ScheduledFuture<?> testTask; @Override public void channelActive(ChannelHandlerContext ctx) { // 连接建立后,等待对端发 STARTDT,也可以主动发 System.out.println("终端接入: " + ctx.channel().remoteAddress()); } @Override protected void channelRead0(ChannelHandlerContext ctx, byte[] frame) { int lowBits = frame[0] & 0x03; if (lowBits == 0x03) { // U 帧处理 handleUFrame(ctx, frame); } else if (lowBits == 0x01) { // S 帧:更新发送确认序号,可以清理已确认的帧 int ackSeq = ((frame[3] & 0xFF) << 8 | (frame[2] & 0xFF)) >> 1; System.out.println("收到 S 帧确认: " + ackSeq); } else { // I 帧:更新接收序号,并回 S 帧确认 recvSeq++; // 每收到一帧 I 帧,回一个 S 帧确认 ctx.writeAndFlush(buildSFrame(recvSeq)); // 处理 ASDU 业务逻辑 handleAsdu(frame); } } private void handleUFrame(ChannelHandlerContext ctx, byte[] frame) { byte cmd = frame[0]; if (cmd == 0x07) { // 收到 STARTDT 激活,回复确认 ctx.writeAndFlush(new byte[]{0x0B, 0x00, 0x00, 0x00, 0x00, 0x00}); // 启动心跳定时任务,每 20 秒发一次 TESTFR testTask = ctx.executor().scheduleAtFixedRate(() -> { ctx.writeAndFlush(new byte[]{0x43, 0x00, 0x00, 0x00, 0x00, 0x00}); }, 20, 20, TimeUnit.SECONDS); } else if (cmd == 0x43) { // 收到 TESTFR 激活,回复确认 ctx.writeAndFlush(new byte[]{0x83, 0x00, 0x00, 0x00, 0x00, 0x00}); } else if (cmd == 0x0B) { System.out.println("STARTDT 确认收到"); } } private void handleAsdu(byte[] frame) { // 从第 6 字节开始是 ASDU byte typeId = frame[6]; if ((typeId & 0xFF) == 0x64) { // 收到总召唤,这里应该触发上送所有数据 System.out.println("收到总召唤,准备上送数据"); } } @Override public void channelInactive(ChannelHandlerContext ctx) { if (testTask != null) testTask.cancel(true); System.out.println("终端断开: " + ctx.channel().remoteAddress()); } }

sendSeq和recvSeq是 104 可靠传输的关键。每发一个 I 帧,sendSeq加一;每收一个 I 帧,recvSeq加一。S 帧里的确认序号表示“我已经收到这个序号之前的所有帧”。实际项目中,不需要每收一帧就回 S 帧,可以累积到一定数量再回,减少网络开销。心跳定时任务用scheduleAtFixedRate每 20 秒发一次 TESTFR,这个间隔可以调,但不要超过 30 秒,否则对端可能认为连接已断。

3.4 客户端实现:断线重连与指数退避

客户端和服务端代码几乎一样,只是用 Bootstrap 主动连接,并且要加断线重连逻辑。

public class Iec104Client { private EventLoopGroup group = new NioEventLoopGroup(); private Channel channel; private String host; private int port; private int retryDelay = 1000; // 初始重连延迟 1 秒 public void connect() { try { Bootstrap b = new Bootstrap(); b.group(group) .channel(NioSocketChannel.class) .handler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) { ch.pipeline().addLast(new Iec104Decoder()); ch.pipeline().addLast(new Iec104Encoder()); ch.pipeline().addLast(new Iec104ClientHandler(Iec104Client.this)); } }); ChannelFuture f = b.connect(host, port).sync(); f.addListener((ChannelFutureListener) future -> { if (future.isSuccess()) { channel = future.channel(); retryDelay = 1000; // 连接成功后重置延迟 System.out.println("连接成功"); } else { // 连接失败,延迟后重连 scheduleReconnect(); } }); if (channel != null) { channel.closeFuture().sync(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } public void scheduleReconnect() { System.out.println("连接断开," + retryDelay + "ms 后重连"); group.schedule(() -> { retryDelay = Math.min(retryDelay * 2, 30000); // 指数退避,最大 30 秒 connect(); }, retryDelay, TimeUnit.MILLISECONDS); } }

指数退避是血泪经验:如果终端侧网络闪断,固定 1 秒重连会导致大量无效连接把对端打挂。从 1 秒开始翻倍,上限 30 秒,既能快速恢复又不会造成风暴。channel.closeFuture().sync()会阻塞当前线程直到连接关闭,然后触发重连。

4. 参数调优与避坑:那些让 104 对接翻车的细节

协议和代码都跑通之后,真正的挑战才开始。下面这几条是我在实际对接中反复踩过的坑,每一条都对应具体的现象和解决方式。

4.1 序号翻转导致连接被对端主动断开

现象:连接建立后正常通信几分钟,突然被对端断开,日志里没有任何异常。

原因:104 的发送序号和接收序号是 15 位,范围 0 到 32767,超过后要翻转回 0。如果代码里用 int 累加但不做取模,序号会一直增大,对端发现序号不连续就会断开。

解决:每次序号加一后立即对 32768 取模。

sendSeq = (sendSeq + 1) % 32768; recvSeq = (recvSeq + 1) % 32768;

4.2 总召唤超时未收到激活终止

现象:主站发总召唤后,终端上送了几帧数据就停了,主站一直等不到 0x0A 的激活终止,总召流程卡死。

原因:终端侧可能因为某个信息对象地址不存在而跳过,或者上送过程中被其他高优先级任务打断。主站侧如果没有超时机制,就会一直等。

解决:主站侧设置总召超时定时器,比如 30 秒,超时后重新发起总召或标记部分数据无效。终端侧则要确保所有点都上送完毕后再发激活终止。

4.3 TESTFR 心跳间隔与 TCP Keepalive 冲突

现象:连接空闲一段时间后断开,抓包发现双方都在发心跳但对方不回复。

原因:TCP 层的 Keepalive 和 104 的 TESTFR 同时开启,某些网络设备会优先处理 TCP Keepalive 而丢弃应用层心跳,导致 104 心跳超时。

解决:关闭 TCP Keepalive,只依赖 104 的 TESTFR。在 Netty 里把SO_KEEPALIVE设为 false,心跳间隔设为 20 秒,超时时间设为 60 秒。

4.4 公共地址与信息对象地址不匹配

现象:数据能收到,但解析出来的遥测值全是零或者乱码。

原因:公共地址(commonAddr)在 ASDU 里占 2 字节,信息对象地址(IOA)占 3 字节,如果字节序搞反了,解析出来的地址就完全对不上。

解决:104 规定所有多字节字段都是低字节在前(小端序)。用 ByteBuffer 时默认是大端序,必须显式设置ByteOrder.LITTLE_ENDIAN,或者手动按低字节在前拼装。

// 手动拼装 3 字节 IOA int ioa = (frame[offset] & 0xFF) | ((frame[offset + 1] & 0xFF) << 8) | ((frame[offset + 2] & 0xFF) << 16);

4.5 遥控命令的选择与执行确认

现象:主站下发遥控命令后,终端没有动作,也没有回复确认。

原因:104 的遥控是“选择-执行”两步流程。主站先发选择命令(传送原因 0x06),终端回选择确认(传送原因 0x07),主站再发执行命令(传送原因 0x06),终端回执行确认(传送原因 0x07)。如果直接发执行命令,终端会拒绝。

解决:严格按照选择-执行流程实现,并且每条命令都要带遥控限定词(如 0x01 表示短脉冲)。选择超时时间一般设为 30 秒,超时后需要重新选择。

5. 进阶技巧:用状态机管理 104 连接生命周期与数据上送节奏

前面几章把协议、代码和坑都过了一遍,最后聊一个让代码从“能跑”变成“好维护”的技巧:用状态机管理连接生命周期。104 的连接有明确的状态迁移:TCP 建立 → STARTDT 激活 → 总召 → 正常运行 → STOPDT → TCP 断开。如果把这些状态散落在各个回调里,代码会很快变成一团乱麻。

我一般会定义一个枚举表示连接状态,然后在业务处理器里用一个字段记录当前状态,所有收发逻辑都先判断状态再执行。

public enum Iec104State { CONNECTED, // TCP 已连接,等待 STARTDT STARTED, // STARTDT 已激活,可以传 I 帧 STOPPED, // STOPDT 已停止 CLOSED // 连接关闭 } // 在 handler 里维护状态 private Iec104State state = Iec104State.CONNECTED; // 收到 STARTDT 确认后迁移状态 if (cmd == 0x0B) { state = Iec104State.STARTED; // 状态迁移后触发总召唤 triggerGeneralInterrogation(ctx); } // 发送 I 帧前检查状态 private void sendIFrame(ChannelHandlerContext ctx, byte[] asdu) { if (state != Iec104State.STARTED) { System.out.println("当前状态不允许发送 I 帧: " + state); return; } // 拼装 APCI + ASDU,发送序号加一 byte[] frame = buildIFrame(asdu, sendSeq, recvSeq); ctx.writeAndFlush(frame); sendSeq = (sendSeq + 1) % 32768; }

状态机的好处是,当连接异常断开时,你只需要把状态置为 CLOSED,所有发送逻辑自动被拦截,不会出现“连接已断还在发数据”的玄学问题。另外,总召唤的上送节奏也建议用状态机控制:收到总召后进入“总召中”状态,每上送一帧检查是否还有剩余点,全部上送完再发激活终止并回到“正常运行”状态。这样即使中间有帧丢失,也能通过超时机制重新触发。

还有一个实用技巧是给每个连接维护一个发送窗口。104 允许连续发送多个 I 帧而不等确认,但窗口太大会导致内存积压,太小又影响吞吐。我一般把窗口设为 12,也就是最多同时有 12 个未确认的 I 帧,超过就等待 S 帧确认后再继续发。这个值可以根据现场网络质量调整,网络差就调小,网络好可以调到 20 左右。

最后说一个我自己的习惯:每次对接新终端,先不急着写业务逻辑,而是用 Wireshark 抓包,把 STARTDT、总召、单点遥信、归一化遥测、遥控选择执行这几个典型流程的报文各抓一份,存成十六进制文本。然后写一个单元测试,把这些报文喂给解码器,断言解析结果。这样后面改代码时,只要跑一遍测试就知道有没有破坏协议兼容性。这个习惯帮我省了无数次现场排查的时间,希望帮到你。

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

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

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

立即咨询