简介:这份PDF聚焦GOOSE报文解析,围绕ISO/IEC 8802-3帧格式,系统梳理普通报文与广播报文的结构差异,并深入讲解APDU报文头、TPID、以太网类型、APPID等关键字段,以及ASN.1 BER编码中标记、长度、值的对应规则。内容覆盖BOOL型、BIT-String型、UTC型(时间)、INT型、Unsigned型、Visible-String型等常用数据类型,并给出报文逐字节分析示例,从目的MAC、源MAC、以太网类型、APPID,到APDU Head与allData数据集合,均逐一标注说明,便于读者对照学习GOOSE报文的完整组成与含义。资源为单份PDF文档,大小约121KB,内容精炼、重点突出,既可作技术入门教材,也能作为日常排错与开发调试的速查参考。目前已有1035人学习下载,对智能变电站、电力系统自动化及IEC 61850相关技术人员具有实用价值。
1. GOOSE报文解析为什么值得自己动手写一遍
做过变电站调试的人大概都有过这种经历:后台报文刷得飞快,GOOSE报文在网线里来回飞,可打开抓包文件一看,全是成串的十六进制字节,设备厂商的解析工具又不给导出接口。想确认保护跳闸信号有没有发出来,只能对着报文一条条猜。这个项目标题「GOOSE报文解析.pdf」看起来像一份说明书,但本质上是在问一件事:GOOSE报文到底怎么从一帧原始字节变成一条能看懂的开入开出?答案并不复杂,但边界条件特别多。这篇文章会把IEC 61850里的GOOSE结构、BER编码规则、抓包过滤、解析代码、常见坑位一次说清,适合刚接触智能变电站调试的工程师,也适合想把手动抓包替换成自动化判据的二次运维人员。
2. 先读懂IEC 61850里的GOOSE结构:从APDU到保留字节
2.1 GOOSE在IEC 61850的哪一层,和其它报文差在哪
GOOSE的全称是Generic Object Oriented Substation Event,走的是以太网二层,不经过TCP/IP协议栈,所以没有IP地址和端口号这一说。它靠的是目的MAC地址组播、APPID、VLAN和数据集内容来识别。如果你之前做过CAN报文解析或者CDT规约解析,会发现GOOSE的报文组织思路完全不一样:CAN是ID加数据域,CDT是固定帧格式加功能码,而GOOSE是ASN.1的BER编码,长度和类型都写在每个字段前面,字段顺序和嵌套结构由SCD文件里的数据集定义决定。也就是说,同样是解析一个双点位置信号,不同装置的GOOSE报文可能长得不一样,光靠固定偏移去切字节一定会翻车。
GOOSE的核心机制是「心跳+变位重发」。正常时装置以T0为周期发稳定帧,状态变化时立刻发一帧,然后按T1、T1、T2、T3逐步拉长间隔重发,直到回到T0心跳。这个机制决定了解析器不能只处理单帧,还要结合sqNum和stNum判断数据的连续性。sqNum是同一状态下的帧序号,stNum是状态变化序号,stNum变了说明发生了变位,sqNum归零重新计数。理解了这两个计数器的关系,才能区分「正常心跳」和「变位后的快速重发」,避免把重复帧当成新事件。
2.2 BER编码:ASN.1不是玄学,是字节级规则
GOOSE的APDU(Application Protocol Data Unit)使用ASN.1 BER的TLV结构,即Tag-Length-Value。每个字段先给一个Tag字节,表示这是什么类型,再给Length字节表示后面值占多长,最后才是真正的值。Tag和Length本身还可能扩展,比如Length超过127字节时,第一个字节高位置1,低7位表示后续还有几个长度字节。这个规则是写解析器的第一道坎,很多新手栽在手动按偏移切字段上,就是因为没按TLV逐层往里走。
一个典型的GOOSE APDU结构长这样:外层是一个Context Tag 0x61(表示Application),往里依次是0x80(gocbRef)、0x81(timeAllowedToLive)、0x82(datSet)、0x83(goID)、0x84(t)、0x85(stNum)、0x86(sqNum)、0x87(test)、0x88(confRev)、0x89(ndsCom)、0x8A(numDatSetEntries)、0xAB(allData,数据集本体)。allData里每个成员又是一个嵌套的TLV,可能是布尔、位串、整数或结构体。所以解析器的骨架应该是「读Tag、读Length、按类型取值、再递归处理嵌套」,而不是一次性把缓冲区按固定偏移掰开。
2.3 用Wireshark做参考:先看对再写码
自己写解析器之前,先用Wireshark把报文生成一遍,确认你的理解有没有跑偏。Wireshark自带goose解析器,抓包后过滤goose,选中一帧,展开IEC 68850 GOOSE标签,你会看到完整的字段树。这一步的作用不是偷懒,而是建立「字节偏移到字段含义」的映射关系。把十六进制字节和Wireshark解析结果对照着看,用不了几次就能摸清BER的规律。
如果变电站现场不方便抓包,可以在自己的电脑上搭一个最小实验环境:用IEC 61850模拟器或者支持GOOSE输出的保护测试仪发帧,笔记本接同一个交换机镜像口,Wireshark开抓。没有测试仪的话,用Python构造一帧伪造报文也行,后面第6章会说怎么构造和自测。记住一件事:GOOSE解析器的正确性判断,永远以「能解析真实装置报文」为准,仿真数据只能用来调通流程。
3. 用Python写最小GOOSE解析器:从网卡抓包到输出数据
3.1 抓包前的网络准备:组播地址与虚拟局域网参数
GOOSE报文的目的MAC通常是01:0C:CD开头的组播地址,VLAN ID在SCD文件里配置,VLAN优先级一般设为4。抓包前要确认两点:网卡开启了混杂模式,交换机的镜像口配置正确。混合模式下才能收到组播报文,否则只能看到发给本机的单播帧。如果现场是虚拟局域网环境,还要保证抓包端口能透传对应VLAN,否则抓到的报文里可能连VLAN Tag都没有。
你的网卡IP地址对GOOSE抓包没有意义,因为GOOSE不走IP。有些新手开着防火墙或抓包工具默认过滤,导致什么都收不到,其实就是没有正确订阅组播。把抓包工具的过滤器设为ether[0:3] == 01:0c:cd,只看以01:0C:CD开头的广播帧,比在应用层过滤省力得多。
3.2 最小解析代码:先剥以太网和VLAN
先不管BER的嵌套细节,写一个最小化的解析骨架,把以太网头、VLAN Tag和GOOSE的固定字段剥出来。这个阶段的目标是验证「网卡收到的确实是GOOSE帧」,而不是直接冲进APDU里挖字段。
import struct from collections import namedtuple # GOOSE 帧的最小结构:以太网头 + VLAN Tag + GOOSE 固定字段 # 目的MAC 6字节 + 源MAC 6字节 + EtherType 2字节 + VLAN Tag 4字节 # + APPID 2字节 + Length 2字节 + 保留字段 4字节 -> APDU GooseHeader = namedtuple('GooseHeader', [ 'dst_mac', 'src_mac', 'appid', 'length', 'reserved' ]) def parse_goose_header(packet: bytes) -> GooseHeader: dst_mac = packet[0:6].hex(':') src_mac = packet[6:12].hex(':') # 以太网类型如果是 0x8100,说明后面跟的是 VLAN Tag etype = struct.unpack('!H', packet[12:14])[0] if etype == 0x8100: # 跳过 VLAN 的 2 字节 TCI,再读 2 字节 EtherType appid = struct.unpack('!H', packet[16:18])[0] length = struct.unpack('!H', packet[18:20])[0] reserved = packet[20:24] else: appid = struct.unpack('!H', packet[14:16])[0] length = struct.unpack('!H', packet[16:18])[0] reserved = packet[18:22] return GooseHeader(dst_mac, src_mac, appid, length, reserved)代码里先判断EtherType是不是0x8100,是的话说明带了VLAN Tag,摸到APPID的偏移就要往后多移4字节。很多解析器翻车的点就在这儿:现场环境有的带VLAN,有的不带,需要看配置再决定是否跳过。struct.unpack用的!H是大端无符号短整型,GOOSE所有字段都是大端序,不能写成小端。拿到APPID后,可以提前做一个映射表,把APPID对应到间隔名称和装置描述,方便后续按间隔过滤。
3.3 解析APDU里的数据集:状态值和时间戳
固定字段剥出来后,剩下的就是APDU。APDU本体严格按TLV结构嵌套,这里写一个通用的BER读取函数,把每个Tag和Value都提出来,再对特定Tag做业务解析。allData里的数据是关键,因为开入开出的实际值都在这里面。
def read_tlv(data: bytes, offset: int): """ 从 data 的 offset 处读取一个 TLV 结构 返回 (tag, value, next_offset) """ tag = data[offset] offset += 1 length = data[offset] offset += 1 # 处理长长度:第一个字节最高位为 1 时,低 7 位表示长度字节数 if length & 0x80: num_bytes = length & 0x7F length_bytes = data[offset:offset + num_bytes] length = int.from_bytes(length_bytes, 'big') offset += num_bytes value = data[offset:offset + length] next_offset = offset + length return tag, value, next_offset def parse_goose_apdu(apdu: bytes) -> dict: result = {} offset = 0 # 外层先读一个 0x61 Application Tag tag, value, offset = read_tlv(apdu, 0) # 内层循环读各个字段 while offset < len(apdu): inner_tag, inner_value, next_offset = read_tlv(apdu, offset) if inner_tag == 0x80: result['gocbRef'] = inner_value.decode('utf-8', errors='replace') elif inner_tag == 0x81: result['timeAllowedToLive'] = int.from_bytes(inner_value, 'big') elif inner_tag == 0x84: # 时间戳,8 字节,从 1583 年起的 100ns 计数 result['timestamp'] = int.from_bytes(inner_value, 'big') / 10_000_000 elif inner_tag == 0x85: result['stNum'] = int.from_bytes(inner_value, 'big') elif inner_tag == 0x86: result['sqNum'] = int.from_bytes(inner_value, 'big') elif inner_tag == 0x88: result['confRev'] = int.from_bytes(inner_value, 'big') elif inner_tag == 0xAB: # allData,还需要按成员类型进一步递归解析 result['allData'] = parse_all_data(inner_value) offset = next_offset return result这段代码的关键是read_tlv函数里的长长度处理。GOOSE报文里有个别字段长度超过127字节,比如allData里嵌套结构体多的时候,Length字段会按长格式编码。不看这个位标志,解析到一半就会错位,然后整帧全乱。timestamp字段是8字节的绝对时间,单位是100纳秒,换算成秒要除以1千万,起点是1583年,不是Unix的1970年,直接转会差一大截。parse_all_data没有展开写,它内部同样是一层层TLV套出来的布尔值或位串,核心逻辑就是递归调用read_tlv,直到把值取出来。
Wireshark里展开GOOSE可以看到allData内部结构;自己写解析器时,建议在allData上递归时打日志,每层记录tag和offset,一旦解析结果对不上数据手册,日志能直接指出哪一层偏了。GOOSE的位串类型比较特殊,开入量是按位存储的,一个字节可以表示8个开入,解析时要注意位序是按大端还是小端。按IEC 61850的定义,位串的位序是第一字节的最高位对应第1个bit,很多国产装置也遵守这个约定,但个别厂商实现有差异,最好用SCD文件里的typeDesc对照验证。
4. 五个高频坑:时间戳、心跳、VLAN优先级和APPID
4.1 现象:stNum和sqNum对不上
调试时把一段抓包导入自己写的解析器,发现stNum没变,sqNum却频繁归零,或者stNum跳了好几下,sqNum都没归零。原因多半是解析字段的偏移错了,把sqNum读到了别的字段上。还有一种情况是allData解析出错后,后续字段的offset整体错位,导致stNum读的是前一个字段的残留数据。
解决:不要在整包上做静态偏移解析,严格按TLV逐层读取。写一个断言,检查解析出来的sqNum范围是不是0到timeAllowedToLive/T0之间,如果超出合理范围,优先怀疑前面某个字段的长度解析错了。另一种常见于多装置场景:多台装置共用一个组播地址,每台装置有自己的APPID和stNum,混在一起抓包时Wireshark能区分,但你自己写的解析器如果只按目的MAC过滤,会把多台装置的报文混在一起,导致stNum忽大忽小。按APPID做二级过滤是必须的。
4.2 现象:解析出来全是FF
报文能抓到,但解析出来的开入开出值全是0xFF,看起来像数据域没更新。原因一般有两个:一是抓包抓到了未初始化的预留帧,装置上电后还没发布第一帧心跳,先发了一帧空数据,这种帧在SCD文件里可能对应无效配置;二是在做手工测试时,测试仪输出的报文把不确定状态的bit都置1了,其实不是装置的真实状态。
解决:对比一下同一装置后续的心跳帧,如果后续帧数据正常,当前帧可以按初始化过程跳过;如果持续每帧都是0xFF,检查SCD文件里数据集成员的顺序和你的解析器是不是一致。allData成员的顺序不匹配是最隐蔽的坑,因为TLV结构不会报错,但每个值都会被读错位置。建议在解析器里增加一个数据降级策略,当出现持续FF时,把该帧标记为无效帧,不参与统计。
4.3 现象:TShark切虚接口时丢包
用命令行工具二次处理抓包文件时,发现goose过滤能出结果,但转成JSON后字段不全,或者用-Y过滤组合条件时结果为空。原因通常是GOOSE报文本身不带IP层,而TShark在按IP字段过滤时会把不匹配的帧全部丢弃。
解决:TShark里过滤GOOSE用goose关键字,不要用ip或tcp开头的过滤表达式。字段名用goose前缀,比如goose.stNum、goose.sqNum。命令行导出数据时建议分两步:第一步先导成pcapng再过滤,第二步再导出字段。一步到位的命令容易因为字段不存在而直接空跑。具体操作是:
tshark -r input.pcapng -Y "goose" -T fields -e frame.time_epoch -e goose.appid -e goose.stNum -e goose.sqNum -e goose.allData -E separator=, -E occurrence=a > goose_parsed.csv这个命令里-e frame.time_epoch把时间戳转成秒级小数,-E occurrence=a会让allData重复字段展开成多列,方便后续在Excel里逐位核对。-E separator=,指定CSV分隔符,默认是制表符,看个人习惯。导出的CSV里allData是十六进制串,仍然需要程序二次解析,但至少字段分离这一步不用手写。
4.4 现象:confRev变化导致解析失败
升级装置程序后,装置下发的GOOSE报文里confRev比SCD文件里配的版本号大,或者干脆不匹配。此时解析器还按旧数据集结构去切allData,结果自然全错。confRev字段就是配置版本号,它一变,数据集成员顺序和类型都可能要做对应调整。
解决:解析器里维护一个APPID到confRev的映射,发现confRev变化时,重新加载相应的数据集描述。常见做法是读取SCD文件里的DataSet节点,按名字解析成员类型列表,再用这个列表指导allData的解析。如果SCD文件不在手边,至少要把confRev作为日志字段打出来,方便发现问题后回溯。不能假设站的版本永远不变,厂站做保护改造太常见了。
4.5 现象:收到但是应用层不刷新
后台监控显示GOOSE链路正常,收到的报文心跳都连续,但某一个开入量就是不变,手动短接开入也不动作。排查到最后,发现是对侧装置把该点配置成了固定值,根本没有在GOOSE数据集里发布这个量。这种情况最迷惑人,因为链路没问题、报文结构没错,就是业务逻辑里该点不存在。
解决:对照SCD文件里数据集的实际内容,逐个点检查是否在发布列表里。GOOSE可以配置成只发布变化量,也可以配置成周期性发送全部量,不发布某个点在设计上是合法的。还有一类情况是装置把开入量映射到了不同数据集,APPID没变,但成员顺序变了,这种也要回SCD文件里去核对。经验是:不要只看报文内容,一定要把SCD文件里的数据集定义和解析输出做成对照表,比对着查比肉眼盯快得多。
5. 把解析结果接进ollama:用本地模型做语义标签
5.1 为什么要做语义层:解析结果不是结论
报文解析完成后,拿到的是一堆stNum、sqNum、bool值,但运维人员想看的不是这些,而是「1号间隔保护A相跳闸出口动作」这种自然语言描述。传统做法是把点号映射表写死在脚本里,点少的时候可行,站里上百个间隔、上千个点的时候,维护映射表本身就成了一座山。既然标题热词里出现了GOOSE连接ollama的方向,可以把它理解为用本地大模型给解析结果做语义标注的实验路径,我不展开讲模型训练,只讲怎么用ollama的HTTP接口把解析结果变成可读文本。
这一节适合的场景是:已经能够稳定解析GOOSE报文,但数据集成员的具体含义散落在设计图纸里,希望用一个本地模型辅助生成语义标签,减少人工读图的时间。注意,这里用到的是「辅助」,不是让模型做故障判断,后边会说清边界条件。
5.2 用ollama的HTTP接口做标注:最小脚本
ollama的调用方式是在命令行启动服务后,往它的HTTP端口发一个JSON请求。这个方式和设备厂家无关,只和你的解析结果有关。下面这段脚本把刚解析出来的one字段变化发给本地模型,让它生成一个自然语言描述:
import requests import json # 假设 goose_result 是上一章 parse_goose_apdu 的返回值 # 其中 allData 结构被简化成了 {"201": 1, "202": 0, "203": 1} def generate_semantic_note(result: dict) -> str: prompt = ( "你是一个变电站GOOSE报文语义化工具。" "根据下面的数据点描述其含义,只输出结论,不解释过程。\n" "数据点:\n" f"stNum={result.get('stNum')}, sqNum={result.get('sqNum')}\n" f"allData={result.get('allData')}\n" ) resp = requests.post( "http://localhost:11434/api/generate", json={ "model": "qwen2.5:7b", "prompt": prompt, "stream": False, "options": {"temperature": 0.1} }, timeout=30 ) if resp.status_code == 200: return resp.json().get("response", "").strip() return "" # 使用示例 semantic_text = generate_semantic_note(goose_result) print(semantic_text)temperature设置成0.1是为了让输出尽量稳定,不发挥。stream设为False是为了拿完整结果,不用处理流式返回。这里有个容易被忽略的点:ollama的/api/generate接口请求体里模型名要写你本地已经拉下来的模型,比如qwen2.5:7b,如果本地没拉过这个模型,会直接报错,需要先执行ollama pull qwen2.5:7b。还有,不要试图把一整天的报文全部塞进prompt,上下文太长时模型输出质量会明显下降,并且响应时间也会变得不可接受。
5.3 这里适合接模型的三个边界条件
用大模型做语义标注有几个前提要讲清楚。第一,它不适合处理ms级的快速重发帧。GOOSE变位后的T1重发间隔以毫秒计,一个状态变化会产生十几帧重复报文,如果你逐帧去调模型,不仅慢,而且模型看到的内容全是重复的,输出没有任何增量价值。正确的做法是把变位事件(按stNum分组,取第一帧)抽出来再送模型,心跳帧直接丢弃。第二,它不适合做安全关键判断。模型的输出天然有随机性,哪怕temperature设成0,也可能因为量化误差改变输出措辞,所以「跳闸出口动作」这种关键语义不能只依赖模型生成,最后还是要落到结构化数值上。第三,它的价值在设计图纸电子化之后会被大幅削弱,但当图纸还没有数字化时,它能帮调试人员快速建立数据点含义的直觉,这个作用已经很值了。
从工程角度看,解析器负责给出事实,模型负责把事实转述成人话,两者职责分开,互不干扰。如果你把模型的输出回灌回控制系统做自动决策,那是在制造新的安全隐患,建议不要这么做。
6. 验证你的解析器:重放、比对与一手经验
6.1 用构造报文做自测
解析器写完之后,最怕的是拿真实报文一测就翻车,而现场机会又不多。我习惯的做法是先构造一批已知内容的GOOSE帧,喂给自己的解析器,看输出是不是和构造时一致。构造报文时可以故意设置几个刁钻值:比如超长Length嵌套结构、stNum相邻两帧跳变、allData里同时包含布尔和位串。这些用例能快速验证BER递归和类型解析的健壮性。
构造时可以用scapy这种通用库简化手工拼包,但要注意scapy默认对GOOSE的支持并不完整,很多时候你得自己往Ether层后面压原始字节。自测的目标不是验证scapy,是验证你的解析逻辑,所以构造报文时把期望解析结果写进注释里,逐一对比。
from scapy.all import Ether, raw # 手工构造一帧最小 GOOSE 报文,数据部分按 TLV 手动压字节 # 这个帧里 allData 只放一个单点值: tag=0x83 value=0x01 apdu_all_data = bytes.fromhex('83 01') # 单点状态=1 goose_tlv = bytes.fromhex('61 81 0c') + bytes.fromhex('80 06 47 4f 4f 53 45 31') # 外层0x61 # 实际构造时要按 SCL 里的数据集拼接,这里只演示自测思路 pkt = Ether(dst='01:0c:cd:01:00:01', src='00:11:22:33:44:55', type=0x88b8) / raw(goose_tlv + apdu_all_data) frame = raw(pkt)这段代码里的0x88b8是GOOSE的EtherType,和普通VLAN帧不同。构造时特意不按完整格式来,只放必要字段,可以让解析器在遇到缺字段时给出明确的错误信息而不是静默跳过。自测的另一个重点是测试超长Length字段的边界行为——构造一个Length值为130的嵌套结构,确认read_tlv能正确按长格式解析。把这类用例固化成一个断言脚本,每次改解析代码后都跑一遍,能省掉后面大量的现场排错时间。
6.2 三种错误定位手段
真实场景里解析器出问题,我一般按顺序做三件事。第一,用Wireshark解析同一帧,对比Wireshark里显示的字段树和你的程序输出,错位时一眼就能看出来是哪一级TLV的问题。第二,在read_tlv函数的入口处打一行日志,记录当前offset、tag、length,定位到具体字段后再往上层看。如果连tag都不对,说明上层的长度算多了或者算少了,直接查上一级的嵌套。第三,用十六进制编辑器看原始帧,把报文头和APDU边界手动画出来,确认你的偏移计算有没有少算了VLAN Tag或保留字段。
这三招里,第一招最直观,第三招最费眼但在没有Wireshark的离线环境里是唯一手段。一套解析器能不能扛住站里一年四季的报文,取决于你留下的日志够不够全。我习惯在每个帧解析完以后,输出一行包含appid|stNum|sqNum|timestamp|allData_len的紧凑日志,这样抓包文件回放时,可以快速筛查哪一帧的解析结果异常。顺带说一句,时间戳字段最容易被忽略,但现场排查时序问题时又必须靠它,建议从第一天起就对纪元换算做好注释,不然半年以后你自己也会忘。
6.3 回放验证与长期维护
抓到的历史报文不要删,按日期归档,解析器每次改完都拿历史报文做回归。这个习惯帮我挡掉过很多次低级失误,比如改了一个字段的偏移,结果把后续所有字段都带偏了,靠直觉看不出来,一跑历史数据立马现形。回放时不需要真发网卡,直接从pcap文件读帧往里灌解析函数就行。用pcap的rdpcap()函数读出所有帧,逐帧丢给解析器,再统计失败帧的百分比。一个稳定的解析器,对同一个站的报文失败率应该是0%,偶尔出现一帧解析失败,优先怀疑抓包抓到了碎包。
最后说一句实话:GOOSE解析不是算法难度的问题,是细节密度的问题。把TLV吃透、把时间戳和VLAN这些边角料处理干净,你的解析器就能覆盖绝大多数真实场景。我做这套东西几年下来,最大的教训是永远不要相信芯片厂商的文档和实际报文完全一致。能证明解析正确性的只有两样东西:SCD文件里的数据集定义,和你自己抓下来的真实报文。带着这个思路去重复劳动,才不会在调试现场被玄学问题困住。希望帮到你。
本文还有配套的精品资源,点击获取