简介:面向电力自动化通信开发者的IEC60870协议开源实现库,聚焦IEC60870-5系列中101、102、104三个常用协议,适合需要将主站或子站通信能力集成进自有软件的中高级开发者,可省去从零实现协议编解码的工作。压缩包共114个文件,以39个C源文件与34个头文件为主体,另有12个txt说明、9个makefile及若干mk构建脚本,辅以pem证书、readme、changelog等文档,整体约186KB,结构紧凑便于嵌入工程。已有约2500人学习下载。库内提供主站与子站双向通信、101/102/104协议编码解码、API调用接口及错误检测恢复机制,并支持串口与以太网等传输介质,配套配置调试工具,可帮助读者快速理解协议分层实现、复用成熟代码并提升软件可靠性与兼容性。
1. 从一台变电站网关的调试现场说起
去年帮一个做配电自动化的朋友排查数据上送问题,现场网关跑的是某款商用规约转换软件,日志里全是十六进制报文,抓包能看到68 0E 00 00 00 00 68 0B 01 03 00 01 00 00 00 00 14这种以68开头的帧,但到底是总召唤还是遥测上送,谁也说不清。后来我干脆自己拉了一份 IEC60870 开源实现库的源码,把规约帧的组装、解析、链路状态机全过了一遍,才把问题定位到链路层地址配置不一致上。这个库解决的就是这类问题:把 IEC 60870-5-101/104 这套电力系统远动规约的编码解码、连接管理、应用层服务封装成可复用的代码,让开发者不用对着规约文本一行行抠字节。适合做电力监控、配网自动化、工业网关、规约转换器的后端和嵌入式开发者,也适合想搞懂 104 规约到底怎么跑起来的学习者。
2. 技术栈与目录结构:先看清这套库的骨架
2.1 语言选型与依赖边界
这类规约库的常见做法是用 C 或 C++ 写核心编解码,保证在嵌入式网关和工控机上都能跑,再配一层 C++ 封装或脚本绑定方便上层调用。我拿到的这份实现以 C++ 为主,核心编解码部分刻意保持无第三方依赖,只用到标准库的std::vector、std::string和<cstdint>定长类型,这样交叉编译到 ARM 平台时不会因为链接库缺失翻车。网络层用 POSIX socket 或 asio 二选一,规约库本身不绑定具体 IO 框架,这是它比较讨巧的地方——你可以把它塞进现有的 epoll 事件循环,也可以单独起线程跑阻塞式收发。
构建系统常见的是 CMake,顶层CMakeLists.txt里通过option(BUILD_EXAMPLES)和option(BUILD_TESTS)控制示例和测试是否编译,库本体默认编成静态库,方便静态链接进网关固件。依赖上除了编译器只需要 CMake 3.10 以上,测试用 Catch2 或 gtest,示例程序不依赖任何 UI 库,纯命令行输出十六进制报文,这点对现场调试很友好。
2.2 目录分层与模块职责
把源码包解开,目录大致是这么分的:
iec60870/ ├── CMakeLists.txt ├── include/ │ └── iec60870/ │ ├── link_layer.h # 链路层:帧格式、地址、超时重传 │ ├── application_layer.h # 应用层:ASDU 类型、信息对象 │ ├── apci.h # 104 的 APCI 控制域定义 │ ├── cp56time2a.h # 七字节时标编解码 │ └── types.h # 公共类型、枚举、错误码 ├── src/ │ ├── link_layer.cpp │ ├── application_layer.cpp │ ├── apci.cpp │ └── cp56time2a.cpp ├── examples/ │ ├── client_104.cpp # 104 客户端示例 │ └── server_104.cpp # 104 服务端示例 └── tests/ ├── test_link_layer.cpp └── test_asdu.cpplink_layer负责 101 的 FT1.2 帧和 104 的 APCI 帧的组装与校验,application_layer负责 ASDU 的类型标识、可变结构限定词、传送原因、公共地址和信息对象体,cp56time2a单独抽出来是因为七字节时标在遥测、遥信、电度里到处用,抽出来能单独测。这种分层的好处是改规约版本时只动一层,比如从 101 平衡链路切到 104 TCP,链路层换实现,应用层代码基本不动。
2.3 编译与跑通第一个示例
在 Linux 上编译这套库,我一般这么走:
# 克隆或解压源码后进入根目录 mkdir -p build && cd build # 打开示例和测试,Release 模式减少调试符号 cmake .. -DCMAKE_BUILD_TYPE=Release -DBUILD_EXAMPLES=ON -DBUILD_TESTS=ON make -j4 # 先跑单元测试确认编解码没问题 ctest --output-on-failureCMAKE_BUILD_TYPE=Release影响的是优化级别和断言开关,规约库里有不少assert校验帧长度,Release 下这些断言会被裁掉,所以调试阶段建议先用Debug跑一遍测试。BUILD_EXAMPLES=ON会生成client_104和server_104两个可执行文件,先起服务端再起客户端,服务端默认监听2404端口——这是 104 规约的约定端口,客户端连上后会发启动帧和总召唤,终端里能看到收发报文的十六进制打印。如果ctest有失败,先看是不是字节序问题,规约里多字节字段默认小端,但有些实现会在公共地址上搞错。
提示:交叉编译到 ARM 网关时,把
CMAKE_C_COMPILER和CMAKE_CXX_COMPILER指向工具链里的交叉编译器,同时确认目标平台的sizeof(long),规约里不要用long存 32 位地址,统一用uint32_t。
3. 核心模块拆解:一帧 104 报文从组装到解析
3.1 APCI 控制域的三种帧格式
104 规约在 TCP 之上套了一层 APCI,所有报文以0x68起始,第二个字节是长度,后面四个字节是控制域。控制域的低两位决定帧类型:01是信息传输帧(I 帧),10是编号监视帧(S 帧),11是无编号控制帧(U 帧)。这段代码是控制域解析的核心:
// apci.h 中控制域的结构定义 struct APCI { uint8_t start; // 固定 0x68 uint8_t length; // 控制域+ASDU 的字节数,不含起始和长度本身 uint8_t ctrl[4]; // 控制域四字节 }; // 判断帧类型:取控制域第一个字节的低两位 FrameType getFrameType(const APCI& apci) { uint8_t low2 = apci.ctrl[0] & 0x03; if (low2 == 0x01) return FrameType::I; if (low2 == 0x02) return FrameType::S; return FrameType::U; // 0x03 } // I 帧的发送序号和接收序号:各占两字节,低 15 位有效 uint16_t getSendSeq(const APCI& apci) { return (static_cast<uint16_t>(apci.ctrl[1]) << 8 | apci.ctrl[0]) >> 1; } uint16_t getRecvSeq(const APCI& apci) { return (static_cast<uint16_t>(apci.ctrl[3]) << 8 | apci.ctrl[2]) >> 1; }getFrameType只看低两位,这是规约硬性规定,不要试图用长度或端口去猜帧类型。getSendSeq和getRecvSeq先拼成 16 位再右移一位,因为序号占 15 位,最低位是帧类型标志。这里容易翻车的地方是字节序:ctrl[0]是低字节,ctrl[1]是高字节,拼的时候先移高字节再或低字节,写反了序号就会跳变,表现为客户端一直重传、服务端一直回 S 帧确认但数据不前进。
3.2 ASDU 的组装:以总召唤和遥测上送为例
ASDU 是应用层的数据单元,结构是类型标识(1 字节)、可变结构限定词(1 字节)、传送原因(2 字节)、公共地址(2 字节),后面跟信息对象。总召唤是 U 帧之后客户端发的第一个 I 帧,类型标识0x64,传送原因0x06(激活)。遥测上送类型标识0x0D(短浮点)或0x09(归一化值),可变结构限定词的高位表示是否顺序传送,低七位是信息对象个数。组装遥测 ASDU 的代码大致长这样:
// 组装一个带多个遥测值的 ASDU(类型 0x0D,短浮点) std::vector<uint8_t> buildMeasuredFloatAsdu( uint16_t commonAddr, uint8_t cause, const std::vector<std::pair<uint32_t, float>>& points) { std::vector<uint8_t> asdu; asdu.push_back(0x0D); // 类型标识:短浮点遥测 asdu.push_back(static_cast<uint8_t>(points.size() & 0x7F)); // 非顺序,个数 asdu.push_back(cause); // 传送原因低字节 asdu.push_back(0x00); // 传送原因高字节,通常 0 asdu.push_back(commonAddr & 0xFF); // 公共地址低字节 asdu.push_back((commonAddr >> 8) & 0xFF); // 公共地址高字节 for (const auto& p : points) { // 信息对象地址三字节,小端 asdu.push_back(p.first & 0xFF); asdu.push_back((p.first >> 8) & 0xFF); asdu.push_back((p.first >> 16) & 0xFF); // 短浮点四字节,IEEE754,小端 uint32_t bits; std::memcpy(&bits, &p.second, sizeof(bits)); asdu.push_back(bits & 0xFF); asdu.push_back((bits >> 8) & 0xFF); asdu.push_back((bits >> 16) & 0xFF); asdu.push_back((bits >> 24) & 0xFF); } return asdu; }points.size() & 0x7F是因为可变结构限定词最高位是 SQ 标志,0 表示每个信息对象都带地址,1 表示顺序传送只带首地址。cause传0x03是突发、0x05是请求、0x14是响应总召唤,具体用哪个要看交互阶段。信息对象地址三字节是 104 的约定,101 里是两字节,这也是两层分离的好处——改地址宽度只动应用层。短浮点用memcpy转uint32_t再拆字节,比用 union 更安全,避免严格别名问题。
3.3 链路状态机与超时重传
104 的链路管理靠t0、t1、t2、t3四个定时器:t0是连接建立超时,t1是发送后等确认的超时,t2是无数据时发 S 帧确认的超时,t3是空闲时发测试帧的超时。库里的链路状态机常见做法是用一个enum class LinkState { Closed, Connecting, Connected, Error }加定时器回调,发送 I 帧后把未确认计数加一,收到 S 帧或带接收序号的 I 帧后清计数。这段是发送侧的简化逻辑:
// 发送 I 帧并启动 t1 定时器 bool LinkLayer::sendIFrame(const std::vector<uint8_t>& asdu) { if (state_ != LinkState::Connected) return false; if (unackedCount_ >= kMaxUnacked) { // 窗口满,等确认 return false; } APCI apci; apci.start = 0x68; apci.length = static_cast<uint8_t>(4 + asdu.size()); uint16_t sendSeq = (sendSeq_ << 1) | 0x01; // 左移一位,低位置 1 apci.ctrl[0] = sendSeq & 0xFF; apci.ctrl[1] = (sendSeq >> 8) & 0xFF; uint16_t recvSeq = (recvSeq_ << 1) | 0x01; apci.ctrl[2] = recvSeq & 0xFF; apci.ctrl[3] = (recvSeq >> 8) & 0xFF; std::vector<uint8_t> frame; frame.push_back(apci.start); frame.push_back(apci.length); frame.insert(frame.end(), apci.ctrl, apci.ctrl + 4); frame.insert(frame.end(), asdu.begin(), asdu.end()); if (!transport_->send(frame)) return false; sendSeq_ = (sendSeq_ + 1) & 0x7FFF; // 15 位回绕 unackedCount_++; startTimer(t1_, std::chrono::seconds(15)); // t1 默认 15 秒 return true; }kMaxUnacked是发送窗口,规约建议 12 个 I 帧,超过就得等确认,不然对端缓冲区会溢出。sendSeq_回绕用& 0x7FFF保证 15 位循环,这里如果写成% 32768效果一样但可读性差些。t1超时后要么重传最后一帧,要么直接断链重连,具体看实现策略,我一般选重传一次再断,避免网络抖动导致频繁重连。t2和t3在接收侧和空闲侧触发,t2到期发 S 帧把接收序号告诉对端,t3到期发 U 帧的测试命令0x43,对端回测试确认0x83。
3.4 CP56Time2a 时标编解码
七字节时标是 104 里另一个高频踩坑点,格式是毫秒低字节、毫秒高字节、分钟、小时、日、月、年,其中分钟字节的高两位是无效标志和夏令时标志,日字节的高四位是星期。编码时容易把毫秒当成秒写进去,解码时容易忽略无效标志位。库里的实现:
// 编码:从 std::chrono::system_clock::time_point 转七字节 std::array<uint8_t, 7> encodeCp56Time2a(std::chrono::system_clock::time_point tp) { auto ms = std::chrono::duration_cast<std::chrono::milliseconds>( tp.time_since_epoch()).count(); std::time_t t = ms / 1000; std::tm tm = *std::localtime(&t); uint16_t milli = static_cast<uint16_t>(ms % 1000); std::array<uint8_t, 7> out; out[0] = milli & 0xFF; out[1] = (milli >> 8) & 0xFF; out[2] = static_cast<uint8_t>(tm.tm_min & 0x3F); // 低六位有效 out[3] = static_cast<uint8_t>(tm.tm_hour & 0x1F); // 低五位有效 out[4] = static_cast<uint8_t>(tm.tm_mday & 0x1F); // 低五位有效 out[5] = static_cast<uint8_t>((tm.tm_mon + 1) & 0x0F); // 月份 1-12 out[6] = static_cast<uint8_t>((tm.tm_year + 1900) % 100); // 两位年份 return out; }tm_mon从 0 开始所以要加一,tm_year是相对 1900 的偏移所以要加 1900 再取模 100。分钟掩0x3F、小时掩0x1F、日掩0x1F是为了清掉高位标志,写的时候如果不清,对端解析出来可能把无效标志当成有效数据。解码是逆过程,注意先判断分钟字节的0x80位,置位表示时标无效,这时候不要拿它去做历史数据存储。
4. 避坑与排查:现场最容易翻车的五个点
4.1 连接建立后总召唤无响应
现象是客户端 TCP 连上、发了启动帧0x07,服务端回了启动确认0x0B,但总召唤发出去后没有任何遥测上送。原因多半是公共地址不匹配,服务端按公共地址过滤 ASDU,客户端填的commonAddr和服务端配置的不一致,报文被静默丢弃。解决是把两端的公共地址打印出来比对,或者临时把服务端过滤逻辑改成接受任意地址,确认通了再改回去。我一般会在buildMeasuredFloatAsdu调用处加一行日志,把commonAddr和cause都打出来。
4.2 序号跳变导致链路反复重连
现象是抓包看到 I 帧的发送序号从 0 跳到 2 再跳回 0,服务端回 S 帧的接收序号对不上,几秒后连接断开。原因是sendSeq_在重传路径上被多加了一次,或者getSendSeq的字节序拼反。排查方法是把每次发送前后的sendSeq_和帧里的序号都打日志,对比是否连续。规约要求序号严格递增、15 位回绕,任何跳变都会被对端判为链路错误。
4.3 时标差八小时
现象是上送的遥测时标比实际时间慢八小时。原因是编码时用了std::localtime但运行环境时区是 UTC,或者解码端用了std::gmtime而编码端用了本地时间。规约本身不规定时区,但两端必须约定一致,常见做法是统一用 UTC 编码、展示层再转本地。改法是把std::localtime换成std::gmtime,或者在部署时把容器时区设成Asia/Shanghai并保证两端一致。
4.4 大端平台解析出错
现象是在 x86 上跑得好好的,交叉编译到某款大端 ARM 网关后遥测值全是乱码。原因是代码里直接对float取地址按字节读,没做字节序转换。规约规定多字节字段小端,所以不管宿主平台是什么,编码时都要显式拆字节、解码时显式拼字节,不能依赖memcpy到结构体再强转。上面buildMeasuredFloatAsdu里用memcpy到uint32_t再逐字节拆,就是为了避开这个坑。
4.5 窗口满后死锁
现象是数据量大时链路卡住,抓包看到客户端一直不发新 I 帧,服务端也不回确认。原因是unackedCount_到了kMaxUnacked后sendIFrame直接返回 false,但上层没有重试机制,数据就积压了。解决是在窗口满时把待发数据放进队列,等收到确认回调里再触发发送,而不是简单丢弃。这个坑在突发遥测多的场景特别容易踩,我一般会在sendIFrame返回 false 时打一条警告日志,方便定位。
5. 二次开发:从改一个 ASDU 类型到接自己的数据源
拿到这套库做二次开发,最常见的需求是加一种自定义 ASDU 类型或者对接自己的采集数据。改造路径我一般这么走:先在types.h里加类型标识枚举,再在application_layer.cpp里加对应的编码和解码函数,最后在示例的收发循环里挂上。加类型时注意可变结构限定词的 SQ 位,如果你的数据是连续地址,用顺序传送能省掉每个对象的地址字段,报文更短;地址不连续就老老实实每个对象带三字节地址。
验证方法上,我习惯先写单元测试再改业务代码。tests/test_asdu.cpp里已经有现成的断言框架,加一个TEST_CASE("custom asdu roundtrip"),构造数据、编码、解码、比对字段,跑ctest通过再往示例里接。这样改坏了能立刻定位到是编码错还是解码错,比在现场抓包猜快得多。下面是一个加自定义类型的骨架:
// 1. types.h 里加枚举 enum class AsduType : uint8_t { M_SP_NA_1 = 0x01, M_ME_NC_1 = 0x0D, C_SC_NA_1 = 0x2D, C_IC_NA_1 = 0x64, CUSTOM_TYPE = 0xC0, // 自定义,避开标准已用区间 }; // 2. application_layer.cpp 里加编解码 std::vector<uint8_t> encodeCustom(uint16_t addr, const CustomData& d) { std::vector<uint8_t> out; out.push_back(static_cast<uint8_t>(AsduType::CUSTOM_TYPE)); out.push_back(0x01); // 一个信息对象,非顺序 out.push_back(0x03); // 突发 out.push_back(0x00); out.push_back(addr & 0xFF); out.push_back((addr >> 8) & 0xFF); // 自定义载荷,按约定字段顺序写 out.push_back(d.field1); out.push_back(d.field2 & 0xFF); out.push_back((d.field2 >> 8) & 0xFF); return out; }自定义类型标识选0xC0是因为标准类型集中在0x01到0x64附近,0xC0以上一般留给厂商扩展,但要注意对端也得认这个类型,不然解析会走到默认分支丢弃。载荷字段的字节序和宽度必须两端约定死,最好写进接口文档,别靠口头传。
另一个常见改造是换传输层。库默认用阻塞 socket,如果你的网关是单线程事件循环,把transport_抽象成一个带send和onRecv回调的接口,用 epoll 或 asio 实现,链路层代码不用动。这个抽象点选得好,后面换 TLS、换串口(101 场景)都只是换一个 transport 实现。
最后说个我自己的习惯:每次改完规约相关代码,不管多小的改动,都强制跑一遍ctest加一次示例程序的端到端收发,把收发报文存成文件对比。有次图省事只跑了单元测试,结果示例里公共地址写死成 1,现场设备地址是 3,白跑一趟现场。从那以后我每次动 ASDU 编码都强制走一遍端到端,希望这个习惯也能帮到你。
本文还有配套的精品资源,点击获取