☰
C#通过MODBUS TCP读取台达触摸屏软元件实现上位机监控
2026/10/19 11:38:22 网站建设 项目流程

做了好几年设备数据采集,我发现一个很有意思的现象:很多现场的PLC通讯口早就被占满了,可墙上挂的那台触摸屏还空着一个网口没人用。于是经常有朋友问我,能不能把触摸屏当成数据中继站,上位机直接去读屏内部的数据?答案是能,而且某些场合比直接啃PLC协议还要顺滑。这里就记录一下我用C#写台达触摸屏上位机监控项目的完整过程,核心是基于MODBUS TCP协议去读取屏上的软元件数据(LW、LB这类),实现设备产量、状态、报警的PC端展示和记录。

这个项目特别适合两类人看:一类是现场设备已经定型、PLC通讯口没法动、但又急着加一套数据监控或者MES采集的家伙;另一类是刚接触MODBUS TCP,想知道怎么把工业触摸屏的软元件数据拿到手写代码里用的工程师。文章里我不会只给结论,会把为什么从屏取数、屏里软元件地址怎么映射、C#读写代码怎么落地、调试时踩过哪些坑都讲清楚。

1. 为什么放着PLC不采集,偏要从触摸屏下手

1.1 现场通讯口不够用是常态

绝大多数设备控制系统的通讯拓扑是:PLC作为主站,触摸屏作为人机界面挂在PLC的串口或者以太网上。PLC上面一般只有一个编程口、一个扩展通讯口,甚至只有一个422/485口。这个口被触摸屏占住之后,你再想挂一台PC上去做数据采集,就只能动PLC的通讯配置,改站号、加协议、换端口,运气好几分钟搞定,运气不好现场设备正在生产,根本不允许你随便改PLC参数。

更麻烦的是,有些PLC用的厂商私有协议本身就不对外开放,只给自家软件用。你想在外面通过第三方库读取其内部寄存器,要么绕很远的路,要么就得花不少钱买授权协议文档。这时候你就发现,触摸屏反而变成了现场“最好说话”的设备——它本身就是一个带有网络接口的嵌入式主机,很多型号天生就支持MODBUS TCP从站功能,你只要把它当成一个网络服务器,上位机以客户端身份去读它内部的数据就行。

另外还有一个很现实的问题:设备程序里的很多逻辑、报警文字、配方信息,本来就在触摸屏工程里。从屏里取数据,有时候比从PLC里取更能对应上操作界面上的显示逻辑,不容易出现“数值明明在屏上显示着,PLC里却找不到是哪一段地址”的尴尬。

1.2 触摸屏作为数据源的架构位置与坑点

从屏上取数据的架构本质上是一个“二次中转”:

PLC --(串口/以太网)--> 触摸屏 --(MODBUS TCP)--> C#上位机

触摸屏在中间扮演的角色是数据镜像节点。屏和PLC之间本来就有周期性的数据交换,触摸屏工程里用到的PLC变量会实时缓存在屏的内部寄存器中。上位机去读这些内部缓存,就相当于间接拿到了PLC的数据。

但这个方案也有自己的边界,清楚边界才不会翻车:

  • 触摸屏必须支持以太网口,并且固件支持MODBUS TCP从站服务。太老的型号或者某些不带网络口的阉割版装不了,买屏的时候要确认。
  • 屏上必须有一个正在运行的工程,而且工程里确实使用了对应的软元件(LW、LB之类)。如果画面元件直接绑定PLC地址,而你没有把数据搬运到屏内部软元件里,上位机是读不到东西的。
  • 数据实时性由“触摸屏刷新PLC数据的时间”和“上位机轮询时间”共同决定。做产量统计、设备状态监视、报警推送没问题,想做闭环运动控制就不现实了。

我在实际项目里最喜欢这个方案的一点是:它完全不动设备原有控制逻辑,不影响生产,风险可控。就算上位机程序写崩了,最多是读数据超时,设备照常运转,不会因为采集程序故障把产线搞停。

2. 台达屏的MODBUS TCP从站配置与软元件映射

