☰
Java实现电力104协议对接:基于Netty自研主站服务全解析
2026/9/28 15:47:41 网站建设 项目流程

简介:Java与Netty实现的电力104协议服务端源代码,面向电力系统通信开发者及Java网络编程学习者。项目依据IEC 60870-5-104标准,构建了完整的服务端通信框架,覆盖RTU与SCADA调度中心之间的远程数据交换。核心代码涉及ASDU应用服务数据单元的解析与封装、控制信息字段处理、二进制及ASCII格式的编解码、报文校验与异常重传、超时与连接恢复机制等,实现了从网络层到业务层的模块化解耦。压缩包共61个文件,其中包含55个Java源文件,另有properties配置文件用于参数调整、xml工程文件辅助项目构建、jar依赖包支持运行,以及README说明帮助快速上手;整体仅105KB,结构清晰便于检索。已有134人学习浏览。借助此源码,可深入理解Netty异步非阻塞I/O模型在工业协议通信中的落地方式,学习104协议报文的分层解析与状态机设计,同时掌握高并发连接管理、性能优化和容错策略,适合具备Java基础并希望参与电力自动化系统开发的读者作为实战参考。

1. Java实现电力104协议对接:为什么我选了Netty自研服务

最近在给一个地区调度做数据接入,任务落到手里就是 Java实现电力104协议对接 netty服务源代码。一开始我也想直接用现成的开源协议栈,翻了一圈发现两个问题:一是版本停在老框架上,二是一致性测试出问题时你根本无从下手。最后还是决定用Netty自己写主站服务端,协议解析、链路状态机、编解码器全部自己控制。电力104协议(IEC 60870-5-104)本质上是运行在TCP长连接上的应用层帧协议,Netty的事件驱动模型和ByteBuf正好把“粘包拆包”这个最烦人的问题变成管道里的一小段逻辑。这篇文章适合两类人:一类是刚接手电力接入项目、需要快速跑通从站数据采集的Java后端;另一类是已经被“链路正常但数据不刷新”折磨过、想搞明白104链路状态机的工程师。

2. 先把104帧结构拆明白:APCI与ASDU的字节布局

2.1 I帧、S帧、U帧:三种控制域决定链路怎么走

IEC 60870-5-104把一帧数据切成两个部分:APCI(应用协议控制信息,固定4字节)和ASDU(应用服务数据单元,可变长)。TCP流里看到的每一帧都是这样排布的:

启动字符长度L控制域(APCI)ASDU
0x681字节4字节L-4字节

长度L是关键,它表示从控制域第一个字节开始到帧尾的字节数,也就是4加ASDU长度。最小L是4(纯控制帧),最大不能超过253,因为L本身只有1字节。注意L不含启动字符0x68和L自己,所以解码时一帧完整长度是2加L。

控制域的前4个字节决定这帧属于哪种类型。I帧最常用,承载遥测、遥信、遥控报文,带发送序号和接收序号,需要对方确认;S帧只带接收序号,用来确认收到哪些I帧,不带ASDU;U帧不带序号,专门做链路控制,典型命令是STARTDT(启动数据传输)、STOPDT(停止数据传输)、TESTFR(测试帧,也就是心跳探测)。判断方式很简单:控制域第一个字节的最低位bit0为0就是I帧,为1再看具体值,0x01是S帧,0x07、0x0B、0x13、0x43这些就是U帧的各种命令。

这里有个新手必踩的认知误区:104协议虽然跑在TCP上,但它自己维护了一套传输确认机制,不是TCP确认了就完事。TCP只保证字节流到达,104应用层还要检查“序号连续不连续、该确认的是不是确认了”。所以写业务前,先把I帧的发送序号和接收序号怎么增减搞清楚,否则后面联调时你会看到从站一直不回数据。

2.2 ASDU里装着遥测、遥信和电度:类型标识与信息体地址

ASDU才是真正有业务含义的部分。常见的ASDU结构是:

类型标识可变结构限定词VSQ传送原因COT公共地址信息体
1字节1字节2字节2字节N个信息体

类型标识决定后面信息体怎么解释:类型1是单点遥信,类型13是带品质的短浮点遥测,类型100是总召唤命令,类型30是带时标的单点遥信。VSQ的低7位表示本帧包含几个信息体,最高位表示信息体地址是否连续;COT的低6位是传送原因,比如1是周期、3是突发、6是激活、20是响应总召。公共地址是变电站站地址,很多项目里一台主站接几十个站,就是靠这个字段区分数据属于哪台从站。

