☰
IEC104报文解析实战:从抓包到Python代码逐字节拆解
2026/10/6 5:08:23 网站建设 项目流程

简介:这是一份面向电力自动化、变电站通信调试及工控协议开发人员的IEC104报文解析参考资料,聚焦主站与子站交互报文的逐字节拆解,帮助读者看懂原始十六进制帧并理解其字段含义。资源包共1个PDF文件,压缩包约22KB,内容以报文实例与字段说明为主,便于随身查阅和对照调试。目前已有1324人学习下载,在协议入门与现场排错场景中具备一定参考热度。资料围绕变化遥测、链路测试帧、主站接收数据确认帧、总召唤上送遥测、遥信变位或告警、SOE、遥控等典型报文展开,逐条给出报文头、ASDU类型、可变机构限定词、传送原因、公共地址与信息体地址的取值解释,并说明信息体地址连续与不连续两种组织方式及遥测三字节、遥信一字节的数据差异。读者可据此快速建立IEC104帧结构认知,掌握从十六进制码流中定位起始地址、数据个数与上送原因的方法,为现场抓包分析和通信故障排查提供直接参照。

1. IEC104 报文解析:从抓包到看懂每一个字节

手上拿到一份 IEC104 报文解析的 PDF,或者被丢了一个抓包文件让你“看看为什么遥控失败了”,这大概是电力自动化方向工程师最常遇到的场景之一。IEC104 全称 IEC 60870-5-104,是变电站与调度主站之间最主流的远动通信协议,跑在 TCP 之上,默认端口 2404。它不像 Modbus 那么直白,一个字节一个含义;也不像 CAN 报文那样靠 DBC 文件就能翻译。IEC104 的报文分 APCI 和 ASDU 两层,类型标识、传送原因、公共地址、信息对象地址层层嵌套,不搞清楚结构,抓包看到的就只是一串十六进制。

这篇笔记面向三类人:刚接触 104 规约、需要照着解析报文的新手;已经在做 104 通信但总在“传送原因对不上”“遥测值跳变”上翻车的熟手;以及需要把 104 解析能力集成到自己工具链里的开发者。我会从报文结构讲起,给出可复现的解析代码,把参数含义、常见坑位和验证方法都摊开说。读完你至少能做到:拿到一段 68 开头的十六进制,手动拆出它是什么类型、从哪个站来、带什么值。

2. IEC104 报文结构拆解:APCI 与 ASDU 到底怎么分层

2.1 为什么 104 报文不能直接按字节读

IEC104 的报文有一个固定 6 字节的 APCI(应用规约控制信息)头,后面跟着可变长度的 ASDU(应用服务数据单元)。APCI 里最关键的是第一个字节 0x68,它是启动字符,看到 68 就知道这是一条 104 报文。第二个字节是长度域,表示从第三个字节开始到报文结束的字节数,注意它不包含启动字符和长度域本身。接下来 4 个字节是四个控制域,根据控制域的不同取值,报文分为三种格式:I 格式(信息传输)、S 格式(确认)、U 格式(控制)。

很多人第一次解析 104 会犯一个错:把长度域当成整个报文的长度。实际上如果长度域是 0x0E,那整条报文长度是 2 + 14 = 16 字节。这个偏移量搞错,后面 ASDU 全部错位,解析出来的类型标识就是乱的。我一般会在代码里先把长度域读出来,然后校验实际收到的字节数是否匹配,不匹配直接丢弃,这是最省事的后悔药。

ASDU 的结构才是真正承载业务数据的部分。它由类型标识(1 字节)、可变结构限定词(1 字节)、传送原因(2 字节)、公共地址(2 字节)开头,然后是信息对象地址(3 字节)和具体信息元素。类型标识决定这条报文是遥测、遥信、遥控还是总召唤;可变结构限定词的最高位表示信息对象是单个还是序列,低 7 位表示信息对象的个数;传送原因说明这条报文是周期上送、突发上送还是响应总召唤。

2.2 用 Python 拆出 APCI 和 ASDU 的最小代码

下面这段代码只做一件事:把一段十六进制字符串解析成结构化的字典。我刻意没有引入任何第三方 104 库,因为自己手写一遍才能真正理解每个字段的位置。

