新闻详情

新闻详情

首页 / 资讯中心 / 详情

104报文解析实战:从APCI/ASDU结构到避坑指南

发布时间:2026/9/26 13:17:02来源:尧图网络
104报文解析实战:从APCI/ASDU结构到避坑指南
简介面向电力自动化、工业控制、变电站监控及规约调试人员这套104报文解析工具覆盖文件解析、抓包解析与高级分析等典型场景集成了从基础报文查看到深度协议解析的完整能力可协助快速定位辅控序号异常、报文超时、链路中断等通讯问题。工具包共20个文件以两个独立exe主程序与8个功能dll动态库为核心另有pcap抓包样本、csv配置模板、json规则说明、运行日志及RTF帮助文档压缩包仅11.68MB体积小巧、结构清晰便于直接部署使用。目前已有437人学习下载适合正在开展IEC 60870-5-104规约联调、报文排查或协议学习的中高级技术人员。借助内置的真实报文样例与高级分析功能读者能直观理解104主规约的报文格式、传输过程与异常判定思路并可通过配套配置和日志快速验证自己的解析结果显著提升现场调试与协议研究的效率。1. 104报文解析工具到底在解什么一次通道事故复盘就够了调度主站报“遥信误动”后台查了一圈点表没毛病最后打开抓包文件用104报文解析工具逐帧比对发现是总召唤响应里混进一条3秒前的遥信变位——这是我在光伏场站调试时真实遇到过的一幕。所谓104报文解析就是把IEC 60870-5-104这条规约里跑的报文拆开按APCI、ASDU分层还原成有业务含义的遥测、遥信、遥控记录。平时它最多帮你看一眼点表有没有对上但真到了通道抖动、数据跳变、SOE对不上时一份干净的报文解析结果就是唯一能让你看清端到端链路的技术依据。这套东西适合三类人刚接手104联调的现场工程师、做主站侧协议适配的开发、以及做电力自动化故障复盘的老手。2. 报文解析前先搞懂APCI与ASDUIEC 104帧的四个关键字段104报文从结构上拆成“APCI ASDU”两层。APCI负责承载传输控制信息谁发的、序号多少ASDU负责承载业务哪个遥测点、值是多少、质量位是什么。大多数104报文解析工具做的事就是把这两层字段拆开、翻译成表格或CSV。但直接拿来用的前提是你知道镜头该对准哪里。2.1 I帧、S帧、U帧0x68后面的控制域决定了消息类型任何一条104报文都以0x68开头紧跟一个字节的APDU长度。真正决定“这条消息在干什么”的是接下来的控制域。I帧信息帧最常用控制域第一个字节低两位是00用来携带遥测、遥信等业务数据同时带着发送序列号和接收序列号。S帧监督帧低两位是01不背数据只回确认告诉对端“你发到第N帧我收到了”。U帧未编号帧低两位是11用于启停链路现场最常见的启动序列就是主站发STARTDT激活帧、从站回STARTDT确认帧链路握手瞬间就完成了。抓包解析时第一件事就是判定帧类型。把控制域第一个字节和0x03做与运算就能区分。别小看这一步——很多脚本连I帧和S帧都没分开直接用ASDU偏移去取值出来的自然全是错位数据。这一点在后面的避坑章会细说。2.2 ASDU公共字段类型标识、传送原因、公共地址怎么读I帧里从第7个字节开始就是ASDU的天地。绝大多数104报文解析工具并不会把整段字节都翻译成人话而是先读四个公共字段类型标识、可变结构限定词、传送原因、公共地址。类型标识占1字节决定信息体类型后面紧跟的就是可变结构限定词VSQ。VSQ里的bit7表示信息体地址是否连续排列bit0到bit6表示这条报文里带了多少个信息体。传送原因占2字节、小端对齐0x03是最常见的突发遥信0x06表示激活命令0x14表示总召唤响应。公共地址占2字节对应站址多站共享一个104端口时靠它区分数据归属。很多人误以为“公共地址就是点号”实际不是。公共地址一般对应站地址和数据库里的点号是两回事。点号是靠信息体地址IOA索引的IOA占3字节每个遥测、遥信、遥控点都有唯一IOA。解析工具导出报表时通常把“公共地址IOA”拼起来当业务主键字段对齐时要注意这个拼法。2.3 遥测、遥信、遥控的点表映射常见ASDU类型对照表解析工具最核心的翻译行为是把类型标识映射成实际含义。以下是现场最常碰到的几个类型类型标识含义信息体结构说明0x01单点遥信IOA SIQ开关量单点值0/10x03双点遥信IOA DIQ双点位置值0/1/2/30x09归一化遥测IOA NVA QDS短整型标度化-1.0~1.00x0D浮点遥测IOA IEEE754 QDS最常用直接读float0x2D单点遥控IOA SCO遥控输出0/10x2E双点遥控IOA DCO遥控合分上表里IOA固定占3字节QDS是质量描述符包含是否取代、是否溢出、是否无效等8个状态位。如果你只要数值QDS可以先不展开但做高级分析时质量位决定了一条数据能不能用于统计——比如“无效”位为1时数值再准也不能参与计算。这个习惯从第一次解析数据就该养成先看质量位再决定用不用这个点。3. 抓包解析的完整链路tcpdump抓网卡、Wireshark验协议、离线文件再分析104报文解析工具要处理的数据绝大多数来自两处在线抓包和离线文件。在线抓包是在变电站或主站侧把实时网口流量存成pcap离线文件则是设备厂商导出的hex日志、主站侧导出的通道日志等。两种输入长得不一样处理链路也不同。3.1 tcpdump抓104报文端口过滤与轮询文件输出104规约没有强制固定端口但现场最常见的是TCP 2404少数老旧设备用2408。抓包时不要只按固定端口抓先问清楚对端实际端口再动手。常见做法是用tcpdump在网卡上抓指定端口同时写轮询文件避免单文件过大tcpdump -i eth0 -s 0 -w station_104.pcap -G 3600 -W 24 \ tcp port 2404参数含义-s 0表示抓全报文长度不截断-w写pcap文件-G 3600表示每3600秒切一个新文件-W 24保留24个文件轮询覆盖这样抓一天也不会撑爆磁盘。抓完记得核对文件名时间戳确认覆盖周期和现场时间对得上。如果怀疑通道建不起来可以先抓握手包验证链路tcpdump -i eth0 -nn tcp[tcpflags] (tcp-syn|tcp-fin|tcp-rst) ! 0这条命令不看业务数据只看TCP连接状态变化。现场先确认三次握手能完成再谈解析业务数据能省大量排查时间。3.2 Wireshark的104解析器三处需要人工复核的信息把抓回来的pcap拖进Wireshark能看到自带的IEC 104解析器已经把APCI、ASDU逐层展开。但依赖这个结果有三处要人工复核。第一处是TCP重传。Wireshark对重传包默认标记为黑底红字104解析器可能照样解读但这条数据并不是对端真实收到的那条统计时不排除会多算重复事件。第二处是pcap文件时间戳与实际SOE时间的关系104的带时标类型在信息体里自带CP56Time2a它比抓包时间戳更可信分析事件时序时以信息体时标为准。第三处是自定义端口或隧道封装104报文被封装时Wireshark默认不会识别需要先手动指定端口解析。3.3 文件解析pcap导出与hex日志清洗的两种输入“文件解析”在工具里往往指两件事解析pcap导出文件和解析纯文本hex日志。pcap导出我一般先用tshark把TCP载荷抽出来变成统一的十六进制文本格式tshark -r station_104.pcap \ -Y tcp.port2404 tcp.len 0 \ -T fields -e tcp.payload payload_hex.txt导出的payload_hex.txt每行是一段TCP应用层数据十六进制字符连续。但它是按TCP包粒度拆的一条APDU可能被拆成多行也可能多段APDU挤在一行。真正解析时程序得先做“流重组”把所有同一条TCP流的数据按序号拼接再按0x68和长度字段切帧。这一步处理不好是后面各种玄学报错的源头。另一种常见输入是设备厂商导出的hex日志通常长这样68 12 00 00 00 06 64 01 06 01 03 00 01 00 04 00 01 00 00 05 00 01 00 06 00 01 00这种文件里每一行几乎就是一条完整APDU的hex字节处理更直接。我做文件解析时的Python入口通常一个函数就够了def load_hex_log(path: str) - list: 读取设备导出的hex日志转换成字节数组列表 frames [] with open(path, r, encodingutf-8) as f: for line in f: line line.strip() if not line: continue hex_bytes bytes.fromhex(line) if len(hex_bytes) 6 or hex_bytes[0] ! 0x68: continue # 跳过无效行 frames.append(hex_bytes) return framesbytes.fromhex会自动容忍行内空格这行代码能把人从“手动去空格”的体力活里解放出来。跳过长度不足6字节的行是担心厂商日志里掺杂了空行或ASCII版权头。这种日志我在两个不同厂家的装置里都见过提前过滤比运行时报错省心。4. 动手写一个104报文解析脚本从TCP载荷到结构化点表记录自己写报文解析并不难难的是不把边界情况算错。下面这套脚本按“教学版可用”的粒度给出来抓包解析和文件解析共用一套核心函数。4.1 APCI解析函数剥离控制域区分I/S/U先从字节流里切出完整APDU再读APCI。注意这段代码默认输入已经是完整APDU如果是从TCP流里拼出来的得先做流重组再调用。#!/usr/bin/env python3 # -*- coding: utf-8 -*- 104报文解析APCI层函数 import struct from typing import List, Dict, Any def split_frames(data: bytes) - List[bytes]: 从字节流中按起始符长度字段切出完整APDU frames [] i 0 while i len(data) - 1: if data[i] ! 0x68: i 1 continue apdu_len data[i 1] if i 2 apdu_len len(data): break frames.append(data[i: i 2 apdu_len]) i i 2 apdu_len return frames def parse_apci(frame: bytes) - Dict[str, Any]: 解析APCI区分I帧/S帧/U帧取出序列号和ASDU if len(frame) 6 or frame[0] ! 0x68: raise ValueError(f非法APDU首字节: 0x{frame[0]:02x}) apdu_len frame[1] ctrl frame[2:6] ctrl0 ctrl[0] 0x03 if ctrl0 0x00: # I帧 send_seq ((ctrl[1] 7) | (ctrl[0] 1)) 0x7FFF recv_seq ((ctrl[3] 7) | (ctrl[2] 1)) 0x7FFF return { kind: I, send_seq: send_seq, recv_seq: recv_seq, asdu: frame[6: 2 apdu_len], } if ctrl0 0x01: # S帧 recv_seq ((ctrl[3] 7) | (ctrl[2] 1)) 0x7FFF return {kind: S, recv_seq: recv_seq} if ctrl0 0x03: # U帧 names {0x07: STARTDT_act, 0x0B: STARTDT_con, 0x13: STOPDT_act, 0x23: STOPDT_con, 0x43: TESTFR_act, 0x83: TESTFR_con} return {kind: U, function: names.get(ctrl[0], hex(ctrl[0]))} raise ValueError(f无法识别的控制域: {ctrl[0]:02x})代码逻辑说明split_frames在连续字节流里通过0x68定位APDU边界用第二个字节的长度字段跳到下一个APDU位置。parse_apci对控制域前两个比特做判断00是I帧01是S帧11是U帧。I帧的15位发送/接收序列号是从控制域四个字节里按位拼出来的结尾的 0x7FFF 是为了把15位序列号掩干净否则序号回绕后会出现负数或大于8191的异常值。参数说明长度为APCI后面的字节数切ASDU时用2 apdu_len而不是0 apdu_len是因为前面还有起始符和长度字段占2字节。调试时解析出的ASDU总是差2字节检查的就是这个位置。4.2 ASDU展开把信息体还原成IOA, 数值记录拿到I帧里的ASDU后先拆公共字段再按VSQ的SQ位决定是连续IOA还是散列IOA。ASDU_TYPE_INFO { 0x01: (单点遥信, 1), 0x03: (双点遥信, 1), 0x09: (归一化遥测, 3), 0x0D: (浮点遥测, 5), 0x2D: (单点遥控, 1), 0x2E: (双点遥控, 1), } def parse_asdu(asdu: bytes) - Dict[str, Any]: 解析ASDU公共字段返回结构化的信息体信息 type_id asdu[0] vsq asdu[1] cot struct.unpack(H, asdu[2:4])[0] common_addr struct.unpack(H, asdu[4:6])[0] sq (vsq 0x80) ! 0 count vsq 0x7F info asdu[6:] name, obj_size ASDU_TYPE_INFO.get(type_id, (未知, 0)) return { type_id: type_id, name: name, cot: cot, common_addr: common_addr, count: count, sq: sq, obj_size: obj_size, info: info, } def expand_objects(asdu_parsed: Dict[str, Any]) - List[Dict[str, Any]]: 将信息体展开成 (ioa, 原始值字节) 的点记录列表 type_id asdu_parsed[type_id] sq asdu_parsed[sq] count asdu_parsed[count] obj_size asdu_parsed[obj_size] info asdu_parsed[info] if obj_size 0 or count 0: return [] records [] p 0 if sq: # 连续IOA只编码第一个IOA后续IOA顺延 ioa info[p] | info[p 1] 8 | info[p 2] 16 p 3 for i in range(count): records.append({ ioa: ioa i, value_bytes: info[p: p obj_size], }) p obj_size else: # 散列IOA每个信息体带独立IOA for _ in range(count): ioa info[p] | info[p 1] 8 | info[p 2] 16 p 3 records.append({ ioa: ioa, value_bytes: info[p: p obj_size], }) p obj_size return records这段代码有个容易被忽略的点VSQ的bit7决定IOA排列方式。现场若看到VSQ0x80表示只有一个测点VSQ0x81表示连续地址的复数量。连续IOA时信息体里只编码第一个IOA后面按数量递增。如果解析工具不支持SQ位一遇到批量遥信就会把IOA全部读错这也是常见的报文解析翻车现场。4.3 数值解码浮点遥测、归一化遥测与遥控开关量展开后的value_bytes还只是原始字节要按类型标识转换成工程值。def decode_value(type_id: int, raw: bytes) - Dict[str, Any]: 按ASDU类型把原始字节解码成可读数值 if type_id 0x01: # 单点遥信 return {value: raw[0] 0x01, quality: raw[0] 4} if type_id 0x03: # 双点遥信 return {value: raw[0] 0x03, quality: raw[0] 4} if type_id 0x09: # 归一化遥测有符号短整型按-1~1标度化 nva struct.unpack(h, raw[:2])[0] return {value: nva / 32767.0, quality: raw[2]} if type_id 0x0D: # 浮点遥测IEEE754单精度 fval struct.unpack(f, raw[:4])[0] return {value: fval, quality: raw[4]} if type_id 0x2D: # 单点遥控命令 return {value: raw[0] 0x01, select: (raw[0] 6) 0x01} if type_id 0x2E: # 双点遥控命令 return {value: raw[0] 0x03, select: (raw[0] 6) 0x01} return {value: raw.hex(), quality: 0}解码时需特别注意归一化遥测NVA在规约里用-32768到32767表示-1.0到1.0的标度化值除32767后才是实际的标幺值。浮点遥测在IEC 104里是小端IEEE754结构用struct.unpack(f)读大端习惯的程序员在这里最容易翻车——换成f读出来的数值大得离谱或者直接nan。5. 104报文解析避坑指南5个我用血泪换来的翻车点这章专门整理我在变电站联调和主站侧接入时真正遇到过的坑每一条按“现象—原因—解决”写成笔记方便对号入座。5.1 TCP分节把APDU切成两半解析器报乱码现象从抓包文件提取TCP载荷后解析器频繁报“非法起始字符”或“长度越界”。原因TCP是流式协议一个APDU可能被拆进两个TCP分节也可能两个APDU拼在一个分节里。单纯按“一个TCP包等于一个APDU”来解析必然错位。解决先做TCP流重组按同一条TCP流的序号拼接成完整字节流再调用split_frames切帧。我的做法是先把整条流拼成一段bytes再统一按0x68和长度字段切而不是包级逐条解析。5.2 15位序列号回绕被误判为乱序现象把I帧的send_seq展开后发现8191后面跟着0某些脚本直接判定乱序重传。原因104的I帧序列号是15位范围0到81918191之后再发下一帧就是0这是正常回绕不是网络丢包。解决判断乱序要按模运算(current - prev 8192) % 8192只有结果大于1时才可能是异常丢帧。同样判断ACK序号也要按模比较不能直接做减法。5.3 总召唤COT20被当成遥信变位现象主站下发总召唤后从站全量上送遥信分析工具把全量数据当成大量变位产生误报。原因COT0x14表示总召唤响应是周期性的全量快照不是状态突变。解决解析后按COT分桶。COT3是突发遥信COT20是全量快照。做变位检测时只会计入COT3的数据COT20单独存一份用于全量核对。5.4 冗余双链路重复数据没去重现象同一测点的遥测在解析结果里出现两份时间戳还不一样导致曲线跳变。原因现场常见的冗余主备双链路同时在传同一份数据。解决按TCP流序号识别链路来源加上点主键“公共地址IOA”做去重保留质量位更优或时间更新的记录。报文字节本身只是一部分链路来源本身就是一条关键的元数据。5.5 TCP重传造成重复解析现象pcap里同一个发送序号出现两次被解析成两条事件。原因TCP超时重传抓包工具把原始帧和重传帧都记下来了。解决在导出时先过滤tcp.analysis.retransmission或导出后用发送序号做一次去重。注意重传帧和业务重复帧是两回事——重传的业务内容一样不该计成两条事件。6. 高级分析把解析结果变成通道体检报告与SOE校准解析出结构化记录只是第一步真正有价值的是把一堆时序数据变成能直接指导动作的信号。6.1 每秒I帧数与ACK延迟通道卡顿一眼就能看出来我把解析后的帧列表和抓包时间戳放在一起做统计核心是逐秒计数from collections import Counter def frame_rate_by_second(timestamps: list, kinds: list) - Counter: 按秒统计I帧数量用于通道健康度评估 bucket Counter() for ts, kind in zip(timestamps, kinds): if kind ! I: continue bucket[int(ts)] 1 return bucket正常通道的I帧速率相对平稳发生变位时会出现一个窄峰。如果出现持续高频每秒上百帧或者间隙性突刺往往意味着主站与从站之间的链路握手有问题——要么是初始帧重复请求要么是ACK延迟导致对端重发整个窗口。配合解析出的发送序号做差分还能直接算出重发窗口大小这比单纯看流量计准确得多。6.2 按COT时序过滤校准SOE事件点表高级分析另一个常用动作是校验SOE。把所有COT3的遥信记录按“公共地址IOA时间”排序与主站SOE报表交叉比对能快速发现三类问题SOE时间戳偏差超过100ms、同一IOA重复上报但值相同、双点遥信中间状态被丢弃。把解析结果按IOA聚合后画一条事件时间轴是排查“误动还是拒动”最直接的证据。用到的字段就是前面decode_value解析出的value和qualityquality里的“无效”位一旦置1该条记录在时间轴上要标灰不能删。很多现场事故分析翻车就是因为把无效帧当正常数据用得出错误的动作时序。我的习惯是每个项目维护一个点表字典格式是{common_addr: 1, ioa: 1024: (主变高压侧开关, 遥信)}把解析结果和点表一键映射导出的CSV直接能交给同事核对。这个习惯省过我好几次重复劳动后来接手的同事也少踩了不少坑。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