信息体地址是3字节,低位在前排列。它不像HTTP URL有统一语义,在104里遥测、遥信、电度分别落在哪个地址段,完全看厂站的点表配置。我一般会把这部分做成配置映射:收到信息体地址后查表,转成内部测点名。千万别在代码里硬编码地址,不同厂家、不同工程差距非常大。

2.3 把帧结构映射成Java对象:字段设计与字节顺序

写104对接源码,我习惯不引入太重的协议类框架,直接用byte数组和ByteBuf操作,自己封装一个帧对象。字节序这块必须从一开始就统一:104的多字节字段是低字节在前(Little-Endian),信息体地址、公共地址、短浮点数都是这样。用Netty的ByteBuf时,默认大端序反而容易踩坑,所以我解析时全部用手工读字节再组装。

public class Iec104Frame { public static final byte START_BYTE = 0x68; private final byte[] ctrl = new byte[4]; private byte[] asdu; public Iec104Frame() { } public Iec104Frame(byte[] ctrl, byte[] asdu) { System.arraycopy(ctrl, 0, this.ctrl, 0, 4); this.asdu = asdu; } public static Iec104Frame buildIFrame(int sendSeq, int recvSeq, byte[] asdu) { Iec104Frame frame = new Iec104Frame(); // 发送序号和接收序号都左移1位写入,最低位留作帧类型标志 frame.ctrl[0] = (byte) ((sendSeq << 1) & 0xFF); frame.ctrl[1] = (byte) ((sendSeq >> 7) & 0xFF); frame.ctrl[2] = (byte) ((recvSeq << 1) & 0xFF); frame.ctrl[3] = (byte) ((recvSeq >> 7) & 0xFF); frame.asdu = asdu; return frame; } public static Iec104Frame buildSFrame(int recvSeq) { Iec104Frame frame = new Iec104Frame(); frame.ctrl[0] = 0x01; frame.ctrl[1] = 0x00; frame.ctrl[2] = (byte) ((recvSeq << 1) & 0xFF); frame.ctrl[3] = (byte) ((recvSeq >> 7) & 0xFF); frame.asdu = new byte[0]; return frame; } public static Iec104Frame buildUFrame(byte command) { Iec104Frame frame = new Iec104Frame(); frame.ctrl[0] = command; frame.ctrl[1] = 0x00; frame.ctrl[2] = 0x00; frame.ctrl[3] = 0x00; frame.asdu = new byte[0]; return frame; } public boolean isIFrame() { return (ctrl[0] & 0x01) == 0; } public int getSendSeq() { if (!isIFrame()) { throw new IllegalStateException("不是I帧"); } return ((ctrl[0] & 0xFF) >> 1) | ((ctrl[1] & 0xFF) << 7); } public int getRecvSeq() { return ((ctrl[2] & 0xFF) >> 1) | ((ctrl[3] & 0xFF) << 7); } public boolean isSFrame() { return (ctrl[0] & 0xFF) == 0x01; } public byte[] getCtrl() { return ctrl; } public byte[] getAsdu() { return asdu; } }

这个类把帧类型判断和序号读写收敛在一起,后续编解码器只用它。注意buildIFrame里序号左移一位的写法,这是104规约里序号的真实落盘方式:序号本身加一条最低位标识,所以收到0x02代表的发送序号是1,不是2。如果你用别的开源实现,一定要先确认它有没有做这一步移位,否则联调时序号永远对不上。

3. 用Netty搭一个104主站服务:工程结构与链路建立

3.1 Maven依赖与最少类清单

先解决依赖。Netty推荐4.1.x,用最普通的聚合包就够了:

<dependency> <groupId>io.netty</groupId> <artifactId>netty-all</artifactId> <version>4.1.100.Final</version> </dependency>

不要为了省依赖去引netty-handler单独包,后面调试粘包时你会发现缺了ByteBuf相关工具类。工程结构上最少需要四个类:服务启动类、ChannelInitializer、帧解码器、帧编码器,加上链路状态Handler。我自己还会单独拆一个ASDU解析类,避免Handler越来越胖。

3.2 启动入口:EventLoopGroup与ChannelInitializer怎么配

104主站服务本质上是一个TCP服务端,监听2404端口等着各从站连上来。Netty的启动参数不要照抄HTTP服务的套路,104长连接的特点是连接数量不算大但每路连接消息持续不断,所以worker线程数按CPU核数配,别贪多。

public final class Iec104Server { public static void main(String[] args) throws Exception { EventLoopGroup bossGroup = new NioEventLoopGroup(1); EventLoopGroup workerGroup = new NioEventLoopGroup( Math.max(2, Runtime.getRuntime().availableProcessors() * 2)); try { ServerBootstrap bootstrap = new ServerBootstrap(); bootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .option(ChannelOption.SO_BACKLOG, 128) .childOption(ChannelOption.TCP_NODELAY, true) .childOption(ChannelOption.SO_KEEPALIVE, true) .childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) { ch.pipeline().addLast("frameDecoder", new Iec104FrameDecoder()); ch.pipeline().addLast("frameEncoder", new Iec104FrameEncoder()); ch.pipeline().addLast("linkHandler", new Iec104LinkHandler()); } }); ChannelFuture future = bootstrap.bind(2404).sync(); future.channel().closeFuture().sync(); } finally { bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); } } }

