简介:面向需要基于 IEC 104 2002 规约对接西门子电表与网关的 Java 开发者,这份主站程序实现可作为规约通信链路搭建、报文交互理解与二次开发的参考工程。包内共 253 个文件,以 java 源码和 class 编译产物为主体,配套 jar 依赖、xml 与 properties 配置、bat 启动/安装脚本以及 sql 数据库脚本等,覆盖源码查阅、依赖组织、配置调整和运行脚本等完整结构,压缩包约 10.17MB。工程把源码、依赖、配置和数据库脚本放在同一包内,便于对照规约字段与业务数据处理逻辑,也便于在本地搭建调试环境。已有 4176 人学习,说明其在 IEC104 主站开发场景中具有一定参考价值。读者可从中了解主站侧连接管理、数据读写流程、报文收发组织与工程目录划分方式,结合源码和配置排查通信问题,并在此基础上裁剪或扩展功能,适合需要快速建立可调试 IEC104 主站原型的初、中级 Java 开发人员。
1. 一条 17 字节的报文里藏着什么
现场联调最尴尬的一幕:设备侧说总召唤已经返回了全量数据,你这边 Java 主站的控制台一行解析结果都没打印出来,抓包工具里只有68 04 43 00 00 00和68 04 0B 00 00 00两条 U 帧在来回,ASDU 一个字节都没上来。问题往往不在规约本身,而在主站侧的握手时序、长度域计算和粘包处理。这份资源是一套用 Java 写 IEC104 主站(客户端)的源码,覆盖 TCP 2404 建链、APCI 拆包组包、STARTDT 握手、站总召唤、遥测遥信遥脉解析、S 帧确认与 TESTFR 心跳保活,拿到手能直接对着现场从站跑。它适合做工业数据采集、能源监控平台后台的 Java 开发;如果你的活儿是在现成组态软件里配点表,这套东西对你帮助有限。
2. 拆开 APCI 和 ASDU:六个字节的控制域怎么用 Java 拼
IEC104 的报文结构比 Modbus 之类的规约要“讲规矩”得多:它把 TCP 当可靠传输通道,但又不完全信任 TCP,自己在应用层维护发送序号、接收序号和未确认帧计数。理解这一点,后面所有代码才有落脚点。
2.1 三种帧型:I 帧、S 帧、U 帧的字节布局
每个 APDU 都以启动字符0x68开头,第二个字节是长度域,后面固定 4 个字节的控制域。控制域首字节的低两位决定帧型:bit0=0是 I 帧(信息帧,带 ASDU),bit0=1 且 bit1=0是 S 帧(监督帧,只回确认),bit0=1 且 bit1=1是 U 帧(无编号控制帧,做启停和测试)。
| 帧型 | 控制字节 1 | 控制字节 2 | 控制字节 3 | 控制字节 4 | 带 ASDU |
|---|---|---|---|---|---|
| I 帧 | (NS & 0x7F) << 1 | NS >> 7 | (NR & 0x7F) << 1 | NR >> 7 | 是 |
| S 帧 | 0x01 | 0x00 | (NR & 0x7F) << 1 | NR >> 7 | 否 |
| U 帧 | 0x03 | 0x00 | 功能码 | 0x00 | 否 |
U 帧功能码一共六种:STARTDT 激活0x07、STARTDT 确认0x0B、STOPDT 激活0x13、STOPDT 确认0x23、TESTFR 激活0x43、TESTFR 确认0x83。现场看到的68 04 43 00 00 00就是一条 TESTFR 激活,长度域是 4,因为 U 帧只有控制域、没有 ASDU。
2.2 长度域和序列号:两个最容易写错的地方
长度域只统计“控制域 4 字节 + ASDU 长度”,不包括启动字符和长度字节本身,所以合法范围是 4 到 253,对应 ASDU 最长 249 字节。很多自己攒报文的人习惯把整包长度填进去,多算了 2,从站收到后会一直等剩余字节,表现就是“发出去毫无反应”。
序列号是 15 位,取值 0 到 32767,到顶回绕到 0。NS 是主站自己发 I 帧时递增的发送序号,NR 是“我期望你下一次发给我”的接收序号。主站每收到一条 I 帧,NR 必须加一;收到一条 S 帧只是对方在确认,不动 NR。这套机制不看代码也能讲清楚,但真正跑起来连续几万帧之后断链,八成就是回绕没处理或者确认计数漏加。
提示:公共地址 CA 在 IEC104 里允许配 1 字节或 2 字节,国内设备绝大多数是 2 字节小端。对接前先问清设备侧配置,别自己猜。
2.3 ASDU 头部七个字节:类型标识、VSQ、传送原因、公共地址
ASDU 从 APDU 的第 7 个字节(索引 6)开始,依次是:类型标识 TI(1 字节)、可变结构限定词 VSQ(1 字节)、传送原因 COT(2 字节小端)、源发地址 OA(1 字节)、公共地址 CA(2 字节小端),合计 7 字节,信息对象从索引 13 开始。
VSQ 的最高位是 SQ 标志,低 7 位是信息对象个数。SQ=0 表示每个信息对象前都带 3 字节信息对象地址;SQ=1 表示只有第一个对象带地址,后面的地址依次加一,这是总召唤响应里最常见的省字节写法。COT 的低 6 位是原因值,bit6是 P/N 否定标志,bit7是测试标志,解析时必须先做掩码,否则会把原因 6(激活)读成 70。
2.4 用 ByteBuffer 写一版可复用的 APDU 编解码
下面这段是三种帧的组包,全部用位运算直接拼,不依赖任何第三方库,方便嵌到既有工程里。
public final class Apci { public static final byte START = 0x68; public static final int MAX_LEN = 253; /** I 帧:NS/NR 各 15 bit,低字节左移一位,bit0 恒为 0 */ public static byte[] i(int ns, int nr, byte[] asdu) { if (asdu.length > 249) throw new IllegalArgumentException("ASDU 超长"); byte[] a = new byte[6 + asdu.length]; a[0] = START; a[1] = (byte) (4 + asdu.length); // 长度域 = 控制域 4 + ASDU a[2] = (byte) ((ns & 0x7F) << 1); a[3] = (byte) ((ns >> 7) & 0xFF); a[4] = (byte) ((nr & 0x7F) << 1); a[5] = (byte) ((nr >> 7) & 0xFF); System.arraycopy(asdu, 0, a, 6, asdu.length); return a; } /** S 帧:只回 NR,控制域首字节固定 0x01 */ public static byte[] s(int nr) { return new byte[]{START, 4, 0x01, 0x00, (byte) ((nr & 0x7F) << 1), (byte) ((nr >> 7) & 0xFF)}; } /** U 帧:0x07/0x0B/0x13/0x23/0x43/0x83 */ public static byte[] u(int fn) { return new byte[]{START, 4, 0x03, 0x00, (byte) fn, 0x00}; } }i()里ns & 0x7F再左移一位,等于把 15 位序号的低 7 位放到控制字节 1 的 bit1 到 bit7;高 8 位放到控制字节 2。s()和u()的长度域写死 4,没有 ASDU。这三个方法构成了整个主站发包的底座,所有业务报文都走i(),控制帧走s()和u()。
收包侧不能假设一次read就是一条完整 APDU。TCP 是字节流,现场跑久了必然出现半包和粘包,正确做法是先读 2 字节拿长度域,再按长度读剩余部分。
public byte[] readApdu(InputStream in) throws IOException { int start = in.read(); if (start < 0) throw new EOFException("链路已被对端关闭"); if (start != 0x68) throw new IOException("启动字符错误: " + start); int len = in.read(); // 长度域:4~253 if (len < 4 || len > Apci.MAX_LEN) throw new IOException("长度域越界: " + len); byte[] body = new byte[len]; int off = 0; while (off < len) { // 必须循环读满,一次 read 可能只给几个字节 int n = in.read(body, off, len - off); if (n < 0) throw new EOFException("读包体时链路中断"); off += n; } byte[] apdu = new byte[2 + len]; apdu[0] = (byte) start; apdu[1] = (byte) len; System.arraycopy(body, 0, apdu, 2, len); return apdu; }while (off < len)这个循环是刚需,Socket 的read返回多少字节完全看内核缓冲区,不保证读满。len的边界校验也要留,遇到脏数据能早失败早报警,别让它进到后面的解析逻辑里把点位表搅乱。
3. 建链与总召唤:把 STARTDT 和 C_IC_NA_1 的时序跑通
链路上来先连 TCP,连上之后并不代表可以收数据。IEC104 规定了 STARTDT 握手:主站发 STARTDT 激活,从站回 STARTDT 确认,此后从站才会把应用层数据往这条连接上送。跳过这一步直接发总召唤,很多设备会安静地把你晾在那儿。
3.1 端口 2404 的连接参数:t0、TCP_NODELAY 与读超时
标准端口是 2404,t0 连接超时默认 30 秒,现场建议收到 5 秒以内,链路不通就快速失败重连。TCP_NODELAY一定要开,IEC104 的报文都很短,Nagle 算法攒包会把总召唤的响应硬生生拖慢几十毫秒,看起来像是设备慢,其实是自己的 Socket 在等。
读超时setSoTimeout我一般设 1 秒,配合一个独立的心跳线程。设成 0(无限阻塞)会让链路半死时线程永久挂住,连重连都触发不了。
Socket socket = new Socket(); socket.connect(new InetSocketAddress(host, 2404), 5000); // t0 收短到 5s socket.setTcpNoDelay(true); // 关掉 Nagle socket.setSoTimeout(1000); // 读超时,用于驱动心跳与超时判定 socket.setKeepAlive(true);3.2 STARTDT 握手与 TESTFR 心跳
握手逻辑不复杂,但必须带超时和重试。发出去之后等 U 帧0x0B,等到就进入数据态;等不到就重发,一般是三次之后断开重连。TESTFR 是链路空闲时的保活,t3 默认 20 秒没数据就发一次,收到0x83就重置计时。
// 1) 启动数据传输 out.write(Apci.u(0x07)); // STARTDT act out.flush(); // 2) 等待 STARTDT con(0x0B),最多重试 3 次 for (int retry = 0; retry < 3; retry++) { byte[] apdu = readApdu(in); if ((apdu[2] & 0x03) == 0x03 && (apdu[4] & 0xFF) == 0x0B) { connected = true; break; } } if (!connected) throw new IOException("STARTDT 握手失败");判断 U 帧看的是apdu[2] & 0x03 == 0x03,功能码在apdu[4]。这里有个细节:握手期间从站可能已经插了几条别的帧进来,比如另一路测试帧,所以循环里要丢弃不匹配的帧而不是直接抛异常,否则握手容易被偶发报文打断。
3.3 总召唤的完整时序:激活、确认、数据、结束
站总召唤的类型标识是 100(C_IC_NA_1),限定词 QOI 填 20。完整时序是:主站发激活(COT=6)→ 从站回激活确认(COT=7)→ 从站按组上送数据(COT=20)→ 从站回激活终止(COT=10)。只有收到 COT=10 才算这一轮总召唤结束,中间任何一步缺失,点表就是不完整的。
/** 组装站总召唤 ASDU:CA 2 字节小端,IOA 3 字节小端,QOI=20 */ public static byte[] interrogation(int ca, int qoi) { return new byte[]{ (byte) 100, // TI = C_IC_NA_1 (byte) 0x01, // SQ=0,对象个数 1 (byte) 6, 0, // COT=6 激活 0, // 源发地址 (byte) (ca & 0xFF), (byte) (ca >> 8), // 公共地址 0, 0, 0, // 信息对象地址 0 (byte) qoi // QOI=20 站总召唤 }; }整包 11 字节的 ASDU 加上 6 字节 APCI,实际发出去的 APDU 长度域是 15,十六进制形如68 0F xx xx xx xx 64 01 06 00 00 01 00 00 00 00 14。中途的xx是当前 NS/NR,每次发都不一样。如果抓包看到长度域不是 0x0F,那基本可以断定 ASDU 里多拼或少拼了字节。
收侧的处理是一个状态机:COT=7 时打日志;COT=20 时进解析器;COT=10 时置“总召唤完成”标志并通知上层。COT 是 44、45、46、47 这几个负向原因时,分别对应类型标识不认识、传送原因不认识、公共地址不认识、信息对象地址不认识,这时候该做的是把这条日志原样打出来,而不是吞掉。
3.4 k/w 流控与 S 帧确认的落地写法
k 是未确认 I 帧的最大数量(默认 12),w 是收到多少条 I 帧后必须回一次确认(默认 8)。主站发的 I 帧从站的确认方式有两种:对方主动发 S 帧,或者在它回的 I 帧里带 NR。主站自己作为接收方,也要按 w 回 S 帧,否则从站那边的未确认计数会一直涨,涨到 k 就停发。
private int recvCount = 0; // 已收未确认的 I 帧数 private static final int W = 8; void onIFrame(byte[] apdu) { int nr = ((apdu[4] & 0xFE) >> 1) | ((apdu[5] & 0xFF) << 7); // nr 是从站期望主站下次发送的序号,可用于校验自己的 NS recvCount++; if (recvCount >= W) { try { out.write(Apci.s(nextNrSend)); // nextNrSend = 已收到的 I 帧总数 % 32768 out.flush(); recvCount = 0; } catch (IOException e) { // 交给重连逻辑 } } }nextNrSend就是从建链开始收到的 I 帧累计数对 32768 取模,不要用别的计数源,否则 NR 会和从站期望值错开,表现是链路还能收但发不出去,或者从站直接 STOPDT。
注意:k 和 w 是可配置项,有些设备侧配的是 k=12、w=8,有些是 k=8、w=5。主站这边按保守值走,收够 8 条就确认,兼容性最好。
4. 三遥解析:类型标识 1/3/9/13/30/36 与点位映射
数据上送这一段是整个主站里最琐碎的部分。类型标识决定了信息对象占几个字节、值怎么换算、带不带时标,写错了不会报错,只会给你一堆看起来像真的的数字。
4.1 单点、双点、归一化、标度化、短浮点的字节长度对照
| TI | 名称 | 值域编码 | 单个对象字节数 |
|---|---|---|---|
| 1 | M_SP_NA_1 单点遥信 | SIQ,bit0 为状态 | 3 + 1 |
| 3 | M_DP_NA_1 双点遥信 | DIQ,bit0~1 为状态 | 3 + 1 |
| 9 | M_ME_NA_1 归一化遥测 | 2 字节有符号,除以 32768 | 3 + 2 |
| 11 | M_ME_NB_1 标度化遥测 | 2 字节有符号整数 | 3 + 2 |
| 13 | M_ME_NC_1 短浮点遥测 | IEEE754 单精度 | 3 + 4 |
| 15 | M_IT_NA_1 累计量 | 4 字节有符号 | 3 + 4 + 1 |
| 30 | M_SP_TB_1 带时标单点 | SIQ + CP56Time2a | 3 + 1 + 7 |
| 31 | M_DP_TB_1 带时标双点 | DIQ + CP56Time2a | 3 + 1 + 7 |
| 36 | M_ME_TF_1 带时标短浮点 | 短浮点 + CP56Time2a | 3 + 4 + 7 |
归一化值最容易翻车:它是把工程量映射到 -1 到 1 之间的定点数,解析出来必须再乘量程上限。有人直接拿原始 short 当值用,结果一条 10kV 的母线电压显示成 8000 多,现场一看就知道不对。短浮点则是纯 IEEE754,但字节序是小端,Java 里不能直接用ByteBuffer.getFloat()的默认大端,必须order(LITTLE_ENDIAN)或者自己拼 int 再Float.intBitsToFloat。
4.2 CP56Time2a 七个字节怎么还原成时间
带时标的类型(30、31、34、35、36、37)在信息对象值后面跟 7 字节时间。前 2 字节是毫秒(0 到 59999)小端,第 3 字节是分钟(低 6 位),第 4 字节是小时(低 5 位),第 5 字节是日(低 5 位)加星期(高 3 位),第 6 字节是月(低 4 位),第 7 字节是年(低 7 位,表示 2000 到 2099)。
public static LocalDateTime cp56(byte[] b, int off) { int ms = (b[off] & 0xFF) | ((b[off + 1] & 0xFF) << 8); int min = b[off + 2] & 0x3F; int hour = b[off + 3] & 0x1F; int day = b[off + 4] & 0x1F; int month = b[off + 5] & 0x0F; int year = 2000 + (b[off + 6] & 0x7F); // 设备对时不准时会给出 0 月 0 日这种脏数据,必须挡住 if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || min > 59 || ms > 59999) { return null; } return LocalDateTime.of(year, month, day, hour, min, ms / 1000, (ms % 1000) * 1_000_000); }那个null返回不是偷懒,是血泪经验。设备刚上电、对时命令还没走完的时候,时间域经常是全 0,直接LocalDateTime.of会抛异常把整条解析线程带崩。返回 null 让上层决定是丢弃还是用本地时间补,主动权留在自己手里。
4.3 品质位:IV、NT、SB、BL 该不该入库
品质描述词是每个信息对象都带的 1 字节(累计量是 2 字节,多一个顺序号)。bit7是 IV(无效),bit6是 NT(非当前值),bit5是 SB(取代值),bit4是 BL(封锁值),bit0是 OV(溢出)。
工程上我的处理是:IV=1 直接丢弃并记录一次告警,不入库;NT=1 入库但打上“非实时”标签,因为总召唤期间很多设备会先送一版缓存值;SB=1 和 BL=1 入库并且单独标记,人工置数在调试期很常见,直接当真值会误导后面的计算。
public record Quality(boolean invalid, boolean notTopical, boolean substituted, boolean blocked) { public static Quality of(int qds) { return new Quality((qds & 0x80) != 0, (qds & 0x40) != 0, (qds & 0x20) != 0, (qds & 0x10) != 0); } }4.4 从信息对象地址到业务点位:点表驱动的解析器
解析器不应该认识“主变 A 相电压”这种业务名,它只负责把 IOA 和值吐出来,映射交给点表。这样换一个站只需要换点表文件,解析代码一行不动。
public void parseAsdu(byte[] apdu, PointTable table) { int ti = apdu[6] & 0xFF; int vsq = apdu[7] & 0xFF; boolean sq = (vsq & 0x80) != 0; int num = vsq & 0x7F; int cot = ((apdu[9] & 0xFF) << 8 | (apdu[8] & 0xFF)) & 0x3F; // 低位是原因 int ca = (apdu[11] & 0xFF) << 8 | (apdu[10] & 0xFF); int p = 13, base = -1; for (int i = 0; i < num; i++) { int ioa; if (!sq) { ioa = u24(apdu, p); p += 3; } // 每个对象都带地址 else if (i == 0) { base = u24(apdu, p); p += 3; ioa = base; } else { ioa = base + i; } // SQ=1 后续地址自增 switch (ti) { case 1, 30 -> { int siq = apdu[p] & 0xFF; p += 1; table.accept(ioa, (siq & 0x01) != 0, Quality.of(siq), ti == 30 ? cp56(apdu, p) : null); if (ti == 30) p += 7; } case 3, 31 -> { int diq = apdu[p] & 0xFF; p += 1; table.accept(ioa, diq & 0x03, Quality.of(diq), ti == 31 ? cp56(apdu, p) : null); if (ti == 31) p += 7; } case 9, 34 -> { short raw = (short) ((apdu[p] & 0xFF) | (apdu[p + 1] << 8)); p += 2; table.accept(ioa, raw / 32768f, Quality.of(apdu[p]), ti == 34 ? cp56(apdu, p) : null); if (ti == 34) { p += 1; p += 7; } } case 13, 36 -> { int bits = (apdu[p] & 0xFF) | ((apdu[p + 1] & 0xFF) << 8) | ((apdu[p + 2] & 0xFF) << 16) | ((apdu[p + 3] & 0xFF) << 24); p += 4; float v = Float.intBitsToFloat(bits); // 小端拼完再转 float table.accept(ioa, v, Quality.of(apdu[p]), ti == 36 ? cp56(apdu, p) : null); if (ti == 36) { p += 1; p += 7; } } default -> { return; } // 不认识就整条丢弃,别硬解 } } }u24是取 3 字节小端整数。这里的default分支直接 return 而不是继续循环,是因为一旦类型不认识,后面的字节长度全是猜的,继续解只会读到越界或者错位数据,不如整条放弃并打日志,让维护的人知道这个站用了没覆盖的类型标识。
5. 避坑与排查:主站联调里最容易翻车的五件事
下面五条都是现场真实踩过的,按出现频率排序。每条按现象、原因、解决写,遇到对不上的场景可以直接照着对。
5.1 连上了但总召唤没有任何回应
现象:TCP 三次握手成功,抓包能看到主站发出的68 0F ...总召唤,从站那边一个字节都不回,链路也没断。
原因:九成是 STARTDT 没握手就发总召唤,从站还在停止态,收到也不处理;剩下的一成是公共地址配错,从站按 COT=46(公共地址不认识)回了否定确认但被主站的解析器当成正常帧吞掉了。
解决:把发送顺序改成严格串行——TCP 连上、发 STARTDT act、收到 0x0B、再发总召唤。同时在收帧分支里把 COT 大于 43 的帧全部打 warn 日志,别让否定确认消失。检查 CA 时直接对着设备侧配置文件比对,1 字节和 2 字节 CA 的报文长度差一个字节,长度域能直接看出来。
5.2 跑了几小时后链路自己断开
现象:白天跑得好好的,晚上或者连续跑 6 到 8 小时之后,从站主动断开,重连又能继续跑。
原因:序列号回绕没处理,或者 w 计数没有及时清。15 位序号到 32767 回 0,如果代码里用的是int一直自增没有取模,从站校验 NR 时发现对不上,按规约可以主动断链。另一个常见原因是主站一直不回 S 帧,从站未确认 I 帧数涨到 k,按流控停发,看起来像断链。
解决:NS 和 NR 在每次自增后立刻% 32768,不要留到发报文时再算。w 计数用recvCount独立维护,回完 S 帧清零。排查时把每次收发的 NS/NR 打进日志,断链前最后几十行一定能看到跳变。
5.3 遥测值一会儿正常一会儿是 1.0E-38
现象:短浮点遥测偶尔解析出1.0E-38、-3.4E38这类极端值,重启后又恢复正常。
原因:字节序搞反了。Java 的ByteBuffer默认大端,直接getFloat()读小端数据,得到的位模式完全对不上。反过来,如果你手工拼 int 时漏了符号扩展,或者把 4 字节读成了 2 字节,也会得到类似的垃圾值。
解决:统一用order(ByteOrder.LITTLE_ENDIAN),或者像 4.4 那样手工移位拼 int 再Float.intBitsToFloat。解析完之后加一道范围校验,超出点表配置的量程就直接丢弃并打日志,别让这种值流到数据库里。
5.4 一个 read 里读出两条报文,解析直接错位
现象:平时没事,总召唤的时候偶发解析异常,日志里能看到ArrayIndexOutOfBoundsException,或者点位值整体串位。
原因:总召唤响应是连续多个 APDU 短时间内涌进来,TCP 把它们粘在一起了。代码里假设“一次读一条”,把第一条之后的内容当成新报文的开头,启动字符校验就会失败或者错位。
解决:按 2.4 的写法,先读启动字符和长度域,再按长度循环读满。长度域不是 0x68 或者超出 4 到 253 的范围,直接断开重连并记录,不要尝试“跳过一字节重新找 0x68”,那样只会把一条错位的流越解越乱。
5.5 遥信全是 0,现场说开关明明是合的
现象:总召唤完成,点表里所有单点遥信都是 0,但设备面板上指示灯是亮的。
原因:SIQ 的状态位在 bit0,品质位在 bit4 到 bit7。如果代码用siq == 1判断,遇到品质位非零(比如 NT=1,值为 0x41)就判成 false;如果用了双点类型 3 的解析逻辑去解类型 1 的报文,DPI 在 bit0 到 bit1,取值 1 和 2 都算合位,== 1也只覆盖一半。
解决:单点用(siq & 0x01) != 0,双点用(diq & 0x03)判断 1 或 2 为合、0 为分、3 为不确定。解析前先把 TI 和点表里配的类型对一遍,类型不一致直接告警,不要“尽力而为”地硬解。
6. 进阶:点表外部化加报文存档,把联调变成可复现
现场调试最耗时间的不是写代码,是“刚才那一下到底发生了什么”。我的做法是两件事:点表从代码里挪出去,报文全部存档。
点表用 YAML 或者 CSV 都行,字段至少包含 IOA、类型标识、业务名、量程和死区。下面是一个能用的最小结构:
| 字段 | 示例 | 说明 |
|---|---|---|
| ioa | 16385 | 信息对象地址,十进制,配表时和厂家点表对齐 |
| type | 13 | 类型标识,决定用哪个解析分支 |
| name | 母线电压 | 业务名,只给上层用 |
| scale | 0.1 | 归一化值等比缩放,短浮点填 1.0 |
| deadband | 0.02 | 变化死区,小于它不触发入库 |
解析器启动时把这张表加载成Map<Integer, PointDef>,收到 ASDU 只做一次查表,查不到就打一条“未配置点位 IOA=xxx TI=xxx”的日志。这条日志的价值极高,厂家给的点和实际送的对不上时,一眼就能看出来。
报文存档就是把每一条收发的 APDU 按十六进制写文件,前面带时间戳和方向:
public static String hex(byte[] b) { StringBuilder sb = new StringBuilder(b.length * 3); for (byte x : b) sb.append(String.format("%02X ", x)); return sb.toString().trim(); } // 发送与接收两侧都调用 log.info("{} {} len={} {}", ts, dir, apdu.length, hex(apdu));存档文件的价值在于可回放。写一个小的回放器,把这个文件按行读进来喂给解析器,不用连设备就能复现白天的解析异常。我一般会把回放器做成一个单元测试,改完解析逻辑先跑一遍历史报文,确认点位值和当时一致再上线。
验证方法上还有一招:用同一套代码起两个实例,一个当主站一个当从站,主站发总召唤、从站按点表造数据回,先用它把时序跑通再上现场。这比在现场拿设备试要快得多,也不会因为反复启停影响运行中的站。
从那以后我每次接新站,都强制走一遍“点表先对齐、报文先存档、回放能复现”这三步,哪怕甲方催得再紧也不跳。很多所谓的玄学问题,回头看都是长度域差一个字节、序号没取模、或者一个==该写成&。希望帮到你。
本文还有配套的精品资源,点击获取