简介资源为南瑞继保串口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(fchecksum mismatch: calc0x{calc_cs(body):02X}, recv0x{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:3length]切片取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置位、功能码0FCB和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(levellogging.INFO, format%(asctime)s %(message)s) SER_PORT COM3 SER_BAUD 9600 DEV_ADDR 1 ser serial.Serial( portSER_PORT, baudrateSER_BAUD, bytesizeserial.EIGHTBITS, parityserial.PARITY_EVEN, stopbitsserial.STOPBITS_ONE, timeout0.3, write_timeout0.3, )timeout0.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 Nonetransact_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(fASDU type0x{type_id:02X} vsq0x{vsq:02X} cot0x{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(freset 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(ftotal 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_dataFalse): 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(ftimeout 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模拟器连COM4Linux下用socat创建pty对。模拟器收到复位链路就回确认帧收到总召唤就回可变帧数据收到召唤1级数据就回一条造好的变位事件。import serial slaver serial.Serial(COM4, 9600, timeout0.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]))模拟器的作用是把主站脚本的帧封装、校验和、超时重发状态机先跑通真机接线前先排除掉自己代码里的低级错误。我现在的习惯是任何主站脚本改动先对模拟器做一次回归确认帧格式没坏再上真机。这个习惯是从一次翻车里得来的——某次跳过模拟器直接接装置结果发现校验范围定义错了来来回回浪费了整个下午。技术上多准备一步现场就能少踩一个坑。接真机前我还会做三件事备份装置原有配置、用厂家工具抓一条原始报文留底、把通信参数菜单拍张照片。这三件事看着啰嗦但遇到链路问题时都是救命的东西。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?