SO_BACKLOG设成128足够,104从站一般就几十个。TCP_NODELAY必须开,104报文交互频繁,Nagle算法会把小报文攒着不发,直接导致链路握手看起来像卡死。SO_KEEPALIVE开的是TCP层保活,但真正的心跳是应用层的TESTFR帧,TCP保活只是兜底,别指望它。

3.3 握手时序:U帧STARTDT怎么发、确认怎么收

TCP连接建立后,主站不能直接发遥测查询,必须先走一遍104链路握手:主站发STARTDT激活帧(U帧0x07),从站回STARTDT确认帧(U帧0x0B),确认后才允许收发I帧。很多第一次写的人把握手当成可有可无的步骤,结果从站一律不回业务数据。

链路状态机的四个状态是:未连接、等待STARTDT确认、运行、停止。我把状态迁移放在Iec104LinkHandler里,用AtomicReference保存状态,避免多线程读脏。

public class Iec104LinkHandler extends SimpleChannelInboundHandler<ByteBuf> { private enum LinkState { WAIT_START_CON, STARTED, STOPPED } private final AtomicReference<LinkState> state = new AtomicReference<>(LinkState.WAIT_START_CON); private int sendSeq; private int recvSeq; private ScheduledFuture<?> testFrameTask; @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { // 连接建立,立刻发STARTDT激活 Iec104Frame startdt = Iec104Frame.buildUFrame((byte) 0x07); writeFrame(ctx, startdt); // T3超时后发TESTFR心跳,这里先按20秒一轮 testFrameTask = ctx.executor().scheduleWithFixedDelay(() -> { if (state.get() == LinkState.STARTED) { writeFrame(ctx, Iec104Frame.buildUFrame((byte) 0x43)); } }, 20, 20, TimeUnit.SECONDS); } @Override protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) throws Exception { // 复制成byte[]再解析,避免ByteBuf被后续业务线程池释放 byte[] frameBytes = new byte[msg.readableBytes()]; msg.readBytes(frameBytes); Iec104Frame frame = parseFrame(frameBytes); if (!frame.isIFrame() && !frame.isSFrame() && frame.getCtrl()[0] == (byte) 0x0B) { // STARTDT确认,链路进入运行态,接着发总召 if (state.compareAndSet(LinkState.WAIT_START_CON, LinkState.STARTED)) { sendGeneralCall(ctx); } } else if (frame.isIFrame()) { // 处理遥测、遥信数据,处理完回S帧 handleASDU(frame.getAsdu()); recvSeq++; writeFrame(ctx, Iec104Frame.buildSFrame(recvSeq)); } } private void sendGeneralCall(ChannelHandlerContext ctx) { // 总召:类型100、COT=6激活、公共地址按站配置、信息体地址0、QOI=0x14 byte[] asdu = buildGeneralCallAsdu(); Iec104Frame frame = Iec104Frame.buildIFrame(sendSeq++, recvSeq, asdu); writeFrame(ctx, frame); } private void writeFrame(ChannelHandlerContext ctx, Iec104Frame frame) { // 这里走编码器统一输出 ctx.writeAndFlush(frame); } }

细看这个逻辑,我把发送序号和接收序号放在链路Handler里,每路连接一份,不会串。收到I帧后先业务处理再回S帧,S帧携带的接收序号是我期望的下一个序号,也就是当前收到序号加1。如果漏了这步,从站发送窗口很快会被打满,表现就是“连接在,数据停”。

TESTFR心跳是这里容易被忽略的点。从站端通常要求主站定时发测试帧,超过一定时间没收到就判定主站失联并断开链路。我的经验是T3按20秒周期配,连续发两次没有TESTFR确认就主动关连接重连,比干等TCP超时快得多。

4. 编解码器实现:粘包拆包与APDU解析

4.1 基于长度字段的拆包:FrameDecoder核心逻辑

粘包拆包是Netty对接104绕不开的一关。TCP是字节流,上层发的一帧可能被拆成两半,也可能好几帧黏在一起。104自带长度字段L,所以用固定头+长度字段的方式拆,这是最稳的。我写了一个自定义的ByteToMessageDecoder,没有直接用LengthFieldBasedFrameDecoder,是因为我需要校验同步头0x68和长度范围,发现脏数据时能主动丢弃重找。

public class Iec104FrameDecoder extends ByteToMessageDecoder { @Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) { if (in.readableBytes() < 2) { return; } in.markReaderIndex(); byte start = in.readByte(); if (start != 0x68) { // 没有同步头,丢掉当前字节继续找 in.resetReaderIndex(); in.skipBytes(1); return; } int len = in.readUnsignedByte(); if (len < 4 || len > 253) { // L非法,丢掉0x68,重新同步 in.resetReaderIndex(); in.skipBytes(1); return; } if (in.readableBytes() < len) { // 半包,等下一个TCP段 in.resetReaderIndex(); return; } byte[] frame = new byte[2 + len]; in.resetReaderIndex(); in.readBytes(frame); out.add(Unpooled.wrappedBuffer(frame)); } }

这里最关键的细节是半包处理。in.readableBytes() < len时不能直接把已读的头丢出去,要resetReaderIndex回到帧头位置,等后续字节到齐后再重新走一遍。否则下次decode进来时,读取位置已经在半包数据中间,永远拼不出完整帧。我见过有人在半包分支里直接return不reset,结果每帧数据都被截断,联调时全是“接收序号不连续”。

脏数据处理也要讲方法。start不等于0x68时只丢1个字节再重新找,不能整批丢弃,因为0x68可能就藏在下一个字节。L非法时同样只丢同步头,把后面内容留给下次解析继续找。这种“逐字节滑窗”看起来慢,但104流量不大,几路从站完全扛得住。

4.2 编码器:从Java对象写出68帧

编码器反过来,把Iec104Frame对象写回ByteBuf。MessageToByteEncoder会自动处理每个出站消息,注意writeAndFlush的线程模型,这个方法会在IO线程执行,所以编码器里不要做耗时操作。

public class Iec104FrameEncoder extends MessageToByteEncoder<Iec104Frame> { @Override protected void encode(ChannelHandlerContext ctx, Iec104Frame msg, ByteBuf out) { byte[] ctrl = msg.getCtrl(); byte[] asdu = msg.getAsdu(); out.writeByte(0x68); out.writeByte(4 + asdu.length); out.writeBytes(ctrl); out.writeBytes(asdu); } }

这个编码器有个默认约定:Iec104Frame的ASDU长度不能超过249,否则L会溢出变成负数。ASDU本身按规约最大248字节,实际业务报文很少到这么大。但如果你在写转发代理,把一个长ASDU原样塞进来,最好在构建Iec104Frame时先查一下长度,超过就分帧。

4.3 解析遥测数据:类型标识13与品质描述

解码器拆出来的只是完整帧,真正的业务解析在ASDU上。我用一个静态方法处理最常见的类型13短浮点遥测,顺便把品质位拆出来,让上层能判断数据是否有效。

public final class AsduParser { public static final int TYPE_M_ME_NC = 13; // 短浮点遥测 public static final int TYPE_M_SP_NA = 1; // 单点遥信 public static List<MeasPoint> parseMeas(byte[] asdu) { List<MeasPoint> points = new ArrayList<>(); int type = asdu[0] & 0xFF; int vsq = asdu[1] & 0xFF; int count = vsq & 0x7F; boolean continuous = (vsq & 0x80) == 0; // COT在asdu[2..3],公共地址在asdu[4..5],跳过这6个字节到信息体 int pos = 6; int addr = readUInt24(asdu, pos); pos += 3; for (int i = 0; i < count; i++) { if (type == TYPE_M_ME_NC) { float value = readFloatLE(asdu, pos); int quality = asdu[pos + 4] & 0xFF; points.add(new MeasPoint(addr, value, quality)); pos += 5; } else if (type == TYPE_M_SP_NA) { int value = asdu[pos] & 0x01; int quality = (asdu[pos] >> 4) & 0x0F; points.add(new MeasPoint(addr, value, quality)); pos += 1; } if (continuous) { addr++; } else { addr = readUInt24(asdu, pos); pos += 3; } } return points; } private static int readUInt24(byte[] data, int offset) { return (data[offset] & 0xFF) | ((data[offset + 1] & 0xFF) << 8) | ((data[offset + 2] & 0xFF) << 16); } private static float readFloatLE(byte[] data, int offset) { int bits = (data[offset] & 0xFF) | ((data[offset + 1] & 0xFF) << 8) | ((data[offset + 2] & 0xFF) << 16) | ((data[offset + 3] & 0xFF) << 24); return Float.intBitsToFloat(bits); } }

注意readFloatLE,这是全网最容易翻车的地方。短浮点按IEEE 754标准在104线路上用小端字节序传输,直接用ByteBuf.getFloat(默认大端)读出来的数会大得离谱,或者是个负数。我一开始就是吃这个亏,后来对照录波文件才定位到是字节序问题。品质位也不能忽略,从站检修时品质字节会变成无效或者溢出,你不判断就入库,调度大屏上会飘着一条冻住的死数据。

5. 量产接入避坑记录:超时、序号与双端状态机

5.1 现象:链路起来了,遥测数据却总是“不刷新”

原因:STARTDT确认后没有发总召,或者总召的传送原因写错。104协议里从站默认不会主动把所有数据推给主站,主站必须发C_IC_NA类型100的总召唤命令,从站才会把全站遥测、遥信以突发方式传上来。只建链不发总召,链路是通的,数据表永远是空的。

解决:在收到0x0B(STARTDT确认)后立即构造总召ASDU并作为I帧发送。总召的ASDU必须带COT=0x06(激活)、信息体地址全0、召唤限定词QOI=0x14(总召唤),缺一项从站都可能不回。我后来把“发总召”这个动作直接绑进链路状态机的STARTED状态迁移里,不依赖外部定时器去触发,才彻底解决。

还有一层坑:总召发出后,从站会先回一个总召确认帧(COT=0x07),然后才是大量遥测数据。有些主站实现把“确认帧”当成“数据到了”提前结束等待,导致展示层只拿到空表。

5.2 现象:从站重启后,主站一直报接收序号不连续

原因:从站重启后发送序号清零,主站侧还记着重启前的接收序号。双方序号基准不一致,I帧校验直接失败,链路看起来“活着”但业务数据全被丢弃。

解决:发现接收序号不连续时,不要硬着头皮继续等,主动断开连接重建。重建流程里把发送序号、接收序号全部清零,重新走STARTDT握手。这是最干净的做法,不要尝试在旧序号基础上做偏移补偿,104状态机不认。同时把TESTFR心跳重传逻辑补上:T3超时发一次TESTFR,没确认再发一次,连续两次无响应就断链重连,这个“心跳包重传”逻辑能帮你从站端异常恢复时自动把链路拉回来。

5.3 现象:压测时内存飙高、GC频繁

原因:解码器产出的ByteBuf没有被正确释放。如果直接把ByteBuf传给业务线程池,业务还在异步处理时IO线程可能已经把它归还池子,数据读到一半变乱码;反过来如果不释放,内存池会被占满,GC压力全上来。

解决:我统一在channelRead0里先把ByteBuf复制成byte[],再交给业务线程池。这样IO线程的ByteBuf在方法结束后由SimpleChannelInboundHandler自动释放,业务线程只管普通byte数组,不存在生命周期竞争。代价是多一次拷贝,但104数据量远没到需要零拷贝的规模,这一份拷贝换来的稳定性非常值。

Netty的线程模型也要说清楚:channelRead0默认跑在EventLoop线程,任何阻塞操作都会卡住整个连接的所有消息。写数据库、调第三方接口必须甩给独立业务线程池。我用一个固定线程池做ASDU入库,线程数按核数配,遇到从站突发几百个遥测点时不会被IO线程拖死。

5.4 现象:同时接几十个从站,报文串站了

原因:把公共地址或链路状态做成了静态变量。104主站一般同时服务几十个从站,每个从站的公共地址不同、发送窗口不同,如果用一个全局Map存接收序号,两个从站同时收发时互相覆盖,报文解析出来全变成另一站的地址。

解决:所有链路状态都必须绑在Channel上。我的Iec104LinkHandler用@ChannelHandler.Sharable标注的类里不允许有非线程安全的成员变量,连接相关的序号、状态全部放在单独的对象里,通过AttributeKey附着在Channel上。公共地址从ASDU里读取后,再由连接对应的点表配置做映射,不能从全局缓存里按“当前站”取。

这个坑的排查成本很高,因为串站信号在测试环境很稳定,一旦上生产接入几十个站就偶发。我用录波文件回放才发现是状态共享导致序号互相污染,最后把所有可变状态收敛到连接维度才根治。

6. 验证与进阶:用模拟从站做一致性回放

6.1 先做一个104子站模拟器

正式联调前,我会先写一个极简模拟从站,反向校验自己的主站源码。模拟器起一个ServerSocket,收到0x07后回0x0B,再收到总召后给对方发固定几帧遥测。这个模拟器不用做全,能验证握手、总召、遥测解析就够。

public class Iec104Simulator { public static void main(String[] args) throws Exception { EventLoopGroup group = new NioEventLoopGroup(1); ServerBootstrap b = new ServerBootstrap(); b.group(group) .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) { ch.pipeline().addLast(new SimpleChannelInboundHandler<ByteBuf>() { @Override public void channelActive(ChannelHandlerContext ctx) { // 主动连上来的是主站,等STARTDT } @Override protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) { byte[] data = new byte[msg.readableBytes()]; msg.readBytes(data); if ((data[2] & 0xFF) == 0x07) { // 回STARTDT确认 ctx.writeAndFlush(Unpooled.wrappedBuffer( new byte[]{0x68, 0x04, 0x0B, 0x00, 0x00, 0x00})); } else if ((data[2] & 0xFF) == 0x02 || (data[2] & 0xFF) == 0x00) { // 模拟回一帧遥测:类型13,公共地址1,信息体地址1 byte[] frame = buildMeasureFrame(); ctx.writeAndFlush(Unpooled.wrappedBuffer(frame)); } } }); } }); b.bind(2404).sync().channel().closeFuture().sync(); } }

模拟器跑在你自己的机器上,用主站程序去连它。这个反向验证能过滤掉八成的基础问题:编解码时序、长度计算、字节序、握手状态。比直接上电科院一致性测试省心太多。

6.2 把录波报文回放成测试用例

从站厂家通常会提供录波文件,或者你在联调现场用抓包工具录一段真实报文。把其中的十六进制字节流存成测试资源,在JUnit里直接喂给FrameDecoder,断言能拆出预期的帧数、解析出正确的遥测值。

@Test public void testDecodeRealPacket() { byte[] realFrame = hexToBytes( "68 0E 02 00 00 00 64 01 06 00 01 00 00 00 00 00 14"); ByteBuf in = Unpooled.wrappedBuffer(realFrame); Iec104FrameDecoder decoder = new Iec104FrameDecoder(); EmbeddedChannel channel = new EmbeddedChannel(decoder); channel.writeInbound(in); ByteBuf out = channel.readInbound(); assertArrayEquals(realFrame, ByteBufUtil.getBytes(out)); }

这种回放测试的价值在于:现场问题发生时,你不需要让厂家复现,直接把当时的原始报文丢进测试环境,改一版测一版。我修过一个非常隐蔽的帧长边界问题,就是靠回放定位的。

6.3 上线前自查清单

检查项自检标准
长度校验解码器拒绝L小于4和大于253的帧
序号复位断线重连后发送、接收序号归零重走握手
总召触发STARTDT确认后自动下发C_IC_NA总召
TESTFR心跳T3超时发测试帧,连续失败断链重连
字节序公共地址、信息体地址、浮点数全部小端解析
业务线程池IO线程内无数据库、无阻塞等待
状态隔离每连接独立状态,禁止全局共享

还有一条实战教训:上线前一定把日志里的十六进制报文保留一份。104问题诡异起来,链路通、数据错、指数级跳变,没有原始报文你只能猜。后来我把所有收发的原始帧都按连接归档到日志文件,排查时间至少省一半。这套自研方案做完之后,我对从站异常重连、序号错乱这类问题的可控程度,是直接用黑匣子开源包永远达不到的。希望帮到你。

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

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

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

立即咨询