简介:面向电力自动化上位机软件开发与调试场景,一份围绕南自电气在 IEC 60870-5-103 基础上扩展的以太网103规约资料包,同时提供协议说明文档与可参考的 C++ 实现,覆盖变电站自动化、电网监控、配电自动化等场景下的设备通信与调试需求。包内共2个文件,包含1个 zip 规约资料包和1个 cpp 源程序文件,整体约788KB。资料包系统梳理了103规约的数据帧结构、服务类型、透明传输机制,并结合南自以太网环境说明其在传输效率与可靠性上的优化思路;cpp 代码则针对报文构造、数据编码与解码、序列号管理、错误检测与重传、连接管理、心跳报文等关键点给出可运行示例,便于对照协议文档逐行理解,也可作为二次开发的框架基础。目前已有1455人学习下载,适合具备一定 C++ 或网络编程基础、需快速掌握电力通信协议并构建上位机应用的中高级研发人员。
1. 拿到南自以太网103规约及上位机代码.zip,先别急着解压,要知道你在跟什么打交道
变电站调试现场经常遇到这种场景:后台监控电脑上只有这一个压缩包,没有厚厚的规约手册,也没有厂家远程支持。包里的东西概括起来就三样——南自系列的以太网103规约说明、一套示例上位机代码,以及用这套代码把保护装置接入后台的联调思路。它的价值不是“开箱即用”,而是让你在没有原厂支持的情况下,靠抓包和读代码把遥信、遥测、SOE和遥控这条链路打通。适合继保调试工程师、正在做C#上位机开发的人,以及想系统理解电力103规约的入门者。先说个反直觉结论:这类包里的代码八成是“示例级”的,直接打开运行大概率卡在“TCP连上了却收不到数据”,问题不在网络,而在地址域和帧格式。
2. 先把规约立住:以太网版103与串口版103的差异,帧与ASDU如何解析
2.1 以太网103规约的两层结构:链路层帧和ASDU,抓包前先理清边界
IEC 60870-5-103原本是为继电保护设备和监控后台之间的信息交换设计的规约,早期跑在串行链路上,一个口子对应一条线,报文边界靠帧头和校验和确定。南自的以太网103规约则是把同一套链路层和应用层内容搬到了TCP或UDP上,工程里最常见的是TCP长连接。这里有个容易犯的错:觉得“以太网103就是104规约”或“103报文和串口报文长得一模一样”。实际上103的应用层ASDU体系和104完全不同,而以太网承载方式又不改变ASDU的内容,只是把链路层的字节流塞进TCP包里。
链路层需要处理的是帧同步和校验。以太网103抓包里最常见两种帧形:一种以0x10开头、0x10结尾的短帧,控制域、地址域、用户数据和校验和夹在中间;另一种以0x68开头,后面跟着长度字段,适合承载较长的ASDU报文。理解这一点之后,抓包时就不会拿着0x68当头去硬找0x10结尾。帧结构可以粗略表示成下面这样:
| 位置 | 字段 | 长度 | 说明 |
|---|---|---|---|
| 帧头 | 启动字符 | 1字节 | 0x10或0x68,0x68长帧后带长度字节 |
| 控制域 | C | 1字节 | 传输方向、启动/确认/结束位等 |
| 地址域 | A | 2字节 | 低字节在前,常见65535代表广播地址 |
| 用户数据 | DATA | 变长 | 应用层控制域 + ASDU |
| 校验和 | CS | 1字节 | 控制域、地址域与用户数据累加后取低8位 |
| 帧尾 | 结束字符 | 1字节 | 0x10,部分长帧用0x16 |
以太网103的应用层核心是ASDU,也就是应用服务数据单元。ASDU里带着类型标识、传送原因、公共地址、功能类型FUN和信息序号INF,这些字段直接决定你收到的报文是“遥控执行确认”还是“遥测带时标”。
2.2 总召唤、遥信变位、SOE与遥测:ASDU类型标识和INF点表怎么对
调试上位机代码时,第一步不是写界面,而是先摸清这个包用到了哪些ASDU类型标识。常见做法是打开抓包文件,统计所有报文的类型标识字节。下面是绝大多数以太网103实现里都能见到的类型:
| 类型标识 | 用途 | 传输方向 |
|---|---|---|
| 1 | 总召唤 | 主站 → 从站 |
| 2 | 带时标的事件报文(遥信变位/SOE) | 从站 → 主站 |
| 3 | 带时标的遥信 | 从站 → 主站 |
| 9 | 带时标的遥测 | 从站 → 主站 |
| 10 | 不带时标的遥测 | 从站 → 主站 |
| 20 | 遥控选择 | 主站 → 从站 |
| 21 | 遥控执行 | 主站 → 从站 |
但这里有个必须强调的边界:103规约允许厂家在标准类型上扩展,不同装置对功能类型FUN和信息序号INF的编排是不一样的。比如总召唤在很多实现里是FUN=0xFF、INF=0x00,但有的装置会用别的INF值。所以包里那份规约说明才是最终依据,你在代码里看到“魔法数”时,一定要返回到点表去核对,而不是默认所有厂家都用同一套。
调试时把总召唤的交互流程背下来,能省一半时间:上位机发类型标识1、传送原因6的激活帧,装置回一帧传送原因7的确认帧,然后开始上送遥信遥测报文,最后以传送原因10的终止帧收尾。如果你看到确认帧却一直等不到数据帧,问题多半在FUN/INF或公共地址配置;如果确认帧都没有,先查链路层地址和TCP连接。
2.3 用Wireshark抓一次完整总召唤交互,验证规约理解是否正确
我一般会把“抓包”排在“读代码”前面,因为代码里的解析逻辑写得再清楚,也不如亲眼看到一帧帧真实报文来得直接。连接方式按顺序来:先用网线把电脑和保护装置接到同一个交换机上,或者直接网线对连;Wireshark选择对应网卡,过滤条件写成tcp.port==2404或你工程里实际用的端口。然后在上位机上点一次总召唤按钮,同时开始抓包。
抓包结果里应该能看到几类特征:上行短帧以0x10开头,控制域为“主站→从站建立活动”之类的值,地址域两个字节,ASDU类型标识为1;装置回包以0x68开头或0x10开头,长度明显比上行帧大,里面可以找到类型标识2和9的报文,最后有一个类型标识1但传送原因不同的终止帧。验证要点有两个——长度字段是否把自身和校验和算进去了,以及地址域到底填的是公共地址还是装置地址。这两个点看代码时容易忽略,但报文的字节序列不会骗你。
3. 把上位机代码跑起来:C#工程常见分层,总召唤与解析的关键函数
3.1 常见工程结构:网络层、规约解析层、界面层各管一摊,别在一个按钮里写完所有逻辑
打开这类上位机代码包,最常见的工程形态是C# WinForm,也有一些老项目用C++ MFC或VB.NET。不管哪种语言,能落地的代码基本都遵守同一个分层:网络层管TCP连接、收包、按帧边界切包;规约解析层管校验和、ASDU拆解、遥信遥测点表映射;界面层只做绑定和刷新。三层混在一起是新的上位机开发最容易翻车的地方——在Form_Load里直接开同步接收循环,界面假死到连窗口都拖不动。
接收线程的设计要特别留意。TCP是字节流,没有消息边界,网络上的一包数据可能装着半帧103报文,也可能一次带来三帧完整报文。所以网络层一定要有缓冲区,要把“接收字节”和“拆帧”两个动作分离。核心逻辑用C#写出来大致是这样:
public class Eth103Client : IDisposable { private TcpClient _tcp; private NetworkStream _stream; private readonly List<byte> _buffer = new List<byte>(); private CancellationTokenSource _cts = new CancellationTokenSource(); // 拆出一帧完整报文后向外抛事件,界面层订阅这个事件刷新表格 public event Action<byte[]> FrameReceived; public async Task ConnectAsync(string ip, int port) { _tcp = new TcpClient(); await _tcp.ConnectAsync(ip, port); // 异步连接,避免卡界面 _stream = _tcp.GetStream(); _ = Task.Run(ReceiveLoop); // 后台线程持续读流 } private void ReceiveLoop() { byte[] chunk = new byte[2048]; try { while (!_cts.IsCancellationRequested && _tcp.Connected) { int n = _stream.Read(chunk, 0, chunk.Length); if (n <= 0) break; lock (_buffer) { _buffer.AddRange(chunk.Take(n)); // 收到的字节先入缓冲区 TryExtractFrames(); // 再按帧边界切分 } } } catch (IOException) { // 对端断开或网线拔出,触发上层重连逻辑 } } }这里有两个参数值得注意:chunk数组的2048字节是一次Read最多拿到的数据量,以太网103的报文通常不超过几百字节,2048足够;lock (_buffer)保证了后台读线程和拆帧逻辑不会同时修改缓冲区,否则会出现拆出“半个帧”的灵异问题。接收线程里不要直接操作DataGridView,这是C#上位机开发的经典血泪经验,线程里改界面控件会让程序随机崩溃。
3.2 组总召唤帧:手工拼字节不如写个FrameBuilder,地址字段最容易错
总召唤看起来就是发一帧短报文,但真实实现里有几个坑:控制域的启动/确认位含义、公共地址和装置地址的关系、以及校验和到底从哪个字节开始累加。下面这段是我习惯用的组帧方式,用列表先拼用户数据,最后统一算校验和:
public byte[] BuildGeneralCall(ushort commonAddr) { // ASDU部分:类型标识1=总召唤,可变结构限定词0x81表示1个信息对象 // 传送原因0x06=激活,公共地址两个字节低字节在前 List<byte> asdu = new List<byte>(); asdu.Add(0x01); // 类型标识:总召唤 asdu.Add(0x81); // 可变结构限定词 asdu.Add(0x06); // 传送原因:激活 asdu.Add((byte)(commonAddr & 0xFF)); // 公共地址低字节 asdu.Add((byte)(commonAddr >> 8)); // 公共地址高字节 asdu.Add(0xFF); // FUN,多数实现用255表示全局功能 asdu.Add(0x00); // INF,总召唤的信息序号 asdu.AddRange(new byte[] { 0, 0, 0, 0, 0, 0 }); // 6字节时标占位 List<byte> frame = new List<byte>(); frame.Add(0x10); // 启动字符 frame.Add(0x53); // 控制域:主站发起,具体值以规约说明为准 frame.Add((byte)(commonAddr & 0xFF)); // 链路层地址低字节 frame.Add((byte)(commonAddr >> 8)); // 链路层地址高字节 frame.AddRange(asdu); // 校验和:控制域+地址域+用户数据,单字节累加取低8位 int sum = 0; for (int i = 1; i < frame.Count; i++) sum += frame[i]; frame.Add((byte)(sum & 0xFF)); frame.Add(0x10); // 结束字符 return frame.ToArray(); }需要说清楚的是代码里的两处“地址”:ASDU里的公共地址和链路层地址域。很多装置的公共地址就是装置地址,两者相等,但这不是硬性规定。我见过一个现场,链路层地址用65535广播,ASDU公共地址却写具体装置号,两边不一致导致总召唤无响应。所以调试时要把这两个字段分开设,不要想当然用同一个变量。
另外,0x53这个控制域是我在多个工程里见过的主站启动值,但它不是103规约里唯一的写法。不同厂家的链路层控制域编码有差异,有些用0x49,有些用0x73,正确值一定要对照包里的规约说明。代码里的“魔法数”全部要留注释,不然三个月后自己都看不懂。
3.3 解析遥测帧:有符号还是无符号、带不带时标、要不要乘系数
收到遥测后,代码要做的是从ASDU里抽出信息对象,还原成可显示的数值。类型标识9的ASDU每个信息对象通常包含FUN、INF和两字节数据,但不同厂家可能追加时标或质量描述字节。解析函数写得太“脆”,遇到多一个字节就会整体错位。下面是兼容性较好的解析骨架:
public class AnalogPoint { public int Fun { get; set; } public int Inf { get; set; } public short Raw { get; set; } // 原始两字节值 public double Scaled { get; set; } // 乘系数后的工程值 } public List<AnalogPoint> ParseMeasurement(byte[] asdu, int start, int vsq) { List<AnalogPoint> result = new List<AnalogPoint>(); int p = start; for (int i = 0; i < (vsq & 0x7F); i++) // 可变结构限定词低7位是信息对象数 { AnalogPoint point = new AnalogPoint(); point.Fun = asdu[p++]; point.Inf = asdu[p++]; // 按低字节在前拼short,并保留符号位 point.Raw = (short)(asdu[p] | (asdu[p + 1] << 8)); p += 2; // 如果ASDU类型标识是9,这里通常还有6字节时标 // 工程里以类型标识和规约说明为准,决定是否p += 6; result.Add(point); } return result; }这段代码里最值得思考的是(short)强转。遥测在规约里通常定义为二进制补码,正数正常显示,负数代表反向功率之类的物理量。如果你用ushort解析,负数的满码值会显示成一个巨大的正数。接着是系数问题:遥测的原始值是二次侧数值或码值,需要乘以CT变比、PT变比换算成一次值。每个遥测点有自己的系数,最稳妥的做法是在界面层维护一张“点号→系数”的映射表,解析时只保留原始值,显示时再乘系数。
界面层刷新也有讲究。收到报文后通过FrameReceived事件抛出来,界面线程用BeginInvoke更新表格行。BeginInvoke是异步的,不会因为界面卡顿阻塞接收线程,这是很多C#上位机在长时间运行时保持稳定的关键。我自己写代码时还会给每个遥测点记录“时间戳”,显示最后刷新时间,排查“某个点不刷新”这类问题时非常有帮助。
4. 联调避坑:端口、公共地址、时标与报文缓存四个常见问题排查
4.1 现象:TCP能连上但总召唤无响应,先核对公共地址和功能号
现场最常见的情况是:Telnet或socket测试显示TCP端口通,上位机一直发总召唤,装置就是不理你。问题几乎不在网络,而在应用层的“身份对不上”。我在一个35kV变电站遇到过类似情况,排查完后发现问题出在公共地址上——上位机默认发0xFFFF,装置却只响应自己的装置号。
原因是103规约的地址域和ASDU公共地址需要两端一致,而很多示例代码为了图省事直接写死地址。解决方法是抓包看装置上电后有没有主动上送任何报文,如果有,直接解析它的公共地址字段照抄过去;如果没有,把公共地址从65535逐步改成装置面板上显示的地址。还有一个点别忽略:总召唤的FUN和INF必须和装置点表一致,某厂家用FUN=0xFF、INF=0x00,另一个厂家可能用0xFE、0x01。排错顺序是:TCP通不通→链路层地址对不对→ASDU公共地址对不对→FUN/INF对不对。
4.2 现象:遥测全为0或全满码,符号位和字节序先查一遍
上位机界面出来了,总召唤也有响应,但遥测值明显不对,要么全部是0,要么全部是32767或65535。这个现象十有八九是解析层的问题而不是装置的问题。先做一步“原始值验证”:把收到的遥测点原始字节直接以十六进制打印到日志窗口,对比装置液晶屏显示值。
全0的原因通常是信息对象序号没对上,解析到了空点;全满码的原因则集中在两点——有符号数被当成无符号数解析,或者字节序反了。103链路层地址基本是低字节在前,但ASDU内部的数据区不一定,某些厂家数据区用高字节在前。解决方法是准备一个“字节序切换”的配置项,不要写死在代码里。另外,如果遥测值看着是“正常但偏大100倍”,是缺少变比系数;如果“所有点都偏大固定值”,先检查是不是把时标字节当数据解析了,这类错位问题在ASDU里非常隐蔽。
4.3 现象:SOE时间差8小时或精度只到分钟级,时标踩坑实录
SOE报文的时间字段在以太网103里通常是6字节,顺序常见为毫秒低字节、毫秒高字节、分钟、小时、日、月、年。第一位踩坑就是把毫秒两个字节拆开处理,或者干脆丢弃,导致事件时间只能对到分钟级。对继保调试来说,SOE精度到毫秒是硬需求,差几十毫秒就可能影响故障反演结论。
第二位踩坑是时区。有的后台系统把SOE时间当成UTC入库,显示时忘了加8小时,界面上所有事件整齐地差8小时,看起来像全体设备时钟没对。解决方法是入库前统一转成本地时间,并在日志里同时记录原始时标和解析后时间。第三位踩坑是年份处理,很多装置的年份只给两位,默认加2000,遇到跨世纪的老装置要特别留意。我习惯写一个专门函数解析SOE时标,单独做单元测试,因为它在定位故障时太重要。
4.4 现象:报文偶发“校验和错误”,多半是TCP粘包半包处理不完整
一帧103报文被TCP拆成两次发送,接收端在缓冲区里只等到了前半段,这时候如果直接找结束字符,会把后半段的数据当成新帧头,于是接二连三地报“校验错误”。这不是玄学,而是字节流通信的必然情况。
解决思路是维护一个接收缓冲区,用状态机的思路处理:第一个字节决定帧类型,如果是0x10短帧,找到下一个0x10当作结束;如果是0x68长帧,先读长度字段,再等待数据凑满。我在这类代码里从不依赖“一次Read一定拿到完整帧”这种假设。用前面3.1小节的缓冲切帧逻辑,能把绝大多数粘包半包问题消化掉。还有个细节:校验和是累加后取低8位,不是取反,也不是CRC,很多网上流传代码在这一点上写错了,连装置都回错,接的时候也校验不过。
4.5 现象:装置重启后上位机再也不刷新,缺的是重连和周期总召唤机制
上位机跑了一晚上,第二天早上界面数据还停留在昨晚,装置没关机,网络也没断,但就是再也不报数据。这在103链路里很常见,因为从站不会主动维持应用层会话,而主站的接收线程如果没感知到连接断开,就会一直等。
解决方法是两层配合:网络层检测TCP断开后自动重连;应用层每隔一段时间发送一次总召唤或周期遥测召唤。重连间隔设在5到10秒比较合适,太短会让装置日志里刷满连接记录,太长则影响恢复速度。重连成功后必须重新发一次总召唤,否则装置不会补发缓存的事件,SOE照样收不到。这个“重启后失联”的坑,几乎每个上位机项目都会遇到一回。
5. 收尾:把现场报文录下来做离线回放,解析器不连设备也能自测
5.1 用tshark导出TCP载荷,转成十六进制帧日志喂给解析器
我调试这类规约代码时,最常用的自测手段是“回放”——把现场抓的报文存成文件,让解析器离线逐帧处理,和连真实装置调试完全等价。抓包时用Wireshark保存pcapng,然后用tshark导出TCP payload:
tshark -r capture.pcapng -Y "tcp.port==2404" -T fields -e tcp.payload > tcp_payload.txt导出的内容是十六进制、冒号分隔的字符串,一行代表一个TCP包。用Python脚本把冒号去掉,整理成一行一帧的日志文件,同时做帧边界切分:
import re frames = [] with open("tcp_payload.txt", encoding="utf-8") as f: for line in f: payload = line.strip() if not payload: continue hex_str = payload.replace(":", "") # 去掉冒号分隔符 frames.append(hex_str) with open("frames_hex.log", "w", encoding="utf-8") as out: for item in frames: out.write(item + "\n")脚本里最关键的参数是端口号2404,它决定过滤哪些报文。如果你工程里用的是别的端口,改这里就好。生成后的frames_hex.log可以直接喂给上位机的规约解析层,让解析层像从TCP读取一样逐帧处理。这样不连装置也能验证解析器对真实报文的兼容性,还能把“现场问题”带回来复现。
5.2 回放验证时重点看三处:类型标识覆盖、SOE毫秒、遥控帧成对性
离线回放时不要只看“日志没报错”就结束。我会做三个检查:第一,统计回放文件里出现的所有ASDU类型标识,和上位机代码里支持的解析分支对照,看有没有漏掉的类型,这一步能发现“界面上少了一列数据是因为解析器根本不认识这种帧”;第二,抽出所有SOE事件,逐个检查毫秒字段是否为0,如果全是0,说明装置时标没传或解析层丢弃了毫秒;第三,检查遥控选择帧和执行帧是否成对出现,只选不执行或只执行没选择,都是逻辑缺陷。
这套回放习惯帮我省了大量现场时间。我早期做这类上位机时不太会抓包,上来就写界面,结果在现场对着报文一行行改解析,一个时标丢毫秒的问题花了一整天。后来养成了“先抓包、后写解析、再连设备”的顺序,把解析器当成一个要过回放测试的模块来做,联调时间被压缩到半小时以内。如果你正打算用这个包做自己的上位机,建议也按这个顺序走,会少踩很多坑。希望帮到你。
本文还有配套的精品资源,点击获取