☰
IEC 104主站源码落地:链路状态机、序号编码与超时机制全解析
2026/10/20 1:48:09 网站建设 项目流程

简介:这是一份以IEC 104协议为核心的电力自动化主站通信源码,基于TCP/IP传输与ASDU应用层数据结构,面向SCADA系统开发与运维人员,用于解决主站与RTU等子站设备之间的数据采集、控制命令下发及链路维护等实际问题,适合具有基本网络编程基础的开发者参考。资源包共28个文件,主要包含4个C++源文件、3个头文件、9个编译中间文件(.o),以及VC工程配置(vcproj/sln/dsw)、Linux/HP-UX平台的make文件和工程说明文档,整体仅302KB,代码量适中、结构紧凑,便于按模块研读和复用。源码覆盖连接建立、心跳维持、数据请求、ASDU解析、错误处理及时间同步等主站关键功能,其中连接管理负责TCP链路建立与重连,数据请求与控制命令模块处理遥测、遥信及遥控操作,ASDU解析模块完成应用层数据编解码,层次清晰,并带有跨平台构建配置与多用户环境文件,方便在不同开发环境中复现和修改。已有832人学习下载,尤其适合需要快速掌握IEC 104主站程序框架、理解ASDU/TCPU消息结构或进行二次开发的工程师参考。

1. 104主站源码到手:先认清它管的是链路状态机,不是单纯收发

我之前调过一段 104 主站接入项目,A同学从某渠道拿了一套 IEC 104 主站源码,打开一看里面有 TCP 收发、报文解析、总召对话框,以为直接就能连厂站。结果一接真实设备就出问题:总召发出去一直等不到结束帧,遥控偶发超时,从站那边还抱怨主站序号对不上。后来排查才发现,这套源码的核心价值不在拼报文,而在一套完整的主站侧链路状态机:启动激活、序号校验、超时兜底、总召会话、遥控选择执行。它解决的是“TCP 连着但 104 链路不稳定”这一类问题,适合做前置接入、监控主站开发、协议栈集成的人。拿到手别急着跑通例子,先把序、帧、超时这三件事看明白,后面调设备才不至于全靠玄学。

2. 规约核心机制:序、帧、超时三件事决定主站能不能稳定跑

2.1 三种帧型与序号左移一比特的编码套路

IEC 60870-5-104 在 TCP 之上跑的是 APCI 控制域,总共四字节。按控制域第一字节的最低两位区分帧型:最低位为 0 是 I 帧,最低位为 1 且第二位为 0 是 S 帧,低两位都为 1 是 U 帧。U 帧最常见的就是三条:STARTDT 启动链路(0x07)、TESTFR 测试帧(0x43)、STOPDT 停止链路(0x13),对应的确认帧分别是 0x0B、0x83、0x17。

序号这块容易把人绕晕。I 帧的发送序号 N(S) 和接收序号 N(R) 都是模 32768 递增,但在字节编码里不是直接放原始数值,而是把序号左移一位再按小端放到控制域里。也就是 N(S)=1 时,控制域前两字节是02 00而不是01 00;N(S)=0 时才是00 00。很多人第一次抓包看到总召帧控制域是02 00 00 00,以为报文错了,其实那是 N(S)=1 的合法编码。S 帧固定01 00开头,接收序号同样左移一位后放在第三、第四字节。

为什么这样设计?因为最低一位要让给帧型标志,收发双方才能在任何时刻只读第一个字节就判断出这是哪类帧,不用先解析完整控制域。我看过一些从站实现,主站发来序号为 0 的 I 帧后,从站回复 S 帧确认,第三字节是02,对应 N(R)=1,这就是左移位的直观体现。主站源码里如果发送或解析序号时漏掉这一步,和真实从站联调必定出现序号不连续的错误。

2.2 超时参数别看默认值,要看链路冗余和 t2<t1 约束

104 主站能稳定跑,一半功劳在超时参数。标准里定义了四个基础超时:t0 是 TCP 连接建立后的等待激活确认时间,t1 是发送 I 帧后等待对方确认的时间,t2 是收到对端 I 帧后、本地最迟回确认的时间,t3 是链路上无数据时发送测试帧的周期。典型推荐值如下表:

参数含义推荐范围典型实现值
t0连接建立到收到 STARTDT_CON10~30s10s
t1发帧后等确认15~30s15s
t2收帧后回确认的最大时延10~15s10s
t3无数据时发送 TESTFR 周期20~45s20s
k最大未确认 I 帧数1212
w连续收帧后必须回确认的门限88

