news 2026/10/13 10:33:40

以太网103规约与上位机代码:帧结构、ASDU解析及TCP粘包实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
以太网103规约与上位机代码:帧结构、ASDU解析及TCP粘包实践

简介:一份面向电力自动化二次开发与变电站通信调试场景的南自以太网103规约资源,涵盖IEC 60870-5-103规约在以太网环境下的实现要点。压缩包共2个文件,内含规约说明文档(zip格式)与一份C++上位机源码文件,整体大小约788KB。源码部分涉及数据帧结构设计与构造、控制域和地址域的设置、透明传输机制下的数据编码/解码、发送序列号管理、错误检测与重传、TCP连接管理以及定时心跳报文等关键逻辑,可直接作为与南自设备联调时的参考实现。规约说明文档则对103规约的报文头、信息域、校验域以及请求/响应、确认/未确认等服务类型进行了梳理,适合在变电站自动化、电网监控、配电自动化、电量计量等项目中快速理解协议细节。目前已有1455人学习浏览,值得电力自动化行业的开发者和协议研究人员参考。

1. 以太网103规约:一份能直接落地的上位机代码,别拿它当教科书

做电力自动化通讯的工程师,早晚会遇到 IEC 60870-5-103 规约,也就是常说的 103 规约。这份规约在变电站、电厂测控装置和继保设备里大量存在,但问题是:网上能搜到的资料要么只讲串口 103,要么给了规约文本却没有能跑的代码。而手头这份「以太网103规约及上位机代码」解决的正是这个痛点——它把以太网帧格式的 103 规约、ASDU 报文解析、上位机通讯界面整合在了一个工程里,拿到手不是看文档,是能直接编译、连装置、抓报文、看遥测遥信的。这篇笔记就围绕这份资源展开:规约结构怎么拆、上位机代码怎么读、报文怎么验证、最大的坑在哪,以及一个我验证过多次的 txt 回放测试技巧。适合三类人:刚接手 103 项目的新人、要把串口 103 迁移到以太网的工程师、以及想抄一套稳定通讯框架的开发者。

2. 以太网 103 规约:为什么这套报文结构和串口版本不一样

2.1 帧格式:从 68 起始到 16 结束,链路层到底在传什么

103 规约的本质是「链路层 + 应用层」两层结构。以太网 103 和串口 103 最大的差异不在应用层,而在传输载体:以太网把 103 报文封装在 TCP 或 UDP 里,所以不需要串口那套握手流程,但帧格式本身仍然保留了完整链路层。

以太网 103 最常见的帧格式是:

68 启动符 (0x68) 长度 L (从控制域到校验和的总字节数) 控制域 C 地址域 A 链路用户数据 (ASDU) 校验和 CS 16 结束符

套到代码里,结构体定义长这样:

