☰
IEC60870开源库实战:104规约编解码、避坑与二次开发
2026/10/16 3:28:18 网站建设 项目流程

简介:lib60870-2.0.1 是 IEC60870-5 系列协议的开源实现库,面向电力自动化、SCADA 与智能电网领域的开发者,帮助其快速集成 101、102、104 协议,构建主站或子站应用,无需从零实现底层通信逻辑。压缩包共 114 个文件,约 186KB,以 39 个 c 源文件与 34 个 h 头文件为核心,覆盖链路层、ASDU 编解码、主从站连接等模块,另含 9 个 makefile、3 个 mk 构建脚本、12 个 txt 说明及 pem 证书、readme、changelog 等辅助文件,结构紧凑便于移植。目前已有 2496 人学习下载。库内提供 API 接口、错误检测与恢复机制,并支持串口、以太网等多种传输介质,读者可据此掌握 101/102/104 的编码解码流程与主从站双向通信实现,快速搭建调试环境,降低协议开发成本,提升软件可靠性与兼容性。

1. 从一次变电站调试翻车说起:IEC60870 开源实现库到底能干什么

去年帮一个做配电自动化的朋友排查问题,现场装置用的是 IEC 60870-5-104 规约,主站那边死活收不到遥测数据。抓包一看,链路层握手正常,但总召唤报文发出去之后,从站回的 ASDU 里传送原因字节被解析错了——对方主站用的是某商业库,对 CP56Time2a 时间戳的毫秒字段处理有偏差。那次之后我就养成了一个习惯:手头常备一套能读能改的开源 IEC60870 实现,出问题直接翻源码对字节,比对着规约文档猜快得多。

IEC60870 这套规约在电力监控、轨道交通、水利调度里用得极广,尤其是 104 规约(IEC 60870-5-104),跑在 TCP 之上,是厂站和调度主站之间的标准语言。但规约文档厚、字段多、字节序和时标格式容易踩坑,商业协议栈又是个黑匣子,出了问题只能等厂商支持。这个开源实现库的价值就在这儿:它把 101(串口)、104(网络)两套链路层的编解码、ASDU 类型解析、总召唤/时钟同步/遥控等典型流程都摊开给你看,代码可读、可改、可嵌到自己项目里。适合谁?做电力/工控上位机、协议网关、规约测试工具的开发者,以及需要把 104 从站塞进嵌入式设备的人。下面我按「能干什么 → 怎么跑 → 怎么改」的顺序,把这份源码拆一遍。

2. 架构与目录拆解:链路层、ASDU、应用层怎么分层

2.1 整体分层设计

这套库的分层思路很清晰,基本照着规约本身的层次来切。最底下是链路层,负责 101 的 FT1.2 帧格式和 104 的 APCI(启动字符 0x68、长度、控制域四字节);往上是传输层/会话层,处理 104 的发送序号 N(S)、接收序号 N(R) 和 k/w 窗口流控;再往上是ASDU 层,也就是应用服务数据单元,承载类型标识、可变结构限定词、传送原因、公共地址和信息对象;最上面是应用层,封装总召唤、时钟同步、遥控、遥调这些业务动作。

这种分法好处是改一层不影响其他层。比如你要把 104 换成 101 串口,链路层换掉,ASDU 和应用层几乎不用动。我见过不少项目把三层揉在一个文件里,加个新类型标识就得全局搜字符串,维护起来是血泪经验。

2.2 目录结构说明

源码包解压后大致是这么个结构(不同实现命名略有差异,但职责划分一致):