这里有几个硬约束。t2 必须小于 t1,否则对端还没等到你的确认,自己先按 t1 超时把链路判断成故障;t3 通常建议大于 t1,这样测试帧的周期不会比数据确认超时还短,避免链路抖动时测试帧和业务帧互相干扰。主站源码里如果这三个超时是写死的,我一般会改成配置项,因为不同从站对遥测上送速度要求不一样,有些设备要求主站总召期间不发其他 I 帧,这时候 k 和 t1 都得跟着调。

2.3 APDU 组装顺序与总召帧逐字节拆解

104 报文的 APDU 结构是启动符 0x68、长度字节、APCI 四字节、ASDU。ASDU 的字段顺序别记错:类型标识、信息体数目、传输原因两字节(原因在前,附加标志在后)、公共地址两字节、信息体地址三字节、最后才是信息元素。以总召唤为例,典型帧是这样:

68 0E 02 00 00 00 64 01 06 00 01 00 00 00 00 14

逐段拆:68是启动符,0E是 APCI 加 ASDU 共 14 字节;02 00 00 00是 APCI 控制域;64是类型标识 C_IC_NA 总召唤;01是信息体数目;06 00是传输原因,0x06 表示激活;01 00是公共地址,低字节在前;00 00 00是信息体地址;14是限定词,表示总召唤。从站回总召数据时,类型标识不变,传输原因换成 0x14(响应总召唤),等全部数据上送完,再回一帧传输原因 0x0A(激活终止)的总召唤帧,主站只有收到这帧才算一轮总召结束。

ASDU 里所有多字节数值都是低字节在前,这是最容易和 TCP 字节序搞混的地方。很多源码在解析遥测值时把信息体地址直接按大端读,结果地址从 0x0102 变成 0x0201,定位不到具体测点。主站源码里我建议把所有数值读取统一封装成小端接口,别在业务代码里散着写。

2.4 源码包里哪些模块对应规约的哪些职责

一份能落地的 104 主站源码,通常包含以下几个模块:链路管理模块负责 TCP 连接、启动激活、心跳保持;APCI 编解码模块负责序号的左移编码与校验;ASDU 解析模块负责类型标识、传输原因、公共地址的逐项解析;业务调度模块负责总召、遥控、对时的流程编排;状态统计模块负责链路质量和收发计数。对应关系大致是:链路管理管 t0/t3,APCI 模块管 N(S)/N(R) 和 t1/t2,业务调度管总召会话里的 COT 流转。

如果源码里只有收发和解析,没有独立的状态机,那它大概率只覆盖了“能发能收”,没覆盖“链路异常后怎么恢复”。我判断一份源码质量时,会优先看链路管理模块里有没有处理“重复 STARTDT”“TESTFR 无响应”“序号不连续”这三个分支,有的话才值得继续看下去。

3. 主站源码落地:TCP 链路、序号管理、总召与遥控的代码实现

3.1 建立 TCP 连接并完成 STARTDT 激活

主站和从站建立 TCP 连接后,不能直接发 I 帧,必须先发 STARTDT 激活帧,等收到 STARTDT_CON 才能开始业务传输。下面是主站侧连接激活的核心代码:

import socket import struct import time class Iec104Link: def __init__(self, ip, port=2404): self.ip = ip self.port = port self.sock = None self.send_seq = 0 # 主站发送序号,只发 I 帧时自增 self.recv_seq = 0 # 主站期望收到的对端发送序号 self.active = False def connect(self, t0=10): self.sock = socket.create_connection((self.ip, self.port), timeout=t0) # 发送 STARTDT 激活帧:68 04 07 00 00 00 self.sock.sendall(bytes([0x68, 0x04, 0x07, 0x00, 0x00, 0x00])) resp = self.sock.recv(6) if resp == bytes([0x68, 0x04, 0x0B, 0x00, 0x00, 0x00]): self.active = True self.send_seq = 0 self.recv_seq = 0

连接建立后立即发送 0x07 的 U 帧,这个动作必须在业务帧之前完成。收到 0x0B 的确认后,才把序号清零并置位激活标志。如果 t0 时间内没等到确认,我一般把连接直接关闭重连,而不是继续等待,因为从站可能处于异常状态,继续等只会拖累主站调度。

3.2 I 帧序号编码与收帧校验

