ARTICLE DETAIL

资讯详情

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

IEC 60870-5-104测试床搭建指南:从帧时序到模拟实战

IEC 60870-5-104测试床搭建指南:从帧时序到模拟实战 简介聚焦 IEC60870-5-104 工控协议模拟实战的 PDF 文档面向电力调度自动化、工业控制系统安全测试人员以及协议分析入门与进阶者系统梳理测试床的架构设计与搭建流程。文档内容从协议背景入手逐步覆盖测试床硬件层、软件层、网络层设计再到报文模拟与解析、测试用例设计与执行、故障注入与异常处理、性能与安全测试形成从搭建到验证的完整链路。其中对 APDU/ASDU 结构、网络拓扑选择、中间人攻击与 DoS 防范等关键点均有讲解整体结构紧凑从原理到实操环环相扣适合作为搭建模拟环境、开展协议兼容性测试和安全加固的参考资料。资源包为单个 PDF4.37MB排版完整支持目录章节跳转与阅读器左侧大纲、章节快速定位便于按需查阅。目前已有 73 人学习下载尤其适合需要从零构建工控测试台或理解 IEC104 协议交互细节的工程师与研究者。1. 为什么需要一张 IEC 60870-5-104 测试床把变电站通信从黑匣子变成可控实验做电力自动化或者工控安全的人迟早会撞上 IEC 60870-5-104 这套协议。它是调度主站和变电站测控装置之间最常用的远动通信规约跑在 TCP 2404 端口上。调试时最尴尬的场景是手里没有真实测控装置或者装置在生产线上不能随便重启、灌异常报文。把主站、从站都用软件模拟出来在一台笔记本上启一个测试床让 IEC 104 报文按你的意愿产生、篡改、加速重放这就是标题里「测试床、模拟实战」的实际价值。这篇文章按我自己的落地路径来写先讲帧和时序再给一套能跑的最小从站代码最后把总召唤、遥信、遥控完整走一遍并把调试里最容易翻车的几个点列出来。2. 动手之前先读懂 104 的帧与时序I帧/S帧/U帧和 t0/t1/t2/t3 决定你后面怎么调参2.1 三种帧的职责边界数据、确认、链路控制各干各的IEC 60870-5-104 的 APDU 结构很固定起始字节 0x68接着一个长度字节表示后续 APCI 加 ASDU 的总长度再往后是 4 字节控制域最后是 ASDU。控制域的低两位决定了帧类型这是抓包时第一眼要认的东西。I 帧信息帧控制域第一字节低两位为 00。它既携带数据也携带确认信息。I 帧的控制域里同时有发送序号 N(S) 和接收序号 N(R)序号按模 32768 递增所以协议栈可以一边发数据一边确认对方的数据。测试床里绝大多数业务报文都是 I 帧。S 帧监视帧控制域第一字节为 0x01。它不携带 ASDU只有接收序号 N(R)作用是「你说的我都收到了」。当接收方暂时没有数据要发又必须按窗口要求回确认时就用 S 帧顶上去。这在测试床里是验证窗口机制的关键道具。U 帧控制帧控制域第一字节为 0x03。它负责链路启停和测试典型的有三个用途STARTDT 激活传输、STOPDT 停止传输、TESTFR 测试链路。上电后必须先来一轮 STARTDT 握手双方才会开始收发 I 帧。U 帧用途主动帧十六进制确认帧十六进制STARTDT 激活传输68 04 07 00 00 0068 04 0B 00 00 00STOPDT 停止传输68 04 13 00 00 0068 04 23 00 00 00TESTFR 测试链路68 04 43 00 00 0068 04 83 00 00 00这六个字节结构里68 是起始符04 表示后面还有 4 个字节的控制域。U 帧没有 ASDU所以 APDU 总长只有 6 个字节。测试床首测的第一步先确认双方能完成 STARTDT 握手再谈别的。2.2 ASDU 里哪些字节必须吃透类型标识、传输原因、公共地址和品质位帧结构里真正承载业务语义的是 ASDU。先列一张我在测试床里高频使用的类型标识速查表类型标识编号名称含义1M_SP_NA_1单点遥信9M_ME_NA_1标度化遥测整数值13M_ME_NC_1短浮点遥测IEEE 75430M_SP_TB_1带时标单点遥信45C_SC_NA_1单点遥控100C_IC_NA_1总召唤103C_CS_NA_1时钟同步每个 ASDU 的开头几个字节是有固定顺序的类型标识1 字节、可变结构限定词 VSQ1 字节、传输原因 COT2 字节小端、公共地址2 字节小端然后是信息对象地址 IOA3 字节小端加信息元素。VSQ 的低 7 位表示本帧携带几个信息对象最高位为 1 表示按连续地址顺序寻址为 0 表示对象地址不连续、每个对象都要单列 IOA。传输原因 COT 是调试时最容易看漏的字段。测试床里常碰到的值有1 周期上送、3 突发上送比如遥信变位、4 初始化、6 激活命令下发、7 激活确认、10 激活终止、20 响应总召唤。同样一条单点遥信COT 是 3 还是 20 直接决定你该把它理解成「设备自己变位了」还是「我在回复总召唤」解析逻辑完全不一样。品质位在信息元素的最高 4 位低 4 位才是实际数据单点遥信里 bit0 表示合/分。品质位里 IV 为 1 表示无效NT 为 1 表示非最新BL 为 1 表示被闭锁。测试床里经常故意把品质位置 1验证主站侧有没有对无效数据做过滤这是工控环境里很值得做的模拟实战。2.3 连接时序与定时器参数2404 端口上先跑通 STARTDT 再谈数据TCP 连接建立后主站必须先发 STARTDT从站回 STARTDT 确认双方才允许发 I 帧。如果直接一发 T 帧I 帧很多严格实现会直接丢弃。测试床里模拟器通常把这个过程封装了但自己写报文时必须手动补上。IEC 60870-5-104 标准里有一组链路定时参数测试床的价值之一就是把它们压到极端值做验证。常用默认参数如下参数默认值含义t010 秒TCP 连接建立超时t115 秒发送或测试帧后等待确认的超时t210 秒接收方无数据可发时最迟用 S 帧回确认的间隔必须小于 t1t320 秒链路空闲时发送 TESTFR 的周期k12发送方最大未确认 I 帧数量w8接收方未确认 I 帧数量达到该值时必须立即回确认调试时最容易出问题的是 t2 和 t3。t2 大于 t1 会导致对端在等待你的确认时先超时断链t3 触发后必须收到 TESTFR 确认否则对端会认为链路死了。测试床里我习惯把 t1 改到 2 秒、t3 改到 5 秒这样断链和重连的复现时间被压缩好几倍等不起 20 秒的场景在测试床里几秒钟就能看到结果。这个「把时间尺度压缩」是测试床比真机调试强得多的地方。3. 搭建最小测试床从零写一个能响应总召唤的 104 从站3.1 选型思路为什么先用手写 Python 而不是直接上开源库常见的 IEC 104 模拟器方案大概有三类一是用 lib60870 这类开源库搭二是直接用商用协议模拟软件三是从字节层面手写最小实现。第一次做测试床我建议从第三种开始哪怕只跑一个晚上。原因很实际开源库帮你把 ASDU 封装成了对象你拖几个属性就能发一条总召唤响应但一旦对方设备回的报文不规范你根本不知道是协议栈里哪一层把字节弄拧了。自己用 Python 标准库里的 socket 和 struct 从空白文件写一条 104 报文你会把 68、长度、控制域、ASDU 每个字节的位置都刻进脑子里之后再用库或者抓包工具效率反而最高。等手写链路通了再决定要不要换 lib60870 做更复杂的场景。3.2 最小从站代码监听 2404、应答 STARTDT、回总召唤数据下面这个从站只做三件事监听 TCP 2404收到 STARTDT 后回确认收到总召唤后先回一条激活确认再补一条单点遥信数据。代码刻意省略了序号管理和窗口控制方便对照标准看每一帧的字节构成。import socket LISTEN_ADDR (0.0.0.0, 2404) COMMON_ADDR 0x0001 # 公共地址测试床内固定为 1字节序为小端 def handle_conn(conn): buf b while True: data conn.recv(256) if not data: return buf data while len(buf) 2: apdu_len buf[1] 2 if len(buf) apdu_len: break frame buf[:apdu_len] buf buf[apdu_len:] if frame[2] 0x07: # STARTDT 主动帧68 04 07 00 00 00 conn.sendall(bytes.fromhex(68 04 0b 00 00 00)) elif frame[2] 0x03 0: # I 帧低两位为 00 if frame[6] 100: # C_IC_NA_1 总召唤 # 先回激活确认COT7N(S)0N(R)1 conn.sendall(bytes.fromhex( 68 0c 00 00 01 00 64 01 07 00 01 00 01 00 00 00)) # 再回单点遥信M_SP_NA_1COT20IOA1值合品质正常 conn.sendall(bytes.fromhex( 68 0e 02 00 01 00 01 01 14 00 01 00 01 00 00 01)) def main(): srv socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind(LISTEN_ADDR) srv.listen(5) while True: conn, _ srv.accept() handle_conn(conn) if __name__ __main__: main()两帧回包的构成要拆开看。第一帧68 0c 00 00 01 00 64 01 07 00 01 00 01 00 00 000x68 起始0x0C 表示后面有 12 字节控制域00 00 01 00中 N(S)0、N(R)1因为收到了主站发来的一个 I 帧ASDU 里 0x64 是总召唤类型标识COT 是07 00即激活确认。第二帧68 0e 02 00 01 00 01 01 14 00 01 00 01 00 00 010x0E 表示后面 14 字节控制域02 00 01 00的发送序号变成 1ASDU 里 0x01 是单点遥信类型COT 是14 00即响应总召唤最后信息元素 0x01 的 bit0 为 1 表示合位品质位为 0 表示数据有效。这里有几个参数你在自己的测试床里一定要改COMMON_ADDR 要与主站侧一致不同厂商设备可能默认 1 也可能默认 255IOA 起始地址各电站定义不同遥信的初始值到底是 0 还是 1看你想模拟的是分位还是合位。这段代码不处理粘包以外的任何边界情况但它足以让你在 20 分钟内把一个能从站角色跑起来。3.3 用 Wireshark 对照验证抓包确认每一帧的 APCI 与 ASDU 字节从站起来后另开一个终端用 Python 快速当一次主站把握手和数据流程完整触发一遍。import socket import time s socket.create_connection((127.0.0.1, 2404)) s.sendall(bytes.fromhex(68 04 07 00 00 00)) # STARTDT time.sleep(0.2) s.sendall(bytes.fromhex( 68 0c 00 00 00 00 64 01 06 00 01 00 01 00 00 00)) # 总召唤 resp s.recv(256) print(resp.hex())正常输出会是一串包含三帧响应的十六进制先可能是68040b000000的 STARTDT 确认再是680c0000010064010700010001000000的激活确认最后是680e0200010001011400010001000001的遥信数据。如果你看到的长度不是这样先检查你手里抓包是不是把多帧粘在了一次 recv 里——这正是 TCP 流式协议的常见误会。Wireshark 里对 2404 端口能自动识别 IEC 60870-5-104 协议。抓包后点开任意一帧左上角会直接展开 APCI 和 ASDU 树APCI 里能看到帧类型I/S/U和序号ASDU 里能看到类型标识、传输原因、公共地址和信息对象地址。建议做一件事把自己发出的总召唤帧在抓包里展开对照 2.2 节的字节顺序逐个字段看一遍。这一步做完你对 104 协议的信心会从「好像懂了」变成「我能预测下一个字节」。抓包还需要注意一个行为从站收到总召唤后先回激活确认再回数据这两个 I 帧的发送序号是连续递增的。如果抓包里从站回的第二个 I 帧序号不是 1 而是 0说明你的从站在重复使用序号这是窗口协议里最不能接受的行为之一。4. 模拟实战把总召唤、遥信、遥测、遥控在测试床上完整走一遍4.1 主站侧脚本连接、总召唤、按 APDU 长度拆帧从站能跑了主站侧脚本也要有一个能按 APDU 长度拆帧的循环否则 TCP 粘包会把多帧混在一起初学者经常在这里怀疑人生。我一般把拆帧逻辑写成独立函数方便后面加业务处理。import socket def recv_frames(sock): buf b frames [] while True: chunk sock.recv(256) if not chunk: break buf chunk while len(buf) 2: apdu_len buf[1] 2 if len(buf) apdu_len: break frames.append(buf[:apdu_len]) buf buf[apdu_len:] if frames: break return frames s socket.create_connection((127.0.0.1, 2404)) s.sendall(bytes.fromhex(68 04 07 00 00 00)) frames recv_frames(s) print([f.hex() for f in frames])这段代码里的apdu_len buf[1] 2是核心长度字段只统计控制域加 ASDU不包含 68 和长度自身所以要从 buf 里取出长度字节后加 2 才是整帧跨度。每次读完一帧就把数据从缓冲头部切掉循环处理直到缓冲不足一帧。break放在if frames里的逻辑保证至少收到一帧就返回避免长时间空等。主站和从站的握手顺序有讲究先发 STARTDT等确认再发总召唤。有些设备严格要求收到 STARTDT 确认后才能发 I 帧你要是在测试床里把总召唤和 STARTDT 一次性同时发出很可能会看到对端只回确认不处理召唤。这是我在多个厂家设备上都遇到过的行为差异测试床里先按最严格的标准走能兼容更多真实设备。4.2 遥信变位与遥测上送单点信息、品质位和传输原因的组合总召唤只是把当前值批量上送一次真正体现 IEC 104 业务能力的是设备主动上送变位和遥测。测试床里模拟一个遥信变位关键是传输原因要从 1周期或 20响应总召唤换成 3突发。突发遥信帧的最小字节如下字段字节内容说明APCI 长度68 0E后续共 14 字节控制域00 00 00 00假设这是新连接后的第三个 I 帧按你的实际序号写类型标识01单点遥信 M_SP_NA_1VSQ011 个信息对象COT03 00突发上送公共地址01 00与从站配置一致IOA02 00 00第二个遥信点信息元素01bit01 表示合位品质位为 0遥测上送和遥信的区别在信息元素部分。标度化遥测 M_ME_NA_1 类型标识用 9信息元素是两个字节的整数值小端排列比如值 1234 就是d2 04。短浮点遥测 M_ME_NC_1 类型标识用 13信息元素是 4 字节 IEEE 754 小端比如 22.5 就是00 00 b4 41。在测试床里验证浮点字节序非常划算拿 Python 的struct.pack(f, 22.5)直接生成字节再拼进帧里发给主站看主站解析回来是不是 22.5。这一步能帮你排查一多半的端序问题。品质位的模拟更值得做。把信息元素的最高位置 1比如单点遥信发0x81意思变成「遥信值为合但数据无效」。很多主站会对无效遥信做特殊标记而不是直接刷新画面这是调度端的基本要求。我在测试床里专门写过用例让设备周期上送带 IV 位的遥测验证主站侧是否把这些值排除在告警计算之外。如果你做的是工控安全测试这类用例几乎是必做的模拟实战。4.3 遥控选择/执行C_SC_NA_1 从下发到确认的完整链路遥控比遥信遥测多了一层选择/执行保护。标准流程是主站先发单点遥控 C_SC_NA_1COT6激活从站校验通过后回 COT7激活确认表示选择成功主站再发一次同地址的 C_SC_NA_1COT6从站确认执行最后从站回 COT10激活终止表示整个过程结束。选择命令的信息对象地址是唯一索引比如要操作 5 号开关IOA5。信息元素里 bit0 是命令值0 表示分、1 表示合品质位默认 0。一条典型的选择命令长这样68 0C 00 00 01 00 2D 01 06 00 01 00 05 00 00 01注意类型标识 0x2D 就是 45C_SC_NA_1COT 是06 00IOA 是05 00 00最后信息元素01表示合闸命令。从站收到后如果设备允许操作回 COT7 的确认如果设备在闭锁状态回 COT7 但信息元素带 BL 品质位或者干脆回 COT10 拒绝。测试床里这两种情况都要能模拟因为真实电站里最常见的遥控失败就是闭锁和品质位异常。遥控调试里出现次数最多的失败现场是主站显示「遥控选择成功」但「执行超时」。原因多半是从站把执行帧当成了重复的选择帧因为两次下发的类型标识、IOA 和命令值完全一样区别只在发送序号和时序。真实从站是靠时序和确认状态机来区分的测试床里要把两次命令之间留出足够间隔并确认从站回完选择确认后再发执行命令。如果从站严格按照 IEC 60870-5-104 的状态机走选择后未收到执行前重复的选择命令会被直接拒绝。5. 测试床踩坑记录5 个让调试凭空多花几天的常见问题5.1 端口连不上2404 被占或被防火墙拦截现象主站脚本 connect 报错或者 TCP 握手都完成了但收不到任何确认帧。原因最常见的是本机已有其他程序占用了 2404其次是系统防火墙默认拦截了非本机的 TCP 连接。测试床虽然常常跑在 127.0.0.1但只要你想让虚拟机里的主站连宿主机从站防火墙就会出来拦路。解决先netstat -ano | findstr 2404Windows或sudo lsof -i :2404Linux确认端口占用有进程就先 kill。防火墙方面开发环境最简单的做法是允许 python 或 java 进程联网或者干脆在测试网卡上把防火墙关掉。注意别再开一个隐形的坑如果从站代码绑的是 127.0.0.1虚拟机里永远连不上要绑 0.0.0.0。5.2 序号对不上能握手但后续报文被对端静默丢弃现象STARTDT 握手正常主站发总召唤后迟迟收不到数据抓包看对端已经回了确认但业务帧不出现。原因主站发送序号 N(S) 没按帧递增。比如你连续发了两个 I 帧但控制域里 N(S) 都写的 0。严格实现的从站会认为第二帧是重复帧直接丢弃。更隐蔽的情况是两个 I 帧之间你插入了一个 S 帧导致序号统计和抓包对不上。解决抓包后在 Wireshark 的 IEC 104 解析树里看每一帧的 N(S) 和 N(R)。我习惯在代码里维护一个send_seq变量每发一个 I 帧递增 1S 帧和 U 帧不影响序号。另有一个容易忽略的点N(R) 是对端发送序号的确认值表示「我期望你下一次发的 N(S) 是多少」不是已收到的数量填错了同样会被当丢帧处理。5.3 字节序翻车把 IEC 104 当 Modbus 用大小端反了现象报文长度和类型都对但公共地址、IOA、遥测值解析出来是乱码。比如公共地址明明是 1解析成 256浮点遥测 22.5 读出来是个天文数。原因写过 Modbus 的人潜意识会按大端低地址高字节去解但 IEC 60870-5-104 的 ASDU 里公共地址、COT、IOA、整型遥测值全是小端低字节在前短浮点也按小端排列。RFC 风格的「高字节在前」在这个协议里不适用。解决构造报文时统一用struct.pack(H, addr)、struct.pack(I, value)这类小端格式解析时用对应的小端 unpack。冷启动测试床时第一条遥测报文最好用固定值比如 1234一眼就能看出来字节有没有反。5.4 空闲被踢下线t3 与 TESTFR 没形成闭环现象测试床跑一会儿双方 TCP 还连着但都不发业务了再过一阵子某一端主动断开连接。原因链路空闲时间超过 t3 后设备会发 TESTFR 主动帧等待确认。对端如果没实现 TESTFR 响应或者测试床代码里只处理了 I 帧和 S 帧TESTFR 确认就没人回发起方会在 t1 超时后断开连接。解决从站代码里至少补一个分支收到68 04 43 00 00 00时回68 04 83 00 00 00。更稳妥的做法是每隔 t3 的一半时间主动发一次 S 帧因为 S 帧本身就能重置对端空闲计时器链路保持会更持久。测试床里把 t3 设成 5 秒而不是 20 秒能在 10 秒内快速复现这个坑。5.5 品质位当数据读0xFF 里藏着无效标记现象从站上送的单点遥信值在画面里忽分忽合或者遥测值偶尔跳出一个大数打印原始字节明明是对的。原因解析信息元素时只取了最低位或低两字节把高 4 位的品质位当成了数据的一部分。比如单点遥信字节0x81bit0 的值明明是 1合但有些解析代码把它当整型 129 处理画面自然会异常。解决信息元素解析要按位处理数据位和品质位分开。单点遥信取byte 0x01品质位取(byte 4) 0x0F标度化遥测的整数值是两个字节品质位在第二个字节的高 4 位。测试床里构造报文的顺序反过来操作先确定数据值再按需往上拼品质位避免把品质位写进数据位。6. 进阶用法异常注入与定时器极限测试把测试床变成检测工具6.1 序号跳变与乱序注入验证对端窗口机制是否严密测试床真正值钱的地方在异常注入。一个最经典的用例是故意把 I 帧的 N(S) 跳过中间值看对端会怎么反应。import socket s socket.create_connection((127.0.0.1, 2404)) s.sendall(bytes.fromhex(68 04 07 00 00 00)) # STARTDT # 正常总召唤帧N(S)0 normal bytearray(bytes.fromhex( 68 0c 00 00 00 00 64 01 06 00 01 00 01 00 00 00)) s.sendall(bytes(normal)) # 篡改 N(S) 为 2跳过 1制造乱序 evil bytearray(normal) evil[2:6] b\x04\x00\x00\x00 # N(S)2N(R)0 s.sendall(bytes(evil)) print(s.recv(256).hex())代码里evil[2:6] b\x04\x00\x00\x00这句是注入点控制域低两字节改成04 00对应 N(S)2。如果对端实现严格会回 S 帧确认 N(R)1意思是「我还在等你序号为 1 的帧」如果对端直接断 TCP说明其协议栈把乱序当成严重故障处理如果对端照单全收并正常处理那你就要留个心眼这种实现可能在真实链路上丢操作记录。不同设备的这三类反应用手写测试床跑一遍就全清楚了。6.2 把定时器压到极端值验证断链恢复和重连策略另一个高频进阶操作是把 t1、t3 压到极端值在几秒钟内跑完真机上需要几分钟才能复现的超时场景。具体做法是把从站的 t1 改成 2 秒主站在收到数据后故意不回任何确认抓包观察从站是否在 2 秒后重发未确认的 I 帧再把 t3 改成 4 秒双方无业务交互时看 TESTFR 是否按 4 秒周期到达。这个过程顺便能验证一件事主站侧断开后会不会主动重连重连后会不会重新 STARTDT重连前需不需要等 TCP 的 TIME_WAIT 结束。这些都是真实电站故障恢复里绕不开的行为测试床里用压缩时间的方式把这些场景变成日常可重复的用例。我自己的习惯是每次搭完测试床先花半小时把这三类反常帧各注入一遍非法的 N(S) 跳变、故意不回的 S 帧确认、t3 期间不回 TESTFR。这三关过了这个测试床才能算「能拿去测别人的设备」。这套方法在同行的真实项目里帮我提前拦下过不少协议栈边界问题也希望帮你在做模拟实战时少走弯路。本文还有配套的精品资源点击获取
返回列表