typedef struct _IEC103_FRAME { uint8_t start; // 固定 0x68 uint8_t length; // 控制域 + 地址域 + ASDU + 校验和的字节数 uint8_t ctrl; // 控制域:确认帧/请求帧/复位帧由 bit7~bit4 区分 uint8_t addr; // 装置地址,链路层寻址 uint8_t asdu[256]; // 应用服务数据单元 uint8_t cs; // 校验和:从 ctrl 到 asdu 末字节的累加和 uint8_t end; // 固定 0x16 } IEC103_FRAME;

逻辑说明:这个结构体直接对应报文的最小内存视图,解析时先校验 start、end、CS,再把 asdu 部分交给应用层处理。我一般会在接收缓冲区里做「滑动窗口找 68」的处理,因为以太网上 TCP 可能一次收到多个报文,也可能收到半包。

参数说明:length字段的值 = 1(ctrl)+ 1(addr)+ ASDU 长度 + 1(CS),所以最大长度受 ASDU 体积限制,通常 103 规约的 ASDU 不会超过 253 字节。校验和 CS 是从 ctrl 开始到 ASDU 最后一个字节的累加和,不包含 start、length、end。

2.2 ASDU 结构:类型标识、信息体地址、信息元素

如果说帧格式是信封,ASDU 就是信纸。103 规约的 ASDU 由类型标识、可变结构限定词、传送原因、公共地址、信息体地址和信息元素组成。以太网版本和串口版本在这部分完全一致,所以大部分串口 103 的解析经验可以直接平移过来。

典型的总召唤(类型标识 0x64)响应报文,ASDU 里嵌的是遥信变位或遥测数据。为方便代码解析,我通常把所有信息体统一抽象成「标记 + 数据」:

typedef struct _ASDU_INFO { uint8_t type_id; // 类型标识,如 0x64 总召唤、0x01 遥信、0x09 遥测 uint8_t vsq; // 可变结构限定词:bit7=1 连续寻址,低7位=信息体个数 uint8_t cause; // 传送原因:0x06 激活、0x07 激活确认、0x08 停止激活 uint8_t common_addr; // 公共地址,通常与链路层 addr 一致 uint16_t info_addr; // 信息体地址,连续寻址时为起始地址 uint8_t data[8]; // 信息元素,可能是单点遥信或归一化遥测值 } ASDU_INFO;

逻辑说明:vsq是最容易误判的字段,bit7 为 1 表示连续寻址,此时信息体地址只需给第一个,后续地址递增;bit7 为 0 表示不定长寻址,每个信息体都要带完整地址。很多人在代码里按「固定 2 字节信息体地址」处理,解析不定长寻址时直接错位。

参数说明:type_id 是解析分支的唯一依据。我在上位机里维护了一张「类型标识→处理函数」的映射表,新增类型不需要改主流程,注册一个回调即可。这个思路在做协议扩展时特别省事。

2.3 为什么不建议直接抄串口 103 代码

有个常见误操作:把串口 103 的解析代码直接套到以太网上,改个收发函数就上线。结果通常是——能连上、能收到数据,但总召唤超时、遥信刷新慢、报文偶尔报校验错。原因在于串口 103 靠 RTS/CTS 或延时控制发送节奏,而以太网 TcpClient 没有物理流控,底层还会把多个小报文合并成一个包。你按串口思维去「读一帧处理一帧」,遇到合并包就漏帧。

3. 上位机代码还原:这套工程里的通讯层、解析层、显示层各在做什么

3.1 通讯层:TcpClient 异步接收与断线重连

这套上位机代码的通讯层用的是异步 TcpClient,典型的代码骨架如下:

private void StartTcpClient(string ip, int port) { TcpClient client = new TcpClient(); client.BeginConnect(IPAddress.Parse(ip), port, new AsyncCallback(OnConnected), client); } private void OnConnected(IAsyncResult ar) { TcpClient client = (TcpClient)ar.AsyncResult; if (client.Connected) { NetworkStream stream = client.GetStream(); byte[] buffer = new byte[4096]; stream.BeginRead(buffer, 0, buffer.Length, new AsyncCallback(OnDataReceived), new object[] { stream, buffer, client }); } } private void OnDataReceived(IAsyncResult ar) { object[] objs = (object[])ar.AsyncState; NetworkStream stream = (NetworkStream)objs[0]; byte[] buffer = (byte[])objs[1]; TcpClient client = (TcpClient)objs[2]; int bytesRead = stream.EndRead(ar); if (bytesRead > 0) { BufferBlock<byte> block = new BufferBlock<byte>(); block.Post(buffer.Take(bytesRead).ToArray()); // 把字节流交给解析线程 } stream.BeginRead(buffer, 0, buffer.Length, new AsyncCallback(OnDataReceived), objs); }

逻辑说明:这里用BeginRead持续等待数据,收到数据后放进BufferBlock,由独立线程负责拼帧和解析。好处是 UI 线程不会被报文阻塞,即使装置以极高频率上送,界面依然流畅。断线重连我一般放在定时器里,每 3 秒检查一次client.Connected,不满足则重新连接。

参数说明:缓冲区 4096 字节对 103 报文足够,但如果用了以太网转串口网关,设备可能一次性下发几千字节,此时要动态扩容或分段处理。我在实际项目里遇到过收到 8KB 以上的情况,原因是网关把多帧打包了,解析层必须带缓冲队列。

3.2 解析层:帧提取与校验

帧提取的逻辑是我格外关注的——TCP 是流,不是帧。代码里通常会有这么一段:

private Queue<byte[]> ParseFrames(byte[] rawData, ref List<byte> cache) { Queue<byte[]> frames = new Queue<byte[]>(); cache.AddRange(rawData); while (cache.Count >= 2) { // 找帧起始符 int startIdx = cache.FindIndex(b => b == 0x68); if (startIdx < 0) { cache.Clear(); break; } if (startIdx + 1 < cache.Count) { int len = cache[startIdx + 1]; int totalLen = len + 2; // 0x68 + len + 数据 + 0x16 if (totalLen + startIdx > cache.Count) { // 帧未完整,缓存后退出 break; } if (cache[startIdx + totalLen - 1] == 0x16) { byte[] frame = cache.GetRange(startIdx, totalLen).ToArray(); frames.Enqueue(frame); cache.RemoveRange(0, startIdx + totalLen); } else { // 帧校验失败,跳过这个起始符 cache.RemoveRange(0, startIdx + 1); } } } return frames; }

逻辑说明:这段代码的核心是「先找帧头,再看长度字段,最后验帧尾」。如果 0x16 不在预期位置,说明起帧位置判断错误,就跳过当前起始符继续找。这样可以避免因为单个错帧导致整个缓冲数据不可用。注意cache是按引用传的,函数执行完后未处理完的残帧会留在缓存里等下一包。

参数说明:len是 1 字节,最大 255,所以单帧长度有上限。校验完 0x16 之后,还应该在解析层做一次 CS 累加和校验,两个校验都过了才把帧交给 ASDU 解析器。这套流程做完,基本不可能有错帧进入业务逻辑。

3.3 显示层:遥测刷新表与报文日志

上位机的显示层核心是一个DataGridView或 WPF 的DataGrid,绑定遥测点表。需要注意的坑是跨线程更新控件——异步收包线程里不能直接改 UI。这套代码用的做法是SynchronizationContext.Post,把刷新动作封送到 UI 线程,示例逻辑如下:

private void UpdateTelemetryValue(string pointName, float value) { if (InvokeRequired) { BeginInvoke(new Action(() => UpdateTelemetryValue(pointName, value))); return; } // 更新 DataGrid 对应行 }

逻辑说明:这段是 WinForms 的标准写屏操作。InvokeRequired判断当前线程是否与创建控件的线程一致,不一致就通过BeginInvoke切回 UI 线程。

这个上位机工程里值得借鉴的一个设计是把「原始报文 + 解析结果」同时写日志。原始报文用十六进制字符串写进 txt,解析结果用带时间戳的行写入同一文件,方便事后对着报文比对。

4. 把工程跑起来:编译环境、连接配置与报文观测步骤

4.1 代码结构速览与编译环境

解压「以太网103规约及上位机代码」后,常见目录会是这样:

目录内容说明
Protocol/帧解析、ASDU 解析、CRC 校验与界面无关,建议打底复用
Communication/TcpClient 封装、断线重连可独立测试
UI/遥测表、遥信表、报文日志WinForms 或 WPF 工程
Utils/日志、Hex 转换、线程调度通用工具

我拿到压缩包的第一件事不是打开工程文件,而是先看 Protocol 文件夹的代码——它是整个资源的核心价值所在。解析器的职责边界如果清晰,后续换 UI 框架、换语言都不用伤筋动骨。

编译环境以某公司工程常见配置为例:Visual Studio 2019 或更高版本,目标框架 .NET Framework 4.7.2 或 .NET 6。如果用 WPF 版本,注意在 csproj 里确认UseWPF标签。整个解决方案还原后点击生成,正常情况下不会有依赖缺失问题。

4.2 连不上装置时的排查路径

首次连接装置,最常见的表现是「上位机提示连接成功,但总召唤发出去没应答」。按以下顺序排查:

第一步,确认 TCP 端口。103 以太网规约默认端口大多数装置是 2404,有些厂商用 4001,需要在装置说明书或配置文件里确认,不能想当然。

第二步,抓包确认报文是否真的到了设备。用 Wireshark 过滤tcp.port == 2404,看三次握手是否完成,再看是否有数据包从本机发出。如果只有 SYN 没有 ACK,基本是 IP 或端口配置错误。

第三步,检查总召唤报文格式。用一个十六进制编辑器人为构造一帧总召唤报文发给装置,看是否有响应:

68 09 09 01 01 64 01 06 01 00 00 16

这段帧拆解:68起始,09长度,09控制域(请求/确认),01地址,64类型标识(总召唤),01VSQ,06传送原因(激活),01公共地址,00 00信息体地址,16结束符。

如果手工帧有响应而上位机帧没有,问题一定出在上位机组帧代码里,去检查长度字段和校验和计算。

4.3 用 txt 报文文件验证解析器

理论上你可以在没有物理装置的情况下,完全用模拟报文验证解析器的正确性。做法是把上面那类十六进制报文逐行存进 txt:

68 09 09 01 01 64 01 07 01 00 00 6D 16 68 11 11 01 01 09 01 07 01 01 00 00 01 09 00 00 01 00 2E 16

然后让上位机读取该文件模拟接收。很多解析器问题在真实装置上才暴露,但用 txt 模拟报文可以在开发早期就暴露大部分边界问题。我在第 6 章专门展开讲这套验证方法。

5. 避坑清单:以太网 103 规约最常见的五个坑

5.1 坑一:传送原因只认 0x06,忽略 0x07 和 0x14

现象:总召唤发出后,收到装置回帧但界面遥测无任何刷新。

原因:装置回了两帧——先回 0x07(激活确认)表示「收到总召唤命令」,随后上送数据时带 0x14(响应)或 0x0A(激活终止)。解析器只用 0x06 作为分支条件,导致响应数据帧全被丢弃。

解决:在 ASDU 解析里把0x06、0x07、0x14、0x0A都纳入有效分支,特别是 0x14 对应的数据体必须进遥测/遥信刷新逻辑。我一般动手写解析器第一件事就是列出所有可能出现的传送原因,逐个确认处理方式。

5.2 坑二:信息体地址连续寻址时地址递增方向搞反

现象:遥信点表 1-64 全部错位,第一个点显示正常,后面越偏越远。

原因:103 规约信息体地址是低位在前(小端序),且连续寻址时地址递增。若代码按大端序读 2 字节地址,再把后续信息体地址做递减,数据自然错位。

解决:统一用小端序读取地址。可以打印第一个信息体地址的实际值和规约文档做比对,确认地址偏移方向。遇到过最隐蔽的情况是型号 A 递增、型号 B 递减,这类问题只能通过「装置说明书 + 实际抓包」双重确认。

5.3 坑三:TCP 粘包导致帧尾 0x16 找不到

现象:报文日志里出现大量无头无尾的乱码行,但每隔一段时间又恢复。

原因:装置连续上送多帧,TCP 栈把多个 103 帧合并成一个 TCP 分段。解析器如果「读一次解析一次」,第二帧的 0x68 就会成为第一帧的尾部数据,帧解析直接错乱。

解决:必须引入缓存队列,按第 3 章的ParseFrames逻辑处理。核心原则是「每次收到网络数据只往缓存里追加,帧提取只从缓存头部开始」。

5.4 坑四:控制域 C 的确认/请求机制被遗漏

现象:总召唤可以收到遥测,但装置发来的「召唤命令」上位机不响应。

原因:103 规约里装置可以主动上送变化数据,也可能在特定情况下请求主站下发参数。主站侧的控制域C的 bit0~bit1 表达确认需求,若收到请求帧但没有回确认帧,装置侧会按超时重发,导致网络中出现大量重复报文。

解决:解析控制域时先把确认需求和请求/响应分类,对带确认需求的帧走独立 ACK 分支。这个坑在只读装置数据的场景下容易忽略,但影响通讯效率,且会让报错统计很难看。

5.5 坑五:不同厂商对公共地址的用法不一致

现象:A 厂的装置用公共地址0x01,B 厂用0x00或0xFF,用同一套上位机连 B 厂时解析出的公共地址和链路地址对不上。

原因:103 规约文本没有强制规定公共地址与链路地址必须一致。有些厂商把公共地址固定成 1,有些按屏号分配,有些直接置 0。

解决:把公共地址做成配置项,不写死在代码里。上位机侧提供一个「装置地址 + 公共地址」的映射表,换站点只需改配置文件。

6. 收尾技巧:用 txt 回放报文做上位机回归测试的完整流程

我习惯在交付前做一轮「txt 回放测试」来验证整个链路——Modbus、104、103 都适用,但 103 因帧格式复杂、变位逻辑多,收益最大。原理很简单:把真实装置上的抓包结果保存成 txt,每行一帧十六进制报文,然后写一个播放器模拟 TCP 服务端,按设定节奏把报文逐帧发给上位机。

播放器代码骨架如下:

import socket import time def load_frames(file_path): frames = [] with open(file_path, 'r') as f: for line in f: line = line.strip() if not line: continue hex_bytes = bytes.fromhex(line) frames.append(hex_bytes) return frames def replay(frames, host, port): srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind((host, port)) srv.listen(1) print('等待上位机连接...') conn, addr = srv.accept() print(f'上位机已接入: {addr[0]}:{addr[1]}') for i, frame in enumerate(frames): conn.send(frame) print(f'[{i+1}] 已发送 {len(frame)} 字节') time.sleep(0.1) conn.close() srv.close() if __name__ == '__main__': frames = load_frames('replay_frames.txt') replay(frames, '127.0.0.1', 2404)

逻辑说明:load_frames把 txt 里每行十六进制字符串转成字节对象,replay启动 TCP 服务端等待上位机连接,连上后逐帧发送。为了模拟现场的粘包情况,我通常会在发送间隔上加随机抖动,甚至把 2-3 帧拼一次发送。这样不仅能验证解析器,连 UI 的刷新流畅度都一块测了。

参数说明:端口2404是上位机里配置的端口,模拟端要一致。ip 用127.0.0.1是纯本机回环,如果上位机跑在别的机器上,需要改成服务端实际地址。

有了这套回放机制,我每次改完上位机代码都会做一遍流程:抓包存 txt → 回放 → 对照报文日志确认每条遥测、遥信都更新到位 → 截图留底。从那以后我每次写规约解析代码都会强制走一遍这个闭环,不带装置也能把解析和显示逻辑调到八九成稳。文中最实用的地方就是把原始报文留了 txt 存档,配合回放器和报文日志,排障效率不是高点,是高很多。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 10:32:58

LSDK-WLAN-9.2.0.31_b驱动编译与AP模式实战指南

简介&#xff1a;LSDK-WLAN-9.2.0.31_b.gz 是面向Linux嵌入式与无线网络开发者的专用软件开发套件&#xff0c;聚焦WLAN驱动适配、协议栈调试及无线应用开发&#xff0c;适用于Wi-Fi模块厂商、驱动工程师及Linux内核开发者解决802.11a/b/g/n/ac等无线标准的移植、性能调优与故障…

作者头像 李华
网站建设 2026/10/13 10:32:57

FileZilla 3.5.3:老旧系统FTP兼容性终极解决方案

简介&#xff1a;本资源为开源跨平台FTP客户端FileZilla 3.5.3的完整安装包&#xff0c;面向Web开发、运维工程师及高校计算机专业学生&#xff0c;解决日常远程服务器文件上传下载、多环境部署与安全传输等核心需求。压缩包为ZIP格式&#xff0c;共459个文件&#xff0c;含385…

作者头像 李华
网站建设 2026/10/13 10:32:12

STM32 JPEG解码源程序:从原理移植到内存优化与踩坑实战

简介&#xff1a;在嵌入式视觉应用开发中&#xff0c;STM32等微控制器因内存与算力有限&#xff0c;直接解码JPEG图片往往面临性能瓶颈。这份JPEG图片STM32解码源程序&#xff0c;面向需要在裸机或Keil MDK环境中实现图片显示的软硬件工程师&#xff0c;提供了一套可移植、可裁…

作者头像 李华
网站建设 2026/10/13 10:32:01

用C语言实现PNG/JPEG转BMP:从解码原理到工程实践

简介&#xff1a;这是一份面向C语言学习者和图像处理开发者的图片格式转换工程&#xff0c;围绕PNG与JPEG转BMP的核心任务&#xff0c;同时涵盖图片缩放、旋转等常见操作。工程基于VC6.0开发&#xff0c;包含完整C语言源码、libpng与jpeglib库文件、头文件及可执行程序&#xf…

作者头像 李华
网站建设 2026/10/13 10:31:30

Oracle通过DG4ODBC与dblink访问SQL Server的完整实践指南

1. 一个典型的需求场景&#xff1a;Oracle里的数据&#xff0c;有一半还得去SQL Server取做数据集成的人&#xff0c;十有八九都遇到过这种局面&#xff1a;核心业务库在Oracle上&#xff0c;但某个历史系统、报表系统或者第三方系统跑在SQL Server上。两边数据要打通&#xff…

作者头像 李华
网站建设 2026/10/13 10:29:18

905nm激光测距模块串口协议解析与 Arduino / 树莓派 / ROS2 接入实战

披露&#xff1a;我是这类模块的厂商&#xff08;ERDILRF&#xff0c;成都&#xff09;一方的工程人员。文中的帧格式、波特率、单位都转录自厂商手册&#xff0c;驱动是 MIT 协议开源的。我没有在这里贴任何实测精度或性能数据&#xff0c;文末会说清楚哪些没验证过。第一次接…

作者头像 李华