ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

基于Netty的IEC 104服务端对接:解析、解码与避坑实战

2026/9/28 15:49:37 拓冰建站 浏览量
基于Netty的IEC 104服务端对接:解析、解码与避坑实战 简介这是一份基于Java与Netty框架实现的电力IEC 60870-5-104协议服务端源代码面向电力自动化、SCADA系统开发人员及对工业通信协议感兴趣的Java工程师。资源帮助读者理解104协议报文结构、ASDU数据单元解析、编码解码流程以及如何借助Netty异步非阻塞I/O模型构建高并发、高实时的电力通信服务。压缩包共61个文件以55个Java源码为主辅以pom.xml、properties配置、README说明及一个jar包内容涵盖协议解析、数据处理、异常恢复等核心模块包体仅105KB结构紧凑便于快速阅读。目前已有134人学习下载。通过研读源码可掌握RTU与调度中心之间的数据交换机制学习Netty服务端启动与管线设计、ASDU关键字段信息对象地址、质控标志、时标的解析方法以及断线重传、超时处理等可靠性策略适合作为电力104协议接入项目的参考实现或教学示例。1. 电力104协议的服务端对接为什么这份Netty源码值得先下载前阵子帮一个地调项目做终端接入对方RTU上送报文总是丢遥测抓包看IP和TCP层都正常最后定位到我们自己解析104报文的代码把信息对象个数读错了。那个晚上翻协议PDF翻到凌晨就后悔没早点找到一份能直接照着跑的完整服务端源码。104协议在电力行业用得极广SCADA与RTU之间的数据交换几乎都走它但网上能搜到的资料大多是客户端模拟或者只讲报文的碎片文章服务端完整工程很少。这份源码基于Java和Netty把2404端口监听、I帧S帧U帧收发、ASDU解析、链路保活全串起来了适合三类人做调度自动化或负控采集的Java开发需要自己搭主站侧服务对接厂站终端刚接触104协议想找个真实工程对照报文理解的人以及想看看Netty在做真实协议栈时怎么处理粘包和链路管理的读者。先别急着改代码跟着下面的顺序读后面接项目会顺手很多。2. 先看懂链路再改代码APCI控制域与ASDU报文的解析路径2.1 四字节控制域定帧类型I帧、S帧、U帧一眼分辨104协议跑在TCP之上TCP本身已经保证了字节流的可靠传输和排序所以帧里不再做102规约那种复杂的链路层校验这是初学时最容易绕晕的地方。每个104报文最外层固定是起始字节0x68、长度字节L、然后是四字节控制域控制域之后才是ASDU。摘要里提到的TCU在工程代码里基本上就是这四字节控制域的封装类不必被名词吓住。控制域第一字节的低两位决定了帧类型00是I帧携带遥测遥信和遥控数据01是S帧用于对接收到的I帧做确认不带数据10和11是U帧负责启动、停止、测试链路。工程里常见的U帧第一字节值有0x07启动链路、0x0B启动确认、0x13停止链路、0x23测试帧、0x43测试确认这些值在抓包工具里几乎天天见。帧类型第一字节典型值含义是否带序号I帧0x00~0x0E应用数据帧带发送序号N(S)和接收序号N(R)S帧0x01确认已收到的I帧只带接收序号N(R)U帧0x07 / 0x0B / 0x13 / 0x23 / 0x43启动/停止/测试链路不带序号I帧的序号是15位循环计数的范围0~32767分布在控制域四个字节里按位拆。具体做法发送序号的低3位在第一个字节的bit1~bit3、中间8位在第二个字节、高3位在第三个字节的bit1~bit3接收序号的低3位在第一个字节的bit4~bit6、中间8位在第四个字节、高3位在第三个字节的bit4~bit6。这个分布是后面调试序号告警和粘包问题的核心建议读到这先在纸上画一遍后面第四章的代码会直接复用这套位运算。2.2 ASDU关键字段类型标识、信息对象地址与品质描述控制域之后是ASDU结构固定类型标识1字节、可变结构限定词1字节、传送原因2字节、公共地址2字节然后是信息体。可变结构限定词的bit0是SQ位为0表示后面的信息对象按地址顺序一个个排列为1表示按连续地址压包高位则是信息对象个数。代码里解析个数就是(vsq 0x7F)很多丢数据问题就出在这位被当成整个字节用。公共地址在标准里是2字节但某些厂家的实现按1字节发这是站间跨厂商联调最常见的兼容问题解析时需要做一个可配置开关。信息对象地址固定3字节小端排列也就是低字节在前计算时用(b0 0xFF) | ((b1 0xFF) 8) | ((b2 0xFF) 16)这个细节写错会导致所有对象地址偏移到意想不到的值。类型标识决定了信息体的具体长度和含义工程里最常见的几种类型标识数值含义信息体长度10x01单点遥信地址3字节 值1字节 品质1字节30x03双点遥信地址3字节 值1字节 品质1字节90x09归一化遥测地址3字节 值2字节 品质1字节130x0D短浮点遥测地址3字节 值4字节 品质1字节45 / 460x2D / 0x2E单点/双点遥控地址3字节 值1字节 品质1字节100 / 101 / 1020x64 / 0x65 / 0x66短浮点带品质和时标地址3字节 值4字节 品质1字节 时标7字节品质描述字段是1字节bit1是IV有效标志、bit2是NT更新标志、bit3是SB取代标志、bit4是BL封锁标志解析遥测时如果位被置1这路数据通常要在界面上打坏标不能直接入库。时标用CP56Time2a格式共7字节毫秒2字节、分钟、小时、日、月、年各1字节。常见翻车点是把毫秒按1字节读时标全错下文避坑章节会展开。3. 基于Netty起一个104服务端启动类骨架与TCP参数选型3.1 ServerBootstrap与boss/worker线程划分Netty服务端的线程模型是经典的Reactor模式一个boss线程组负责接受新连接worker线程组负责已经建立的连接上的读写。104服务端对并发要求不像互联网网关那么极端调度主站一般同时接几十个厂站每个厂站一条TCP长连接boss组给1个线程完全够worker组用默认的2倍CPU核数即可。把这段启动代码放进项目的ServerStartup类里就是最精简可跑的骨架。EventLoopGroup bossGroup new NioEventLoopGroup(1); EventLoopGroup workerGroup new NioEventLoopGroup(); try { ServerBootstrap bootstrap new ServerBootstrap(); bootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .option(ChannelOption.SO_BACKLOG, 1024) .option(ChannelOption.SO_REUSEADDR, true) .childOption(ChannelOption.TCP_NODELAY, true) .childOption(ChannelOption.SO_KEEPALIVE, true) .childHandler(new ChannelInitializerSocketChannel() { Override protected void initChannel(SocketChannel ch) { ch.pipeline().addLast(ieclength, new Iec104Decoder()); ch.pipeline().addLast(protoHandler, new Iec104ProtocolHandler()); } }); ChannelFuture future bootstrap.bind(2404).sync(); future.channel().closeFuture().sync(); } finally { bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); }SO_BACKLOG是TCP连接等待队列长度104厂站终端可能会在重启瞬间集中重连1024是比较稳妥的值太小会出现连接被内核丢弃的情况。SO_REUSEADDR允许端口在服务重启后立即复用否则用kill -9杀掉进程再启动经常会报Address already in use调度现场等着恢复业务时多等一秒都难受。TCP_NODELAY关闭Nagle算法104报文讲究实时性尤其是遥控命令下发宁可多几个小包也别让数据在缓冲区里凑时间。SO_KEEPALIVE是TCP层的心跳兜底但因为默认探测间隔太长真正的链路保活还是要靠应用层的U帧测试和IdleStateHandler。childHandler里加了两个自定义HandlerIec104Decoder负责把TCP字节流拆成一个个完整的104帧Iec104ProtocolHandler负责业务处理。Netty的pipeline是链式的前一个Handler输出的对象会交给后一个这样拆包和业务逻辑彻底分离。3.2 Handler职责分配与工作线程的边界写104 Handler时有个很容易踩的坑Handler里的代码默认跑在Netty的IO线程上如果直接在channelRead里做数据库写入或者复杂的协议转换会阻塞后续所有连接的读写。常见的做法是让Handler只做解析和简单判断耗时业务丢给独立的业务线程池。public class Iec104ProtocolHandler extends ChannelInboundHandlerAdapter { private static final ExecutorService BIZ_EXECUTOR new ThreadPoolExecutor(8, 16, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue(1024), new ThreadPoolExecutor.CallerRunsPolicy()); Override public void channelRead(ChannelHandlerContext ctx, Object msg) { if (msg instanceof Iec104Frame) { Iec104Frame frame (Iec104Frame) msg; BIZ_EXECUTOR.execute(() - handleFrame(ctx, frame)); } } }业务线程池单独维护队列容量给1024拒绝策略用CallerRunsPolicy。这个策略的意思是队列满了之后由提交任务的IO线程自己执行虽然会让IO线程短时间变慢但能保证任务不丢失。调度数据宁可延迟也不该静默丢弃这一点比互联网业务更敏感。Handler里也要把ctx引用传进去因为异步处理完可能还需要通过ctx写出响应帧。decode拆出的帧对象Iec104Frame通常是包含字节数组和解析结果字段的POJOHandler拿到它以后不再碰底层ByteBuf避免引用计数的麻烦。如果直接在Handler里操作Netty的ByteBuf一定要记得release或者用readRetainedSlice把数据复制出来。这个项目的Handler我翻了一下采用的是后者解码器里直接把ByteBuf转成独立字节数组Handler这部分可以少操很多心。4. 从字节流到业务数据解码器与I帧S帧U帧状态机4.1 粘包半包处理ByteToMessageDecoder与缓冲区游标TCP是字节流没有消息边界。厂站终端连续上送多个遥测帧时内核可能在一个数据包里塞好几帧也可能一帧被拆成两次read到达这就是面试里常被问的Netty粘包处理场景。104协议本身带长度字段所以不需要依赖特殊分隔符正确姿势是继承ByteToMessageDecoder自己写拆包逻辑操作它内部维护的累积缓冲区。public class Iec104Decoder extends ByteToMessageDecoder { Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, ListObject out) { while (in.readableBytes() 2) { in.markReaderIndex(); byte flag in.readByte(); if (flag ! (byte) 0x68) { ctx.close(); return; } int len in.readUnsignedByte(); if (len 4 || len 253) { ctx.close(); return; } if (in.readableBytes() len) { in.resetReaderIndex(); return; } ByteBuf frameBuf in.readRetainedSlice(len); byte[] bytes new byte[len]; frameBuf.readBytes(bytes); frameBuf.release(); out.add(new Iec104Frame(bytes)); } } }markReaderIndex在读取前记录当前游标发现剩下的字节不够一个完整帧时用resetReaderIndex回退等待下一次read触发decode。while循环是关键因为一个TCP包里可能有多个完整帧只读一个就返回会把剩下的帧留到下次read虽然不至于丢但延迟了一拍。readRetainedSlice复制出len字节的独立ByteBuf后立刻转成byte数组并release保证netty的引用计数归零不然后面内存泄漏排查能查到你怀疑人生。长度字段len的范围检查同样重要。104帧最短是U帧四字节控制域所以len最小4最大是带长ASDU的I帧算出来253封顶。超出这个范围要么是字节错位要么是链路异常直接关闭连接比继续解析下去更安全因为一个错误帧会让后续所有帧的边界全错。4.2 帧类型识别与15位序号拆分拿到完整帧后第一步是判类型。控制域四字节就藏在帧字节数组的头四个位置第一字节的低两位决定帧类型。从第2章画过的位分布图可以直接落到这段代码public class ApciCodec { public static Iec104Frame parse(byte[] frame) { int b0 frame[0] 0xFF; int b1 frame[1] 0xFF; int b2 frame[2] 0xFF; int b3 frame[3] 0xFF; int frameType b0 0x03; int sendSeq -1; int recvSeq -1; if (frameType 0x00) { // I帧既有发送序号也有接收序号 sendSeq ((b0 1) 0x07) | (b1 3) | (((b2 1) 0x07) 11); recvSeq ((b0 5) 0x07) | (b3 3) | (((b2 5) 0x07) 11); return Iec104Frame.createDataFrame(sendSeq, recvSeq, frame); } else if (frameType 0x01) { recvSeq ((b0 5) 0x07) | (b3 3) | (((b2 5) 0x07) 11); return Iec104Frame.createAckFrame(recvSeq); } else { return Iec104Frame.createControlFrame(frame[0]); } } }位运算的逻辑拆开看(b0 1) 0x07把第一字节bit1~bit3挪到最低三位得到发送序号的低3位b1 3把第二字节整体抬到bit3~bit10的位置((b2 1) 0x07) 11把第三字节bit1~bit3挪到位后抬到bit11~bit13的位置三段拼接刚好是15位。接收序号的算法同理只是起点换成第一字节的bit4~bit6和第四字节。这段代码是104协议栈的核心面试时能把15位序号的位分布讲清楚比背概念有用得多。4.3 ASDU按类型标识分发到业务解析帧类型确认后ASDU部分从帧的第五字节开始。业务Handler拿到Iec104Frame先读类型标识再走switch分发。遥测和遥信的解析结构不同拆开写更清晰switch (asduType) { case 0x01: // 单点遥信 parseTeleIndication(frame); break; case 0x0D: // 短浮点遥测 parseShortFloat(frame); break; case 0x64: // 短浮点带品质描述 parseMeasuredValueWithQuality(frame); break; case 0x2D: // 单点遥控 parseCommand(frame); break; case 0x65: // 短浮点带品质和时标 parseMeasuredValueWithTime(frame); break; default: log.warn(未知类型标识: 0x%02X, asduType); }每个parser内部要严格按第2章那张表的长度推着读。比如0x64类型每个信息体是信息对象地址3字节、短浮点值4字节、品质描述1字节共8字节。多对象时按可变结构限定词中的个数循环每轮偏移量加8。最容易翻车的不是单帧解析而是连帧场景下解析完第一帧后偏移量没跟上第二个信息体对象地址凭空错位数据全乱但框架不报错这就是典型的黑匣子Bug。5. 避坑与排查粘包、序号错位、数据丢帧与断链重连5.1 坑位一多对象ASDU只解析第一个信息体静默丢数据现象厂站上送一批遥测数据库里只有第一条有值后面的全是0或旧值。抓包看报文是完整的类型标识和个数都对得上。原因解析代码只按单个信息体长度读了一次循环漏写。可变结构限定词里明明写了对象个数是10代码只处理了第一个。解决解析前取vsq 0x7F作为循环次数在循环里按信息体固定长度推进读游标。建议写一个公共的autoIncrementOffset指针封装类每个parser读完当前对象后统一调next(),避免人工维护偏移量。5.2 坑位二粘包场景下长度校验不过直接close连接现象链路运行一段时间后Netty端突然大量报连接关闭厂站不断重连抓包发现TCP数据都正常。原因两个帧紧挨着到达时解码器读到了前一个帧的长度字段但缓冲区里实际是后一帧的残留字节长度值落在合法范围之外于是走到ctx.close()分支。解决不要在长度异常时立刻close。常见做法是连续3次读到同一个非法起始字节才断链或者配合IdleStateHandler做超时兜底。缓冲区的markReaderIndex和resetReaderIndex一定要配对使用进decode时标记判长度后的readableBytes判断放在reset之前。5.3 坑位三对端S帧确认序号一直为0I帧积压现象I帧发出去之后对方始终不回复确认日志里sendSeq越走越大最后超过对方的接收窗口对方链路直接断开。原因发送序号和接收序号的位顺序搞反了。比如把I帧的接收序号位置当成发送序号拼接导致发出去的N(S)跳变异常对端规约栈认为序号不连续按标准不应答。解决先在本机自测。用一个模拟终端回包确认序号对照抓包软件的Invalid告警看发送序号的每段位。这里有个血泪经验调试序号问题一定要打印二进制位别光看十进制Integer.toBinaryString(seq)一行代码能省两小时。5.4 坑位四公共地址按1字节解析跨厂家联调失败现象和某厂家RTU联调时对方上送的报文公共地址被读成两位数站地址完全对不上主站把所有帧当无效报文丢弃。原因标准里公共地址是2字节但这个厂家实际上把高位恒置0只用低字节表达站地址。主站侧如果写死按2字节读站地址数字恰好落在第二个字节的高位就错位了。解决把公共地址长度做成配置项caLength1或caLength2启动时读取。解析时先拼出两字节再判断配置1字节时丢弃高字节。同理传送原因也有类似兼容问题某些老设备按1字节发。5.5 坑位五IdleStateHandler超时设太长断链检测失效现象厂站终端停电重启主站侧连接迟迟不释放TCP协议栈都收不到RST直到半天以后才发现链路断了。原因只依赖TCP层的SO_KEEPALIVE默认探测间隔是2小时对调度业务来说太慢。应用层的U帧测试周期又是全链路空的。解决在pipeline里加IdleStateHandler读空闲和写空闲都设置比如15秒读空闲、10秒写空闲。触发userEventTriggered后主动发U帧测试帧0x68 0x04 0x23 0x00 0x00 0x00连续两次无响应就关闭连接。调度现场宁可误判断链也不该傻等。这条做好了上面说的粘包误杀问题也更容错因为链路状态是主动可控的。6. 上线的最后一公里用I帧序号校验做链路保活与报文审计6.1 发送序号追踪器把未确认报文数控制在窗口内104协议是主从问答和主动上送混合模式I帧发出后必须收到S帧确认否则不能无限发下去。很多厂站终端要求主站侧未确认的I帧不能超过一定数量超了直接断链。用一个简单的追踪器就能把这个窗口管住public class SendSeqTracker { private int sendSeq; private int ackSeq; private long lastAckTime System.currentTimeMillis(); public synchronized int nextSendSeq() { sendSeq (sendSeq 1) 0x7FFF; lastAckTime System.currentTimeMillis(); return sendSeq; } public synchronized void onReceiveAck(int recvSeq) { this.ackSeq recvSeq 0x7FFF; this.lastAckTime System.currentTimeMillis(); } public synchronized int unconfirmedCount() { return (sendSeq - ackSeq) 0x7FFF; } public synchronized boolean isWindowAvailable(int windowSize) { return unconfirmedCount() windowSize; } }(sendSeq - ackSeq) 0x7FFF利用了15位循环序号的特性不用写复杂的差值判断序号回绕时减法和掩码直接给对未确认数。每次发业务帧前调isWindowAvailable(8)达到窗口上限就停发等S帧确认或超时重发。收到对端S帧时调onReceiveAck把确认序号同步进来同时重置确认时间。6.2 报文留痕断链重连后回看最近20帧104调试里最糟心的情况是链路断了但找不到断点两边都说是对方的错。我在Handler里维护一个环形队列把每个收发的帧摘要记下来断链事件发生时把留痕打出来。private final ArrayDequeString traceQueue new ArrayDeque(64); private void recordFrame(String direction, Iec104Frame frame) { String summary String.format(%s | type%d | sendSeq%d | recvSeq%d, direction, frame.getAsduType(), frame.getSendSeq(), frame.getRecvSeq()); traceQueue.offer(summary); while (traceQueue.size() 64) { traceQueue.poll(); } }64条约等于现场几十秒的吞吐量断链后把这些数据和时间戳一起dump到日志文件。排查时先看双方最后一次交互的序号如果本站发的是sendSeq100、对端回的是recvSeq50说明对方最终确认到的位置停在50中间那50帧大概率在网络里丢了重传逻辑就有依据了。从那以后我每次对接新厂家终端都强制先跑一遍序号校验脚本确认对端S帧的确认行为是正常的再谈数据入库。这一套流程帮我省掉了不少半夜在机房的折腾希望帮到你。本文还有配套的精品资源点击获取