炸裂!Codex 塞进 ChatGPT 5.6 后,AI 编程的 config.toml 该怎么写?TaoToken 统一 Key 配置实战 2026/9/26 13:59:42

炸裂!Codex 塞进 ChatGPT 5.6 后,AI 编程的 config.toml 该怎么写?TaoToken 统一 Key 配置实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
Corundum开源100G NIC在Bittware VV4 FPGA卡上的全栈移植指南 2026/9/26 13:59:29

Corundum开源100G NIC在Bittware VV4 FPGA卡上的全栈移植指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
植物叶片分割数据集从标注到U-Net训练全流程指南 2026/9/26 13:59:29

植物叶片分割数据集从标注到U-Net训练全流程指南

简介:面向植物图像分割任务,这份数据集包含110张真实植物叶片图像及一一对应的mask标注,适合用于图像分割模型的训练、验证与算法效果对比。图像前景区域丰富,标注边界精细、质量可靠,能满足入门到进阶的视觉学习者在分…

阅读更多 →
SDR硬件实战指南:Pluto/RTL-SDR/Airspy与SDRangel深度配置 2026/9/26 13:59:29

SDR硬件实战指南:Pluto/RTL-SDR/Airspy与SDRangel深度配置

1. 这不是软件教程,而是一份无线电实验室的“硬件入场券”如果你正盯着SDRangel这个界面漂亮、功能繁多的开源SDR软件发呆,却连USB线插上电脑后设备管理器里那个黄色感叹号都搞不定;如果你已经下载了Pluto SDR、RTL-SDR或Airspy HF&#xff0…

阅读更多 →
AI编程助手大比拼:RooCode如何用自由Agent逆袭? 2026/9/26 13:59:29

AI编程助手大比拼:RooCode如何用自由Agent逆袭?

1. 这场AI编程助手混战里,为什么我会盯上RooCode 最近几个月,身边讨论AI编程助手的频率明显高了起来。Cursor的订阅制用得顺不顺手、Windsurf的流程自动化到底省不省心、VS Code Copilot的补全是不是已经落伍、Trae的免费额度够不够用——群里几乎每隔几…

阅读更多 →
玩转 ClaudeCode:Linux 安装 + Windows/MacOS 适配,TaoToken 统一 Key 配置一篇搞定 2026/9/26 13:59:16

玩转 ClaudeCode:Linux 安装 + Windows/MacOS 适配,TaoToken 统一 Key 配置一篇搞定

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