ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

南瑞继保串口103程序实战:IEC 60870-5-103规约与Python主站实现

南瑞继保串口103程序实战:IEC 60870-5-103规约与Python主站实现

简介:资源为南瑞继保串口103通信程序源码包,面向电力自动化领域的嵌入式或上位机开发者,解决基于IEC 60870-5-103规约的串口数据收发与监控问题。包内共4个文件,含2个C源文件、1个头文件和1个文本说明,整体仅9KB,结构精简,适合快速阅读与移植;C/H文件覆盖103规约报文组帧、解析及串口收发核心逻辑,文本文件可帮助了解工程配置或使用要点。由于103规约涉及控制域、地址域、信息域及校验等细节,这份实现能辅助初学者对照报文结构理解协议流程,也给有经验的开发者提供可复用的编码框架。已有630人学习过该资源,在变电站自动化、配网自动化或SCADA系统调试中,可作为串口通信模块的参考样例,用于排查通信不稳定、数据丢帧或同步异常等问题。

1. 南瑞继保串口103程序在解决什么问题

很多人第一次听到「南瑞继保串口103程序」,都是站在保护测控装置面前拿着串口线发呆的场面:监控后台不通,厂家调试软件没装或没带,唯一能和装置对话的路径就是前面板调试口或者背板串口。这时候一个可靠的串口103主站程序,就是那个能把装置从黑匣子变成白盒子的工具。它的本质是运行在PC侧的IEC 60870-5-103规约主站,通过RS232/RS485串口与保护装置内置的103从站通信,完成总召唤、遥信遥测读取、事件录波获取、定值读写和链路诊断。适合三类人:做二次调试的工程师、搞检修消缺的运维人员、需要把装置数据取出来做专项分析的系统集成商。

这篇文章按我实际调试的路径来写:先讲103规约的帧格式和校验和,再给一个能跑起来的Python最小主站,接着列出串口103程序现场最常见的五个翻车点,最后给出从调试工具升级成巡检探针的改造思路和本地回归验证方法。

2. 串口103程序的底层协议:FT1.2帧结构、校验和与链路问答

2.1 三种帧格式与可变帧长帧的字节布局

IEC 60870-5-103的物理层和链路层沿用了101规约的FT1.2帧格式,一共三种帧:单字符帧、固定帧长帧、可变帧长帧。串口上跑的大部分关键数据都在可变帧长帧里,固定帧长帧负责链路命令和确认,单字符帧用得最少。

帧类型启动符字节结构典型用途
单字符帧0xE5E5简单确认
固定帧长帧0x1010 控制域 地址 校验和 16复位链路、召唤数据、确认
可变帧长帧0x6868 长度 68 控制域 地址 数据 校验和 16总召唤、遥信遥测、事件上报

可变帧长帧是排错的重点。我一般会对着报文手算一遍校验和,确认自己理解对了再写解析代码。下面是一条总召唤命令的示例帧,注意这是示例,不同装置固件可能有微小差异:

68 08 68 A3 01 65 01 06 00 00 00 10 16

逐字节拆开看:第一个68是启动符;08是长度,表示后面从第二个68开始到校验和之前一共8个字节;第二个68是重复启动符;A3是控制域;01是装置地址;65 01 06 00 00 00是应用层数据;10是校验和;16是结束符。算一下校验和:A3 + 01 + 65 + 01 + 06 + 00 + 00 + 00 = 0x110,取低字节0x10,正好对上。这条帧能对上,说明你理解的长度范围和校验范围是正确的。

2.2 校验和算法与Python帧封装/解析

校验和的计算范围是关键:从控制域开始,到应用层最后一个字节结束,所有字节累加后取低8位。起始符68、长度字节、结束符16都不参与累加。长度字段的值等于「控制域 + 地址 + 应用层数据」的总字节数,我在调试中见过有些实现把长度定义成只含应用层数据,这种差别会在解析时直接导致错位,后面避坑章再展开。

