☰
C#上位机与PLC通讯实战:基于Modbus TCP/RTU的通讯框架解析
2026/10/20 7:36:51 网站建设 项目流程

简介:工业自动化场景中,上位机与可编程逻辑控制器(PLC)的数据交换至关重要。这套以C#语言实现的通讯示例项目,面向自动化工程师和上位机开发入门者,演示了如何通过Modbus TCP等协议读取和设置PLC地址值,并配有Windows窗体界面便于交互操作。压缩包内共33个文件,以C#源码、可执行程序、配置文件为核心,辅以调试符号、资源映射与工程解决方案,整体仅331KB,结构精简,适合直接打开工程研读或运行验证。已有995人学习下载。项目完整覆盖从建立TCP/IP或串口连接、按协议封装读写指令,到接收响应、解析数据与异常处理的全过程;通过阅读源码可掌握TcpClient/SerialPort的调用方式、异步通信的阻塞规避,以及如何将通信模块与界面解耦,为后续开发生产级监控系统打下扎实基础。

1. 这个上位机项目到底解决了什么问题:从一次产线调试经历说起

有段时间我在现场陪产,接手一台设备的C#上位机程序,设备PLC是新换的型号,但上位机一直报“通讯超时”。查了半天发现不是IP不对,而是寄存器地址映射错了——旧程序把DB块地址当成保持寄存器直接读,新PLC却用的是标准Modbus地址。那台设备停了两小时产线,代价惨重。后来我把这套通讯层彻底重写,沉淀成了一个可复用的C#上位机与PLC通讯项目:它内置了Modbus TCP/RTU驱动,留了厂家私有协议的扩展口,UI层和通讯层完全分离,换PLC型号只改配置不碰代码。这篇文章就围绕这个项目的源码,从技术选型、目录结构、核心代码到运行和改造,讲清楚一套能落地的上位机通讯框架是怎么从零搭起来的,适合正在做设备上位机、MES接数或者工控软件二次开发的开发者。

2. 通讯方案与技术选型:Modbus TCP为主,串口和RTU协议并存

2.1 为什么把Modbus TCP作为核心通讯方案

Modbus是工控领域最普及的公开协议,只要是个正经PLC,基本都支持Modbus TCP或者Modbus RTU。选它作为默认驱动有几个现实原因:第一,报文结构公开透明,调试时抓包一眼能看懂;第二,跨品牌兼容性好,不用绑定某家PLC的私有协议;第三,C#里有成熟的库可以直接用,但项目里没有一上来就引库,而是先封装了自己的通讯接口——因为库可以换,接口稳定比什么都重要。

这个项目的通讯层同时支持Modbus TCP和Modbus RTU。TCP走以太网,适合机台联网;RTU走串口,适合老设备改造。对于那些不支持Modbus的PLC,比如某些日系和欧系品牌的私有协议,项目通过接口预留了扩展位,后续添加一个驱动类就能接入。在实际产线里,以太网和串口的混合场景很常见,所以这套双协议设计不是炫技,是被现场需求逼出来的。

2.2 项目目录结构:一个典型的C#解决方案长什么样

拿到源码后第一件事就是看目录结构。整个解决方案分成了四个核心工程,每一层的职责很清晰:

PlcCommunicator.sln ├─ src │ ├─ Plc.Core # 核心抽象层:接口、数据模型、转换器 │ ├─ Plc.Modbus # Modbus TCP/RTU 驱动实现 │ ├─ Plc.App # WPF 上位机界面 (MVVM) │ └─ Plc.Simulator # 调试辅助工具:虚拟PLC ├─ tests │ └─ Plc.Core.Tests # 单元测试,主要测数据转换和报文构造 ├─ docs │ └─ protocol.md # 通讯协议说明和寄存器地址规划表 └─ samples └─ DumpMonitoring # 一个配置好的完整应用示例

Plc.Core是整个解决方案的地基,里面定义了ICommunicator、IReadWrite这些接口,还有数据转换器。Plc.Modbus是Modbus协议的落地实现,不依赖任何第三方库,自己拼报文、算CRC。Plc.App是演示用的WPF界面,用来输入IP、端口、寄存器地址,然后读数据、写数据。Plc.Simulator很关键——调试的时候不一定有真PLC,这个模拟器可以充当PLC,方便在办公室开发。

提示:建议优先读Plc.Core和Plc.Modbus两个项目的源码,它们才是通讯的核心。WPF页面只是外壳,看着多,实际不复杂。

2.3 通讯层的接口设计:把PLC当成一个可替换的驱动