I 帧的编码是主站源码里最容易出 bug 的地方,关键在于序号要左移一位再按小端写入控制域:

def build_i_frame(self, asdu): # 发送序号左移 1 位后,和接收序号一起小端打包 ns_code = (self.send_seq << 1) & 0xFFFF nr_code = (self.recv_seq << 1) & 0xFFFF apci = struct.pack('<HH', ns_code, nr_code) self.send_seq = (self.send_seq + 1) % 32768 return bytes([0x68, len(apci) + len(asdu)]) + apci + asdu def parse_i_frame(self, frame): if len(frame) < 6: return None ctrl = frame[2:6] # 从控制域解析出对端发送序号和确认序号 ns = struct.unpack('<H', ctrl[0:2])[0] >> 1 nr = struct.unpack('<H', ctrl[2:4])[0] >> 1 return ns, nr

说明:struct 的<H是小端无符号短整型,正好把控制域一、二字节拼成发送序号的编码值,右移一位还原原始序号。主站发 I 帧前先把当前 send_seq 左移编码进帧,发送后立即自增;解析对端 I 帧时,ns 必须等于本地 recv_seq,否则说明两侧序号失步。这里需要留意 mod 32768 的回绕,序号超过 32767 后要回到 0,很多源码忽略了这一点,长期运行的链路会在某个时刻突然序号不匹配。

收到 I 帧之后还要及时回确认。标准允许攒够 w 帧再回 S 帧,但实现上为了简单可以先每收一帧就回一个 S 帧,确认序号用当前 recv_seq 左移一位:

def send_s_frame(self): nr_code = (self.recv_seq << 1) & 0xFFFF self.sock.sendall(bytes([0x68, 0x04, 0x01, 0x00]) + struct.pack('<H', nr_code))

逻辑说明:S 帧不携带业务数据,只用来确认收到的 I 帧数量。recv_seq 代表“下一个期望收到的序号”,所以确认时直接用 recv_seq 左移一位即可。如果数据量很大,建议按参数 w 做计数,每收满 w 帧回一次 S 帧,减少空帧占用带宽。

3.3 总召发起与会话结束判定

总召是一轮完整的“询问—响应—结束”会话,主站发总召唤 C_IC_NA,然后从站会先回激活确认,接着上送遥信遥测数据,最后回激活终止。实现时要靠传输原因来判断阶段:

def send_total_interrogation(self, common_addr=1): # C_IC_NA 总召唤 ASDU,COT=0x06 激活,限定词 0x14 总召唤 asdu = bytes([0x64, 0x01, 0x06, 0x00, common_addr & 0xFF, (common_addr >> 8) & 0xFF, 0x00, 0x00, 0x00, 0x14]) self.sock.sendall(self.build_i_frame(asdu)) def wait_interrogation_done(self, timeout=15): end = time.time() + timeout while time.time() < end: frame = self.sock.recv(2048) if len(frame) < 6: continue if frame[2] & 0x01 == 0: # 是 I 帧 asdu = frame[6:] if asdu and asdu[0] == 0x64 and asdu[2] == 0x0A: return True # 收到激活终止,总召结束 return False

参数说明:总召唤 ASDU 的信息体地址填 0,限定词 0x14 表示站级总召。wait_interrogation_done 里通过类型标识 0x64 和传输原因 0x0A 判断结束帧,这里的 asdu[2] 是传输原因的第一个字节。一个常见的错误是只等待数据帧,不等待激活终止,导致总召还没完成就发下一条指令,某些从站会在总召未结束时拒绝其他 I 帧。

3.4 遥控命令的选择与执行

遥控分两步:先是选择命令,从站确认后,再发执行命令。直接执行在部分从站也可用,但对国网规范的厂站,建议按选择加执行来做,避免误动。

def send_remote_control(self, io_addr, on_off, select=True): # C_SC_NA 双点遥控,类型标识 0x2D,COT=0x06 激活 value = 0x01 if on_off else 0x00 base = bytes([0x2D, 0x01, 0x06, 0x00, 0x01, 0x00]) + \ io_addr.to_bytes(3, 'little') sel_frame = base + bytes([value, 0x81]) # 选择命令 exe_frame = base + bytes([value, 0x82]) # 执行命令 self.sock.sendall(self.build_i_frame(sel_frame if select else exe_frame))