def calc_cs(body: bytes) -> int: return sum(body) & 0xFF def build_var_frame(ctrl: int, addr: int, asdu: bytes) -> bytes: body = bytes([ctrl, addr]) + asdu length = len(body) frame = bytes([0x68, length, 0x68]) + body + bytes([calc_cs(body), 0x16]) return frame def parse_var_frame(frame: bytes): if frame[0] != 0x68 or frame[2] != 0x68 or frame[-1] != 0x16: raise ValueError("frame header or trailer error") length = frame[1] body = frame[3:3 + length] recv_cs = frame[3 + length] if calc_cs(body) != recv_cs: raise ValueError(f"checksum mismatch: calc=0x{calc_cs(body):02X}, recv=0x{recv_cs:02X}") ctrl, addr, asdu = body[0], body[1], body[2:] return ctrl, addr, asdu

这段代码里,body是参与校验的字节序列,length等于len(body),发送和解析用的是同一套长度定义。parse_var_frame里通过frame[3:3+length]切片取body,位置计算依赖长度字段,如果从站固件对长度字段定义不同,这里会立刻暴露。收到帧后先校验再取数,校验不过直接抛异常,不要带着错误数据往下走。实际调试中我会把校验失败帧和原始hex一起打出来,对排错非常有用。

2.3 控制域、FCB翻转与主从问答时序

控制域是整个链路问答的指挥棒。主站发出的帧控制域bit7为1,从站发出的控制域bit7为0。主站侧bit5是FCB帧计数位,bit4是FCV帧计数有效位,低4位是功能码。从站侧bit6是ACD要求访问位,bit5是DFC数据流控制位。这些位在串口调试时经常被忽略,但链路卡死、变位丢失往往就出在这几个位上。

主站常用功能码就三个:0复位远方链路、3发送/确认、7召唤1级数据。召唤1级数据时必须置FCV为1,而且每一帧都要翻转FCB,目的是让从站能识别出重复帧和丢帧。从站回应时功能码8表示带数据响应,功能码9表示无所请求的数据,功能码0表示确认。我把主站控制域封装成函数,FCB翻转让调用方控制,方便测试:

def build_fixed_frame(ctrl: int, addr: int) -> bytes: body = bytes([ctrl, addr]) return bytes([0x10]) + body + bytes([calc_cs(body), 0x16]) def ctrl_reset_link() -> int: return 0x80 def ctrl_request_class1(fcb: int) -> int: return 0x80 | (0x20 if fcb else 0x00) | 0x10 | 0x07 def ctrl_total_call(fcb: int) -> int: return 0x80 | (0x20 if fcb else 0x00) | 0x03

固定帧长帧结构是10、控制域、地址、校验和、16,一共5个字节,校验和只算控制域加地址。ctrl_reset_link返回0x80,对应PRM置位、功能码0,FCB和FCV都不带。ctrl_request_class1里0x20是FCB位,0x10是FCV位,0x07是召唤1级数据功能码。ctrl_total_call用功能码3发送确认方式发起总召唤。每次发送后调用方需要把FCB取反,第一次置1,之后依次翻转。

正常的问答时序是这样:先发复位远方链路,从站回一个固定帧确认;然后发总召唤,从站回固定帧确认;接着主站循环发召唤1级数据,从站回带应用数据的可变帧或无所请求的确认帧。如果主站一直只发总召唤而不轮询1级数据,变位遥信和事件永远上不来。

3. 用Python从零写一个能跑的串口103主站

3.1 串口参数和装置地址:先让物理链路通起来

写应用层之前,先把串口参数对上。保护装置的103从站口常见参数是9600波特率、8数据位、1停止位、偶校验,也有装置默认用无校验或19200。最典型的坑是装置有两个串口:前面板调试口和背板通讯口,两个口的规约和参数可能完全不同。

