ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

IEC60870开源库实战:从帧解析到二次开发避坑指南

IEC60870开源库实战:从帧解析到二次开发避坑指南

简介:面向电力自动化通信开发者的IEC60870协议开源实现库,聚焦IEC60870-5系列中的101、102、104三种常用协议,适合需要为RTU、变电站自动化系统或SCADA构建主站与子站通信能力的软件工程师,帮助其免去从零实现协议编解码的重复工作。压缩包共114个文件,以39个C源文件与34个头文件为核心,辅以12个txt说明、9个makefile及若干工程配置与证书文件,整体约186KB,体量轻便、便于嵌入现有工程。已有2500人学习下载,具备一定的社区验证基础。库内提供主站与子站双向通信支持、101/102/104协议的编码解码实现及可调用的API接口,并涉及链路层、ASDU信息对象、时间处理等模块,读者可据此快速集成协议栈、理解分层结构并排查通信问题,降低开发成本与兼容性风险。

1. 从一台变电站网关说起:IEC60870 开源实现库能解决什么

去年帮一个做配电自动化的团队排查数据上送问题,现场网关日志里全是0x68开头的十六进制帧,抓包工具解析不出来,运维只能靠猜。后来把帧丢进一个 IEC60870 开源实现库里跑了一遍,APCI、ASDU 一层层剥开,才发现是传送原因字段的私有位被置了,主站直接丢弃。这件事让我意识到,电力规约这类东西,光看标准文本没用,得有一个能跑、能改、能打印中间状态的实现库,才能把黑匣子打开。

IEC60870 是电力系统远动通信里绕不开的一套规约族,常见的是 IEC 60870-5-101(串口)和 104(TCP/IP),用于主站和子站之间的遥测、遥信、遥控、遥调四遥数据交换。这份开源实现库把规约的帧结构、链路层状态机、ASDU 类型编解码都做成了可复用的代码,适合三类人:一是要给现有 SCADA 或网关加 101/104 接入能力的后端开发;二是做电力终端、DTU、FTU 固件、需要自己拼帧解析的嵌入式工程师;三是想拿规约练手、理解远动通信链路的同学。它不替代商业规约栈,但胜在源码可读、可裁剪、可加日志,二次开发成本低。

2. 技术栈与目录结构:先看清这套库的骨架

2.1 语言选型与依赖边界

这类规约库主流有两种写法:C/C++ 追求嵌入式和零依赖,Java/Go 追求跨平台和并发处理。从项目定位看,它走的是「核心协议逻辑与传输层解耦」的路子,规约编解码部分不绑定具体网络框架,串口和 TCP 只是两个适配层。常见做法是核心层只依赖标准库,用结构体或类描述 APDU、ASDU,传输层通过回调或接口注入。这样做的直接好处是:你在 Linux 网关上跑 104,在裸机或 RTOS 上跑 101,编解码代码是同一份,改一处两边都生效。

依赖上要留意两点。第一,串口 101 通常需要 termios(Linux)或 Win32 串口 API,库一般会做条件编译隔离;第二,104 的 TCP 服务端如果用了事件循环,可能引入 epoll 或 select 封装,但这不是规约本身的要求。我一般会先确认核心目录里有没有#include <winsock2.h>这类平台头,判断移植工作量。

2.2 目录分层与模块职责

一个合格的 IEC60870 实现,目录大致会分成下面几层,读源码时按这个顺序切入最省时间:

目录/文件职责读的顺序
core/或src/protocol/APCI/APDU 帧结构、启动/停止/测试链路帧1
asdu/或src/asdu/ASDU 头、信息对象地址、各类 TI(类型标识)编解码2
link/或src/link/101 链路层状态机、平衡/非平衡模式3
transport/串口、TCP 客户端/服务端适配4
app/或examples/主站/子站示例、四遥数据组装5
tests/帧级单元测试、抓包回放随时

先看core能让你明白一帧长什么样,再看asdu知道业务数据怎么塞进去,最后看link和transport才不会被状态机绕晕。反过来从示例入手也行,但调试时容易只看到调用、看不到帧。

2.3 关键数据结构长什么样

规约库的核心不是业务逻辑,而是几个结构体。下面这段是典型的 APDU 与 ASDU 头定义,语言按 C 风格示意,实际字段名以源码为准:

/* APDU:应用规约数据单元,104 的帧头固定 6 字节 */ typedef struct { uint8_t start; /* 固定 0x68 */ uint8_t length; /* APDU 剩余长度,不含 start 和 length 本身 */ uint8_t ctrl[4]; /* 控制域,I/S/U 三种格式共用这 4 字节 */ uint8_t payload[]; /* 仅 I 帧有,承载 ASDU */ } iec_apdu_t; /* ASDU 头:类型标识 + 可变结构限定词 + 传送原因 + 公共地址 */ typedef struct { uint8_t type_id; /* TI,如 1=单点遥信, 9=归一化遥测, 45=单点遥控 */ uint8_t vsq; /* 可变结构限定词:bit7 是否连续,低 7 位信息对象个数 */ uint8_t cot[2]; /* 传送原因,低 6 位原因,高位 test/negative 标志 */ uint8_t ca[2]; /* 公共地址,区分不同子站 */ uint8_t ioa[]; /* 信息对象地址,长度由链路配置决定,常见 2 或 3 字节 */ } iec_asdu_t;

length字段是新手最容易翻车的地方:它统计的是从控制域第一个字节到 APDU 末尾的字节数,不含起始符和长度字节本身。少算一个字节,对端就会把下一帧的头当成当前帧的尾巴,表现为「偶发解析错位」。vsq的 bit7 表示信息对象地址是否连续,连续时只写一个起始 IOA,后续对象地址自增,这个优化在批量遥测上送时很常见,解析时必须分支处理。

3. 跑起来:从编译到一次完整的 104 交互

3.1 编译与最小可运行目标

拿到源码先别急着接现场设备,用示例程序在本机起一对主站和子站最稳。典型构建流程是 CMake 或 Makefile,先看根目录有没有CMakeLists.txt:

# 常见构建流程,具体目标名以源码为准 mkdir -p build && cd build cmake .. -DCMAKE_BUILD_TYPE=Debug cmake --build . -j4 # 列出可执行目标,找 server/client 或 master/slave 示例 find . -maxdepth 2 -type f -executable

-DCMAKE_BUILD_TYPE=Debug不是可有可无的,规约调试大量依赖断点和帧打印,Release 下变量被优化掉,排查状态机跳转会很痛苦。如果项目只有 Makefile,直接make后看bin/或examples/下产物。编译报错集中在串口头文件时,说明当前平台没装对应开发包,先只编 104 部分,把 101 的编译开关关掉。

3.2 起一个子站和一个主站

104 默认端口是 2404,示例通常允许命令行指定端口和公共地址。下面是一次本机联调的典型命令组合:

# 终端 1:启动子站(服务端),监听 2404,公共地址 1 ./iec104_server --port 2404 --ca 1 --ioa-size 3 # 终端 2:启动主站(客户端),连接本机 ./iec104_client --host 127.0.0.1 --port 2404 --ca 1

参数含义:--port是 TCP 监听/连接端口,104 标准默认 2404;--ca是公共地址,主站和子站必须一致,否则 ASDU 会被判为「未知公共地址」丢弃;--ioa-size是信息对象地址字节数,常见 2 或 3,两端必须一致,这是互操作里最高频的配置项之一。启动后主站会先发STARTDT激活传输,子站回STARTDT CON,之后才允许 I 帧业务数据。

3.3 用日志确认链路真的通了

不要只看「连接成功」就以为通了。104 的链路建立分两步:TCP 连接成功只是物理层通,STARTDT握手完成才是应用层可传数据。合格的实现会在日志里打印 U 帧和 I 帧的方向、控制域序号。观察下面几个信号:

  • 出现STARTDT act和STARTDT con,说明传输激活成功;
  • 出现TESTFR act/con周期性帧,说明空闲保活在工作;
  • I 帧的发送序号 N(S) 和接收序号 N(R) 连续递增,没有跳变或回退;
  • 子站上送 ASDU 时,type_id、cot、ca与主站配置匹配。

如果只有 TCP 连接日志、没有 U 帧,多半是主站没发启动帧,或者子站把启动帧当未知帧丢了。这时候把日志级别调到帧级,逐字节对照标准里的控制域定义,比盲猜快得多。

3.4 一次遥测上送的完整链路

以子站主动上送归一化遥测(类型标识 9)为例,代码大致是这样组织的,重点是 ASDU 组装和发送时机:

/* 组装一条归一化遥测并上送,示意流程 */ iec_asdu_t *asdu = asdu_create(TYPE_M_ME_NB_1, CA_SIZE); asdu_set_cot(asdu, COT_SPONT, 0); /* 传送原因:突发/自发 */ asdu_set_ca(asdu, 1); /* 公共地址 */ asdu_add_measured_value(asdu, ioa, value); /* IOA + 归一化值 */ asdu_set_vsq(asdu, 1, 0); /* 1 个对象,地址不连续 */ int rc = link_send_asdu(link, asdu); /* 交给链路层排队发送 */ if (rc != IEC_OK) { log_error("send asdu failed: %d", rc); /* 常见:发送队列满、链路未激活 */ } asdu_free(asdu);

