简介:面向电力自动化开发者的南自以太网103规约实战资料,基于IEC 60870-5-103改进而来,适用于变电站自动化、电网监控与配电通信场景,是设备互联的关键技术基础。包内共2个文件,包含ZIP压缩包及配套C++源码,整体约788KB,聚焦上位机如何实现规约收发,适合协议栈开发与调试人员参考。内容涵盖数据帧结构、服务类型与透明传输机制,并具体剖析报文构造、编码解码、序列号管理、错误检测与重传、连接维护和心跳报文等核心代码模块,帮助理解主站与子站间的信息交互过程。已有1455人学习,读者可借此快速掌握在以太网环境中调用103规约构建可靠通信应用的编码思路,从协议规范到代码落地均有参照;也可作为工程开发与调试的直接参考,对排查链路异常和报文错误很有帮助。
1. 南自以太网103规约是什么:变电站监控里的老功臣
做变电站综自系统的人,对“南自以太网103规约”这个名字一定不陌生。它是国电南自系列保护装置与后台监控系统之间的通信“母语”,本质上就是把IEC 60870-5-103规约的报文,从传统的串口链路搬到以太网上传输。103规约在电力行业里的地位有点像工控领域的Modbus——老、稳、到处都在用,而以太网化了之后,它既保留了103原生的信息体结构,又能走TCP/IP网络,不需要再拉RS485线。
这篇文章要解决的问题非常具体:你手里拿到一个南自的线路保护装置,想写一个上位机程序把遥信、遥测、SOE读出来,或者你刚接手一个老综自项目,后台数据库都建好了,但通信报文还是黑匣子,不知道从哪里下手。适合的读者是电力自动化方向的工程师、集成商调试人员,也包括想在毕业设计或技能竞赛里快速上手103规约的在校生。我会从报文结构讲到代码实现,最后把最容易翻车的几个点一次说清楚。
2. 以太网103规约的报文结构:从链路层到ASDU三层拆开看
2.1 串口103和以太网103的区别:变的是承载,不变的是芯
传统103规约跑在串口上,用的是FT1.2帧格式,一帧报文里有启动字符、控制域、地址域、用户数据、校验和。以太网103规约省掉了串口链路的物理层约束,直接把用户数据部分封装进TCP或者UDP报文里。换句话说,如果你已经理解串口103,那么以太网103的核心学习成本不在规约本身,而在“TCP流里怎么切帧”。
南自装置的以太网103普通采用客户端/服务器模型,后台监控作为客户端主动发起连接,装置作为服务器监听端口。连接建立后,链路层就变成“透明”的了,报文之间不需要像串口那样靠字节间间隔来分帧,而是依赖报文自身的长度字段。
2.2 以太网103的帧结构:长度字段是分帧的关键
抓一段南自以太网103的报文,最常见的结构大致如下:
68 0E 08 00 00 00 00 64 01 06 00 01 00 00 00 0068:启动字符,固定值0x68。0E:后面的字节长度(不含启动字符和长度字节本身)。08:控制域,0x08表示“确认/响应”类报文。00 00:地址域,这里通常是装置地址。- 后面跟的才是ASDU用户数据。
注意这里面最容易搞错的是长度计算。有些实现把长度定义为“从控制域开始到结尾的字节数”,有些则定义为“从地址域开始”,差两个字节。南自装置配的规约文档里会有明确说明,但我见过不少同行在写代码时忽略了这一点,导致解析出来的帧永远差两三个字节。建议拿到报文先数一遍,把长度含义验证清楚再写代码。
2.3 总召唤和链路启动:上位机连接后第一件该做的事
TCP连接建立之后,上位机不能干等装置上送数据,必须先发总召唤(C_IC_NA_1,ASDU类型7)请求装置把当前全站遥信、遥测状态一次性上送。这就像你新到一个班组,先让大家报一遍在岗情况,而不是等谁有事了才喊你。
常见的总召唤请求报文格式如下(十六进制):
68 0E 08 00 00 00 00 64 01 07 00 01 00 00 00 00控制域同样是0x08,ASDU类型07表示总召唤,64 01是公共地址(装置地址),后面跟着召唤限定词和序号。发送之后,装置会先回一个确认帧,然后开始按遥信、遥测的顺序把数据上送,最后发送一个结束帧。整个过程中上位机必须保持连接不中断,否则装置可能直接放弃本次召唤。
3. 上位机通信框架:C#做主站加Python报文解析的组合拳
3.1 语言选型:为什么不是LabVIEW也不是纯Python
先回答一个很多新手纠结的问题:“上位机用什么语言写?”如果你只做单台装置的调试和报文分析,Python足够;但如果要做一套能长期运行、界面稳定、还要对接数据库和画面系统的监控后台,C#是更常见的选型。C#写TCP通信顺手,WinForm或WPF做监控画面也成熟,而且跟PLC、保护装置通信的第三方库生态多。
LabVIEW在电力测试仪器领域用得也不少,但它更适合做波形分析和仪器控制,做规约解析和后台联动反而不顺手。我个人建议的搭配是:C#负责通信和界面,Python脚本负责离线解析抓包文件,两边各干各的活。
3.2 用C#写一个能连接南自装置的TCP主站
下面是一个最小可用的C# TCP客户端框架,能连上装置、发总召唤、收原始报文并做最简单的长度校验。
using System; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; public class Tcp103Master { private TcpClient _client; private NetworkStream _stream; private readonly object _lock = new object(); public async Task ConnectAsync(string ip, int port) { _client = new TcpClient(); await _client.ConnectAsync(ip, port); _stream = _client.GetStream(); Console.WriteLine($"[INFO] 已连接 {ip}:{port}"); } public void SendTotalCall() { // 总召唤报文:68 0E 08 00 00 00 00 64 01 07 00 01 00 00 00 00 byte[] frame = new byte[] { 0x68, 0x0E, 0x08, 0x00, 0x00, 0x00, 0x00, 0x64, 0x01, 0x07, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00 }; Send(frame); } private void Send(byte[] data) { lock (_lock) { _stream.Write(data, 0, data.Length); Console.WriteLine($"[SEND] {BitConverter.ToString(data)}"); } } public byte[] ReceiveFrame() { // 先读启动字符和长度字节 int first = _stream.ReadByte(); if (first != 0x68) return null; int len = _stream.ReadByte(); byte[] buffer = new byte[len]; int read = 0; while (read < len) { int n = _stream.Read(buffer, read, len - read); if (n == 0) break; read += n; } if (read != len) { Console.WriteLine("[WARN] 半包,长度不足"); return null; } Console.WriteLine($"[RECV] 68 {len:X2} {BitConverter.ToString(buffer)}"); return buffer; } }这段代码做了三件事:建立TCP连接、发送总召唤、按“68 + 长度”方式切帧接收。ReceiveFrame()的循环读取很关键,因为TCP流不存在“一次Recv正好一帧”这种好事,必须按长度凑满才算完整。你没做这个处理就解析报文,大概率会遇到解析到一半就报长度不对的玄学问题。
实际工程里还需要处理断线重连、心跳检测和异常退出,这些代码量不大但直接影响现场稳定性。一套完整的C#主站框架至少还要再加一个后台接收线程、一个事件队列和一个定时重连器。
3.3 用Python快速验证上一段收到的报文
现场调试时,我习惯在笔记本上跑一个Python脚本,把抓到的报文离线解析,看ASDU类型和公共地址对不对。下面的脚本按以太网帧格式做一层简单解析:
import struct def parse_frame(data: bytes): if len(data) < 7: return None if data[0] != 0x68: print("启动字符错误") return None length = data[1] if len(data) != length + 2: print(f"长度不匹配: 实际{len(data)-2}, 声明{length}") return None ctrl = data[2] addr = data[3:5] asdu_type = data[7] if len(data) > 7 else None print(f"控制域: 0x{ctrl:02X}, 地址: {int.from_bytes(addr, 'little')}, ASDU类型: {asdu_type}") return asdu_type # 示例报文 frame = bytes.fromhex("68 0E 08 00 00 00 00 64 01 07 00 01 00 00 00 00") parse_frame(frame)注意这里ASDU类型是从第7个字节取的,因为以太网帧的0~6字节分别是启动字符、长度、控制域、两个地址字节、两个ASDU类型前的固定字节。不同的厂家实现可能有细微差别,解析前最好对照规约文档确认偏移量。脚本本身不复杂,但作为“报文翻译器”在现场调试时能省不少时间。
4. 把ASDU翻译成测点数据:总召唤流程与遥信遥测对位
4.1 总召唤流程:从请求到结束的三段式交互
总召唤不是发一帧就完事,整个交互过程分为三个阶段。首先上位机发总召唤请求,装置收到后回一个“确认帧”表示收到指令;接着装置开始刷数据,依次上送遥信、遥测等ASDU报文;最后发送一个“召唤结束帧”,告诉上位机数据发完了。
| 阶段 | 报文方向 | ASDU类型 | 说明 |
|---|---|---|---|
| 召唤请求 | 主站→装置 | 7 (C_IC_NA_1) | 请求全量数据 |
| 数据上送 | 装置→主站 | 1 (M_SP_NA_1) 等 | 遥信、遥测实时值 |
| 召唤结束 | 装置→主站 | 8 (C_IC_NA_1 确认) | 结束标志 |
如果上位机在收到结束帧之前断了连接或者报文超时,数据库里的点号会缺数据。这也是为什么现场调试时经常出现“画面有一部分遥信有值,另一部分是灰色”——多半就是召唤过程被打断,而且没有重发的机制。好的实现应该在收到结束帧之前持续等待,并在超时后重新发起召唤。
4.2 单点遥信解析:比特位和字节的对应关系
单点遥信ASDU类型是M_SP_NA_1(类型ID 1),它的信息体比较紧凑,每个遥信点只占用很少的字节。以太网103的遥信报文里,信息对象标识符(IOA)从1开始编号,后面跟着一个描述状态的字节,最低位为0表示分闸,为1表示合闸。
下面是一段解析遥信报文的Python代码:
def parse_single_point(data: bytes): # 假设data是从ASDU部分开始的完整信息体 if len(data) < 3: return None ioa = data[0] | (data[1] << 8) status = data[2] & 0x01 quality = data[2] & 0xFE print(f"遥信点: IOA={ioa}, 状态={'合' if status else '分'}, 品质=0x{quality:02X}") return ioa, status, quality # 示例:IOA=12, 合位, 品质0x00 parse_single_point(bytes([0x0C, 0x00, 0x01]))品质描述字节里面的位是有讲究的:0x01是状态位,0x02是保留位,0x04是取代标志,0x08是闭锁标志,0x10是溢出标志,0x20是无效标志。品质位为0才表示数据有效,如果品质字节是0x21,说明数据无效且被取代,后台画面如果显示这种状态,不要怀疑是通信坏了,先检查装置本身有没有闭锁或检修压板投入。
4.3 遥测报文解析:三个字节转一个浮点数
南自装置上送遥测时,常见的信息体格式是每个测点占三个字节,分别表示数值的低字节、高字节和品质描述字节。值部分通常是有符号数,单位由后台数据库定义——规约本身不传输单位,所以对位时千万不能搞错。
import struct def parse_measure(data: bytes): # 三个字节: value_low, value_high, quality if len(data) < 3: return None raw = data[0] | (data[1] << 8) if raw >= 0x8000: raw -= 0x10000 # 转为有符号数 quality = data[2] print(f"遥测值: raw={raw}, 品质=0x{quality:02X}") return raw, quality这里最容易踩的坑是符号扩展。很多新手直接把两个字节拼成uint16,结果负的遥测值变成65535或者65534这种天文数字。记得判断最高位,是1就按补码转换。品质字节和遥信的品质定义基本一致,调试时看到“品质0x20”基本可以判断测点数据超出量程或装置未启动。
5. 避坑:以太网103上位机开发的5个高频翻车点
5.1 TCP粘包导致解析错位
现象:连着装置跑几个小时后,程序突然发疯,解析出来的IOA乱跳,有时一帧报文的长度字段变成了0x00,然后整条链路越错越远。
原因:TCP是流协议,装置可能在一次send里塞进两帧报文,也可能半帧就到达。很多新手在一个Receive里直接解析,没有循环凑长度,粘包后长度错位就再也无法对齐。
解决:必须按“读启动字符→读长度→读满长度”的状态机处理字节流。每次收到数据先缓存到一个队列,然后在缓存里不停找0x68并校验长度,长度够了再切出来一帧。我一般会把切帧逻辑单独写一个类,方便复用。
5.2 公共地址写错导致装置不响应
现象:总召唤发出去后,装置完全无响应,抓包看TCP连接完全正常,但就是没有一帧数据回来。
原因:装置里设置的公共地址(装置地址)和上位机发送报文里的地址不一致,比如装置里设的是0x0001,上位机却发了0x6401(十进制10001)。
解决:连接前先确认装置的“装置地址”参数,而不是猜。部分南自装置的地址是十六进制显示的,后台配置里的地址可能是十进制,两个数换算不对就会出现这种“黑匣子”现象。现场血泪经验:地址问题能在开工前用串口调试助手直接验证,别等到后台建完库再查。
5.3 召唤超时设置太短
现象:后台画面偶尔出现大片灰色遥信,刷新一下又恢复了,但过程曲线里有一段时间的“坑”。
原因:总召唤过程中装置要逐个点位上送,如果点位多(比如几百上千个),数据上送需要几秒到十几秒。上位机如果设置了3秒超时就直接断开,召唤永远走不完。
解决:总召唤超时建议设10~30秒,数据等待期间每次收到任意一帧就刷新计数器,而不是从召唤开始一次性倒计时。判断召唤结束的唯一标准是收到结束帧,不是时间到。
5.4 装置重启后不自动重发遥信
现象:装置重启或检修后,后台画面上所有遥信保持旧状态,和实际开关位置对不上。
原因:103规约是问答式为主的上送模式,装置重启后不会自动把全量状态上送给你,必须上位机重新发总召唤才能刷新。
解决:在上位机上做一个周期性的总召唤任务,比如每5~10分钟自动召唤一次,或者在检测到装置断开重连后主动补召一次。这样即使装置中途重启,后台也能在下一轮召唤后恢复正确状态。
5.5 SOE时间标签时区处理不同
现象:SOE事件报文里的时间,比后台显示的时间早8个小时,或者反过来。
原因:装置的时钟芯片走的是UTC,而后台默认用本地时间(东八区)显示,规约里又没有明确的时区字段。
解决:解析SOE时间戳的时候,把它当作UTC时间,显示前加上本地时区偏移。常见做法是解析成DateTime后统一转Unix时间戳,再在界面层转回本地时间。不要在上位机数据层写死8小时偏移,否则跨时区部署时又要翻车。
6. 上现场前先练一遍:最小模拟装置帮你验证整条链路
在没有真实装置的时候,一个Python脚本就能模拟南自装置做链路验证。下面这段代码监听本地端口,收到总召唤后模拟两个遥信点和一个遥测点的上送:
import socket import time HOST = '0.0.0.0' PORT = 5001 def build_single_point(ioa, state): # ASDU: 类型1 + 信息体地址 + 状态品质 asdu = bytes([0x01, 0x00]) + ioa.to_bytes(2, 'little') + bytes([state]) length = len(asdu) + 6 # 控制域+地址+ASDU固定头 return bytes([0x68, length, 0x08, 0x00, 0x00, 0x64, 0x01]) + asdu srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.bind((HOST, PORT)) srv.listen(1) print(f"[模拟器] 监听 {PORT} 端口...") conn, addr = srv.accept() print(f"[模拟器] 收到连接: {addr}") while True: data = conn.recv(1024) if not data: break # 简单判断,不拆帧,只确认收到总召唤后发数据 if data[7] == 0x07: print("[模拟器] 收到总召唤,上送遥信和遥测") conn.send(build_single_point(1, 0x01)) conn.send(build_single_point(2, 0x00)) time.sleep(0.2) conn.send(build_measure(1, 1234)) conn.close() break这个模拟器不追求完整实现,目的是把“连接→召唤→上送→结束”的过程串起来。我每次现场调试前都会先跑一遍模拟器,确认上位机的切帧逻辑、超时重召和品质位处理都正确,再去接真装置。这样能避免很多“现场网络有问题”的假象——很多所谓的通信故障,其实是上位机自己的解析逻辑不过关。
希望这篇文章能帮你把南自以太网103规约这条路走通。我的习惯是:先通帧、再对点、最后再调界面,顺序不要反。祝调试顺利。
本文还有配套的精品资源,点击获取