import struct def parse_iec104(hex_str): """解析一条 IEC104 报文,返回 APCI 和 ASDU 的字段字典""" data = bytes.fromhex(hex_str.replace(" ", "")) # APCI 固定 6 字节 if data[0] != 0x68: raise ValueError("不是 IEC104 报文,启动字符错误") length = data[1] # 长度域,不含启动字符和长度域本身 if len(data) != length + 2: raise ValueError(f"长度不匹配:声明 {length},实际 {len(data)-2}") ctrl1, ctrl2, ctrl3, ctrl4 = data[2], data[3], data[4], data[5] # 判断帧格式 if ctrl1 & 0x01 == 0: frame_type = "I" # I 格式,最低位为 0 elif ctrl1 & 0x03 == 0x01: frame_type = "S" # S 格式,最低两位为 01 else: frame_type = "U" # U 格式,最低两位为 11 result = { "frame_type": frame_type, "length": length, "raw_apci": data[:6].hex(), } # 只有 I 格式帧才携带 ASDU if frame_type == "I": asdu = data[6:] type_id = asdu[0] # 类型标识 vsq = asdu[1] # 可变结构限定词 cot = struct.unpack(">H", asdu[2:4])[0] & 0x3F # 传送原因,取低 6 位 common_addr = struct.unpack(">H", asdu[4:6])[0] # 公共地址 is_sequence = (vsq & 0x80) != 0 obj_count = vsq & 0x7F result.update({ "type_id": type_id, "is_sequence": is_sequence, "obj_count": obj_count, "cot": cot, "common_addr": common_addr, "asdu_hex": asdu.hex(), }) return result # 示例:一条总召唤激活报文 print(parse_iec104("68 0E 00 00 00 00 64 01 06 00 01 00 00 00 00 14"))

这段代码里几个参数需要重点说明。length是长度域原始值,校验时用len(data) != length + 2来判断,因为启动字符和长度域本身各占 1 字节。ctrl1 & 0x01判断 I 格式,这是 104 规约里最基础的位运算,I 格式帧的控制域最低位固定为 0。传送原因cot用& 0x3F是因为高两位可能被用作否定确认或测试标志,实际原因值在低 6 位。vsq & 0x80判断是否连续序列,vsq & 0x7F才是信息对象个数。

运行结果里type_id为 100(0x64)表示总召唤,cot为 6 表示激活,common_addr为 1 表示站地址 1。这就是一条主站向子站发起的总召唤激活命令。如果你拿到的报文type_id是 9 或 13,那分别是归一化遥测和短浮点遥测;type_id是 1 或 3 则是单点遥信和双点遥信。

2.3 类型标识和传送原因的对照关系

光会拆结构还不够,得知道每个数字代表什么。下面这张表是我从实际调试中整理的高频类型标识和传送原因,建议直接存下来当速查表用。

类型标识含义常见传送原因
1单点遥信3(突发)、20(响应总召唤)
3双点遥信3、20
9归一化遥测3、20
13短浮点遥测3、20
45单点遥控6(激活)、7(激活确认)、10(终止)
100总召唤6(激活)、7(激活确认)、10(终止)

传送原因里 3 是突发上送,20 是响应总召唤,6 是激活,7 是激活确认,10 是激活终止。遥控类报文最容易被搞混的就是 6 和 7:主站发下去的是 6,子站回上来的是 7,如果抓包看到子站回的是 6,那说明方向搞反了或者子站实现有问题。我遇到过现场调试时遥控一直失败,最后发现是子站把激活确认的传送原因写成了 6,主站认为没收到确认,超时后报遥控失败。这种坑不抓包根本看不出来。

3. 从抓包到解析:用 Python 跑通一条完整链路

3.1 抓包环境怎么搭才不白费功夫

解析 104 报文的前提是拿到干净的报文。常见做法是用 Wireshark 在变电站网关或者主站前置机上抓包,过滤条件直接写tcp.port == 2404。但这里有个坑:Wireshark 自带的 104 解析器有时候会把粘包拆错,尤其是当多条 104 报文在同一个 TCP 段里传输时。我一般会先把 TCP 流导出成原始十六进制,再用自己的脚本按 0x68 和长度域重新分帧,这样最可靠。

如果你没有现场环境,可以用模拟器造数据。常见的做法是起一个 104 服务端模拟子站,然后用客户端模拟主站发总召唤,抓取本地回环流量。模拟器选型上,开源的 lib60870 或者商业的 104 测试工具都可以,关键是能造出 I 格式、S 格式、U 格式三种帧,方便验证解析逻辑。

