简介面向电力自动化与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 帧控制帧用于启动/停止链路和测试。库的配置结构体里默认参数可以直接背参数默认值含义t015sTCP 连接建立超时t115s发送 I 帧后等待确认的超时t210s接收站无新 I 帧时主动发 S 帧确认的间隔t320s链路空闲时发送测试帧 TESTFR 的周期K12最大发送窗口最多连续发 12 个未确认 I 帧W8最大接收窗口收到 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] 必须是起始符 0x68buf[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 编解码TI13 遥测帧的字节布局打点数据是遥测最常用的类型。TI13 表示带品质描述符的规格化值信息体固定 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 重启后拉不到数据就是这里出了问题。完整时序分三步主站发 TI100、COT6激活从站回 TI100、COT7激活确认然后开始逐个点发遥测 TI13、COT20响应总召唤最后发一帧 TI100、COT10激活终止。这库从站侧的回调注册如下void slave_register_callbacks(slave_t *slave) { /* 注册 TI100 总召唤处理收到 COT6 时进入总召唤流程 */ slave_on_asdu(slave, 100, on_general_interrogation); /* 注册 TI13 遥测点的读取函数按 IOA 查实时库 */ slave_register_point(slave, 13, 0x1234, read_pt_value); }这里有个很容易忽略的设计意图从站响应总召唤用的 COT20而平常突发变位上报用的 COT3。两种类型的报文吞吐要求不一样——总召唤可能一次带几百个点突发上报却要求低延迟。如果你在主站侧只处理 COT20 不处理 COT3就会出现“总召唤好使但设备遥信变位不上送”的诡异现象。协议文档会把 COT 列成表但实际工程里靠日志把 COT 打出来比什么都快。4. 跑起来依赖、编译、最小示例与抓包验证4.1 构建一条命令编译完无第三方依赖这个库用 CMake 组织依赖只有系统自带的 socket 和 pthread交叉编译到 ARM 板上也不需要额外拉库。我本机的编译过程cmake -S . -B build -DCMAKE_BUILD_TYPERelease 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_LIBSON。我平时调试喜欢开 Debug 构建因为协议栈里打了很多log_debugRelease 编译时全被优化掉了现场定位问题还得重新编一版血泪经验。4.2 最小从站代码二十行跑起一个可联调的从站examples/slave_demo.c 是整个库最简单的切入点核心逻辑就这么点#include iec60870_slave.h /* 从站遥测点的读取回调IOA0x1234 返回 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 注册成 TI13 类型的可读点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), timeout5) # U 帧 STARTDT act68 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(TI100, COT6, CA1, IOA0) # 帧长 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 帧不带 ASDU07是 STARTDT actbit0/bit1 为 11bit2 为 STARTDTbit6 为 act0b是 STARTDT conbit6 为 con。总召唤帧的64是 TI10001是 VSQ 表示 1 个对象06 00是 COT6 激活01 00是公共地址 1。收到从站响应后你会看到一串 I 帧里面 TI13 的遥测值就是 5020 的小端拼装。4.4 Wireshark 验证用现成解析器看协议树手动拼报文练一次就够了日常联调还是要靠 Wireshark。抓包时过滤tcp.port 2404Wireshark 会直接识别出 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。解决解析 TI13 时外层循环次数取vsq 0x7F每个对象的地址按信息对象地址连续累加。如果点号不连续协议里会用地址偏移字段这时要检查信息对象地址是不是每个对象独立携带。我在代码里给这个循环加了计数器一旦解析的对象数和 VSQ 对不上立刻打错误日志上线后基本没再出过丢点。5.4 主站收不到突发变位COT3 被当成非法帧丢弃现象遥测例行召唤正常但设备状态变化时从站主动发上来的变位报文主站完全不理也不报错。原因主站侧对 COT 的过滤逻辑写得太严格只认总召唤响应的 COT20把 COT3突发当成非法帧静默丢弃。或者从站侧没有在事件发生时主动调发送接口把变位信息也塞进总召唤缓冲区等下一次召唤才发。解决两端都要改。从站侧要保证变位时立刻组帧发送不等待总召唤主站侧检查asdu_handle_iframe里对 COT 的分支把 3、11、12、20 等合法原因码都纳入处理范围。调这个问题时最有效的手段是 Wireshark 看主站到底收没收到帧——收到了不处理是应用层问题没收到是链路层问题一步就能分清楚。6. 二次开发加一个私有 TI 类型改造主动上报逻辑6.1 在 ti_map 里注册自定义 TI70实际项目里总有规约覆盖不到的数据类型比如带时标的浮点遥测。扩展方式是在 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; }注册之后解析器遇到 TI70 就走这个分支上层代码拿到的直接是 float。这里要注意自定义 TI 类型必须避开标准类型标识的范围IEC60870 里 0-255 大部分已定义新增前先查一遍文档现场设备不支持私有类型时能自动降级。6.2 让主动上报变得可靠事件队列代替直接发包默认从站是事件驱动但直接在主逻辑里调slave_send_asdu有窗口风险。我的习惯是加一个 FIFOstatic 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); } }这里 COT3 表示突发发送前要确认当前链路已激活且发送窗口没满。如果队列满宁可丢旧报也别丢新报——遥信变位要的是实时性堵在队列里的旧状态已经不具备参考价值了。6.3 改造后怎么验证才靠谱改完协议栈我一般先把 Wireshark 抓到的新报文丢到测试用例里跑一遍再起 Python 模拟主站做回归。从那以后每次加新 TI 类型或改窗口参数我都会强制走一遍“本地重放 全链路抓包 异常注入”三连测试再上现场。规约这种东西不自己亲手拼一遍报文永远不知道坑在哪。希望帮到你。本文还有配套的精品资源点击获取