简介:面向电力系统远动通信、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、链路地址 A(1~2 字节)、ASDU(应用服务数据单元)、校验和 CS、结束符 0x16。L 表示从控制域到校验和 CS 的总字节数。因为 L 只有 1 个字节,可变帧最大长度为 255,对应 ASDU 上限约 251 字节,接收缓冲按 300 字节规划足够。
| 帧型 | 启动符 | 帧结构 | 典型用途 |
|---|---|---|---|
| 单字符帧 | 0xE5 | 无 | 从站确认正确接收 |
| 固定帧长 | 0x10 | C、A、CS、0x16 | 召唤链路状态、确认、否定确认 |
| 可变帧长 | 0x68 | L、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 代表主站以 FCB=1 发送用户数据,总召唤、对时、遥控都用它;0x43 是 FCB=0 的同一类帧,重发总召唤时必须交替;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 是长度 L,0x53 是控制域,01 01 是链路地址;从 64 开始是 ASDU:64 是总召唤类型标识,01 表示一个对象且地址连续,06 是传送原因“激活”,01 是公共地址,00 00 00 是信息体地址 0;0xC1 是 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应该是 11(0x0B),CS 应该是 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[l+1],结束符在buf[l+2];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(类型标识 100,COT=6 激活),但从站不是直接上数据,而是回一个激活确认(COT=7),紧接着按点表顺序上送大量数据帧,最后回一帧总召唤激活终止(COT=10)表示这轮召唤结束。
主站 -> 从站:C_IC_NA COT=6 总召唤激活 从站 -> 主站:C_IC_NA COT=7 激活确认 从站 -> 主站:M_SP_NA COT=20 遥信数据 从站 -> 主站:M_ME_NB COT=20 遥测数据 从站 -> 主站:M_ME_NB COT=20 遥测数据 主站 <- 从站:C_IC_NA COT=10 激活终止,召唤结束常见错误是只收到 COT=20 的数据就宣布总召唤完成,忽略最后的 COT=10。遇到大数据点表时,从站可能分几十帧上送,期间每帧都会带 ACD 或 DFC 标志,主站如果没有把“激活终止”作为完成条件,会把半途数据当成完整快照,后续遥控选择就可能基于过期的对点结果。
4.2 传送原因 COT 与遥控选择执行的配合
COT 在 ASDU 里的位置是固定偏移,解析时直接用asdu[2]取低 6 位即可。总召唤和遥控依赖的是同一套 COT 机制,但遥控多了“选择、执行”两个步骤。南瑞装置普遍要求单点遥控先选择再执行:主站发类型 45,COT=6,DCO=1 表示选择;从站回 COT=7 确认;主站再发 COT=6,DCO=2 表示执行;从站回 COT=7,最终回 COT=10 激活终止。DCO 是遥控信息元素的第一个字节的低 3 位,QC 品质字节必须填 0,否则从站可能拒绝执行。
| COT | 含义 | 调试中典型出现位置 |
|---|---|---|
| 6 | 激活 | 总召唤请求、遥控选择/执行、对时命令 |
| 7 | 激活确认 | 从站确认收到了上面的激活 |
| 8 | 停止激活 | 主站想中止一个激活过程 |
| 9 | 停止激活确认 | 从站确认已停止 |
| 10 | 激活终止 | 总召唤全部数据结束、遥控执行完成 |
| 20 | 响应总召唤 | 从站上送遥信遥测数据 |
一个值得注意的细节是:从站对总召唤的“激活确认”有时用同类型标识 100 回 COT=7,有时直接用固定帧确认回 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 置位后立即无脑补发,导致数据堆积
从站接收缓冲区快满时会在响应帧里置 DFC=1,主站此时应停发用户数据,等从站消化后再继续。有些实现不判断 DFC,收到 DFC=1 后继续无脑补发,结果越积越多,最终链路复位。这个坑在大批量总召唤数据回灌主站时特别明显,主站收到 DFC=1 后要延迟一两个轮询周期再重发,而不是立刻重发。
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: ctrl=0x%02X addr=0x%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: ctrl=0x%02X addr=0x%04X type=0x%02X cot=0x%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输出的 type=0x64、cot=0x06 正好对应总召唤激活。随后可以按固定帧、可变帧的顺序把整个链路的交互帧跑一遍:请求链路状态10 49 00 00 49 16应输出 cs=ok;从站确认10 00 01 01 02 16控制域应为 0x00;从站上送遥测68 0D 08 01 01 09 01 14 01 01 00 00 00 00 2A 16应解析出 type=0x09、cot=0x14,这时再去看信息体地址 1 的遥测值。手头有 Tera Term 或任意串口助手的日志,把抓到的 hex 原样喂给这个小工具,一条条跑完,基本就能把南瑞从站的链路问题定位到帧级别。
本文还有配套的精品资源,点击获取