
简介面向电力自动化开发者的南自以太网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_1ASDU类型7请求装置把当前全站遥信、遥测状态一次性上送。这就像你新到一个班组先让大家报一遍在岗情况而不是等谁有事了才喊你。常见的总召唤请求报文格式如下十六进制68 0E 08 00 00 00 00 64 01 07 00 01 00 00 00 00控制域同样是0x08ASDU类型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个字节取的因为以太网帧的06字节分别是启动字符、长度、控制域、两个地址字节、两个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 # 示例IOA12, 合位, 品质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秒超时就直接断开召唤永远走不完。解决总召唤超时建议设1030秒数据等待期间每次收到任意一帧就刷新计数器而不是从召唤开始一次性倒计时。判断召唤结束的唯一标准是收到结束帧不是时间到。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规约这条路走通。我的习惯是先通帧、再对点、最后再调界面顺序不要反。祝调试顺利。本文还有配套的精品资源点击获取