逻辑说明:asdu_create按类型标识和公共地址长度分配缓冲;COT_SPONT表示自发上送,区别于总召唤响应;asdu_add_measured_value负责把工程值转成归一化整数并写入 IOA;link_send_asdu内部会检查STARTDT是否完成、发送窗口是否还有空位。参数上最容易错的是vsq的连续标志和对象个数,批量上送时如果设了连续但只填了一个地址,对端会按个数去读后续地址,读到脏数据。

4. 核心模块拆解:帧编解码、链路状态机与四遥映射

4.1 APCI 三种帧格式的判别

104 的控制域只有 4 字节,却要表达 I、S、U 三种帧,判别靠最低位和次低位。这是整个库最该先吃透的一段:

/* 根据控制域首字节判别帧类型 */ iec_frame_type_t apci_frame_type(const uint8_t ctrl[4]) { if ((ctrl[0] & 0x01) == 0) return IEC_FRAME_I; /* bit0=0 为 I 帧 */ if ((ctrl[0] & 0x03) == 0x01) return IEC_FRAME_S; /* bit0=1,bit1=0 为 S 帧 */ return IEC_FRAME_U; /* 其余为 U 帧 */ }

I 帧承载 ASDU,控制域里同时有发送序号和接收序号;S 帧只确认接收,不带数据;U 帧用于启动、停止、测试。判别顺序不能反:先看 bit0,再看 bit1。把 S 帧误判成 U 帧,会导致确认序号不推进,发送窗口很快耗尽,表现为「发了几帧后卡住不动」。这类问题在日志里看控制域十六进制最直接,0x01开头是 S 帧,0x03/0x07/0x0B开头是各类 U 帧。

4.2 链路层状态机与发送窗口

101/104 的链路层不是简单的收发,它有激活状态、发送序号、接收序号和未确认帧计数。核心状态迁移大致是:初始停止 → 收到STARTDT→ 激活可发 I 帧 → 收到STOPDT或链路异常 → 回到停止。发送侧每发一个 I 帧,N(S) 加一;收到对端 S 帧或 I 帧里的 N(R),把已确认帧从重发队列移除。窗口默认常见为 12 帧,超过未确认上限就停发。

实现上一般用一个环形缓冲存待确认帧,配合定时器做超时重传。读这段代码时重点看三个变量:send_seq、recv_seq、ack_pending。如果现场出现「数据延迟上送但最终能到」,多半是窗口太小加上主站确认慢;如果出现「重复上送」,看重传定时器是不是设得比主站处理还短。这些参数通常放在配置结构体里,改之前先确认两端窗口和超时匹配。

4.3 ASDU 类型标识与四遥数据映射

四遥在 ASDU 层体现为不同的类型标识:单点遥信常见 1,双点遥信 3,归一化遥测 9,标度化遥测 11,短浮点遥测 13,单点遥控 45,设点命令 48 等。库一般会为每类提供编解码函数,内部处理品质描述词 QDS。二次开发时最常改的就是这张映射表:现场点表里一个遥信可能带时标(类型 2/30),一个遥测可能是短浮点(类型 13),如果库只实现了基础类型,就得自己补。

补类型时不要只加枚举,要同时处理三件事:信息对象长度、品质位默认值、时标格式(CP56Time2a 是 7 字节,毫秒在低 16 位)。漏掉时标字节数,整帧长度就会错,对端解析直接失败。我一般会拿一条真实抓包帧做单元测试,编码后再解码,比对每个字段,这比对着标准文本逐条核对可靠。

4.4 传输层适配:串口 101 与 TCP 104 的差异

101 和 104 共用 ASDU,但链路层差别不小。101 走串口,有平衡和非平衡两种模式,非平衡模式下子站只能应答主站召唤,不能主动上送;104 走 TCP,天然支持子站自发上送,还有STARTDT/STOPDT显式激活。移植时最容易忽略的是 101 的帧校验和与固定帧长,串口上还有 0x10 起始、0x16 结束的变长帧格式,和 104 的 0x68 头完全两套。

库如果做了传输抽象,通常定义一个transport_ops接口,包含open/close/send/recv,串口和 TCP 各实现一份。二次开发要换传输介质(比如换成本地 Unix socket 做进程间联调),只需实现这个接口,不用碰规约核心。这也是判断一个规约库设计好坏的关键点:核心层有没有被传输细节污染。