分帧逻辑本身不复杂:从字节流里找到 0x68,读下一个字节作为长度,然后往后取长度 + 2个字节作为一条完整报文。如果剩余字节不够,就等下一个 TCP 段。这个逻辑写成代码大概是这样:

def split_iec104_frames(stream: bytes): """从 TCP 字节流中切分出完整的 IEC104 报文""" frames = [] i = 0 while i < len(stream): if stream[i] != 0x68: i += 1 # 跳过非启动字符 continue if i + 1 >= len(stream): break # 长度域还没收到 length = stream[i + 1] total = length + 2 if i + total > len(stream): break # 整条报文还没收全 frames.append(stream[i:i + total]) i += total return frames

这段代码的关键在于i + total > len(stream)这个判断,它保证了不会把半条报文当成完整的解析。实际网络里 TCP 粘包和拆包很常见,没有这个判断,解析出来的 ASDU 会莫名其妙少几个字节,类型标识对不上,传送原因也是乱的。我一般会在分帧之后打印每条报文的长度和十六进制,跟 Wireshark 里看到的对比一下,确认分帧正确再往下走。

3.2 解析遥测值:归一化和短浮点的区别

遥测是 104 里最常解析的数据。类型标识 9 是归一化遥测,信息元素是 2 字节的有符号整数,实际值需要除以 32768 再乘以量程;类型标识 13 是短浮点遥测,信息元素是 4 字节的 IEEE 754 浮点数,直接struct.unpack就能得到实际值。很多人第一次解析遥测会发现值不对,要么是量程没乘,要么是把归一化当成了短浮点。

def parse_measurement(asdu: bytes, type_id: int): """解析遥测 ASDU 中的测量值""" # 信息对象地址占 3 字节,从第 6 字节开始 ioa = int.from_bytes(asdu[6:9], "big") value_bytes = asdu[9:] if type_id == 9: # 归一化值:2 字节有符号整数,范围 -1 到 1 raw = struct.unpack(">h", value_bytes[:2])[0] value = raw / 32768.0 return {"ioa": ioa, "raw": raw, "value": value, "type": "normalized"} elif type_id == 13: # 短浮点值:4 字节 IEEE 754 value = struct.unpack(">f", value_bytes[:4])[0] return {"ioa": ioa, "value": value, "type": "float"} else: raise ValueError(f"不支持的遥测类型:{type_id}")

参数上要注意,信息对象地址ioa是 3 字节大端,范围从 0 到 16777215。归一化值的原始整数除以 32768 得到的是标幺值,实际工程值还需要乘以该测点的量程上限。短浮点值直接就是工程值,不需要额外换算。如果解析出来的遥测值一直在 -1 到 1 之间跳,那大概率是归一化值没乘量程;如果值是一个很大的数或者 NaN,那可能是把短浮点当成了归一化,字节对齐错了。

3.3 遥控报文的解析和验证

遥控是 104 里最需要小心的一类报文,因为它涉及实际动作。类型标识 45 是单点遥控,46 是双点遥控。遥控报文的 ASDU 里,信息对象地址之后是遥控命令值,单点遥控用 1 字节,0 表示分闸,1 表示合闸;双点遥控用 1 字节,1 表示分闸,2 表示合闸。传送原因 6 是主站发下去的激活,7 是子站回的激活确认,10 是激活终止。

解析遥控报文时,除了看类型标识和传送原因,还要看遥控命令值是否和主站下发的一致。我遇到过子站回激活确认时把命令值改了的情况,主站下发合闸,子站回确认时命令值变成了分闸,结果实际动作反了。这种问题在抓包时如果不逐字节对比,很容易漏掉。验证方法很简单:把主站发出的遥控报文和子站回的确认报文都解析出来,对比信息对象地址和命令值,不一致就是子站的问题。

4. IEC104 报文解析的避坑与排查

4.1 长度域算错导致 ASDU 整体偏移

现象:解析出来的类型标识是一个不可能的值,比如 0 或者 255,传送原因也是乱的。原因:长度域没有正确理解,把启动字符和长度域本身也算进去了,导致 ASDU 的起始位置偏移了 2 字节。解决:记住长度域的定义是从第三个字节开始算,校验时用len(data) == length + 2,解析 ASDU 时从data[6]开始。

4.2 传送原因高位没屏蔽导致判断失败