参数常见取值说明
波特率9600 / 19200看装置通信参数菜单
数据位8103规约固定8位
停止位1个别老装置用2
校验位偶校验 / 无校验以装置实际设置为准
装置地址1装置地址或拨码设置

用pyserial打开串口,参数集中放在脚本头部,方便现场改:

import serial, time, logging logging.basicConfig(level=logging.INFO, format='%(asctime)s %(message)s') SER_PORT = 'COM3' SER_BAUD = 9600 DEV_ADDR = 1 ser = serial.Serial( port=SER_PORT, baudrate=SER_BAUD, bytesize=serial.EIGHTBITS, parity=serial.PARITY_EVEN, stopbits=serial.STOPBITS_ONE, timeout=0.3, write_timeout=0.3, )

timeout=0.3表示读串口时最多等300ms没有数据就返回,这个值直接影响轮询节奏。写超时也设成0.3s,防止程序卡在写操作上。parity用serial.PARITY_EVEN对应偶校验,如果装置设无校验就改成serial.PARITY_NONE。USB转串口线建议插在电脑后置USB口,线材和芯片供电不足时会出现字节丢失,具体现象在避坑章说。

3.2 最小主站脚本:复位链路、总召唤、轮询1级数据

最小主站由三个动作组成:复位远方链路、发总召唤、循环召唤1级数据。我用两个收发函数分别处理固定帧响应和可变帧响应,这样主流程干净,出错时也容易定位是链路层还是应用层问题。

def transact_fixed(frame: bytes, retries: int = 3): for _ in range(retries): ser.reset_input_buffer() ser.write(frame) hdr = ser.read(1) if not hdr: continue if hdr == b'\x10': rest = ser.read(4) body = bytes([hdr[0]]) + rest if len(rest) < 4 or rest[-1] != 0x16: logging.warning("fixed frame incomplete") continue return hdr + rest return None def transact_variable(frame: bytes, retries: int = 4): for _ in range(retries): ser.reset_input_buffer() ser.write(frame) hdr = ser.read(1) if not hdr: continue if hdr == b'\x68': length = ser.read(1)[0] rest = ser.read(2 + length + 1) full = hdr + bytes([length]) + rest if len(full) < 3 + length + 1: continue if full[-1] != 0x16: continue return full return None

transact_fixed负责发固定帧命令并等固定帧确认,收到10开头后继续读4个字节凑齐5字节完整帧。reset_input_buffer在发送前清空串口缓冲,避免上次残留数据干扰判断。transact_variable处理68开头的可变帧,先读长度字节,再读2 + length + 1个字节覆盖第二个68、body、校验和、结束符。两个函数的重试次数都做成参数,现场可以根据链路质量调。

主流程脚本:

TOTAL_CALL_ASDU = bytes([0x65, 0x01, 0x06, 0x00, 0x00, 0x00]) def parse_asdu(asdu: bytes): type_id, vsq, cot = asdu[0], asdu[1], asdu[2] info = asdu[3:] logging.info(f"ASDU type=0x{type_id:02X} vsq=0x{vsq:02X} cot=0x{cot:02X} info_len={len(info)}") if type_id == 0x03: if len(info) >= 3: ioa = int.from_bytes(info[0:2], 'big') value = info[2] & 0x03 logging.info(f" double-point IOA={ioa} value={value}") elif type_id in (0x09, 0x0A, 0x0B): logging.info(f" measurement info_len={len(info)}") fcb = 1 ack = transact_fixed(build_fixed_frame(ctrl_reset_link(), DEV_ADDR)) logging.info(f"reset link ack={'OK' if ack else 'TIMEOUT'}") send_frame = build_var_frame(ctrl_total_call(fcb), DEV_ADDR, TOTAL_CALL_ASDU) fcb ^= 1 ack = transact_fixed(send_frame) logging.info(f"total call ack={'OK' if ack else 'TIMEOUT'}") for _ in range(200): req = build_fixed_frame(ctrl_request_class1(fcb), DEV_ADDR) fcb ^= 1 resp = transact_variable(req) if resp: ctrl, addr, asdu = parse_var_frame(resp) if asdu: parse_asdu(asdu) time.sleep(0.3)

