☰
C# Modbus TCP上位机源码实战:从报文解析到PLC数据采集
2026/10/7 16:21:03 网站建设 项目流程

简介:这是一套面向工业自动化工程师、物联网开发者及高校学生的PLC上位机程序源码,基于工业标准Modbus TCP协议,采用C#实现,可解决上位机与多种品牌PLC之间的数据读写、设备监控与异常报警等通信需求,适合作为协议学习与二次开发的实践参考。资源包共56个文件,约641KB,以cs源码文件为核心,配合json、xml、config等配置与数据文件,以及dll依赖库、resx资源文件和sln、csproj工程文件,整体结构清晰,便于在Visual Studio中直接打开运行。源码中已包含NModbus等通信库,并附带详细注释与使用文档,读者可据此理解Modbus TCP报文交互流程、掌握上位机与汇川等PLC的联调方法,并在此基础上扩展报警、监控等功能模块。目前已有100人学习下载,适合希望快速搭建PLC通信原型或深入理解Modbus协议实现细节的开发者参考。

1. 从一根网线到产线数据:这套 C# 源码到底能帮你省掉多少试错

车间里一台汇川 AM763 或者西门子 S7-1200 跑着产线逻辑,你想在办公室的电脑上把它的寄存器、线圈、扭矩值实时读出来,第一反应往往是找现成的组态软件。但组态软件按点位收费、界面改不动、想接个自定义报警逻辑还得买授权。这时候一套能直接编译、能改、注释齐全的 C# Modbus TCP 上位机源码,价值就出来了。它解决的不是“能不能连”的问题,而是“连上之后数据怎么按你的业务逻辑走”的问题。这份源码基于 Modbus TCP 协议,用 C# 实现,带详细注释,适合两类人:一是刚入行做上位机开发、想搞懂 Modbus 报文到底长什么样的新手;二是手头有 PLC 但被商业软件卡住、想自己写采集程序的熟手。它不依赖任何第三方商业库,核心就是 Socket 加协议封装,拿到手改改 IP 和寄存器地址就能跑。

2. Modbus TCP 报文拆解:为什么你的上位机读不到 FX5U 的数据

2.1 先搞清楚 Modbus TCP 和 Modbus RTU 的本质区别

很多人第一次做 PLC 通讯,脑子里装的是串口那套 RTU 帧格式,结果用 TCP 发出去死活没响应。Modbus TCP 在 RTU 的基础上砍掉了 CRC 校验,换上了 7 字节的 MBAP 报文头。这 7 个字节是:事务标识符 2 字节、协议标识符 2 字节、长度 2 字节、单元标识符 1 字节。事务标识符用来匹配请求和响应,协议标识符固定为 0,长度表示后面还有多少字节,单元标识符在 TCP 场景下通常填 1 或者 0xFF,具体看 PLC 型号。

我见过有人用三菱 FX5U 做 Modbus TCP 主站功能测试,上位机发过去的功能码 03 读保持寄存器,结果返回异常码 0x01。查了半天发现是单元标识符填了 0,而 FX5U 那边要求填 1。这种坑在源码里如果注释写清楚了,能省掉一整个下午的排查时间。这份 C# 源码把 MBAP 头的构造单独封装成了一个方法,每个字段都有注释说明取值范围,你对着 PLC 手册改就行。

2.2 功能码 03 和 06 的请求响应结构

读保持寄存器用功能码 03,请求帧里跟的是起始地址 2 字节和寄存器数量 2 字节。响应帧里第一个字节是字节数,后面才是寄存器值,每个寄存器占 2 字节,高字节在前。写单个寄存器用功能码 06,请求和响应格式一样,都是地址加值。

下面这段代码是源码里构造读请求的核心逻辑,我加了注释方便你对照报文看:

// 构造 Modbus TCP 读保持寄存器请求帧 // transactionId: 事务标识,每次请求递增,用于匹配响应 // unitId: 单元标识,FX5U 填 1,西门子通常填 1 或 255 // startAddress: 起始寄存器地址,注意 PLC 手册里可能是 40001 这种 1-based 地址 // registerCount: 要读的寄存器数量,一次别超过 125 个 public byte[] BuildReadHoldingRegisters(ushort transactionId, byte unitId, ushort startAddress, ushort registerCount) { var frame = new byte[12]; // MBAP 7 字节 + PDU 5 字节 // MBAP 头 frame[0] = (byte)(transactionId >> 8); // 事务标识高字节 frame[1] = (byte)(transactionId & 0xFF); // 事务标识低字节 frame[2] = 0x00; // 协议标识固定 0 frame[3] = 0x00; frame[4] = 0x00; // 长度高字节,后面还有 6 字节 frame[5] = 0x06; // 长度低字节 frame[6] = unitId; // 单元标识 // PDU frame[7] = 0x03; // 功能码:读保持寄存器 frame[8] = (byte)(startAddress >> 8); frame[9] = (byte)(startAddress & 0xFF); frame[10] = (byte)(registerCount >> 8); frame[11] = (byte)(registerCount & 0xFF); return frame; }

这段代码里最容易翻车的是地址转换。PLC 手册上写的 40001 对应 Modbus 协议里的地址 0,40002 对应 1,以此类推。如果你直接把 40001 塞进 startAddress,读出来的就是完全不相干的数据。源码里提供了一个 AddressConverter 类专门处理这种偏移,注释里列了三菱、西门子、汇川常见的地址映射规则。

2.3 用 Socket 还是用 TcpClient

源码用的是 TcpClient 加 NetworkStream,没有直接用裸 Socket。原因很简单:TcpClient 对连接状态的管理更省心,超时设置、异步读写都有现成方法。但要注意,Modbus TCP 是短连接还是长连接取决于 PLC 配置。有些 PLC 默认允许长连接,你保持 NetworkStream 打开就行;有些 PLC 空闲几分钟就断开,这时候你得在代码里加心跳或者重连逻辑。

源码里有一个 ConnectionManager 类,封装了断线重连。它的做法是每次发送请求前检查 TcpClient.Connected 属性,但这个属性其实不可靠——它只表示上次操作的状态。更稳妥的做法是发一个读请求,如果超时或者抛异常就重建连接。我在注释里看到作者写了一句“Connected 属性是玄学,别信”,看来也是踩过坑的。

3. 从零跑通第一个采集程序:环境、配置与调试步骤

3.1 开发环境准备与项目结构说明

这份源码是 Visual Studio 解决方案,目标框架 .NET Framework 4.7.2 或者 .NET 6/8 都能编译,取决于你拿到的版本。用 Visual Studio 2022 打开 .sln 文件,还原 NuGet 包,直接 F5 就能跑。项目结构大致是:Protocol 文件夹放报文构造和解析,Communication 文件夹放连接管理,Models 文件夹放数据模型,UI 是一个简单的 WinForms 界面,用来输入 IP、端口、寄存器地址和数量。

如果你用的是 .NET Core 或者 .NET 5+,需要把 WinForms 部分换成控制台或者 WPF,核心的 Protocol 和 Communication 类库不用动。源码里没有用任何第三方 Modbus 库,所以迁移成本很低。

3.2 配置 PLC 侧参数:以 FX5U 和 S7-1200 为例

在跑上位机之前,PLC 那边得先开 Modbus TCP 从站。三菱 FX5U 需要在 GX Works3 里配置以太网端口,勾选 Modbus TCP 从站功能,设置端口号 502,单元标识符一般默认 1。西门子 S7-1200 稍微麻烦一点,需要用 TIA Portal 添加 MB_SERVER 指令块,配置连接参数,然后把要暴露的寄存器映射到 DB 块里。

