简介: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 位:00=I 帧,01=S 帧,11=U 帧 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、接收序号 0;ASDU 里 64 是类型 100,01 是可变结构限定词,06 00 是传送原因 6,01 00 是公共地址 1,00 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_addr=1): """发送总召唤:类型 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, on=True, select=False): """发送双点遥控:类型 46,DCS+QU 两个信息元素字节。""" 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))select=False时qu=0x00,表示直接执行;select=True时qu=0x40,表示先选择。现场规约习惯是“选择-执行”两步走,仿真主站如果要严格模拟真实调度端,应该先发 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: # 归一化遥测:类型 21,2 字节带符号整数 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 00,0x03 是 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 异常注入是仿真工具最值钱的能力
手工测试很难稳定复现“总召唤不结束”“序号跳变”“公共地址不匹配”这类异常,但自动化注入很容易。我会在服务端加一个故障开关,支持一次性注入指定异常,然后断言主站是否按照预期报警或超时。这比用真实设备去模拟故障要安全太多——真实子站不会配合你做这种测试,也不该拿它做这种测试。
这套玩法坚持下来,最深的教训是:异常注入一定要可回放,每次注入前打印出发送的全部字节和序号,失败时能精确重放现场。否则问题变成玄学,排查全靠猜。希望这个思路帮你在后续联调里少熬几个夜。
本文还有配套的精品资源,点击获取