☰
IEC-104 报文解析实战:APCI 控制域与 ASDU 类型标识详解
2026/10/1 2:08:18 网站建设 项目流程

简介:这份资源面向电力系统通信开发、调试与运维人员,以及需要快速掌握IEC-104规约的初学者,系统梳理了104报文中常用类型的作用与每个字节的含义,帮助读者在开发与排错中迅速定位问题、理解报文规则。压缩包共11个文件,约838KB,包含PDF文档、HTML页面、JavaScript脚本、字体与图标等资源,其中PDF适合离线精读,HTML页面可直接在浏览器中打开浏览,脚本与样式文件支撑页面交互与展示效果。目前已有8257人学习下载,说明其在电力自动化领域具有较高的参考价值。读者可借助这份资料建立对104报文结构的整体认知,掌握常用报文类型的字段解析方法,并对照实际抓包数据验证理解,从而在开发调试中减少试错成本,提升协议对接与故障排查效率。

1. 电力系统 IEC-104 报文到底在传什么:从四遥点到主站的一条链路

厂站侧 RTU 上电,主站前置服务起来,104 链路一建立,最先跑起来的往往不是遥测,而是总召唤。很多刚接手 104 调试的人会盯着 Wireshark 里那一串十六进制发懵:68 开头、两个长度字节、四个控制域字节,后面跟着 ASDU,看着像乱码,其实每一个字节都有明确归属。IEC-104 就是 IEC 60870-5-104,它把 60870-5-101 的链路层搬到 TCP/IP 上,用 2404 端口承载远动报文,国内变电站、配电自动化、新能源场站到调度的数据上送,绝大多数走的就是它。它传的东西说白了就是四遥:遥测、遥信、遥控、遥调,外加电度、定值、文件传输这些扩展。这篇文章不空谈标准,而是按一线调试的顺序,把 104 报文的类型标识、传送原因、控制域、信息对象地址、时标这些真正决定你能不能抓对包、解对点的东西拆开讲,让新手能照着抓包复现,熟手能对着参数边界判断问题出在链路层还是应用层。

104 的报文分两大类:一类是链路控制报文,只有 APCI,没有 ASDU,比如启动、停止、测试、确认;另一类是应用报文,APCI 后面挂 ASDU,承载具体数据。判断一条 104 链路是否健康,第一步不是看数据对不对,而是看控制域里的发送序号和接收序号是否连续、t1/t2/t3 定时器是否正常。很多现场“数据不刷新”的锅,最后查出来是链路层序号对不上,而不是点表配错。所以理解 104,必须先把 APCI 这层黑匣子打开,再去看 ASDU 里到底装了什么。

2. IEC-104 报文结构拆解:APCI 四个控制域字节怎么读

2.1 启动字符、长度与 2404 端口

一条 104 报文的最外层结构非常固定:第一个字节是启动字符 0x68,第二个字节是 APDU 长度,注意这个长度只统计它后面的字节数,也就是 4 个控制域字节加上 ASDU 的长度,不包含 0x68 和长度字节本身。所以当你看到68 04 07 00 00 00,长度是 04,后面正好四个控制域字节,没有 ASDU,这就是一条典型的 U 格式链路报文。端口方面,标准默认 2404,但现场经常被改成 2405、2406 甚至 9999,抓包前先跟主站和厂站确认端口,否则过滤器写错,抓半天一个包都没有。

用 Wireshark 抓 104 最省事的过滤写法是tcp.port == 2404,如果端口被改过就换成实际端口。想只看应用报文,可以再加tcp.len > 6,因为纯链路报文 TCP 载荷只有 6 字节。下面这条命令是我在 Linux 前置机上常用的抓包方式,把包存下来再拖到 Wireshark 里分析:

# 在 2404 端口抓 104 报文,-s 0 抓完整帧,-w 存成 pcap 供 Wireshark 分析 tcpdump -i eth0 -s 0 -w iec104.pcap 'tcp port 2404'

参数说明:-i eth0指定网卡,现场如果是 bond 口就换成 bond0;-s 0表示不截断,104 报文虽然短,但截断后 ASDU 可能不完整;-w直接落盘,避免终端刷屏看不清。抓完用 Wireshark 打开,在过滤栏输入iec60870_104可以直接调用内置解析器,比人肉数字节快得多。