通讯层最忌讳把具体协议的报文拼写散落在UI代码里。这个项目定义了一个统一的接口,所有协议驱动都实现同一个契约:

public interface ICommunicator : IDisposable { Task ConnectAsync(CancellationToken ct = default); Task DisconnectAsync(); bool IsConnected { get; } Task<T> ReadAsync<T>(string address, ushort length, CancellationToken ct = default); Task WriteAsync<T>(string address, T value, CancellationToken ct = default); }

接口里只有连接、断开、读、写四组方法。address不是裸的数字,而是像4x0010这样的带前缀地址字符串,前缀代表寄存器类型:0x是线圈,1x是离散输入,3x是输入寄存器,4x是保持寄存器。这样设计的好处是,上层UI完全不用知道Modbus功能码长什么样。

Modbus TCP驱动对这个接口的实现,内部会把4x0010解析成功能码03、起始地址9、读取数量等参数,然后组报文发给PLC。如果未来要接一个不支持Modbus的设备,只需要写一个新的类实现ICommunicator,然后把依赖注入的驱动类型换掉。后面第六章我会详细说怎么改。

3. 核心模块拆解:从读Writable到写寄存器,一次请求的完整链路

3.1 报文构造:MBAP头和功能码的细节

Modbus TCP请求报文是一个固定的结构:事务标识符(2字节)+ 协议标识符(2字节)+ 长度(2字节)+ 单元标识(1字节)+ 功能码(1字节)+ 数据段。项目里ModbusTcpPacketBuilder负责构造这种报文。这里拿出读取保持寄存器(功能码0x03)的代码:

public static byte[] BuildReadHoldingRegisters( ushort transactionId, byte unitId, ushort startAddr, ushort quantity) { if (quantity < 1 || quantity > 125) throw new ArgumentOutOfRangeException(nameof(quantity), "单次读取最多125个寄存器"); var buffer = new byte[12]; buffer[0] = (byte)(transactionId >> 8); buffer[1] = (byte)transactionId; buffer[2] = 0x00; // 协议标识符高字节 buffer[3] = 0x00; // 协议标识符低字节,Modbus固定为0 buffer[4] = 0x00; // 后续字节长度占位 buffer[5] = 0x06; // 剩余长度 = 单元标识1 + 功能码1 + 起始地址2 + 数量2 buffer[6] = unitId; buffer[7] = 0x03; // 功能码:读保持寄存器 buffer[8] = (byte)(startAddr >> 8); buffer[9] = (byte)startAddr; buffer[10] = (byte)(quantity >> 8); buffer[11] = (byte)quantity; return buffer; }

事务标识符每次请求都要递增,用来匹配响应,否则数据错乱。协议标识符固定为0x0000,Modbus TCP不像RTU那样需要CRC,因为TCP/IP本身有校验机制。单元标识一般填0x01,但配合某些网关时需要改成实际设备号。quantity限制在125以内是Modbus协议定义的单帧最多读取寄存器数量,超过125就得拆分成多次请求。

3.2 异步读写与超时重试逻辑

TCP通讯最烦的就是无响应。如果上位机卡在Read上,界面整个冻结。这个项目所有读写方法都是async,并且使用CancellationTokenSource控制超时。下面是读保持寄存器的核心逻辑:

public async Task<byte[]> ReadHoldingRegistersAsync( ushort startAddr, ushort quantity, int timeoutMs = 1000, CancellationToken externalCt = default) { using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(externalCt); timeoutCts.CancelAfter(timeoutMs); var request = ModbusTcpPacketBuilder.BuildReadHoldingRegisters( TransactionId++, UnitId, startAddr, quantity); await _stream.WriteAsync(request, timeoutCts.Token); var response = await ReadExactAsync(9 + quantity * 2, timeoutCts.Token); // 功能码最高位为1表示异常响应 if ((response[7] & 0x80) != 0) throw new ModbusException($"PLC返回异常: {response[8]}"); var data = new byte[quantity * 2]; Array.Copy(response, 9, data, 0, data.Length); return data; }

这里用了链接TokenSource,外部的取消令牌和内部超时令牌合在一起,谁先触发谁先取消。ReadExactAsync很关键,因为TCP包是有边界的,一次ReadAsync可能读到半个报文,也可能一次读到两包数据。所以必须循环读,直到凑够9 + quantity * 2个字节为止。TransactionId++不是线程安全的,项目里用了Interlocked.Increment,避免多线程读写时事务ID错乱。

重试策略放在上层调用方:读取失败后先判断是不是超时异常,然后重试两次,每次间隔200ms;如果连续三次都失败,直接置为断线状态,避免无效重试把日志刷爆。

3.3 数据转换:从字节数组到int、float、bool的映射

PLC里一个寄存器是16位,上位机要读32位整数或者32位浮点数时,必须连续读两个寄存器,然后自己拼字节。这里有一个最常见的坑:字节顺序。项目里写了一个RegisterConverter,默认支持大小端切换:

public static class RegisterConverter { // 两个寄存器拼一个int,bigEndian=true时高字节在前 public static int ToInt32(byte[] data, int offset, bool bigEndian = true) { if (bigEndian) return (data[offset] << 24) | (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3]; else return (data[offset + 2] << 24) | (data[offset + 3] << 16) | (data[offset] << 8) | data[offset + 1]; } public static float ToFloat(byte[] data, int offset, bool bigEndian = true) { var intVal = ToInt32(data, offset, bigEndian); return BitConverter.Int32BitsToSingle(intVal); } public static bool ToBool(byte[] data, int bitIndex) { // 一个寄存器16位,bitIndex 0~15 int byteIndex = bitIndex / 8; int bit = bitIndex % 8; return (data[byteIndex] & (1 << bit)) != 0; } }

不同PLC厂家对“字序”的定义不一样,有的A寄存器高字节在前,有的是低字节在前。项目里把大小端作为可配置项写在了PLC连接参数里,用户换成不同PLC时,发现float值不对,可以一键切换,不用改代码。表格里记录了常用数据类型对应的寄存器长度:

数据类型寄存器数量说明
bool1(但常用1位)一般读整个寄存器再按位取
int161单寄存器
int322有大小端问题
float232位浮点,大小端+字序都影响
stringN按ASCII/UNICODE解析

4. 让它跑起来:环境配置、模拟器调试与真实PLC连接

4.1 需要的开发环境与依赖包

整个项目面向.NET 6/8,用Visual Studio 2022或者JetBrains Rider都能打开。不需要额外装特殊组件,因为通讯部分完全手工拼报文,没有引用第三方Modbus库,所以NuGet依赖很少:

Microsoft.Extensions.DependencyInjection.Abstractions 用于依赖注入 Microsoft.Extensions.Logging.Abstractions 用于日志输出 CommunityToolkit.Mvvm 用于WPF绑定的ObservableObject

如果你的电脑只装了.NET Framework 4.x,那需要把项目里的目标框架改成net462,但代码本身兼容。建议直接用.NET 8,字符串处理和异步支持都更省心。

4.2 用Modbus Slave模拟器验证通讯

没有实体PLC的时候,模拟器就是唯一的调试工具。项目自带的Plc.Simulator是一个控制台程序,启动后在本机监听502端口。它会模拟一个拥有100个保持寄存器、50个线圈的从站设备。运行方式:

cd src/Plc.Simulator dotnet run

看到“Listening on 0.0.0.0:502”就说明虚拟PLC已经就绪。接着启动Plc.App,界面里IP填127.0.0.1,端口填502,单元ID填1,点击“连接”,然后读取保持寄存器4x0000~4x0010。如果一切正常,你会看到界面上的数据表格每500ms刷新一次,数值不断变化。

这个模拟器有一个很有用的功能:可以手动注入异常。比如按下E键,它会模拟一次超时——停止响应5秒;按下X键,模拟非法寄存器地址,PLC会返回异常功能码。我在测上位机重试逻辑的时候,全靠这两个按键制造故障场景。

4.3 连接真实PLC的参数配置与注意事项

从模拟器切换到真实PLC,配置参数基本只有IP和端口不同,但有几个隐藏参数一旦错了,数据就读不上来。我整理了一份典型配置表:

参数项取值范围说明
PLC IP192.168.x.x必须和电脑在同一网段
端口502Modbus TCP固定端口,有的PLC可改
单元ID1~247直连PLC一般填1;经过网关的要对应
超时时间300~3000ms现场总线繁忙时调大
重试次数0~10建议不超过5
字节序BigEndian / LittleEndian按PLC手册里“字序”配置
轮询周期100ms以上低于100ms可能加重PLC负担

真实PLC连接一个容易忽略的点:部分PLC出厂默认不开Modbus TCP服务,需要先在手操器或者组态软件里把服务使能打开。另外,上位机电脑的防火墙要放行502端口,否则TCP连接会被Windows直接拦掉。我遇到过一台设备,网络抓包能看到请求发出去了,但PLC就是不回,最后查出是PLC侧的通讯服务被之前的维护人员关闭了。

5. 避坑指南:通讯不上、数据错乱、延迟高的常见排查

5.1 现象:换了PLC型号后连不上,但是模拟器正常

原因:模拟器默认的寄存器映射是定义好的,但不同PLC对Modbus地址的映射并不统一。例如某品牌PLC的保持寄存器从DB块开始,需要把“数据块号”映射到Modbus地址,而不是直接使用物理地址。

解决:查阅PLC的Modbus映射表,确认你要读的变量在4x区域的起始地址。如果PLC手册写明“DB1.DBD0对应Modbus地址40001”,那么上位机地址应该填4x0001而不是4x0000。项目里把地址偏移量做成了连接参数AddressOffset,遇到这种映射偏差时直接填一个偏移,不用改代码。

5.2 现象:读取float数据总是对不上,有时候是大数有时候是乱码

原因:Modbus寄存器是16位,float占两个寄存器。PLC在内部存储时,存在“字节序”和“字序”两个维度。有的PLC先传输高位字,有的先传输低位字,上位机如果按固定顺序拼装,自然错位。

解决:项目里的RegisterConverter提供了多种组合模式:大端字节序+大端字序、大端字节序+小端字序等。调试时可以写一个循环遍历四种组合,把解析结果都打出来,数值在合理范围内的那一种就是PLC的存储顺序。我一般会把这个值写进PLC地址配置,之后所有该类型数据都用同一个转换器。

5.3 现象:界面卡顿,点按钮后转圈几秒才反应

原因:很多人写上位机习惯直接在Button_Click里同步调ReadHoldingRegisters,这个方法内部阻塞线程,UI线程被占住,界面自然冻结。

解决:所有通讯调用必须走async,并且用await异步等待,不要在回调里直接操作界面控件。项目里的WPF页面全是MVVM绑定,数据到达后通过ObservableCollection通知刷新。如果你的老代码是拖控件开发的,最低限度是把通讯方法放进Task.Run,但要注意取消和异常处理。

5.4 现象:运行一段时间后偶发超时,重启后恢复

原因:Modbus TCP事务标识符如果重复使用,PLC响应就会和上一次混淆,TCP层收到的数据包被错误地当成当前请求的响应,导致解析失败。另外,频繁连接断开会把PLC的TCP连接资源耗尽。

解决:确认事务ID是每个请求都递增的,并且用Interlocked保证线程安全。连接保持长连接,不要每次读写都新建TcpClient。项目里增加了心跳机制,每5秒发送一次“读保持寄存器0x0000长度1”的请求,用来检测连接是否仍然活着;一旦心跳失败超过3次,自动重连。

6. 二次开发思路:把这套通讯框架接到你自己的设备上

6.1 修改点1:自定义协议驱动

如果目标PLC不支持Modbus,需要自己写驱动。做法是新建一个类,比如MyLtPlcCommunicator,实现ICommunicator接口。接口里有四个方法:连接、断开、读、写。读方法里需要根据设备手册构造自己的报文帧,调用底层Socket收发数据。写好实现后,修改项目里的依赖注入注册处,把IModbusCommunicator换成你的新类。UI层的请求地址4x0001按你的协议重新映射即可,比如改成字符串M100,然后由驱动解析。

6.2 修改点2:数据模型与标签系统

现实的设备变量成百上千,不可能一个一个定义属性。项目里提供了一个PlcTag模型:每个标签包含名称、地址、数据类型、缩放比例、读写权限。你只需要初始化一个标签列表,框架就会自动轮询这些地址,把数据存进统一的变量字典里。做MES系统对接时,框架暴露了一个事件,数据每刷新一次就触发,方便你在事件里把数据上传到数据库。我一般建议把标签表放到配置文件或者数据库里,这样设备员改变量地址时不用重编译。

6.3 验证方法:用模拟器构造异常场景

二次开发完成后,别急着上现场。先启动Plc.Simulator,把地址偏移、字节序配置成和目标PLC一致,然后跑一遍上位机的全流程。重点测试三条路径:正常读写、PLC无响应超时、PLC返回异常码。模拟器里可以按T键人为注入延迟,按F键返回非法功能码。我的习惯是,每次改完驱动代码后,都会强制走一遍“断电重连”“网络拔插”“数据变更”三连测,确认恢复逻辑不翻车。这套流程帮我在办公室里就挡掉了几十个现场才会暴露的问题,希望也能帮到你。

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

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

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

立即咨询