IEC104报文解析实战:从抓包到Python代码逐字节拆解 简介这是一份面向电力自动化、变电站通信调试及工控协议开发人员的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为 1000x64表示总召唤cot为 6 表示激活common_addr为 1 表示站地址 1。这就是一条主站向子站发起的总召唤激活命令。如果你拿到的报文type_id是 9 或 13那分别是归一化遥测和短浮点遥测type_id是 1 或 3 则是单点遥信和双点遥信。2.3 类型标识和传送原因的对照关系光会拆结构还不够得知道每个数字代表什么。下面这张表是我从实际调试中整理的高频类型标识和传送原因建议直接存下来当速查表用。类型标识含义常见传送原因1单点遥信3突发、20响应总召唤3双点遥信3、209归一化遥测3、2013短浮点遥测3、2045单点遥控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_ratio0.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不要设得太小否则正常的负荷波动也会触发告警也不要设得太大否则真正的突变会被漏掉。我一般先跑一遍看告警分布再根据实际测点的历史数据调整。这个技巧帮我省了很多现场排查的时间尤其是那种偶发的遥控失败人工翻抓包很难找到规律自动化校验一跑就定位到了。最后说个习惯每次解析完一批报文我都会把原始十六进制、解析结果和校验告警存成三列方便回溯。这个习惯是从一次惨痛经历来的——现场遥控失败抓包文件被覆盖了只能凭记忆复现结果折腾了一整天。从那以后不管多急先存原始数据再分析。希望帮到你。本文还有配套的精品资源点击获取