2.2 I 帧、S 帧、U 帧:控制域第一个字节就能分辨

控制域四个字节是 104 的精华,也是最容易看错的地方。判断帧类型只看第一个控制域字节的最低位和次低位:

帧类型第一个控制域字节特征作用
I 帧bit0 = 0承载 ASDU,带发送序号和接收序号
S 帧bit0 = 1, bit1 = 0只确认接收,不带 ASDU
U 帧bit0 = 1, bit1 = 1链路启动、停止、测试、确认

I 帧格式里,第一个字节和第二个字节组成发送序号 N(S),第三个字节和第四个字节组成接收序号 N(R),每个序号占 15 位,低字节在前。比如68 0E 00 00 02 00 ...,发送序号是 0,接收序号是 2,说明本端已经收到对方 2 帧。S 帧只填接收序号,U 帧则用第一个字节的低两位区分具体功能,常见的有 STARTDT act(0x07)、STARTDT con(0x0B)、TESTFR act(0x43)、TESTFR con(0x83)、STOPDT act(0x13)、STOPDT con(0x23)。

现场排查链路断没断,最直接的办法就是看有没有 TESTFR 心跳。t3 默认 20 秒,如果 20 秒内没有任何报文,主站或厂站会发 TESTFR act,对方必须回 TESTFR con。如果只看到 act 看不到 con,链路就是单向通的,问题多半在防火墙或对端进程。这里有个血泪经验:有些厂站设备 t3 被改成 60 秒甚至更长,主站侧却按 20 秒判断,结果主站频繁报链路中断,实际链路是好的,只是心跳节奏没对齐。所以对接前一定把 t1、t2、t3 三个定时器白纸黑字确认下来。

2.3 用 Python 解析控制域,验证序号连续性

光靠眼睛数序号容易翻车,尤其是报文一多。我一般会写个小脚本把 pcap 里的 104 控制域抽出来,检查 N(S) 和 N(R) 是否连续。下面这段代码用 scapy 读取 pcap,只解析 TCP 载荷的前六个字节,够用了:

from scapy.all import rdpcap, TCP, Raw def parse_apci(payload): # payload 至少 6 字节:0x68 + len + 4 控制域 if len(payload) < 6 or payload[0] != 0x68: return None ctrl = payload[2:6] if ctrl[0] & 0x01 == 0: # I 帧:发送序号 = ctrl[0..1] >> 1,接收序号 = ctrl[2..3] >> 1 ns = ((ctrl[1] << 8) | ctrl[0]) >> 1 nr = ((ctrl[3] << 8) | ctrl[2]) >> 1 return ('I', ns, nr) elif ctrl[0] & 0x03 == 0x01: nr = ((ctrl[3] << 8) | ctrl[2]) >> 1 return ('S', None, nr) else: return ('U', ctrl[0], None) pkts = rdpcap('iec104.pcap') for p in pkts: if p.haslayer(TCP) and p.haslayer(Raw): r = parse_apci(bytes(p[Raw].load)) if r: print(r)

逻辑说明:先判断启动字符,再按控制域第一个字节的最低位区分帧类型。I 帧的序号要右移一位,因为最低位是标志位不是序号的一部分。参数上,ctrl[1] << 8 | ctrl[0]是小端拼接,104 规定低字节在前。跑完如果发现 N(S) 跳号,比如从 5 直接跳到 8,中间两帧丢了,那就要看 TCP 层有没有重传,或者对端是不是真的没发。这个脚本不依赖 Wireshark 的解析器,适合在只有命令行环境的前置机上快速定位。

3. ASDU 类型标识与传送原因:遥测遥信遥控怎么区分

3.1 类型标识决定这条报文装的是什么数据

APCI 之后就是 ASDU,ASDU 第一个字节是类型标识 TypeID,它直接告诉你这条报文是干什么的。现场最常打交道的几个类型标识必须背下来:单点遥信是 1(M_SP_NA_1),带时标单点遥信是 30(M_SP_TB_1);双点遥信是 3(M_DP_NA_1),带时标双点遥信是 31(M_DP_TB_1);归一化遥测是 9(M_ME_NA_1),标度化遥测是 11(M_ME_NB_1),短浮点遥测是 13(M_ME_NC_1),带时标短浮点遥测是 36(M_ME_TF_1);遥控是 45(C_SC_NA_1)单点遥控和 46(C_DC_NA_1)双点遥控;总召唤是 100(C_IC_NA_1);电度是 15(M_IT_NA_1)。看到 100,就知道这是总召唤,后面会跟一批 1、3、9、13 的数据上送。