iec60870/ ├── src/ │ ├── link/ # 链路层:FT1.2 帧、APCI 控制域 │ │ ├── ft12.c # 101 串口帧编解码 │ │ └── apci.c # 104 的 I/S/U 帧格式 │ ├── asdu/ # ASDU 编解码 │ │ ├── asdu.c # 类型标识分发 │ │ └── types.c # 各信息对象类型(单点、双点、归一化值等) │ ├── app/ # 应用层业务 │ │ ├── interrogation.c # 总召唤 │ │ ├── clock_sync.c # 时钟同步 │ │ └── command.c # 遥控/遥调 │ ├── session/ # 会话管理、序号维护 │ └── port/ # 平台适配层(socket、串口、内存) ├── include/ # 对外头文件 ├── examples/ # 示例:主站/从站 demo ├── tests/ # 单元测试 └── CMakeLists.txt

port/这层是移植的关键,它把 socket、串口读写、内存分配、时间获取都抽象成函数指针,换平台只改这一层。examples/里通常有一个 104 从站和一个 104 主站的 demo,跑起来就能看到完整交互。

2.3 关键数据结构

ASDU 是核心,几乎所有业务都围绕它转。典型定义长这样:

typedef struct { uint8_t type_id; // 类型标识,如 1=单点信息, 9=归一化测量值 uint8_t vsq; // 可变结构限定词,最高位SQ,低7位是信息对象个数 uint8_t cot; // 传送原因,如 6=激活, 7=激活确认, 20=响应总召唤 uint8_t ca; // 公共地址,区分不同厂站 uint8_t oa; // 源发站地址(101 用,104 常为 0) void *objects; // 信息对象数组,按 type_id 决定元素类型 int obj_count; // 对象个数 } iec_asdu_t;

vsq这个字段最容易翻车:最高位 SQ=1 表示后面是「一个信息对象 + 连续地址序列」,SQ=0 表示「多个独立信息对象」。很多新手解析时不管 SQ 直接按个数读,遇到 SQ=1 的报文就错位。cot的取值也要对着规约表查,6/7/8/9/10 是激活相关,20 是响应总召唤,这些在asdu.c里通常有个 switch 分发。

2.4 104 的 APCI 控制域

104 的帧分 I、S、U 三种,靠控制域第一个字节的低两位区分:

// 控制域四字节解析 // I 帧:bit0=0, bit1=0,含发送序号和接收序号 // S 帧:bit0=0, bit1=1,只有接收序号 // U 帧:bit0=1, bit1=1,用于 STARTDT/STOPDT/TESTFR static int apci_frame_type(const uint8_t *ctrl) { if ((ctrl[0] & 0x01) == 0) return I_FRAME; if ((ctrl[0] & 0x03) == 0x01) return S_FRAME; return U_FRAME; }

I 帧的发送序号在 ctrl[0..1] 的低 15 位,接收序号在 ctrl[2..3] 的低 15 位,都是小端。这里有个坑:序号是 15 位循环,到 32767 回绕到 0,比较序号时不能直接用大于小于,得用模运算。session/里维护序号的那段代码值得细看。

3. 跑起来:从编译到主从站联调的完整步骤

3.1 编译与依赖

这套库一般用 CMake 构建,依赖很少,标准 C 库加平台 socket 就够。Linux 下:

mkdir build && cd build cmake .. -DCMAKE_BUILD_TYPE=Release make -j4

如果开了测试,make完会生成iec_test之类的可执行文件。Windows 下用 MinGW 或 MSVC 都行,port/层里通常有port_win32.c和port_posix.c两套适配,CMake 里按平台自动选。要是编译报找不到sys/socket.h,说明平台宏没配对,去CMakeLists.txt里看if(WIN32)那段。

3.2 启动一个 104 从站

examples/里的从站 demo 是理解整个流程的最好入口。核心就三步:初始化、绑定端口、进事件循环。

#include "iec60870.h" int main(void) { // 1. 创建从站实例,指定公共地址和端口 iec_slave_t *slave = iec_slave_create(1 /*CA*/, 2404 /*端口*/); if (!slave) return -1; // 2. 注册回调:收到总召唤时怎么回数据 iec_slave_set_interrogation_cb(slave, on_interrogation); // 收到遥控时怎么处理 iec_slave_set_command_cb(slave, on_command); // 3. 启动监听,进循环 iec_slave_listen(slave); while (iec_slave_run(slave, 100 /*ms*/) == 0) { // 周期性上送变化遥测 iec_slave_send_measurement(slave, 1, 0x4001, 12345); } iec_slave_destroy(slave); return 0; }

iec_slave_create的第二个参数是监听端口,104 默认 2404。iec_slave_run每次跑 100ms,内部处理收包、超时、心跳(TESTFR)。回调on_interrogation里你要把当前所有遥测遥信按 ASDU 格式填回去,传送原因填 20。这里注意:总召唤响应可能分多帧发,因为一帧 ASDU 的信息对象个数有限(受 APDU 最大长度 253 字节限制),库一般会自动分包,但你要保证回调里把数据一次性给全。

3.3 启动一个 104 主站去连它

主站侧对称:

iec_master_t *m = iec_master_create("127.0.0.1", 2404, 1 /*CA*/); iec_master_set_asdu_cb(m, on_asdu_received); iec_master_connect(m); // 发起总召唤 iec_master_interrogation(m, 1 /*CA*/, 20 /*QOI: 站总召唤*/); while (iec_master_run(m, 100) == 0) { // 主循环 }

iec_master_interrogation的第三个参数是召唤限定词 QOI,20 表示站总召唤,21~36 是分组召唤。发完之后从站会回一系列 ASDU,on_asdu_received里按type_id分发处理。联调时先看能不能收到 STARTDT 确认,再发总召唤,最后看遥测值对不对。

3.4 用抓包工具验证

跑起来之后别急着写业务,先用 Wireshark 抓一下 2404 端口。过滤tcp.port == 2404,能看到 U 帧的 STARTDT 激活、I 帧的总召唤和响应。Wireshark 自带 104 解析器,能把 APCI 和 ASDU 字段都展开。对照抓包和源码里的编解码函数,哪个字节对不上立刻就能定位。我一般会先构造一个已知内容的 ASDU,用库编码成字节,再和抓包里的原始字节逐位比对,这是最快的验证方式。

提示:如果从站收不到主站的连接,先确认防火墙和端口占用;如果连上了但总召唤无响应,检查公共地址 CA 是否匹配,很多现场问题就出在 CA 配错。

4. 避坑与排查:那些年我们在 104 上踩过的雷

4.1 序号回绕导致连接莫名断开

现象:跑几个小时或几天后,主从站突然断链,重连后又正常,周而复始。原因:104 的发送/接收序号是 15 位,到 32767 回绕。如果代码里用if (send_seq > ack_seq)判断是否超窗,回绕后就判断反了,k 窗口(默认 12)被误判超限,触发断链。解决:序号比较一律用模运算,(send_seq - ack_seq + 32768) % 32768得到未确认帧数,再和 k 比较。session/里如果有现成的窗口判断函数,直接复用,别自己写。

4.2 CP56Time2a 时标解析差 8 小时

现象:主站显示的遥测时间比实际早或晚 8 小时。原因:CP56Time2a 的时标是本地时间还是 UTC,规约里没强制,不同厂商实现不一样。有的库默认按 UTC 解析,现场装置发的是本地时间,就差出时区偏移。解决:在配置里加一个时区/时标模式开关,解析时按需加减偏移。types.c里解析 CP56Time2a 的函数通常长这样:毫秒在低 16 位,分钟在 bit0~5,小时在 bit0~4,日和月年依次排。改之前先抓包确认装置发的是哪种。

4.3 SQ 位没处理导致信息对象错位

现象:解析带多个信息对象的 ASDU 时,第一个值对,后面全乱。原因:vsq最高位 SQ=1 时,报文里只有一个信息对象,后面跟的是连续地址;SQ=0 才是多个独立对象。代码里如果统一按「对象个数」循环读,SQ=1 的报文就会把地址当成值读。解决:解析前先判 SQ 位,分两条路径。asdu.c里一般有asdu_parse_objects函数,进去先看vsq & 0x80。

4.4 总召唤响应超时

现象:主站发了总召唤,从站没回或只回了一部分。原因:一是从站回调里数据没填全,二是分包逻辑有问题,三是 APDU 超过 253 字节被截断。解决:先确认回调被调用了(加日志),再看返回的 ASDU 个数。如果数据量大,库应该自动分包,每包传送原因都是 20,最后一包带激活终止(COT=10)。如果库没自动分包,得自己在回调里分批调发送函数。

4.5 遥控返校流程走不通

现象:主站发遥控选择,从站没回选择确认,或直接执行了。原因:104 遥控是「选择-执行」两阶段,选择(COT=6)和执行(COT=6,但 S/E 位不同)要分开处理。有的实现把两个阶段揉在一起,或者 S/E 位(在信息对象地址里)没解析对。解决:command.c里按 S/E 位分发,选择阶段只校验不动作,回 COT=7 确认;执行阶段才真正操作,回 COT=7 或 10。抓包看 S/E 位是 0 还是 1。

5. 二次开发:加一个自定义 ASDU 类型和移植到新平台

5.1 扩展一个新的类型标识

规约里类型标识是 1~127,有些私有实现会用 128~255 做扩展。假设你要加一个类型标识 200 的自定义遥测,步骤是:

// 1. 在 types.h 里定义类型和结构 #define IEC_TYPE_CUSTOM_MEASURE 200 typedef struct { uint16_t address; float value; uint8_t quality; } custom_measure_t; // 2. 在 types.c 的编解码分发里注册 static int custom_measure_encode(const void *obj, uint8_t *buf, int len) { const custom_measure_t *m = (const custom_measure_t *)obj; // 地址 3 字节小端 buf[0] = m->address & 0xFF; buf[1] = (m->address >> 8) & 0xFF; buf[2] = 0; // 值 4 字节浮点,按需转字节序 memcpy(&buf[3], &m->value, 4); buf[7] = m->quality; return 8; // 返回编码后字节数 } // 3. 在 asdu.c 的分发表里挂上 static const iec_type_handler_t type_handlers[] = { // ... 已有类型 { IEC_TYPE_CUSTOM_MEASURE, custom_measure_encode, custom_measure_decode }, };

关键是编解码函数要返回实际字节数,分发层靠这个推进缓冲区指针。加完之后,主从站两侧都要更新,否则一边发一边不认。测试时先单元测试编解码,再联调。

5.2 移植到新平台

移植只动port/层,需要实现这几个函数:

函数作用注意事项
port_tcp_listen创建监听 socket设置 SO_REUSEADDR
port_tcp_accept接受连接非阻塞或带超时
port_tcp_send/recv收发数据处理 EAGAIN 和部分写
port_serial_open打开串口(101 用)波特率、校验位配置
port_get_time_ms取毫秒时间用于超时和时标
port_malloc/free内存管理嵌入式可换成静态池

嵌入式平台如果没 socket,104 就用不了,只能走 101 串口。串口那套 FT1.2 帧有固定头 0x68 和校验和,ft12.c里编解码是独立的,移植时重点测校验和计算和转义(0x68 在数据里要转义)。

5.3 一个调试技巧:字节级对照

我改协议栈有个习惯,每次改完编解码,先写个最小测试:构造一个已知 ASDU,编码成字节数组,打印出来,再手动对着规约文档逐字节核。比如总召唤响应的单点信息,类型标识 1,VSQ 0x01,COT 20,CA 1,然后信息对象地址 3 字节加 SIQ 1 字节。核完再解码回来,看是否还原。这个习惯帮我省了无数次现场返工——协议这东西,差一个字节就是收不到数据,抓包也未必一眼看出来。

从那以后我每次动协议栈代码,都强制走一遍「编码-打印-对照-解码」的闭环,哪怕只改了一个字段。希望这套拆解能帮到你,把 IEC60870 这个开源库跑起来、改起来,少走点我当年走过的弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询