ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

以太网103规约上位机调试:从规约原理到C#代码实战

以太网103规约上位机调试:从规约原理到C#代码实战 简介面向电力自动化上位机软件开发与调试场景一份围绕南自电气在 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或0x680x68长帧后带长度字节控制域C1字节传输方向、启动/确认/结束位等地址域A2字节低字节在前常见65535代表广播地址用户数据DATA变长应用层控制域 ASDU校验和CS1字节控制域、地址域与用户数据累加后取低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的编排是不一样的。比如总召唤在很多实现里是FUN0xFF、INF0x00但有的装置会用别的INF值。所以包里那份规约说明才是最终依据你在代码里看到“魔法数”时一定要返回到点表去核对而不是默认所有厂家都用同一套。调试时把总召唤的交互流程背下来能省一半时间上位机发类型标识1、传送原因6的激活帧装置回一帧传送原因7的确认帧然后开始上送遥信遥测报文最后以传送原因10的终止帧收尾。如果你看到确认帧却一直等不到数据帧问题多半在FUN/INF或公共地址配置如果确认帧都没有先查链路层地址和TCP连接。2.3 用Wireshark抓一次完整总召唤交互验证规约理解是否正确我一般会把“抓包”排在“读代码”前面因为代码里的解析逻辑写得再清楚也不如亲眼看到一帧帧真实报文来得直接。连接方式按顺序来先用网线把电脑和保护装置接到同一个交换机上或者直接网线对连Wireshark选择对应网卡过滤条件写成tcp.port2404或你工程里实际用的端口。然后在上位机上点一次总召唤按钮同时开始抓包。抓包结果里应该能看到几类特征上行短帧以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 Listbyte _buffer new Listbyte(); private CancellationTokenSource _cts new CancellationTokenSource(); // 拆出一帧完整报文后向外抛事件界面层订阅这个事件刷新表格 public event Actionbyte[] 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激活公共地址两个字节低字节在前 Listbyte asdu new Listbyte(); 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字节时标占位 Listbyte frame new Listbyte(); 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 ListAnalogPoint ParseMeasurement(byte[] asdu, int start, int vsq) { ListAnalogPoint result new ListAnalogPoint(); 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必须和装置点表一致某厂家用FUN0xFF、INF0x00另一个厂家可能用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 payloadtshark -r capture.pcapng -Y tcp.port2404 -T fields -e tcp.payload tcp_payload.txt导出的内容是十六进制、冒号分隔的字符串一行代表一个TCP包。用Python脚本把冒号去掉整理成一行一帧的日志文件同时做帧边界切分import re frames [] with open(tcp_payload.txt, encodingutf-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, encodingutf-8) as out: for item in frames: out.write(item \n)脚本里最关键的参数是端口号2404它决定过滤哪些报文。如果你工程里用的是别的端口改这里就好。生成后的frames_hex.log可以直接喂给上位机的规约解析层让解析层像从TCP读取一样逐帧处理。这样不连装置也能验证解析器对真实报文的兼容性还能把“现场问题”带回来复现。5.2 回放验证时重点看三处类型标识覆盖、SOE毫秒、遥控帧成对性离线回放时不要只看“日志没报错”就结束。我会做三个检查第一统计回放文件里出现的所有ASDU类型标识和上位机代码里支持的解析分支对照看有没有漏掉的类型这一步能发现“界面上少了一列数据是因为解析器根本不认识这种帧”第二抽出所有SOE事件逐个检查毫秒字段是否为0如果全是0说明装置时标没传或解析层丢弃了毫秒第三检查遥控选择帧和执行帧是否成对出现只选不执行或只执行没选择都是逻辑缺陷。这套回放习惯帮我省了大量现场时间。我早期做这类上位机时不太会抓包上来就写界面结果在现场对着报文一行行改解析一个时标丢毫秒的问题花了一整天。后来养成了“先抓包、后写解析、再连设备”的顺序把解析器当成一个要过回放测试的模块来做联调时间被压缩到半小时以内。如果你正打算用这个包做自己的上位机建议也按这个顺序走会少踩很多坑。希望帮到你。本文还有配套的精品资源点击获取
返回列表