5. 避坑与排查:现场联调最容易翻车的五件事

5.1 帧长度字段算错导致解析错位

现象:主站偶发解析失败,日志里 ASDU 字段明显错乱,重启后又正常一阵。原因:APDU 的length统计范围理解错,把起始符或长度字节本身算进去了,或者 ASDU 里时标、品质位多写/少写一个字节。解决:写一个帧级自检函数,编码后重新解析,比对length与实际缓冲长度;对每条新增类型标识补单元测试,用真实抓包帧做回归。

5.2 公共地址或 IOA 长度两端不一致

现象:TCP 连上了,STARTDT也成功,但业务数据一条都上不来,子站日志显示「未知公共地址」。原因:主站配的 CA 是 1,子站发的是 2;或者一端 IOA 用 2 字节、另一端用 3 字节,ASDU 头之后整体错位。解决:把 CA 和 IOA 长度做成启动时打印的配置项,联调第一步先核对这两个值;抓一帧原始十六进制,手工数 ASDU 头后面的字节,确认 IOA 占几位。

5.3 发送窗口耗尽后链路假死

现象:运行一段时间后不再上送新数据,但 TCP 连接还在,TESTFR也正常。原因:I 帧发出后对端没有回 S 帧确认,未确认计数达到窗口上限,发送侧停发;或者重传定时器没触发。解决:打开帧级日志看 N(S)/N(R) 是否停滞;检查主站是否在处理慢时漏发确认;核对两端窗口大小和 t1 超时参数,必要时先调小窗口验证。

5.4 总召唤响应顺序与品质位处理错误

现象:主站发起总召唤后,部分点收不到或值不对。原因:总召唤激活确认、数据上送、激活终止三段的 COT 用错;或者品质位 QDS 的无效位、溢出位没按现场状态置位,主站按无效丢弃。解决:对照总召唤流程逐帧核对 COT(激活 6、激活确认 7、激活终止 10);品质位按实际采集状态填,不要一律写 0。

5.5 串口 101 的非平衡模式误用

现象:101 子站想主动上送变化遥信,但主站收不到。原因:非平衡模式下子站没有主动发送权,只能等主站召唤;代码里直接调了发送接口,帧发出去了但不符合链路约定。解决:确认链路是平衡还是非平衡,非平衡下把变化数据缓存,等主站召唤或按约定周期响应;需要主动上送就切平衡模式或改用 104。

6. 二次开发:加一个自定义类型标识并验证

真正落到项目里,改得最多的不是链路层,而是 ASDU 类型扩展。假设现场有一类私有遥测,标准类型覆盖不了,需要在库里加一个自定义 TI。我的做法是分四步走,每步都能独立验证。

第一步,在类型枚举和长度表里登记。不要只加枚举值,长度表决定解析时读多少字节,漏了就会越界:

/* 新增私有类型:1 个 IOA + 4 字节浮点 + 1 字节品质 */ #define TYPE_PRIVATE_FLOAT_1 200 static const asdu_type_info_t type_table[] = { { TYPE_PRIVATE_FLOAT_1, 1, 4 + 1, decode_private_float }, /* 其余标准类型... */ };

第二步,写编解码函数,入参出参都用统一的 ASDU 结构,避免在业务层直接拼字节。第三步,补一条单元测试:构造 ASDU → 编码成字节流 → 解码 → 逐字段比对,同时用一条手工构造的十六进制帧做反向验证。第四步,在示例主站里注册该类型的回调,跑一次本机联调,确认日志里类型标识、长度、值都对得上。

验证方法上,我习惯保留一份「黄金帧」文件:几条覆盖启动、总召唤、自发上送、遥控的原始十六进制,每次改完编解码就跑一遍回放,任何字段偏移都会立刻暴露。这比只跑端到端业务测试更容易定位问题,因为端到端失败时你分不清是规约错了还是业务逻辑错了。

还有一个容易忽略的点:扩展类型后要同步更新文档和配置模板,否则下一个接手的人不知道 200 这个类型代表什么。我一般会在类型表旁边直接写注释,标明来源和字段布局,省得日后自己都忘了。

血泪经验是,规约库的改动一定要小步提交,每加一个类型就配一条测试帧,别攒一堆改动再联调。从那以后我每次动 ASDU 编解码,都强制先跑一遍黄金帧回放再连真实设备,这个习惯帮我省掉了大量现场返工。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进