简介:lib60870-2.0.1 是一套面向电力自动化开发者的开源 IEC60870 协议实现库,覆盖 101、102、104 三种常用规约:101 面向串行链路点对点通信,102 用于电能量数据传输,104 基于 TCP/IP 适配广域组网,支持主站与子站双向通信、编解码与多传输介质,适合需要快速接入 SCADA、RTU 或智能电网系统的 C/C++ 工程师,也可作为学习协议栈的参考范本。资源包共 114 个文件、约 186KB,主体为 39 个 C 源码与 34 个头文件,另有 makefile/mk 构建脚本、adoc/md/readme 使用文档、cer/pem 证书及 txt 示例说明,结构清晰,便于按模块阅读、裁剪和移植到嵌入式或桌面平台,并附示例程序与单元测试辅助验证。已有 2499 人学习浏览。通过这套代码可获得完整的协议栈参考实现,包括 cs101/cs104 主从站示例、链路层与 ASDU 编解码逻辑、时间标签处理等,并从中理解规约细节和工程落地方式,节省从零开发协议栈的工期,同时提高通信可靠性与兼容性。
1. IEC60870开源实现库:一份能直接上线的电力通信协议栈
做电力调度或 SCADA 场景的开发者,大概率经历过这样的阶段:手里一批 RTU 要接进监控后台,协议是 IEC 60870-5-104,文档翻了几十页,报文却总对不上。自己从零写协议栈,链路层状态机、ASDU 解析、序号管理、超时重传,每一步都是坑。这个 IEC 60870 开源实现库把 101/104 双栈都封装好了,主站和从站角色实例化就能用,适合变电站监控、配网自动化、储能系统接入的开发者直接拿来做底层通信。核心代码围绕 ASDU 数据模型编解码和链路层状态机展开,改造成本低,接入业务系统很快。
2. 整体架构与目录设计:从 APCI 到业务数据的转换链路
2.1 Master 与 Slave 双角色设计
IEC 60870-5-104 协议框架里,通信双方天然分成主站(控制端)和从站(被控端)。这个库没有把两者做成两套完全独立的代码,而是在同一套抽象之上分别实现CS104Connection和CS101Slave两类对象。主站负责发起连接、周期总召唤、发送遥控命令;从站负责监听端口、维护链路状态、响应总召唤并主动上报变位信息。
这种双角色设计的价值在于,同一份 ASDU 编解码代码被两侧共用。主站收到的遥信遥测数据和从站上报的变位事件,走的是同一个解析入口;区别只在于主站侧需要管理发送序号和接收序号,从站侧需要处理多个并发连接。实际项目中常见的用法是,一个后台服务同时打开一个主站连接去采集下辖的几个 RTU,再挂一个从站服务把聚合后的数据转发给上级调度系统,两侧用的是同一个库。
从线程模型上看,库内部为每个 TCP 连接维护一个独立的工作线程,收发缓冲区和回调分发都在这个线程里串行处理。这样设计的好处是业务回调里不需要加锁,坏处是回调如果执行太久会阻塞链路层的接收循环。常见做法是回调里只做数据拷贝和投递,真正的业务处理放到外部线程池去,这一点在第五部分的避坑记录里会展开讲。
2.2 目录结构与代码分层
整个代码库按功能分成四层,目录结构建议阅读时按这个顺序走:
iec60870/ ├── core/ # 基础数据结构和编解码工具 │ ├── asdu.c # ASDU 编解码核心实现 │ ├── asdu_types.h # 信息对象类型定义与联合体 │ ├── buffer.c # 环形缓冲区实现 │ └── frame.c # 104 帧类型判断与初析 ├── linklayer/ # 链路层 │ ├── cs104_frame.c # 104 帧格式组织与解析 │ ├── cs101_link.c # 101 链路层状态机 │ └── connection.c # 连接管理与超时控制 ├── hal/ # 硬件抽象层 │ ├── thread.c # 线程封装,pthread 与 Win32 两套实现 │ ├── socket.c # TCP/UDP 套接字封装 │ └── time.c # 毫秒级时间戳工具 ├── examples/ # 可直接运行的示例 │ ├── slave_demo.c # 从站示例程序 │ └── master_demo.c # 主站示例程序 └── tests/ # 单元测试与抓包辅助脚本core 层是整个库的基石,把类型标识、信息对象地址、品质描述符这些协议概念全部映射成 C 结构体。linklayer 层负责把 ASDU 装进 104 帧,或者把收到的字节流还原成 ASDU。hal 层把操作系统相关的线程、套接字和时钟全部抽象掉,交叉编译到其他平台时只需要替换这一个目录。examples 目录是上手的捷径,后面跑通部分就基于它来改。
这种分层在二次开发时很舒服。业务代码不会散落到协议解析逻辑里,协议层的修改也不会影响上层的业务模块。我见过不少团队直接在 core 层增加自定义 ASDU 类型,然后把新增的编解码函数挂进 linklayer 的分发表里,改动量通常控制在几百行以内,这就是分层设计带来的直接收益。
2.3 构建系统与外部依赖
构建系统用 CMake,最低版本 3.10,支持 GCC 和 Clang 两类编译器。库本体不依赖任何第三方库,TCP 通信和线程管理都走操作系统标准接口,这个约束是刻意的——电力行业项目经常要部署在国产化系统或者老的 ARM 板子上,依赖越少迁移成本越低。编译产物包括静态库libiec60870.a和几个示例程序。
mkdir build && cd build cmake -DCMAKE_BUILD_TYPE=Release -DENABLE_SAMPLES=ON .. make -j4如果只想编译库本体不带示例,把-DENABLE_SAMPLES改成OFF。交叉编译时设置CMAKE_TOOLCHAIN_FILE指向工具链文件,hal 层的 socket 实现会自动选择 POSIX 分支还是 Windows 分支。构建阶段值得注意的细节有三个:默认 TCP 接收缓冲区大小是 8KB,报文量大时要调大;日志输出默认走 stderr,生产环境建议挂一个日志回调函数,把输出重定向到文件系统;Debug 构建默认开启断言检查,报文异常时直接触发断言,这在开发期帮助很大,但发布版本务必切到 Release。
3. 核心模块拆解:一个报文从网线到回调函数的完整旅程
3.1 ASDU 的编解码:类型标识决定一切
ASDU(Application Service Data Unit)是 IEC 60870 协议里真正承载业务数据的单元。报文里的每一个遥测、遥信、遥控指令都以 ASDU 的形式存在。一个完整的 ASDU 由类型标识(TypeID)、可变结构限定词(VSQ)、传送原因(COT)、公共地址(COA)和若干个信息对象组成。
// 构建一个单点遥信 ASDU(M_SP_NA_1),上报开关分合位 ASDU asdu; ASDU_initialize(&asdu, M_SP_NA_1, false); ASDU_setCOT(&asdu, COT_SPONTANEOUS); // 自发原因,值为 3 ASDU_setCA(&asdu, 0x0001); // 公共地址 ASDU_addInformationObject(&asdu, (InformationObject)SinglePointInfo_create(0x010203, 1, QualityDescriptor_getValid(true)));这段代码创建了一个类型为M_SP_NA_1的 ASDU,传送原因是自发(从站主动上报),公共地址设为 1,然后添加了一个信息对象:信息对象地址0x010203,值是 1(表示合位),品质描述符标记为有效。InformationObject是一个联合体,支持单点信息、双点信息、短浮点测量值、步进命令等几十种类型,具体用哪个取决于 TypeID。
解析侧的逻辑是编解码的镜像。收到一个 ASDU 后,先读类型标识,再用一个分支语句分发到对应的解析函数。难点在变长信息对象的数量计算——VSQ 的低 7 位表示对象个数,最高位 SQ 表示这批对象的地址是否连续。地址连续时只需要传第一个对象的地址再加偏移量;不连续时每个对象都要带完整的三字节地址。这个细节很容易写漏,一旦漏了,解析出来的数据全是错的。
// 解析侧:根据类型标识分发给具体处理函数 static void handleASDU(ASDU asdu, void* userData) { // 短浮点遥测,常见于电压、电流、功率量 if (ASDU_getTypeID(asdu) == M_ME_NC) { MeasuresSequence values; ASDU_getMeasures((uint8_t*)ASDU_getData(asdu), &values); for (int i = 0; i < values.size; i++) { printf("IOA=0x%06X value=%.2f quality=0x%02X\n", values.objects[i].ioa, values.objects[i].value, values.objects[i].quality); } } }一个完整的读取流程是:先用ASDU_parseFromByteArray把裸字节流转成 ASDU 结构体,再根据ASDU_getTypeID拿到类型标识,最后调用对应的取值函数提取信息对象。这个库把信息对象地址统一按三字节处理,高位补零。实际调试时把 IOA 直接打印出来,和现场装置说明书里的点表比对,能很快确认接线和配置是否正确。
3.2 链路层服务状态机:I 帧、S 帧与 U 帧的协作
104 协议的链路层和 101 最大的不同是它跑在 TCP 之上,因此多了一层连接管理和确认机制。帧类型分三种:I 帧承载数据并携带收发序号,S 帧只做接收确认,U 帧负责建立连接、启停数据传输和测试链路。每帧固定是68 长度 控制域1~4加上紧随其后的 ASDU 载荷。
// I 帧控制域构造示例 static void buildIFrame(uint8_t* buffer, uint16_t sendSeq, uint16_t recvSeq) { buffer[0] = 0x68; // 起始字符,固定值 buffer[1] = 0x00; // 长度字段,回调前回填 buffer[2] = (uint8_t)((sendSeq << 1) & 0xFE); // 发送序号低位 buffer[3] = (uint8_t)((sendSeq >> 7) & 0x01); // 发送序号高位 buffer[4] = (uint8_t)((recvSeq << 1) & 0xFE); // 接收序号低位 buffer[5] = (uint8_t)((recvSeq >> 7) & 0x01); // 接收序号高位 }控制域四个字节里,真正有用的比特位很少:发送序号占 15 位,接收序号占 15 位,其余是帧类型标识位。注意序号是每发一帧加一,而不是按字节计数。接收方校验序号是否连续,如果不连续就丢弃后续帧直到收到正确的重传。U 帧不带序号,三个命令分别是 STARTDT(启动数据传输)、STOPDT(停止数据传输)、TESTFR(链路测试),各自的确认帧是对应控制域加 0x10 偏移。
从站收到 I 帧后并不会每帧都回复 S 帧,常见做法是攒够一定数量的帧或者超过一个很短的时间窗口后统一确认,这个窗口默认是 5 帧或 50 毫秒。主站侧如果发出去 12 帧还没收到确认,就会关闭发送窗口,停止继续发送并启动超时重传。这个半窗口机制是协议性能和可靠性的关键平衡点,很多现场断链问题都出在窗口和超时参数的配合上。
3.3 回调机制与数据上行通道
从站侧的数据上行是整个库最常用的一条路径。协议栈在收到主站的召唤命令或者检测到内部数据变化后,需要把结果送给业务层。这个库用的是注册回调函数的方式,而不是队列加轮询的传统模型。
// 注册从站数据上报回调 CS101_Slave slave = CS101_Slave_create(2404, &slaveParams, &linkLayerParams); CS101_Slave_setASDUHandler(slave, handleASDU, NULL); CS101_Slave_start(slave);setASDUHandler注册的handleASDU会在每个完整 ASDU 被解析出来后立即调用。参数里的那个指针可以指向自己的业务上下文,比如一个会话结构体或者一个环形缓冲区。库内部保证同一个连接上的回调不会并发进入,但多个连接之间是并行回调的,如果业务代码要访问共享数据,还是得自己加锁。
还有一个更细粒度的上报接口,在每一个信息对象被解析后触发,适合按单个开关量或模拟量处理业务的场景。这两种回调不能同时用,注册了对象级回调后,ASDU 级回调就不会被触发。实际使用中,数据量小用 ASDU 级回调,数据量大用对象级回调直接逐条投递,避免在回调里再拆一层。回调模型的选择,直接影响后续业务代码的组织方式。
4. 编译构建与运行验证:把协议栈跑通
4.1 编译参数与构建步骤
源码包里的构建流程已经在主流 Linux 发行版和 Windows 上验证过。构建命令前面给过,这里补充几个实际操作中经常调整的 CMake 开关:
cmake -DCMAKE_BUILD_TYPE=Debug \ -DENABLE_SAMPLES=ON \ -DENABLE_LOGGING=ON \ -DBUILD_SHARED_LIBS=ON ..ENABLE_LOGGING控制协议栈内置日志。开发阶段建议始终开着,它会把每个收到的原始报文按十六进制打印出来,排查问题时不至于对着裸字节发呆。发布时建议关掉,因为日志输出本身对时序有影响,在真实调度场景里可能干扰链路层的超时判断。还有一个开关控制是否编译 101 链路层代码,如果项目只走 104 走 TCP,关掉它可以减小编译产物体积。
编译完成后,build/examples目录下会生成master_demo和slave_demo两个可执行文件。这两个示例是理解库用法的活文档,代码量不大,覆盖了连接建立、总召唤、遥控下发、变位上报四条核心交互路径。我拿到一个新版本源码的习惯动作,是先跑这两个示例确认环境没问题,再开始看业务集成代码。
4.2 快速搭建一个主站实例
主站的业务逻辑围绕CS104_Connection展开。创建连接时传入从站 IP 和端口,然后设置参数、注册回调、发起连接。下面这段代码基本是一个最小可用的主站模板:
CS104_Connection conn = CS104_Connection_create("192.168.1.100", 2404); CS104_Connection_setConnectionTimeout(conn, 10); // 连接超时 10 秒 CS104_Connection_setSentIFrameTimeout(conn, 10); // 发送超时 10 秒 CS104_Connection_setMaxSentIFrames(conn, 12); // 未确认最大帧数 CS104_Connection_setASDUReceivedHandler(conn, handleASDU, &appContext); CS104_Connection_connect(conn); CS104_Connection_sendInterrogationCommand(conn, 1, IEC60870_QOI_STATION);最后一行发出的是一轮站总召唤,参数IEC60870_QOI_STATION表示全站数据,从站收到后会按顺序把全部遥信、遥测数据以多个 ASDU 回送。实际的调度系统里,总召唤通常定时执行,比如每小时一次,用来修复长期运行中可能出现的微小数据偏差。召唤命令本身也是一个 ASDU,类型是 C_IC_NA_1,不带信息对象,只带一个召唤限定词。
handleASDU回调里的实现可以参照上一章的解析代码,把遥信、遥测分类归档到内部数据结构。这里有一个值得借鉴的做法:回调里只做数据拷贝,用轻量锁保护一个队列,另一个线程负责消费这个队列写入数据库或者做界面刷新。这样即使从站短时间内爆发大量数据,也不会拖慢链路层接收速度。
4.3 模拟从站与收发验证
没有真实装置的时候,直接用slave_demo当模拟从站是最快的验证方式。默认配置下它会监听 2404 端口,内部维护一组模拟数据点,收到总召唤命令后回送 100 个遥测点和 50 个遥信点。
# 终端 A:启动模拟从站 ./slave_demo # 终端 B:用主站示例发起总召唤并打印结果 ./master_demo 127.0.0.1 2404终端 B 会打印类似下面的输出,每一行对应一个信息对象:
RECV ASDU type=13 (M_ME_NC) cot=3 (spontaneous) ca=1 IOA=0x000001 value=220.50 quality=0x00 IOA=0x000002 value=10.32 quality=0x00如果收不到输出,按顺序检查三件事:两个进程是否都正常启动;主站连接参数里的端口和从站监听端口是否一致;系统防火墙有没有拦截 2404 端口。协议层的抓包建议用带 IEC 60870-5-104 解析器功能的抓包工具,能直接看到帧类型、序号和 ASDU 内容,比单纯看 HEX dump 直观得多。开发阶段养成抓包对比的习惯,能省掉大量后面找人排障的时间。
5. 协议调试避坑指南:五个真实踩坑记录
5.1 发送序号不回卷导致连接假死
现象:主站运行一段时间后停止发送数据,日志里看不到任何新帧,TCP 连接没有断开,从站也没有收到数据。
原因:发送序号是 15 位,范围 0 到 32767。长时间连续上报的数据量很大时,序号达到上限后如果实现里忘了回卷,序号溢出变成负值或者超出帧格式能容纳的位宽,接收端校验序号时认为不连续,直接丢弃所有后续帧。主站侧表现为停止发送,从站侧表现为主站静默。
解决:检查序号递增代码,确认每次加一之前判断是否等于 32767,是则回零。更稳妥的方式是让序号变量用无符号类型,让位运算的截断自然完成回卷。验证方法是在测试环境人为往发送循环里加一个计数器,触发序号跳变,观察从站是否恢复正常接收。
5.2 遥信变位丢失:COT 与时钟对不上
现象:从站明明上报了变位,比如开关跳闸,主站却收不到任何记录,日志里只有周期性总召唤的数据。
原因:变位上报告要求的传送原因是自发,值为 3。现场不少装置默认配置的原因码是周期,值为 2,主站侧的过滤逻辑遇到非自发原因的事件型 ASDU 会直接丢弃,因为无法确认这是新变位还是重复数据。另一个常见原因是事件时标字段的三字节毫秒时标溢出或者使用了零时标,主站收到后因为时标异常直接忽略。
解决:第一步查从站配置文件里事件上送的原因码设置,改成 3;第二步在主站回调里打印 ASDU 的 COT 和时标字段,看实际收到的值。如果是时标为零,尝试关闭主站侧的时标校验逻辑,或者修改从站的时钟同步周期,先排除协议栈本身的问题再查时钟源。
5.3 多从站接入时公共地址冲突
现象:主站同时接十几个 RTU,其中两个 RTU 的数据串到另一个设备名下,遥测值张冠李戴,画面上的曲线乱跳。
原因:104 协议里公共地址(COA)用于区分不同的从站或逻辑设备,但默认配置下很多 RTU 出厂时 COA 都是 1。主站侧如果只按连接区分数据源,而没有把连接和 COA 联合起来做映射,就会出现不同设备上报的数据在同一个设备节点下堆积,路由不到正确的数据点。
解决:所有设备接入前先做 COA 规划,每个 RTU 分配一个唯一地址。主站侧建立一张连接到 COA 集合的映射表,收到 ASDU 时先取 COA 再查表路由。开发期可以在回调入口打印 COA 字段,用一个简单脚本扫描所有接入设备是否出现重复 COA。这个坑在集中器场景尤其常见,值得提前规划地址表,不要等设备挂上去了再改。
5.4 结构体位域与大小端对齐问题
现象:跨平台编译后,遥控命令的开关分合位取反,品质描述符的字节错位。在开发机的 x86 上一切正常,部署到 ARM 板子上就翻车。
原因:协议栈的某些实现直接用位域结构体去映射报文字节,比如把品质描述符的 8 个 bit 用带位域的匿名结构体表示。C 语言标准不保证位域的内存布局,同一套代码在不同架构上的位域分配方向可能相反。远动装置的双点遥控恰好依赖最高位,一旦位序反了,合位变分位,分位变合位,这在调度台上是严重事故。
解决:不用位域结构体直接映射报文,全部改成手工掩码和移位操作。品质描述符的读取收敛到一个函数里,内部用(byte >> bitIndex) & 0x01逐位提取,写入同理。这个问题排查起来很费时间,我踩过一次之后,新代码里所有跨平台报文解析一律禁止位域映射,宁可多写几行移位代码。
5.5 心跳超时参数设置不当引发频繁断链
现象:主站和从站都在线,但每隔几分钟连接就断开重建一次,日志里反复出现 STOPDT 和 STARTDT,业务数据跟着中断。
原因:协议的 t3 参数控制链路空闲时的测试帧发送周期,主站和从站两端如果配置不一致,比如主站设 20 秒,从站设 30 秒,主站发 TESTFR 后从站可能不确认,主站超时后判定链路异常直接断开。另一种情况是心跳周期设置太短,确认帧打进业务数据洪峰里被延迟,导致误判。
解决:两端的 t0(连接超时)、t1(发送超时)、t2(确认超时)、t3(空闲测试周期)必须完全一致,且满足 t2 小于 t1、t3 大于 t2 的关系。推荐一组经过验证的配置:t0 取 10 秒,t1 取 15 秒,t2 取 10 秒,t3 取 20 秒。修改完参数后跑 24 小时稳定性测试,观察日志里 TESTFR 帧的响应时间是否稳定在 1 秒以内。
6. 二次开发改造:从拿来用到定制自己的协议栈
6.1 扩展一个私有 ASDU 类型
接入某些非标装置时,厂商会在标准 ASDU 类型之外自定义类型标识,通常落在 100 到 127 的保留段,用于传输私有数据。改造步骤是先在前面的asdu_types.h里加枚举值定义,再在asdu.c的编解码函数里补一个分支解析私有信息结构体。需要留意的是,新类型也要在类型名称映射表里登记,否则日志和调试工具打印出来的是 UNKNOWN,排查问题时全靠猜。
case 100: // 私有类型,报文头带设备状态字 parsePrivateType(asdu, &obj); break;6.2 数据通道替换:从回调打印到消息队列
生产环境里数据要进时序数据库或者消息中间件,直接把回调函数替换成队列生产者的实现即可。常见做法是在回调里组装一个结构体推入队列,由独立的消费者线程负责批量写入。需要保证的是回调的投递操作不能阻塞,队列满了就丢弃并计数,等链路空闲时靠周期总召唤补偿数据空缺。
6.3 性能验证与参数调优
跑通之后建议压一轮性能。用并发脚本模拟多主站同时连接,观察库的 CPU 占用和数据吞吐。经验上单核处理 2000 点规模的百毫秒级周期上送没有压力,瓶颈基本出现在业务回调而不是协议栈本体。调优时优先调两处:TCP 收发缓冲区改大一个数量级;链路层确认窗口从默认的 12 调到 20,前提是从站端参数同步调整,否则两端的窗口不匹配反而引发重传风暴。
我每次把协议栈接入新项目,都会强制走一遍完整的报文抓取对比流程:把每个 ASDU 的 TypeID、COT、COA、IOA 逐一记录,与装置侧导出的点表比对,全部核对无误后才开始写业务代码。这个习惯帮我躲过了数不清的现场调试环节。希望这份文档能帮你在 IEC 60870 这条路上少踩几个坑。
本文还有配套的精品资源,点击获取