简介:面向电力自动化与工业通信开发者的IEC 104主站通信源码,聚焦SCADA系统中主站与远方子站之间的数据采集与控制交互。代码围绕连接建立与心跳维持、遥测遥信数据请求、遥控与参数设置命令下发、ASDU报文解析以及超时重传等异常处理展开,并预留数据库接口、时间同步、安全认证与运行日志等模块,可作为理解规约实现细节、搭建调试环境的参考工程。压缩包共28个文件,约302KB,以cpp与h源文件为主体,配合o目标文件、vcproj/sln/dsw等工程配置及mak编译脚本,另附ReadMe说明,兼顾Windows与类Unix环境下的编译查看。已有834人学习下载,适合具备网络编程基础、希望从代码层面吃透IEC 104主站通信流程的中高级开发者对照研读与二次开发。
1. 从一台储能柜的遥测卡死说起:IEC 104 主站源码到底解决什么问题
调度画面上有台储能柜的 SOC 卡在 47% 一动不动,现场换了采集板、重启了通信管理机、把网线拔插了三遍,问题照旧。最后翻日志才发现,是 IEC 104 主站源码里那个总召周期被写死成了 15 分钟,子站只在变位时才补发,而变位那一帧的类型标识又没在解析分支里注册,被当成未知 ASDU 静默丢弃了。这类问题在配电房远动接入、储能 EMS、光伏电站并网监控里几乎每周都会碰到,也是很多人第一次拿 104 规约做对接时最难受的地方——协议本身只有薄薄几页,现场的坑却全藏在时序和边界里。
这份 IEC 104 主站源码要做的就是把这层黑匣子拆开:APDU 的三种帧格式、STARTDT/STOPDT 链路状态机、k/w 窗口流控、总召与分组召、遥控选择与执行、时钟同步、SOE 时标解析,全部落在可读的代码里,业务侧只需要面对“点位号”和“值”,不用再手拼十六进制。它适合做电力监控、能源管理、工业网关的开发者,尤其是手里捏着一份点表和一个子站模拟器、准备从零接一套 104 主站的人。
2. 协议骨架拆解:APDU 报文格式与主站侧状态机
读这套源码之前,最好先把 IEC 60870-5-104 的帧结构在脑子里摆正。它跑在 TCP 之上,默认端口 2404,一个 APDU 由“启动字符 + 长度 + 控制域 + ASDU”四段拼出来,长度字段只算控制域加 ASDU,最大 253。把这个边界记住,后面所有粘包、拆包的问题都能自己推出来。
2.1 三种帧格式:I 帧、S 帧、U 帧
控制域是 4 个字节,靠最低两位区分帧类型。I 帧承载实际业务数据,带发送序号 N(S) 和接收序号 N(R),各占 15 位;S 帧只做确认,不带数据,只带 N(R);U 帧承载链路控制命令,比如启动传输、停止传输、测试链路。源码里这三类帧的编解码通常放在同一组工具类里,下面这段是解析入口的典型写法。
public static Apdu decode(byte[] body) { int c1 = body[0] & 0xFF, c2 = body[1] & 0xFF; int c3 = body[2] & 0xFF, c4 = body[3] & 0xFF; if ((c1 & 0x01) == 0) { // I 帧:bit0 = 0 int ns = ((c1 >> 1) | (c2 << 7)) & 0x7FFF; int nr = ((c3 >> 1) | (c4 << 7)) & 0x7FFF; byte[] asdu = Arrays.copyOfRange(body, 4, body.length); return Apdu.iFrame(ns, nr, asdu); } if ((c1 & 0x03) == 0x01) { // S 帧:bit0 = 1, bit1 = 0 int nr = ((c3 >> 1) | (c4 << 7)) & 0x7FFF; return Apdu.sFrame(nr); } // U 帧:bit0 = 1, bit1 = 1,功能码在 bit2~bit7 return Apdu.uFrame(c1 & 0xFC); }这段逻辑的关键在于先判 bit0 再判 bit1,不能反过来。N(S) 和 N(R) 是 15 位无符号数,溢出后按 32768 回绕,取值时必须& 0x7FFF,否则序号一旦跨过 32767 就会变成负数,窗口比对直接失效。U 帧的功能码用c1 & 0xFC取高六位,常见的几个值是 0x04 启动激活、0x08 启动确认、0x10 停止激活、0x20 停止确认、0x40 测试激活、0x80 测试确认。这几个常量建议在源码里定义成枚举,比在业务代码里写死0x07好查得多。
编码侧的对称实现是下面这样,注意长度字段只统计控制域加 ASDU。
public static byte[] encodeI(int ns, int nr, byte[] asdu) { int len = 4 + asdu.length; if (len > 253) { throw new IllegalArgumentException("APDU too long: " + len); } byte[] frame = new byte[2 + len]; frame[0] = 0x68; frame[1] = (byte) len; frame[2] = (byte) ((ns << 1) & 0xFE); frame[3] = (byte) ((ns >> 7) & 0xFF); frame[4] = (byte) ((nr << 1) & 0xFE); frame[5] = (byte) ((nr >> 7) & 0xFF); System.arraycopy(asdu, 0, frame, 6, asdu.length); return frame; }ns << 1是把序号左移一位,把最低位腾出来放帧类型标志,& 0xFE保证 bit0 为 0。ns >> 7是取高位字节,不需要掩码,因为 Java 的 byte 截断天然只留低 8 位。参数asdu传进来前必须已经是完整字节数组,别在里面再做一次拼接,否则长度字段和实际内容对不上,对面子站会出现“收到了但解析失败”的玄学现象。
2.2 链路状态机:STARTDT、STOPDT 与 TESTFR
TCP 连上不等于链路通了,这是新手最容易踩的第一个坑。104 的连接分两段:TCP 层建链,然后主站发 STARTDT 激活,子站回 STARTDT 确认,之后才允许传 I 帧。这个状态机在源码里一般做成三态:CONNECTING、STARTING、TRANSFER,只有进到TRANSFER才把数据往业务总线上抛。
停止过程对称,主站发 STOPDT 激活,子站回确认。测试帧 TESTFR 是心跳,由 t3 定时器驱动,默认 20 秒空闲后发一次,收到确认就认为链路活着。源码里的定时器不要用Thread.sleep轮询,用 Netty 的HashedWheelTimer或者ScheduledExecutorService单独起一条线程,每条链路一个句柄,断开时记得取消,不然连接多了以后定时器会堆成垃圾。
提示:t0 建链超时、t1 发送超时、t2 无数据确认超时、t3 测试周期,这四个参数在现场往往要跟着子站厂家调,默认 30/15/10/20 秒不是万能值。
2.3 ASDU 内部结构与类型标识映射
ASDU 的头是固定的六字节:类型标识、可变结构限定词、传送原因低字节、传送原因高字节、公共地址低字节、公共地址高字节。之后是信息对象,每个对象以 3 字节信息对象地址开头。可变结构限定词的最高位是 SQ 标志,SQ=1 表示连续地址,只需要写第一个 IOA,后续按顺序递增 1;SQ=0 表示每个对象都带自己的 IOA。
| 类型标识 | 名称 | 用途 | 信息元素长度 |
|---|---|---|---|
| 1 | M_SP_NA_1 | 单点遥信 | 1 字节 SIQ |
| 3 | M_DP_NA_1 | 双点遥信 | 1 字节 DIQ |
| 9 | M_ME_NA_1 | 归一化测量值 | 2 字节 + 1 字节 QDS |
| 11 | M_ME_NB_1 | 标度化测量值 | 2 字节 + 1 字节 QDS |
| 13 | M_ME_NC_1 | 短浮点测量值 | 4 字节 + 1 字节 QDS |
| 30 | M_SP_TB_1 | 带时标单点遥信 | 1 + 7 字节 |
| 36 | M_ME_TF_1 | 带时标短浮点 | 4 + 1 + 7 字节 |
| 45 | C_SC_NA_1 | 单点遥控 | 1 字节 SCO |
| 100 | C_IC_NA_1 | 总召唤 | 1 字节 QOI |
| 103 | C_CS_NA_1 | 时钟同步 | 7 字节 CP56Time2a |
这张表在手,看任何一段 ASDU 十六进制都能自己拆。传送原因那一列源码里通常还要对应一份枚举,3 是突发、5 是被请求、6 是激活、7 是激活确认、10 是激活终止、20 是响应站总召。看到 COT=7 就知道这是对面在确认你刚才那条遥控,看到 COT=10 就知道这条命令走完了。
3. 源码目录与核心模块:从 Netty 通道到业务数据总线
这套实现按 Netty 的 pipeline 模型组织,链路层的东西全部压在 codec 里,会话状态和业务解析分开,好处是换成同步阻塞 IO 或者别的框架时,编解码和 ASDU 解析这两层能整体搬走。下面按模块把职责摊开。
3.1 工程目录结构与各模块职责
iec104-master/ ├── src/main/java/com/example/iec104/ │ ├── codec/ │ │ ├── Iec104FrameDecoder.java # 解决粘包,按长度字段切帧 │ │ ├── Iec104FrameEncoder.java # APDU 编码 │ │ └── Apdu.java # 帧对象,含 I/S/U 三态 │ ├── asdu/ │ │ ├── Asdu.java # ASDU 头解析 │ │ ├── TypeId.java # 类型标识枚举 │ │ ├── CauseOfTransmission.java # 传送原因枚举 │ │ └── InformationElement.java # 信息元素解码器 │ ├── session/ │ │ ├── Iec104Session.java # 单条链路的状态机 │ │ ├── SessionManager.java # 多链路注册与重连 │ │ └── SequenceWindow.java # k/w 窗口与序号管理 │ ├── service/ │ │ ├── InterrogationService.java # 总召、分组召 │ │ ├── ControlService.java # 遥控选择/执行 │ │ └── ClockSyncService.java # 时钟同步 │ ├── model/ │ │ ├── PointValue.java # 点位值模型 │ │ └── PointTable.java # 点表加载与映射 │ └── server/ │ └── Iec104MasterServer.java # 启动类 └── src/main/resources/ ├── application.yml # 链路与协议参数 └── points.csv # 点表:IOA 与业务 key 的对应这个分层里最容易被改坏的是session和service的边界。session只管协议状态和序号,不应该出现任何业务字段名;service只管发命令和收结果,不碰字节。实际二次开发时经常有人图省事,把点表映射塞进 decoder,结果一换子站就得改编解码,血泪经验。
3.2 编解码链路:按长度字段切帧
TCP 是字节流,一个 recv 可能拿到半个 APDU,也可能拿到三个半。Netty 里用ByteToMessageDecoder处理,核心就是“找到 0x68,读长度,不够就等”。
public class Iec104FrameDecoder extends ByteToMessageDecoder { private static final int START = 0x68; private static final int MAX_LEN = 253; @Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) { while (in.readableBytes() >= 6) { in.markReaderIndex(); int start = in.getUnsignedByte(in.readerIndex()); if (start != START) { in.skipBytes(1); // 字节错位,向后找下一个起始符 continue; } int len = in.getUnsignedByte(in.readerIndex() + 1); if (len < 4 || len > MAX_LEN) { in.skipBytes(1); continue; } if (in.readableBytes() < len + 2) { in.resetReaderIndex(); // 半包,等下一次数据 return; } in.skipBytes(2); byte[] body = new byte[len]; in.readBytes(body); out.add(Apdu.decode(body)); } } }markReaderIndex和resetReaderIndex是半包场景的解药,读到不完整的一帧必须把读指针退回去,否则半截数据就永久丢了。skipBytes(1)是容错路径,现场确实会遇到包头前面多几个字节的情况,比如子站被别的程序接过一次、缓冲区里残留了脏数据。长度上限 253 必须校验,不然一个错位的长度字节会把缓冲区读穿。解码出来的Apdu对象往下传到Iec104Session,由它决定是回 S 帧、回 U 帧确认,还是丢进业务队列。
3.3 会话管理:k/w 窗口与序号确认
k 是发送窗口,表示最多允许多少个 I 帧未被确认,默认 12;w 是接收窗口,表示收到多少个 I 帧后必须回一次 S 帧确认,默认 8。这套机制做不好,表现就是链路跑着跑着突然不动了,两边都在等对方确认。
public class SequenceWindow { private int sendSeq; // N(S) 下一个要发的序号 private int recvSeq; // N(R) 期望对方发来的序号 private int unacked; // 已发未确认的 I 帧数 private final int k = 12; private final int w = 8; private int recvSinceAck; public synchronized boolean canSend() { return unacked < k; } public synchronized int nextSend() { int seq = sendSeq; sendSeq = (sendSeq + 1) & 0x7FFF; unacked++; return seq; } public synchronized void onAck(int nr) { // 对端确认到 nr,说明 nr 之前(含回绕)的帧都收到了 int acked = (nr - (sendSeq - unacked) + 32768) & 0x7FFF; unacked = Math.max(0, unacked - acked); } public synchronized boolean needSendAck() { recvSinceAck++; if (recvSinceAck >= w) { recvSinceAck = 0; return true; } return false; } }canSend在发送前判断窗口有没有满,满了就排队等确认,别硬发,硬发对面会直接丢掉超窗的帧。onAck里那段回绕计算是重点,(nr - base + 32768) & 0x7FFF能正确处理序号跨过 32767 的情况。needSendAck达到 w 就返回 true,上层据此发一个空的 S 帧,把 N(R) 带回去。这三个方法都要加锁,Netty 的 IO 线程和业务线程会同时碰它们。
注意:不少现成源码把 k 和 w 写成常量,现场遇到弱网或者子站处理慢的时候调不动。建议把这两个值连同 t0~t3 一起放进配置文件。
3.4 数据上送处理:总召、分组召与突发上送
总召是主站主动发一条 C_IC_NA_1,传送原因 6,QOI 填 20 表示站总召,子站按点表把所有点回一遍,每帧的 COT 是 20。分组召把 QOI 换成 1 到 16,对应 16 个组。突发上送是子站自己触发,COT 是 3。
public byte[] buildInterrogation(int commonAddr, int ioa, int qoi, int cot) { return new byte[]{ (byte) 100, // TypeID: C_IC_NA_1 (byte) 0x01, // VSQ: SQ=0,对象数 1 (byte) cot, // COT 低字节 (byte) 0x00, // COT 高字节:源发地址 (byte) (commonAddr & 0xFF), (byte) ((commonAddr >> 8) & 0xFF), (byte) (ioa & 0xFF), (byte) ((ioa >> 8) & 0xFF), (byte) ((ioa >> 16) & 0xFF), (byte) qoi }; }commonAddr是公共地址,也叫 ASDU 地址,通常一个子站一个值,现场对不上就是“链路通但全部超时”。ioa是信息对象地址,占三字节小端,总召时一般填 0。qoi填 20 是站总召,填 1~16 是分组召。cot发命令时传 6,如果要做召唤终止,传 8。这个方法返回的是 ASDU,外面还要套一层encodeI才发得出去。
解析侧按类型标识分支,短浮点是最常见的测量值类型。信息元素是小端,四个字节直接转 float,后面跟一个品质字节。
// TypeID = 13, M_ME_NC_1 float value = ByteBuffer.wrap(element, 0, 4) .order(ByteOrder.LITTLE_ENDIAN) .getFloat(); int qds = element[4] & 0xFF; boolean overflow = (qds & 0x01) != 0; // OV 溢出 boolean blocked = (qds & 0x10) != 0; // BL 闭锁 boolean invalid = (qds & 0x80) != 0; // IV 无效品质位不能不看,IV 置位说明这个值不可信,直接展示到画面上会误导运行人员。工程上一般把 IV 和 OV 分别映射成“数据无效”和“溢出”两个状态,而不是简单丢点。
4. 跑起来:从零启动一个 104 主站并连上模拟子站
协议看完,接下来落到能看见数据。这一章按“配环境 → 起主站 → 造子站 → 验证”走一遍,全程单机就能跑完,不需要真实设备。
4.1 环境准备与配置文件
JDK 8 以上即可,Netty 4.1.x,构建用 Maven。配置文件里把链路参数和协议参数分开写,后面调参不用改代码。
iec104: master: port: 2404 common-address: 1 # 与子站约定一致,对不上会一直超时 reconnect-interval: 5000 # 断线重连间隔,毫秒 auto-interrogation: true interrogation-period: 900000 # 总召周期 15 分钟,别学我把这里写死 protocol: k: 12 w: 8 t0: 30 t1: 15 t2: 10 t3: 20 cot-size: 2 # 传送原因字节数 ioa-size: 3 # 信息对象地址字节数common-address是最容易翻车的配置项,有些子站支持广播地址 65535,有些只认 1,接之前一定问清楚。cot-size和ioa-size在标准里固定是 2 和 3,但少数厂家固件会改成 1 和 2,源码里做成可配就是为了应付这种情况。
4.2 启动类与第一条链路
启动类负责拉起 Netty、注册 pipeline、把会话放进管理器。下面是最小可运行版本的核心部分。
public class Iec104MasterServer { public static void main(String[] args) throws Exception { Iec104Config cfg = Iec104Config.load("application.yml"); EventLoopGroup boss = new NioEventLoopGroup(1); EventLoopGroup worker = new NioEventLoopGroup(); try { Bootstrap b = new Bootstrap(); b.group(worker) .channel(NioSocketChannel.class) .option(ChannelOption.TCP_NODELAY, true) .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10_000) .handler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) { ch.pipeline() .addLast(new Iec104FrameDecoder()) .addLast(new Iec104FrameEncoder()) .addLast(new Iec104Session(cfg)); } }); Channel ch = b.connect(cfg.getHost(), cfg.getPort()).sync().channel(); ch.closeFuture().sync(); } finally { boss.shutdownGracefully(); worker.shutdownGracefully(); } } }TCP_NODELAY建议开,104 的帧都很小,Nagle 算法攒包会让总召响应看起来慢半拍,现场排查时特别容易被误判成子站性能问题。CONNECT_TIMEOUT_MILLIS对应 t0,设成 10 秒比默认的 30 秒更容易发现问题。Iec104Session是ChannelInboundHandlerAdapter的子类,在channelActive里发 STARTDT,在channelRead里分发 I/S/U 帧,在channelInactive里触发重连。
4.3 总召与遥测解析验证
链路进到传输态后,业务线程调一次总召,然后观察日志里有没有 COT=20 的数据回来。
public void startInterrogation(int commonAddr) { byte[] asdu = buildInterrogation(commonAddr, 0x000000, 20, 6); int ns = window.nextSend(); byte[] frame = Apdu.encodeI(ns, window.getRecvSeq(), asdu); channel.writeAndFlush(Unpooled.wrappedBuffer(frame)); log.info("总召已发送, N(S)={}, CA={}", ns, commonAddr); }发出去之后要打两个日志点:帧发出时的 N(S),和收到 COT=10(激活终止)时的 N(R)。如果只看到 COT=7 看不到 10,多半是子站点数多、一帧装不下、后面的帧序号乱了。如果连 COT=7 都没有,先查公共地址,再查 STARTDT 有没有收到确认。
4.4 用 Python 造一个最小子站联调
没有真实设备时,写个几十行的模拟子站最省事。它监听 2404,回 STARTDT 确认,收到总召就吐三个短浮点测量值。
import socket, struct def u_frame(func): return bytes([0x68, 0x04, func, 0x00, 0x00, 0x00]) def i_frame(ns, nr, asdu): ctl = bytes([(ns << 1) & 0xFE, (ns >> 7) & 0xFF, (nr << 1) & 0xFE, (nr >> 7) & 0xFF]) return bytes([0x68, len(ctl) + len(asdu)]) + ctl + asdu srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind(("0.0.0.0", 2404)) srv.listen(1) conn, _ = srv.accept() ns = 0 while True: head = conn.recv(2) if len(head) < 2: break body = b"" while len(body) < head[1]: body += conn.recv(head[1] - len(body)) c1 = body[0] if c1 & 0x03 == 0x03: # U 帧 func = c1 & 0xFC if func == 0x04: # STARTDT act conn.sendall(u_frame(0x08)) elif func == 0x40: # TESTFR act conn.sendall(u_frame(0x80)) elif c1 & 0x01 == 0: # I 帧 remote_ns = (c1 >> 1) | (body[1] << 7) asdu = body[4:] if asdu[0] == 100: # 总召 ca = asdu[4:6] payload = b"" for i, val in enumerate([220.5, 50.2, 12.8]): payload += (i + 1).to_bytes(3, "little") payload += struct.pack("<f", val) # 短浮点 payload += b"\x00" # QDS 品质位全 0 resp = bytes([13, 3, 20, 0]) + ca + payload conn.sendall(i_frame(ns, remote_ns, resp)) ns = (ns + 1) & 0x7FFFbytes([13, 3, 20, 0])依次是类型标识 13、VSQ=3(SQ=0,三个对象)、传送原因 20、源发地址 0,后面接两字节公共地址。payload每项 8 字节,三字节 IOA 加四字节浮点加一字节品质,正好对上 VSQ 里的 3。<f是小端单精度,跟 IEEE 754 的短浮点一致。跑通这个脚本,主站侧应该能打印出三个点位,值分别是 220.5、50.2、12.8。
5. 避坑与排查:104 主站联调现场最常见的五个坑
协议看得再熟,现场还是会被时序和边界教做人。下面这五条是这套源码在联调阶段反复出现的问题,每条都按“看到什么 → 为什么 → 怎么改”记下来。
5.1 链路显示已连接,但一帧数据都不上来
现象是 TCP 三次握手成功,日志里也有channelActive,但总召发出去石沉大海,t1 超时反复触发。原因基本只有一个:STARTDT 没发,或者发得太早。有些实现把 STARTDT 写在channelActive里,而 TCP 连接刚建立时对端还没准备好接收,激活帧丢了,链路永远停在“连上但未传输”的状态。
解决方式是在channelActive里延迟几百毫秒再发,或者干脆用定时器重发 STARTDT 直到收到确认,同时把 t0 超时打出来。Iec104Session里应该有一个startRetry计数器,超过三次就主动断开重连,别让它无限挂着。
5.2 总召返回的数据解析错位,点位全部对不上
现象是能收到数据帧,但解析出来的浮点数全是NaN或者一个离谱的大数,点表上的 IOA 和实际值错位。原因是字节流边界没处理好,一个 TCP 包里塞了两帧,或者一帧被拆成两次 recv,解码器没按长度字段切,直接按固定长度取。
解决靠Iec104FrameDecoder里那套markReaderIndex/resetReaderIndex逻辑,长度不够就退出循环等下次。另外长度字段的校验不能省,len < 4 || len > 253直接判非法,跳过一字节重新找包头。
5.3 SQ=1 的连续地址被当成单个点位
现象是子站一次上送 20 个遥信,主站只解析出一个,剩下的全丢了。原因是VSQ的最高位 SQ 标志没判断。SQ=1 时整个 ASDU 里只有一个 IOA,后续对象的地址在这个基础上递增 1;SQ=0 时每个对象前面都有自己的三字节 IOA。
int vsq = asdu[1] & 0xFF; boolean sq = (vsq & 0x80) != 0; int count = vsq & 0x7F; int baseIoa = readIoa(asdu, offset); for (int i = 0; i < count; i++) { int ioa = sq ? baseIoa + i : readIoa(asdu, cursor); // 按类型标识决定信息元素长度后再读值 }这段分支必须在解析入口就分流,不能等到读值的时候再判断,否则游标位置全乱。
5.4 遥控返校成功但执行不生效
现象是选择命令收到 COT=7 确认,执行命令发出去也回了确认,但现场开关没动。原因是选择和执行的信息对象地址或命令值不一致,或者执行帧的 COT 用了 6 而不是 6(这里是对的),但SCO字节里的 QU 字段( bit2~bit6)没带选择/执行标志。
标准流程是:选择发 COT=6、SCO 的 S/E 位为 1;执行发 COT=6、S/E 位为 0。两次的 IOA 和命令值必须完全一致,子站会比对,不一致就只回确认不动作。排查时把两帧的十六进制打出来逐字节对,比看日志快。
5.5 空闲几分钟后连接假死,重启才恢复
现象是链路白天正常,夜里没数据,第二天早上发现所有点位都不刷新,TCP 连接还在。原因是 t3 测试帧没发,或者发了但没处理对端的确认,中间的网络设备把空闲连接的超时表清了。
解决是给每条链路挂一个 t3 定时器,空闲超过 20 秒发TESTFR act,收到TESTFR con就重置计时。同时 t1 超时要能触发断线重连,别只在连接建立时判断一次。重连逻辑里记得把SequenceWindow的序号全部清零,序号不复位是重连后第一批数据必然错乱的根源。
注意:重连之后不要立刻发总召,先等 STARTDT 确认回来,进到传输态再发,否则又回到 5.1 那个坑。
6. 二次开发:把通用主站改造成业务侧能直接用的采集服务
这套源码是通用实现,直接扔进业务系统会别扭,因为业务侧要的是“设备-点位-值”三级模型,而不是 IOA 加类型标识。改造的核心就一件事:在service层和model层之间加一层点位映射,协议层完全不动。
扩展点有三个。第一是类型标识的解析注册表,源码里如果用的是switch,改成Map<Integer, InformationElementDecoder>,新增一个厂家私有类型只需要注册一个解码器,不用动主流程。第二是点表加载,points.csv里维护 IOA、类型标识、业务 key、系数、单位五列,启动时读进PointTable,收到数据按 IOA 查表转成业务对象。第三是数据出口,Iec104Session解析完不要直接写数据库,扔进一个有界队列,由独立的消费线程批量落库,避免 IO 阻塞 Netty 的 IO 线程。
| 改造点 | 原实现 | 建议改法 | 验证方式 |
|---|---|---|---|
| 类型解析 | switch 分支 | 解码器注册表 | 加一个未知类型不抛异常 |
| 点位映射 | 无 | 点表 CSV 加载 | IOA 查不到时记 warn 不中断 |
| 数据出口 | 直接回调 | 有界队列 + 批量落库 | 断网 10 分钟不丢队列内数据 |
| 参数配置 | 常量 | 配置文件 + 热更新 | 改 k/w 后重连生效 |
验证方法我一般分三步走:先用第 4 章那个 Python 子站跑单机回环,确认解析链路没问题;再把 k 改成 1、w 改成 1,人为制造窗口压力,看会不会卡死;最后拔网线五分钟再插回去,观察重连后序号有没有复位、总召有没有自动补发。这三步走完,八成的问题在进现场之前就暴露了。
还有个具体技巧:给每条链路加一个“最近一次收到 I 帧的时间戳”,在监控页面上直接显示。现场遇到“数据不刷新”的投诉,先看这个时间戳,比翻几百行日志快得多,也不用去猜是不是画面渲染的问题。
改造时最容易犯的错是把业务逻辑往codec里塞。我早年图省事,在解码器里直接按点表做单位换算,后来子站换了一批、点表结构变了,编解码全得重写,返工两天。从那以后我每次动codec之前都强制问自己一句:这段逻辑换个厂家还成立吗?不成立就往下挪一层。希望帮到你。
本文还有配套的精品资源,点击获取