资讯动态

Java IEC104 主站源码:建链、总召唤与遥测遥信解析

发布时间:2026/10/26 10:32:48 来源:尧图企业网站定制
简介面向需要基于 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 个字节的控制域。控制域首字节的低两位决定帧型bit00是 I 帧信息帧带 ASDUbit01 且 bit10是 S 帧监督帧只回确认bit01 且 bit11是 U 帧无编号控制帧做启停和测试。帧型控制字节 1控制字节 2控制字节 3控制字节 4带 ASDUI 帧(NS 0x7F) 1NS 7(NR 0x7F) 1NR 7是S 帧0x010x00(NR 0x7F) 1NR 7否U 帧0x030x00功能码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开始依次是类型标识 TI1 字节、可变结构限定词 VSQ1 字节、传送原因 COT2 字节小端、源发地址 OA1 字节、公共地址 CA2 字节小端合计 7 字节信息对象从索引 13 开始。VSQ 的最高位是 SQ 标志低 7 位是信息对象个数。SQ0 表示每个信息对象前都带 3 字节信息对象地址SQ1 表示只有第一个对象带地址后面的地址依次加一这是总召唤响应里最常见的省字节写法。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 与读超时标准端口是 2404t0 连接超时默认 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 con0x0B最多重试 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 总召唤的完整时序激活、确认、数据、结束站总召唤的类型标识是 100C_IC_NA_1限定词 QOI 填 20。完整时序是主站发激活COT6→ 从站回激活确认COT7→ 从站按组上送数据COT20→ 从站回激活终止COT10。只有收到 COT10 才算这一轮总召唤结束中间任何一步缺失点表就是不完整的。/** 组装站总召唤 ASDUCA 2 字节小端IOA 3 字节小端QOI20 */ public static byte[] interrogation(int ca, int qoi) { return new byte[]{ (byte) 100, // TI C_IC_NA_1 (byte) 0x01, // SQ0对象个数 1 (byte) 6, 0, // COT6 激活 0, // 源发地址 (byte) (ca 0xFF), (byte) (ca 8), // 公共地址 0, 0, 0, // 信息对象地址 0 (byte) qoi // QOI20 站总召唤 }; }整包 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 里多拼或少拼了字节。收侧的处理是一个状态机COT7 时打日志COT20 时进解析器COT10 时置“总召唤完成”标志并通知上层。COT 是 44、45、46、47 这几个负向原因时分别对应类型标识不认识、传送原因不认识、公共地址不认识、信息对象地址不认识这时候该做的是把这条日志原样打出来而不是吞掉。3.4 k/w 流控与 S 帧确认的落地写法k 是未确认 I 帧的最大数量默认 12w 是收到多少条 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 是可配置项有些设备侧配的是 k12、w8有些是 k8、w5。主站这边按保守值走收够 8 条就确认兼容性最好。4. 三遥解析类型标识 1/3/9/13/30/36 与点位映射数据上送这一段是整个主站里最琐碎的部分。类型标识决定了信息对象占几个字节、值怎么换算、带不带时标写错了不会报错只会给你一堆看起来像真的的数字。4.1 单点、双点、归一化、标度化、短浮点的字节长度对照TI名称值域编码单个对象字节数1M_SP_NA_1 单点遥信SIQbit0 为状态3 13M_DP_NA_1 双点遥信DIQbit0~1 为状态3 19M_ME_NA_1 归一化遥测2 字节有符号除以 327683 211M_ME_NB_1 标度化遥测2 字节有符号整数3 213M_ME_NC_1 短浮点遥测IEEE754 单精度3 415M_IT_NA_1 累计量4 字节有符号3 4 130M_SP_TB_1 带时标单点SIQ CP56Time2a3 1 731M_DP_TB_1 带时标双点DIQ CP56Time2a3 1 736M_ME_TF_1 带时标短浮点短浮点 CP56Time2a3 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溢出。工程上我的处理是IV1 直接丢弃并记录一次告警不入库NT1 入库但打上“非实时”标签因为总召唤期间很多设备会先送一版缓存值SB1 和 BL1 入库并且单独标记人工置数在调试期很常见直接当真值会误导后面的计算。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; } // SQ1 后续地址自增 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 没握手就发总召唤从站还在停止态收到也不处理剩下的一成是公共地址配错从站按 COT46公共地址不认识回了否定确认但被主站的解析器当成正常帧吞掉了。解决把发送顺序改成严格串行——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判断遇到品质位非零比如 NT1值为 0x41就判成 false如果用了双点类型 3 的解析逻辑去解类型 1 的报文DPI 在 bit0 到 bit1取值 1 和 2 都算合位 1也只覆盖一半。解决单点用(siq 0x01) ! 0双点用(diq 0x03)判断 1 或 2 为合、0 为分、3 为不确定。解析前先把 TI 和点表里配的类型对一遍类型不一致直接告警不要“尽力而为”地硬解。6. 进阶点表外部化加报文存档把联调变成可复现现场调试最耗时间的不是写代码是“刚才那一下到底发生了什么”。我的做法是两件事点表从代码里挪出去报文全部存档。点表用 YAML 或者 CSV 都行字段至少包含 IOA、类型标识、业务名、量程和死区。下面是一个能用的最小结构字段示例说明ioa16385信息对象地址十进制配表时和厂家点表对齐type13类型标识决定用哪个解析分支name母线电压业务名只给上层用scale0.1归一化值等比缩放短浮点填 1.0deadband0.02变化死区小于它不触发入库解析器启动时把这张表加载成MapInteger, PointDef收到 ASDU 只做一次查表查不到就打一条“未配置点位 IOAxxx TIxxx”的日志。这条日志的价值极高厂家给的点和实际送的对不上时一眼就能看出来。报文存档就是把每一条收发的 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));存档文件的价值在于可回放。写一个小的回放器把这个文件按行读进来喂给解析器不用连设备就能复现白天的解析异常。我一般会把回放器做成一个单元测试改完解析逻辑先跑一遍历史报文确认点位值和当时一致再上线。验证方法上还有一招用同一套代码起两个实例一个当主站一个当从站主站发总召唤、从站按点表造数据回先用它把时序跑通再上现场。这比在现场拿设备试要快得多也不会因为反复启停影响运行中的站。从那以后我每次接新站都强制走一遍“点表先对齐、报文先存档、回放能复现”这三步哪怕甲方催得再紧也不跳。很多所谓的玄学问题回头看都是长度域差一个字节、序号没取模、或者一个该写成。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