做变电站远动调试和电力调度自动化运维的朋友,大概率都有过对着104报文发呆的夜晚。104规约报文以十六进制字节流跑在TCP链路上,联调现场往往是主站和厂站工程师各执一词,唯一能当裁判的就是抓包文件里那一串串十六进制数据。我第一次被逼着系统研究104报文解析工具,是在一次总召唤无响应的联调现场——遥信变位丢失、遥控返校超时,两边都说自己没问题。最后把抓包文件导出来,逐字节对照IEC 60870-5-104标准去抠,才定位到问题出在帧序号处理上。从那以后我就认定,干远动调试,手里没有一套顺手的报文解析工具,等于裸奔。
这篇文章不打算讲教科书式的规约理论,就讲我实际在用的解析思路、工具选型、自己写解析脚本的代码,以及这些年踩过的坑。内容对正在做104联调、远动维护的朋友应该能直接上手,刚入行电力自动化或者对报文解析感兴趣的新人,也能从中建立一套自己的分析框架。
1. 为什么我坚持要有一套顺手的三维解析工具
1.1 104报文在工程现场到底有多难啃
IEC 60870-5-104规约,工程上直接叫104规约,是电力系统远动通信的主流协议之一。调度主站通过它采集变电站的遥信、遥测、遥控、遥调数据,可以理解为电网调度系统的神经末梢。规约本身跑在TCP/IP之上,默认端口2404,链路层有I、S、U三种帧,应用层又套一层ASDU。现场联调时,一条链路上每秒少则几条、多则几十条报文,信息体地址按间隔划分,遥信遥测密密麻麻。数据量大、帧类型多、点位关系复杂,光靠人眼去读十六进制字符串,别说快速定位故障,能把一屏报文念完就不错了。
我见过不少新同事,拿着抓包文件往上翻半天,最后问我"这一堆68 04 07 00 00 00到底什么意思"。其实68 04表示这是一帧长度为4字节的APDU,07 00 00 00是U帧的STARTDT激活命令。如果不熟悉这类基础帧结构,后面每一个字节都会卡住。解析工具的价值首先就是把这种"人眼不友好"的字节流翻译成人能读懂的帧类型、序号、类型标识和点位值,让分析焦点从"猜字节"转移到"找问题"上。
1.2 解析工具能解决的四类核心问题
我在实际调试中归纳过,104解析工具最核心的价值是解决四类问题。
第一是帧定位与完整性判断。TCP是字节流,没有天然的消息边界,报文的切分必须依赖启动字符0x68和帧长度字段。工具自动完成切帧后,能立刻看出哪些报文被半包、粘包影响了。
第二是收发序号的还原与一致性分析。I帧的发送序号N(S)和接收序号N(R)分别编码在控制域的特定字节中,S帧也有接收序号。序号是否连续,直接反映链路是否丢帧、对端是否收齐,这是排查很多疑难问题的关键入口。
第三是ASDU类型识别与信息体提取。不同类型标识对应不同结构,比如单点遥信、短浮点遥测、单点遥控、总召唤、时钟同步等,每个信息体的长度和含义都不同。工具按类型标识解析出传送原因、公共地址、信息体地址和值,才是真正能指导现场的数据。
第四是链路交互过程的可视化。U帧的STARTDT、STOPDT、TESTFR,I帧的周期数据、突发数据,S帧的确认响应,这些交互过程串起来就是一条完整的链路状态机。工具把这层关系呈现清楚,联调时沟通效率会高很多。
2. 上手解析之前,先把104报文结构刻在脑子里
2.1 APCI层:一帧报文的三张脸
104规约的APDU格式是固定套路:启动字符0x68、帧长度、控制域四字节,后面跟着ASDU(I帧才有)。控制域四字节决定了帧类型,也就是I帧、S帧、U帧"三张脸"。
I帧是信息帧,用来传遥信、遥测、遥控等实际数据。控制域第一个字节的最低位是0,发送序号N(S)和接收序号N(R)按规约的位偏移编码在四字节中,工具会自动还原成十进制序号。S帧是监视帧,只带接收序号N(R),用来确认对端发来的I帧,第一个字节最低两位是01。U帧是控制帧,管理链路启停和测试,比如STARTDT激活、STOPDT停止、TESTFR测试帧,最低两位是11。
工程解读有个小技巧:看到控制域第一字节的十六进制值,基本就能判断帧类型。0x00开头通常是I帧序号0或小序号,0x01开头是S帧,0x07、0x0B、0x13这类是U帧的常见控制字。Wireshark里直接会标注"Frame: I/S/U",但自己写工具的时候,这个最低位判断是必须的。
2.2 ASDU层:真正的业务数据藏在哪
I帧的ASDU部分才是业务数据的核心。ASDU由类型标识、可变结构限定词VSQ、传送原因COT、公共地址COA以及信息体组成。类型标识一个字节,告诉解析者后面跟的是什么类型的数据。VSQ一个字节,低7位是信息对象的个数,最高位表示信息体地址是否连续(连续方式下只需一个起始地址,后续地址自动递增)。传送原因COT两个字节,低6位是原因值,第7位表示试验标志,第8位表示肯定或否定确认。公共地址COA两个字节,在工程上通常对应一个厂站或者装置。
信息体的组织是解析中最容易出错的地方。以短浮点遥测(类型标识13)为例,每个信息体包含3字节信息体地址、4字节IEEE754浮点值,如果带时标还要再追加7字节的CP56Time2a时间对象。不同类型标识对应不同信息体长度,解析时必须按规约表格逐个匹配。这也是为什么我认为解析工具的核心算法,不是简单的字节打印,而是"知道每个字节应该在什么位置"。
2.3 工程中高频出现的类型标识和传送原因
我整理过一个现场常用表,调试时对照着看效率很高,这里分享出来。
| 类型标识 | 含义 | 信息体简要说明 |
|---|---|---|
| 1 | 单点遥信 | 1字节状态,bit0=0/1 |
| 3 | 双点遥信 | 1字节状态,bit0-1=0/1/2/3 |
| 9 | 归一化遥测 | 2字节有符号整数,需按量程换算 |
| 11 | 标度化遥测 | 2字节有符号整数 |
| 13 | 短浮点遥测 | 4字节IEEE754浮点数 |
| 30 | 带时标单点遥信 | 1字节状态+7字节CP56Time2a |
| 45 | 单点遥控命令 | 1字节SCO,含命令值、操作限定词 |
| 100 | 总召唤命令 | 信息体地址固定,召唤限定词1字节 |
| 103 | 时钟同步命令 | 7字节时标 |
传送原因里最常用的几个:1是周期循环,3是突发(变位或越限),5是请求或响应请求,6是激活,7是激活确认,8是停止激活,9是停止激活确认,20是响应站召唤。联调时看到COT=3,基本可以判断是遥信变位或者遥测越死区上送;看到COT=20,那就是总召唤的响应数据。这些值背熟了,看报文会快很多。
3. 工具选型:Wireshark、商用软件和自研脚本怎么配
3.1 Wireshark的104解析能力到底够不够
Wireshark内置了IEC 60870-5-104的协议解析器,抓包后能直接看到帧类型、序号、ASDU类型、传送原因、公共地址,甚至信息体里的浮点值。对于绝大多数联调分析场景,Wireshark是够用的。它的优势是免费、跨平台、生态强,能同时分析TCP层状态,比如丢包、重传、乱序,这些对判断链路易损非常有帮助。
但Wireshark也有不顺手的地方。一是点位不直观。它解析出的是信息体地址和值,但工程上你需要知道"地址60018对应的是一次设备开关位置",这个映射关系Wireshark做不了,得自己去查点表。二是批量过滤统计弱,比如要统计一段时间内某个地址的变位次数,得用tshark配合脚本。三是它对U帧和链路交互过程的展示是平铺的,没有清晰的状态机视图。所以我通常把Wireshark定位为"第一现场取证工具",但不会只用它来做深度分析。
3.2 什么时候值得自己写解析脚本
如果你只是偶尔看一次报文,用Wireshark就足够了。但如果你跟我一样,常年做多个厂站的联调、消缺,或者要复现某些链路上的偶发问题,我强烈建议自己写一套解析脚本。原因有三点。
第一,点位表可以内置。把全站遥信遥测点表导进脚本,解析结果的每一行直接显示"线路开关位置=合",调试效率比对照点表翻快太多。第二,可以批量跑历史抓包。现场没复现的问题,拿旧包反复分析,脚本能快速生成统计报告。第三,可以嵌入自动化测试。我在做规约一致性测试时,就是让脚本自动检查序号连续性、召唤响应完整性,超过阈值直接报错。
选型上我的组合是:Wireshark负责现场快速取证,Python脚本负责深度分析和批量处理。Python的优势是生态好,pyshark、dpkt、scapy都能用,但104解析逻辑本身不复杂,完全可以直接基于原始字节流写,不依赖重型库。下面我分享的这版核心代码,就是没有第三方依赖的纯Python实现。
4. 解析工具核心功能拆解
4.1 帧定位与完整性校验
TCP流上切分104帧,算法并不复杂:扫描启动字符0x68,读下一个字节得到APDU长度,然后从缓冲区中取长度字节出来。如果缓冲区剩余不够一帧,就继续等待后续数据,这就是半包处理。如果缓冲区内连续多帧,循环切分即可,这就是对粘包的处理。
切帧成功之后,还要做一次快速校验。APDU长度最少6字节(控制域四字节),U帧和S帧固定长度6,I帧如果带ASDU则大于6。如果切出来的帧长度小于6,或者I帧的启动字符位置不规律,就要警惕抓包异常或者对端实现不规范。
这里有个容易踩的坑:104报文长度字段包含的是控制域加ASDU的总长度,不含启动字符和长度字段本身。也就是说,如果报文的完整APDU是68 04 07 00 00 00,那么长度字段0x04恰好是后面四个字节的控制域。如果误把长度字段理解成整个APDU长度,切帧会整体错位,后面所有解析全部作废。
4.2 序号还原与收发一致性判断
I帧的N(S)和N(R)在控制域四字节中有严格的位偏移,解析时要按位拼接。以I帧为例,假设控制域四字节为ctrl[0]到ctrl[3],那么N(S)等于ctrl[0]右移一位后的低7位,加上ctrl[1]左移7位;N(R)同理,由ctrl[2]和ctrl[3]还原。S帧的N(R)则是根据帧类型标志位,从ctrl[0]的bit2以上和后续字节中提取。
序号还原之后,我会在工具里加一个检查逻辑:连续追踪每个方向上的N(S),如果出现跳号,就标记为"疑似丢帧"。注意,跳号不一定是真的丢帧,也可能是抓包点没抓到所有流量,或者对端有重传机制。但至少它能给排查提供一个清晰的方向,比盯着十六进制猜强。
4.3 ASDU类型识别与信息体提取
ASDU解析是我的工具里最值得花时间打磨的部分。拿到I帧的ASDU字节后,第一步读类型标识,第二步读VSQ得到信息体个数和地址连续性,第三步读COT和COA,第四步按类型标识从payload中提取信息体。每个信息体都必须处理"地址字段+值字段+可选时标"的结构。
比如类型标识13短浮点遥测,信息体长度是3字节地址+4字节浮点值,不带时标就是7字节。类型标识30带时标单点遥信,则是3字节地址+1字节状态+7字节时标,共11字节。解析时如果不按类型标识跳长度,后续信息体地址全部错位,这也是自写脚本最容易出bug的地方。我的建议是先把常用类型标识做出一张长度映射表,然后为每个类型写一个独立的小解析函数,不要试图写一个万能函数。
4.4 链路状态机与交互过程可视化
很多现场问题其实不在单帧数据本身,而在交互过程。比如总召唤发出去了,对端迟迟不回COT=20的响应,或者时钟同步命令发出后被拒绝,这些从单帧里看不出来,但把U帧的STARDT/STOPDT、I帧的召唤命令、S帧的确认响应按时间排列,整个链路状态机就清晰了。
我在工具里做一个简单的状态机视图:链路未激活、激活中、激活完成三个状态,状态迁移靠U帧的STARTDT、STOPDT,数据交换靠I帧。联调时看到"激活中"一直不跳转,基本能判定是TCP链路异常或对端规约栈卡死。这个功能用不上复杂算法,本质是把帧类型和时间戳组织成一张表,但调试价值极高。
5. 实操:用Python手写一个104报文解析器
5.1 抓包数据准备
我们先准备一份抓包文件。如果你没有现成的现场包,可以在本机用模拟工具生成一段104报文,或者直接拿Wireshark自带的示例抓包文件。我习惯把抓包导出为pcap后,用tshark把数据链路层的载荷导成一行行的hex字符串,再交给Python解析。这样可以把"抓包层"和"规约解析层"解耦,方便调试。
如果是TCP流,记得先按TCP会话把双向数据分离开,因为104规约的两个方向是独立的,帧序号也是各自独立的。分析时一定要知道当前这条TCP流是从主站到厂站,还是从厂站到主站,否则序号的连续性检查没有意义。实战中我会在脚本里把源端口和目的端口打出来,默认2404是厂站端监听端口。
5.2 核心代码实现
下面这份代码,我按"切帧-A PCI解析-ASDU基础解析"三层来组织,剥掉业务点表逻辑后大约不到100行,足够跑通大部分调试场景。
import struct # 从TCP字节流中切出完整APDU def split_apdu(buffer): apdus = [] while len(buffer) >= 2: if buffer[0] != 0x68: buffer = buffer[1:] continue length = buffer[1] if len(buffer) < 2 + length: break apdus.append(buffer[:2 + length]) buffer = buffer[2 + length:] return apdus, buffer # 解析APCI控制域四字节 def parse_apci(ctrl): b0 = ctrl[0] if (b0 & 0x01) == 0: ns = ((b0 >> 1) & 0x7F) | (ctrl[1] << 7) nr = ((ctrl[2] >> 1) & 0x7F) | (ctrl[3] << 7) return {'frame': 'I', 'ns': ns, 'nr': nr} elif (b0 & 0x03) == 0x01: nr = ((b0 >> 2) & 0x3F) | (ctrl[1] << 6) | (ctrl[2] << 14) return {'frame': 'S', 'nr': nr} else: u_cmd = { 0x07: 'STARTDT act', 0x0B: 'STARTDT con', 0x13: 'STOPDT act', 0x23: 'STOPDT con', 0x43: 'TESTFR act', 0x83: 'TESTFR con' } return {'frame': 'U', 'cmd': u_cmd.get(b0, 'UNKNOWN')} # ASDU基础信息解析 def parse_asdu(asdu): if len(asdu) < 6: return None type_id = asdu[0] vsq = asdu[1] cot = asdu[2] | (asdu[3] << 8) coa = asdu[4] | (asdu[5] << 8) num = vsq & 0x7F sq = (vsq >> 7) & 0x01 return { 'type_id': type_id, 'cot': cot, 'coa': coa, 'num': num, 'sq': sq, 'payload': asdu[6:] } # 根据类型标识解析信息体(以短浮点遥测为例) def parse_info_objects(asdu_info, type_id): payload = asdu_info['payload'] objs = [] offset = 0 for _ in range(asdu_info['num']): addr = payload[offset] | (payload[offset+1] << 8) | (payload[offset+2] << 16) offset += 3 if type_id == 1 or type_id == 3: val = payload[offset] offset += 1 objs.append((addr, val)) elif type_id == 9 or type_id == 11: val = struct.unpack('<h', payload[offset:offset+2])[0] offset += 2 objs.append((addr, val)) elif type_id == 13: val = struct.unpack('<f', payload[offset:offset+4])[0] offset += 4 objs.append((addr, val)) else: # 其他类型按最短处理,生产需要补全 objs.append((addr, payload[offset:offset+4])) offset += 4 return objs # 主流程:输入一帧完整APDU,输出解析结果 def parse_one_apdu(apdu): if len(apdu) < 6: return {'error': 'APDU too short'} ctrl = apdu[2:6] apci = parse_apci(ctrl) if apci['frame'] != 'I': return apci asdu = apdu[6:] asdu_info = parse_asdu(asdu) if asdu_info is None: return {**apci, 'error': 'ASDU too short'} objs = parse_info_objects(asdu_info, asdu_info['type_id']) return {**apci, 'asdu': asdu_info, 'objects': objs}这份代码的核心点在实际调试中已经够用:它能把每一帧切成I/S/U,能还原出序号,能提取常用的遥信遥测值。生产环境要再加两块,一是完整类型标识映射表,把所有信息体长度和解析函数补齐;二是点位表翻译层,把3字节信息体地址映射到中文描述。
5.3 一次实际解析案例的输出解读
我拿一段典型的站内抓包数据做演示。假设抓到的第一条I帧APDU可能是这样:
68 14 02 00 00 00 1D 01 03 00 01 00 00 60 EA 00 00 00 42 8C 00 00 36 00 00 00
长度字段0x14表示控制域加ASDU共20字节,控制域前四位02 00 00 00,最低位0说明是I帧,N(S)=1、N(R)=0。ASDU紧跟其后,类型标识1D也就是29?0x1D=29,这里我举例用的类型,实际现场要以规约点表为准。VSQ=0x01表示1个信息对象,COT=0x0003表示突发上送,COA=0x0001是厂站地址,信息体地址0x006000,后面是值字节和时间戳。
输出结果时,我习惯打印成一行:
12:30:01.123456 I帧 N(S)=1 N(R)=0 ASDU 类型=29 COT=突发 COA=1 地址=24576 值=0.0如果在联调现场看到这类行,基本不用再去翻原始字节流,直接对照点位表就能往下查。尤其是多方扯皮的时候,这种打印结果就是客观证据。
6. 常见问题与排查技巧实录
6.1 TCP粘包与半包处理
104报文在TCP流上传输,最常见的解析坑就是粘包和半包。我见过现场抓包文件里,一条TCP段里同时装了3个完整APDU,也见过一条段里只有一个APDU的前半截。如果解析器不做拆帧和缓冲,结果必然是错位的。
处理方法在我上面的split_apdu函数里已经体现了:循环从缓冲区中查找0x68,根据长度字段截取完整帧。半包则保留在缓冲区里等待下一段数据到达。这里要特别提醒,不要用TCP段的边界来当报文边界,TCP协议本身是字节流,一次send不一定对应一次recv,很多新手在这里栽过。
6.2 序号不连续不一定是丢包
解析工具提示序号跳变,先不要直接下"链路丢包"的结论。我在现场遇到过几种伪丢包场景:抓包点设在交换机镜像口,镜像口带宽不足导致采样丢帧;Wireshark解析时开启了TCP重组选项,导致多个APDU被合并显示;对端设备的多条TCP连接复用同一个IP,但过滤条件只筛选了2404端口,遗漏了其他端口上的流。
真正的应用层丢包,一般伴随TCP重传、序号跳变后又有重传帧补回。所以我建议判断丢包时,把TCP层指标和104层序号一起看,双证据再下结论。解析工具的职责是提示异常,而不是替人做最终裁决。
6.3 总召唤不回或响应不全
总召唤是联调过程中排查最多的动作之一。主站发类型标识100、COT=6的召唤命令后,厂站应在规定时间内回COT=20的响应数据,最后还要有COT=10的激活终止帧。如果解析工具显示只有命令帧、没有响应,先检查厂站是否真的收到了命令,这时候需要看一下TCP层有没有重传,确认命令帧是否被对端确认。
如果响应不全,比如只回了一部分遥信就停了,优先检查VSQ的地址连续标志。我遇到过厂站把VSQ的最高位置错,导致主站解析时信息体地址不连续,后续点位全部乱套。这种问题用工具一看就能发现,但手算会算到怀疑人生。
6.4 抓包与设备侧日志不一致
最后聊一个容易让人头大的问题:工具抓到的报文和设备后台日志显示的报文对不上。我在排查时发现,很多后台软件显示的是经过规约栈处理后的"业务数据",比如遥信变位记录,而抓包工具抓到的是原始链路帧,两者本身就不是同一层的东西。
真正的对比,应该是同一链路点上的原始报文对比。抓包位置要尽量靠近链路两端,最好是主站侧和厂站侧各抓一份,然后逐一比对帧序号和时间戳。帧序号对不上,就看哪一侧先出现了序号的跳变。这种双端抓包的排查思路,帮我在不少疑难杂症里找到了方向,比单侧抓包分析可靠得多。
我个人在实际操作中的体会是,报文解析工具的功底不在工具本身,而在对帧结构、序号机制、ASDU组织方式的熟悉程度。工具只是把规约标准翻译成人能看懂的界面,但"看懂了之后怎么定位问题",靠的还是对这些底层细节的理解。建议刚接触104的朋友,先拿Wireshark抓几天正常运行的链路报文,把I帧、S帧、U帧的交替规律看熟,再上手自写解析脚本,这样踩坑的概率会低很多。最后再分享一个小技巧:不管用什么工具,每次联调前先解析一遍历史包,确认工具版本解析结果和过去一致,再开始现场工作。报文不会说谎,但工具会,先校准工具,再校准链路。