ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Netty实现电力104协议服务端:帧解析、会话状态机与避坑指南

Netty实现电力104协议服务端:帧解析、会话状态机与避坑指南 简介面向电力系统通信开发者的Java实现IEC 60870-5-104协议对接服务端源码基于Netty框架构建适用于电网自动化、变电站监控及SCADA与RTU数据交互等场景可通过源码研读快速掌握电力远动规约的编码、解码与状态机落地。压缩包共61个文件以55个Java类为核心另含pom.xml工程配置、properties配置文件、README说明及依赖jar包整体仅105KB内容紧凑便于按类阅读与定位关键逻辑。已有134人学习。源码提供Netty服务启动类、ASDU应用服务数据单元编解码器、报文解析与异常重传处理覆盖连接管理、字节流拆包、信息体公共地址解析、时间戳处理、心跳及超时机制等关键环节目录结构清晰可将其中模块直接复用至现有Java网络项目对研究104协议通信链路和Netty异步非阻塞模型具有较高参考价值同时附带测试与调试思路便于验证通信可靠性。1. 电力104协议对接为什么绕不开Netty从一次调度链路闪断说起配电房里调度主站下发总召唤子站一口气回了几百条遥信主站侧却始终停在“总召唤中”不刷新同一套程序换到实验室模拟主站又一切正常。这类问题在电力104协议对接里很常见根子往往不在报文解析而在netty服务端对帧序号、测试帧和终止帧的处理顺序没对上。标题这套Java实现电力104协议对接的netty服务源代码解决的就是把IEC 60870-5-104的从站侧子站用Netty稳定跑起来做调度数据网网关、配电终端、储能协调控制器接入主站都可以直接复用。适合有Java基础、想绕开商业协议库、自己掌握从站侧状态机的工程师。2. 先拆清104的帧结构APCI与ASDU的分层以及Netty解码器怎么配2.1 104协议在TCP之上的报文骨架启动字符、长度、控制域、ASDUIEC 60870-5-104本质是在TCP上跑IEC 101的应用数据单元所以每个完整的104帧长得非常规整固定以0x68开头接着一个长度字节再往后是控制域和ASDU。把这个骨架背下来后面所有解析代码都不会跑偏。一帧标准104报文由四段组成启动字符0x68占1字节一帧的硬性开头。TCP是字节流没有消息边界这个0x68就是找帧头的锚点。长度字段占1字节表示“从控制域第一个字节开始到ASDU最后一个字节为止”的总长度。注意它不含启动字符0x68和长度字段自身。这是很多人第一次写解码器就翻车的地方。控制域占4字节承载帧类型I帧、S帧、U帧、发送序号、接收序号是104会话状态机的核心。ASDU应用服务数据单元类型标识、可变结构限定词、传送原因、公共地址、信息体地址和信息体元素都在这段里。长度字段只有1字节所以单帧最大长度是255字节控制域4字节ASDU部分再加上0x68和长度本身一帧最多257字节。这意味着一帧不可能塞下几千条遥信总召唤的数据量大时子站要按帧分批上送每帧能装多少信息体取决于VSQ和单个信息体的大小。Netty解码时不需要考虑“超大帧”的极端场景但要把“若干个104帧粘在一个TCP段里”和“一个104帧被拆成多个TCP段”这两种情况处理干净这正好是LengthFieldBasedFrameDecoder的强项。2.2 粘包拆包LengthFieldBasedFrameDecoder的参数推演TCP是流式协议主站可能一口气发来三个104帧也可能一个帧分两次到达。Netty里处理这种定长头变长体的最常用手段就是LengthFieldBasedFrameDecoder关键在于把长度字段的偏移和调整值算对。104帧长度字段在偏移1的位置占1字节。长度值本身不含0x68和长度字段所以解码器读到的lengthValue再加上帧头2字节正好就是整帧长度。用LengthFieldBasedFrameDecoder时lengthAdjustment要设为0initialBytesToStrip设成0保留整帧给后面的Handler解析。public class Iec104FrameDecoder extends LengthFieldBasedFrameDecoder { public Iec104FrameDecoder() { // maxFrameLength: 单帧最大255字节加帧头2字节给到260留余量 // lengthFieldOffset: 1长度字段在偏移1 // lengthFieldLength: 1长度字段占1字节 // lengthAdjustment: 0长度值控制域4字节ASDU长度加帧头2字节即整帧长度 // initialBytesToStrip: 0解码后保留完整帧后续Handler直接按偏移解析 super(260, 1, 1, 0, 0); } Override protected Object decode(ChannelHandlerContext ctx, ByteBuf in) throws Exception { if (in.readableBytes() 2) { return null; } int readerIndex in.readerIndex(); if (in.getUnsignedByte(readerIndex) ! 0x68) { // 帧头不是0x68丢弃一个字节继续找避免TCP流中间接入 in.skipBytes(1); return null; } return super.decode(ctx, in); } }逻辑说明先用in.getUnsignedByte(readerIndex)做帧头预检不是0x68就跳过1字节等下一个TCP段到达后继续找。Netty的LengthFieldBasedFrameDecoder本身只按长度字段切帧不保证帧头合法性所以这个预检是必要的。常见做法是把预检放在子类里重写decode或单独加一个ByteToMessageDecoder做前置过滤。参数说明maxFrameLength260是安全上限lengthFieldOffset1和lengthFieldLength1对照104帧结构一眼就能对上lengthAdjustment0是这套参数里最容易改错的点——很多人在别处见过lengthAdjustment-2的写法那是长度字段按“整帧长度”填的实现104标准按“控制域ASDU”填所以这里是0。initialBytesToStrip0意味着后续Handler拿到的ByteBuf是从0x68开始的完整帧解析控制域时从索引2开始解析ASDU时从索引6开始心智负担最小。如果你碰到某家主站把长度字段填成了整帧长度那只要把lengthAdjustment改成-2即可不用动其他任何代码这也是这个类的优势。2.3 区分I帧、S帧、U帧控制域的位级判断104的4字节控制域里第一个字节的低2位决定了帧类型bit00是I帧低2位为0b01是S帧低2位为0b11是U帧。I帧是有序号的用来承载ASDUS帧是纯确认帧只有接收序号U帧是链路控制帧做启动、停止、测试。这3类帧在一开始的解码器里就要分流否则后边的业务Handler会收到一堆不该处理的帧。public final class Iec104Frames { public static boolean isIFrame(ByteBuf frame) { return (frame.getUnsignedByte(2) 0x01) 0; } public static boolean isSFrame(ByteBuf frame) { return (frame.getUnsignedByte(2) 0x03) 0x01; } public static boolean isUFrame(ByteBuf frame) { return (frame.getUnsignedByte(2) 0x03) 0x03; } public static int getSendSeq(ByteBuf frame) { // I帧低2字节为发送序号bit0恒为0序号值/2 return (frame.getUnsignedShortLE(2) 1); } public static int getRecvSeq(ByteBuf frame) { // I帧和S帧高2字节为接收序号bit0恒为0 return (frame.getUnsignedShortLE(4) 1); } public static boolean isStartDtAct(ByteBuf frame) { return frame.getUnsignedByte(2) 0x07; } public static boolean isTestFrAct(ByteBuf frame) { return frame.getUnsignedByte(2) 0x43; } }逻辑说明getUnsignedShortLE按小端读2字节104的序号低位在前。控制域低2字节表示发送序号高2字节表示接收序号因为最低位是帧类型标记所以实际序号要右移1位。U帧没有序号第一个控制域字节取值固定0x07是STARTDT act0x0B是STARTDT con0x43是TESTFR act0x83是TESTFR con0x13是STOPDT act0x23是STOPDT con。这些值建议全部做成常量不要散落在Handler里用魔法数字硬比较。参数说明U帧的4字节控制域里只有第一个字节有意义后3字节固定为0。写判断时用getUnsignedByte(2)和整个字节比较不要用 0x03拆位因为U帧的标记是靠完整字节值表达的拆位会把0x07和0x43这些值搅乱。3. 用Netty搭104服务端会话状态机、线程池与四个Handler的职责3.1 服务端启动参数boss/worker/业务线程池的分工与TCP_NODELAY104服务端典型场景是一台子站网关同时接入多个调度主站每个主站一条TCP连接分别做链路激活、总召唤、遥控。Netty的服务端启动参数不需要特别夸张但线程池要分层boss组管acceptworker组管IO读写业务组管ASDU解析和数据上送。总召唤时子站要连续上送几百帧数据如果直接在worker线程里同步拼帧、查库、写库很容易把IO线程卡住导致其他链路的S帧迟迟发不出去。public class Iec104Server { public static void main(String[] args) throws Exception { int port 2404; // 104协议默认端口 EventLoopGroup boss new NioEventLoopGroup(1); EventLoopGroup worker new NioEventLoopGroup(Runtime.getRuntime().availableProcessors() * 2); // 业务线程池独立避免ASDU解析和业务查询阻塞IO线程 DefaultEventExecutorGroup business new DefaultEventExecutorGroup(4); try { ServerBootstrap bootstrap new ServerBootstrap(); bootstrap.group(boss, worker) .channel(NioServerSocketChannel.class) .option(ChannelOption.SO_BACKLOG, 128) .childOption(ChannelOption.TCP_NODELAY, true) .childOption(ChannelOption.SO_KEEPALIVE, true) .childHandler(new ChannelInitializerSocketChannel() { Override protected void initChannel(SocketChannel ch) { ch.pipeline().addLast(decoder, new Iec104FrameDecoder()); ch.pipeline().addLast(encoder, new Iec104FrameEncoder()); ch.pipeline().addLast(session, new SessionHandler()); ch.pipeline().addLast(business, asdu, new AsduHandler()); ch.pipeline().addLast(business, service, new ServiceHandler()); } }); ChannelFuture future bootstrap.bind(port).sync(); future.channel().closeFuture().sync(); } finally { boss.shutdownGracefully(); worker.shutdownGracefully(); business.shutdownGracefully(); } } }逻辑说明pipeline里5个Handler按序执行。decoder负责拆帧encoder负责拼帧出站session负责U帧握手和会话状态asdu负责把ByteBuf解析成ASDU对象service负责具体的业务分发和响应帧生成。把asdu和service挂到business线程池上是为了让总召唤的多帧上送不阻塞worker线程。TCP_NODELAY必须开104是请求-响应型协议主站发一帧等一帧Nagle算法会把小包攒在缓冲区里肉眼可见地增加几十毫秒延迟严重时触发主站侧T1超时。参数说明SO_BACKLOG128足够支撑几十个主站同时接入SO_KEEPALIVEtrue只是兜底TCP层keepalive默认2小时才探测一次104链路真正的保活靠TESTFR两者不要混为一谈。business线程数先给4总召唤数据量大的话全站几百个遥信遥测建议调到8或按CPU核数走避免上送任务排队。3.2 会话HandlerSTARTDT握手、测试帧应答、激活前数据丢弃104的链路是“先握手后传数”。主站连上TCP后必须先发STARTDT act子站回STARTDT con链路才进入激活状态。激活前收到的I帧按标准应当丢弃不能处理更不能回确认。测试帧TESTFR act收到后必须立即回TESTFR con这是主站判断链路活着的手段。这些都放在SessionHandler里因为它只关心控制域不关心ASDU内容。public class SessionHandler extends ChannelInboundHandlerAdapter { public static final byte STARTDT_ACT 0x07; public static final byte STARTDT_CON 0x0B; public static final byte TESTFR_ACT 0x43; public static final byte TESTFR_CON 0x83; public static final byte STOPDT_ACT 0x13; public static final byte STOPDT_CON 0x23; private volatile boolean activated false; Override public void channelRead(ChannelHandlerContext ctx, Object msg) { ByteBuf frame (ByteBuf) msg; try { if (Iec104Frames.isUFrame(frame)) { int cmd frame.getUnsignedByte(2); if (cmd STARTDT_ACT) { activated true; ctx.writeAndFlush(buildUFrame(STARTDT_CON)); System.out.println(收到STARTDT act链路已激活); } else if (cmd TESTFR_ACT) { ctx.writeAndFlush(buildUFrame(TESTFR_CON)); System.out.println(收到TESTFR act已回con); } else if (cmd STOPDT_ACT) { activated false; ctx.writeAndFlush(buildUFrame(STOPDT_CON)); System.out.println(收到STOPDT act链路已停止); } return; } if (!activated) { // 链路未激活时的I帧直接丢弃不回S帧 System.out.println(链路未激活丢弃I帧); return; } ctx.fireChannelRead(frame); } finally { if (frame.refCnt() 0) { frame.release(); } } } private ByteBuf buildUFrame(int cmd) { ByteBuf buf Unpooled.buffer(6); buf.writeByte(0x68); buf.writeByte(4); buf.writeByte(cmd); buf.writeByte(0); buf.writeByte(0); buf.writeByte(0); return buf; } }逻辑说明U帧处理完就直接return不再往后传I帧在未激活状态下丢弃不回S帧这是104标准里“从站侧未收到STARTDT con前不应处理数据”的落地实现。很多联调问题都出在未激活也回S帧导致主站以为链路通了实际上双方状态机已经错位。buildUFrame拼的是固定6字节帧0x68、长度4、命令字节、3个0。参数说明这里要注意帧的引用计数。ctx.fireChannelRead(frame)把帧传给下一个Handler后本Handler不再release但U帧和未激活的I帧由本Handler消费必须release。用finally块判refCnt()是防重复释放的惯用写法。3.3 编码器按seq拼帧发送序号与接收序号如何维护104的I帧要带发送序号和接收序号S帧只带接收序号。子站发给主站的每一帧序号都要严格递增。接收序号回的是“对方最近一帧发送序号1”。这里最容易写错的是序号翻倍104序号在控制域里存储时要左移1位最低位留给帧类型标记。public class Iec104FrameEncoder extends MessageToByteEncoderByteBuf { /** 子站侧发送序号 */ private int sendSeq 0; /** 子站侧接收序号回给主站用 */ private int recvSeq 0; Override protected void encode(ChannelHandlerContext ctx, ByteBuf msg, ByteBuf out) { // 传入的msg是待发送的ASDU部分 int asduLen msg.readableBytes(); int totalLen 4 asduLen; out.writeByte(0x68); out.writeByte(totalLen); // I帧控制域发送序号左移1位接收序号左移1位 out.writeShortLE(sendSeq 1); out.writeShortLE(recvSeq 1); out.writeBytes(msg); sendSeq; } public void setRecvSeq(int recvSeq) { this.recvSeq recvSeq; } }逻辑说明writeShortLE写入低字节在前符合104的小端序要求。sendSeq从0开始每发一帧自增1。recvSeq由外部设置——收到主站发的I帧后把主站的发送序号1传进来编码器在下一帧自动带上。S帧的构造可以直接复用编码器的拼帧逻辑单独写一个方法public ByteBuf buildSFrame() { ByteBuf buf Unpooled.buffer(6); buf.writeByte(0x68); buf.writeByte(4); buf.writeByte(0x01); // S帧标记 buf.writeByte(0x00); buf.writeShortLE(recvSeq 1); return buf; }参数说明S帧长度固定4控制域第一个字节为0x01后3字节里存接收序号。IRI帧的发送序号在低2字节S帧的确认序号也放在低2字节。若收到主站发来的重发请求主站收序号小于预期说明中间丢帧了子站需要从重发缓冲区取数据重新上送。简单实现里可以先把重发逻辑做成日志告警等联调时看主站是否强依赖。4. ASDU解析与业务上送总召唤、遥信遥测、遥控的落地实现4.1 从帧里切出ASDU类型标识、VSQ、传送原因、公共地址与信息体地址I帧的控制域后面就是ASDU。ASDU固定头包括类型标识1字节、VSQ 1字节、传送原因2字节、公共地址1字节或2字节、信息体地址3字节。公共地址的字节数在104标准文本里是2字节但国内不少主站/子站实现按1字节收发所以解析代码里把公共地址字节长度做成配置联调时按对方报文调整不要写死。public class AsduHandler extends ChannelInboundHandlerAdapter { /** 公共地址字节数按主站实现配置常见为1或2 */ private final int commonAddrSize; public AsduHandler(int commonAddrSize) { this.commonAddrSize commonAddrSize; } Override public void channelRead(ChannelHandlerContext ctx, Object msg) { ByteBuf frame (ByteBuf) msg; // 帧结构: 0x68(1) len(1) 控制域(4) ASDU int typeId frame.getUnsignedByte(6); int vsq frame.getUnsignedByte(7); int cot frame.getUnsignedShortLE(8); int commonAddr commonAddrSize 1 ? frame.getUnsignedByte(10) : frame.getUnsignedShortLE(10); int infoAddr frame.getUnsignedMediumLE(10 commonAddrSize); System.out.printf(typeId%d vsq%d cot%d commonAddr%d infoAddr%d%n, typeId, vsq, cot, commonAddr, infoAddr); // 按类型标识分发 dispatch(ctx, frame, typeId, vsq, cot, commonAddr, infoAddr); } private void dispatch(ChannelHandlerContext ctx, ByteBuf frame, int typeId, int vsq, int cot, int commonAddr, int infoAddr) { switch (typeId) { case 1: // M_SP_NA 单点遥信 parseSinglePoint(frame, vsq, infoAddr); break; case 13: // M_ME_NC 遥测(归一化值) parseMeasuredValue(frame, vsq, infoAddr); break; case 45: // C_SC_NA 单点遥控 handleRemoteCommand(ctx, frame, vsq, cot, commonAddr, infoAddr); break; case 100: // C_IC_NA 总召唤 handleGeneralCall(ctx, frame, vsq, cot, commonAddr, infoAddr); break; case 103: // C_CS_NA 时钟同步 handleClockSync(ctx, frame, cot, commonAddr, infoAddr); break; default: System.out.println(未处理的类型标识: typeId); } } }逻辑说明索引全是相对整帧算的0x68在索引0长度在索引1控制域占索引2到5ASDU从索引6开始。类型标识和VSQ各占1字节传送原因2字节小端公共地址按配置读1或2字节信息体地址3字节小端。getUnsignedMediumLE是Netty读3字节小端的标准方法。参数说明vsq低7位表示信息体个数最高位为1时表示后续还有同类型的continuation帧。解析时先记住rawInfoAddr每读一个信息体后递增VSQ只决定这批信息体数量不决定信息体地址步长——步长由类型标识决定单点遥信步长1字节遥测归一化值步长2字节。这块是解析里最琐碎也最容易出错的地方建议把每个类型标识的“单信息体字节长度”放进一个静态表。4.2 三大高频功能码遥信、遥测、总召唤的处理流程遥信M_SP_NA的信息体是1字节bit0是开关状态高4位是品质位。遥测M_ME_NC的信息体是2字节有符号整数低位在前配合缩放系数换算成实际值。总召唤C_IC_NA的处理是三段式先回激活确认再全量上送数据最后回激活终止——终止帧丢了主站永远认为总召唤没完成。这是104联调里最高频的翻车点必须在代码里死死咬住顺序。private void parseSinglePoint(ByteBuf frame, int vsq, int firstInfoAddr) { int count vsq 0x7F; int addr firstInfoAddr; for (int i 0; i count; i) { byte data frame.readByte(); int value data 0x01; int quality (data 4) 0x0F; System.out.printf(遥信地址%d 值%d 品质%d%n, addr, value, quality); addr; } } private void parseMeasuredValue(ByteBuf frame, int vsq, int firstInfoAddr) { int count vsq 0x7F; int addr firstInfoAddr; double scale 0.01; // 缩放系数按遥测点表配置 for (int i 0; i count; i) { short raw frame.readShortLE(); double val raw * scale; System.out.printf(遥测地址%d 原始值%d 实际值%.2f%n, addr, raw, val); addr 2; } }逻辑说明frame.readByte()和readShortLE()会推进读指针所以循环里不需要手动算偏移读完一个信息体指针自然前移。品质位的4个bit含义在104标准里有明确约定但实际工程里不同主站对品质位的处理差异很大——有的把bit4当有效位有的把bit7当无效位。代码里先把品质位整体打日志联调时对照主站画面确认再决定是否过滤无效数据。遥测归一化值是有符号的readShortLE读出的short可以直接乘系数。缩放系数在真实项目里来自点表配置不是协议标准写死的。有的主站对温度用0.1对电压用0.01对功率用0.001写死一处就会让整站数据看起来时好时坏。4.3 遥控与设定值的确认链路为什么不能只回一个S帧遥控命令C_SC_NA类型45是主站下发“合闸/分闸”的强操作子站收到后不能只回S帧确认收到必须回一个完整的遥控确认ASDU而且确认帧的传送原因要区分“确认”和“执行”。标准链路是主站发C_SC_NACOT6激活→ 子站回C_SC_NA确认COT7激活确认→ 子站执行 → 子站回C_SC_NA执行结束COT10激活终止或COT11?。private void handleRemoteCommand(ChannelHandlerContext ctx, ByteBuf frame, int vsq, int cot, int commonAddr, int infoAddr) { if (cot ! 6) { System.out.println(非激活遥控报文忽略); return; } // 读SCObit0-5为合/分bit6为执行/撤销 byte sco frame.readByte(); boolean close (sco 0x01) 1; boolean execute (sco 0x40) 0; System.out.printf(遥控地址%d 操作%s 执行%s%n, infoAddr, close ? 合闸 : 分闸, execute ? 执行 : 撤销); // 回确认帧 ByteBuf confirm buildRemoteConfirm(commonAddr, infoAddr, sco); ctx.writeAndFlush(confirm); } private ByteBuf buildRemoteConfirm(int commonAddr, int infoAddr, byte sco) { ByteBuf asdu Unpooled.buffer(16); asdu.writeByte(45); // 类型标识 C_SC_NA asdu.writeByte(1); // VSQ: 1个信息体 asdu.writeShortLE(7); // COT: 激活确认 asdu.writeByte(commonAddr); // 公共地址 asdu.writeMediumLE(infoAddr); // 信息体地址 asdu.writeByte(sco); // 回传原SCO return asdu; }逻辑说明确认帧的传送原因必须与原命令区分原命令COT6是“激活”确认帧COT7是“激活确认”执行完再回COT10“激活终止”。很多子站只回了一个S帧或者回错COT主站画面就会一直停留在“遥控未执行”状态。SCO字节建议原样回传让主站能核对操作对象和操作类型。参数说明writeMediumLE对应前面解析用的getUnsignedMediumLE信息体地址3字节小端。确认帧走I帧由编码器自动拼上发送序号和接收序号。要注意的是遥控执行往往涉及实际断路器/接触器动作代码里不要自动执行只做确认并抛给上层业务系统“确认收到”和“执行成功”是两个状态千万别在Netty Handler里直接操作设备。5. 104对接避坑清单5个让主站反复重连的细节5.1 长度字段理解错位能连上但解不出数据的“黑匣子”现象现象TCP连接建立成功STARTDT握手也正常但主站发过来的总召唤报文到了子站这边解析出来的类型标识和传送原因全对不上甚至第一个0x68后面的长度值都乱套。原因长度字段不含0x68和长度字节自身。如果解码器把lengthAdjustment设成-2或者实现方在生成报文时把整帧长度填进了长度字段两种规矩混在一起帧边界永远是错的。104是变长帧一旦边界错后续所有帧都跟着错位肉眼看到的日志就是一堆“未处理的类型标识”。解决用协议抓包工具对比一下主站发出的原始字节流数出长度字段实际值确认它是“控制域ASDU”还是“整帧长度”。前者lengthAdjustment0后者lengthAdjustment-2。我一般会在解码器里加一个统计计数器如果连续10帧的帧头都是0x68但长度字段超出255直接打印长度字段原始值能快速定位是哪一种实现。5.2 收到I帧没及时回S帧T1超时引发的连环重连现象主站侧日志出现T1超时然后主动断开连接重连后又超时反复循环。子站侧看着一切正常数据也在上报就是主站不认。原因104协议里子站收到主站发来的I帧后必须在一定时间内回一个S帧或I帧作为确认。这个时间由T1参数控制默认15秒。很多子站只在业务Handler里有数据时才回帧主站下发时钟同步或总召唤后子站解析了半天没回包主站的T1就超时了。更隐蔽的是回S帧的接收序号写错比主站预期的小主站认为丢包触发重发重发的帧又没被正确处理。解决收到I帧后立即回S帧不要等业务处理完。在SessionHandler里解析出I帧的发送序号后立刻用recvSeq 发送序号 1构建S帧发出去。总召唤这种重活先把S帧回了再去慢慢准备数据主站就知道“它收到了在处理”不会超时重连。血泪经验S帧和业务Handler的执行顺序不要反。5.3 公共地址和链路地址傻傻分不清多站配置时误判的根源现象单站联调一切正常接入第二个站址的数据时两个站的报文互相串主站把A站的遥信显示到B站上或者直接把连接踢掉。原因104的ASDU里有公共地址标识这帧属于哪个站部分厂家实现还会在控制域后面带2字节站地址。有的工程师把这两个字节当成公共地址有的把ASDU公共地址当成站地址写死偏移后多站配置时就乱套。解决解析代码里把“控制域后的2字节”和“ASDU公共地址”分开打印先看真实报文里这两个值分别在什么位置。公共地址按1字节还是2字节做成配置项用ConfigurationProperties或简单的Properties读取。站地址只在日志里展示不作为解析主键多站连接建议用ChannelGroup按Channel维度映射站号不要全局用一个公共地址。5.4 总召唤不回终止帧主站一直卡在“总召唤中”现象主站发起总召唤后子站把遥信遥测数据都上送了主站画面数据也刷新了但界面上“总召唤”状态一直转圈过几分钟又自动发一次。原因总召唤是三步握手激活确认、数据上送、激活终止。子站少发了COT10的终止帧主站只能靠自己判定“数据没上送完”于是反复总召唤。日志里看着一切正常实际上没有一个明确的收尾标志。解决在总召唤处理逻辑里用一个状态位记录当前是否在总召唤中数据全部上送完后必须调用sendGeneralCallTerminate(commonAddr)回一帧类型100、COT10、无信息体的报文。建议把“总召唤数据是否发完”做成一个计数器遥信、遥测、遥脉三块数据各自维护已发送数量全部归零后再发终止帧别在第三个数据块循环刚开始就发终止帧。5.5 测试帧不回con看似稳定的链路被主站单方面掐断现象TCP连接没有断开子站日志也没有任何错误但主站每隔一段时间就重新发起STARTDT握手。子站侧看是“链接正常”主站侧却是“通道中断”。原因主站发送TESTFR act后子站必须在T3时间内回TESTFR con。T3默认是20秒如果子站没有识别出U帧的0x43并回0x83主站就认为链路不活跃主动断开重连。这个坑最阴的地方在于子站的Netty服务端线程一直活着TCP层也没有RST从服务器进程看完全健康。解决TESTFR con的处理要放在会话Handler的最前面优先级高于业务数据。我在SessionHandler里单独处理U帧任何情况下只要读到0x43立即回0x83不经过ASDU解析和业务线程池。联调时可以在服务端加一个U帧计数日志主站发一次TESTFR就打印一次避免“根本没收到”和“收到了没回应”两个现象混淆。6. 本地验证用一段模拟主站脚本把104服务端跑通6.1 最小模拟主站STARTDT激活与总召唤真实主站不在手边时用一小段Python脚本模拟主站行为能省掉大量联调时间。脚本的核心逻辑就是104主站侧的启动流程连上TCP、发STARTDT act、解析回包、发总召唤、打印后续每一帧的类型和关键字段。有了这段脚本服务端代码的每次改动都能在几十秒内得到验证。import socket HOST 127.0.0.1 PORT 2404 sock socket.create_connection((HOST, PORT), timeout5) def send_hex(hex_str: str): sock.sendall(bytes.fromhex(hex_str)) def recv_frame(): 按104长度字段读取一帧返回字节串 head b while len(head) 2: head sock.recv(2 - len(head)) length head[1] body b while len(body) length: body sock.recv(length - len(body)) return head body # 1. 链路激活 send_hex(68 04 07 00 00 00) # STARTDT act frame recv_frame() print(STARTDT con:, frame.hex()) # 2. 发送总召唤I帧发送序号0接收序号0 # 帧结构: 68 0E 控制域(00 00 00 00) 站地址(00 00) ASDU # ASDU: 64(总召唤) 01(VSQ1) 06 00(COT激活) 01(公共地址) 00 00 00(信息体地址) send_hex(68 0E 00 00 00 00 00 00 64 01 06 00 01 00 00 00) while True: frame recv_frame() ctrl0 frame[2] if (ctrl0 0x03) 0b01: print(S帧(确认):, frame.hex()) elif (ctrl0 0x01) 0: print(I帧(数据):, frame.hex()) else: print(U帧:, frame.hex())逻辑说明脚本先发一个U帧STARTDT act然后等子站回0x0B收到后正式发总召唤命令。总召唤的报文构造严格按照前面讲的帧结构0x68 0x0E长度14表示控制域4字节加站地址2字节加ASDU 8字节控制域全0表示发送序号0、接收序号0ASDU里的64是总召唤类型标识COT6表示激活公共地址1信息体地址0x000000。脚本里recv_frame按长度字段循环读取能正确处理粘包。参数说明如果子站服务端配置的端口不是2404改脚本开头的PORT即可。公共地址如果用了2字节配置把总召唤帧里的01改成01 00同时把ASDU长度从8改成9总长度从14改成15否则子站解析会错位。这也是一个很好的联调练习每改一处看子站日志报什么错。6.2 验证要点帧序、S帧间隔、品质位与重连表现脚本跑通只是第一步真正的验证要看几个容易出问题的细节。第一个是帧序完整性总召唤后子站应依次返回总召唤确认、一批I帧数据、总召唤终止顺序不能乱。第二个是S帧间隔脚本里收到I帧后可以记录时间如果子站超过10秒才回S帧多半是业务线程池阻塞要回代码里查DefaultEventExecutorGroup的排队情况。第三个是品质位脚本打印出来的遥信遥测数据里品质位是否全零如果有非零值主站画面上可能显示“无效”或“取代”这类问题在脚本阶段就能提前发现。第四个验证点是重连表现脚本主动断连后立即重连观察子站是否还能正常处理STARTDT握手。有的服务端实现里Channel关闭时没有清理会话状态重连后新连接复用旧状态导致握手回包异常。我的做法是在channelInactive里显式把会话状态置为未激活并移除ChannelGroup中的旧引用。这个习惯帮我在现场少踩了好几次坑。最后建议上真实主站前先用脚本把总召唤、单点遥控、时钟同步三条主链路全部跑通保存脚本输出为文本留档。等现场联调时主站侧一有异常第一件事就是对比“模拟主站输出”和“真实主站报文”的差异通常问题都出在报文偏移或COT取值上而不是服务端框架本身。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进