这里的 0x81 是限定词:低两位为 1 表示选择,bit7 的 S/E 位为 1 表示带选择标志;0x82 同理,低两位为 2 表示执行。从站收到选择命令后会回 COT=0x07 的激活确认,主站收到后再发执行命令。注意从站对遥调的执行时间可能较长,t1 超时建议按从站手册的最大动作时间设置,不要盲目用默认 15s。

4. 主站调试避坑:五个最常见的翻车点与排查路径

4.1 现象:STARTDT 发出去一直等不到 0x0B 确认,主站反复重连

原因大概率不在报文本身,而在链路建立前的状态。我遇到过两种情况:一种是对端端口没监听,TCP 连接根本建不起来;另一种是从站已经被另一条链路激活,部分从站只允许单个活动连接,新主站的 STARTDT 直接被忽略。解决方法是先在主机侧确认 TCP 能连通,再看从站侧是否已有活动连接。如果确认存在旧连接,先发 STOPDT 停运帧,或者重启从站通信进程,然后再发 STARTDT。源码里最好把重连动作改成指数退避,避免每秒钟原地重试把从站日志刷爆。

4.2 现象:调试工具解析总召帧控制域02 00报“非法帧类型”

原因就是 2.1 节说的序号编码规则:发送序号要左移一位,N(S)=1 时编码结果是 0x02。很多调试工具或初学者按原始序号读取,发现最低两位不是 00 就判定为非法帧。解决方法是解析时把控制域前两字节读成小端无符号数,再右移一位得到真实序号。发送时反过来左移。如果源码里用的是裸字节拼接而不是 struct 打包,建议统一改成(序号 << 1) & 0xFFFF的方式,省得后续维护的人再踩一次。

4.3 现象:遥控命令频繁超时,但手工用调试工具发同样的帧从站有响应

原因通常是主站侧的 t1 设置小于从站实际执行时间,或者主站在总召会话未结束时发了遥控帧,被从站当作链路状态异常处理。解决方法是先排除会话冲突:确认总召已经收到激活终止再发遥控;再把 t1 调大到从站允许的最长动作时间。有些主站实现用的是“发送即计时、收到任意帧即重置”的粗粒度超时,这在点表扫描密集时会被无关帧干扰,应该改成“针对单个未确认帧计时”。这个细节源码里如果没做,我一般会补一个 pending 队列,逐帧记录发送时间和重试次数。

4.4 现象:链路显示已激活,但一收 I 帧就报序号不连续

原因有两个方向。一是主站重连后没有重置序号,还在用旧连接的 send_seq 和 recv_seq;二是对端从站重启过,自身序号已经清零,而主站还在期待旧序号。解决方法是把序号清零动作放到 STARTDT 确认之后,而不是 TCP 连接建立时;同时收到序号不连续帧时,不要立刻断开,先发 TESTFR 探活,如果测试帧有响应,说明链路本身是通的,只是序号失步,可以重新走一次 STARTDT 流程复位。我在源码里会做一个保护:连续三次序号失步才触发链路复位,避免抖动造成不必要的重连。

4.5 现象:总召后遥测数据长时间不刷新,从站侧却显示已上送

原因大多是主站没有正确识别总召会话边界。从站上送遥测时传输原因通常是 0x14,但主站如果只把这一批数据存下来,没有继续等待 0x0A 的激活终止,就不会触发后续的点表刷新。另一个常见原因是总召期间主站又发了其他 I 帧,占用了从站发送窗口,导致剩余数据没发完。解决方法一是代码里严格按“发总召—收激活确认—收数据—收激活终止”四段来管理,中间不发业务帧;二是给总召会话设一个较长的超时,比如 30 秒,超时后重新发起新一轮总召,保证链路数据最终收敛。

5. 不接设备也能验证:模拟从站、抓包过滤与回归用例

5.1 先用一个最小的模拟从站跑通握手

没有真实厂站时,我用 Python 写一个最小模拟从站,监听 2404 端口,把主站的握手和数据帧都打出来。这样做的好处是把问题限定在主站这一侧,避免真实设备的抖动干扰判断。

import socket HOST, PORT = '0.0.0.0', 2404 srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.bind((HOST, PORT)) srv.listen(1) conn, addr = srv.accept() print('client connected', addr) while True: data = conn.recv(2048) if not data: break # 简单应答:U 帧直接回对应的确认帧 if data[2] == 0x07: conn.sendall(bytes([0x68, 0x04, 0x0B, 0x00, 0x00, 0x00])) elif data[2] == 0x43: conn.sendall(bytes([0x68, 0x04, 0x83, 0x00, 0x00, 0x00])) else: print('recv', data.hex())

