简介:南瑞继保网络103规约是面向电力系统自动化从业者、远动调试人员及二次开发工程师的技术文档,围绕IEC 60870-5-103标准在南瑞继保设备上的实现展开,重点解决RTU与调度中心、集控站之间遥测、遥信、遥控、遥调等四遥数据交换的协议理解与工程应用问题。资源包共1个doc文件,约3.38MB,内容涵盖规约的兼容性扩展、功能码与报文格式、安全验证与数据加密机制、通信性能优化、故障诊断恢复策略以及自定义服务接口等关键知识点,并对网络拓扑适应性和数据编解码方式进行了说明。目前已有1973人学习下载,适合从事变电站自动化、调度通信调试及系统集成的技术人员参考。通过阅读可系统掌握南瑞继保103规约的帧结构、命令帧与应答帧交互逻辑、错误处理流程,为现场调试、协议对接和二次开发提供可落地的排错思路与实现依据。
1. 南瑞继保网络103规约:从报文黑匣子到能抓能解的实战路径
如果你在变电站自动化现场干过调试,大概率遇到过这样的场景:后台监控画面上某个间隔的遥测值突然不刷新了,或者遥控命令下发后装置毫无反应,而屏柜后面的保护装置面板显示一切正常。这时候老师傅通常会拎着笔记本往站控层交换机上一插,打开抓包工具,盯着满屏的十六进制报文说一句——“看,103规约的链路断了”。南瑞继保网络103规约,就是这套排查动作背后的核心通信协议。它脱胎于IEC 60870-5-103规约,原本是串口上的主从问答式协议,后来被映射到TCP/IP之上,形成了“网络103”。在国内变电站,尤其是南瑞继保的保护测控装置与后台监控系统之间,这套规约至今仍是主力。它不像IEC 61850那样模型自描述、配置复杂,也不像Modbus那样简单粗暴,而是卡在中间——有固定帧格式、有链路层确认机制、有应用层信息元素,但文档散落、抓包工具默认不解析、不同厂家实现还有细微差异。这篇文章面向的是需要现场调试、故障排查、或者自己写工具解析网络103报文的工程师。我会从协议帧结构讲起,给出可复现的解析代码,拆解链路建立、总召唤、遥控这些关键流程,最后落到几个只有踩过坑才知道的排查技巧上。不堆术语,直接上能跑的东西。
2. 网络103规约的帧结构与链路层:为什么抓到的报文总对不上
2.1 从串口103到网络103:封装方式决定了你抓包的位置
IEC 60870-5-103的原始形态是串行链路上的异步通信,采用FT1.2帧格式,有固定帧长和可变帧长两种。固定帧用于链路确认、请求链路状态、复位等,长度固定为5字节(不含校验和起始符);可变帧用于传输数据,长度随信息元素变化。当它被搬到TCP/IP上,最常见的做法是保留FT1.2的帧结构,直接作为TCP payload传输。南瑞继保的装置通常监听TCP 2404端口,这个端口号在IEC 60870-5-104里是标准端口,但103 over TCP并没有强制规定,只是现场约定俗成用2404的居多。这里第一个容易翻车的点就来了:你在交换机上做端口镜像抓包,Wireshark默认把2404端口识别为104规约,然后按104的APCI格式去解析,结果满屏红字“Malformed Packet”。实际上你抓到的payload是完整的FT1.2帧,只是被错误地套用了104的解析器。解决办法很简单,在Wireshark里右键报文选“Decode As”,把TCP 2404指定为“Data”或者直接看Raw十六进制。我一般会先看TCP payload的前几个字节,如果以0x68开头,那是104的启动字符;如果以0x10或0x68开头但后面跟的是103的链路控制域,就需要手动区分。南瑞继保网络103的固定帧启动字符是0x10,可变帧启动字符是0x68,这一点和104的0x68容易混淆,但固定帧的0x10是103独有的。
2.2 FT1.2帧格式拆解:固定帧与可变帧的字节级差异
固定帧长5字节,结构如下:启动字符0x10(1字节)、控制域(1字节)、链路地址(1字节)、校验和(1字节)、结束字符0x16(1字节)。控制域里包含了链路功能码,比如请求链路状态、复位链路、确认等。可变帧结构稍复杂:启动字符0x68(1字节)、长度域L(1字节,表示后续用户数据长度)、长度域L重复(1字节)、启动字符0x68重复(1字节)、控制域(1字节)、链路地址(1字节)、ASDU(应用服务数据单元,长度可变)、校验和(1字节)、结束字符0x16(1字节)。注意长度域L的计算范围是从控制域开始到校验和之前的所有字节数,不包括启动字符、长度域本身和结束字符。这个定义如果搞错,解析出来的ASDU长度就会偏移,导致后续所有字段错位。下面这段Python代码演示了如何从TCP payload中提取并解析一个完整的可变帧。代码假设你已经从抓包文件或socket中拿到了原始bytes。
import struct def parse_ft12_frame(data: bytes): """ 解析FT1.2帧,返回帧类型和字段字典 data: 从TCP payload中提取的完整帧字节 """ if len(data) < 5: return None, {"error": "frame too short"} start = data[0] if start == 0x10: # 固定帧 if len(data) != 5: return None, {"error": "fixed frame length mismatch"} ctrl = data[1] link_addr = data[2] checksum = data[3] end = data[4] if end != 0x16: return None, {"error": "invalid end byte"} return "fixed", { "control": ctrl, "link_addr": link_addr, "checksum": checksum } elif start == 0x68: # 可变帧 if len(data) < 8: return None, {"error": "variable frame too short"} L = data[1] L_rep = data[2] start_rep = data[3] if L != L_rep or start_rep != 0x68: return None, {"error": "length or start repeat mismatch"} # 计算帧总长:启动(1) + L(1) + L重复(1) + 启动重复(1) + L字节用户数据 + 校验(1) + 结束(1) total_len = 4 + L + 2 if len(data) < total_len: return None, {"error": "incomplete frame"} ctrl = data[4] link_addr = data[5] asdu = data[6:6+L-2] # L包含控制域和链路地址,所以ASDU长度是L-2 checksum = data[6+L-2] end = data[6+L-1] if end != 0x16: return None, {"error": "invalid end byte"} return "variable", { "control": ctrl, "link_addr": link_addr, "asdu": asdu, "checksum": checksum } else: return None, {"error": f"unknown start byte 0x{start:02x}"}这段代码的关键逻辑在于长度域L的解读。L的值等于控制域、链路地址、ASDU三部分的总字节数。所以ASDU的实际长度是L减去2(控制域1字节+链路地址1字节)。校验和的计算范围是从控制域到ASDU最后一个字节,采用算术和取反加一的方式,但很多网络103实现并不严格校验,抓包时可以先跳过校验直接看内容。参数方面,链路地址在103规约里通常对应装置的物理地址,南瑞继保的装置一般从1开始编号,后台通过链路地址区分不同间隔。如果你在解析时发现链路地址和预期不符,先检查抓包位置是否经过了规约转换器或网关,有些网关会修改链路地址。
2.3 控制域功能码:链路握手为什么总在“请求链路状态”
控制域是链路层的心脏。对于固定帧,控制域的低4位是功能码,高4位保留或用于其他标志。103规约定义的功能码包括:0x0 复位链路、0x1 请求链路状态、0x2 请求链路状态并期待确认、0x3 确认、0x4 用户数据确认、0x5 链路忙、0x6 链路空闲、0x7 否定确认等。实际抓包时,你会看到后台和装置之间反复出现“请求链路状态”和“确认”的配对。如果装置一直不回确认,或者回“链路忙”,后台就会不断重试,最终报通信中断。这里有个血泪经验:南瑞继保的部分装置在链路层有“链路状态保持”机制,如果后台超过一定时间没有发送任何帧,装置会主动断开TCP连接。这个超时时间不同版本不一样,有的30秒,有的60秒。所以你在写测试工具时,不能只建立连接就不管了,要定期发链路状态请求或者总召唤来维持链路。另外,控制域里的FCB位(帧计数位)和FCV位(帧计数有效位)用于防止帧重复和丢失,在可变帧传输时,发送方会翻转FCB,接收方通过比较FCB来判断是否是新帧。如果你自己实现主站,忘记翻转FCB,装置会认为你在重发旧帧,直接丢弃。
3. 应用层ASDU解析:总召唤、遥测、遥信和遥控的报文长什么样
3.1 ASDU类型标识与信息元素:从类型号反查数据含义
ASDU是应用层的数据单元,结构为:类型标识(1字节)、可变结构限定词(1字节)、传送原因(1字节)、公共地址(1字节)、信息对象地址(2字节或3字节,103规约通常用2字节)、信息元素集。类型标识决定了这个ASDU携带的是什么数据。网络103常用的类型标识包括:0x01 带时标的报文、0x02 带相对时间的报文、0x03 被测值、0x04 带时标的被测值、0x05 标识、0x06 带时标的标识、0x07 总召唤启动、0x08 总召唤结束、0x09 带时标的被测值(II型)、0x0A 通用分类数据、0x0B 通用分类标识、0x0C 通用分类命令、0x0D 通用分类命令确认、0x0E 遥控命令、0x0F 遥控命令确认、0x10 遥控命令终止等。注意不同厂家可能对类型标识有扩展,南瑞继保在通用分类服务里定义了自己的数据集,用于传输保护定值、故障报告等。如果你抓到一个类型标识为0x0A的ASDU,那大概率是通用分类数据,需要结合装置的点表来解析具体含义。可变结构限定词的最高位表示信息元素是否按顺序排列,低7位表示信息元素的数量。传送原因占1字节,常见值:0x03 突发、0x05 请求或被请求、0x06 激活、0x07 激活确认、0x08 停止激活、0x09 停止激活确认、0x0A 激活终止、0x14 响应站召唤、0x15 响应第1组召唤等。公共地址在103里通常等于链路地址,但有些实现会固定为0xFF或0x00。信息对象地址是2字节,低字节在前,高字节在后,对应装置内部的信号点号。
3.2 总召唤流程:从后台发起请求到装置上送全数据
总召唤是后台获取装置全部遥测遥信的标准流程。后台先发一个类型标识0x07的ASDU(总召唤启动),传送原因为0x06(激活),装置回一个0x07且传送原因为0x07(激活确认)的ASDU,然后装置开始逐个上送类型标识0x01、0x02、0x03、0x04等的数据ASDU,传送原因为0x14(响应站召唤)。全部数据送完后,装置发一个类型标识0x08的ASDU(总召唤结束),传送原因为0x0A(激活终止)。后台收到后,总召唤流程结束。这里容易踩的坑是:总召唤启动ASDU里的信息对象地址通常为0,但有些装置要求填特定的组号。南瑞继保的装置一般支持全局总召唤和分组总召唤,全局总召唤的信息对象地址为0,分组总召唤则填组号。如果你发全局总召唤装置没反应,试试分组召唤。另外,总召唤过程中如果后台不及时发链路层确认,装置可能会因为链路层超时而中断上送。下面这段代码演示了如何构造一个总召唤启动帧并解析装置返回的遥测ASDU。
def build_general_interrogation(link_addr: int, common_addr: int = 0xFF): """ 构造总召唤启动可变帧 link_addr: 链路地址 common_addr: 公共地址,南瑞继保常用0xFF或0x01 """ # ASDU: 类型标识0x07, 可变结构限定词0x81(顺序+1个元素), 传送原因0x06, 公共地址, 信息对象地址0x0000 asdu = bytes([0x07, 0x81, 0x06, common_addr, 0x00, 0x00]) L = len(asdu) + 2 # 控制域1 + 链路地址1 + ASDU # 控制域:主站发送,PRM=1, FCB=0, FCV=0, 功能码0x03(用户数据确认)?实际总召唤用可变帧,控制域功能码为0x03 # 103规约中可变帧的控制域功能码通常为0x03(用户数据) ctrl = 0x03 # 简化处理,实际需根据FCB/FCV翻转 frame = bytes([0x68, L, L, 0x68, ctrl, link_addr]) + asdu # 计算校验和:从控制域到ASDU末尾 checksum = sum(frame[4:]) & 0xFF checksum = (~checksum + 1) & 0xFF frame += bytes([checksum, 0x16]) return frame def parse_measure_asdu(asdu: bytes): """ 解析遥测ASDU(类型标识0x03或0x04) 返回信息对象列表 """ if len(asdu) < 6: return [] type_id = asdu[0] vsq = asdu[1] cot = asdu[2] common_addr = asdu[3] num = vsq & 0x7F results = [] offset = 4 for i in range(num): if offset + 2 > len(asdu): break ioa = asdu[offset] | (asdu[offset+1] << 8) offset += 2 if type_id == 0x03: # 被测值,通常2字节,归一化值或标度化值 if offset + 2 > len(asdu): break raw = asdu[offset] | (asdu[offset+1] << 8) offset += 2 # 归一化值:-1到1之间,公式 raw/32768 value = raw / 32768.0 results.append({"ioa": ioa, "raw": raw, "value": value}) elif type_id == 0x04: # 带时标的被测值,7字节时标 if offset + 2 + 7 > len(asdu): break raw = asdu[offset] | (asdu[offset+1] << 8) offset += 2 timestamp = asdu[offset:offset+7] offset += 7 value = raw / 32768.0 results.append({"ioa": ioa, "raw": raw, "value": value, "ts": timestamp.hex()}) return results构造总召唤帧时,控制域的值需要根据链路状态机的当前状态来设置。上面的代码简化了FCB/FCV的处理,实际实现中每次发送新帧要翻转FCB位。解析遥测ASDU时,注意类型标识0x03和0x04的区别:0x03不带时标,0x04带7字节时标。时标格式通常是毫秒、秒、分、时、日、月、年,但有些厂家会调整顺序。归一化值的转换公式是raw/32768,如果装置上送的是标度化值,则需要根据点表里的系数和偏移量换算。南瑞继保的装置在总召唤时上送的遥测值可能是归一化值,也可能是标度化值,取决于装置配置。如果你发现解析出来的值明显偏大或偏小,先检查点表里的量程定义。
3.3 遥控命令的报文交互:选择、执行、确认与超时
遥控是网络103里最需要小心处理的部分,因为它直接操作一次设备。遥控流程通常分为“选择”和“执行”两个阶段。后台先发遥控选择命令(类型标识0x0E,传送原因0x06激活),装置回遥控选择确认(类型标识0x0F,传送原因0x07激活确认),然后后台发遥控执行命令(类型标识0x0E,传送原因0x06激活,但信息元素里的状态不同),装置回执行确认(类型标识0x0F,传送原因0x07激活确认),最后装置可能发一个遥控终止(类型标识0x10,传送原因0x0A激活终止)。这里的关键参数是遥控命令ASDU里的信息元素:通常包括遥控命令类型(分/合)、输出状态、时间参数等。南瑞继保的遥控命令ASDU格式为:类型标识0x0E、可变结构限定词0x81、传送原因、公共地址、信息对象地址(2字节)、遥控命令(1字节,0x01分闸、0x02合闸)、输出状态(1字节)、时间参数(1字节,通常为0)。如果你发的遥控命令装置不回确认,先检查信息对象地址是否正确对应遥控点号,再检查公共地址是否匹配。另外,遥控选择和执行之间的超时时间一般设为30秒到60秒,超过这个时间装置会自动终止遥控流程,需要重新选择。我遇到过因为后台在遥控选择后等了太久才发执行,装置已经超时终止,但后台没收到终止报文,导致执行命令发出去后装置直接丢弃,后台却显示“遥控成功”的假象。所以调试遥控时,一定要在装置侧同时观察报文,确认每一步都有对应的确认帧。
4. 现场调试避坑:网络103规约排查的五个血泪教训
4.1 抓包工具默认解析错误导致误判链路断开
现象:Wireshark抓包显示大量“Malformed Packet”或“TCP Retransmission”,但装置面板通信灯正常闪烁。原因:Wireshark将TCP 2404端口默认识别为IEC 104规约,按104的APCI格式解析103的FT1.2帧,自然报错。解决:在Wireshark中右键任意2404端口报文,选择“Decode As” -> “TCP Port” -> “2404” -> “Data”或“Raw”,然后手动查看十六进制。或者直接用“Follow TCP Stream”看原始字节流,按0x10和0x68起始符人工分段。我习惯在抓包前先过滤“tcp.port==2404 && tcp.len>0”,然后导出为纯文本十六进制,用脚本解析,避免Wireshark的自动解析干扰。
4.2 链路地址不匹配导致总召唤无响应
现象:后台发总召唤,装置无任何回应,但TCP连接已建立。原因:后台配置的链路地址与装置实际地址不一致。南瑞继保的装置链路地址通常在装置参数里设置,默认可能是1,但有些工程会改成其他值。另外,如果中间有规约转换器,转换器可能会修改链路地址。解决:先用固定帧“请求链路状态”逐个链路地址试探,看哪个地址能收到确认。或者直接抓取后台与装置之间已有的正常通信报文,看链路地址字段的值。注意,有些装置在TCP连接建立后,如果收到的第一个帧的链路地址不对,会直接断开TCP连接,而不是回否定确认。
4.3 校验和计算错误导致帧被静默丢弃
现象:自己写的测试工具发送的帧,装置完全不回复,但用抓包工具看帧已经发出。原因:FT1.2的校验和计算错误。校验和是从控制域到ASDU最后一个字节的算术和取反加一,但有些实现要求校验和包含启动字符和长度域,有些则不包含。南瑞继保的装置通常按标准FT1.2计算,即从控制域开始。解决:先用一个已知正确的报文(比如从正常通信中抓到的)验证你的校验和函数。如果校验和错误,装置链路层会直接丢弃帧,不会返回任何错误信息,这就是“静默丢弃”,非常难排查。我一般会在代码里加一个校验和自检,用抓到的正常帧反推校验和算法。
4.4 总召唤过程中遥测值跳变或缺失
现象:总召唤上送的遥测值在后台显示为0或满量程,或者部分点号缺失。原因:ASDU长度域L计算错误导致解析偏移,或者信息对象地址与点表不对应。另外,如果装置上送的是带时标的被测值(类型标识0x04),而你的解析代码按不带时标(0x03)处理,就会把时标字节当成遥测值解析,导致数值异常。解决:先确认装置实际使用的类型标识,可以在抓包中看ASDU第一个字节。然后核对点表里的信息对象地址和量程系数。南瑞继保的装置在总召唤时可能只上送变化的数据,而不是全部数据,这取决于装置的总召唤配置。如果发现缺失,检查装置是否配置了“总召唤上送全部”还是“只上送变化”。
4.5 遥控执行后装置无动作但后台显示成功
现象:后台遥控操作返回成功,但一次设备没有动作。原因:遥控选择和执行之间的超时,或者遥控命令的信息元素格式不对。有些后台在收到选择确认后,如果执行命令发送延迟超过装置的超时时间,装置已经终止遥控流程,但后台可能没有正确处理终止报文,仍然显示成功。解决:在装置侧同时抓包,确认选择确认、执行确认、终止报文是否完整。另外,检查遥控命令里的输出状态字节,有些装置要求该字节为0x00,有些要求为0x01,不同版本有差异。最可靠的办法是拿一个已知能正常遥控的点,抓取正常操作的报文,逐字节对比。
5. 用Python写一个网络103解析器:从抓包文件到点表映射
5.1 用Scapy或原始socket提取TCP payload
如果你已经有抓包文件(pcap/pcapng),可以用Scapy提取TCP payload。Scapy的rdpcap函数可以读取pcap文件,然后遍历TCP流,按端口过滤。下面代码演示了如何从pcap中提取所有2404端口的TCP payload,并按FT1.2帧起始符分段。
from scapy.all import rdpcap, TCP import struct def extract_103_frames(pcap_path: str, port: int = 2404): """ 从pcap文件中提取网络103帧 返回帧列表,每个元素为bytes """ packets = rdpcap(pcap_path) frames = [] for pkt in packets: if TCP in pkt and (pkt[TCP].sport == port or pkt[TCP].dport == port): payload = bytes(pkt[TCP].payload) if not payload: continue # 按起始符分段,简单处理:假设每个TCP payload包含完整帧或帧的一部分 # 实际需要处理TCP流重组,这里简化演示 offset = 0 while offset < len(payload): if payload[offset] == 0x10: if offset + 5 <= len(payload): frames.append(payload[offset:offset+5]) offset += 5 else: break elif payload[offset] == 0x68: if offset + 2 <= len(payload): L = payload[offset+1] total = 4 + L + 2 if offset + total <= len(payload): frames.append(payload[offset:offset+total]) offset += total else: break else: break else: offset += 1 return frames这段代码的局限性在于没有处理TCP流重组。实际网络中,一个FT1.2帧可能被拆到多个TCP segment里,也可能多个帧合在一个segment里。生产环境建议用Scapy的TCPSession或者自己维护缓冲区。参数port默认2404,如果你的现场用了其他端口,需要修改。提取出的frames列表可以直接传给前面的parse_ft12_frame函数解析。
5.2 将解析结果映射到点表:信息对象地址与信号名的对应
解析出ASDU后,你需要一张点表来把信息对象地址(IOA)翻译成人类可读的信号名。点表通常由装置厂家提供,格式可能是Excel或CSV。南瑞继保的装置点表里,遥测、遥信、遥控、电度等分别有不同的IOA范围。比如遥测从0x4000开始,遥信从0x0001开始,遥控从0x6000开始。下面是一个简单的点表映射示例,用字典存储。
# 示例点表:IOA -> (信号名, 类型, 系数) point_table = { 0x0001: ("断路器位置", "遥信", 1), 0x0002: ("隔离开关位置", "遥信", 1), 0x4001: ("A相电压", "遥测", 0.1), 0x4002: ("B相电压", "遥测", 0.1), 0x4003: ("C相电压", "遥测", 0.1), 0x6001: ("断路器遥控", "遥控", 1), } def map_to_point(ioa: int, raw_value, type_id: int): """ 将IOA和原始值映射到点表 """ if ioa not in point_table: return {"ioa": hex(ioa), "name": "未知点", "value": raw_value} name, ptype, factor = point_table[ioa] if ptype == "遥测": value = raw_value * factor elif ptype == "遥信": value = "合" if raw_value else "分" else: value = raw_value return {"ioa": hex(ioa), "name": name, "type": ptype, "value": value}点表映射的关键是确认IOA的字节序。103规约里IOA是低字节在前,高字节在后,所以解析时用asdu[offset] | (asdu[offset+1] << 8)。如果你从厂家拿到的点表里IOA是十六进制字符串,注意转换。另外,遥测的系数可能不是简单的乘法,有些装置上送的是归一化值,需要先转成浮点数再乘量程。南瑞继保的装置在通用分类数据里还会用数据集索引来传输保护定值,那套映射更复杂,需要单独处理。
5.3 实时监听与自动应答:用socket实现一个简易主站
如果你想实时监听装置上送的数据,或者模拟主站发总召唤,可以用Python的socket库直接连接装置。下面代码演示了建立TCP连接、发送总召唤、接收并解析响应的最小实现。
import socket import time def simple_master(ip: str, port: int = 2404, link_addr: int = 1): """ 简易网络103主站:连接装置,发送总召唤,打印解析结果 """ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(5) try: sock.connect((ip, port)) print(f"已连接 {ip}:{port}") # 发送总召唤 frame = build_general_interrogation(link_addr) sock.send(frame) print(f"发送总召唤: {frame.hex()}") # 接收响应 buffer = b"" start_time = time.time() while time.time() - start_time < 10: try: data = sock.recv(4096) if not data: break buffer += data # 按帧解析 while len(buffer) >= 5: if buffer[0] == 0x10: if len(buffer) >= 5: ftype, fields = parse_ft12_frame(buffer[:5]) print(f"固定帧: {fields}") buffer = buffer[5:] else: break elif buffer[0] == 0x68: if len(buffer) >= 2: L = buffer[1] total = 4 + L + 2 if len(buffer) >= total: ftype, fields = parse_ft12_frame(buffer[:total]) if ftype == "variable": asdu = fields["asdu"] type_id = asdu[0] if type_id in (0x03, 0x04): points = parse_measure_asdu(asdu) for p in points: mapped = map_to_point(p["ioa"], p["value"], type_id) print(f"遥测: {mapped}") elif type_id == 0x08: print("总召唤结束") buffer = buffer[total:] else: break else: break else: buffer = buffer[1:] except socket.timeout: continue finally: sock.close() # 使用示例(请替换为实际IP) # simple_master("192.168.1.100", 2404, 1)这个简易主站没有处理链路层确认和FCB翻转,只适合在实验室环境快速验证。实际主站需要维护链路状态机,收到固定帧请求链路状态时要回确认,收到可变帧时要回用户数据确认。另外,总召唤结束后要发链路层确认。如果你用这个代码连现场装置,可能会因为缺少确认而导致装置断开连接。建议先用它来解析抓包文件,确认解析逻辑正确后,再逐步完善链路层。
5.4 解析结果验证:用已知报文做单元测试
写完解析器后,一定要用已知正确的报文做测试。你可以从现场抓一个总召唤的完整交互,把每个帧的十六进制保存下来,然后写单元测试验证解析结果。比如下面这个测试用例,用一个模拟的遥测ASDU验证解析函数。
def test_parse_measure_asdu(): # 模拟一个类型标识0x03的遥测ASDU:类型0x03, VSQ 0x81, COT 0x14, 公共地址0xFF, IOA 0x4001, 值0x1234 asdu = bytes([0x03, 0x81, 0x14, 0xFF, 0x01, 0x40, 0x34, 0x12]) points = parse_measure_asdu(asdu) assert len(points) == 1 assert points[0]["ioa"] == 0x4001 assert points[0]["raw"] == 0x1234 # 归一化值 0x1234 / 32768 = 0.142... assert abs(points[0]["value"] - 0x1234/32768.0) < 1e-6 print("测试通过") test_parse_measure_asdu()这个测试用例覆盖了IOA字节序和归一化值转换。实际项目中,我会把现场抓到的典型报文都做成测试用例,每次修改解析代码后跑一遍,确保不会引入回归错误。南瑞继保的装置在不同版本间可能有细微差异,比如时标格式、公共地址默认值等,用测试用例固化下来能省很多排查时间。
5.5 性能与稳定性:长时间运行时的内存和超时处理
如果你要把解析器做成长期运行的服务,需要注意内存管理和超时处理。TCP流重组时,缓冲区不能无限增长,要设置最大长度,超过就丢弃旧数据。socket接收要设置超时,避免阻塞。另外,链路层要定期发送链路状态请求,维持连接。我一般会用一个独立的线程做链路保持,每20秒发一次固定帧请求链路状态,收到确认后继续。如果连续3次没收到确认,就断开重连。解析线程和链路保持线程之间用队列传递数据,避免锁竞争。还有一点:南瑞继保的装置在TCP连接断开后,可能需要几秒钟才能重新接受连接,所以重连逻辑里要加退避,不要立即重试。
6. 从能解析到能诊断:用报文时间序列定位通信中断的根因
当你已经能稳定解析网络103报文后,下一步就是用它来诊断那些“时好时坏”的通信故障。这类故障最让人头疼,因为现场复现困难,后台日志往往只记录“通信中断”,没有细节。我的做法是:在后台和装置之间的交换机上做端口镜像,用tcpdump或Wireshark长时间抓包,然后把抓包文件丢给解析器,生成一份带时间戳的报文流水。流水里记录每个帧的发送方、帧类型、控制域功能码、ASDU类型、传送原因、IOA和值。然后按时间排序,看通信中断前最后几个帧是什么。常见的模式有三种:第一种,后台发总召唤后,装置回了几个遥测帧就没了,既没有总召唤结束,也没有链路层确认。这通常是装置CPU负载过高,或者某个遥测点采集阻塞导致上送卡死。第二种,后台和装置之间反复出现“请求链路状态”和“链路忙”,说明装置链路层缓冲区满了,可能是后台发送频率太高,或者装置处理不过来。第三种,TCP连接被RST复位,抓包能看到RST标志。这通常是中间网络设备(比如防火墙或网关)超时断开了空闲连接。针对第一种,可以在装置侧查看CPU负载和任务状态;第二种,降低后台的总召唤频率或增加链路层确认的及时性;第三种,在后台和装置上启用TCP Keepalive,或者让后台定期发链路状态请求维持连接。我自己的习惯是,每次现场调试完,把抓包文件和解析出的流水存档,标注日期和装置版本。下次再遇到类似问题,先翻历史记录,往往能快速定位。这个方案值不值得做?如果你负责的变电站有几十台南瑞继保装置,每次通信故障都靠猜和试,那花两天写个解析器绝对划算。如果你只是偶尔调试一台装置,用Wireshark手动看也够。希望帮到你。
本文还有配套的精品资源,点击获取