简介:南自以太网103规约及上位机代码的压缩包,面向电力自动化通信协议开发者,解决IEC 60870-5-103规约在以太网环境下的落地实现问题,适用于变电站自动化、电网监控、配电自动化等场景的联调与学习。包内共2个文件,包括一个子zip压缩文档和一个C++源文件,整体大小约788KB,轻量而聚焦核心实现。源码覆盖报文构造、控制域与地址域设置、数据编码解码、序列号管理、错误检测与重传、TCP/IP连接维护及心跳机制等关键模块,可帮助开发者快速理解103协议帧格式、服务类型与透明传输的实际应用,并据此构建与南自设备通信的应用程序。对于学习IEC103协议栈的开发者,这是很好的源码级参考资料。已有1455人学习下载,适合需要参考IEC103上位机实现、进行电力规约二次开发或梳理协议细节的初中级工程师。
1. 以太网103规约及上位机代码:为什么你必须先搞定报文链路
很多做电力自动化上位机的人,第一次接触以太网103规约及上位机代码时都有同一个感受:规约文档能看懂,一到现场就抓瞎。串口时代还能用串口助手直接看字节流,换成以太网之后,保护装置变成TCP服务端,你连上去发一帧总召唤,对面半天不吭声,你也分不清是链路没建立、地址配错还是ASDU没对上。这类代码包要解决的就是这件事——把103规约从链路层到ASDU解析的完整主站实现铺开,让你在办公室就能把报文流程调通,而不是把调试时间耗在现场连蒙带猜。适合继保调试工程师、综自系统集成人员和做配电监控上位机开发的从业者。你不用重新啃一遍规范,但你需要知道报文长什么样、以太网封装在哪变了、代码该怎么拆。
2. 从链路帧到ASDU:以太网103规约的报文骨架与封装差异
2.1 链路帧三兄弟:单字符帧、固定帧长、可变帧长
103规约的链路层基于FT1.2帧格式,先不谈以太网,串口时代的帧格式就是这三类。单字符帧只有一个字节0xE5,用于从站对主站的直接确认,你发一帧询问,从站回一个E5就是“收到了”。固定帧长用在无用户数据的链路控制场景,典型是链路测试和复位链路。真正承载数据的是可变帧长,也是上位机代码里需要重点解析的对象。
可变帧长的字节排布有固定套路,建议直接背下来,比现场翻文档快得多:
| 字节位置 | 内容 | 说明 |
|---|---|---|
| 1 | 0x68 | 帧启动符,所有可变帧的起点 |
| 2 | L | 从控制域到ASDU末尾的总长度 |
| 3 | L | 与字节2重复,用于校验 |
| 4 | 控制域C | PRM、FCB、FCV、功能码所在 |
| 5 | 地址域A | 从站公共地址 |
| 6...n | ASDU | 应用服务数据单元,真正承载数据 |
| n+1 | 校验和CS | 控制域、地址域和ASDU的无进位累加和 |
| n+2 | 0x16 | 帧结束符 |
注意L的算法:它统计的是控制域、地址域、ASDU三部分的字节数,不包含0x68、两个L自身、CS和0x16。所以一帧总长度是L+4,这个关系在写缓冲区定位时非常关键,算错一位整帧就废。
校验和CS在多数厂家里是控制域、地址域和ASDU每个字节做模256累加的结果,不取进位。但有个别从站对这个字段很随意,接收时不校验也能过。写代码时建议发送方向严格按累加和填充,接收方向把校验不合格的帧先缓存并告警,不要直接丢弃——有些老装置发出的校验和本身就算错,丢弃会导致一整路数据都进不来。
2.2 以太网封装变了什么:TCP字节流上找回帧边界
以太网103没有一个统一的国际标准封装,常见做法有两种。第一种最省事:TCP负载里直接放FT1.2可变帧,上位机沿用串口时代那套解析逻辑,只把物理层从串口换成Socket,很多厂商的以太网103实际上就这么干的。第二种是在TCP负载外面再包一层自定义头,比如4字节的报文类型加长度,内部还是103帧。遇到第二种,你得先跳过自定义头再进帧解析,这也是很多上位机代码包后期加补丁的地方。
TCP是字节流,没有消息边界,所以要靠帧本身自描述。我一般会在通信层里单独写一个帧定界器,只做一件事:从不断到达的字节流里切出完整的103帧。
public class FrameParser { private byte[] _buf = new byte[4096]; private int _len = 0; // 外部每收到一段TCP数据就调一次,返回完整帧列表 public List<byte[]> Feed(byte[] data, int count) { Array.Copy(data, 0, _buf, _len, count); // 追加到缓冲区尾部 _len += count; return ExtractFrames(); } private List<byte[]> ExtractFrames() { var frames = new List<byte[]>(); int idx = 0; while (idx + 2 < _len) { if (_buf[idx] != 0x68) { idx++; continue; } // 扫描SOF int len = _buf[idx + 1]; // 长度字段 if (idx + len + 4 > _len) break; // 半帧,等下一段TCP数据 if (_buf[idx + len + 3] != 0x16) { idx++; continue; } // 帧尾不对,继续扫描 var frame = new byte[len + 4]; Array.Copy(_buf, idx, frame, 0, len + 4); frames.Add(frame); idx += len + 4; } if (idx > 0) // 剩余未处理的数据移到头部,避免缓冲区溢出 { Array.Copy(_buf, idx, _buf, 0, _len - idx); _len -= idx; } return frames; } }这段代码有两点值得说明。一是为什么不能用TCP段的边界当帧边界:TCP可能把一个103帧拆成两段发,也可能把三个帧合并成一段发,必须靠0x68和长度自描述来切。二是扫描SOF时不要见到0x68就当成帧头,ASDU的用户数据里也可能出现0x68,正确做法是顺着L走到帧尾,检查是不是0x16,不是就继续往后找。这样解析器才能应对粘包和错位。
2.3 看懂ASDU才能往下写:类型标识、传送原因、信息体地址
链路帧切开后,里面是ASDU。一个完整ASDU包含类型标识、可变结构限定词、传送原因、公共地址、信息体地址和信息元素。很多人在这里被绕晕,其实只要先记常用类型标识,剩下的查表就能走。
| 类型标识 | 常见用途 | 说明 |
|---|---|---|
| 0x01 | 带时标单点遥信(SOE) | 保护动作、开关变位,带毫秒级时标 |
| 0x03 | 不带时标单点遥信 | 实时状态刷新,无历史时标 |
| 0x09 | 归一化遥测 | 以标幺值表示的电流电压 |
| 0x0B | 标度化遥测 | 带量纲和量程的测量值 |
| 0x0D | 短浮点遥测 | 直接float32,省去标度换算 |
| 0x2D | 双点遥控命令 | 合闸、分闸操作 |
| 0x2E | 带时标双点遥控命令 | 遥控并附带操作时间 |
| 0x64 | 总召唤 | 请求从站全数据上送 |
| 0x06 | 时间同步 | 对时命令 |
传送原因COT是另一个高频判断点,几个值必须记牢:0x01周期上送,0x03突发上送,0x06激活,0x07激活确认,0x14总召唤。比如你发总召唤,从站回一帧COT等于0x14就代表“这是总召唤的响应帧”,而遥控返回COT等于0x07代表“命令已确认”。
信息体地址在103里比较特殊,用FUN加INF组合定位,功能类型FUN指向保护装置的某个功能模块,信息序号INF指向模块里的具体信号。有的厂商把FUN和INF压缩在2字节里,有的扩展成3字节,解析时不能写死,最好在配置里留一个“地址字节数”的开关。这块搞明白之后,点表映射才有意义。
3. 上位机代码怎么组织:通信层、解析层、点表层三层分离
3.1 通信层:TCP连接与接收缓冲区设计
我见过不少翻车案例,问题不在协议,而在通信层写得乱。接收、解析、界面刷新全塞在一个线程里,从站一下发几百帧SOE,界面直接卡死。正确做法是通信层只负责连接和数据到达,收到字节后丢给帧定界器,把完整帧通过事件抛出去。
public class Tcp103Link { private Socket _socket; private byte[] _recvBuf = new byte[4096]; private FrameParser _parser = new FrameParser(); public event Action<byte[]> FrameReceived; public string RemoteIp { get; set; } public int RemotePort { get; set; } public void Start() { _socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); _socket.Connect(RemoteIp, RemotePort); // 主站作为TCP客户端主动连从站 while (true) { int n = _socket.Receive(_recvBuf); // 阻塞接收 if (n <= 0) break; foreach (var frame in _parser.Feed(_recvBuf, n)) FrameReceived?.Invoke(frame); } } }这里有几个参数值得细说。连接超时我一般设3秒,超过就触发重连,不要在Connect上无限等。重连间隔用5秒,太频繁会把从站或网关的连接队列打满。Receive的超时时间设500毫秒,这是用来检测连接假死的——长时间收不到字节时,通过超时异常主动断开重连。另外,不管上层逻辑需不需要,E5这种单字符帧也要记录下来,它代表链路确认,后续排查链路状态时非常有用。
3.2 解析层:把68 L L 开头的字节流拆成可用对象
解析层是把完整帧拆成ASDU并转成业务对象的地方。写解析器时先别追求一次解完所有类型,把通用骨架搭好,再把常用类型填进去,后续加新类型只是加一个case的事。
public class Asdu { public byte TypeId; // 类型标识 public byte VSQ; // 可变结构限定词,低7位是信息体个数 public byte Cot; // 传送原因 public byte CommonAddr; // 公共地址 public List<InfoElement> Elements = new List<InfoElement>(); } public static Asdu ParseAsdu(byte[] frame, int offset) { var asdu = new Asdu(); asdu.TypeId = frame[offset]; asdu.VSQ = frame[offset + 1]; asdu.Cot = frame[offset + 2]; asdu.CommonAddr = frame[offset + 3]; int count = asdu.VSQ & 0x7F; // 去掉最高位的时标标记 int pos = offset + 4; for (int i = 0; i < count; i++) { var el = new InfoElement(); el.Fun = frame[pos]; // 功能类型 el.Inf = frame[pos + 1]; // 信息序号 pos += 2; // 这里根据TypeId继续解析信息元素和7字节时标 pos += ParseInfoBody(frame, pos, asdu.TypeId, el); asdu.Elements.Add(el); } return asdu; }代码里有两个点容易忽略。其一,VSQ的低7位才是信息体个数,最高位用来表示这一帧时间是否统一带时标,不处理会多读或少读一个信息体。其二,每种类型标识后面的信息体长度不一样,单点遥信一个字节,归一化遥测两个字节,短浮点遥测四个字节,带时标的还要再加7字节时标,ParseInfoBody必须按类型分派。这部分是坑最多的地方,建议解析失败时打印TypeId和原始hex,不要静默吞掉。
3.3 点表层:把信息体地址映射成中文信号名
解析出FUN和INF之后,光有原始地址不够,还得知道它对应“线路保护跳闸”还是“A相电流”。点表层就是干这个的,核心是一张配置表,格式一般用CSV或XML,程序启动时加载成字典。
| 信号名 | FUN | INF | 类型 | 方向 |
|---|---|---|---|---|
| 线路保护跳闸 | 0x01 | 0x10 | 单点遥信 | 上送 |
| A相电流 | 0x02 | 0x20 | 标度化遥测 | 上送 |
| 断路器合闸 | 0x03 | 0x30 | 双点遥控 | 下行 |
加载逻辑不复杂,但要注意两点。一是FUN和INF在配置里最好用十六进制写,和装置说明书对得上。二是同一套代码要能适配多个装置,点表文件按装置型号分开,主界面切换装置时重新加载,不要在代码里写死信号名。点表加载完成后,信息体地址到信号的映射就能在界面层直接使用了,后续的实时刷新、变化上送、遥控操作都基于这张表。
4. 把整套流程跑起来:模拟从站、总召唤、对时与遥控的最小实践
4.1 先造一个模拟从站:用Python在本地把链路跑通
没有真实装置时,用Python写个模拟从站是最高效的验证手段。它不用实现全部103规约,只要能把链路层跑通、能响应总召唤,就足够让上位机代码动起来。
import socket def build_frame(ctrl, addr, user_data): L = 2 + len(user_data) # 控制域 + 地址域 + ASDU s = ctrl + addr + sum(user_data) # 累加和,取低8位 return bytes([0x68, L, L, ctrl, addr]) + user_data + bytes([s & 0xFF, 0x16]) srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.bind(('0.0.0.0', 4001)) # 从站监听端口 srv.listen(1) conn, addr = srv.accept() print('主站接入:', addr) while True: data = conn.recv(4096) if not data: break print('收到:', data.hex()) # 这里解析收到的是不是总召唤,是的话回一帧遥测ASDU这个脚本的核心价值是验证两件事:上位机能不能正确连上端口,帧定界器能不能从TCP流里切出完整帧。端口4001是模拟端口,真实使用时要改成装置说明书里的端口号。注意模拟从站的校验和算法必须和上位机一致,如果校验和逻辑有差异,上位机发了帧但模拟端不回包,你就要怀疑是解析问题还是校验问题,排查成本反而更高。
4.2 总召唤与时间同步的标准时序
链路连通后,第一个要调通的流程是总召唤。上位机发送类型标识0x64的ASDU,传送原因0x14,从站收到后会先回一帧确认,然后开始大批量上送遥信遥测,最后再回一帧总召唤结束的ASDU。上位机必须等到结束帧,才能判定总召唤完成,否则界面会显示数据不全。
时间同步的时序更简单:上位机定时发类型标识0x06的对时帧,从站回确认即可。周期我一般建议5分钟一次。对时太频繁会挤占正常遥测通道,太少则装置时间漂移大,SOE时标失真。如果有多个从站,每路连接独立对时,不要共用一个定时器。
这段流程跑通后,上位机的基本盘就稳了。接下来再调遥控、设点、故障录波这些扩展功能,都是在这个骨架上加类型标识的事。
4.3 Wireshark抓包验证与常见报文判读
调以太网103一定要学会用抓包工具验证,不要只信上位机日志。抓包工具有时候比代码更早暴露出问题。过滤条件用tcp.port加上你配置的端口号,比如tcp.port == 4001。抓到包后直接看原始hex,重点确认三件事:TCP段里有没有完整的68开头帧、长度字段和实际字节数是否一致、帧尾是不是0x16。
提示:不要把以太网103在Wireshark里强制解析成IEC 60870-5-104,二者封装完全不同,解码出来的内容会误导你。直接看原始字节最可靠。
5. 以太网103调试避坑:五个高频翻车点与实测解法
5.1 TCP能连上但总召唤始终没回应
现象:Socket连接建立成功,发总召唤后等待超时,从站一直不回数据。
原因:多数情况是从站要求先收到链路复位命令才会进入正常数据上送状态,也有情况是公共地址不匹配,上位机发的地址域和从站拨码地址不一致,从站直接忽略。
解决:先把链路流程梳理清楚,按“复位链路、等待确认、总召唤”的顺序发,不要跳步。再核对公共地址,103从站的地址一般就一个字节,在装置面板或说明书里能找到,确认和上位机配置完全一致。
5.2 抓包显示帧完整,上位机解析却全是乱码
现象:Wireshark里能看到清晰可读的完整帧,但上位机解析出来的遥测值离谱,比如电流变负数、电压变成天文数字。
原因:字节序不匹配。103规范本身没有强制统一大小端,有的装置按大端发,有的按小端发。再加上部分厂商在TCP负载前额外加了自定义头,导致ASDU整体偏移。
解决:先看抓包,如果帧的前面多了几个不是68开头的字节,就是自定义头,解析时要跳过。如果帧头正常但数据值不对,在点表配置里加一个“字节序”开关,对大端小端分别适配。这两个问题在现场都遇到过,而且经常同时出现。
5.3 遥控返校一直超时,现场开关却已经动作
现象:上位机报了“返校超时”,但操作人员说开关已经合上了,设备动作正常。
原因:从站动作太快,在你还在匹配合适的返回帧时,确认已经到了。另一个原因是返回帧的传送原因不是标准的0x07,有些装置用自定义值表示“遥控已执行”,上位机只认标准值就把合法返回当垃圾丢了。
解决:遥控命令发出后,把后续一段时间内所有上行ASDU都缓存下来,用信息体地址反向匹配,不直接按传送原因过滤。同时把说明书里关于遥控确认的段落拍下来对着看,确认该装置的返校码到底是哪个值。
5.4 时间同步后站内时间仍然偏差好几个小时
现象:SOE时标和实际时间差8小时或几个分钟,反复对时也修不回来。
原因:时标编码格式不匹配。有的装置时标字段用BCD码,有的用二进制秒。如果上位机按BCD解析二进制数据,时间自然错乱。差8小时往往是时区没处理,装置内部按UTC存储,上位机把本地时间当UTC下发。
解决:在配置里加“时标格式”选项,BCD或二进制可选。对时下发前确认装置时区设置,国内项目统一用UTC+8,由上位机在发送前把时间转成装置期望的时区。
5.5 遥信变化密集时总是漏点,抓包却看不出问题
现象:开关变位时偶尔丢信号,总召唤后数据正常,但平时变化上送会漏。抓包看从站发过,上位机日志里却没有。
原因:接收线程处理太慢。TCP缓冲区被填满后,新的数据会被内核丢弃,从站认为已经发完,上位机却根本没收到。E5单字符帧如果没被当成链路确认记录下来,还会导致后续链路状态错位。
解决:通信层接收线程只做收包和入队,把帧解析、点表匹配、界面刷新全部移到工作线程里。同时把单字符帧E5作为有效帧记录到日志里,这样链路确认和ASDU上送都能追踪到,漏点问题就能定位到底丢在哪个环节。
6. 把调试现场变成武器:报文回放与抓包日志的进阶玩法
6.1 报文回放:把现场抓包变成可重复的回归用例
现场问题最怕回到办公室复现不了。我习惯把抓包工具导出的报文存成文本,写个小脚本回放给解析器,算是给上位机解析层做自动化回归。
# 抓包导出命令参考 # tshark -r capture.pcap -Y 'tcp.port==4001' -T fields -e data > frames.txt for line in open('frames.txt'): line = line.strip() if line: data = bytes.fromhex(line) parser.feed(data) # 复用同一个帧定界器这个脚本的价值在于,现场抓回来的报文可以在办公室里反复跑,解析代码改一版就回归一次。尤其是调完一个坑之后,用之前的失败报文再跑一遍,能确认没有引入新的解析问题。
6.2 原始报文日志与人读日志分离
上位机日志最少分两层。第一层是原始hex日志,按连接和日期滚动保存,排查问题时能还原现场。第二层是人读日志,把解析后的信号名、值、时间、类型标识打印成可读文本。两层必须分开存,混在一起会导致原始报文写不全,真出事了找不到依据。
6.3 给私有ASDU预留扩展槽
不少装置都有厂商自定义的ASDU类型标识,编号通常在0x80以上。解析器遇到不认识类型时,不要直接报错跳过,把原始信息体保留到一条“未解析数据”记录里,界面上能看到hex,后续根据说明书补充解析逻辑就能无缝兼容。
做103协议栈这几年,最大的教训是:报文解析出问题,八成不是规范看不懂,而是没把抓包、链路流程、点表三者对起来看。曾经有个现场调了两天,最后发现是网关把装置地址从1改成了2。那之后我每次都先抓一段完整开机报文再对点表,比抱着文档猜快得多。希望帮到你。
本文还有配套的精品资源,点击获取