现象:明明抓包看到传送原因是 6,代码里判断cot == 6却为 False。原因:传送原因占 2 字节,但高两位可能被置位,比如否定确认标志。实际原因值在低 6 位,直接取整数值会把高位也算进去。解决:用cot & 0x3F取低 6 位再判断,这是 104 规约里明确规定的。

4.3 信息对象地址字节序搞反

现象:遥测值能解析出来,但对应不到正确的测点,信息对象地址看起来是个很大的数。原因:信息对象地址是 3 字节大端,如果按小端解析或者字节顺序搞反,得到的地址就完全不对。解决:用int.from_bytes(asdu[6:9], "big")明确指定大端,不要用默认的字节序。

4.4 粘包导致多条报文被当成一条

现象:解析一条报文时发现 ASDU 后面还有多余的字节,或者类型标识解析出来是对的但信息元素个数不对。原因:TCP 是流式协议,多条 104 报文可能粘在一个 TCP 段里,没有按长度域分帧。解决:先用split_iec104_frames按 0x68 和长度域切分,再逐条解析,不要直接把整个 TCP 载荷当成一条报文。

4.5 总召唤和突发上送的传送原因混淆

现象:总召唤响应里混进了传送原因 3 的报文,或者突发上送的报文被当成了总召唤响应。原因:总召唤响应的传送原因是 20,突发上送是 3,两者可能交替出现。如果代码里只按类型标识过滤,不按传送原因区分,就会把突发上送的数据也算进总召唤结果里。解决:解析时同时判断类型标识和传送原因,总召唤响应只取 cot 为 20 的报文,突发上送单独处理。

5. 用解析结果做自动化校验:一个实用技巧

解析报文本身不是目的,目的是用解析结果发现问题。我一般会在解析脚本里加一层校验逻辑:把主站发出的遥控报文和子站回的确认报文配对,检查信息对象地址、命令值、传送原因是否匹配;把总召唤响应的遥测值和上一轮的值对比,如果变化超过阈值就标记出来。这样跑一遍抓包文件,能自动筛出可疑的报文,不用一条条人工看。

具体做法是维护一个状态字典,key 用信息对象地址,value 存最近一次的值和时间戳。每解析一条遥测报文,就和字典里的值比较,如果变化率超过设定阈值,就打印告警。这个阈值根据实际测点的量程来定,一般取量程的 10% 到 20%。遥控校验则是把 cot 为 6 的报文和 cot 为 7 的报文按信息对象地址配对,检查命令值是否一致。

class Iec104Validator: def __init__(self, threshold_ratio=0.1): self.last_values = {} # ioa -> (value, timestamp) self.threshold_ratio = threshold_ratio self.pending_commands = {} # ioa -> command_value def check_measurement(self, ioa, value, timestamp): if ioa in self.last_values: last_val, last_ts = self.last_values[ioa] if last_val != 0: change_ratio = abs(value - last_val) / abs(last_val) if change_ratio > self.threshold_ratio: print(f"遥测突变:IOA={ioa},{last_val} -> {value}") self.last_values[ioa] = (value, timestamp) def check_command(self, ioa, cmd_value, cot): if cot == 6: self.pending_commands[ioa] = cmd_value elif cot == 7: if ioa in self.pending_commands: if self.pending_commands[ioa] != cmd_value: print(f"遥控确认不一致:IOA={ioa},下发 {self.pending_commands[ioa]},确认 {cmd_value}") del self.pending_commands[ioa]

这个校验器的核心思路是把解析结果结构化之后做状态跟踪,而不是孤立地看每一条报文。实际用的时候,我会把抓包文件里的所有 104 报文按顺序喂进去,最后看告警列表。如果遥控确认不一致的告警出现,基本可以定位到子站实现问题;如果遥测突变告警集中在某个时间段,可能是该测点的采集回路有干扰。

阈值threshold_ratio不要设得太小,否则正常的负荷波动也会触发告警;也不要设得太大,否则真正的突变会被漏掉。我一般先跑一遍看告警分布,再根据实际测点的历史数据调整。这个技巧帮我省了很多现场排查的时间,尤其是那种偶发的遥控失败,人工翻抓包很难找到规律,自动化校验一跑就定位到了。

最后说个习惯:每次解析完一批报文,我都会把原始十六进制、解析结果和校验告警存成三列,方便回溯。这个习惯是从一次惨痛经历来的——现场遥控失败,抓包文件被覆盖了,只能凭记忆复现,结果折腾了一整天。从那以后,不管多急,先存原始数据再分析。希望帮到你。

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

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

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

立即咨询