简介:面向电力自动化与SCADA系统开发者的IEC60870开源实现库,基于lib60870-2.0.1封装了IEC60870-5-101/102/104协议,支持主站/子站双向通信、ASDU编解码与多种链路层传输,解决了从零实现复杂电力协议栈的难题,适用于RTU、变电站自动化及智能电网等中高级开发场景。资源包共114个文件,压缩后约186KB,以C源文件(39个)和头文件(34个)为代码主体,辅以makefile构建脚本、说明文档、证书文件及测试用例,既有可直接阅读的协议实现,也便于重新编译集成。已有2499人学习下载,适合嵌入式或电力通信领域开发者深入研读。内容包含完整库实现与使用指南,涵盖对象信息组装、连接管理、链路层收发与错误恢复等核心逻辑,参考自带测试用例可进行协议校验,对实际工程项目集成或协议二次开发均有较高参考价值。
1. 某变电站的遥测跳变到第三天才被定位:一份把规约黑匣子摊开的 IEC60870 实现库
某变电站的一路遥测电压每 20 分钟跳变一次,值班员盯着 SCADA 画面怀疑人生,最后抓包一看:不是互感器坏了,是解析端把品质描述符当成数据位了。干过远动的都懂,iec60870 这套协议本身不算难,难的是时序、字节序和状态机这些细节。这次拆的是这份 IEC60870 开源实现库:纯 C 编写,覆盖 104/101 的从站与主站协议栈,从链路层状态机到 ASDU 编解码,再到总召唤和主动上报,全都在可编译源码里。适合做 SCADA 上位机、电力终端、规约转换器的开发者。它把黑匣子变成几百个可读文件,跑一遍就能看见报文从进入到返回的完整轨迹。下面从协议选型开始,一步步拆到编译、联调和二次开发。
2. 协议与工程选型:101 管串口、104 管网络,这库怎么组织才能不糊成一团
2.1 先把规约家族认清楚:101 与 104 的共性和分歧
IEC60870-5 系列里,最常打交道的是 101 和 104。101 是面向串口的远动规约,通常在 RS-232/RS-485 上跑,需要自己处理链路层确认、重发和超时;104 是 101 的网络化映射,直接把 TCP 当传输层,端口固定为 2404,把链路层的确认机制交给 TCP,同时保留了自己的发送/接收窗口。工程上 104 成为主流不是因为协议更强,而是调试成本低:串口抓包要接硬件,TCP 一条nc命令就能看到数据。
这库的聪明之处在于,ASDU(应用服务数据单元)层完全共用,只有 linklayer 分开。也就是说,你在 104 上调好的数据模型,迁到 101 时不用动应用层的编解码逻辑,只换链路层驱动。这对做规约转换器的团队特别友好,一次实现双规约适配。我拿到源码第一件事就是确认这个结构——如果 asdu 也被复制了两份,那就说明作者没有真正理解规约的层次设计。
2.2 目录结构:协议栈分四层,阅读顺序有讲究
源码目录很清爽,我不喜欢那种一百个文件平铺的工程,这库的分层值得抄作业:
iec60870-lib/ ├── CMakeLists.txt ├── src/ │ ├── linklayer/ # 104 TCP 链路:连接建立、定时器、收发线程 │ │ ├── tcp_link.c │ │ └── timer.c │ ├── asdu/ # 应用层数据单元:编解码、TI 类型映射 │ │ ├── asdu_codec.c │ │ ├── ti_map.c │ │ └── information_object.c │ ├── slave/ # 从站协议栈:总召唤、事件上报、初始化流程 │ │ └── slave_stack.c │ └── master/ # 主站协议栈:启动链路、发送召唤、处理响应 │ └── master_stack.c ├── examples/ # 可运行的最小主站/从站 demo ├── tests/ # 编解码单测与链路层测试 └── tools/ # 网关、抓包导出辅助工具阅读顺序我建议倒着来:先看 asdu,因为业务上的大多数问题(遥测乱码、总召唤丢点)都发生在这一层;再看 linklayer,理解帧是怎么被送出去和收进来的;最后看 slave / master,理解两端的握手状态。新手最容易犯的错误是一头扎进 tcp_link.c 里抠 socket 代码,结果看了一周还没明白总召唤怎么触发。
2.3 为什么选纯 C 和状态机驱动:嵌入式移植性是第一诉求
远动终端大多跑在 MCU 上,C 是绕不开的。但这个库用 C 不只是为了移植性,还为了内存可控:从站设备可能只有几十 KB RAM,不能容忍一个协议栈给你吃掉一半堆空间。看它的实现,没有依赖任何第三方库,socket 用 POSIX 接口,tcp_link.c 里做了简单的线程封装,方便裸机环境替换成 RTOS 的recv接口。
状态机驱动的设计也很务实。104 链路层的时序要求严格:连接、激活、运行、停止各有各的合法转移路径。用 switch-case 状态机比用回调嵌套直观得多,出问题的时候打日志就能定位到是哪个状态没跳过去。后续第 3 章拆的定时器 t0~t3 和窗口 K/W,就是这套状态机的核心参数。
3. 核心模块拆解:一帧 104 报文从接收到应答的完整链路
3.1 APCI 帧结构与参数表:先背熟这几个数字
104 的报文最小单位是 APDU,由 APCI(应用规约控制信息)加 ASDU 组成。APCI 的前 6 个字节是固定的:起始符 0x68、长度字段、控制域。控制域又按帧类型分为三种:I 帧(信息帧)带发送序号和接收序号,S 帧(确认帧)只带接收序号,U 帧(控制帧)用于启动/停止链路和测试。
库的配置结构体里默认参数可以直接背:
| 参数 | 默认值 | 含义 |
|---|---|---|
| t0 | 15s | TCP 连接建立超时 |
| t1 | 15s | 发送 I 帧后等待确认的超时 |
| t2 | 10s | 接收站无新 I 帧时,主动发 S 帧确认的间隔 |
| t3 | 20s | 链路空闲时发送测试帧 TESTFR 的周期 |
| K | 12 | 最大发送窗口,最多连续发 12 个未确认 I 帧 |
| W | 8 | 最大接收窗口,收到 8 个 I 帧后必须回确认 |
K 和 W 是最容易让人栽跟头的。TCP 自己也有窗口,但 104 的窗口是应用层的,它要求接收方在收到 8 个 I 帧后即使没有新数据也要发 S 帧确认,否则发送方发满 12 个未确认帧就会停住。如果你在主站上把 W 配得大于 K,对端会直接判定协议错误断开。
3.2 链路层分发:一帧数据进来先过这三关
链路层收包代码的逻辑很直接,核心是一个分发函数:
int link_receive(link_conn_t *conn, uint8_t *buf, int len) { /* buf[0] 必须是起始符 0x68,buf[1] 是长度字段 */ if (buf[0] != 0x68 || len < 6) return -1; int pdu_len = buf[1] + 2; /* 长度字段不含起始符和自身 */ switch (buf[2] & 0x03) { /* 控制域第一个字节的低 2 位用来识别帧类型 */ case 0x00: /* I 帧:携带发送/接收序号,需要走 ASDU 解析 */ return handle_iframe(conn, buf, pdu_len); case 0x01: /* S 帧:纯确认,更新接收序号即可 */ return handle_sframe(conn, buf, pdu_len); case 0x03: /* U 帧:STARTDT / STOPDT / TESTFR */ return handle_uframe(conn, buf, pdu_len); } return 0; }这里的三个细节我要强调:第一,长度字段算的是它后面所有字节数,不包括起始符 0x68 和长度本身,所以实际 APDU 总长是buf[1] + 2,很多初学者会把buf[1]当整包长度直接读到 244 字节超限;第二,帧类型只看buf[2]的低两位,U 帧的低两位是 0x03,但 U 帧本身只有 4 字节控制域,后面不跟 ASDU;第三,I 帧和 S 帧的第 2、3 个字节才是序号位,解析时要用小端拼装。这些细节不看源码,光靠协议文档很容易错。
3.3 ASDU 编解码:TI=13 遥测帧的字节布局
打点数据是遥测最常用的类型。TI=13 表示带品质描述符的规格化值,信息体固定 11 字节:类型标识 + 可变结构限定词 + 传送原因 + 公共地址 + 信息对象地址 + 规格化值 + 品质描述符。解码代码如下:
typedef struct { uint8_t ti; /* 类型标识,13 = 带品质的规格化值 */ uint8_t vsq; /* 可变结构限定词,低 7 位表示信息对象个数 */ uint16_t cot; /* 传送原因,3 = 突发上报,20 = 总召唤响应 */ uint16_t ca; /* 公共地址,即站地址 */ uint16_t ioa; /* 信息对象地址,即点号 */ int16_t nva; /* 遥测原始值,要除以变比才是物理值 */ uint8_t qds; /* 品质描述符,bit0 无效位,bit1 非当前值 */ } asdu_measure_t; int asdu_decode_ti13(const uint8_t *p, int len, asdu_measure_t *out) { if (len < 11) return -1; out->ti = p[0]; out->vsq = p[1]; out->cot = (uint16_t)(p[2] | (p[3] << 8)); out->ca = (uint16_t)(p[4] | (p[5] << 8)); out->ioa = (uint16_t)(p[6] | (p[7] << 8)); out->nva = (int16_t)(p[8] | (p[9] << 8)); out->qds = p[10]; return 0; }逻辑说明:整个 IEC60870-5-4 规定数值用低字节在前的小端序,所以 COT、CA、IOA、NVA 都要用移位拼装,不能直接memcpy到结构体——C 结构体的字段对齐和大小端在不同编译器下行为不同,这也是我常说“打死不要用 struct 直接强转字节流”的原因。CA 和 IOA 的区别要分清:CA 是站地址,一台设备一个;IOA 是设备内的点号,一个数据点一个。现场出问题时,我看到不少人拿着 IOA 去对站地址,报文当然对不上。
3.4 总召唤的完整时序:六帧报文把数据要点齐
总召唤是 104 最核心的交互,很多 SCADA 重启后拉不到数据就是这里出了问题。完整时序分三步:主站发 TI=100、COT=6(激活),从站回 TI=100、COT=7(激活确认),然后开始逐个点发遥测 TI=13、COT=20(响应总召唤),最后发一帧 TI=100、COT=10(激活终止)。这库从站侧的回调注册如下:
void slave_register_callbacks(slave_t *slave) { /* 注册 TI=100 总召唤处理:收到 COT=6 时进入总召唤流程 */ slave_on_asdu(slave, 100, on_general_interrogation); /* 注册 TI=13 遥测点的读取函数,按 IOA 查实时库 */ slave_register_point(slave, 13, 0x1234, read_pt_value); }这里有个很容易忽略的设计意图:从站响应总召唤用的 COT=20,而平常突发变位上报用的 COT=3。两种类型的报文吞吐要求不一样——总召唤可能一次带几百个点,突发上报却要求低延迟。如果你在主站侧只处理 COT=20 不处理 COT=3,就会出现“总召唤好使,但设备遥信变位不上送”的诡异现象。协议文档会把 COT 列成表,但实际工程里,靠日志把 COT 打出来比什么都快。
4. 跑起来:依赖、编译、最小示例与抓包验证
4.1 构建:一条命令编译完,无第三方依赖
这个库用 CMake 组织,依赖只有系统自带的 socket 和 pthread,交叉编译到 ARM 板上也不需要额外拉库。我本机的编译过程:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release cmake --build build -j$(nproc) ctest --test-dir build --output-on-failurectest会跑 tests 目录里的单测,大概二三十个用例,主要覆盖 ASDU 编解码的边界情况和链路层的帧校验。如果你改动过 asdu_codec.c,跑完 ctest 心里就有底了——这比你自己写 printf 打日志验证靠谱得多。构建选项里BUILD_SHARED_LIBS默认是 OFF,生成静态库,适合直接嵌到现有工程里;要出动态库就加-DBUILD_SHARED_LIBS=ON。我平时调试喜欢开 Debug 构建,因为协议栈里打了很多log_debug,Release 编译时全被优化掉了,现场定位问题还得重新编一版,血泪经验。
4.2 最小从站代码:二十行跑起一个可联调的从站
examples/slave_demo.c 是整个库最简单的切入点,核心逻辑就这么点:
#include "iec60870_slave.h" /* 从站遥测点的读取回调:IOA=0x1234 返回 5020,即 50.20V */ static int read_pt_value(slave_t *s, uint16_t ioa, uint8_t *value) { *(int16_t *)value = 5020; return 1; /* 1 表示读取成功 */ } int main(void) { slave_t slave = slave_create(1); /* 站地址 = 1 */ slave_register_point(&slave, 13, 0x1234, read_pt_value); slave_run(&slave, 2404); /* 绑定 2404 端口,阻塞运行 */ return 0; }解释一下流程:slave_create(1)初始化从站协议栈并设置公共地址,slave_register_point把 IOA 0x1234 注册成 TI=13 类型的可读点,slave_run进入主循环开始监听 TCP。实际项目中,read_pt_value里应该去查实时数据库或读 ADC 寄存器,这里返回固定值是为了让你先跑通链路。如果你要加几十个点,循环调用slave_register_point就行,不用改协议栈内部。
4.3 用 Python 模拟主站:手拼报文验证链路
没有现成主站时,我用一段 Python 就能验证从站是否正常。关键是手拼 U 帧和总召唤帧:
import socket, time conn = socket.create_connection(("127.0.0.1", 2404), timeout=5) # U 帧 STARTDT act:68 04 07 00 00 00 conn.sendall(bytes.fromhex("68 04 07 00 00 00")) resp = conn.recv(1024) assert resp == bytes.fromhex("68 04 0b 00 00 00"), "启动链路失败" # I 帧总召唤:控制域(1 0) + ASDU(TI=100, COT=6, CA=1, IOA=0) # 帧长 0x0c = 4 字节控制 + 8 字节 ASDU conn.sendall(bytes.fromhex("68 0c 01 00 00 00 64 01 06 00 01 00 00 00 00 00")) time.sleep(1) print(conn.recv(4096).hex())这段脚本的每个字节都有讲究:68 04说明 U 帧不带 ASDU,07是 STARTDT act(bit0/bit1 为 11,bit2 为 STARTDT,bit6 为 act);0b是 STARTDT con(bit6 为 con)。总召唤帧的64是 TI=100,01是 VSQ 表示 1 个对象,06 00是 COT=6 激活,01 00是公共地址 1。收到从站响应后,你会看到一串 I 帧,里面 TI=13 的遥测值就是 5020 的小端拼装。
4.4 Wireshark 验证:用现成解析器看协议树
手动拼报文练一次就够了,日常联调还是要靠 Wireshark。抓包时过滤tcp.port == 2404,Wireshark 会直接识别出 IEC 60870-5-104 协议,在协议树里自动解析出类型标识、传送原因、公共地址、信息对象地址和品质描述符。有一个 Wireshark 的偏好设置建议改一下:协议 -> IEC 60870-5-104,勾选“Try to decode as IEC 60870-5-101”,这样某些误把 104 封装成 101 的场景也能识别出来。
我个人的工作习惯是:从站起来后先不用 SCADA,先用这套 Python 脚本加 Wireshark 做一次全流程验证,跑通了再交给上位机组。否则直接接 SCADA,报一个链路超时你都不知道是自己代码的问题还是 SCADA 配置的问题。
5. 避坑与排查:现场调 104 规约的常见问题实录
5.1 链路握手一直失败:STARTDT act 发出去没有回音
现象:主站反复发68 04 07 00 00 00,从站就是不回68 04 0b 00 00 00,然后主站报 t0 超时。
原因:排查顺序很重要。最常见的是从站进程根本没有起来,TCP 连接被内核拒绝,Wireshark 里能看到 RST 包;其次是从站代码里漏了slave_run,或者监听的端口不是 2404;还有一种情况是防火墙拦截了非本机 IP 的连接,本机用 127.0.0.1 能通,换成局域网 IP 就超时。
解决:先用netstat -an | grep 2404确认端口在监听,再确认句柄在哪个线程;然后看防火墙规则,把 2404 加白名单。从代码角度,检查slave_create(1)里的站地址是否和主站配置一致——有时候主站配的站地址是 2,从站是 1,握手帧能通,但总召唤的响应主站会丢弃。这个坑我踩过以后,现在每次联调第一件事就是对公共地址。
5.2 遥测值乱跳或出现 0xFFFFFFFF:字节序与结构体对齐
现象:SCADA 画面上遥测数据全是乱码,一会是 65535 一会是 -1,严重时直接显示 0xFFFFFFFF。
原因:十有八九是字节序搞反了。IEC60870-5-4 规定多字节数值低字节在前,如果你按大端解析,遥测值几乎不可能落在合理范围内。另一个隐蔽原因是用 C 结构体直接memcpy转换字节流,不同编译器对结构体的对齐和填充处理不一样,导致字段错位。
解决:所有解析都走移位拼装,不要用结构体指针强转。验证方法很笨但很有效:把收到的原始字节按十六进制打出来,手算出期望值再对比。比如协议报文中9a 13,小端拼装是 0x139A 即 5018,如果代码算出 0x9A13 那就是字节序反了。调试完记得把品质描述符也打出来——我看到过一个项目遥测偶尔跳变,最后发现是品质的无效位被当成了数据位参与计算。
5.3 总召唤只回了一小部分数据:VSQ 循环没写完就返回
现象:主站发总召唤,从站确实响应了,但只收到十几个点,后面全是 S 帧。
原因:ASDU 的 VSQ 低 7 位表示这一帧里带多少个信息对象(最多 127 个)。一种情况是从站代码里只处理了第一个对象就return,没按 VSQ 循环;另一种情况是数据点注册不全,slave_register_point没覆盖到所有应该上送的 IOA。
解决:解析 TI=13 时,外层循环次数取vsq & 0x7F,每个对象的地址按信息对象地址连续累加。如果点号不连续,协议里会用地址偏移字段,这时要检查信息对象地址是不是每个对象独立携带。我在代码里给这个循环加了计数器,一旦解析的对象数和 VSQ 对不上,立刻打错误日志,上线后基本没再出过丢点。
5.4 主站收不到突发变位:COT=3 被当成非法帧丢弃
现象:遥测例行召唤正常,但设备状态变化时,从站主动发上来的变位报文主站完全不理,也不报错。
原因:主站侧对 COT 的过滤逻辑写得太严格,只认总召唤响应的 COT=20,把 COT=3(突发)当成非法帧静默丢弃。或者从站侧没有在事件发生时主动调发送接口,把变位信息也塞进总召唤缓冲区,等下一次召唤才发。
解决:两端都要改。从站侧要保证变位时立刻组帧发送,不等待总召唤;主站侧检查asdu_handle_iframe里对 COT 的分支,把 3、11、12、20 等合法原因码都纳入处理范围。调这个问题时最有效的手段是 Wireshark 看主站到底收没收到帧——收到了不处理是应用层问题,没收到是链路层问题,一步就能分清楚。
6. 二次开发:加一个私有 TI 类型,改造主动上报逻辑
6.1 在 ti_map 里注册自定义 TI=70
实际项目里总有规约覆盖不到的数据类型,比如带时标的浮点遥测。扩展方式是在 ti_map.c 里加一条映射:
int asdu_decode_ti70(const uint8_t *p, int len, float *out) { /* 自定义格式:字节 0-5 是公共头,字节 6-9 是 IEEE 754 浮点 */ if (len < 10) return -1; uint32_t tmp = (uint32_t)(p[6] | (p[7] << 8) | (p[8] << 16) | (p[9] << 24)); memcpy(out, &tmp, sizeof(float)); return 0; }注册之后,解析器遇到 TI=70 就走这个分支,上层代码拿到的直接是 float。这里要注意自定义 TI 类型必须避开标准类型标识的范围,IEC60870 里 0-255 大部分已定义,新增前先查一遍文档,现场设备不支持私有类型时能自动降级。
6.2 让主动上报变得可靠:事件队列代替直接发包
默认从站是事件驱动,但直接在主逻辑里调slave_send_asdu有窗口风险。我的习惯是加一个 FIFO:
static fifo_t evt_queue; void main_loop(void) { while (1) { event_t evt; if (fifo_pop(&evt_queue, &evt)) { slave_send_asdu_with_cot(&slave, TI_EVENT, evt.ioa, &evt.val, 3); } usleep(50000); } }这里 COT=3 表示突发,发送前要确认当前链路已激活且发送窗口没满。如果队列满,宁可丢旧报也别丢新报——遥信变位要的是实时性,堵在队列里的旧状态已经不具备参考价值了。
6.3 改造后怎么验证才靠谱
改完协议栈,我一般先把 Wireshark 抓到的新报文丢到测试用例里跑一遍,再起 Python 模拟主站做回归。从那以后,每次加新 TI 类型或改窗口参数,我都会强制走一遍“本地重放 + 全链路抓包 + 异常注入”三连测试,再上现场。规约这种东西,不自己亲手拼一遍报文,永远不知道坑在哪。希望帮到你。
本文还有配套的精品资源,点击获取