2.1 DOPSoft里的服务开关与IP规划

台达触摸屏的组态软件是DOPSoft,工程下载到屏里之后,MODBUS TCP服务是否监听、端口是多少、从站地址怎么映射,都取决于工程里的设置。第一次做这个项目,我建议按下面的顺序把屏侧配置理一遍。

首先规划IP地址。假设触摸屏设定为192.168.1.10,子网掩码255.255.255.0,上位机PC设定为192.168.1.50。两者接在同一个交换机下,或者用网线直连。现在的网卡大多自适应交叉线,直连一般没问题,但为了省事我还是建议过交换机。

然后在DOPSoft的工程里打开HMI属性,找到以太网设置,把IP地址、子网掩码填进去。有的型号还需要设置默认网关,没有网关需求的设备可以直接留空或者填交换机管理地址。

接下来最关键的一步:找到组态软件里的通讯服务设置入口,启用MODBUS TCP Server功能,端口默认是502。有的固件版本还允许你自定义端口,如果是自定义端口,上位机连接时要同步改掉。不少人在这一步栽跟头,以为屏通电就能用,结果上位机连502端口一直超时——因为服务根本没启用,或者工程下载后没重启屏生效。

工程配置好以后,下载到触摸屏,触摸屏必须进入运行画面。下载完成后如果屏幕提示需要冷启动,就断电重启一次。实测中有些型号在工程下载后,网络服务要等屏进入运行模式才真正监听端口,这点很坑,排查时第一件事就是看屏有没有进运行画面。

2.2 LW、LB这些“软元件”到底对应MODBUS哪一块

台达触摸屏内部的变量沿用了PLC软元件的叫法。最常用的是两类:

  • LW:字软元件,16位一个字,用来存整数、状态字、BCD码之类的数据,类似PLC里的D寄存器或者保持寄存器。
  • LB:位软元件,一个位只有0和1,用来存开关量、报警标志位,类似PLC里的M继电器。

另有RW、RB这类扩展寄存器,数量更大,还有RW_A这种32位寄存器,实际项目中按需使用。对C#上位机来说,MODBUS TCP协议规定了两类核心存储区:

MODBUS功能码作用对应台达触摸屏软元件
03 (0x03)读保持寄存器LW、RW等16位字元件
06 (0x06)写单个寄存器LW、RW等
16 (0x10)写多个寄存器LW、RW等
01 (0x01)读线圈LB、RB等位元件
05 (0x05)写单个线圈LB、RB等
15 (0x0F)写多个线圈LB、RB等

也就是说,上位机读LW就是“读保持寄存器”,读LB就是“读线圈”,底层是同一套MODBUS标准动作,只是访问的区域不同。很多新手在这里卡住,是因为总把LW想成某种神秘协议格式,其实MODBUS TCP帧里根本不认识LW这种写法,它只认寄存器偏移地址。把LW翻译成偏移地址,就是你的程序要干的活。

2.3 偏移量陷阱与验证方法

接下来是台达屏最容易让人糊涂的地方:软元件编号和MODBUS偏移地址之间,到底是不是一一对应?

比如LW0,在台达很多固件里,上位机用偏移地址0就能读到。但也存在从站起始地址被配置成“第一个地址对应1”的情况,也就是说偏移0可能落在LW0前面一个空位上,偏移1才是LW0。不同型号、不同固件版本我也见过不一样的表现。联网一搜,论坛里吵来吵去的帖子一抓一大把,都是因为各家出厂默认不一致。

我不建议你靠记忆硬背映射表,我给一个“只需要验证一次”的土办法,可靠得多:

  1. 在屏工程里做一个变量LW0,用数值显示元件放到画面上。
  2. 工程下载后,在屏上手动把LW0改成900。
  3. 上位机分别读偏移地址0和偏移地址1,看哪个位置返回值是900。
  4. 记住这个校准结果,在代码里用一个常量来存“软元件0对应偏移地址几”。

实际项目中我一般把屏上某个空闲寄存器当成“探针寄存器”直接用,反正屏工程软元件数量多,浪费一两个无所谓。这个校准步骤只要做过一次,后续所有地址换算都围绕它展开,稳得很。

