简介:面向电力调度与工业自动化场景的JAVA104协议监听工具包,主要帮助Java开发者在Spring Boot项目中快速实现IEC 60870-5-104规约的主站连接、实时数据监听与报文解析,解决传统十六进制报文难以直接阅读、调试定位困难的问题。压缩包共5个文件,包含3个Java核心源码——J60870Client负责主站连接、J60870ClientListener负责协议监听、J60870Main负责启动运行,另附readme说明文档和已打好的jar依赖包,整体仅105KB,轻量易用,导入后可按readme指引自行调整环境。源码已将104报文统一解析成常规十进制数据,并重写toString()方法把关键字段汇总为便于读取的字符串,读者可根据业务自行调整输出格式;同时提供通过POST请求将监听数据转发至客户端处理的参考实现,可灵活适配本地展示或对接后端系统。资源包已上传所需jar包,readme中也补充了Maven依赖,方便快速集成。已有1411人学习浏览,适合需要二次开发或深入理解104规约通信流程的初中级工程师。 做电力自动化调试这几年,最常被问的问题之一就是:104规约的数据到底怎么监听?尤其是手头没有厂家专用的报文分析工具时,面对一堆TCP字节流完全无从下手。前阵子我配合变电站做数据网交换机调试,现场主站侧说收不到遥测,子站侧说已经上送了,两边都在"甩锅"。手边只有一台笔记本,我索性用Java写了个小型104监听程序,把链路上的TCP帧原样抓下来,逐帧解析出遥测、遥信和遥控报文,十分钟就定位到了问题。这篇文章就把这套监听程序的实现思路和踩过的坑完整记录下来,给同样要做104规约调试、协议分析、或需要快速验证子站上送数据的同行一个能直接参考的路径。
1. 需要写监听程序的几种现场场景
1.1 主站不发遥测?先分清"链路没通"还是"数据没上送"
104规约调试中最常见的现象就是数据"静默"。但真正上手排查时你会发现,"数据静默"至少有三层含义:
- 链路层没建立:主站发起了TCP连接,但U帧握手没有完成,双方没有进入数据传输状态;
- 链路通了但没有数据:连接正常、握手完成,但主站没有下发总召唤或想召命令,子站自然不会主动上送;
- 数据上了但没到主站:子站正常上送遥测遥信,但中间交换机、网关或防火墙把报文丢了,或者改了端口。
这三种场景,用抓包工具看TCP层全都能看到,但问题在于:现场大多没有专门的104协议分析仪。而用Java写一个监听程序,本质上是把"看到TCP报文"提升到"看懂104报文"的层面,直接判断问题出在哪一层。
我那次调试遇到的实际情况是:主站已经发了总召唤激活命令,子站也回了确认,但主站始终不刷新遥测。监听程序一跑就发现,子站上送的I帧序号一直没被主站确认,主站的S帧确认报文也没有正常回来。问题根本不在应用层数据,而在两边的传输确认机制上。没有监听工具,这种问题只能靠猜。
1.2 三种监听落点:接在子站、挂在主站、串在链路中间
写监听程序之前,先想清楚你的监听程序要放在网络拓扑的哪个位置。这决定了你是做ServerSocket监听还是主动连接对端。
- 作为服务端监听在子站侧:子站(测控装置、保护装置、远动通信管理机)一般是104服务端,主站是客户端主动连过来。你的Java程序监听2404端口,等主站连接,这样能完整捕获主站下发的遥控、遥调、召唤命令,以及子站回给主站的一切响应。适合做"从站仿真"或"主站报文观测"。
- 作为客户端连接主站或前置机:有些场合主站侧开放了一个调试端口,你的Java程序主动连接过去,接收对方推送的报文。这种模式通常用于"被动观测"。
- 串在中间做报文镜像:利用交换机的镜像口,或者用T型分光器把双向流量复制一份,Java程序完全透明地抓取双向报文。这种方式最接近"监听"的本意,不会影响原有链路,也是我最推荐现场实测时使用的方式。
定位不同,代码入口就不一样。本文后面以"服务端监听2404端口"为主展开,因为这是最容易复现、也最适合做协议验证的场景。如果需要镜像抓包,程序核心的帧解析部分完全一样,只需要把数据源从ServerSocket换成Pcap4J之类的抓包API即可。
2. 解析前必须吃透的104帧结构:从串口思维转换到TCP流
2.1 一个字节一个字节拆开看APDU
104规约的本质是IEC 60870-5-101规约在TCP/IP上的映射。它的每个应用层协议数据单元(APDU)结构非常规整,起始字符固定为0x68,第二个字节是APDU长度,然后是4字节的控制域,之后是ASDU(应用服务数据单元)。
以一条最常见的I帧报文为例,它的字节分布如下:
| 字节偏移 | 内容 | 说明 |
|---|---|---|
| 0 | 0x68 | 启动字符,固定不变 |
| 1 | APDU长度 | 不含前两个字节,但包含控制域和ASDU,范围4-253 |
| 2-5 | 控制域 | 决定帧类型,I帧/S帧/U帧 |
| 6+ | ASDU | 业务数据,包含类型标识、传送原因、公共地址、信息体 |
注意一个容易记错的地方:APDU长度字段的值包含了4字节控制域,但不包含启动字符0x68和长度字段自己那一字节。也就是说,如果一帧APDU只有控制域没有ASDU(比如S帧),长度字段的值是4,整帧从0x68到最后一个字节共6个字节。很多初次写解析程序的人在这里栽跟头,直接用长度字段值做数组下标,导致越界或少读。
用Java读取时,最简单的做法是:先循环读取直到遇到0x68,再读一个字节得到长度len,然后接着读len个字节,这一帧就完整了。这里要强调一下,TCP是字节流,不是报文流,0x68只是应用层帧头,不代表TCP分段的边界,所以必须自己维护"找帧头、读长度、读完整帧"的过程,这就是后面要说的粘包拆包。
2.2 控制域判断:I帧、S帧、U帧的区别
控制域的第一个字节(即整帧的第2个字节)的低两位决定了帧类型。这是解析程序里第一个要做的分支判断,也是最基础的分流逻辑。
- I帧(信息传输帧):低两位为00(即该字节bit0=0,bit1=0)。I帧携带ASDU业务数据,是遥测、遥信、遥控、遥调这些正经数据的载体。控制域的第2-5字节中,前两个字节拆出发送序号N(S),后两个字节拆出接收序号N(R)。序号都是15位,范围0-32767,循环使用。
- S帧(监视帧):控制域第一字节为0x01。S帧只有确认功能,不携带ASDU,用来确认对方已发来的I帧。控制域后两个字节是接收序号N(R)。
- U帧(控制帧):控制域第一字节为0x03。U帧用于链路控制,携带STARTDT(启动数据传输)、STOPDT(停止数据传输)、TESTFR(测试帧)三类命令及确认。U帧没有序号。
监听程序拿到一帧数据后,第一步就是根据控制域第一字节的低两位判断类型,然后分别处理。对监听来说,I帧的价值最高,因为业务数据全在ASDU里;S帧和U帧的价值在于判断链路状态是否正常——如果长期只收到S帧而没有I帧,说明链路是通的但没有数据流动;如果连U帧握手都没完成,那链路压根没建立起来。
2.3 ASDU里的业务字段:类型标识、传送原因、公共地址、信息体地址
ASDU是104规约里真正有业务含义的部分。它的开头几个字段非常固定,解析时按顺序读即可:
- 类型标识(1字节):这一字节决定了后面信息体的结构和含义。比如1代表单点遥信,3代表双点遥信,9代表归一化遥测,11代表标度化遥测,13代表短浮点遥测(float),45代表单点遥控,50代表短浮点遥调。一张类型标识表是必须常备的资料。
- 可变结构限定词VSQ(1字节):最高位表示信息体地址是否连续。bit7=0时,后面的信息体不含地址,只有一个起始地址,自增累加;bit7=1时,每个信息体自带完整地址。低7位表示信息体个数。解析时先读VSQ,确定"有几个信息体"以及"要不要读地址",才能正确游走到下一个信息体。
- 传送原因COT(2字节):第一个字节的低6位表示传送原因,如1=周期/循环,3=突发,4=初始化,5=请求/召唤,6=激活,7=激活确认,20=响应站召唤。第二字节一般是源发地址,通常为0。
- 公共地址CA(2字节):站地址。低字节在前,多站转发时靠它区分。
- 信息体地址IOA(3字节):具体测点的地址。低字节在前,结合类型标识就能唯一定位一个数据点。
整个ASDU的解析逻辑就是:先读类型标识和VSQ,再读传送原因和公共地址,然后根据类型标识进入不同的信息体解析分支。监听的最终目的,无非就是把这条链路里所有I帧的ASDU解出来,按类型归类、按公共地址分站、按信息体地址对应到具体测点。
3. Java监听程序的落地:Socket读取、帧边界划分与链路握手
3.1 先用ServerSocket还是先连出去:实现落点选择
前面提到监听落点有三种,实际写代码时,我强烈建议优先以"服务端监听"为起点。原因很直接:104主站作为TCP客户端,会主动连接子站的2404端口,我们只要在本机或现场笔记本上起一个ServerSocket(2404),让主站或前置机连过来,就能立刻开始收报文。这种方式不需要改动网络拓扑,不需要在链路上做镜像,还能顺便在主站连接失败时第一时间暴露网络连通性问题。
如果你要监听的是子站主动上送的数据,而现场不允许你占用现网端口,那就在自己笔记本上搭一个"虚拟主站"环境,用Java程序模拟主站连接真实的子站设备,同样走2404端口,效果一样。两种模式的核心代码几乎没有差别,区别只在ServerSocket.accept()和socket.connect()这两种建立连接的方式上。
3.2 粘包拆包的正确处理方式
TCP粘包拆包是写监听程序遇到的第一个技术门槛。104协议靠0x68定位帧头,但TCP流不会保证每个0x68正好是一帧的开头,可能出现一帧被拆成多个TCP段,或多个104帧连在一个TCP段里。处理思路是维护一个累积缓冲区,循环读流、找帧头、读长度、取整帧。
承诺一个实用写法:不要用read(byte[])直接读固定长度,因为InputStream.read(byte[])并不能保证一次调用就读满数组长度,网络数据不足时会返回实际读到的字节数。我之前见过不少同行在这里踩坑,一帧数据没读完就硬套协议解析,出来全是乱码。正确做法是循环读,直到凑够整帧需要的字节数。
public static byte[] readNextFrame(InputStream in) throws IOException { int first; // 找帧头 0x68 while ((first = in.read()) != -1) { if (first == 0x68) { int len = in.read(); if (len < 4 || len > 253) { continue; // 长度异常,重新找帧头 } byte[] frame = new byte[len + 2]; frame[0] = (byte) 0x68; frame[1] = (byte) len; int readCount = 2; while (readCount < frame.length) { int n = in.read(frame, readCount, frame.length - readCount); if (n == -1) { throw new EOFException("连接已关闭"); } readCount += n; } return frame; } } return null; }这段代码的核心思路是"先凑长度,再读满"。第二层while循环处理了拆包:即使一帧被拆成两次TCP到达,也能拼齐。
3.3 完整代码骨架:链路激活与心跳维持
有了帧读取方法,监听主流程就可以搭起来了。作为服务端监听时,accept()到主站连接后,下一步不是直接解析业务数据,而是要完成104链路的握手:主站会发U帧STARTDT激活(0x68 04 07 00 00 00),服务端要回一个STARTDT确认(0x68 04 0B 00 00 00),然后双方才进入数据传输状态。这一步不完成,主站后续不会发送任何I帧。
完整骨架大致如下:
try (ServerSocket server = new ServerSocket(2404)) { System.out.println("104监听服务已启动,端口2404"); while (true) { Socket socket = server.accept(); System.out.println("主站连接接入: " + socket.getRemoteSocketAddress()); new Thread(() -> handleConnection(socket)).start(); } } private static void handleConnection(Socket socket) { try (InputStream in = socket.getInputStream(); OutputStream out = socket.getOutputStream()) { while (true) { byte[] frame = readNextFrame(in); if (frame == null) break; // 解析控制域第一字节 int control = frame[2] & 0xFF; if ((control & 0x03) == 0x03) { // U帧 if (control == 0x07) { // STARTDT激活 out.write(new byte[]{(byte)0x68, 0x04, (byte)0x0B, 0x00, 0x00, 0x00}); out.flush(); System.out.println("链路启动确认已发送"); } else if (control == 0x43) { // TESTFR测试帧 out.write(new byte[]{(byte)0x68, 0x04, (byte)0x83, 0x00, 0x00, 0x00}); out.flush(); } } else if ((control & 0x03) == 0x01) { // S帧,仅确认,不处理数据 System.out.println("收到S帧确认"); } else { // I帧 // 处理ASDU,解析业务数据 parseASDU(frame); // 一定要回复S帧确认,否则主站序号无法滑动 int recvSeq = ((control & 0xFE) >> 1) | ((frame[3] & 0xFF) << 7); int ackSeq = recvSeq; byte[] sFrame = new byte[]{ (byte)0x68, 0x04, 0x01, 0x00, (byte)((ackSeq << 1) & 0xFE), (byte)((ackSeq >> 7) & 0xFF) }; out.write(sFrame); out.flush(); } } } catch (IOException e) { System.out.println("连接断开: " + e.getMessage()); } }要特别说明一下:S帧确认不是可选项,是掉了就会出事的必选项。104协议的收发序号机制类似TCP的滑动窗口,主站发出N个I帧后,如果收不到你的确认,发送序号达到窗口上限就会停下来,表现为"主站只发了几个帧就没下文了"。我在调试现场见过多次"只收到前几帧数据后面再也不来了"的现象,一查就是监听端或子站没有回复S帧。
4. 数据解析这一关:从字节到遥测遥信的换算细节
4.1 遥测的三张表:归一化、标度化、短浮点
104规约里遥测是高频数据,也是最需要在监听解析时做换算的部分。三个常用类型标识必须烂熟于心:
- 类型标识9,归一化遥测(M_ME_NA_1):信息体数据为2字节有符号整型(short),单位为原始值的千分之一。比如收到的值为-5000,实际遥测值就是-5.000。这类数据的精度和量程由双方的整定决定,监听端无法自动还原成工程值,最好的做法是先原样打印short值,再对照点表手工换算。
- 类型标识11,标度化遥测(M_ME_NB_1):同样是2字节有符号整型,但比例尺由ASCII字符c(float)描述。很多老装置的功率、电流用这种格式,监听时要额外解析后面的比例因子。
- 类型标识13,短浮点遥测(M_ME_NC_1):信息体数据为4字节IEEE 754浮点数,Java中直接用
Float.intBitsToFloat()转换即可。这是目前主流综自设备最常用的遥测格式,也是现场排查时最直观的。收到一帧13类型,4个字节拼出一个float,基本不需要额外处理。
以短浮点遥测为例,单信息体的ASDU段解析代码:
int typeId = asdu[0] & 0xFF; int vsq = asdu[1] & 0xFF; int count = vsq & 0x7F; boolean isContinuous = (vsq & 0x80) == 0; int offset = 6; // 跳过类型、VSQ、COT(2)、CA(2) int ioa = 0; for (int i = 0; i < count; i++) { if (isContinuous && i > 0) { ioa++; } else if (!isContinuous || i == 0) { ioa = (asdu[offset] & 0xFF) | ((asdu[offset + 1] & 0xFF) << 8) | ((asdu[offset + 2] & 0xFF) << 16); offset += 3; } if (typeId == 13) { int bits = (asdu[offset] & 0xFF) | ((asdu[offset + 1] & 0xFF) << 8) | ((asdu[offset + 2] & 0xFF) << 16) | ((asdu[offset + 3] & 0xFF) << 24); float value = Float.intBitsToFloat(bits); System.out.println("IOA=" + ioa + ", 遥测值=" + value); offset += 5; // 4字节float + 1字节品质 } }需要特别提醒:品质字节是信息体的一部分,不是可忽略的尾巴。很多监听工具把最后1字节品质描述当成填充字节丢掉,导致解析结果错位,后续所有信息体全部错乱,这属于新手常见问题。
4.2 遥信和遥控的位与字节
遥信相对简单,但有几个细节值得说明。类型标识1是单点遥信,每个信息体的数据就是1个字节,bit0表示分合状态:0为分,1为合。类型标识3是双点遥信,bit0和bit1组合:01=合,10=分,其他为中间状态或不确定状态。后面通常跟随1字节品质描述,表示是否有效、是否被取代等。
监听遥信时最实用的是一个"位视图"打点法。别急着把每个字节都解释成分/合,先按信息体地址把收到的状态存在一个Map里,等数据攒几轮再对比变化。104调试中最常排查的就是"某个遥信位置不上来"或"状态突变",用监听程序拿到IOA和对应状态后,直接跟后台点表比对,问题立刻浮出水面。
遥控解析要小心的是激活与执行分开处理。类型标识45(单点遥控)、46(双点遥控)的控制命令,主站先发一帧激活(COT=6),子站回激活确认(COT=7),执行完成后回激活终止(COT=10)。监听程序要把三个阶段的帧都抓下来,才能完整还原一次遥控过程。如果只盯着激活帧看,会漏掉装置未执行的真相。
4.3 品质描述字节为什么不能当噪声丢掉
品质描述(QDS)是信息体最后1字节,其bit位含义如下:
| bit位 | 含义 |
|---|---|
| bit0 | 是否被取代(SB,0=未取代,1=取代) |
| bit1 | 是否带时标(NT,0=不带,1=带) |
| bit2 | 是否有效(IV,0=有效,1=无效) |
| bit3 | 是否被封锁(BL,0=未封锁,1=封锁) |
| bit4-7 | 保留 |
我想强调的是bit2(IV位)。我遇到过不少现场,后台一直显示某个测值不对,抓包发现装置确实上送了数据,但IV位是1,说明这个值是无效的——可能是通讯中断保护置位、手动置数或检修压板投入导致的。如果监听程序不考虑品质位,就会把这帧当成有效数据误判。所以解析时务必把这个字节打印出来或展示为"有效/无效/取代/封锁",哪怕只是用来快速排除异常。
5. 调试实录:TCP字节流里的几个隐藏坑
5.1 帧头0x68和业务数据里的0x68撞车
这是我在一次监听写完后遇到的第一个"灵异事件":程序解析出来的帧长度忽长忽短,偶发性地丢掉一帧。排查后发现,APDU的数据区内(尤其是浮点数的字节序列里)完全可能出现0x68这个值。我的第一版找帧头逻辑是"看到0x68就认为是一帧的起点",结果把数据区里的0x68当成了帧头,后面所有字节错位。
解决思路是在找帧头时增加合理性校验:以0x68为候选起点后,读取长度字段len,必须满足4≤len≤253,且后面的len个字节能完整读够,才认为这是一个合法帧;如果读不齐或者后续解析控制域时发现既不像I/S/U帧的合法组合,就说明帧头定位错误,回退重新找。
实际工程中,最好配合"连续两帧校验"来定位帧头。在缓冲流里一旦找到一个合法的0x68+len结构,解析完这帧后,检查下一帧的起始字节是否还是0x68且长度合法。如果多轮解析都稳定,说明缓冲区对齐正确。如果中途错位,立即清空缓冲,重新从原始字节流里搜索。
5.2 序号不确认,主站直接沉默
前面提过S帧确认的重要性,这里说一个更隐蔽的情况:序号计算错误。104规约的序号是15位循环的,很多初写者在做S帧确认时直接用收到的发送序号原样回填,但S帧里的接收序号需要左移一位,因为S帧控制域第二字节的低位是0,接收序号放在bit1到bit7。我早期写的确认代码:
byte sFrame = 0x01; byte nR = (byte)((recvSeq << 1) & 0xFE);这里如果recvSeq超过127,左移后溢出就会出问题。正确做法是用两个字节分别承载15位序号:
int nRSend = ((control & 0xFE) >> 1) | ((frame[3] & 0xFF) << 7); // 发送序号 int ackSeq = nRSend; // 确认对方发来的序号 byte[] s = new byte[]{ (byte)0x68, 0x04, 0x01, 0x00, (byte)((ackSeq << 1) & 0xFE), (byte)((ackSeq >> 7) & 0xFF) };主站收到错误序号的S帧后,轻则忽略,重则直接断开连接。如果你发现程序收了几帧后链路就不再前进,优先检查确认帧的序号拼装。
5.3 端口占用、防火墙与2404的"玄学"
另一个现场高频坑:程序启动报BindException: Address already in use。原因大多数是上一个监听进程没有完全退出,端口还处于TIME_WAIT状态。我的经验是开发调试阶段用ServerSocket时加上setReuseAddress(true):
ServerSocket server = new ServerSocket(); server.setReuseAddress(true); server.bind(new InetSocketAddress(2404));还有windows和部分Linux服务器的防火墙会拦截非本机的TCP连接。现场笔记本做监听时,如果主站连不上,不要只盯着代码,先在本机用netstat -an | findstr 2404看端口是否处于LISTENING状态,再用telnet 10.x.x.x 2404测试主站到本机的连通性。很多"协议解析错误"的假象,最后发现是TCP层就没通。
5.4 报文时间戳和缓存落盘
监听程序跑起来后,如果只是把报文解析结果打印在控制台,一旦数据量上来,打印本身就会拖慢读取速度,甚至造成丢帧。实用的做法是:解析线程和业务处理线程分离。网络读取线程只负责把原始帧放进一个阻塞队列,另一个解析线程从队列里取帧、打时间戳、解析和落盘。这里的时间戳务必以帧接收时刻为准,不要用解析时刻,否则在高并发场景下时间误差会很大。
我在实际监听工具里还加了原始报文hex落盘功能:每帧除了解析内容,顺便把整帧十六进制字节写进一个日志文件。这样即使后来发现某个字段解析逻辑写错了,还能用原始报文重新复盘,不用再到现场抓一次包。对调试来说,留一手原始数据永远不亏。
6. 从监听工具到调试平台:选型与扩展建议
6.1 用现成库还是自己解析?
如果你的目标只是快速看数据,不必重复造轮子。Java生态里有几个现成的104协议库,最常用的是OpenMUC的j60870,它已经实现了连接管理、ASDU解析、类型标识映射等底层逻辑。直接引入依赖,几行代码就能监视一个连接:
ClientConnection connection = new ClientConnection("10.1.1.1", 2404); connection.connect(); connection.setConnectionListener(new ConnectionListener() { public void connectionOpened(Connection c) { c.startDataTransfer(); } public void newASdu(ASdu asdu) { System.out.println(asdu.getTypeIdentification() + " " + asdu.getCommonAddress()); } });用库的优势是省时省力,但它也有明显的局限:库帮你封装了太多细节,一旦遇到非标准厂家的私有类型标识或特殊信息体结构,反而很难排查;而且你拿不到解析过程的中间态(比如原始帧缓冲、控制域序号),对深入调试帮助有限。我的经验是:现场快速验证用库,深入到协议层面的问题排查必须自己解析关键帧。
6.2 一套可复用的监听日志格式
最后分享一个实战习惯。我最终实现的监听工具,不只是打印解析结果,而是输出一行紧凑的结构化日志,方便事后用文本工具检索:
[2025-01-15 10:23:45.123] [I帧] 发送序号=1024, 接收序号=332, 类型=13, COT=3, CA=1 IOA=1001, 值=220.50, 品质=有效 IOA=1002, 值=10.02, 品质=有效这样的日志格式,既能在控制台实时观察数据变化,又能落盘后用grep或awk过滤特定IOA的曲线。对于动辄几万条记录的遥测报文,按IOA过滤是定位问题最快的方式。
总而言之,写一个104规约的Java监听程序并不复杂,难的是把TCP字节流、帧边界、序号确认和ASDU嵌套结构这些层层细节串起来,并在真实现场的环境里保持稳定。如果你正准备做类似的工具,建议按"先解析帧->再握手->再解析ASDU->最后加日志落盘"的顺序逐步实现,不要想着一步到位。手里有了一套能随时解析原始报文的工具,后续不管是处理主站通信异常还是验证设备上送逻辑,都会从容很多。
本文还有配套的精品资源,点击获取