这段代码把主站的 STARTDT、TESTFR、I 帧全打印出来,模拟从站只回复链路控制帧,不回复业务数据。验证主站时,先看主站是否能在连接建立后立刻发出 STARTDT,再手动往主站回几帧带序号的 I 帧,观察主站是否会回 S 帧。这个手法能快速区分问题在链路层还是在业务层。

5.2 用抓包工具按 2404 端口过滤逐帧核对

Wireshark 里我一般用tcp.port == 2404过滤,再配合104apci解析器看每一帧的类型和序号。重点关注三个时序:启动阶段是否有完整的STARTDT -> STARTDT_CON;链路空闲时是否按 t3 周期出现TESTFR -> TESTFR_CON;主站每发一个 I 帧,对端是否在合理时间内回 S 帧或反向 I 帧。如果抓包显示主站发了 I 帧但长时间没有对端确认,而 TCP 又是通的,基本可以断定问题在应用层序号管理:要么序号编码错误,要么对端因序号失步静默丢帧。

我还习惯在抓包里核对发送序号的连续性。比如主站连续发三帧 I 帧,控制域前两字节应该依次是00 00、02 00、04 00,因为每发一帧序号加一、左移一位后就是步进 2。如果中间跳了一档,说明主站代码里有发送失败但序号仍然自增的逻辑,这个 bug 很隐蔽,因为 TCP 层面不会丢帧,但对端会因序号不连续把后续帧全部丢弃。

5.3 把常见交互场景固化成回归用例

调试稳定后,我会把下面这些场景做成自动回归用例,每次改动代码都跑一遍:

场景操作预期结果
初次连接启动主站发出 STARTDT,收到 0x0B 后状态为激活
链路空闲不发送业务帧每隔 t3 收到 TESTFR 且有确认
I 帧确认模拟从站发一帧 I 帧主站回 S 帧且 NR 正确
总召一轮模拟从站回全量数据加 0x0A主站标记总召完成并刷新点表
遥控执行模拟从站回 0x07 确认主站判定遥控成功并记录日志
链路中断断开 TCP 后重连序号清零,重新走 STARTDT

这组用例我花了半天时间搭完,之后所有主站改动都先过一遍,再上真实设备。模拟从站不能覆盖真实设备的所有异常行为,但能把主站自身的问题挡掉大半,剩下的才是设备侧兼容问题。

6. 进阶技巧:双主站冗余切换与总召瘦身

6.1 双主站冗余:备用机别发激活,切换时先停再启

双主站冗余最常见的坑是两台主站同时和从站建立 104 连接,同时发 STARTDT,导致从站侧出现两个活动连接,链路状态互相干扰。我见过某项目就因为这,备机切换时从站一直拒绝业务帧,现场以为是设备故障。后来把备用机的行为改成“只建立 TCP 连接,不发 STARTDT,不参与业务”,切换时先由新主站发 STOPDT 停掉旧连接,再发 STARTDT 激活。这样从站每次只响应一个活动主站,序号也重新从 0 开始,不会出现旧主站序号残留。

切换时序号处理要特别小心。主备切换后,新主站的 send_seq 和 recv_seq 都从 0 开始,但如果从站没复位,它的发送序号可能还停在旧值,导致新主站收到第一帧就序号不匹配。因此新主站激活后,可以先发一个 TESTFR 探活,正常收到确认后再发总召,让从站按总召流程把数据重新上送一遍。

6.2 总召瘦身:按分组召唤替代全站总召

大厂站全站总召数据量动辄几千个测点,一轮总召经常要十几秒,这段时间主站还不能发其他 I 帧。合理的做法是改成分组召唤:按信息体地址段拆分,比如每 100 个地址一组,分开发 C_IC_NA,每组单独等激活终止。代码上只需要把总召唤 ASDU 的信息体地址从 0 改成起始地址,限定词保持不变,从站就会按指定地址段响应。这样哪怕某一组超时,也只影响那一段数据,不需要整站重新总召。

实现分组召唤时,建议在调度模块里维护一张“总召任务表”,记录每组的发送时间、是否收到激活终止、超时重试次数。我经历过一次全站总召 15 分钟才完成的事故,排查后就是单帧总召太大,从站上送窗口被占满。从那以后我每次做主站总召都强制走一遍分组召唤,先把地址段拆好,再启动总召流程。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询