总召唤ASDU我按103规约应用层常见形式拼了6个字节:65是类型标识总召唤,01是单信息对象,06是激活传送原因,后3个字节是信息对象地址,总召唤地址通常为0。这里特别说明一下:103规约在应用层省去了101规约里的公共地址字段,所以ASDU从类型标识直接开始。部分装置固件对总召唤命令体要求略有差异,第一次接真机前尽量用厂家工具抓一条真实的总召唤帧核对,方法在下一节。

parse_asdu里只解析了双点遥信和测量值的信息长度,没有做完整的字节级位解析,因为信息对象的IOA宽度和品质描述字节在不同装置的点表里不完全一致。工程上我会先把原始ASDU十六进制打到日志里,对照装置点表确认后再补解析逻辑,不要凭标准全集硬解析,那样反而容易被长帧吓住。

3.3 用厂家工具对标:抓一帧真实报文校准

别急着把自写脚本直接接到真机上。我每次接新站点,都会先做一次对照:用厂家调试软件连接装置,发起一次总召唤和一次遥信变位,同时用串口监听把原始字节完整录下来。没有硬件分线器时,可以用带串口日志功能的调试软件或虚拟串口转发工具,总能把厂家发出的帧内容抓出来。

抓到后做的事很简单:把厂家发出的总召唤ASDU原样替换到我脚本里的TOTAL_CALL_ASDU,把召唤1级数据的控制域字节和我代码生成的ctrl_request_class1结果对比一遍。如果厂家帧里FCB初始值和翻转顺序跟我的习惯不一致,以厂家的为准。这一步看起来笨,但能过滤掉规约实现里各种固件差异,比翻手册有效率得多。说到底,103规约标准给的是底线,装置固件才是现场的实际标准。

4. 串口103程序现场避坑指南:5个高频翻车点

4.1 总召唤发出去没反应,先查站地址和链路复位

现象:脚本发出总召唤后,串口完全静默,重试多次也没有任何响应。原因有几种:装置地址和脚本里DEV_ADDR不一致,或者从站链路处于忙状态,没有先复位就直接总召唤会被忽略。解决:第一步先用串口助手手动发一帧复位远方链路,观察装置是否回确认帧;第二步确认装置通信菜单里的从站地址和拨码设置;第三步把总召唤改成发送/确认方式并等待固定帧确认。我遇到过地址漏改的情况,厂家默认地址是1,装置实际拨到了3,所有报文全部石沉大海,改地址后立刻通了。

4.2 校验和永远不对:长度定义和校验范围要对齐

现象:能收到从站帧,但程序一直报checksum mismatch。原因:从站固件对长度字段的定义可能是"只含ASDU长度",而我的解析器按"控制域+地址+ASDU"来计算,两者差2个字节,导致body切片位置整体错位,校验和自然对不上。解决:抓一条已知正确的帧,按两种长度定义分别手算校验和,算出正确的那套就是该装置固件的实际定义;然后把parse_var_frame里的长度计算同步改成装置的定义。这个坑在混用不同厂家装置时会反复出现,写成配置项而不是写死在代码里是最省事的做法。

4.3 遥信变位上不来:不能只发总召唤,要持续轮询1级数据

现象:总召唤之后能收到遥信稳态值,但现场做分合闸实验时变位事件收不到。原因:变位和事件报文在从站的1级数据缓冲区里,不会随总召唤主动上送,必须由主站持续用召唤1级数据功能码去取。解决:确认主循环里一直在发ctrl_request_class1,并且每一帧FCB都做了翻转。还有个容易忽略的点:从站如果置了ACD位,说明缓冲区里还有1级数据等着被取,这时候要连续召唤直到ACD消失。

