ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

IEC 104规约客户端与服务端仿真工具:从协议原理到自动化联调

IEC 104规约客户端与服务端仿真工具:从协议原理到自动化联调 简介IEC 104规约客户端与服务端仿真工具面向电力系统自动化领域的软件开发、测试与运维人员用于模拟调度主站与远动终端/保护装置之间的实时数据通信覆盖遥测、遥信、遥控、遥调等典型业务场景。资源包共137个文件以C源程序和头文件为主体附带编译好的可执行程序与动态链接库以及工程与编译辅助文件包体约14.55MB既可直接运行验证也可按需修改二次开发。仿真工具同时提供客户端与服务端两种角色客户端模拟调度主站发起请求并处理响应服务端模拟远动终端/保护设备返回数据并执行命令内部支持ASDU与U格式交互可模拟断线重连、异常帧、流量控制等复杂通信情况满足功能测试、兼容性测试、压力测试等多层次需求。对于需要掌握IEC 104规约报文结构、调试规约一致性或提前验证上下位机联调的工程师这套工具能显著缩短环境搭建时间降低依赖真实设备的测试成本。目前已有710人学习下载适合电力自动化领域初中级开发人员及测试人员参考使用。1. 从一次现场联调说起IEC 104规约客户端、服务端仿真工具到底解决什么问题在电力远动联调现场最怕的不是报文错而是手里没有对端可测。子站设备没到货主站程序写完了也只能干等或者设备到了但点表对不上主站解析逻辑对不对完全没法验证。IEC 104规约客户端、服务端仿真工具就是用来填这个空当的它既能扮演调度主站去连子站、发总召唤和遥控也能扮演子站去响应主站的查询把规约交互从一个黑匣子变成你能控制、能回放、能注入异常的过程。做远动主站开发的人、做子站接入测试的人、刚接触104规约想弄清一帧报文到底怎么组成的人都会从这套工具里得到实在的帮助。下面我把自己常用的一套做法拆开讲从协议模型到可运行代码最后落到避坑和自动化回归。2. 上手前先拆协议帧结构、ASDU 与三种地址仿真工具的核心能力就两件事把报文组织出来把收到的报文拆开。IEC 104 规约跑在 TCP 之上默认端口 2404应用层的每个完整报文叫 APDU。理解 APDU 怎么组装是后面所有代码的地基。这一章不打算抄规约文档只挑你写仿真工具必须用到的三块来讲控制域、ASDU 和具体报文长什么样。2.1 一帧报文怎么组装控制域与三种帧类型每一帧 APDU 的开头是两个固定字节启动符 0x68然后是长度字节。长度字节的值是“控制域 4 字节 ASDU 字节数”不是整帧长度。真正从 TCP 流里切帧时总长度要按2 data[1]来取这个细节很多新手会在粘包时栽跟头。长度字节后面是 4 字节控制域。控制域决定了这帧是 I 帧、S 帧还是 U 帧也携带序号信息帧类型低 2 位用途是否带序号I 帧00传数据ASDU带发送序号和接收序号S 帧01只确认对端 I 帧只带接收序号U 帧11连接控制STARTDT/STOPDT/TESTFR不带序号I 帧的发送序号和接收序号各 12 位编码时左移 1 位低字节在前。你要写仿真工具就得亲自处理这个编码不能偷懒。下面这个函数是我常用的一段拆帧逻辑可以直接放进工程里def parse_frame(data: bytes): 从缓冲区中切出一帧 APDU并判断帧类型。 data 至少要够 6 字节启动符 长度 4 字节控制域 一帧实际总长度 2 data[1]。 if len(data) 6: return None length data[1] if length 253 or len(data) length 2: return None apci data[2:6] asdu data[6:length 2] # 控制域第一字节的低 2 位00I 帧01S 帧11U 帧 tag apci[0] 0x03 if tag 0: # I 帧 send_seq ((apci[0] 1) | (apci[1] 7)) 0x0FFF recv_seq ((apci[2] 1) | (apci[3] 7)) 0x0FFF return I, send_seq, recv_seq, asdu if tag 1: # S 帧 recv_seq ((apci[2] 1) | (apci[3] 7)) 0x0FFF return S, recv_seq return U, apci[0] # 具体 U 帧功能码直接看字节这段代码的关键点在于I 帧返回发送序号和接收序号S 帧只返回接收序号U 帧只需返回第一个控制域字节就能区分 STARTDT、STOPDT、TESTFR。序号做 12 位截断是因为规约里序号到 4096 会回绕 0x0FFF可以保证这个行为。长度上限 253 是规约写死的收帧时先判断长度再决定要不要继续等数据能避免缓冲区被异常报文撑爆。提示TCP 是流协议没有消息边界。仿真工具必须自己用长度字段切帧否则两帧粘在一起时解析会全部错位。2.2 ASDU 里藏着三个地址类型标识、传送原因与公共地址控制域后面是 ASDU也就是应用服务数据单元。ASDU 的头部信息依次是类型标识 1 字节、可变结构限定词 1 字节、传送原因 2 字节、公共地址 2 字节然后才是信息体。这里有两个地方最容易翻车一是传送原因在 104 标准里是 2 字节且低字节在前很多老资料写成 1 字节二是公共地址也是 2 字节但部分设备自定义成 1 字节仿真工具必须支持配置不能写死。类型标识决定了这个 ASDU 是干什么的。写仿真工具只需要记住下面这一小张表就够用类型标识名称方向典型用途1单点遥信子站→主站上送开关位置、告警信号3双点遥信子站→主站上送双点位置信号21归一化遥测子站→主站上送电流电压等模拟量45单点遥控主站→子站下发单点命令46双点遥控主站→子站下发分合闸命令70初始化结束子站→主站子站上电后主动上报100总召唤双向主站发起全量数据召唤103时钟同步主站→子站对时命令传送原因常用的也不多6 表示激活7 表示激活确认20 表示响应站召唤3 表示突发4 表示自发。总召唤流程里主站发类型 100、传送原因 6子站先回传送原因 7 的确认帧再逐点上送传送原因 20 的数据最后再回一个传送原因 20、信息体地址为 0 的结束帧。这个流程逻辑上不难但仿真工具实现时最容易漏的是最后的结束帧后面避坑章节我会专门讲。2.3 常用报文速查先用手工报文验证工具可用动手写代码之前我习惯先用一组十六进制报文验证抓包工具和网络通路。以下是最常用的三组可以直接在调试助手里发送# U 帧STARTDT 激活主站请求开始数据传输 68 04 07 00 00 00 # U 帧STARTDT 确认子站同意开始传输 68 04 0B 00 00 00 # 总召唤公共地址 1无其他信息体 68 0D 00 00 00 00 64 01 06 00 01 00 00 00 00按第 2.1 节的拆帧逻辑看最后这帧长度 0x0D 表示控制域 4 字节加 ASDU 9 字节控制域 00 00 00 00 表示发送序号 0、接收序号 0ASDU 里 64 是类型 10001 是可变结构限定词06 00 是传送原因 601 00 是公共地址 100 00 00 是信息体地址 0。整帧就是一次标准的站召唤请求。你可以在仿真工具里用这一帧做回环测试能正确解析出(I, 0, 0, asdu)就说明拆帧逻辑没问题。3. 工具选型与最小工程用 Python 搭一个能收能发的骨架3.1 自研还是找现成先确认你测的是哪一层很多人上来就问有没有现成的仿真软件。我的判断标准是如果你只是偶尔抓包看一眼报文现成抓包工具加一个 TCP 调试助手就够了但如果你要验证主站的点表解析、要做故障注入、要模拟子站异常行为那必须自己写。原因很简单——现成工具大多数是“规约完整、行为固定”的你没法让它故意不回结束帧、故意把公共地址写成 1 字节、故意丢一个序号。可这些恰恰是现场联调最常遇到的场景。自研的成本没有想象中高。IEC 104 的报文规律性很强核心就两类U 帧管连接I 帧管数据。用 Python 的 socket 标准库加一个线程就能搭出双向仿真骨架不需要引入额外依赖。下面这个最小工程是我每次写新工具都会用的底子。3.2 最小工程结构与文件职责文件职责iec104_endpoint.py帧收发、粘包切帧、序号编码解码point_table.json点表配置遥信、遥测的地址和初始值client_demo.py仿真主站连接、总召唤、遥控server_demo.py仿真子站监听、握手、按点表应答iec104_endpoint.py是公共层两个角色都用它。point_table.json把点表从代码里拆出去这样换一套点表不用改程序改完 JSON 直接重启。后面的章节里我会沿着这个结构逐一填充代码。3.3 收发框架代码先解决粘包和拆帧import socket import threading class Iec104Endpoint: 既是客户端又是服务端的收发骨架先解决粘包和拆帧。 def __init__(self, role: str): self.role role # client 或 server self.sock None self.buf b def recv_frame(self): 从自维护缓冲中切出一帧返回 bytes 或 None。 if len(self.buf) 2: return None if self.buf[0] ! 0x68: # 帧头不是 0x68说明同步点丢了做最保守处理等下一段数据 self.buf self.buf[1:] return None total 2 self.buf[1] if len(self.buf) total: return None frame self.buf[:total] self.buf self.buf[total:] return frame def run(self): 阻塞式接收循环收数据、追加缓冲、逐帧交给上层处理。 while True: chunk self.sock.recv(4096) if not chunk: break self.buf chunk while True: frame self.recv_frame() if frame is None: break self.handle_frame(frame) def handle_frame(self, frame: bytes): raise NotImplementedError(由客户端/服务端子类实现)核心思想是recv(4096)拿到的数据可能包含多帧也可能只包含半帧所以不能拿到什么解什么。先把数据塞进buf再循环调用recv_frame按长度字段切帧。recv_frame返回None代表缓冲区里的数据还不够一帧等下一轮recv继续凑。帧头校验失败时我只丢一个字节而不是清空缓冲这是从一次现场数据串位里学到的——清空缓冲会把后面所有好帧也一起牺牲掉。提示recv_frame里总长度计算用2 data[1]不是data[1]。长度字段不包含启动符自己这个换算关系要刻在脑子里。4. 客户端与服务端双向仿真从 U 帧握手到总召唤完成4.1 客户端侧U 帧握手、总召唤与遥控命令主站连上子站后第一步不是直接发数据而是要先发 STARTDT 激活帧。子站回确认帧后双方才进入数据传输状态。这个握手看起来简单但很多自研工具在这步就翻了车U 帧不占序号控制域四字节是固定的直接按字节构造别用序号编码函数去碰它。import struct def build_i_frame(send_seq: int, recv_seq: int, asdu: bytes) - bytes: 构造一帧 I 帧。 控制域 4 字节前 2 字节放发送序号后 2 字节放接收序号 序号编码方式是左移 1 位低字节在前。 s (send_seq 1) 0xFFFF r (recv_seq 1) 0xFFFF control struct.pack(HH, s, r) return b\x68 bytes([4 len(asdu)]) control asdu def client_handshake(sock): 发送 STARTDT 激活等待确认帧返回 True/False。 sock.sendall(b\x68\x04\x07\x00\x00\x00) resp sock.recv(6) if len(resp) 6 and resp[2] 0x0B: return True return False def send_total_call(sock, send_seq, recv_seq, common_addr1): 发送总召唤类型 100传送原因 6激活信息体地址 0。 asdu bytes([100, 1, 6, 0]) bytes([common_addr, 0]) b\x00\x00\x00 sock.sendall(build_i_frame(send_seq, recv_seq, asdu)) return (send_seq 1) % 4096client_handshake里我直接对resp[2]判断是因为 U 帧控制域第一字节是 0x07确认帧是 0x0B。收到的确认帧长度固定是 6 字节如果对端不回或者回了别的帧基本可以断定握手失败。send_total_call里构造 ASDU 时有几个参数需要特别说明传送原因 6 占 2 字节低字节在前公共地址也占 2 字节信息体地址占 3 字节且全为 0。这里的公共地址默认写 1如果你的系统是 2 或者 5改common_addr参数就行。发送序号每次发完一帧要加 1模 4096这就是 I 帧的序号维护逻辑。遥控命令和总召唤的差别只在信息体部分类型 45/46信息体地址指向具体的遥控点号然后跟两个字节——第一个字节是分/合命令值第二个字节是操作限定词。def send_remote_control(sock, send_seq, recv_seq, common_addr, ioa, onTrue, selectFalse): 发送双点遥控类型 46DCSQU 两个信息元素字节。 dcs 1 if on else 0 qu 0x40 if select else 0 asdu bytes([46, 1, 6, 0]) bytes([common_addr, 0]) \ ioa.to_bytes(3, little) bytes([dcs, qu]) sock.sendall(build_i_frame(send_seq, recv_seq, asdu))selectFalse时qu0x00表示直接执行selectTrue时qu0x40表示先选择。现场规约习惯是“选择-执行”两步走仿真主站如果要严格模拟真实调度端应该先发 select 帧收到确认后再发 execute 帧。但有些子站实现并不区分直接执行也能接受。这里做成参数而不是写死两个函数就是为了兼容这两种用法。4.2 服务端侧启动上报、点表响应与总召唤结束服务端仿真要处理的事情比客户端多一点。除了回应握手还要在上电后主动上报初始化结束类型 70传送原因 4以及响应总召唤。下面这段是一个可运行的处理骨架def send_i_frame(conn, send_seq, recv_seq, asdu): conn.sendall(build_i_frame(send_seq, recv_seq, asdu)) def handle_total_call(conn, send_seq, recv_seq, common_addr, points): 子站响应总召唤确认帧 - 逐点上送 - 结束帧返回新的发送序号。 # 1. 激活确认类型 100传送原因 7 asdu bytes([100, 1, 7, 0]) bytes([common_addr, 0]) b\x00\x00\x00 send_i_frame(conn, send_seq, recv_seq, asdu) send_seq (send_seq 1) % 4096 # 2. 按点表逐点上送传送原因 20响应站召唤 for pt in points: if pt[type] sp: # 单点遥信 value pt[value] 0x01 asdu bytes([1, 1, 20, 0]) bytes([common_addr, 0]) \ pt[ioa].to_bytes(3, little) bytes([value]) else: # 归一化遥测类型 212 字节带符号整数 value int(pt[value] / pt[scale]) asdu bytes([21, 1, 20, 0]) bytes([common_addr, 0]) \ pt[ioa].to_bytes(3, little) struct.pack(h, value) send_i_frame(conn, send_seq, recv_seq, asdu) send_seq (send_seq 1) % 4096 # 3. 总召唤结束类型 100传送原因 20信息体地址 0 asdu bytes([100, 1, 20, 0]) bytes([common_addr, 0]) b\x00\x00\x00 send_i_frame(conn, send_seq, recv_seq, asdu) return (send_seq 1) % 4096注意handle_total_call里的recv_seq参数传的是“服务端期望收到的下一帧序号”也就是客户端已经发过来的 I 帧数量。这个序号在服务端发送的每一帧里都要带上否则主站会认为序号对不上而丢帧。参数命名容易绕我这里说清楚send_seq是服务端自己的发送序号recv_seq是服务端对客户端的确认序号。两个序号各自维护互不干扰。总召回的结束帧是整个流程最容易漏的一步。主站判断一次总召唤是否完成不是看数据量而是等这个类型 100、传送原因 20、信息体地址 0 的结束帧。漏了它主站侧会一直认为召唤还在进行中超时后才放弃。你写服务端仿真时把结束帧当成和确认帧一样的必发步骤就不会踩这个坑。初始化结束帧的构造更简单子站一连接成功就主动发def send_init_end(conn, send_seq, recv_seq, common_addr): 上电初始化结束类型 70传送原因 4自发。 asdu bytes([70, 1, 4, 0]) bytes([common_addr, 0]) \ b\x00\x00\x00 bytes([0]) send_i_frame(conn, send_seq, recv_seq, asdu) return (send_seq 1) % 4096类型 70 的信息元素只有一个字节表示初始化原因0 代表本地电源合上1 代表手动复位2 代表远方复位。仿真工具里默认发 0 就行。发送时机要在 STARTDT 握手完成之后不要在 TCP 刚建立时就发因为数据还没激活主站可能直接丢弃。4.3 双向联调一个工具切换两种角色前面两端逻辑都齐了但仿真工具最好能做到“同一个程序参数不同角色不同”。我的做法是在Iec104Endpoint里加一个role属性运行入口通过命令行参数决定启动客户端还是服务端# 模拟主站连接 127.0.0.1:2404 python client_demo.py --host 127.0.0.1 --port 2404 # 模拟子站监听 2404 python server_demo.py --port 2404这样联调时最方便——一台机器开两个进程一个是主站逻辑一个是子站逻辑能直接自测也能把进程拆到两台机器上中间隔一个交换机验证真实网络环境下的超时和重连。我常用的联调方式是先回环测通再用真实 IP 跑一遍最后在中间加一个延迟代理模拟弱网。别小看这个切换它能让一个工具同时承担主站开发自测和子站出厂联调两种角色省掉两套工具。5. 仿真工具常见问题定位与避坑实录5.1 握手没回应STARTDT 帧字节写错现象主站侧一直发 U 帧子站侧收得到但没有任何回包。原因最常见是 U 帧字节构造错误。比如把 STARTDT 激活帧写成68 04 03 00 00 000x03 是 U 帧标识但功能位不对或者把确认帧写成了激活帧子站收到两个 STARTDT 激活自然不会再回。另一个常见原因是子站程序在握手之前就把数据帧发出去了违反时序。解决先用第 2.3 节的报文速查表在网络调试助手里手工发一次68 04 07 00 00 00确定对端会回68 04 0B 00 00 00。能回说明网络和对端程序正常问题出在自己的代码不回则先检查对端监听端口和 TCP 连通性。5.2 总召唤流程卡死漏了结束帧现象主站能收到遥信遥测数据但流程一直不结束界面始终显示“总召唤中”直到超时。原因主站判断总召唤完毕靠的是结束帧也就是类型 100、传送原因 20、信息体地址 0 的那一帧。很多仿真子站在发完点表数据后就直接返回了漏掉了这个收尾动作。主站等不到结束帧只能等超时表现出来就是流程卡死。解决在handle_total_call里数据发送循环结束后必须追加结束帧。可以写一个断言测试把服务端回包全部抓下来检查最后一帧 ASDU 是不是64 01 14 00 ...开头的类型 100 报文。是流程才完整不是说明你的响应逻辑有遗漏。5.3 数据错位串行地址宽度不一致现象收到多条遥信后对象号对不上有的点无故消失有的点反复跳变。原因104 标准里公共地址固定 2 字节、信息体地址固定 3 字节但不少实际设备把公共地址精简成 1 字节。如果你的仿真工具按标准 3 字节去解 1 字节的报文所有信息体地址都会往后错一位解析出来全是乱码。我在某项目里就碰到过这样的老设备用标准解析器看它的报文怎么都对不齐后来逐个字节对照才发现公共地址只占 1 字节。解决仿真工具把地址宽度做成配置项。我一般在点表 JSON 里加两个字段common_addr_len和ioa_len默认 2 和 3遇到自定义设备时改成 1 和 2。关联的编解码函数全部走这两个参数不要在任何地方写死地址长度。5.4 序号对不上I 帧被对端静默丢弃现象TCP 连接正常握手也成功但发了几个 I 帧后对端就不再响应过一会主动断开。原因I 帧序号维护出错。典型场景是服务端收到两个 I 帧后只回了一个 S 帧或者客户端发送序号忘记加 1导致对端期望的接收序号和收到的序号不匹配。104 对序号错位很严格对端收到非期望序号的帧会直接丢弃连续几次就会触发 TCP 层断开。解决把序号维护收敛到两个函数里发送帧的地方统一走build_i_frame接收确认的地方统一走parse_frame不要到处手动改序号。另外每收到一个 I 帧就立即回一个 S 帧是仿真工具最稳妥的做法。下面是 S 帧构造方法def build_s_frame(recv_seq: int) - bytes: S 帧只带接收序号用于确认对端 I 帧本身不占发送序号。 r (recv_seq 1) 0xFFFF return b\x68\x04\x01\x00 struct.pack(H, r)注意 S 帧不占发送序号这一点和 I 帧完全不同。它的 4 字节控制域里第一位固定 01后三个字节里只有第三、四字节携带接收序号。5.5 带时标报文总报错CP56Time2a 字节顺序反了现象带时标的遥信能收到但时间显示完全不对年份像是负数分钟突然跳到 60 多。原因CP56Time2a 这个 7 字节时标的顺序很容易踩坑。很多初学者凭直觉按“年月日时分秒”排字节但规约里是先毫秒2 字节、再分钟、再秒、再日星期、再月年最后才是单独的年份字节。顺序一错解析出来的时间必然乱成一团。解决按规约顺序逐字节填不要自己重新排列。构造时可以用一个小函数def build_cp56_time(now) - bytes: 按 CP56Time2a 的 7 字节顺序构造时标。 ms now.microsecond // 1000 return (struct.pack(H, ms) bytes([now.minute]) bytes([now.second]) bytes([now.day | (now.weekday() 1) 5]) bytes([now.month | ((now.year % 100) 0x0F) 4]) bytes([(now.year % 100) 4]))这个函数里最容易看错的是后三个字节第 5 字节低 5 位是日、高 3 位是星期第 6 字节低 4 位是月、高 4 位是年的低 4 位第 7 字节是年的高 7 位实际上年份 0-99 需要 7 位拆到了两个字节里。写完后用一个已知时间回环验证解出来一致再集成进主流程。6. 进阶让仿真工具变成自动化回归平台6.1 用点表文件驱动一次性回归当你手里有了一个能跑通的双向仿真工具下一步就不要停留在“手工点一下、看一眼报文”的层面了。我会把点表文件直接当作测试用例的输入启动服务端后用脚本自动连接、自动发总召唤、自动比对返回值和点表里的期望值。点表 JSON 可以这样组织{ common_addr: 1, common_addr_len: 2, ioa_len: 3, points: [ {ioa: 1, type: sp, value: 1, expect: 1}, {ioa: 2, type: sp, value: 0, expect: 0}, {ioa: 3, type: meas, value: 50, scale: 1, expect: 50} ] }回归脚本做的事很简单把点表里每个点的预期值发到服务端服务端按点表内容应答脚本断言收到的值等于expect。跑一轮全程不到 1 秒比手工核对快得多也更不容易漏点。6.2 异常注入是仿真工具最值钱的能力手工测试很难稳定复现“总召唤不结束”“序号跳变”“公共地址不匹配”这类异常但自动化注入很容易。我会在服务端加一个故障开关支持一次性注入指定异常然后断言主站是否按照预期报警或超时。这比用真实设备去模拟故障要安全太多——真实子站不会配合你做这种测试也不该拿它做这种测试。这套玩法坚持下来最深的教训是异常注入一定要可回放每次注入前打印出发送的全部字节和序号失败时能精确重放现场。否则问题变成玄学排查全靠猜。希望这个思路帮你在后续联调里少熬几个夜。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表