3. C#读写代码骨架:手写协议帧还是上封装库

3.1 为什么推荐先搞懂协议帧,再决定用不用库

写上位机有两种路线:一是拿起现成的MODBUS库直接调用,几分钟就能跑通;二是自己照着协议栈,手写一个最小可用的TCP客户端。我个人的建议是先手写一遍,哪怕写完就扔,也要亲手构造一帧数据、解析一帧响应,因为后面百分之八十的排查问题,都出在“帧格式不对”“字节序反了”“异常码没识别”这些细节上。

MODBUS TCP的报文头和串口MODBUS不一样,它不需要CRC校验,取而代之的是一个7字节的MBAP报文头:

  • 事务标识符:2字节,每一次请求递增,用于配对请求和响应。
  • 协议标识符:2字节,MODBUS固定为0x0000。
  • 长度:2字节,表示后面单元标识符+功能码+数据的字节数。
  • 单元标识符:1字节,TCP模式下一般用0xFF或者从站号。

一个读保持寄存器的请求帧长这样:

00 01 00 00 00 06 FF 03 00 00 00 0A

拆开看:事务标识00 01,协议标识00 00,长度00 06,单元标识FF,功能码03,起始地址00 00,寄存器数量00 0A(10个)。响应帧则是在头部之后多一个字节计数字段,再跟着寄存器值。

3.2 一个最小可用的MODBUS TCP客户端类

下面这段代码我经常拿来当项目底座的雏形,只实现最常用的三个功能:读保持寄存器、读线圈、写单个寄存器。其余功能码思路完全一样,按协议构造数据段即可。

public class ModbusTcpClient : IDisposable { private TcpClient _tcp; private readonly string _host; private readonly int _port; private readonly byte _unitId; private ushort _transactionId = 0; public ModbusTcpClient(string host, int port = 502, byte unitId = 0xFF) { _host = host; _port = port; _unitId = unitId; } public void Connect() { _tcp = new TcpClient(); _tcp.ReceiveTimeout = 2000; _tcp.SendTimeout = 2000; _tcp.Connect(_host, _port); } public ushort[] ReadHoldingRegisters(ushort startAddress, ushort count) { byte[] data = new byte[4]; data[0] = (byte)(startAddress >> 8); data[1] = (byte)startAddress; data[2] = (byte)(count >> 8); data[3] = (byte)count; byte[] payload = Transact(0x03, data); byte byteCount = payload[0]; ushort[] result = new ushort[byteCount / 2]; for (int i = 0; i < result.Length; i++) { // MODBUS寄存器是大端序:高字节在前 result[i] = (ushort)((payload[1 + i * 2] << 8) | payload[2 + i * 2]); } return result; } public bool[] ReadCoils(ushort startAddress, ushort count) { byte[] data = new byte[4]; data[0] = (byte)(startAddress >> 8); data[1] = (byte)startAddress; data[2] = (byte)(count >> 8); data[3] = (byte)count; byte[] payload = Transact(0x01, data); bool[] result = new bool[count]; for (int i = 0; i < count; i++) { result[i] = (payload[1 + i / 8] & (1 << (i % 8))) != 0; } return result; } public void WriteSingleRegister(ushort address, ushort value) { byte[] data = new byte[4]; data[0] = (byte)(address >> 8); data[1] = (byte)address; data[2] = (byte)(value >> 8); data[3] = (byte)value; Transact(0x06, data); } public void WriteSingleCoil(ushort address, bool value) { byte[] data = new byte[4]; data[0] = (byte)(address >> 8); data[1] = (byte)address; data[2] = value ? (byte)0xFF : (byte)0x00; data[3] = 0x00; Transact(0x05, data); } private byte[] Transact(byte function, byte[] data) { EnsureConnected(); byte[] request = BuildRequest(function, data); NetworkStream stream = _tcp.GetStream(); stream.Write(request, 0, request.Length); byte[] head = ReadExact(stream, 7); ushort length = (ushort)((head[4] << 8) | head[5]); // 长度字段包括单元标识符+功能码+数据,单元标识符已读,这里再读其余部分 byte[] rest = ReadExact(stream, length - 1); byte responseFunction = rest[0]; if ((responseFunction & 0x80) != 0) { throw new Exception($"MODBUS异常码: 0x{rest[1]:X2}"); } byte[] payload = new byte[rest.Length - 1]; Array.Copy(rest, 1, payload, 0, payload.Length); return payload; } private byte[] BuildRequest(byte function, byte[] data) { _transactionId++; byte[] frame = new byte[8 + data.Length]; frame[0] = (byte)(_transactionId >> 8); frame[1] = (byte)(_transactionId & 0xFF); frame[2] = 0x00; frame[3] = 0x00; frame[4] = 0x00; frame[5] = (byte)(2 + data.Length); // 单元标识符 + 功能码 + 数据 frame[6] = _unitId; frame[7] = function; Array.Copy(data, 0, frame, 8, data.Length); return frame; } private byte[] ReadExact(NetworkStream stream, int count) { byte[] buffer = new byte[count]; int offset = 0; while (offset < count) { int read = stream.Read(buffer, offset, count - offset); if (read == 0) { throw new IOException("连接被对端关闭"); } offset += read; } return buffer; } private void EnsureConnected() { if (_tcp == null || !_tcp.Connected) { Connect(); } } public void Dispose() { _tcp?.Close(); _tcp = null; } }

这段代码看起来简单,但该有的都齐了:大端序解析、异常码识别、粘包处理里最关键的长连接读满长度。因为MODBUS TCP是长连接请求应答模式,一次请求对应一次响应,所以不用担心多帧粘包,只要每次按“7字节头+长度字段”精确读取即可。

实际项目中完全可以继续用这套手写客户端,没必要非上重型框架。就算后来换成现成库,你自己手写过的这段协议逻辑,也足以让你在出问题时看懂库内部到底做了什么。

3.3 读取代码怎么用:一个具体例子

假设屏工程里约定这些软元件:

  • LW50:班产量
  • LW51:不良数
  • LW52:设备状态字
  • LB100:故障标志(1为故障)
  • LB101:运行标志

上位机定时读取时,分别调用读保持寄存器和读线圈:

ModbusTcpClient client = new ModbusTcpClient("192.168.1.10", 502, 0xFF); client.Connect(); // 一次读取LW50、LW51、LW52三个字 ushort[] words = client.ReadHoldingRegisters(50, 3); int outputCount = words[0]; int badCount = words[1]; int status = words[2]; // 一次读取LB100、LB101两个位 bool[] coils = client.ReadCoils(100, 2); bool hasFault = coils[0]; bool isRunning = coils[1];

这里有个细节:LW50作为起始地址传50,是基于前面“偏移0对应LW0”的校准结果。如果你验证出来偏移1才是LW0,那读LW50就要传起始地址51。偏移量的风险不在代码本身,而在映射关系,前面2.3节已经说过,不要省这一步验证。

十六进制、BCD码、浮点数拆字的问题也要关注。LW是16位字,屏上显示十进制数时,通常对应无符号或带符号整数;如果屏上显示的是32位浮点数,那必然是占两个连续的LW。读回以后不要直接拿低16位当结果,先到屏工程里看清楚这个软元件被定义成什么类型,再选择Int16、UInt16、Int32还是Single来组装。

4. 轮询节奏、界面刷新与断线重连的工程化细节

4.1 后台采集线程加UI事件,而不是在UI线程里硬读

很多刚开始写工业上位机的朋友习惯在窗口的Timer里直接同步调用ReadHoldingRegisters,界面一旦卡住就开始怀疑是不是网线松了。其实问题往往出在UI线程被阻塞——网络超时2秒,界面就卡死2秒,用户体验很差。

正确做法是把采集逻辑放到独立线程里,界面只负责显示。最简单的结构是:一个System.Windows.Forms.Timer作为心跳触发,Tick里丢一个任务到后台线程,读完之后用BeginInvoke把结果封送到UI线程更新控件。

或者更直接的方案:直接用定时器,但里面用异步方法,不阻塞UI。我用WinForms的时候习惯这样:

private async void Timer_Tick(object sender, EventArgs e) { _timer.Stop(); try { ushort[] words = await Task.Run(() => _client.ReadHoldingRegisters(50, 3)); BeginInvoke(new Action(() => { labelOutput.Text = words[0].ToString(); labelBad.Text = words[1].ToString(); })); } catch (Exception ex) { OnCommError(ex.Message); } finally { _timer.Start(); } }

用Task.Run把网络请求丢到线程池,读数据期间UI保持流畅,数据量不大时这种方式足够稳定。如果采集的点位非常多,比如上千个寄存器,那就改成独立线程的while循环,把数据放进一个ConcurrentQueue,界面单独用Timer消费队列,效率和实时性都能兼顾。

4.2 批量读取要一次拿完,控制轮询间隔

我见过不少人在一个界面上放了十几个读数标签,代码里挨个去读,每次读一个寄存器。这种写法不是不能跑,但效率极低。MODBUS TCP请求响应是有网络往返开销的,哪怕读一个寄存器和读十个寄存器,开销相差无几,为什么不一次性读完?

我的习惯是:把要读的字软元件按连续地址合并成组,一次请求最多不要超过120个寄存器。为什么是120?因为MODBUS标准里单次读保持寄存器的上限是125个,台达屏虽然不一定严格要求,但留点余量避免某些固件版本触发异常。位软元件同理,读LB100到LB110就是一次读11个位。

轮询间隔也有讲究。实测下来,100ms到500ms之间,对触摸屏本身的影响基本可以忽略。但如果你把间隔压到50ms以下,触摸屏可能会变慢,因为屏本身还要跟PLC通讯,CPU资源被MODBUS服务占用过多,画面的触摸响应就会迟钝。反过来,如果上位机只是为了每分钟记录一次产量,那间隔设5秒甚至10秒都行,给屏留足处理空间。

4.3 断线重连与数据连续性

工业现场的网络再稳定,也架不住有人踢网线、交换机断电、屏死机重启。上位机如果没有断线重连机制,半小时后设备自己恢复了,程序却还停留在报错状态,那就尴尬了。

我的做法是维护一个连接状态机:

  • 正常轮询时,如果某次请求超时或抛异常,不立刻狂重连,而是记录连续失败次数。
  • 连续失败3次后,关闭当前TcpClient,进入重连流程。
  • 重连采用指数退避:第一次等1秒,第二次2秒,第三次4秒,往后封顶30秒。
  • 重连成功后,先做一次全量读取,把所有显示数据刷新一遍再恢复定时轮询。

代码结构大致这样:

private int _failCount = 0; private void OnCommError(string message) { _failCount++; if (_failCount < 3) return; _timer.Stop(); _client.Dispose(); int delay = Math.Min(30, 1 << _failCount) * 1000; Task.Run(async () => { while (true) { try { _client.Connect(); _failCount = 0; _timer.Start(); break; } catch { await Task.Delay(delay); } } }); }

这里有个容易忽略的点:MODBUS TCP的请求应答协议本身没有心跳包概念,你的周期读取就是天然心跳。只要周期轮询还在跑,就说明链路是通的;一旦某次连续读取失败,就按重连策略处理。不需要另写什么心跳线程,白白增加复杂度。

5. 调试台达屏上位机时的几个高频坑

5.1 数据对不上:大小端、地址偏移和类型解释

第一个高频坑:读回来的数值和屏上显示的不一样。

明明屏上写着500,上位机读回来却是1280,或者两个看起来毫无关系的数字。这种问题十有八九出在两个地方:

一是字节序。MODBUS TCP传输寄存器值是大端序,也就是说一个寄存器0x1234在字节流里是0x12、0x34,而C#的BitConverter在Windows平台默认是小端序。如果你用了BitConverter.ToInt16,不调换字节顺序就会得到0x3412。手写协议的时候我已经在代码里手动拼接了大端序,没有踩这个坑,但换成现成库时就要确认库是否已经处理好字节序。

二是“软元件本身是什么数据类型”。屏工程里把一个LW定义成无符号16位整数,你上位机却按有符号16位解析,数值超过32767就变负数;屏定义成BCD码显示,你按二进制解析,数值自然面目全非。调试时先在屏工程里查元件属性,再选择对应解析方式,比盯着数据瞎猜快得多。

5.2 屏上明明有数据,上位机读回来全是0

这个坑更隐蔽。触摸屏画面上的“数值显示”元件经常直接绑定的是PLC寄存器地址,而不是屏内部的LW软元件。比如你画面上显示D100,那是屏从PLC里读过来直接显示的,数据根本没进入LW区,上位机读LW当然全是0。

解决思路是:在屏工程里把需要上位机监控的数据做一次“搬运”,用台达屏自带的宏指令或者资料传输功能,把PLC寄存器的值周期复制到LW软元件中。搬运完成以后,上位机读LW取到的才是真正有效的数据。

怎么判断是不是这个问题?在屏上手动改一下LW元件的值,如果上位机能读出来,但PLC地址改的时候上位机读不到,说明就是搬运环节缺失。这个小实验是排查“屏上有数、上位机没数”的最快手段。

5.3 写入软元件没反应:功能码、写保护与映射错位

项目里偶尔需要上位机写一些控制字、操作员编号到屏里。写入失败通常有几种情况,排查顺序很重要:

  • 功能码本身错了。写单个LW用06,写多个LW用16,写LB用05。你用03读到的区域,写的时候必须用写功能码,不能复用读功能码。
  • 屏工程开启了写保护或者访问等级。有些屏在工程选项里勾选了“运行时禁止写入”,或者设置了用户密码,未登录状态下外部写入会被静默拒绝。
  • 映射错位。写入偏移地址和读取校准结果不一致,你把数据写到了LW旁边的空寄存器里,屏画面自然没反应。

还有一个非常隐蔽的逻辑问题:上位机写LW成功,不代表PLC变量会跟着变。LW是屏的本地软元件,除非屏工程里有宏指令、按钮或资料传输把LW的内容回写到PLC地址,否则PLC里头的数据纹丝不动。很多人以为“写了屏就等于控制了设备”,这是大错特错。如果你确实需要上位机去控制设备,应该让屏工程把LW当成“上位机下发缓冲区”,再用屏的内部逻辑将LW内容搬运到PLC对应地址,并且要评估好安全风险,这类操作务必加权限和条件判断。

5.4 网络链路的小毛病:防火墙与端口确认

最后补充一个最基础但最容易忽略的:PC防火墙。第一次用Windows开发机器连触摸屏,经常出现上位机报超时,但ping IP却正常。十有八九是Windows防火墙拦了502端口。排查时可以在PC上用简单的TCP客户端工具(比如在线端口检测工具)先连一下触摸屏的502端口,能连上再排查代码。

另外,触摸屏的IP地址设置是在工程属性里完成的,下载工程要重新保存、编译、传输。有些型号在工程下载后需要断电重启才重新加载IP配置。如果你改过IP但新地址没生效,ping的永远还是旧IP,这种问题很难想到,但实际上很常见。

写在最后

这个项目做完以后,我最大的体会就是:采集方案的选择,很多时候不是技术含量决定的,而是现场条件决定的。触摸屏当数据源看起来是“绕路”,但它不碰PLC通讯配置、不干扰生产、不冒险,对产线来说是风险最低的一条路。只要屏的软元件映射校准明确,C#写一个MODBUS TCP客户端读数据并不复杂。

如果你正准备做类似的项目,我建议按这个顺序推进:先到屏工程里确认软元件定义和搬运逻辑,再启用MODBUS TCP服务并做一次偏移校准,接着用任意端口工具验证能读到数据,最后才轮到写C#代码。前面三步走稳,代码环节基本就是复制粘贴的事。别一上来就闷头写轮询和界面,先把“数据从哪来、屏上有没有、地址对不对”这三件事搞清楚,项目才能顺利落地。

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

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

立即咨询