资讯动态

IEC 60870-5-104 规约库实战:从报文解析到二次开发

发布时间:2026/10/28 2:52:08 来源:尧图企业网站定制
简介面向电力自动化通信开发者的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_TYPERelease -DBUILD_EXAMPLESON -DBUILD_TESTSON make -j4 # 先跑单元测试确认编解码没问题 ctest --output-on-failureCMAKE_BUILD_TYPERelease影响的是优化级别和断言开关规约库里有不少assert校验帧长度Release 下这些断言会被裁掉所以调试阶段建议先用Debug跑一遍测试。BUILD_EXAMPLESON会生成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_castuint16_t(apci.ctrl[1]) 8 | apci.ctrl[0]) 1; } uint16_t getRecvSeq(const APCI apci) { return (static_castuint16_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::vectoruint8_t buildMeasuredFloatAsdu( uint16_t commonAddr, uint8_t cause, const std::vectorstd::pairuint32_t, float points) { std::vectoruint8_t asdu; asdu.push_back(0x0D); // 类型标识短浮点遥测 asdu.push_back(static_castuint8_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::vectoruint8_t asdu) { if (state_ ! LinkState::Connected) return false; if (unackedCount_ kMaxUnacked) { // 窗口满等确认 return false; } APCI apci; apci.start 0x68; apci.length static_castuint8_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::vectoruint8_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::arrayuint8_t, 7 encodeCp56Time2a(std::chrono::system_clock::time_point tp) { auto ms std::chrono::duration_caststd::chrono::milliseconds( tp.time_since_epoch()).count(); std::time_t t ms / 1000; std::tm tm *std::localtime(t); uint16_t milli static_castuint16_t(ms % 1000); std::arrayuint8_t, 7 out; out[0] milli 0xFF; out[1] (milli 8) 0xFF; out[2] static_castuint8_t(tm.tm_min 0x3F); // 低六位有效 out[3] static_castuint8_t(tm.tm_hour 0x1F); // 低五位有效 out[4] static_castuint8_t(tm.tm_mday 0x1F); // 低五位有效 out[5] static_castuint8_t((tm.tm_mon 1) 0x0F); // 月份 1-12 out[6] static_castuint8_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::vectoruint8_t encodeCustom(uint16_t addr, const CustomData d) { std::vectoruint8_t out; out.push_back(static_castuint8_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 编码都强制走一遍端到端希望这个习惯也能帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