十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

IEC 101规约C语言实现:帧结构解析与南瑞RTU调试实战

IEC 101规约C语言实现:帧结构解析与南瑞RTU调试实战 简介面向电力系统远动通信、RTU设备调试及IEC 60870-5-101规约初学者这份来自南瑞工程现场的RTU101小工具压缩包将规约文档与可运行源码整合在一起帮助快速绕开协议细节的弯路。包内共有2个文件1个cpp源码文件用于实现101报文的打包、解析与收发逻辑1个txt说明文件则对帧头、控制域、地址域、信息域等结构以及遥信变位20报文、遥测数据上送等典型应用做了注解。全部内容仅13KB轻量且便于对照阅读。目前已有788人学习浏览适合正在研究南瑞101规约实现、需要理解主站与RTU之间数据交互的开发者。借由源码中的具体报文示例和说明文档里的富春江电厂、乔司边省局等现场背景读者能更快掌握规约在真实工程中的落地方式减少自行排查协议兼容性问题的时间。整体而言这是一份能同时服务入门学习和现场排查的紧凑型参考资料。1. 一份 rtu101 压缩包背后是电力远动里绕不开的 IEC 101 规约在工控目录里翻出 rtu101.rar里面大概率是南瑞 101 规约的 C 源码示例、报文抓包或者装置说明文档。IEC 60870-5-101简称 101 规约或 101 报文是电力远动主站与 RTU、保护测控装置之间最常见的串口问答规约经典但绕不开遥信要上送、遥测要冻结、遥控要走选择执行最终全部落在一帧一帧的 101 报文里。很多人卡住的地方不是业务逻辑而是先把 FT1.2 帧格式、CS 累加和、ASDU 编解码这几个底层细节搞混了导致调不出来。这篇按 C 语言的实现思路拆解 101 报文链路帧怎么组、ASDU 怎么解析、南瑞装置对参数和时序的约束以及一套能直接落地的小工具。适合正在写主站前置、规约转换或嵌入式从站手头只有一堆 hex 报文的人。2. IEC 101 规约的帧结构从 FT1.2 拆 101 报文2.1 三种帧型与适用场景单字符帧、固定帧长、可变帧长101 规约的物理层之上是 FT1.2 帧格式一共三种帧型。单字符帧只有一个字节 0xE5从站用它表示“上一帧正确收到”常见于主站重发保护时的快速确认没有地址、没有校验使用场景很窄。固定帧长以 0x10 开头固定 6 个字节2 字节链路地址时用于链路层的控制交互比如主站召唤链路状态、从站回确认帧典型报文是10 49 00 00 49 16。可变帧长以 0x68 开头是真正承载业务数据的帧型遥信、遥测、遥控、总召唤、对时全部走它。区分这三种帧型是解析的第一步也是最容易被忽略的一步拿到一帧数据先看首字节0x10 和 0x68 的解析长度计算完全不同。可变帧长格式为启动符 0x68、长度 L、控制域 C、链路地址 A1~2 字节、ASDU应用服务数据单元、校验和 CS、结束符 0x16。L 表示从控制域到校验和 CS 的总字节数。因为 L 只有 1 个字节可变帧最大长度为 255对应 ASDU 上限约 251 字节接收缓冲按 300 字节规划足够。帧型启动符帧结构典型用途单字符帧0xE5无从站确认正确接收固定帧长0x10C、A、CS、0x16召唤链路状态、确认、否定确认可变帧长0x68L、C、A、ASDU、CS、0x16遥信、遥测、遥控、总召唤、对时这里顺带说一个容易混淆的点IEC 104 规约的报文也是 0x68 开头但它后面跟的是 2 字节 APDU 长度而且没有 CS 和 0x16 结束符。看到68 0B 0B 68 ...这类格式要立刻意识到那是 104 而不是 101。101 的可变帧只出现一个 L 字节CS 是累加和不是 CRC结尾必须有 0x16。2.2 可变帧字段逐一拆解控制域、链路地址、ASDU 与 CS控制域 C 同时携带方向信息和保护标志是 101 报文里最容易出错的地方。主站发给从站的帧里bit6 是 FCB帧计数位bit5 是 FCV帧计数有效位bit0 到 bit3 是功能码从站返回的帧里bit5 是 DFC数据流控制位bit4 是 ACD要求访问位bit0 到 bit3 是功能码。实际调试中需要记住的常用值并不多0x53 代表主站以 FCB1 发送用户数据总召唤、对时、遥控都用它0x43 是 FCB0 的同一类帧重发总召唤时必须交替0x49 是主站召唤链路状态从站的数据响应帧控制域是 0x08从站确认帧是 0x00。链路地址 A 在绝大多数南瑞装置上配置为 2 字节低字节在前高字节在后。比如地址 0x0101 的报文顺序是01 01。公共地址则位于 ASDU 内部不要把链路地址和公共地址当成同一个东西链路地址解决的是“这一帧给哪条链路”公共地址解决的是“这个数据属于哪个厂站或装置”。南瑞调试台上常见的“地址对不上”一半是链路地址配错一半是公共地址配错分别在这两层检查。CS 校验和的计算范围是从控制域 C 开始到 ASDU 最后一个字节结束把 C、A、ASDU 所有字节做累加超过 0xFF 时取低 8 位。注意101 没有多项式 CRC凡是按 CRC 表去验 101 报文的帧校验基本不可能通过。结束符 0x16 也要作为帧合法性的一部分校验否则一个错位字节会把后续整串数据都带偏。C 计算方式2 字节地址 CS (C A_LO A_HI ASDU[0] ... ASDU[n-1]) 0xFF2.3 常见 ASDU 类型标识与典型报文 hex 示例ASDU 是 101 报文里真正承载业务数据的部分从控制域之后、链路地址之后开始到 CS 之前结束。ASDU 的标准结构是类型标识、可变结构限定词 VSQ、传送原因 COT、公共地址 CA、信息体地址 IOA、信息元素。类型标识决定后面的数据怎么解释VSQ 的高位表示信息体地址是否连续低 7 位表示信息体数量COT 表示这条 ASDU 是激活、确认还是数据上送。解析时先按类型标识分支再按 COT 判断流程方向顺序不能反。常用类型标识如下表类型标识名称信息元素1/3单点/双点遥信1 字节开关量状态9归一化遥测2 字节归一化值11带品质遥测2 字节归一化值加品质13短浮点遥测4 字节浮点加品质30单点带时标SOE带毫秒时标45单点遥控DCO 加品质字节100总召唤无信息元素103时钟同步CP56Time2a 时标以最常见的总召唤为例一帧完整的 101 报文是68 0B 53 01 01 64 01 06 01 00 00 00 C1 16逐字节拆开0x68 是可变帧启动符0x0B 是长度 L0x53 是控制域01 01 是链路地址从 64 开始是 ASDU64 是总召唤类型标识01 表示一个对象且地址连续06 是传送原因“激活”01 是公共地址00 00 00 是信息体地址 00xC1 是 CS计算方式是0x53 0x01 0x01 0x64 0x01 0x06 0x01累加结果 0xC1最后的 16 是结束符。拿到这帧报文就能明确区分控制域、地址域和 ASDU 三个层次。2.4 对接南瑞装置前的参数确认表南瑞不同系列的 RTU、保护测控装置对 101 的实现大致一致但参数配置位置和默认值有差异对接前先确认下面这张表能省掉一大半排查时间。参数常见配置说明波特率9600 / 19200两侧必须一致数据格式8 数据位、偶校验、1 停止位也有 8N1以装置参数为准链路地址2 字节低字节在前主站与从站的“对端地址”配置要对得上公共地址1~2 字节南瑞后台和装置点表里统一定义工作方式非平衡方式主站询问从站应答少部分支持平衡方式重发超时1~3 秒主站超时未收到确认就重发FCB 要翻转平衡方式下主站连续轮询、从站可主动上送控制域的 FCB 用法和非平衡不同南瑞现场绝大多数用非平衡方式。确认完参数再看报文才不会在错误前提下反复看抓包。3. 用 C 语言实现 IEC 101 规约的组帧、解析与接收状态机3.1 定义帧对象与缓冲区在 C 语言里实现 101 报文第一步是定义帧对象。我一般不为 ASDU 的每个字段单独建结构体而是把 ASDU 作为字节数组保留只在解析时按偏移量读取字段这样既贴近抓包 hex 的视角又方便适配南瑞各种私有扩展类型标识。#include stdint.h typedef struct { uint8_t ctrl; /* 控制域如 0x53 表示主站发送用户数据 */ uint16_t addr; /* 链路地址低字节在前面发送 */ uint8_t *asdu; /* ASDU 起始指针长度不定 */ uint16_t asdu_len; /* ASDU 字节数 */ } ec101_frame_t; #define EC101_RX_BUF_SIZE 320 static uint8_t rx_buf[EC101_RX_BUF_SIZE];使用传入的 ASDU 指针而不是拷贝数据是为了避免在组帧和解析时反复 memcpy。解析时asdu指向接收缓冲区内部组帧时asdu可以指向应用层准备好的数据区逻辑更清晰。rx_buf给 320 字节留出裕量应对 L 字段上限 255 和后续可能的扩展帧。3.2 组可变帧长度 L 与校验和 CS 的计算顺序组帧的难点不是往缓冲区写字节而是严格按发送顺序计算 L 和 CS。L 是从控制域到校验和的字节数等于 1 个控制域加 2 个链路地址加 ASDU 长度加 1 个校验和所以先算 L 再填数据CS 最后统一累加。static uint16_t build_frame(uint8_t *buf, ec101_frame_t *f) { uint16_t n 0, i; uint8_t sum 0; uint8_t len 1 2 f-asdu_len 1; /* C A2 ASDU CS */ buf[n] 0x68; /* 可变帧启动符 */ buf[n] len; /* 长度字段单字节 */ buf[n] f-ctrl; /* 控制域 */ sum f-ctrl; buf[n] (uint8_t)(f-addr 0xFF); /* 地址低字节 */ buf[n] (uint8_t)((f-addr 8) 0xFF); /* 地址高字节 */ sum (uint8_t)(f-addr 0xFF); sum (uint8_t)((f-addr 8) 0xFF); for (i 0; i f-asdu_len; i) { buf[n] f-asdu[i]; sum f-asdu[i]; } buf[n] sum; /* CS 累加和取低 8 位 */ buf[n] 0x16; /* 结束符 */ return n; }C 语言里uint8_t在累加时会发生整型提升但最终赋值给uint8_t sum时自动截断到低 8 位正好满足 101 校验和要求不需要额外 0xFF。组完帧后建议用 2.3 节的总召唤例子手算核对一次len应该是 110x0BCS 应该是 0xC1。如果组出来的帧 CS 不对优先查链路地址的字节序低字节在前是南瑞装置的固定要求。3.3 解析帧先验长度、CS再拆 ASDU 字段解析过程按“首字节判型、长度判完整、CS 判合法、结束符判收尾、最后拆 ASDU”五步走。顺序很重要先验 CS 再拆字段能拦住串口错位、丢字节导致的垃圾帧先拆字段再验 CS很容易被脏数据带偏。static int parse_var_frame(const uint8_t *buf, uint16_t len, ec101_frame_t *out) { uint8_t l, sum 0; uint16_t i; if (len 7 || buf[0] ! 0x68) return -1; l buf[1]; if (l 4) return -1; /* 至少 C A2 CS */ if (len (uint16_t)(l 3)) return -1; /* 缺结尾 */ for (i 2; i (uint16_t)l; i) { /* C 到 ASDU 末尾 */ sum buf[i]; } if (sum ! buf[l 1]) return -2; /* 与 CS 比较 */ if (buf[l 2] ! 0x16) return -3; /* 结束符 */ out-ctrl buf[2]; out-addr buf[3] | (buf[4] 8); out-asdu (uint8_t *)buf[5]; out-asdu_len l - 4; /* 去掉 C、A2、CS */ return 0; }这里l表示从控制域到 CS 的字节数所以 CS 在buf[l1]结束符在buf[l2]ASDU 长度等于l - 4因为 l 里包含 1 字节控制域、2 字节链路地址和 1 字节 CS。有同行把 CS 位置写成buf[l]那是把 L 理解成“不含 CS”的口径某些老版本南瑞文档确实这么写。遇到这种情况不要争直接以对方装置手册里的计算公式为准在代码里用宏切换即可。3.4 按字节接收一个适合串口中断的状态机101 是串口协议数据不是一个完整帧一次性到达而是逐字节进入接收缓冲。我一般用一个轻量状态机首字节等 0x10 或 0x68第二字节决定整帧长度之后边收边判断是否收满。static uint16_t rx_len 0, rx_need 0; static int framed 0; void ec101_on_byte(uint8_t b) { if (rx_len 0) { if (b 0x10 || b 0x68) { rx_buf[0] b; rx_len 1; framed 0; } return; } if (rx_len 1) { rx_buf[1] b; if (rx_buf[0] 0x10) { rx_need 6; /* C A2 CS 0x16 */ } else { rx_need (uint16_t)b 3; /* L 启动符 结束符 */ } rx_len 2; return; } if (rx_len EC101_RX_BUF_SIZE) { rx_buf[rx_len] b; } if (rx_len rx_need) framed 1; }这个状态机没有处理 0x10 固定帧长度小于 6 的边界实际串口数据通常不会少字节但严谨做法是收到 0x10 后超过 6 字节还没结束就丢弃缓冲重新同步。主循环里每收满一帧就调用parse_var_frame并把整帧交给应用层单帧处理完立即清空rx_len、framed避免下一帧覆盖。注意rx_buf是固定数组写入前先判断下标否则会把缓冲区写穿这种问题只在跑几天后偶发出现排查成本很高。4. 南瑞 101 对点调试参数、时序和常见坑4.1 总召唤的完整时序激活、数据、激活终止总召唤是南瑞主站调试时用的第一把钥匙它要求从站把全部遥信、遥测、电度等静态数据上送一遍。主站发一帧 C_IC_NA类型标识 100COT6 激活但从站不是直接上数据而是回一个激活确认COT7紧接着按点表顺序上送大量数据帧最后回一帧总召唤激活终止COT10表示这轮召唤结束。主站 - 从站C_IC_NA COT6 总召唤激活 从站 - 主站C_IC_NA COT7 激活确认 从站 - 主站M_SP_NA COT20 遥信数据 从站 - 主站M_ME_NB COT20 遥测数据 从站 - 主站M_ME_NB COT20 遥测数据 主站 - 从站C_IC_NA COT10 激活终止召唤结束常见错误是只收到 COT20 的数据就宣布总召唤完成忽略最后的 COT10。遇到大数据点表时从站可能分几十帧上送期间每帧都会带 ACD 或 DFC 标志主站如果没有把“激活终止”作为完成条件会把半途数据当成完整快照后续遥控选择就可能基于过期的对点结果。4.2 传送原因 COT 与遥控选择执行的配合COT 在 ASDU 里的位置是固定偏移解析时直接用asdu[2]取低 6 位即可。总召唤和遥控依赖的是同一套 COT 机制但遥控多了“选择、执行”两个步骤。南瑞装置普遍要求单点遥控先选择再执行主站发类型 45COT6DCO1 表示选择从站回 COT7 确认主站再发 COT6DCO2 表示执行从站回 COT7最终回 COT10 激活终止。DCO 是遥控信息元素的第一个字节的低 3 位QC 品质字节必须填 0否则从站可能拒绝执行。COT含义调试中典型出现位置6激活总召唤请求、遥控选择/执行、对时命令7激活确认从站确认收到了上面的激活8停止激活主站想中止一个激活过程9停止激活确认从站确认已停止10激活终止总召唤全部数据结束、遥控执行完成20响应总召唤从站上送遥信遥测数据一个值得注意的细节是从站对总召唤的“激活确认”有时用同类型标识 100 回 COT7有时直接用固定帧确认回 0x00 先应答链路层真正的激活确认随后出现在数据帧流里。两种做法都合规主站解析时不要把固定帧确认当成 ASDU 确认来处理。4.3 南瑞 101 调试中的高发问题4.3.1 把 CS 当 CRC校验永远不过101 的 CS 是普通累加和取低 8 位和 Modbus CRC、104 的校验机制完全不同。用 CRC 校验算法去算 101 帧结果对不上是正常的。调试第一步就是手工算一帧总召唤的 CS确认理解一致再写代码。4.3.2 FCB 不翻转从站静默丢弃非平衡方式下主站发的用户数据帧FCB 必须逐帧交替翻转。如果主站逻辑用固定 0x53 作为所有帧的控制域从站会认为这是重复帧直接丢弃。重发同一帧时 FCB 也要翻转不能重发原字节。控制域 0x53 和 0x43 交替是南瑞 101 调试里最常见的正确模式。4.3.3 链路地址与公共地址配反或字节序颠倒南瑞装置的链路地址常是两个字节低字节在前公共地址在一部分老装置上只取 1 字节配置后台时显示成十进制报文里却按十六进制展开。出现过把十进制的 16 当 0x16 写进公共地址的案例收到帧后从站直接丢弃。建议把参数表打印出来链路地址、公共地址、地址长度三项逐个对照再联调。4.3.4 DFC 置位后立即无脑补发导致数据堆积从站接收缓冲区快满时会在响应帧里置 DFC1主站此时应停发用户数据等从站消化后再继续。有些实现不判断 DFC收到 DFC1 后继续无脑补发结果越积越多最终链路复位。这个坑在大批量总召唤数据回灌主站时特别明显主站收到 DFC1 后要延迟一两个轮询周期再重发而不是立刻重发。5. 一个验证技巧把 101 解析器做成命令行工具喂 hex联调阶段最缺的是“拿一帧报文快速判断协议栈行为”的工具。把第 3 章的parse_var_frame包装成一个命令行程序参数直接喂十六进制字符串输出控制域、链路地址、ASDU 类型和校验结果配合串口抓包日志使用非常顺手。这个小工具不依赖业务逻辑纯做帧级校验能复用到任意 101 项目。#include stdio.h #include stdint.h #include string.h static int hexval(char c) { if (c 0 c 9) return c - 0; if (c a c f) return c - a 10; if (c A c F) return c - A 10; return -1; } int main(int argc, char **argv) { uint8_t buf[320]; uint16_t n 0; for (int i 1; i argc; i) { int hi hexval(argv[i][0]); int lo hexval(argv[i][1]); if (hi 0 || lo 0) return 1; buf[n] (uint8_t)((hi 4) | lo); } if (n 6 buf[0] 0x10) { uint8_t cs buf[1] buf[2] buf[3]; printf(fix: ctrl0x%02X addr0x%04X cs%s\n, buf[1], buf[2] | (buf[3] 8), cs buf[4] ? ok : bad); } else if (n 7 buf[0] 0x68) { uint8_t l buf[1]; if (n l 3) { printf(var: incomplete\n); return 1; } uint8_t sum 0; for (int i 2; i l; i) sum buf[i]; if (sum ! buf[l 1] || buf[l 2] ! 0x16) { printf(var: cs/end bad\n); return 1; } printf(var: ctrl0x%02X addr0x%04X type0x%02X cot0x%02X len%d\n, buf[2], buf[3] | (buf[4] 8), buf[5], buf[7], l - 4); } return 0; }用法就是打开终端直接打参数用 2.3 节的总召唤示例验证./rtu101parse 68 0B 53 01 01 64 01 06 01 00 00 00 C1 16输出的 type0x64、cot0x06 正好对应总召唤激活。随后可以按固定帧、可变帧的顺序把整个链路的交互帧跑一遍请求链路状态10 49 00 00 49 16应输出 csok从站确认10 00 01 01 02 16控制域应为 0x00从站上送遥测68 0D 08 01 01 09 01 14 01 01 00 00 00 00 2A 16应解析出 type0x09、cot0x14这时再去看信息体地址 1 的遥测值。手头有 Tera Term 或任意串口助手的日志把抓到的 hex 原样喂给这个小工具一条条跑完基本就能把南瑞从站的链路问题定位到帧级别。本文还有配套的精品资源点击获取
返回列表