
简介面向电力自动化及工控领域的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(fAPDU长度异常: {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是短浮点遥测也就是float30是带时标单点遥信SOE31是带时标双点遥信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位——SQ1时表示后续信息体地址连续只需传一个起始地址SQ0时每个信息体都带完整地址。解析时如果忘了处理SQ位连续地址的报文会全部解析错位。这是104解析里最典型的一个“看起来很对、实际全错”的点。2.3 信息体地址的三种基址信息体地址不是你想当然的1、2、3信息体地址是104解析里最需要结合工程实际的部分。标准里定义了三组基址遥信从0x0001开始遥测从0x4001开始遥控从0x6001开始。但现实是很多厂站装置并不严格遵守这套基址有的把遥信直接排从1开始有的把遥测从0x0001开始排。解析工具必须做成基址可配置而不是写死。我曾经遇到过某厂站的一台测控装置遥信地址按0x1001起步和标准基址差了整整0x1000。当时用默认基址解析所有遥信变位都落在了一个完全不存在的测点区间里排查了半个多小时才从配置表里发现是自定义基址。所以一个成熟的104解析工具信息体地址一定要能通过配置文件覆盖。class InfoAddrMapper: def __init__(self, ti_base0x0001, tm_base0x4001, tk_base0x6001): 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, fieldnamesfields) 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事件。区分两者的关键不是类型标识本身而是传输原因COTCOT3是突发COT20是总召唤响应。解决解析工具必须把“类型标识COT”组合起来解释不能只看类型标识。在代码里要对COT20且类型标识为30/31的情况单独走一条解析路径标记为“总召唤带时标遥信”。5.2 SQ位忘记处理导致整个信息体地址错位现象解析结果里连续一条线报出几百个遥信变位而且信息体地址是1、2、3、4递增但现场实际只发生了一个开关变位。原因SQ1表示后续信息体地址连续报文里只含一个起始地址。有的解析器没有处理SQ位把每个字节都当作一个独立信息体地址去解析导致一条记录被拆成几十条假的变位。解决解析信息体时先读SQ位。SQ1时只解析一个信息体地址后续对象的地址按偏移量递增计算SQ0时每个信息体都带完整地址。这两者的代码路径完全不同一旦搞混结果完全不可信。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、载荷被网络栈分段”的情况这时要按seqpayload长度联合去重。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报文解析工具比每次联调都翻原始报文要划算得多。它解决的不是“能不能解析”的问题而是“解析完能不能直接定位问题”的问题。希望这个方案能帮你在下一个现场少熬几个夜。本文还有配套的精品资源点击获取