类型标识后面紧跟一个字节,最高位是 SQ 位,低七位才是可变结构限定词 VSQ。SQ=0 表示 ASDU 里每个信息对象都带自己的信息对象地址;SQ=1 表示连续多个信息对象共用第一个地址,后续地址依次加一。这个位非常关键,解析时如果忽略 SQ,地址就会全错。比如一条 SQ=1 的遥测报文,信息对象地址只出现一次,后面跟多个值,你按 SQ=0 去解,第二个值就会把第一个值后面的字节当成地址,整条报文全乱。

3.2 传送原因:为什么同一条数据会重复上送

传送原因 COT 占两个字节,低字节是原因,高字节是源发站地址。常见原因值:3 是突发(spontaneous),5 是请求或被请求,6 是激活,7 是激活确认,8 是停止激活,9 是停止激活确认,10 是激活终止,20 是响应总召唤,1 是周期上送。现场经常有人问:为什么这个遥信一会儿来一条,一会儿又来一条?看 COT 就明白了,3 是变位主动上送,20 是总召唤响应,两者都会让同一个点出现在报文里,但触发机制完全不同。

总召唤的交互流程是固定的:主站发 TypeID=100、COT=6 的激活报文,厂站回 COT=7 激活确认,然后厂站用 COT=20 把全部数据分批上送,送完再发一个 TypeID=100、COT=10 的激活终止。如果主站发了总召唤却收不到激活确认,先查 ASDU 里的公共地址对不对,公共地址不匹配,厂站会直接丢弃,连拒绝都不回。公共地址通常 1 字节,也有 2 字节的,这个在点表对接时就要和厂站确认清楚,属于典型的“配错一个字,调一整天”。

3.3 信息对象地址与时标:点表对不上的根因

信息对象地址 IOA 占三个字节,低字节在前,范围 0 到 16777215。国内很多厂站习惯把 IOA 按 遥信 1 开头、遥测 4 开头、遥控 6 开头来编,但这只是习惯不是标准,实际以点表为准。时标是 7 个字节:毫秒 2 字节、分钟 1 字节、小时 1 字节、日 1 字节、月 1 字节、年 1 字节。带时标的类型标识(30、31、36)才有这 7 个字节,不带时标的(1、3、9、13)没有。解析时如果类型标识判断错,把不带时标的当带时标的解,后面所有字节都会错位,这是新手最常见的翻车点。

下面这段代码演示如何从 ASDU 里取出类型标识、传送原因、公共地址和第一个信息对象地址,帮助快速核对点表:

def parse_asdu(asdu): type_id = asdu[0] sq = (asdu[1] & 0x80) >> 7 num = asdu[1] & 0x7F cot = asdu[2] | (asdu[3] << 8) # 低字节原因,高字节源发站 ca = asdu[4] # 公共地址,1 字节场景 ioa = asdu[5] | (asdu[6] << 8) | (asdu[7] << 16) return { 'type_id': type_id, 'sq': sq, 'count': num, 'cot': cot & 0xFF, 'ca': ca, 'first_ioa': ioa }

参数说明:asdu[1] & 0x80取 SQ 位,& 0x7F取对象个数;COT 两个字节里真正的原因在低字节,高字节是源发站地址,调试时如果源发站地址不为 0,说明报文经过了多级转发。公共地址这里按 1 字节处理,如果现场是 2 字节公共地址,ca要取两个字节,后面 IOA 的起始位置也要顺延一位。这个函数只取第一个 IOA,SQ=1 时后续地址依次加一即可。

4. 避坑与排查:104 调试现场最容易翻车的五件事

4.1 链路起来了但总召唤没数据

现象:TCP 连接建立成功,STARTDT 也确认了,主站发总召唤,但收不到任何数据上送。原因通常有三个:公共地址不匹配、点表里 IOA 和厂站实际不一致、厂站侧总召唤功能没使能。解决顺序是先确认公共地址,再抓包看厂站有没有回激活确认。如果连激活确认都没有,基本是公共地址或端口问题;如果有激活确认但没有后续数据,查厂站点表是否为空或总召唤被禁用。

