简介:面向电力自动化及工控领域的104规约报文解析工具包,主要服务于需要开展IEC 60870-5-104报文分析、抓包排查与协议调试的工程师和运维人员。工具包提供主程序及无pcap依赖版本,配合动态链接库实现灵活解析,并内置配置目录、COT报文示例、CSV样例、日志记录及抓包文件等素材,可直观理解104规约链路层与应用层交互过程,也适合作为规约学习与故障定位的辅助材料。压缩包共20个文件,包含8个DLL、2个EXE、2个CSV、2个TXT、2个LOG、1个PCAP、1个DAT、1个JSON等,整包约11.68MB,结构简洁,便于快速部署与对照分析。已有438人学习下载,适合有一定规约基础、希望提升高级分析能力的读者使用,可直接上手查看报文解析结果与高级诊断逻辑。
1. 104报文解析工具:为什么看着简单、真正用起来却到处翻车
拿到一个104报文解析工具,很多人的第一反应是“不就是按IEC 60870-5-104规约把帧拆开吗,有什么难的”。但真正在厂站调试、主站联调、故障反演时用过的人都知道,104报文解析的坑从来不在“拆帧”本身,而在“拆完之后的解释”——同样的一个类型标识,在A厂站是遥信变位,在B厂商的装置里可能被定义成SOE事件;同一个信息体地址,在不同协议版本里对应的测点完全不同。没有一套能落地的解析工具,光靠Wireshark的原始报文一屏一屏翻,排查一个遥控失败问题可能要数小时。
这篇文章要讲的,就是一套基于104规约的报文解析与文件解析方案。它覆盖从pcap抓包里过滤出104报文、按APCI层拆帧、按ASDU解释语义,到定值文件、SOE文件等“文件解析”场景,以及基于解析结果做的高级分析——遥信变位追踪、遥控命令反查、品质位突变检测。适合三类人看:正在做电力自动化终端接入的嵌入式工程师、负责主站前置解析的软件开发者、以及经常需要在现场用报文定位问题的调试工程师。后面所有代码都不依赖特定商业工具,一台普通电脑加一个抓包文件就能跑通。
2. 104规约的报文结构:把I帧、S帧、U帧从字节流里认出来
2.1 APCI层的三种帧:启动帧、确认帧、控制帧的判定逻辑
104规约的报文在TCP载荷里是连续的字节流,解析的第一步不是解ASDU,而是先把APCI层的帧头认出来。每个APDU由1个启动字符0x68开头,后面跟1个长度字节表示APDU剩余字节数,再往后是4个控制域字节。控制域的第一个字节最低两个bit决定了帧类型:01是I帧(信息传输帧),11是S帧(监视帧),00是U帧(控制帧)。这里有个新手最容易疏忽的点——控制域字节是低2位有效,不是拿整个字节去比对。
def parse_apci(data: bytes): if data[0] != 0x68: raise ValueError("不是有效的104帧,缺少0x68启动字符") apdu_len = data[1] if apdu_len < 4 or apdu_len > 253: raise ValueError(f"APDU长度异常: {apdu_len}") control_field = data[2] frame_type = control_field & 0x03 if frame_type == 0x01: send_seq = (data[2] >> 1) | ((data[3] & 0x01) << 7) recv_seq = (data[4] >> 1) | ((data[5] & 0x01) << 7) return {"type": "I帧", "send_seq": send_seq, "recv_seq": recv_seq, "payload": data[6:2 + apdu_len]} elif frame_type == 0x03: recv_seq = (data[4] >> 1) | ((data[5] & 0x01) << 7) return {"type": "S帧", "recv_seq": recv_seq, "payload": b""} else: u_frame_cmd = data[2] & 0xFC return {"type": "U帧", "cmd": u_frame_cmd, "payload": b""}这段代码的判定逻辑是:先校验启动字符和长度,再取控制域低2位判断帧类型。I帧的发送序号和接收序号分别拆出来用于后续的丢帧检测——如果你在解析时发现接收序号跳变,说明TCP流里丢了报文,后续的ASDU解释可能出现错位。S帧只有接收序号没有载荷,U帧的cmd字段对应STARTDT/STOPDT/TESTFR等命令,这三个帧类型在调试链路建立、链路复位时非常有用。
2.2 ASDU类型标识:遥信、遥测、遥控、SOE一表对应
APCI层拆完后,I帧的载荷就是ASDU。ASDU的第一个字节是类型标识,它决定了后续怎么解释数据。104规约里和电力监控最相关的类型标识集中在这几个区间:1-3是单点/双点遥信,9-12是单点/双点带品质描述的遥信,13-16是标度化遥测,17-20是归一化遥测,21-24是短浮点遥测(也就是float),30是带时标单点遥信(SOE),31是带时标双点遥信,45-46是遥控命令,50是召唤命令,100-101是总召唤/时钟同步,122-123是文件传输相关的ASDU。解析工具的第一个功能,就是把类型标识映射成可读的语义名称。
我一般会在解析工具里维护一张类型标识映射表,这里的关键不是把表格列全,而是要把“类型标识+信息体地址+品质描述符”三者绑定在一起解释。比如类型标识30,如果只看字节内容是带时标的单点遥信,但它可能既可以是SOE主动上送,也可以是总召唤时的带时标遥信——两者的解释区别在于上下文中是否有总召唤确认帧。解析工具如果不维护这个上下文,很容易把SOE误判成普通遥信。
TYPE_MAP = { 1: "单点遥信", 2: "双点遥信", 3: "单点遥信(带品质)", 9: "单点遥信(带品质)", 10: "双点遥信(带品质)", 13: "标度化遥测", 14: "标度化遥测(带品质)", 21: "短浮点遥测", 30: "带时标单点遥信(SOE)", 31: "带时标双点遥信", 45: "单点遥控", 46: "双点遥控", 100: "总召唤", 101: "时钟同步", 103: "时钟同步确认", 122: "文件传输-就绪", 123: "文件传输-块" } def parse_asdu_header(payload: bytes) -> dict: type_id = payload[0] num_objects = payload[1] & 0x7F cot = payload[2] common_addr = payload[3] | (payload[4] << 8) return { "type_id": type_id, "type_name": TYPE_MAP.get(type_id, f"未知类型({type_id})"), "num_objects": num_objects, "cot": cot, "common_addr": common_addr }注意这里num_objects只取了低7位,最高位是SQ位——SQ=1时表示后续信息体地址连续,只需传一个起始地址;SQ=0时每个信息体都带完整地址。解析时如果忘了处理SQ位,连续地址的报文会全部解析错位。这是104解析里最典型的一个“看起来很对、实际全错”的点。
2.3 信息体地址的三种基址:信息体地址不是你想当然的1、2、3
信息体地址是104解析里最需要结合工程实际的部分。标准里定义了三组基址:遥信从0x0001开始,遥测从0x4001开始,遥控从0x6001开始。但现实是,很多厂站装置并不严格遵守这套基址,有的把遥信直接排从1开始,有的把遥测从0x0001开始排。解析工具必须做成基址可配置,而不是写死。
我曾经遇到过某厂站的一台测控装置,遥信地址按0x1001起步,和标准基址差了整整0x1000。当时用默认基址解析,所有遥信变位都落在了一个完全不存在的测点区间里,排查了半个多小时才从配置表里发现是自定义基址。所以一个成熟的104解析工具,信息体地址一定要能通过配置文件覆盖。
class InfoAddrMapper: def __init__(self, ti_base=0x0001, tm_base=0x4001, tk_base=0x6001): self.bases = {"TI": ti_base, "TM": tm_base, "TK": tk_base} def map(self, raw_addr: int) -> str: if self.bases["TI"] <= raw_addr < self.bases["TM"]: return f"遥信点_{raw_addr - self.bases['TI'] + 1}" elif self.bases["TM"] <= raw_addr < self.bases["TK"]: return f"遥测点_{raw_addr - self.bases['TM'] + 1}" elif raw_addr >= self.bases["TK"]: return f"遥控点_{raw_addr - self.bases['TK'] + 1}" return f"未知_{raw_addr:#06x}"这段代码把信息体地址映射成“遥信点_N”这样的可读格式。实际使用中你还需要把映射表替换成从现场点表导入的测点名称——我通常的做法是让工具支持CSV点表导入,这样解析结果直接显示“主变高压侧开关合位”而不是“遥信点_1024”。这一步对现场调试效率的提升是质的飞跃。
3. 抓包解析落地:从pcap文件到结构化记录的完整脚本
3.1 用scapy过滤TCP端口2404的104报文
Wireshark可以直接看104报文,但它不适合做批量分析和规则判断。实际项目中我一般直接写Python脚本处理pcap文件。104规约的TCP服务端口默认是2404,所以第一步过滤就按这个端口抓取。用scapy读pcap文件,然后按TCP端口过滤出候选报文,再拼接TCP流——这里有个关键点,104报文可能跨越多个TCP段,必须按seq号做TCP重组。
from scapy.all import rdpcap, TCP, IP def extract_104_payloads(pcap_file: str, port: int = 2404): packets = rdpcap(pcap_file) tcp_streams = {} for pkt in packets: if TCP in pkt and (pkt[TCP].sport == port or pkt[TCP].dport == port): key = (pkt[IP].src, pkt[TCP].sport, pkt[IP].dst, pkt[TCP].dport) seq = pkt[TCP].seq payload = bytes(pkt[TCP].payload) if key not in tcp_streams: tcp_streams[key] = [] tcp_streams[key].append((seq, payload)) return tcp_streams这段代码先把四元组相同的TCP段归到同一条流里,然后按seq序号排序。注意这里没有处理TCP重传和乱序,如果抓包文件里有重传包,后面拼出来的字节流会重复。所以更稳妥的做法是在拼接前按seq去重。我在工具里一般用seq为key的字典来做去重,后到的同seq包直接丢弃。
TCP重组完成后,还需要处理一个分包问题:一个104 APDU可能只有6个字节(S帧),也可能有253个字节(大文件传输块的ASDU),而TCP段的大小通常在1448字节左右,所以一个TCP段里可能包含多个完整的104 APDU。这里要按APCI的长度字段逐帧切割,而不是把一个TCP载荷当作一个104帧。
3.2 逐帧解析APDU并输出CSV格式的结构化记录
TCP重组和APDU切分做完之后,就到了解析工具的核心输出环节。我习惯把解析结果落成CSV或者SQLite表,这样后续做筛选、统计、关联分析都方便。CSV的每一行代表一个ASDU信息,字段包含时间戳、方向、帧类型、类型标识、公共地址、信息体地址、值、品质描述、原始字节。这一步看着简单,但对时间戳的处理有几个细节要小心。
import csv def write_records(records: list[dict], output_path: str): fields = ["timestamp", "direction", "frame_type", "type_id", "type_name", "common_addr", "info_addr", "value", "quality", "raw"] with open(output_path, "w", newline="") as f: writer = csv.DictWriter(f, fieldnames=fields) writer.writeheader() for rec in records: writer.writerow(rec)时间戳这里有个容易踩的坑——pcap文件里的时间戳是抓包主机的时间,而104报文内部带的时间是厂站装置的时间。两者可能差着几分钟甚至几个小时。解析工具必须把这两种时间分开:抓包时间用于链路层分析(比如S帧响应时长),报文内部时间用于SOE时序分析。千万不要混用,否则做SOE时标排序时会得到完全错误的事件顺序。
3.3 时间同步偏差和时区问题:两个必须处理的隐藏参数
104规约里的时间戳格式是毫秒级的二进制时间,从1970年1月1日开始计数,而且用的是本地时间,不是UTC。这就带来一个很实际的问题——如果厂站装置的时钟没对时,或对时后仍有几十毫秒偏差,解析出的SOE时间和主站实际收到报文的时间会产生错位。判断是不是时钟偏差导致的问题,方法是对比同一帧里抓包时间和报文内部时间的差值;如果差值在一段时间内基本恒定,说明是时钟偏差,如果差值漂移,说明是装置晶振有问题。
我在解析工具里会加一个“时间偏移分析”功能:把每个SOE报文的“报文内部时间”减去“抓包时间”,得到一个offset序列。正常情况下这个offset应该是一条平直线。如果出现阶梯状跳变,说明装置在对时变化;如果线性增长,说明晶振频率不准。这个功能对排查站控层对时问题非常有用。
def calc_time_offset(pkt_time: float, asdu_time: float) -> float: return asdu_time - pkt_time把每个SOE的offset算出来画个散点图,基本半分钟就能判断出时钟是“准”还是“漂”。顺带一提,很多104解析工具的时区设置是写死的,但实际工程里有跨时区的调度中心,这时候时区配置也要做成可选项——虽然国内电力系统几乎统一用北京时间,但如果你对接的是涉外项目,这个问题马上就会冒出来。
4. 文件解析与高级分析:定值文件、SOE文件与故障录波文件的处理
4.1 104规约的文件传输机制:就绪、传输、校验、终止四个阶段
标题里提到的“文件解析”,在104规约语境下走的是ASDU 122(文件传输就绪)、123(文件传输块)、124(文件传输确认)这一组流程。常见应用场景是定值文件下装、SOE事件文件上送、故障录波文件传输。文件传输的完整流程是:发起方发送“文件就绪”ASDU,包含文件名、文件长度、缓冲区大小;接收方回“文件传输确认”表示可以开始;然后逐块传输数据,每块带块号和CRC校验;最后发送“文件传输结束”ASDU。这个流程在调试时必须按状态机来看,不能只看单个报文。
解析工具对文件传输的处理,我一般做成一个独立模块:它不只解析单个ASDU,而是把整个文件会话的报文关联起来,重组出完整的文件内容。当调试中遇到“文件传一半就断了”的问题时,这个模块能直接告诉你断在哪一块、哪一块的CRC校验失败。这里唯一的“坑”是有些装置的文件块大小不按标准来,缓冲区大小明明是128字节,却发来一个240字节的块——解析工具要能容忍这种不合规的实现,做不了太严格的校验。
4.2 文件内容解析:定值XML、SOE二进制表的通用拆解方法
文件传输完成后,下一步就是文件内容的解析。定值文件通常是XML或INI格式,SOE文件往往是自定义二进制格式,故障录波文件则有标准的COMTRADE格式。市面上没有统一的解析器能通吃所有格式,所以工具的正确设计方式是“框架统一、插件分离”——框架负责从104文件传输里把原始文件字节保存成磁盘文件,具体格式解析用插件实现。
def save_file_block(output_dir: str, filename: str, block_data: bytes): file_path = Path(output_dir) / filename with open(file_path, "ab") as f: f.write(block_data)这里按文件块顺序追加写入,就能把分散在多个ASDU里的内容完整拼回来。文件块是有序的,但如果传输过程中出现了重传,相同块号可能被写入两次——所以写代码时要在写入前查一下当前文件已写入的块号范围,或者用独立的块索引文件记账,避免重复追加。这一点在抓包回放时尤其重要,因为pcap里的重传包会让文件内容多出一截,解析插件反而报格式错误。
4.3 高级分析功能:变位追踪、遥控反查、品质位突变检测
抓包解析出来的结构化记录,只有配上高级分析才真正有价值。我把高频用到的分析功能拆成三类:
变位追踪:从解析结果里筛选出“遥信值从0变到1”或从1变到0的记录,按时间排序展开。它要解决的核心问题是:某个开关位置变化后,主站有没有收到?收到后有没有发出确认?这个链路里的每一步都有对应报文可以查证。
遥控反查:输入一个遥控点号,工具反查这个点上所有的遥控命令、选择/执行/取消、对应的返校报文,以及返校超时记录。现场遥控失败时,这个功能能直接把“主站没下发”和“装置没返校”区分开。
品质位突变检测:品质描述符里的IV(无效位)、NT(非当前值)、SB(被取代)这三位的状态变化往往预示着通道异常或装置重启。工具要能在解析时把品质位的状态变化单独标记出来,生成一条“品质异常报告”。
QUALITY_BITS = {"IV": 0x80, "NT": 0x40, "SB": 0x20, "BL": 0x10, "OV": 0x08} def analyze_quality_change(records): last_state = {} anomalies = [] for rec in records: q = rec["quality"] key = (rec["common_addr"], rec["info_addr"]) changed = {} for name, mask in QUALITY_BITS.items(): cur = (q & mask) != 0 if key in last_state and last_state[key].get(name) is not None: if last_state[key][name] != cur: changed[name] = cur last_state[key] = {n: (q & m) != 0 for n, m in QUALITY_BITS.items()} if changed: anomalies.append((rec["timestamp"], key, changed)) return anomalies这段代码为每条记录逐个比对品质位的状态,发现变化就记一条异常。实际项目中,一个典型应用场景是:某间隔的遥测数据在某个时刻全部变成无效位,经过品质位突变检测定位,发现是该间隔的合并单元重启导致。这类问题如果没有品质位检测,光看遥测值曲线根本发现不了。
5. 104解析避坑指南:5个让解析结果“看起来对、实际错”的陷阱
5.1 类型标识30和31的“双面性”:SOE还是普通遥信?
现象:某厂站上送了一批类型标识30的报文,解析工具按SOE处理并生成了时标,但主站那边显示这些点只是普通遥信变位,时标是空的。
原因:类型标识30(带时标单点遥信)在总召唤响应里也会出现,此时它表示的语义是“带时标的遥信状态量”,而不是主动上送的SOE事件。区分两者的关键不是类型标识本身,而是传输原因(COT):COT=3是突发,COT=20是总召唤响应。
解决:解析工具必须把“类型标识+COT”组合起来解释,不能只看类型标识。在代码里要对COT=20且类型标识为30/31的情况单独走一条解析路径,标记为“总召唤带时标遥信”。
5.2 SQ位忘记处理导致整个信息体地址错位
现象:解析结果里连续一条线报出几百个遥信变位,而且信息体地址是1、2、3、4递增,但现场实际只发生了一个开关变位。
原因:SQ=1表示后续信息体地址连续,报文里只含一个起始地址。有的解析器没有处理SQ位,把每个字节都当作一个独立信息体地址去解析,导致一条记录被拆成几十条假的变位。
解决:解析信息体时先读SQ位。SQ=1时只解析一个信息体地址,后续对象的地址按偏移量递增计算;SQ=0时每个信息体都带完整地址。这两者的代码路径完全不同,一旦搞混,结果完全不可信。
5.3 品质描述符里的IV位是“坑中坑”:无效位不等于0
现象:某路遥测数据在画面上显示为0,但抓包解析出来的原始值是3680,看起来像是解析错误。
原因:该遥测的品质描述符IV位=1(无效),画面收到无效数据后按0显示。解析工具如果只解析数值、不解析品质,就会给出一个“看起来错误”的3680。
解决:解析结果里数值和品质必须同时输出。高级分析里还要把IV位变化作为一个独立事件来追踪——发现IV位从0变成1,就应该主动提示“该点数据有效性发生变化”。
5.4 控制域序号的起始不是0:从哪个seq开始都有
现象:用I帧的发送序号做丢帧检测时,发现一上来序号就是12,触发“丢帧告警”。
原因:104连接的发送序号是连续累积的,不是每次TCP连接都从0开始。如果之前有连接断过又重连,新连接会延续之前的序号。用序号判断丢帧,必须先做“首次序号基线”标定。
解决:工具在连接开始时把第一个I帧的序号记录为baseline,后续丢帧判定用相对差值,而不是绝对序号。另外U帧的STARTDT激活后序号的起始也可能不是0,这个和厂商实现有关,不能假设。
5.5 抓包文件里的TCP重传导致重复解析
现象:同一帧报文在解析结果里出现两次,时间戳完全相同,导致变位分析出现“变位—反变位—变位”的假象。
原因:pcap文件里包含TCP重传包,Wireshark能看到标记,但自研解析工具如果只按seq排序不去重,会把重传包当新报文解析。
解决:在TCP重组阶段,用seq做字典去重,相同的seq只保留一个载荷。注意这里不能只比对IP和TCP头,必须连载荷内容一起比对;有的重传是“相同seq、相同payload”,但也有“相同seq、载荷被网络栈分段”的情况,这时要按seq+payload长度联合去重。
6. 把解析结果做成可回放、可反查的小工具:一个片段级联排错技巧
解析工具做到最后,真正提效的是“检索”和“回放”。给一个我一直在用的技巧:把解析出的结构记录导入SQLite数据库后,不要只做表格展示,加一个“片段回放”功能——选中某条遥信变位记录,工具自动把该变位前5秒到后5秒的所有104报文、SOE事件、S帧确认按时间顺序展开。这样排查链路问题时,不用再手动去翻原始pcap,上下文一目了然。
SELECT timestamp, direction, type_name, info_addr, value, quality FROM records WHERE timestamp BETWEEN ? AND ? AND (info_addr = ? OR frame_type = 'S帧') ORDER BY timestamp ASC这个SQL把目标变位点前后的所有相关记录拉出来,S帧也一并展示——因为S帧表示接收方对I帧的确认,如果变位I帧发出去之后迟迟没有收到对应的S帧确认,说明链路层有丢包或对端处理超时。把这个查询封装成一个函数,再绑一个简单的命令行交互,就是一套轻量级的变位反查工具。需要更复杂的分析时,再往上叠一个按时间窗口聚合的统计查询。
我自己的习惯是,所有新接手的104联调项目,都先拿抓包文件跑一遍这个解析工具的“自检模式”再做正式调试。自检模式会输出一个摘要:总帧数、I帧/S帧/U帧比例、异常类型标识数、品质位突变次数、时钟偏移曲线。哪个厂站的装置行为不合规,这个摘要里基本能看出个大概。这比带着Wireshark到现场一行一行翻报文靠谱得多,也省下了大量重复性的排查时间。
从工程效率的角度看,花一两天搭一个能自定义基址、能处理SQ位、能重组文件块的104报文解析工具,比每次联调都翻原始报文要划算得多。它解决的不是“能不能解析”的问题,而是“解析完能不能直接定位问题”的问题。希望这个方案能帮你在下一个现场少熬几个夜。
本文还有配套的精品资源,点击获取