这里有个血泪经验:西门子 S7-1200 的 MB_SERVER 指令块,HoldStart 参数指向的 DB 块必须是优化访问关闭的,否则地址对不上。我见过有人读出来的全是 0,查了两天才发现 DB 块没关优化。源码的 README 里提了一句“西门子 PLC 请确保 DB 块非优化访问”,但很多人不看 README 直接跑,然后就卡住了。

3.3 上位机连接与读取的完整代码流程

下面这段代码是源码里主流程的简化版,展示了从连接、发送请求到解析响应的完整链路:

// 创建连接管理器,指定 PLC 的 IP 和端口 var connection = new ModbusTcpConnection("192.168.1.10", 502); // 打开连接,超时设 3000 毫秒 connection.Connect(3000); // 构造读请求:事务 ID 从 1 开始,单元标识 1 // 起始地址 0 对应 PLC 手册里的 40001 // 读 10 个寄存器 var request = ModbusFrameBuilder.BuildReadHoldingRegisters( transactionId: 1, unitId: 1, startAddress: 0, registerCount: 10); // 发送并接收响应,超时 2000 毫秒 byte[] response = connection.SendAndReceive(request, 2000); // 解析响应,跳过 MBAP 头和功能码,从第 9 字节开始是数据 // 每个寄存器 2 字节,高字节在前 var values = ModbusFrameParser.ParseRegisterValues(response, 10); // 输出结果 for (int i = 0; i < values.Length; i++) { Console.WriteLine($"寄存器 {40001 + i}: {values[i]}"); } // 用完关闭连接 connection.Close();

这段代码里 SendAndReceive 方法内部处理了粘包和半包问题。Modbus TCP 的响应长度不固定,你不能假设一次 Receive 就能拿到完整帧。源码的做法是先读 7 字节的 MBAP 头,从中解析出长度字段,再根据长度读剩余字节。这个逻辑在 NetworkStream 的 Read 方法里循环处理,注释写得很清楚。

参数方面,超时时间别设太短。车间网络环境复杂,交换机层级多的时候,500 毫秒可能不够。我一般设 2000 到 3000 毫秒。寄存器数量一次别超过 125 个,这是 Modbus 协议规定的上限,超了 PLC 会返回异常码 0x03。

3.4 用 Modbus Poll 交叉验证你的代码

写完代码别急着接真实产线,先用 Modbus Poll 或者 Modbus Slave 模拟器验证。Modbus Slave 可以模拟一个从站,你把自己的上位机连上去,看能不能读到预设的寄存器值。如果能读到,说明你的报文构造和解析没问题;如果读不到,用 Wireshark 抓包,对比你的请求帧和 Modbus Poll 发出的请求帧,逐字节看差异。

源码里附了一个简单的测试用例,用 NUnit 写的,模拟了请求帧的字节数组断言。你可以跑一下测试,确认环境没问题再连 PLC。

4. 避坑与排查:那些让上位机连不上 PLC 的常见问题

4.1 现象:连接被拒绝,提示“目标计算机积极拒绝”

原因通常是 PLC 侧没有开启 Modbus TCP 从站功能,或者端口号不是 502。有些 PLC 默认的 Modbus TCP 端口是 502,但如果你在 TIA Portal 里改了,上位机也得跟着改。另外,Windows 防火墙有时候会拦截出站连接,尤其是你换了网络环境之后。

解决:先用 telnet 命令测试端口通不通,telnet 192.168.1.10 502,如果连不上,问题在 PLC 侧或者网络侧,不在你的代码。检查 PLC 的 Modbus 配置是否启用,端口是否一致。

4.2 现象:能连接但读不到数据,返回异常码 0x02

异常码 0x02 表示非法数据地址。你请求的寄存器地址在 PLC 里不存在,或者超出了映射范围。比如你读 40001 到 40010,但 PLC 只映射了 40001 到 40005,那就会报这个错。

解决:对照 PLC 的 Modbus 地址映射表,确认你要读的地址确实被映射了。西门子 S7-1200 的 MB_SERVER 指令块里,HoldStart 和 HoldRegSize 决定了映射范围,别超。