4.2 遥测值跳变或明显偏大

现象:主站显示的遥测值偶尔跳一下,或者整体偏大几十倍。原因多半是类型标识和系数没对上。归一化值 M_ME_NA_1 是 -1 到 1 之间的小数,需要乘量程系数;标度化值 M_ME_NB_1 是整数,系数在点表里配;短浮点 M_ME_NC_1 本身就是浮点,不需要再乘。把归一化值当标度化值解,或者系数配错,都会导致数值异常。解决方法是抓一条原始报文,按类型标识手动算一遍,和主站显示值对比。

4.3 遥控下发后无返校

现象:主站发遥控选择,厂站不回选择确认,或者回了确认但执行后没有执行确认。原因可能是遥控类型标识用错(单点遥控 45 还是双点遥控 46)、遥控输出方式(直接执行还是选择执行)没对齐、遥控点号在厂站侧被闭锁。解决时先看 ASDU 里的类型标识和 COT,选择执行流程应该是 COT=6 选择、COT=7 选择确认、COT=6 执行、COT=7 执行确认、COT=10 激活终止。少任何一步都要查厂站侧遥控配置。

4.4 序号不连续导致链路复位

现象:链路运行一段时间后自动断开重连,抓包看到 N(S) 或 N(R) 跳号。原因是 TCP 层丢包或对端处理不过来,104 的 k 参数(未确认 I 帧最大数)默认 12,w 参数(收到多少帧必须确认)默认 8,如果厂站侧处理慢,主站发满 k 帧还没收到确认,就会主动断开。解决办法是适当调大 k 和 w,或者查网络质量。注意 k 和 w 必须两端匹配,一端调了一端没调,反而更容易断。

4.5 时标对不上导致顺序错乱

现象:带时标的遥信上送后,主站按时间排序发现顺序不对。原因是时标解析时字节顺序或时区处理错了。104 时标是本地时间,不带时区,7 个字节依次是毫秒低、毫秒高、分、时、日、月、年。如果厂站和主站不在同一时区,或者厂站时钟本身不准,时标就会乱。解决方法是先对时,再核对时标字节解析顺序,尤其是毫秒两个字节的低高顺序。

5. 进阶技巧:用脚本批量校验 104 点表与报文一致性

调试到后期,最耗时间的不是抓包,而是核对几百上千个点。我的习惯是写一个校验脚本,把点表 CSV 和抓包解析结果做比对,自动找出“点表里有但报文里没出现”和“报文里有但点表里没有”的点。下面这个思路可以直接套用:

import csv from collections import defaultdict # 读取点表:ioa, name, type point_table = {} with open('points.csv', encoding='utf-8') as f: for row in csv.DictReader(f): point_table[int(row['ioa'])] = row['name'] # 假设 parse_pcap 返回 [(type_id, ioa), ...] seen = defaultdict(int) for type_id, ioa in parse_pcap('iec104.pcap'): seen[ioa] += 1 # 点表有但报文没出现 missing = [ioa for ioa in point_table if ioa not in seen] # 报文有但点表没有 extra = [ioa for ioa in seen if ioa not in point_table] print('点表缺失上送:', missing[:20]) print('报文多余点号:', extra[:20])

逻辑说明:parse_pcap可以复用前面解析 ASDU 的函数,把每条报文的类型标识和 IOA 抽出来。seen统计每个 IOA 出现次数,出现 0 次的就是点表里有但没上送的。参数上,点表 CSV 至少要有 ioa 和 name 两列,type 列可选,用来区分遥信遥测。这个脚本跑一遍,比人工翻点表快得多,尤其适合总召唤后做全点核对。

还有一个实用技巧是看总召唤的激活终止报文。厂站发完所有数据后会发 TypeID=100、COT=10 的报文,这条报文里的 VSQ 如果是 0,说明没有对象,只是终止信号。如果一直等不到这条终止报文,说明厂站侧总召唤流程没走完,可能是某个数据上送卡住了。这时候可以看最后一条数据报文的 N(S),和厂站侧日志对比,定位卡在哪一批。

我自己的习惯是每次新站调试,先抓一段完整的总召唤过程,存成 pcap 归档,后面点表变更或数据异常时拿出来对比。104 报文看着复杂,但结构极其规整,把 APCI 四个控制域字节和 ASDU 前八个字节吃透,剩下的就是耐心核对。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询