简介:面向电力自动化通信开发者的IEC60870协议开源实现库,聚焦IEC 60870-5系列中101、102、104三种常用协议,适合需要将主站或子站通信能力集成进自研软件的中高级开发者,可省去从零实现协议编解码的工作。压缩包共114个文件,以39个C源文件与34个头文件为核心,辅以12个txt说明、9个makefile及若干工程配置与证书文件,整体约186KB,体量轻便、便于嵌入现有工程。已有2500人学习下载,说明其在电力通信领域具备一定参考价值。库内提供主站与子站双向通信、ASDU信息对象处理、链路层与TCP连接管理等模块,并附用户指南与调试配置材料,读者可据此理解协议分层结构、掌握报文编解码思路,并快速搭建可运行的通信示例,降低开发成本与兼容性风险。
1. 从一份“能跑起来的 IEC60870 实现”说起
手里有一份 IEC60870 开源实现库,第一反应往往不是“协议标准讲了什么”,而是“这东西能不能直接接上我的设备、能不能在半天内跑通一条链路”。IEC60870 是电力自动化里绕不开的远动通信协议族,IEC 60870-5-101 走串口、104 走 TCP,规约本身不复杂,复杂的是各家设备的实现差异、点表映射和链路状态机。这份库的价值就在这儿:它把帧结构、链路层状态机、ASDU 类型编码这些容易写错的部分做成了可复用的代码,省掉从零手撸 104 报文的时间。适合做电力监控、配网终端、规约网关的从业者,也适合想搞懂 104 报文到底长什么样的新手。下面按“先看清结构、再跑通链路、最后处理坑”的顺序拆。
2. 拆开库看结构:帧格式、ASDU 与链路状态机
拿到一个规约库,别急着写业务代码,先搞清楚它把协议切成了哪几层。IEC60870-5-104 的报文本质是 APCI 加 ASDU:APCI 负责链路控制(I 帧传数据、S 帧确认、U 帧做启动/停止/测试),ASDU 负责业务数据(类型标识、可变结构限定词、传送原因、公共地址、信息对象地址)。库如果分层清晰,这几块应该是独立模块,改点表不用动链路代码。
2.1 先认帧类型,再谈业务
104 的帧头固定 6 字节:启动字符 0x68、长度、四个控制域字节。控制域第一个字节的低两位决定帧类型,00 是 I 帧、01 是 S 帧、11 是 U 帧。很多新手翻车就翻在这儿——把 S 帧当数据帧解析,或者 U 帧的启动确认没回,链路一直停在“未传输”状态。库一般会提供帧解析入口,我习惯先写个最小脚本把收到的原始字节打出来,对照帧类型判断,比直接读源码快。
# 最小帧类型判别,用于抓包后快速定位 def classify_frame(raw: bytes) -> str: if len(raw) < 6 or raw[0] != 0x68: return "invalid" ctrl0 = raw[2] ftype = ctrl0 & 0x03 if ftype == 0x00: return "I" # 信息帧,携带 ASDU if ftype == 0x01: return "S" # 监视帧,仅确认 if ftype == 0x03: return "U" # 控制帧,启动/停止/测试 return "unknown"这段代码只做一件事:按控制域低两位分流。参数上注意raw[2]是控制域首字节,不是长度字节;长度字节在raw[1],表示 APDU 剩余长度。实际排查时把它挂在收包回调里,日志里一眼能看出对端发的是数据还是确认,省去大量猜。
2.2 ASDU 的四个固定字段别搞反
ASDU 头部顺序是类型标识(1 字节)、可变结构限定词(1 字节)、传送原因(2 字节,低字节在前)、公共地址(2 字节)。信息对象地址通常是 3 字节,但 101 和 104 在地址长度上可能配置不同,库一般用配置项控制。类型标识决定后面信息体的解析方式,比如 0x0D 是测量值短浮点、0x01 是单点信息。可变结构限定词最高位表示是否顺序寻址,低 7 位是信息对象个数——这个位搞错,解析出来的点数直接翻车。
| 字段 | 长度 | 常见取值 | 作用 |
|---|---|---|---|
| 类型标识 | 1 字节 | 0x01 / 0x0D / 0x1E | 决定信息体解析方式 |
| 可变结构限定词 | 1 字节 | 0x01 / 0x81 | 最高位顺序标志,低位个数 |
| 传送原因 | 2 字节 | 0x03 / 0x05 / 0x14 | 突发、请求、响应等 |
| 公共地址 | 2 字节 | 按现场配置 | 区分不同站 |
选型上,如果库把 ASDU 解析做成“类型标识 → 解析函数”的注册表,扩展新类型只需加一个函数,这种设计比一长串 if-else 好维护。看源码时重点找这个注册表在哪儿,后面加点表就靠它。
2.3 链路状态机是跑通的关键
104 的链路有“停止/启动”两个状态,客户端发 U 帧启动激活(0x07),服务端回启动确认(0x0B),之后才能发 I 帧。库如果封装了状态机,通常暴露start()、stop()、send_asdu()这类接口。我一般先只调start(),抓包确认 U 帧往返正常,再发一条测试 ASDU。跳过这步直接发数据,十有八九卡在“链路未建立”,日志里还看不出原因。
# 伪代码:按状态机顺序推进,别跳步 client = Iec104Client(host="127.0.0.1", port=2404) client.start() # 发 U 帧启动激活 client.wait_started(timeout=5) # 等启动确认,超时即报错 client.send_asdu(asdu) # 链路就绪后再发 I 帧wait_started的超时参数别设太小,现场网络抖动时 5 秒比较稳;send_asdu之前务必确认状态,库若没做保护,自己加一层判断。这套顺序是链路层的基本礼仪,顺序错了后面全白搭。
3. 跑通第一条链路:从配置到收发闭环
结构看清了,接下来是把它跑起来。规约库的“能跑”不是编译通过,而是能和模拟端完成一次启动、一次总召、一次数据上送。这一步建议全在本地做,用库自带的示例或自己写个回环服务端,别一上来就接真实设备。
3.1 连接参数与端口约定
104 默认端口 2404,TCP 长连接。配置项一般包括对端地址、端口、公共地址、超时时间、是否启用测试帧。公共地址必须和对端一致,否则 ASDU 会被丢弃且不一定报错——这是最隐蔽的坑之一。我习惯把配置集中成一个字典,改一处生效,避免散落在代码各处。
CONFIG = { "host": "127.0.0.1", "port": 2404, "common_address": 1, # 必须与对端一致 "t1": 15, # 发送后等确认超时,秒 "t2": 10, # 无数据时发 S 帧的间隔,秒 "t3": 20, # 空闲发测试帧的间隔,秒 }t1/t2/t3 是 104 的三个经典定时器:t1 管“发了 I 帧多久没确认就断链”,t2 管“多久发一次 S 帧确认”,t3 管“链路空闲多久发测试帧”。现场网络差时 t1 适当放大,但别超过对端容忍值,否则对端先断你。这几个参数在库的配置类里通常都有对应字段,找不到就搜t1、timeout关键字。
3.2 总召流程:先要全量再谈增量
链路起来后,常规做法是先发总召(类型标识 0x64,传送原因 0x06 激活),把对端全量点拿一遍,再靠突发上送维持增量。总召的 ASDU 信息体为空,公共地址填对端站地址。库一般提供interrogation()之类的封装,没有就自己拼 ASDU。
# 构造总召 ASDU(激活) def build_interrogation(common_addr: int) -> bytes: asdu = bytearray() asdu.append(0x64) # 类型标识:总召 asdu.append(0x01) # 可变结构限定词:1 个对象 asdu += (0x06).to_bytes(2, "little") # 传送原因:激活 asdu += common_addr.to_bytes(2, "little") asdu += (0x00).to_bytes(3, "little") # 信息对象地址:0 return bytes(asdu)传送原因 0x06 是激活,对端回 0x07 激活确认,随后陆续上送数据,最后回 0x0A 激活终止。解析时按类型标识分发,测量值短浮点(0x0D)信息体是 4 字节浮点加品质位,单点(0x01)是 1 字节状态加品质位。品质位别丢,现场很多“数据不对”其实是品质位标了无效或溢出。
3.3 用回环验证收发闭环
没有真实设备时,最稳的验证是本地起一个最小服务端:收到启动激活回确认,收到总召回几条构造好的 ASDU。这样能把客户端的发送、解析、状态迁移全走一遍。库如果自带示例服务端直接用,没有就照帧格式手写几十行。
# 极简回环服务端骨架,仅验证链路与解析 import socket def serve_once(port=2404): srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind(("127.0.0.1", port)) srv.listen(1) conn, _ = srv.accept() while True: head = conn.recv(2) if not head: break length = head[1] body = conn.recv(length) raw = head + body # 这里按帧类型回对应确认,再按需回 ASDU conn.sendall(build_start_confirm())这段骨架的重点是“按长度字段收全再解析”,TCP 是流,一次recv不保证收全一帧,必须循环读到长度满足。参数上SO_REUSEADDR避免端口占用导致重启失败。回环跑通后,把对端换成真实设备,只改 host/port 和公共地址,其余逻辑不动,这是最省事的迁移路径。
4. 避坑与排查:五条血泪经验
规约类项目最耗时的不是写代码,是排查“为什么没数据”。下面五条是我在实际联调里反复遇到的,按现象、原因、解决写清楚,照着对能省不少时间。
4.1 链路显示已连接但收不到任何 ASDU
现象:TCP 连接建立成功,日志里没有任何数据帧。原因多半是启动流程没走完——只建了 TCP 连接,没发 U 帧启动激活,或者发了但没等确认就发数据。解决:抓包确认 0x07/0x0B 往返,库的start()后必须等状态变为已启动再发 I 帧;若对端要求先由它发起,则调整主从角色配置。
4.2 总召有回应但点数对不上
现象:总召后收到数据,但解析出的点数比预期少或多。原因通常是可变结构限定词的最高位(顺序寻址标志)处理错,或信息对象地址长度配置与对端不一致(3 字节 vs 2 字节)。解决:打印原始 ASDU 十六进制,对照限定词低位个数逐条数;地址长度在库配置里显式指定,别依赖默认值。
4.3 数据值明显异常,比如温度变成天文数字
现象:测量值解析出来是极大或极小值。原因:短浮点字节序搞反,或把品质位当成了数值的一部分。解决:确认信息体是 4 字节 IEEE754 小端,品质位单独取;用已知值(如 25.5)构造一帧做单元测试,比在现场猜快得多。
4.4 运行一段时间后链路莫名断开
现象:初期正常,几十分钟后断链且重连失败。原因:t1 超时未收到确认,或 t3 测试帧没被对端正确回应,状态机卡死。解决:检查 S 帧确认是否按时发出,t1 适当放大;重连逻辑里先复位状态机再重发启动激活,别直接复用旧连接对象。
4.5 多站共用一个客户端时数据串站
现象:多个公共地址的数据混在一起。原因:解析时没按公共地址分流,或连接复用时上下文没清理。解决:按公共地址建独立会话或独立解析上下文,收到 ASDU 先取公共地址再分发;连接关闭时清空未完成的总召状态。
提示:以上排查都建立在能抓到原始报文的基础上,联调阶段务必把收发字节打进日志,别只打解析后的对象。
5. 进阶:把点表配置化,让规约库真正可维护
跑通链路只是起点,真正决定这份库能不能长期用的是点表管理。硬编码信息对象地址和类型,改一个点就要动代码、重新编译,现场根本扛不住。我的做法是把点表抽成外部配置(JSON 或 CSV),启动时加载成“地址 → 解析规则”的映射,库的类型注册表直接对接这份映射。
import json def load_point_table(path: str) -> dict: with open(path, encoding="utf-8") as f: rows = json.load(f) # 以 (公共地址, 信息对象地址) 为键,值为类型与缩放 return {(r["ca"], r["ioa"]): r for r in rows} def decode_by_table(asdu, table): ca = int.from_bytes(asdu[4:6], "little") ioa = int.from_bytes(asdu[6:9], "little") rule = table.get((ca, ioa)) if not rule: return None # 未配置的点直接丢弃并告警 return apply_rule(asdu, rule) # 按 rule 里的类型和系数解析这段的关键是“未配置的点不猜”,直接丢弃并打告警,避免脏数据进库。apply_rule里按类型标识选解析函数,缩放系数也放配置里,现场改量程不用改代码。点表文件建议带版本号,升级时对比差异,防止地址漂移。
验证方法上,我习惯做两件事:一是用构造帧对每个类型各跑一遍单元测试,二是拿历史抓包回放,确认解析结果和当时记录一致。回放比现场复现稳定得多,也不依赖设备在线。参数边界上,信息对象地址别超出配置长度能表示的范围,公共地址变更要同步改点表键,否则整站数据对不上。
从那以后我每次接新规约库,都强制先做“帧类型判别 + 回环 + 点表加载”这三步再碰业务,顺序不乱。希望帮到你。
本文还有配套的精品资源,点击获取