4.4 电脑和装置参数都一样,收到的还是乱码

现象:波特率、校验位、装置地址全部核对过,日志里打印的hex还是错乱的,偶尔能解出半条帧。原因:大概率是物理层问题,常见的有USB转串口线芯片兼容性差、RS232电平异常、TX和RX接反。解决:先用串口助手自发自收确认USB转串口线本身正常;再把装置侧的接收端和发送端对调试试;有条件用万用表量一下串口线在空闲状态的电平,RS232应为负电压,TTL应为高电平。现场翻车最多的是号称工业级的USB转串口线在变电站强电磁环境下丢字节,换一根带屏蔽的线往往立刻好转。

4.5 链路跑几分钟就卡死:DFC要处理

现象:程序刚启动时一切正常,轮询几分钟后从站不再响应,必须重启脚本才能恢复。原因:主站召唤太频繁,从站缓冲区满后置DFC位,请求主站暂停发送,主站无视DFC继续猛发,从站只能不再应答。解决:解析响应帧控制域时检查bit5 DFC,置位后暂停1到2秒再继续召唤;同时把轮询间隔控制在300ms以上。我一般会顺手把暂停逻辑打印出来,方便判断是不是现场数据量太大导致的频繁DFC。

5. 从调试工具到巡检探针:串口103程序的进阶改造

5.1 加上超时重发和链路状态机

最小脚本能跑通后,下一步是加状态机。链路状态分为空闲、等待固定帧确认、等待数据响应三种,超时和异常复位都走统一处理。核心逻辑是:发送命令后进入等待状态,超时则重发,连续重发失败达到阈值就发复位远方链路重新建立链路。

class LinkState: IDLE = 0 WAIT_FIXED_ACK = 1 WAIT_DATA = 2 link_state = LinkState.IDLE retry_count = 0 MAX_RETRY = 3 TIMEOUT = 0.5 def send_and_wait(frame, state, expect_data=False): global link_state, retry_count link_state = state while retry_count < MAX_RETRY: ser.reset_input_buffer() ser.write(frame) deadline = time.time() + TIMEOUT got = None while time.time() < deadline: hdr = ser.read(1) if not hdr: continue got = transact_rest_by_hdr(hdr) break if got is not None: retry_count = 0 return got retry_count += 1 logging.warning(f"timeout retry={retry_count}") logging.error("link lost, reset link") send_fixed(ctrl_reset_link()) retry_count = 0 return None

状态机把超时重发和链路复位统一收口,重发间隔等于TIMEOUT,连续重发3次失败就主动复位链路。这个逻辑在装置短暂重启或串口被干扰时很好用,链路能自愈。注意MAX_RETRY不要设太大,现场链路故障时快速失败比反复磨更有意义,故障信息要打日志而不是悄悄恢复。

5.2 把遥信变位与测量值落地SQLite并出日报

串口103程序跑起来后是个天然的数据源。我会把从站上送的变位事件和测量值全部落到SQLite,一张表存事件,一张表存测量值,时间戳用从站时标和本地接收时间双写,方便追溯。

import sqlite3 conn = sqlite3.connect('relay103.db') conn.execute(''' CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY AUTOINCREMENT, recv_ts TEXT, dev_addr INTEGER, ioa INTEGER, value INTEGER, raw_hex TEXT )''') conn.execute(''' CREATE TABLE IF NOT EXISTS measurements ( id INTEGER PRIMARY KEY AUTOINCREMENT, recv_ts TEXT, dev_addr INTEGER, ioa INTEGER, value REAL, raw_hex TEXT )''') conn.commit() def insert_event(recv_ts, dev_addr, ioa, value, raw_hex): conn.execute( 'INSERT INTO events(recv_ts,dev_addr,ioa,value,raw_hex) VALUES(?,?,?,?,?)', (recv_ts, dev_addr, ioa, value, raw_hex)) conn.commit()