4.3 现象:读上来的数据全是 0 或者明显不对

除了前面说的 DB 块优化访问问题,还有一种可能是字节序。Modbus 协议规定寄存器是高字节在前,但有些 PLC 或者设备会把两个寄存器拼成一个 32 位浮点数,这时候字节序和字序都可能要调。源码里提供了一个 ByteOrderHelper 类,支持 ABCD、CDAB、BADC、DCBA 四种排列,你对着设备手册试。

4.4 现象:程序跑一段时间后卡死或者丢数据

大概率是没处理粘包,或者 NetworkStream 的 Read 方法阻塞了 UI 线程。源码里把通讯逻辑放在后台线程,通过事件或者回调把数据推给 UI。如果你自己改代码的时候把 SendAndReceive 直接放在按钮点击事件里,界面就会假死。

解决:用 Task.Run 或者 BackgroundWorker 把通讯逻辑挪到后台线程,UI 线程只负责更新显示。另外,每次请求之间加个小延时,别把 PLC 的连接数占满。

4.5 现象:换了一台 PLC 就连不上,之前好好的

不同品牌的 PLC 对 Modbus TCP 的实现有差异。三菱 FX5U 的单元标识符通常填 1,西门子 S7-1200 填 1 或 255 都行,汇川 AM763 有些固件版本要求填 0。源码里把 unitId 做成了可配置参数,你换 PLC 的时候改一下就行,别硬编码。

5. 进阶:把采集数据接进你的业务系统

5.1 用定时器做周期采集与数据缓存

实际产线上你不会只读一次,而是每隔几百毫秒读一轮。源码里有一个 PollingService 类,用 System.Threading.Timer 做周期触发,每次采集完把数据放进一个 ConcurrentQueue 或者环形缓冲区。这样即使 UI 卡顿,数据也不会丢。

我一般会把采集周期设成 500 毫秒,寄存器数量控制在 50 个以内。如果点位多,就分批读,别一次读 125 个,响应时间会变长。

5.2 把寄存器值转成有意义的工程量

读上来的寄存器值是 ushort,你得根据设备手册转成温度、扭矩、速度。比如一个扭矩值占两个寄存器,拼成 32 位整数后除以 100 得到实际值。源码里有一个 ScalingHelper 类,支持线性变换和自定义公式。

// 把两个寄存器拼成 32 位整数,再按比例转成工程量 // high 是高字,low 是低字,scale 是缩放系数 public static double ToEngineeringValue(ushort high, ushort low, double scale) { // 高字在前,拼成 32 位无符号整数 uint raw = (uint)((high << 16) | low); // 乘以缩放系数得到实际值 return raw * scale; }

这段代码假设设备用的是高字在前。如果设备手册写的是低字在前,把 high 和 low 换个位置就行。缩放系数看手册,比如扭矩值范围 0 到 1000 对应寄存器 0 到 10000,那 scale 就是 0.1。

5.3 数据落库与报警判断

采集到的数据可以写进 SQLite 或者 SQL Server,源码里有一个 DataRepository 类,用 Dapper 做轻量级 ORM。报警判断就是拿当前值和阈值比较,超了就触发事件。这里注意别在采集线程里直接弹窗,用事件通知 UI 线程去弹。

5.4 一个验证技巧:用回环测试确认解析逻辑

如果你手头没有 PLC,可以用 Modbus Slave 模拟器建一个从站,然后在源码里把 IP 改成 127.0.0.1,端口 502。模拟器里预设几个寄存器值,跑你的上位机,看读出来的值对不对。这个回环测试能验证 90% 的解析逻辑问题。我每次改完报文解析代码,都会先跑一遍回环测试,确认没问题再连真实设备。从那以后,我再也没在产线上因为解析错误被叫去加班。希望帮到你。

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

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

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

立即咨询