事件表里raw_hex保存原始ASDU的十六进制文本,出问题时能反查。查询当日变位直接按recv_ts过滤,生成给运行人员的报表就是一条SQL的事。这个方案的价值在于:数据不经过监控后台,直接从装置链路层落地,定位通信链路问题时要原始数据比要后台报表可靠得多。

5.3 定值读写要过通用分类服务,别自己拼报文

读取和修改保护定值,正确路径是103规约的通用分类服务,不是直接拼遥测ASDU。通用分类服务把定值按分类组组织,主站先发送通用分类读数据请求,从站按条目响应。定值写操作必须走写确认流程,写完要回读对比。

def build_generic_read(fun: int, inf: int) -> bytes: # 通用分类读数据的ASDU结构以装置手册为准 # fun为功能类型,inf为信息序号 asdu = bytes([0x??, 0x01, 0x06, fun, inf, 0x00]) return build_var_frame(ctrl_total_call(0), DEV_ADDR, asdu)

这一段我刻意没给出完整字节,因为通用分类服务的类型标识和条目编码在不同装置固件间差异很大,照抄别人的报文容易把定值写错。现场做法是:先翻装置手册的通用分类服务章节,找到读数据和写数据的类型标识;用读命令把整个分类组遍历一遍,记录每一条定值的功能类型、信息序号和当前值;写之前拍下原始参数照片,写完立即读回对比,确认无误再执行下一条。

这里有个血泪教训:定值写操作必须带确认和回读,不要在一个循环里批量写。我见过写脚本把一组定值连续下发的,第二条写失败后整组定值处于混合状态,处理起来非常被动。定值操作慢就是快,一条条来。

6. 一个验证技巧:用Python从站模拟器做回归测试

在没有真机的时候验证主站程序,我习惯用一个Python写的从站模拟器,通过虚拟串口和主站对接。Windows下用虚拟串口工具创建COM3和COM4这对互联串口,主站连COM3,模拟器连COM4;Linux下用socat创建pty对。模拟器收到复位链路就回确认帧,收到总召唤就回可变帧数据,收到召唤1级数据就回一条造好的变位事件。

import serial slaver = serial.Serial('COM4', 9600, timeout=0.3) TOTAL_CALL_ASDU = bytes([0x65, 0x01, 0x06, 0x00, 0x00, 0x00]) while True: hdr = slaver.read(1) if not hdr: continue if hdr == b'\x10': body = slaver.read(4) # 固定帧请求:回一个固定帧确认 ack_body = bytes([0x00, 0x01]) slaver.write(bytes([0x10]) + ack_body + bytes([sum(ack_body) & 0xFF, 0x16])) elif hdr == b'\x68': length = slaver.read(1)[0] slaver.read(2 + length + 1) # 总召唤:回一组带时标双点遥信 resp_body = bytes([0xA3, 0x01]) + bytes([0x04, 0x01, 0x03, 0x01, 0x00, 0x01]) cs = sum(resp_body) & 0xFF slaver.write(bytes([0x68, len(resp_body), 0x68]) + resp_body + bytes([cs, 0x16]))

模拟器的作用是把主站脚本的帧封装、校验和、超时重发状态机先跑通,真机接线前先排除掉自己代码里的低级错误。我现在的习惯是:任何主站脚本改动,先对模拟器做一次回归,确认帧格式没坏再上真机。这个习惯是从一次翻车里得来的——某次跳过模拟器直接接装置,结果发现校验范围定义错了,来来回回浪费了整个下午。技术上多准备一步,现场就能少踩一个坑。

接真机前我还会做三件事:备份装置原有配置、用厂家工具抓一条原始报文留底、把通信参数菜单拍张照片。这三件事看着啰嗦,但遇到链路问题时都是救命的东西。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进